CUST-WP-0074-T02: C-39/C-40/C-41 and task-block depends_on in C-20
consistency_check.py:
- C-20 now indexes each ```task block's depends_on (workplan ids and
task ids) as workplan_dependencies rows with from_task_id set,
description "<task> depends_on <target>". Targets may live in other
repos: resolved by file mapping first, ADR-007 derived uuid second;
unresolvable targets stay a non-fixable C-20 as before. Frontmatter
edges are unchanged (keyed with from_task_id None).
- C-39 task-wait-unqualified (WARN, not fixable): wait task with neither
task-block depends_on nor needs_human: true. Skipped in closed
workplans.
- C-40 task-wait-blocker-satisfied (WARN, not fixable): every depends_on
target terminal (tasks done/cancel, workplans finished/archived), or
decision_id resolved in the hub. A target or decision the hub cannot
resolve is skipped and withholds the warning; decision_id resolves via
a uuid, the repo's state_hub_decision_id writeback, or the derived id.
- C-41 task-wait-qualifier-drift (WARN, fixable): needs_human,
blocking_reason, decision_id file -> hub via PATCH /tasks/{id}, only
for blocks that carry at least one of the keys so hub-set flags on
silent blocks are left alone. The same fields ride along on the C-10
status PATCH for wait tasks and on the C-06/C-11 task creates. When
needs_human is set and the block has no intervention_note, the
blocking_reason stands in (TaskCreate/Update require the note).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
This commit is contained in:
parent
b9997d6fc7
commit
00c6e7a6ae
2 changed files with 580 additions and 6 deletions
|
|
@ -42,6 +42,10 @@ Checks:
|
|||
C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list
|
||||
C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref
|
||||
C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins)
|
||||
C-39 task-wait-unqualified WARN No wait task has neither task-block depends_on nor needs_human: true (CUST-WP-0074 rule 1)
|
||||
C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, or whose decision_id is resolved (CUST-WP-0074 rules 3/4)
|
||||
C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id differ between file and DB (file wins)
|
||||
(C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set)
|
||||
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
|
||||
|
||||
Usage:
|
||||
|
|
@ -549,6 +553,70 @@ def _frontmatter_depends_on_tasks(meta: dict) -> list[str]:
|
|||
return _dedupe_preserve(from_alias + from_depends_on)
|
||||
|
||||
|
||||
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"true", "yes", "1", "on"}
|
||||
|
||||
|
||||
def _task_wait_fields(task: dict) -> dict[str, Any]:
|
||||
"""File → hub fields that qualify a wait (CUST-WP-0074): needs_human,
|
||||
blocking_reason, decision_id. TaskCreate/TaskUpdate require an
|
||||
intervention_note whenever needs_human is set; the blocking reason is by
|
||||
rule 2 what the human must do, so it stands in when the block has none."""
|
||||
needs_human = _as_bool(task.get("needs_human"))
|
||||
blocking_reason = str(task.get("blocking_reason") or "").strip() or None
|
||||
decision_id = str(task.get("decision_id") or "").strip().strip('"') or None
|
||||
fields: dict[str, Any] = {
|
||||
"needs_human": needs_human,
|
||||
"blocking_reason": blocking_reason,
|
||||
"decision_id": decision_id,
|
||||
}
|
||||
if needs_human:
|
||||
fields["intervention_note"] = (
|
||||
str(task.get("intervention_note") or "").strip() or blocking_reason
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id")
|
||||
|
||||
|
||||
def _dependency_target_satisfied(record_id: str, status: str) -> bool:
|
||||
"""Rule 3: task targets are satisfied at done/cancel, workplan targets at
|
||||
finished/archived."""
|
||||
if _TASK_RECORD_ID_RE.search(record_id):
|
||||
return normalise_task_status(status, default="") in {"done", "cancel"}
|
||||
return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES
|
||||
|
||||
|
||||
def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]:
|
||||
"""Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote
|
||||
back into their YAML block."""
|
||||
out: dict[str, str] = {}
|
||||
for md in sorted(repo_dir.rglob("*.md")):
|
||||
if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts):
|
||||
continue
|
||||
try:
|
||||
text = md.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "state_hub_decision_id" not in text:
|
||||
continue
|
||||
for block in _YAML_ONLY_FENCE_RE.findall(text):
|
||||
meta = _parse_yaml_block(block.strip())
|
||||
if not isinstance(meta, dict) or meta.get("_parse_error"):
|
||||
continue
|
||||
rid = str(meta.get("id", "")).strip()
|
||||
hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"')
|
||||
if rid and hub_id and hub_id not in ("~", "null", "None", "none"):
|
||||
out[rid] = hub_id
|
||||
return out
|
||||
|
||||
|
||||
def _as_int_or_none(value: Any) -> int | None:
|
||||
if value in (None, "", "~", "null", "None", "none"):
|
||||
return None
|
||||
|
|
@ -1421,6 +1489,46 @@ def check_repo(
|
|||
snapshot_dependencies_by_workplan.setdefault(
|
||||
str(row["from_workplan_id"]), []
|
||||
).append(row)
|
||||
snapshot_tasks_by_id: dict[str, dict] = {
|
||||
str(row["id"]): row
|
||||
for rows in snapshot_tasks_by_workplan.values()
|
||||
for row in rows
|
||||
if row.get("id")
|
||||
}
|
||||
|
||||
# Task-block depends_on targets (CUST-WP-0074) may live in other repos, so
|
||||
# resolve them by the file mapping first and the ADR-007 derived uuid second.
|
||||
dependency_target_cache: dict[str, tuple[str | None, str | None]] = {}
|
||||
|
||||
def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]:
|
||||
"""Return (hub uuid, hub status) for a workplan/task record id, or (None, None)."""
|
||||
if record_id in dependency_target_cache:
|
||||
return dependency_target_cache[record_id]
|
||||
is_task = bool(_TASK_RECORD_ID_RE.search(record_id))
|
||||
mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id)
|
||||
hub_id = mapped or _derived_work_record_uuid(record_id)
|
||||
row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id)
|
||||
if row is None:
|
||||
row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}")
|
||||
result: tuple[str | None, str | None] = (
|
||||
(hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None)
|
||||
)
|
||||
dependency_target_cache[record_id] = result
|
||||
return result
|
||||
|
||||
decision_hub_ids: dict[str, str] | None = None
|
||||
|
||||
def _resolve_decision(decision_id: str) -> dict | None:
|
||||
nonlocal decision_hub_ids
|
||||
if decision_hub_ids is None:
|
||||
decision_hub_ids = _repo_decision_hub_ids(repo_dir)
|
||||
hub_id = (
|
||||
decision_id
|
||||
if _UUID_RE.match(decision_id)
|
||||
else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id)
|
||||
)
|
||||
row = _api_get(api_base, f"/decisions/{hub_id}")
|
||||
return row if isinstance(row, dict) else None
|
||||
|
||||
# Per-workplan checks
|
||||
for wp_file, meta, body in workplan_infos:
|
||||
|
|
@ -1802,11 +1910,13 @@ def check_repo(
|
|||
if from_id != ws_id:
|
||||
continue
|
||||
rel = dep.get("relationship_type") or "blocks"
|
||||
# Frontmatter edges carry no from_task_id; task-block edges do.
|
||||
from_task_id = dep.get("from_task_id") or None
|
||||
to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id")
|
||||
if to_workplan_id:
|
||||
existing_dep_keys.add(("workstream", to_workplan_id, rel))
|
||||
existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel))
|
||||
if dep.get("to_task_id"):
|
||||
existing_dep_keys.add(("task", dep["to_task_id"], rel))
|
||||
existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel))
|
||||
|
||||
for target_wp_id in _frontmatter_depends_on_workplans(meta):
|
||||
target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
|
||||
|
|
@ -1820,7 +1930,7 @@ def check_repo(
|
|||
fixable=False,
|
||||
)
|
||||
continue
|
||||
dep_key = ("workstream", target_ws_id, "blocks")
|
||||
dep_key = (None, "workstream", target_ws_id, "blocks")
|
||||
if dep_key not in existing_dep_keys:
|
||||
report.add(
|
||||
severity="WARN",
|
||||
|
|
@ -1849,7 +1959,7 @@ def check_repo(
|
|||
fixable=False,
|
||||
)
|
||||
continue
|
||||
dep_key = ("task", target_sh_id, "starts_after")
|
||||
dep_key = (None, "task", target_sh_id, "starts_after")
|
||||
if dep_key not in existing_dep_keys:
|
||||
report.add(
|
||||
severity="WARN",
|
||||
|
|
@ -1878,6 +1988,99 @@ def check_repo(
|
|||
t_sh_id = ""
|
||||
t_status = normalise_task_status(task.get("status", "todo"))
|
||||
|
||||
# CUST-WP-0074: task-block depends_on and wait qualifiers.
|
||||
task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task)
|
||||
wait_fields = _task_wait_fields(task)
|
||||
if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
if not task_dep_targets and not wait_fields["needs_human"]:
|
||||
report.add(
|
||||
severity="WARN", check_id="C-39",
|
||||
message=(
|
||||
f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has "
|
||||
f"neither depends_on nor needs_human: true — unqualified wait"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=t_status,
|
||||
fixable=False,
|
||||
)
|
||||
# C-40: rule 3 — every depends_on target terminal. A target the
|
||||
# hub cannot resolve is skipped, which also withholds the warning.
|
||||
resolved_targets = [
|
||||
(target, _resolve_dependency_target(target)) for target in task_dep_targets
|
||||
]
|
||||
if resolved_targets and all(
|
||||
hub_status is not None
|
||||
and _dependency_target_satisfied(target, hub_status)
|
||||
for target, (_hub_id, hub_status) in resolved_targets
|
||||
):
|
||||
report.add(
|
||||
severity="WARN", check_id="C-40",
|
||||
message=(
|
||||
f"blocker satisfied, task still wait: '{t_id}' in workplan "
|
||||
f"'{meta.get('id', fname)}' — every depends_on target is terminal "
|
||||
f"({', '.join(task_dep_targets)})"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=", ".join(task_dep_targets),
|
||||
fixable=False,
|
||||
)
|
||||
# C-40: rule 4 — the tracked decision has been resolved.
|
||||
if wait_fields["decision_id"]:
|
||||
decision = _resolve_decision(wait_fields["decision_id"])
|
||||
if decision is not None and str(decision.get("status") or "") == "resolved":
|
||||
report.add(
|
||||
severity="WARN", check_id="C-40",
|
||||
message=(
|
||||
f"blocker satisfied, task still wait: '{t_id}' in workplan "
|
||||
f"'{meta.get('id', fname)}' — decision "
|
||||
f"{wait_fields['decision_id']} is resolved"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=wait_fields["decision_id"],
|
||||
fixable=False,
|
||||
)
|
||||
|
||||
if t_sh_id and task_dep_targets:
|
||||
# C-20 for task blocks: the waiting task is the from side.
|
||||
for target in task_dep_targets:
|
||||
target_hub_id, _target_status = _resolve_dependency_target(target)
|
||||
is_task_target = bool(_TASK_RECORD_ID_RE.search(target))
|
||||
if not target_hub_id:
|
||||
report.add(
|
||||
severity="WARN",
|
||||
check_id="C-20",
|
||||
message=(
|
||||
f"{'Task' if is_task_target else 'Workplan'} dependency target "
|
||||
f"'{target}' of task '{t_id}' is not linked to State Hub"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=target,
|
||||
fixable=False,
|
||||
)
|
||||
continue
|
||||
rel = "starts_after" if is_task_target else "blocks"
|
||||
dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel)
|
||||
if dep_key not in existing_dep_keys:
|
||||
report.add(
|
||||
severity="WARN",
|
||||
check_id="C-20",
|
||||
message=(
|
||||
f"Missing DB dependency edge: task '{t_id}' "
|
||||
f"{'starts after task' if is_task_target else 'depends on workplan'} {target}"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
db_id=ws_id,
|
||||
file_value=target,
|
||||
fixable=True,
|
||||
_fix_context={
|
||||
"from_workstream_id": ws_id,
|
||||
"from_task_id": t_sh_id,
|
||||
"to_task_id" if is_task_target else "to_workstream_id": target_hub_id,
|
||||
"relationship_type": rel,
|
||||
"description": f"{t_id} depends_on {target}",
|
||||
},
|
||||
)
|
||||
|
||||
if t_sh_id:
|
||||
file_task_sh_ids.add(t_sh_id)
|
||||
db_task = db_tasks_by_id.get(t_sh_id)
|
||||
|
|
@ -1946,7 +2149,12 @@ def check_repo(
|
|||
file_value=t_status,
|
||||
db_value=db_t_status,
|
||||
fixable=True,
|
||||
_fix_context={"task_id": t_sh_id, "status": t_status},
|
||||
_fix_context={
|
||||
"task_id": t_sh_id,
|
||||
"status": t_status,
|
||||
# A wait needs its qualifiers in the same PATCH.
|
||||
"wait_fields": wait_fields if t_status == "wait" else {},
|
||||
},
|
||||
)
|
||||
file_description = task.get("description")
|
||||
if isinstance(file_description, str):
|
||||
|
|
@ -1999,6 +2207,29 @@ def check_repo(
|
|||
fixable=True,
|
||||
_fix_context={"task_id": t_sh_id, "flavor": file_task_flavor},
|
||||
)
|
||||
# C-41: wait qualifiers — only where the file speaks, so hub-set
|
||||
# flags on blocks that never carried the keys are left alone.
|
||||
if any(key in task for key in _TASK_WAIT_FIELD_KEYS):
|
||||
db_wait_fields = {
|
||||
"needs_human": bool(db_task.get("needs_human")),
|
||||
"blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None,
|
||||
"decision_id": str(db_task.get("decision_id") or "").strip() or None,
|
||||
}
|
||||
file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS}
|
||||
if file_wait_fields != db_wait_fields:
|
||||
report.add(
|
||||
severity="WARN", check_id="C-41",
|
||||
message=(
|
||||
f"Task wait-qualifier drift '{t_id}': "
|
||||
f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
db_id=t_sh_id,
|
||||
file_value=json.dumps(file_wait_fields, sort_keys=True),
|
||||
db_value=json.dumps(db_wait_fields, sort_keys=True),
|
||||
fixable=True,
|
||||
_fix_context={"task_id": t_sh_id, "wait_fields": wait_fields},
|
||||
)
|
||||
elif t_id:
|
||||
# C-11: task exists in file but not linked to DB
|
||||
ws_status = ws.get("status", "")
|
||||
|
|
@ -3290,6 +3521,7 @@ def fix_repo(
|
|||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
"flavor": task_flavor,
|
||||
**_task_wait_fields(task),
|
||||
})
|
||||
if t_data and "_error" not in t_data:
|
||||
t_db_id = t_data["id"]
|
||||
|
|
@ -3419,6 +3651,9 @@ def fix_repo(
|
|||
"to_task_id": ctx.get("to_task_id"),
|
||||
"relationship_type": ctx["relationship_type"],
|
||||
}
|
||||
if ctx.get("from_task_id"):
|
||||
body["from_task_id"] = ctx["from_task_id"]
|
||||
body["description"] = ctx.get("description")
|
||||
result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body)
|
||||
if result is not None and "_error" not in result:
|
||||
target = ctx.get("to_workstream_id") or ctx.get("to_task_id")
|
||||
|
|
@ -3435,7 +3670,8 @@ def fix_repo(
|
|||
task_id = ctx["task_id"]
|
||||
status = ctx["status"]
|
||||
result = _api_patch(api_base, f"/tasks/{task_id}",
|
||||
{"status": status, "suppress_token_event": True})
|
||||
{"status": status, "suppress_token_event": True,
|
||||
**ctx.get("wait_fields", {})})
|
||||
if result is not None and "_error" not in result:
|
||||
report.fixes_applied.append(
|
||||
f"C-10 fixed: task {task_id[:8]}… status → {status!r}"
|
||||
|
|
@ -3475,6 +3711,7 @@ def fix_repo(
|
|||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
**_task_wait_fields(task),
|
||||
})
|
||||
if t_data:
|
||||
t_db_id = t_data["id"]
|
||||
|
|
@ -3528,6 +3765,19 @@ def fix_repo(
|
|||
f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-41":
|
||||
task_id = ctx["task_id"]
|
||||
wait_fields = ctx["wait_fields"]
|
||||
result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields)
|
||||
if result is not None and "_error" not in result:
|
||||
report.fixes_applied.append(
|
||||
f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced"
|
||||
)
|
||||
elif result is not None:
|
||||
report.fixes_applied.append(
|
||||
f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-15":
|
||||
# T03 — writeback: DB is ahead of file — patch file to match DB.
|
||||
if no_writeback:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue