feat: DoX assessment recording and soft visibility (STATE-WP-0077)
Add quality_doc/dor/dod recording convention, quality-debt CLI, promote-intake and C-34 soft warnings, agent protocol notes, and DoD policy badge language. Mark STATE-WP-0077 finished.
This commit is contained in:
parent
d356fde41c
commit
fe4cfe22c9
13 changed files with 786 additions and 103 deletions
|
|
@ -36,6 +36,8 @@ Checks:
|
|||
C-31 work-record-unregistered WARN No YAML-block id matches no kind in the canon work-record type registry (sidetrack detector, CUST-WP-0060)
|
||||
C-32 work-record-not-indexed WARN Yes kind: intake/decision YAML block has no hub id — not indexed in DB (registration, CUST-WP-0061-T02)
|
||||
C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04)
|
||||
C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft)
|
||||
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
|
||||
|
||||
Usage:
|
||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
||||
|
|
@ -1371,6 +1373,25 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||
db_value="needs_review",
|
||||
fixable=False,
|
||||
)
|
||||
# C-34: soft DoR quality debt (STATE-WP-0077) — never blocks
|
||||
try:
|
||||
from quality_assessment import assessment_from_mapping, is_ok
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from quality_assessment import assessment_from_mapping, is_ok # type: ignore
|
||||
dor = assessment_from_mapping(meta, "DoR")
|
||||
if not is_ok(dor):
|
||||
report.add(
|
||||
severity="WARN",
|
||||
check_id="C-34",
|
||||
message=(
|
||||
"status=ready without quality_dor DoR-Ok — process claim "
|
||||
"without DoR assessment (soft; see docs/work-record-quality-gates.md)"
|
||||
),
|
||||
file_path=fname,
|
||||
file_value=str(dor or "unassessed"),
|
||||
fixable=False,
|
||||
)
|
||||
|
||||
# C-05: title drift
|
||||
db_title = ws.get("title", "")
|
||||
|
|
|
|||
|
|
@ -338,6 +338,23 @@ def _append_yaml_block(target_file: Path, fields: dict, *, origin_intake_id: str
|
|||
# entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _soft_warn_missing_doc(intake: dict) -> None:
|
||||
"""STATE-WP-0077: warn (do not block) if DoC-Ok is not recorded."""
|
||||
try:
|
||||
from quality_assessment import intake_has_doc_ok
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from quality_assessment import intake_has_doc_ok # type: ignore
|
||||
if intake_has_doc_ok(intake):
|
||||
return
|
||||
print(
|
||||
"WARNING: intake has no DoC-Ok recorded (quality_doc / notes). "
|
||||
"Promotion continues (soft only). Assess Definition of Comprehension "
|
||||
"before confident promote — docs/work-record-quality-gates.md",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def promote_intake(
|
||||
api_base: str,
|
||||
intake_id: str,
|
||||
|
|
@ -350,6 +367,7 @@ def promote_intake(
|
|||
workplan_file: str | None = None,
|
||||
) -> dict:
|
||||
intake = _fetch_intake(api_base, intake_id)
|
||||
_soft_warn_missing_doc(intake)
|
||||
|
||||
if to_kind == "workplan":
|
||||
canonical_id, new_file = _promote_to_workplan(api_base, repo_dir, repo_slug, domain, intake)
|
||||
|
|
|
|||
187
scripts/quality_assessment.py
Normal file
187
scripts/quality_assessment.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env python3
|
||||
"""DoX quality assessment helpers (STATE-WP-0077).
|
||||
|
||||
Canonical recording fields and parsing for Definition assessments.
|
||||
See docs/work-record-quality-gates.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
POLICIES = ("DoC", "DoR", "DoD")
|
||||
|
||||
FIELD_FOR_POLICY = {
|
||||
"DoC": "quality_doc",
|
||||
"DoR": "quality_dor",
|
||||
"DoD": "quality_dod",
|
||||
}
|
||||
|
||||
|
||||
def _norm_policy(policy: str) -> str:
|
||||
p = policy.strip()
|
||||
upper = p.upper().replace(" ", "").replace("_", "")
|
||||
if upper in ("DOC", "COMPREHENSION", "DEFINITIONOFCOMPREHENSION"):
|
||||
return "DoC"
|
||||
if upper in ("DOR", "READY", "DEFINITIONOFREADY"):
|
||||
return "DoR"
|
||||
if upper in ("DOD", "DONE", "DEFINITIONOFDONE"):
|
||||
return "DoD"
|
||||
if p in POLICIES:
|
||||
return p
|
||||
raise ValueError(f"unknown policy {policy!r}; use DoC, DoR, or DoD")
|
||||
|
||||
|
||||
def badge(policy: str, outcome: str) -> str:
|
||||
"""Map policy + Ok|Failed (or full badge) to DoX-Ok / DoX-Failed."""
|
||||
pol = _norm_policy(policy)
|
||||
out = outcome.strip().strip('"')
|
||||
if out in (f"{pol}-Ok", f"{pol}-Failed"):
|
||||
return out
|
||||
if out.lower() in ("ok", "pass", "passed"):
|
||||
return f"{pol}-Ok"
|
||||
if out.lower() in ("failed", "fail", "no"):
|
||||
return f"{pol}-Failed"
|
||||
raise ValueError(f"unknown outcome {outcome!r} for {pol}")
|
||||
|
||||
|
||||
def assessment_from_mapping(meta: dict, policy: str) -> str | None:
|
||||
"""Read assessment badge for policy from frontmatter / YAML / task fields."""
|
||||
pol = _norm_policy(policy)
|
||||
key = FIELD_FOR_POLICY[pol]
|
||||
raw = meta.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip().strip('"').strip("'")
|
||||
if not s or s.lower() in ("unassessed", "none", "null", "~", "-"):
|
||||
return None
|
||||
try:
|
||||
return badge(pol, s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_ok(badge_value: str | None) -> bool:
|
||||
return bool(badge_value) and str(badge_value).endswith("-Ok")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityDebtItem:
|
||||
kind: str
|
||||
record_id: str
|
||||
lifecycle_status: str
|
||||
missing: str
|
||||
path: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def debt_for_workplan_meta(meta: dict, *, path: str) -> list[QualityDebtItem]:
|
||||
"""Quality debt for a workplan frontmatter dict."""
|
||||
items: list[QualityDebtItem] = []
|
||||
wp_id = str(meta.get("id", "")).strip() or path
|
||||
status = str(meta.get("status", "")).strip().lower()
|
||||
dor = assessment_from_mapping(meta, "DoR")
|
||||
dod = assessment_from_mapping(meta, "DoD")
|
||||
if status == "ready" and not is_ok(dor):
|
||||
items.append(
|
||||
QualityDebtItem(
|
||||
kind="workplan",
|
||||
record_id=wp_id,
|
||||
lifecycle_status=status,
|
||||
missing="DoR-Ok",
|
||||
path=path,
|
||||
detail=f"quality_dor={dor or 'unassessed'}",
|
||||
)
|
||||
)
|
||||
if status in ("finished", "completed") and not is_ok(dod):
|
||||
items.append(
|
||||
QualityDebtItem(
|
||||
kind="workplan",
|
||||
record_id=wp_id,
|
||||
lifecycle_status=status,
|
||||
missing="DoD-Ok",
|
||||
path=path,
|
||||
detail=f"quality_dod={dod or 'unassessed'}",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def debt_for_intake_meta(meta: dict, *, path: str) -> list[QualityDebtItem]:
|
||||
"""Quality debt for intake blocks in vetted/routed without DoC-Ok."""
|
||||
items: list[QualityDebtItem] = []
|
||||
iid = str(meta.get("id", "")).strip() or path
|
||||
status = str(meta.get("status", "")).strip().lower()
|
||||
doc = assessment_from_mapping(meta, "DoC")
|
||||
if status in ("vetted", "routed") and not is_ok(doc):
|
||||
items.append(
|
||||
QualityDebtItem(
|
||||
kind="intake",
|
||||
record_id=iid,
|
||||
lifecycle_status=status,
|
||||
missing="DoC-Ok",
|
||||
path=path,
|
||||
detail=f"quality_doc={doc or 'unassessed'}",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def intake_has_doc_ok(intake: dict) -> bool:
|
||||
"""Soft check: hub intake notes/description record DoC-Ok."""
|
||||
chunks: list[str] = []
|
||||
for key in ("description", "routed_note", "title"):
|
||||
v = intake.get(key)
|
||||
if v:
|
||||
chunks.append(str(v))
|
||||
for note in intake.get("notes") or []:
|
||||
if isinstance(note, dict):
|
||||
chunks.append(str(note.get("content") or ""))
|
||||
else:
|
||||
chunks.append(str(note))
|
||||
# structured future field
|
||||
if assessment_from_mapping(intake, "DoC") and is_ok(assessment_from_mapping(intake, "DoC")):
|
||||
return True
|
||||
text = "\n".join(chunks)
|
||||
if re.search(r"\bDoC-Ok\b", text):
|
||||
return True
|
||||
if re.search(r"quality_doc\s*[:=]\s*[\"']?(DoC-Ok|Ok)\b", text, re.I):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def progress_event_body(
|
||||
*,
|
||||
policy: str,
|
||||
outcome: str,
|
||||
record_kind: str,
|
||||
record_id: str,
|
||||
assessed_by: str | None = None,
|
||||
note: str | None = None,
|
||||
topic_id: str | None = None,
|
||||
workplan_id: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""JSON body for POST /progress/ recording a quality assessment."""
|
||||
b = badge(policy, outcome)
|
||||
pol = b.rsplit("-", 1)[0]
|
||||
body: dict[str, Any] = {
|
||||
"event_type": "quality_assessment",
|
||||
"summary": f"{b} for {record_kind} {record_id}",
|
||||
"author": author or assessed_by or "agent",
|
||||
"detail": {
|
||||
"policy": pol,
|
||||
"outcome": "Ok" if b.endswith("-Ok") else "Failed",
|
||||
"badge": b,
|
||||
"record_kind": record_kind,
|
||||
"record_id": record_id,
|
||||
"assessed_by": assessed_by,
|
||||
"note": note,
|
||||
},
|
||||
}
|
||||
if topic_id:
|
||||
body["topic_id"] = topic_id
|
||||
if workplan_id:
|
||||
body["workplan_id"] = workplan_id
|
||||
return body
|
||||
226
scripts/quality_debt.py
Normal file
226
scripts/quality_debt.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
"""List DoX quality debt for a repo (STATE-WP-0077-T02).
|
||||
|
||||
Scans local workplan frontmatter and intake YAML blocks; optionally lists
|
||||
hub intakes (vetted/routed) without DoC-Ok in notes.
|
||||
|
||||
Usage:
|
||||
python scripts/quality_debt.py --here
|
||||
python scripts/quality_debt.py --repo-path /path/to/repo
|
||||
statehub quality-debt [--repo-path PATH] [--json] [--api-base URL]
|
||||
|
||||
Exit 0 always (report only); use --strict for exit 1 when debt found.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from quality_assessment import ( # noqa: E402
|
||||
QualityDebtItem,
|
||||
debt_for_intake_meta,
|
||||
debt_for_workplan_meta,
|
||||
intake_has_doc_ok,
|
||||
)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
yaml = None # type: ignore
|
||||
|
||||
_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history", "agents_backup", "dist"})
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return {}
|
||||
raw = parts[1]
|
||||
if yaml is not None:
|
||||
try:
|
||||
data = yaml.safe_load(raw) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
# minimal fallback: key: value lines
|
||||
meta: dict = {}
|
||||
for line in raw.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
k, _, v = line.partition(":")
|
||||
meta[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return meta
|
||||
|
||||
|
||||
def _scan_workplans(repo_dir: Path) -> list[QualityDebtItem]:
|
||||
items: list[QualityDebtItem] = []
|
||||
wp_dir = repo_dir / "workplans"
|
||||
if not wp_dir.is_dir():
|
||||
return items
|
||||
for path in sorted(wp_dir.rglob("*.md")):
|
||||
if path.name == "README.md":
|
||||
continue
|
||||
if "archived" in path.parts:
|
||||
# still scan finished debt in archived? skip archived for noise
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
meta = _parse_frontmatter(text)
|
||||
if not meta:
|
||||
continue
|
||||
rel = str(path.relative_to(repo_dir))
|
||||
items.extend(debt_for_workplan_meta(meta, path=rel))
|
||||
return items
|
||||
|
||||
|
||||
def _scan_intake_blocks(repo_dir: Path) -> list[QualityDebtItem]:
|
||||
items: list[QualityDebtItem] = []
|
||||
if yaml is None:
|
||||
return items
|
||||
fence = re.compile(r"```ya?ml\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
||||
for path in sorted(repo_dir.rglob("*.md")):
|
||||
if any(p in _SKIP_DIRS for p in path.parts):
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = str(path.relative_to(repo_dir))
|
||||
for m in fence.finditer(text):
|
||||
block = m.group(1)
|
||||
try:
|
||||
data = yaml.safe_load(block)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
rid = str(data.get("id", ""))
|
||||
# intake ids: PREFIX-IN-NNNN or grandfathered AWQ-
|
||||
if not re.match(r"^[A-Z]+-IN-\d+", rid) and not re.match(r"^AWQ-\d+", rid):
|
||||
if data.get("kind") != "intake":
|
||||
continue
|
||||
items.extend(debt_for_intake_meta(data, path=rel))
|
||||
return items
|
||||
|
||||
|
||||
def _hub_intake_debt(api_base: str) -> list[QualityDebtItem]:
|
||||
items: list[QualityDebtItem] = []
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{api_base.rstrip('/')}/intakes/",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
rows = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
items.append(
|
||||
QualityDebtItem(
|
||||
kind="intake",
|
||||
record_id="(hub)",
|
||||
lifecycle_status="?",
|
||||
missing="DoC-Ok",
|
||||
path="api:/intakes/",
|
||||
detail=f"could not list intakes: {e}",
|
||||
)
|
||||
)
|
||||
return items
|
||||
if not isinstance(rows, list):
|
||||
return items
|
||||
for row in rows:
|
||||
if row.get("status") not in ("vetted", "routed"):
|
||||
continue
|
||||
if intake_has_doc_ok(row):
|
||||
continue
|
||||
items.append(
|
||||
QualityDebtItem(
|
||||
kind="intake",
|
||||
record_id=str(row.get("id", "")),
|
||||
lifecycle_status=str(row.get("status", "")),
|
||||
missing="DoC-Ok",
|
||||
path="api:/intakes/",
|
||||
detail=(row.get("title") or "")[:80],
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def collect(
|
||||
repo_dir: Path,
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
include_hub_intakes: bool = True,
|
||||
) -> list[QualityDebtItem]:
|
||||
items = _scan_workplans(repo_dir) + _scan_intake_blocks(repo_dir)
|
||||
if include_hub_intakes and api_base:
|
||||
items.extend(_hub_intake_debt(api_base))
|
||||
return items
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--here", action="store_true", help="Use cwd as repo")
|
||||
ap.add_argument("--repo-path", default=None, help="Repo root (default: cwd)")
|
||||
ap.add_argument("--api-base", default=os.environ.get("API_BASE", "http://127.0.0.1:8000"))
|
||||
ap.add_argument("--no-hub", action="store_true", help="Skip hub intake list")
|
||||
ap.add_argument("--json", action="store_true", dest="as_json")
|
||||
ap.add_argument("--strict", action="store_true", help="Exit 1 if any debt")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo_dir = Path(args.repo_path or os.getcwd()).expanduser().resolve()
|
||||
items = collect(
|
||||
repo_dir,
|
||||
api_base=None if args.no_hub else args.api_base,
|
||||
include_hub_intakes=not args.no_hub,
|
||||
)
|
||||
|
||||
if args.as_json:
|
||||
print(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"kind": i.kind,
|
||||
"record_id": i.record_id,
|
||||
"lifecycle_status": i.lifecycle_status,
|
||||
"missing": i.missing,
|
||||
"path": i.path,
|
||||
"detail": i.detail,
|
||||
}
|
||||
for i in items
|
||||
],
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"Quality debt — {repo_dir}")
|
||||
print(f" ({len(items)} item(s); lifecycle may advance without DoX-Ok)\n")
|
||||
if not items:
|
||||
print(" (none)")
|
||||
for i in items:
|
||||
print(
|
||||
f" [{i.kind}] {i.record_id} status={i.lifecycle_status} "
|
||||
f"missing {i.missing} ({i.path}) {i.detail}"
|
||||
)
|
||||
print(
|
||||
"\nRecord assessments with quality_dor / quality_dod / quality_doc "
|
||||
"fields — see docs/work-record-quality-gates.md"
|
||||
)
|
||||
|
||||
if args.strict and items:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue