okstra 0.158.1 → 0.160.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +1 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/report-writer-worker.md +1 -1
  14. package/runtime/bin/okstra-claude-exec.sh +4 -1
  15. package/runtime/prompts/host-orchestration/README.md +18 -0
  16. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  17. package/runtime/prompts/launch.template.md +10 -1
  18. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/report-writer.md +1 -1
  23. package/runtime/prompts/lead/team-contract.md +2 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  25. package/runtime/prompts/profiles/_common-contract.md +3 -1
  26. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  27. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  28. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  29. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
  33. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  34. package/runtime/python/okstra_ctl/error_report.py +56 -0
  35. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  36. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  37. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  38. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  39. package/runtime/python/okstra_ctl/paths.py +38 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  41. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  42. package/runtime/python/okstra_ctl/recap.py +63 -0
  43. package/runtime/python/okstra_ctl/render_final_report.py +11 -62
  44. package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
  45. package/runtime/python/okstra_ctl/report_html/render.py +9 -8
  46. package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
  47. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
  48. package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
  49. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  50. package/runtime/python/okstra_ctl/report_views.py +7 -3
  51. package/runtime/python/okstra_ctl/run.py +41 -2
  52. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  53. package/runtime/python/okstra_ctl/usage_cells.py +47 -0
  54. package/runtime/python/okstra_ctl/user_response.py +25 -10
  55. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  56. package/runtime/python/okstra_ctl/wizard.py +64 -10
  57. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  58. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  59. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  60. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  61. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +56 -2
  63. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  64. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  66. package/runtime/skills/okstra-run/SKILL.md +28 -10
  67. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  68. package/runtime/templates/reports/final-report.template.md +4 -0
  69. package/runtime/templates/reports/html/assets/base.css +14 -1
  70. package/runtime/templates/reports/html/base.template.html +42 -0
  71. package/runtime/templates/reports/html/i18n/en.json +30 -1
  72. package/runtime/templates/reports/html/i18n/ko.json +30 -1
  73. package/runtime/templates/reports/html/macros/forms.html +15 -0
  74. package/runtime/templates/reports/html/macros/visualizations.html +3 -2
  75. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  76. package/runtime/templates/reports/i18n/en.json +2 -0
  77. package/runtime/validators/validate-run.py +331 -208
  78. package/runtime/validators/validate_session_conformance.py +102 -32
  79. package/src/cli-registry.mjs +34 -0
  80. package/src/commands/execute/incremental-scope.mjs +10 -0
  81. package/src/commands/execute/worker-audit-check.mjs +35 -0
  82. package/src/commands/inspect/error-issue.mjs +27 -0
  83. package/src/commands/inspect/profile-show.mjs +29 -0
  84. package/src/commands/inspect/run-audit.mjs +26 -0
@@ -0,0 +1,183 @@
1
+ """Parser for the worker verdict block that plan-body and convergence share.
2
+
3
+ The response shape is fixed by contract (`prompts/lead/plan-body-verification.md`
4
+ §"Response format"), so the parser belongs here rather than in each lead. A
5
+ per-round ad-hoc regex makes the round's fidelity depend on whoever wrote it,
6
+ and its failure mode is silence: dev-10400 lost 19 of 37 assigned items to a
7
+ no-match that nothing reported. Every shape this module cannot read is an error.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ VERDICT_TOKENS = frozenset({
15
+ "AGREE", "DISAGREE", "SUPPLEMENT", "UNVERIFIABLE", "VERIFICATION-ERROR",
16
+ })
17
+ FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
18
+
19
+ # The convergence reverify prompts use their own vocabularies. Collaborative and
20
+ # full-reanalysis rounds speak the schema's own words; the adversarial round asks
21
+ # the verifier to break the finding, so it answers in break/survive terms that
22
+ # have to be translated back (`prompts/lead/convergence.md`
23
+ # §"Adversarial Re-verification Prompt").
24
+ COLLABORATIVE_VERDICTS = {
25
+ "AGREE": "agree",
26
+ "DISAGREE": "disagree",
27
+ "SUPPLEMENT": "supplement",
28
+ "UNVERIFIABLE": "unverifiable",
29
+ "VERIFICATION-ERROR": "verification-error",
30
+ }
31
+ ADVERSARIAL_VERDICTS = {
32
+ "SURVIVES": "agree",
33
+ "SURVIVES-WITH-CAVEAT": "supplement",
34
+ "REFUTED": "disagree",
35
+ # A verifier that looked and could not check is not a verifier that failed.
36
+ # `verification-error` drops the vote from the participating count, which
37
+ # shrinks the roster without saying so.
38
+ "UNVERIFIABLE": "unverifiable",
39
+ "VERIFICATION-ERROR": "verification-error",
40
+ }
41
+ DISAGREE_BASES = frozenset({"counter-evidence", "burden-not-met"})
42
+
43
+ _ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
44
+ # The contract writes some labels with a parenthetical qualifier —
45
+ # `**Fixability** (only when DISAGREE):` — so the colon may trail a `(...)`.
46
+ _FIELD_RE = re.compile(
47
+ r"^\*\*(?P<key>Verdict|Fixability|Note|Explanation|Prior dissent|Basis"
48
+ r"|Your evidence)\*\*"
49
+ r"(?:[ \t]*\([^)]*\))?[ \t]*:[ \t]*(?P<value>.*)$",
50
+ re.MULTILINE,
51
+ )
52
+ _VERDICT_RE = re.compile(r"^(?P<token>[A-Z-]+)(?:\((?P<kind>[a-f])\))?$")
53
+
54
+
55
+ class VerdictBlockError(ValueError):
56
+ """Raised when a worker response does not match the contract shape."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class FindingVote:
61
+ """One worker's vote on one convergence finding, in schema vocabulary."""
62
+
63
+ finding_id: str
64
+ verdict: str
65
+ disagree_basis: str | None
66
+ explanation: str
67
+
68
+
69
+ def _scan_blocks(text: str) -> dict[str, dict[str, str]]:
70
+ """Every `### <id>` block in *text*, as id → field map, in document order."""
71
+ matches = list(_ITEM_RE.finditer(text))
72
+ blocks: dict[str, dict[str, str]] = {}
73
+ for index, match in enumerate(matches):
74
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
75
+ item_id = match.group("id")
76
+ if item_id in blocks:
77
+ raise VerdictBlockError(f"item `{item_id}` appears twice in one response")
78
+ body = text[match.end():end]
79
+ blocks[item_id] = {
80
+ m.group("key"): m.group("value").strip() for m in _FIELD_RE.finditer(body)
81
+ }
82
+ return blocks
83
+
84
+
85
+ def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVote]:
86
+ """Convergence reverify votes in *text*, keyed by finding id.
87
+
88
+ *adversarial* selects the vocabulary; guessing it from the content would make
89
+ an unfamiliar token silently read as a different verdict.
90
+ """
91
+ vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
92
+ return {
93
+ finding_id: _finding_vote(finding_id, fields, vocabulary)
94
+ for finding_id, fields in _scan_blocks(text).items()
95
+ }
96
+
97
+
98
+ def _finding_vote(
99
+ finding_id: str, fields: dict[str, str], vocabulary: dict[str, str]
100
+ ) -> FindingVote:
101
+ raw = fields.get("Verdict", "")
102
+ token = raw.strip().strip("`").strip().upper()
103
+ if token not in vocabulary:
104
+ raise VerdictBlockError(
105
+ f"finding `{finding_id}` has an unknown verdict: {raw or '(missing)'} "
106
+ f"— expected one of {sorted(vocabulary)}"
107
+ )
108
+ verdict = vocabulary[token]
109
+ explanation = fields.get("Explanation", "")
110
+ if not explanation:
111
+ raise VerdictBlockError(
112
+ f"finding `{finding_id}` has no `**Explanation**:` line — every vote "
113
+ f"the classifier reads carries one"
114
+ )
115
+ basis = fields.get("Basis", "").strip() or None
116
+ if verdict != "disagree":
117
+ basis = None
118
+ elif basis is not None and basis not in DISAGREE_BASES:
119
+ raise VerdictBlockError(
120
+ f"finding `{finding_id}` has basis `{basis}` — expected one of "
121
+ f"{sorted(DISAGREE_BASES)}"
122
+ )
123
+ return FindingVote(finding_id, verdict, basis, explanation)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class VerdictBlock:
128
+ """One worker's verdict on one item."""
129
+
130
+ item_id: str
131
+ verdict: str
132
+ breakage_kind: str
133
+ fixability: str
134
+ note: str
135
+ explanation: str
136
+ prior_dissent: str
137
+
138
+
139
+ def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
140
+ """Every `### <item-id>` plan-body verdict block in *text*, keyed by item id."""
141
+ return {
142
+ item_id: _block(item_id, fields)
143
+ for item_id, fields in _scan_blocks(text).items()
144
+ }
145
+
146
+
147
+ def _verdict_token(item_id: str, raw: str) -> tuple[str, str]:
148
+ parsed = _VERDICT_RE.match(raw.strip().strip("`").strip())
149
+ if parsed is None or parsed.group("token") not in VERDICT_TOKENS:
150
+ raise VerdictBlockError(
151
+ f"item `{item_id}` has an unknown verdict: {raw or '(missing)'}"
152
+ )
153
+ return parsed.group("token"), parsed.group("kind") or ""
154
+
155
+
156
+ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
157
+ raw = fields.get("Verdict", "")
158
+ if not raw:
159
+ raise VerdictBlockError(f"item `{item_id}` has no `**Verdict**:` line")
160
+ token, kind = _verdict_token(item_id, raw)
161
+ fixability = fields.get("Fixability", "")
162
+ if token == "DISAGREE":
163
+ if not kind:
164
+ raise VerdictBlockError(
165
+ f"item `{item_id}` is DISAGREE with no breakage kind — the gate "
166
+ f"reads the kind to decide whether one vote blocks, so a bare "
167
+ f"`DISAGREE` cannot be scored"
168
+ )
169
+ if fixability not in FIXABILITY_VALUES:
170
+ raise VerdictBlockError(
171
+ f"item `{item_id}` is DISAGREE with fixability "
172
+ f"`{fixability or '(missing)'}` — expected one of "
173
+ f"{sorted(FIXABILITY_VALUES)}"
174
+ )
175
+ return VerdictBlock(
176
+ item_id=item_id,
177
+ verdict=token,
178
+ breakage_kind=kind,
179
+ fixability=fixability,
180
+ note=fields.get("Note", ""),
181
+ explanation=fields.get("Explanation", ""),
182
+ prior_dissent=fields.get("Prior dissent", ""),
183
+ )
@@ -53,8 +53,10 @@ from okstra_ctl.lead_runtime import lead_runtime_info
53
53
  from okstra_ctl.runner_resolution import native_provider_for_host
54
54
  from okstra_ctl.clarification_items import (
55
55
  scan_approval_gate,
56
+ sidecar_answers,
56
57
  user_response_sidecars,
57
58
  )
59
+ from okstra_ctl.incremental_scope import preview_link_availability_for_report
58
60
  from okstra_ctl.design_prep import (
59
61
  DesignPrepError,
60
62
  load_design_prep_items,
@@ -94,6 +96,7 @@ from okstra_ctl.workers import (
94
96
  from okstra_ctl.workflow import PHASE_SEQUENCE
95
97
  from okstra_ctl.wizard_stage_intent import (
96
98
  WHOLE_TASK_STAGE,
99
+ WizardStageIntent,
97
100
  WizardStageIntentError,
98
101
  resolve_wizard_stage_intent,
99
102
  wizard_stage_confirmation_label,
@@ -4570,6 +4573,23 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
4570
4573
  return {"echo": echo or "", "next": prompt_payload(state, nxt)}
4571
4574
 
4572
4575
 
4576
+ def _stage_intent(state: WizardState) -> WizardStageIntent:
4577
+ """This state's stage selection, resolved once for every consumer.
4578
+
4579
+ `render_args` needs the single stage this run prepares; `wizard_outcome`
4580
+ needs the chain the skill drives. Deriving them separately would let the two
4581
+ disagree about which stage the run is for.
4582
+ """
4583
+ try:
4584
+ return resolve_wizard_stage_intent(
4585
+ task_type=state.task_type,
4586
+ selected_stage=state.selected_stage,
4587
+ selected_stages=state.selected_stages,
4588
+ )
4589
+ except WizardStageIntentError as exc:
4590
+ raise WizardError(str(exc)) from exc
4591
+
4592
+
4573
4593
  def render_args(state: WizardState) -> dict[str, str]:
4574
4594
  """Convert finalized state into ``okstra render-bundle`` argument map."""
4575
4595
  if state.aborted:
@@ -4584,14 +4604,7 @@ def render_args(state: WizardState) -> dict[str, str]:
4584
4604
  if state.reuse_worktree or state.task_type == "final-verification"
4585
4605
  else state.base_ref
4586
4606
  )
4587
- try:
4588
- stage_intent = resolve_wizard_stage_intent(
4589
- task_type=state.task_type,
4590
- selected_stage=state.selected_stage,
4591
- selected_stages=state.selected_stages,
4592
- )
4593
- except WizardStageIntentError as exc:
4594
- raise WizardError(str(exc)) from exc
4607
+ stage_intent = _stage_intent(state)
4595
4608
  pr_template = (
4596
4609
  state.pr_template_path
4597
4610
  if state.task_type == "release-handoff"
@@ -4624,7 +4637,6 @@ def render_args(state: WizardState) -> dict[str, str]:
4624
4637
  "approved-plan": state.approved_plan_path,
4625
4638
  "stage": stage_intent.stage,
4626
4639
  "stages": state.handoff_stages,
4627
- "chain-stages": stage_intent.chain_stages,
4628
4640
  "base-ref": base_ref,
4629
4641
  "workers": workers,
4630
4642
  "directive": state.directive,
@@ -4643,6 +4655,37 @@ def render_args(state: WizardState) -> dict[str, str]:
4643
4655
  }
4644
4656
 
4645
4657
 
4658
+ def _reverify_scope_line(state: WizardState) -> Optional[str]:
4659
+ """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 미리 보여주는 줄.
4660
+
4661
+ 이 판정의 절반(답변된 id 가 stage 로 되짚어지는지)은 base SHA 없이 결정되고
4662
+ 직전 리포트만 있으면 이미 정해져 있다. 그런데 지금까지는 `okstra recap
4663
+ assemble` 을 따로 돌려야만 보였고, run 이 시작된 뒤에 full 로 밝혀지면 두
4664
+ 시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는 마지막 지점이다.
4665
+ """
4666
+ if state.task_type != "implementation-planning":
4667
+ return None
4668
+ if not state.clarification_response_path or not state.project_root:
4669
+ return None
4670
+ report = _resolve_path(
4671
+ state.clarification_response_path, Path(state.project_root)
4672
+ )
4673
+ if not report.is_file():
4674
+ return None
4675
+ preview = preview_link_availability_for_report(
4676
+ report, set(sidecar_answers(report))
4677
+ )
4678
+ if not preview["wouldForceFull"]:
4679
+ return _msg(state.workspace_root, "confirmation",
4680
+ "reverify_scope_incremental")
4681
+ if preview["unlinkedIds"]:
4682
+ return _msg(state.workspace_root, "confirmation",
4683
+ "reverify_scope_unlinked",
4684
+ ids=", ".join(preview["unlinkedIds"]))
4685
+ return _msg(state.workspace_root, "confirmation", "reverify_scope_full",
4686
+ reason=preview["reason"])
4687
+
4688
+
4646
4689
  def confirmation_block(state: WizardState) -> str:
4647
4690
  """Human-readable echo of the resolved selections (for the Confirm step)."""
4648
4691
  header = _msg(state.workspace_root, "confirmation", "header")
@@ -4727,6 +4770,9 @@ def confirmation_block(state: WizardState) -> str:
4727
4770
  lines.append(f" stage : {stage}")
4728
4771
  if state.clarification_response_path:
4729
4772
  lines.append(f" clarification : {state.clarification_response_path}")
4773
+ reverify_line = _reverify_scope_line(state)
4774
+ if reverify_line is not None:
4775
+ lines.append(reverify_line)
4730
4776
  if state.task_type == "release-handoff" and state.handoff_mode:
4731
4777
  scope = (
4732
4778
  _msg(state.workspace_root, "confirmation",
@@ -4760,13 +4806,21 @@ def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
4760
4806
 
4761
4807
 
4762
4808
  def wizard_outcome(state: WizardState) -> dict[str, Any]:
4763
- """Public outcome for callers that need launch data and follow-up writes."""
4809
+ """Public outcome for callers that need launch data and follow-up writes.
4810
+
4811
+ `renderArgs` carries only what `okstra render-bundle` accepts, so a caller
4812
+ can pass every entry through unfiltered — which is exactly what the
4813
+ okstra-run skill is told to do. Signals the skill consumes itself, like the
4814
+ unattended stage chain, live under `orchestration`; mixing them into
4815
+ `renderArgs` made the renderer reject the wizard's own output.
4816
+ """
4764
4817
  if state.aborted:
4765
4818
  raise WizardError("wizard was aborted by the user — outcome is unavailable")
4766
4819
  if state.confirmed is not True:
4767
4820
  raise WizardError("wizard is not complete — outcome is unavailable")
4768
4821
  return {
4769
4822
  "renderArgs": render_args(state),
4823
+ "orchestration": {"chainStages": _stage_intent(state).chain_stages},
4770
4824
  "persistActions": _wizard_persist_actions(state),
4771
4825
  "confirmationText": confirmation_block(state),
4772
4826
  }
@@ -0,0 +1,44 @@
1
+ """CLI adapter for the worker audit-sidecar contract (`okstra worker-audit-check`).
2
+
3
+ Phase 7 runs the same rules through `validate-run.py`, but by then the worker
4
+ session is gone and the only remedies left are a retroactive edit — which breaks
5
+ the audit chain — or a failed run. Called the moment a worker returns, the same
6
+ rules cost one message to a worker that is still listening.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from okstra_ctl.worker_audit_ledger import check_worker_results_audit
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="okstra worker-audit-check",
21
+ description="Check one run's worker audit sidecars (read-only).",
22
+ )
23
+ parser.add_argument("--run-dir", type=Path, required=True,
24
+ help="runs/<task-type>/ for this run")
25
+ parser.add_argument("--task-type", required=True)
26
+ parser.add_argument("--seq", required=True,
27
+ help="this run's 3-digit seq")
28
+ parser.add_argument("--worker", default=None,
29
+ help="check only this worker id (default: every worker)")
30
+ return parser
31
+
32
+
33
+ def main(argv: list[str] | None = None) -> int:
34
+ args = _parser().parse_args(argv)
35
+ failures = check_worker_results_audit(
36
+ args.run_dir, args.task_type, args.seq, worker=args.worker
37
+ )
38
+ print(json.dumps({"ok": not failures, "failures": failures},
39
+ ensure_ascii=False, indent=2))
40
+ return 2 if failures else 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,207 @@
1
+ """The worker audit-sidecar contract, shared by Phase 7 and the mid-run check.
2
+
3
+ Phase 7 has always enforced this post-hoc, but by then the worker session is
4
+ gone and the only remedies left are editing the result after the fact — which
5
+ breaks the audit chain — or failing the run. `okstra worker-audit-check` runs
6
+ the same rules the moment a worker returns, while the worker is still listening
7
+ and can fix its own citation. Both consumers must agree on what a violation is,
8
+ so the rules live here rather than inside either one — the same split
9
+ `worker_heartbeat` makes for the heartbeat cadence.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from okstra_ctl.worker_prompt_headers import EVIDENCE_LEDGER_HEADER
17
+
18
+ # Worker-results filename pattern: `<worker-role>-<task-type>-<seq>.md`.
19
+ # Every analysis-worker role name ends in `-worker` (`claude-worker`,
20
+ # `codex-worker`, `antigravity-worker`, `report-writer-worker`), so anchor the
21
+ # split on that suffix — otherwise `antigravity-worker-error-analysis-001.md`
22
+ # ambiguously parses as `worker=antigravity, task=worker-error-analysis`.
23
+ # Audit sidecars (`*-audit-*`) and errors sidecars (`.json`) are not matched here.
24
+ _WORKER_RESULT_BASENAME_RE = re.compile(
25
+ r"^(?P<worker>[a-z][a-z0-9-]*-worker)-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
26
+ )
27
+
28
+ READING_CONFIRMATION_HEADING_RE = re.compile(
29
+ r"^##[ \t]+0\.[ \t]+Reading Confirmation\b", re.MULTILINE
30
+ )
31
+
32
+ _EVIDENCE_READ_RE = re.compile(
33
+ r"^- Evidence read: `(?P<path>[^`\n]+)`\s*$",
34
+ re.MULTILINE,
35
+ )
36
+ _FILE_LINE_CITATION_RE = re.compile(
37
+ r"`(?P<path>(?!https?://)[^`\n]+?):(?P<line>\d+(?:-\d+)?)`"
38
+ )
39
+ _EXTENSIONLESS_SOURCE_FILENAMES = frozenset(
40
+ {"Dockerfile", "Justfile", "Makefile", "Procfile", "Rakefile"}
41
+ )
42
+
43
+
44
+ def _looks_like_file_path(path: str) -> bool:
45
+ if (
46
+ not path
47
+ or path.startswith(("-", "$"))
48
+ or any(char.isspace() for char in path)
49
+ ):
50
+ return False
51
+ if re.fullmatch(r"[0-9a-fA-F]{7,64}", path):
52
+ return False
53
+ return (
54
+ "/" in path
55
+ or "." in Path(path).name
56
+ or Path(path).name in _EXTENSIONLESS_SOURCE_FILENAMES
57
+ )
58
+
59
+
60
+ def _cited_file_paths(content: str) -> set[str]:
61
+ paths: set[str] = set()
62
+ for match in _FILE_LINE_CITATION_RE.finditer(content):
63
+ path = match.group("path")
64
+ if _looks_like_file_path(path):
65
+ paths.add(path)
66
+ return paths
67
+
68
+
69
+ def _audit_evidence_read_paths(content: str) -> set[str]:
70
+ return {
71
+ match.group("path")
72
+ for match in _EVIDENCE_READ_RE.finditer(content)
73
+ }
74
+
75
+
76
+ def _worker_prompt_path(
77
+ run_dir: Path,
78
+ worker_role: str,
79
+ task_type: str,
80
+ seq: str,
81
+ ) -> Path:
82
+ return run_dir / "prompts" / f"{worker_role}-prompt-{task_type}-{seq}.md"
83
+
84
+
85
+ def _evidence_read_ledger_failures(
86
+ *,
87
+ run_dir: Path,
88
+ worker_role: str,
89
+ task_type: str,
90
+ seq: str,
91
+ result_name: str,
92
+ result_content: str,
93
+ audit_path: Path,
94
+ ) -> list[str]:
95
+ if worker_role == "report-writer-worker":
96
+ return []
97
+ prompt_path = _worker_prompt_path(run_dir, worker_role, task_type, seq)
98
+ try:
99
+ prompt_content = prompt_path.read_text(encoding="utf-8")
100
+ except OSError:
101
+ return []
102
+ if EVIDENCE_LEDGER_HEADER not in prompt_content.splitlines():
103
+ return []
104
+ try:
105
+ audit_content = audit_path.read_text(encoding="utf-8")
106
+ except OSError as exc:
107
+ return [f"worker audit sidecar unreadable: {audit_path.name} ({exc})"]
108
+
109
+ missing_paths = sorted(
110
+ _cited_file_paths(result_content) - _audit_evidence_read_paths(audit_content)
111
+ )
112
+ return [
113
+ f"worker `{worker_role}` result `{result_name}` cites "
114
+ f"`{missing_path}:line` without an Evidence read row for "
115
+ f"`{missing_path}` in `{audit_path.name}`"
116
+ for missing_path in missing_paths
117
+ ]
118
+
119
+
120
+ def _result_files(run_dir: Path, task_type: str, seq: str | None, worker: str | None):
121
+ """Every worker-results file in *run_dir* this check owns, in name order."""
122
+ for path in sorted((run_dir / "worker-results").glob("*.md")):
123
+ if "-audit-" in path.name:
124
+ continue
125
+ match = _WORKER_RESULT_BASENAME_RE.match(path.name)
126
+ if match is None:
127
+ # Files that don't match the canonical pattern (e.g. ad-hoc notes
128
+ # left by the operator) are out of contract scope.
129
+ continue
130
+ if match.group("task_type") != task_type:
131
+ # Cross-phase artifacts shouldn't appear here; skip rather than
132
+ # fail to keep the check focused on the current phase.
133
+ continue
134
+ if seq is not None and match.group("seq") != seq:
135
+ # A prior run's artifact. Its contract was judged when it ran.
136
+ continue
137
+ if worker is not None and match.group("worker") != worker:
138
+ continue
139
+ yield path, match.group("worker"), match.group("seq")
140
+
141
+
142
+ def check_worker_results_audit(
143
+ run_dir: Path,
144
+ task_type: str,
145
+ seq: str | None,
146
+ *,
147
+ worker: str | None = None,
148
+ ) -> list[str]:
149
+ """Every audit-sidecar contract failure among this run's worker results.
150
+
151
+ *run_dir* is `runs/<task-type>/`; `worker-results/` and `prompts/` hang off
152
+ it. *seq* scopes the check to one run — `worker-results/` accumulates every
153
+ run's artifacts, so scanning the whole directory judged a run by files it
154
+ did not produce. Pass ``None`` only when the seq is genuinely unknown, which
155
+ falls back to not filtering rather than silently checking nothing.
156
+
157
+ For each result file this checks that it carries no `## 0. Reading
158
+ Confirmation` heading (that block moved to the sidecar), that the matching
159
+ sidecar exists, and — for prompts carrying the required-v1 evidence-ledger
160
+ marker — that every backticked `path:line` citation has an Evidence read row.
161
+ """
162
+ failures: list[str] = []
163
+ if not (run_dir / "worker-results").is_dir():
164
+ # No worker-results directory means no analysis workers ran (e.g.
165
+ # `release-handoff`, which is single-lead). Nothing to enforce.
166
+ return failures
167
+
168
+ for path, worker_role, result_seq in _result_files(run_dir, task_type, seq, worker):
169
+ rel = path.name
170
+ try:
171
+ content = path.read_text()
172
+ except OSError as exc:
173
+ failures.append(f"worker-results file unreadable: {rel} ({exc})")
174
+ continue
175
+
176
+ if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
177
+ failures.append(
178
+ f"worker-results file `{rel}` contains a `## 0. Reading "
179
+ f"Confirmation` heading — that block moved to the audit "
180
+ f"sidecar (`{worker_role}-audit-{task_type}-{result_seq}.md`). "
181
+ f"Remove the §0 heading + body from the main file and "
182
+ f"write a fresh sidecar."
183
+ )
184
+
185
+ audit_path = (
186
+ run_dir / "worker-results"
187
+ / f"{worker_role}-audit-{task_type}-{result_seq}.md"
188
+ )
189
+ if not audit_path.exists():
190
+ failures.append(
191
+ f"worker `{worker_role}` produced `{rel}` but no audit sidecar "
192
+ f"at `{audit_path.name}` — the sidecar must carry the Reading "
193
+ f"Confirmation block (one short line per input file). Workers "
194
+ f"write this in the same step as the main worker-results file."
195
+ )
196
+ continue
197
+
198
+ failures.extend(_evidence_read_ledger_failures(
199
+ run_dir=run_dir,
200
+ worker_role=worker_role,
201
+ task_type=task_type,
202
+ seq=result_seq,
203
+ result_name=rel,
204
+ result_content=content,
205
+ audit_path=audit_path,
206
+ ))
207
+ return failures
@@ -18,6 +18,10 @@ HEARTBEAT_LINE_RE = re.compile(
18
18
  r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
19
19
  )
20
20
 
21
+ # 한 단계가 cadence 보다 길어질 때 워커가 append 하는 진행 라인의 접두사
22
+ # (claude-worker.md "Heartbeat"). 뒤에 붙는 stage 이름은 원래 단계 그대로다.
23
+ IN_STAGE_PREFIX = "in-stage:"
24
+
21
25
  # 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
22
26
  # 고정 grace 60초를 더한다.
23
27
  HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
@@ -48,10 +52,12 @@ def max_gap_seconds_after(stage: str) -> int:
48
52
  """*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).
49
53
 
50
54
  간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
51
- 구간을 *여는* 단계에서 고른다."""
52
- if stage in SINGLE_WRITE_STAGES:
55
+ 구간을 *여는* 단계에서 고른다. `in-stage:<X>` 는 아직 X 안에 있다는 뜻이라
56
+ 그 라인이 여는 구간도 여전히 X 의 일부다 — 접두사를 떼고 X 의 예산을 쓴다."""
57
+ opener = stage.removeprefix(IN_STAGE_PREFIX)
58
+ if opener in SINGLE_WRITE_STAGES:
53
59
  return SINGLE_WRITE_MAX_GAP_SECONDS
54
- if stage in SYNTHESIS_STAGES:
60
+ if opener in SYNTHESIS_STAGES:
55
61
  return SYNTHESIS_MAX_GAP_SECONDS
56
62
  return HEARTBEAT_MAX_GAP_SECONDS
57
63