okstra 0.129.0 → 0.130.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.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +5 -2
- package/docs/project-structure-overview.md +8 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +7 -0
- package/runtime/agents/workers/codex-worker.md +7 -0
- package/runtime/agents/workers/report-writer-worker.md +2 -1
- package/runtime/bin/okstra-error-log.py +91 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -0
- package/runtime/prompts/lead/report-writer.md +19 -26
- package/runtime/prompts/lead/team-contract.md +17 -2
- package/runtime/prompts/profiles/implementation-planning.md +10 -2
- package/runtime/prompts/profiles/requirements-discovery.md +2 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +28 -222
- package/runtime/python/okstra_ctl/report_finalize.py +394 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +129 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +6 -2
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +4 -1
- package/runtime/templates/reports/fan-out-unit.template.md +11 -0
- package/runtime/templates/worker-error-contract.md +11 -1
- package/runtime/validators/lib/common.sh +0 -9
- package/runtime/validators/validate-run.py +154 -4
- package/runtime/validators/validate_fanout.py +62 -2
- package/src/cli-registry.mjs +11 -0
- package/src/commands/report/finalize.mjs +65 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Single source of truth for scope-provenance grammar.
|
|
2
|
+
|
|
3
|
+
Every requirement a phase emits must declare where it came from. Shared by
|
|
4
|
+
validators/validate-run.py and validators/validate_fanout.py so the grammar
|
|
5
|
+
cannot drift between the planning report and the fan-out packets.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
# Items okstra's own phase contracts mandate — they have no brief line to cite.
|
|
15
|
+
# Keep this narrow: a wide allowlist turns into a bypass for invented scope.
|
|
16
|
+
CONTRACT_RULES = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"decision-record-step", # implementation-planning.md: decisionDrafts materialization
|
|
19
|
+
"glossary-step", # implementation-planning.md: glossary proposals
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Mirrors the report schema's requirement id pattern
|
|
24
|
+
# (schemas/final-report-v1.0.schema.json: `^R-\d{3,}$`).
|
|
25
|
+
_REQ_ID_RE = re.compile(r"^R-\d{3,}$")
|
|
26
|
+
_BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
|
|
27
|
+
_DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
|
|
28
|
+
_CONTRACT_RE = re.compile(r"^contract:\s*(?P<rule>.+?)\s*$")
|
|
29
|
+
_BRIEF_HEADING_RE = re.compile(r"^(#{1,6}\s+.+?)\s*$")
|
|
30
|
+
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_heading(text: str) -> str:
|
|
34
|
+
"""Heading text stripped of its `#` markers and surrounding whitespace.
|
|
35
|
+
|
|
36
|
+
Applied to both sides of the citation comparison so `brief:Acceptance
|
|
37
|
+
Criteria` and `brief:## Acceptance Criteria` both name the same heading.
|
|
38
|
+
The comparison stays exact after this — a heading the brief lacks must
|
|
39
|
+
still fail.
|
|
40
|
+
"""
|
|
41
|
+
return " ".join(text.lstrip("#").split())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def brief_headings(brief_path: Path) -> set[str]:
|
|
45
|
+
"""Markdown headings present in the brief, normalized for comparison.
|
|
46
|
+
|
|
47
|
+
An unreadable brief yields an empty set so callers degrade to skipping
|
|
48
|
+
heading verification instead of failing every brief-sourced citation.
|
|
49
|
+
|
|
50
|
+
Fenced regions are skipped: a shell comment inside a ```bash block starts
|
|
51
|
+
with `#` too, and admitting it would let a requirement cite a "heading" the
|
|
52
|
+
reporter never wrote.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
text = Path(brief_path).read_text(encoding="utf-8")
|
|
56
|
+
except OSError:
|
|
57
|
+
return set()
|
|
58
|
+
|
|
59
|
+
headings: set[str] = set()
|
|
60
|
+
fence: str | None = None
|
|
61
|
+
for line in text.splitlines():
|
|
62
|
+
if m := _FENCE_RE.match(line):
|
|
63
|
+
marker = m.group(1)
|
|
64
|
+
if fence is None:
|
|
65
|
+
fence = marker[0]
|
|
66
|
+
elif marker[0] == fence:
|
|
67
|
+
fence = None
|
|
68
|
+
continue
|
|
69
|
+
if fence is not None:
|
|
70
|
+
continue
|
|
71
|
+
if m := _BRIEF_HEADING_RE.match(line):
|
|
72
|
+
headings.add(normalize_heading(m.group(1)))
|
|
73
|
+
return headings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SourceRef:
|
|
78
|
+
kind: Literal["brief", "derived", "contract", "invalid"]
|
|
79
|
+
value: str = ""
|
|
80
|
+
reason: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_source(raw: str) -> SourceRef:
|
|
84
|
+
text = (raw or "").strip()
|
|
85
|
+
if m := _DERIVED_RE.match(text):
|
|
86
|
+
parent = m.group("parent")
|
|
87
|
+
if not _REQ_ID_RE.match(parent):
|
|
88
|
+
return SourceRef("invalid")
|
|
89
|
+
return SourceRef("derived", parent, m.group("reason"))
|
|
90
|
+
if m := _BRIEF_RE.match(text):
|
|
91
|
+
return SourceRef("brief", m.group("heading"))
|
|
92
|
+
if m := _CONTRACT_RE.match(text):
|
|
93
|
+
rule = m.group("rule")
|
|
94
|
+
if rule not in CONTRACT_RULES:
|
|
95
|
+
return SourceRef("invalid")
|
|
96
|
+
return SourceRef("contract", rule)
|
|
97
|
+
return SourceRef("invalid")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
|
|
101
|
+
"""Walk each row's derivation chain to its terminator.
|
|
102
|
+
|
|
103
|
+
Returns id -> "ok" or a one-line reason the chain is not admissible.
|
|
104
|
+
"""
|
|
105
|
+
result: dict[str, str] = {}
|
|
106
|
+
for rid in rows:
|
|
107
|
+
seen: list[str] = []
|
|
108
|
+
cursor = rid
|
|
109
|
+
while True:
|
|
110
|
+
if cursor in seen:
|
|
111
|
+
result[rid] = f"derivation cycle: {' -> '.join(seen + [cursor])}"
|
|
112
|
+
break
|
|
113
|
+
seen.append(cursor)
|
|
114
|
+
ref = rows.get(cursor)
|
|
115
|
+
if ref is None:
|
|
116
|
+
result[rid] = f"derives from `{cursor}` which is not a row in this table"
|
|
117
|
+
break
|
|
118
|
+
if ref.kind in ("brief", "contract"):
|
|
119
|
+
result[rid] = "ok"
|
|
120
|
+
break
|
|
121
|
+
if ref.kind == "derived":
|
|
122
|
+
cursor = ref.value
|
|
123
|
+
continue
|
|
124
|
+
result[rid] = (
|
|
125
|
+
f"unrecognized source on `{cursor}` — must be one of "
|
|
126
|
+
"`brief:<heading>`, `derived:R-NNN — <reason>`, `contract:<rule>`"
|
|
127
|
+
)
|
|
128
|
+
break
|
|
129
|
+
return result
|
|
@@ -110,9 +110,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
110
110
|
description="Report whether pending workers are still alive (read-only).",
|
|
111
111
|
)
|
|
112
112
|
parser.add_argument("--audit", action="append", default=[],
|
|
113
|
-
help="claude-worker audit sidecar path (repeatable)")
|
|
113
|
+
help="in-process claude-worker audit sidecar path (repeatable)")
|
|
114
114
|
parser.add_argument("--prompt", action="append", default=[],
|
|
115
|
-
help="CLI-wrapper prompt-history path
|
|
115
|
+
help="CLI-wrapper (codex/antigravity) prompt-history path "
|
|
116
|
+
"(repeatable). Only the wrappers write the artifacts "
|
|
117
|
+
"this probe looks for, so passing an in-process "
|
|
118
|
+
"claude-worker path here always reports "
|
|
119
|
+
"did-not-launch — probe those with --audit")
|
|
116
120
|
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
117
121
|
help="heartbeat staleness budget in seconds")
|
|
118
122
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
@@ -6,6 +6,7 @@ from typing import Any, Mapping
|
|
|
6
6
|
|
|
7
7
|
from .paths import okstra_home
|
|
8
8
|
from .worker_prompt_policy import (
|
|
9
|
+
GRILLING_LOG_HEADER,
|
|
9
10
|
PromptPlan,
|
|
10
11
|
WORKER_ERROR_CONTRACT_FILENAME,
|
|
11
12
|
WORKER_PREAMBLE_FILENAME_BY_AUDIENCE,
|
|
@@ -75,7 +76,7 @@ def worker_prompt_headers(
|
|
|
75
76
|
])
|
|
76
77
|
if _string_value(manifest.get("taskType")) == "improvement-discovery":
|
|
77
78
|
headers.append(
|
|
78
|
-
f"
|
|
79
|
+
f"{GRILLING_LOG_HEADER} "
|
|
79
80
|
f"{_improvement_grilling_log_path(project_root, manifest)}"
|
|
80
81
|
)
|
|
81
82
|
if _string_value(manifest.get("taskType")) == "final-verification":
|
|
@@ -14,6 +14,9 @@ PromptAudience = Literal[
|
|
|
14
14
|
"lead-only",
|
|
15
15
|
]
|
|
16
16
|
|
|
17
|
+
# The prompt emitter, the run validator, and the worker-facing docs all bind to
|
|
18
|
+
# this literal; restating it anywhere else silently forks the dispatch contract.
|
|
19
|
+
GRILLING_LOG_HEADER = "**Phase 1.5 Grilling Log:**"
|
|
17
20
|
FINAL_VERIFICATION_HEADERS = (
|
|
18
21
|
"**Worktree:**",
|
|
19
22
|
"**Verification scope:**",
|
|
@@ -98,7 +101,7 @@ def resolve_prompt_plan(
|
|
|
98
101
|
"analysis",
|
|
99
102
|
equality_group="analysis-core",
|
|
100
103
|
packet_only=True,
|
|
101
|
-
required_headers=(
|
|
104
|
+
required_headers=(GRILLING_LOG_HEADER,),
|
|
102
105
|
)
|
|
103
106
|
return _plan("analysis", equality_group="analysis-core", packet_only=True)
|
|
104
107
|
|
|
@@ -16,6 +16,17 @@ recommended-next-phase: {{NEXT_PHASE}}
|
|
|
16
16
|
|
|
17
17
|
<!-- Self-contained description of the single work item this unit covers. Do not mix it with other units. -->
|
|
18
18
|
|
|
19
|
+
## Requirement Provenance
|
|
20
|
+
|
|
21
|
+
<!-- Where this unit's scope came from. One bullet per source. Every bullet must be
|
|
22
|
+
`brief:<heading>` (a heading that literally exists in the task brief) or
|
|
23
|
+
`contract:<rule>` (an okstra-mandated artifact). A unit you cannot source this
|
|
24
|
+
way is not a work item — raise it as a clarification instead of inventing it.
|
|
25
|
+
Unlike a planning report, `derived:<parent> — <reason>` is NOT accepted here: a
|
|
26
|
+
packet is validated on its own, so the parent it names cannot be resolved. -->
|
|
27
|
+
|
|
28
|
+
- brief:
|
|
29
|
+
|
|
19
30
|
## Evidence
|
|
20
31
|
|
|
21
32
|
<!-- path:line evidence. Locations that requirements-discovery confirmed via file inspection. -->
|
|
@@ -27,14 +27,24 @@ If the sidecar does not exist when a real failure occurs, create `{"schemaVersio
|
|
|
27
27
|
"durationMs": null,
|
|
28
28
|
"message": "<one-line human summary>",
|
|
29
29
|
"stderrExcerpt": null,
|
|
30
|
-
"context":
|
|
30
|
+
"context": {
|
|
31
|
+
"cause": "sandbox-denied | service-unavailable | auth-failed | unknown",
|
|
32
|
+
"causeEvidence": {
|
|
33
|
+
"targetProbe": "<command + raw output proving the target's state>",
|
|
34
|
+
"controlProbe": "<command + raw output proving another target is reachable>"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
31
37
|
}
|
|
32
38
|
```
|
|
33
39
|
|
|
40
|
+
`context` and both `cause` fields are optional; omit `context` entirely (`null`) when there is nothing to add.
|
|
41
|
+
|
|
34
42
|
### Rules
|
|
35
43
|
|
|
36
44
|
- Do not add `source`, `recordedAt`, `agent`, `agentRole`, `model`, or `taskKey`; the lead supplies them when merging the sidecar.
|
|
37
45
|
- The sidecar accepts only `errorType: "tool-failure"`. CLI wrappers write `cli-failure` directly to the run log; the lead records `contract-violation`.
|
|
46
|
+
- To record a block (sandbox/permission) as the cause, submit `context.cause = "sandbox-denied"` together with both probes in `context.causeEvidence`. Without the probes, leave `cause` as `unknown` — and then keep `message` to what you observed (`connection refused`, `1045 access denied`), not what you infer. A block asserted in `message` prose is rejected exactly like one asserted in `cause`, so "sandbox blocked it" with `cause: unknown` fails the write too. Failing to reach a target and the target being down are not the same fact — never assert the former without checking. Only `sandbox-denied` requires evidence; `service-unavailable` and `auth-failed` do not. Each probe is stored truncated to 256 bytes, so lead with the decisive command and its output.
|
|
47
|
+
- [Guideline] Put raw output in `stderrExcerpt` verbatim. Never merge several commands' output into one line, summarize it, or elide it with `...`. Once the host, port, and errno are gone, the cause cannot be recovered later.
|
|
38
48
|
- Continue after recording unless the failure makes the task impossible.
|
|
39
49
|
|
|
40
50
|
### Path extraction (BLOCKING)
|
|
@@ -33,12 +33,3 @@ assert_contains() {
|
|
|
33
33
|
fail "Expected to find [$expected_text] in $target_path"
|
|
34
34
|
fi
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
assert_not_contains() {
|
|
38
|
-
local target_path="$1"
|
|
39
|
-
local unexpected_text="$2"
|
|
40
|
-
|
|
41
|
-
if grep -F -- "$unexpected_text" "$target_path" >/dev/null 2>&1; then
|
|
42
|
-
fail "Did not expect to find [$unexpected_text] in $target_path"
|
|
43
|
-
fi
|
|
44
|
-
}
|
|
@@ -49,6 +49,13 @@ from okstra_ctl.final_report_paths import final_report_data_path as _data_path_f
|
|
|
49
49
|
from okstra_ctl.improvement_assignment import ( # noqa: E402
|
|
50
50
|
validate_primary_lens_assignments,
|
|
51
51
|
)
|
|
52
|
+
from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
|
|
53
|
+
from okstra_ctl.scope_provenance import ( # noqa: E402
|
|
54
|
+
brief_headings,
|
|
55
|
+
normalize_heading,
|
|
56
|
+
parse_source,
|
|
57
|
+
resolve_chain,
|
|
58
|
+
)
|
|
52
59
|
from okstra_ctl.design_prep import ( # noqa: E402
|
|
53
60
|
DesignPrepError,
|
|
54
61
|
_planning_seq as _design_prep_planning_seq,
|
|
@@ -472,7 +479,7 @@ def _grilling_log_path_from_prompts(
|
|
|
472
479
|
worker_ids: list[str],
|
|
473
480
|
records: list[PromptRecord],
|
|
474
481
|
) -> tuple[Path | None, list[str]]:
|
|
475
|
-
header =
|
|
482
|
+
header = GRILLING_LOG_HEADER
|
|
476
483
|
values: set[str] = set()
|
|
477
484
|
errors: list[str] = []
|
|
478
485
|
for record in records:
|
|
@@ -2869,6 +2876,134 @@ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -
|
|
|
2869
2876
|
)
|
|
2870
2877
|
|
|
2871
2878
|
|
|
2879
|
+
def _validate_requirement_provenance(
|
|
2880
|
+
data: dict, brief_path: Path, failures: list[str]
|
|
2881
|
+
) -> None:
|
|
2882
|
+
"""Every requirement must declare where it came from.
|
|
2883
|
+
|
|
2884
|
+
The planning input template already states that any change beyond what
|
|
2885
|
+
`Requirement Summary` demands is out of scope by default; without this
|
|
2886
|
+
check that sentence has no enforcement point and a phase can invent
|
|
2887
|
+
requirements freely.
|
|
2888
|
+
"""
|
|
2889
|
+
ip = data.get("implementationPlanning")
|
|
2890
|
+
if not isinstance(ip, dict):
|
|
2891
|
+
return
|
|
2892
|
+
rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
|
|
2893
|
+
if not rows:
|
|
2894
|
+
return
|
|
2895
|
+
|
|
2896
|
+
refs = {
|
|
2897
|
+
str(r.get("id") or "").strip(): parse_source(str(r.get("source") or ""))
|
|
2898
|
+
for r in rows
|
|
2899
|
+
}
|
|
2900
|
+
headings = brief_headings(brief_path)
|
|
2901
|
+
|
|
2902
|
+
for rid, ref in refs.items():
|
|
2903
|
+
if ref.kind == "brief" and headings:
|
|
2904
|
+
if normalize_heading(ref.value) not in headings:
|
|
2905
|
+
failures.append(
|
|
2906
|
+
f"final-report data.json: requirementCoverage `{rid}` cites brief "
|
|
2907
|
+
f"heading `{ref.value}` which does not exist in the brief. A "
|
|
2908
|
+
"requirement must trace to a line the reporter actually wrote."
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
for rid, verdict in resolve_chain(refs).items():
|
|
2912
|
+
if verdict != "ok":
|
|
2913
|
+
failures.append(
|
|
2914
|
+
f"final-report data.json: requirementCoverage `{rid}` provenance "
|
|
2915
|
+
f"failed — {verdict}. An item with no admissible source is not a "
|
|
2916
|
+
"requirement: raise it as a `Blocks=approval` clarification instead."
|
|
2917
|
+
)
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
_CITED_STAGE_RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
|
|
2921
|
+
_CITED_STAGE_ITEM = rf"\d+(?:\s*{_CITED_STAGE_RANGE}\s*\d+)?"
|
|
2922
|
+
# `, and` / `, &` is one separator, not a separator followed by a stray word:
|
|
2923
|
+
# `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
|
|
2924
|
+
_CITED_STAGE_SEP = r"(?:,\s*(?:and|&)?|and|&)"
|
|
2925
|
+
_CITED_STAGE_LIST_RE = re.compile(
|
|
2926
|
+
rf"\bstages?[ \t]*({_CITED_STAGE_ITEM}(?:\s*{_CITED_STAGE_SEP}\s*{_CITED_STAGE_ITEM})*)",
|
|
2927
|
+
re.IGNORECASE,
|
|
2928
|
+
)
|
|
2929
|
+
_CITED_STAGE_ITEM_RE = re.compile(
|
|
2930
|
+
rf"(\d+)(?:\s*{_CITED_STAGE_RANGE}\s*(\d+))?", re.IGNORECASE
|
|
2931
|
+
)
|
|
2932
|
+
_CITED_STAGE_RANGE_MAX_SPAN = 64
|
|
2933
|
+
|
|
2934
|
+
|
|
2935
|
+
def _cited_stage_numbers(covered_by: str) -> set[int]:
|
|
2936
|
+
"""Stage numbers a `coveredBy` cell cites, in every prose form a planner writes.
|
|
2937
|
+
|
|
2938
|
+
Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
|
|
2939
|
+
what "no match" means — forward treats it as nothing to verify and passes,
|
|
2940
|
+
reverse treats it as nothing cited and orphans every stage — so the reverse
|
|
2941
|
+
reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too.
|
|
2942
|
+
Numbers stay anchored to a word-initial `stage`/`stages` token on the same
|
|
2943
|
+
line; harvesting bare numbers — or letting the anchor reach across a line
|
|
2944
|
+
break, or match the tail of `Substage`/`Backstage` — would let any prose,
|
|
2945
|
+
including a row disclaiming every stage, justify any stage.
|
|
2946
|
+
"""
|
|
2947
|
+
cited: set[int] = set()
|
|
2948
|
+
for span in _CITED_STAGE_LIST_RE.finditer(covered_by):
|
|
2949
|
+
for item in _CITED_STAGE_ITEM_RE.finditer(span.group(1)):
|
|
2950
|
+
start = int(item.group(1))
|
|
2951
|
+
cited.add(start)
|
|
2952
|
+
if item.group(2) is None:
|
|
2953
|
+
continue
|
|
2954
|
+
end = int(item.group(2))
|
|
2955
|
+
cited.add(end)
|
|
2956
|
+
# A reversed or absurdly wide range is a typo, not a citation of
|
|
2957
|
+
# everything between its endpoints.
|
|
2958
|
+
if 0 <= end - start <= _CITED_STAGE_RANGE_MAX_SPAN:
|
|
2959
|
+
cited.update(range(start, end))
|
|
2960
|
+
return cited
|
|
2961
|
+
|
|
2962
|
+
|
|
2963
|
+
# Statuses under which a row asserts the plan does work for the requirement.
|
|
2964
|
+
# `gap` is excluded: it states the plan does NOT cover the requirement, so it
|
|
2965
|
+
# can justify no stage. `blocked C-NNN` is included — the requirement is real
|
|
2966
|
+
# and the stage serving it is not invented scope, only pending a user answer,
|
|
2967
|
+
# and such a row already forces a non-passing gate and `approved: false`, so
|
|
2968
|
+
# orphaning its stage here would only push planners to delete legitimate work.
|
|
2969
|
+
_COVERAGE_CLAIMING_STATUS_RE = re.compile(r"^(covered|blocked C-\d{3,})$")
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
|
|
2973
|
+
"""Reverse of `_validate_requirement_coverage_covered_by`.
|
|
2974
|
+
|
|
2975
|
+
That check proves every requirement reaches a stage; this one proves every
|
|
2976
|
+
stage traces back to a requirement. Without it a plan can carry stages no
|
|
2977
|
+
one asked for and still pass every gate.
|
|
2978
|
+
"""
|
|
2979
|
+
ip = data.get("implementationPlanning")
|
|
2980
|
+
if not isinstance(ip, dict):
|
|
2981
|
+
return
|
|
2982
|
+
stage_numbers = {
|
|
2983
|
+
s.get("stage")
|
|
2984
|
+
for s in (ip.get("stages") or [])
|
|
2985
|
+
if isinstance(s, dict) and isinstance(s.get("stage"), int)
|
|
2986
|
+
}
|
|
2987
|
+
if not stage_numbers:
|
|
2988
|
+
return
|
|
2989
|
+
|
|
2990
|
+
cited: set[int] = set()
|
|
2991
|
+
for row in (ip.get("requirementCoverage") or []):
|
|
2992
|
+
if not isinstance(row, dict):
|
|
2993
|
+
continue
|
|
2994
|
+
if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
|
|
2995
|
+
continue
|
|
2996
|
+
cited |= _cited_stage_numbers(str(row.get("coveredBy") or ""))
|
|
2997
|
+
|
|
2998
|
+
for orphan in sorted(stage_numbers - cited):
|
|
2999
|
+
failures.append(
|
|
3000
|
+
f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
|
|
3001
|
+
"row — it traces back to nothing the brief asked for. Either cite the "
|
|
3002
|
+
"requirement it serves, or drop it and raise it as a `Blocks=approval` "
|
|
3003
|
+
"clarification (profile: scope provenance)."
|
|
3004
|
+
)
|
|
3005
|
+
|
|
3006
|
+
|
|
2872
3007
|
def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
|
|
2873
3008
|
"""Enforce verdict ↔ blocker/condition/routing consistency on the
|
|
2874
3009
|
final-verification data.json (SSOT). The schema guarantees field SHAPE;
|
|
@@ -3323,7 +3458,7 @@ def _validate_convergence_states(run_dir, failures) -> None:
|
|
|
3323
3458
|
)
|
|
3324
3459
|
|
|
3325
3460
|
|
|
3326
|
-
def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
|
|
3461
|
+
def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
|
|
3327
3462
|
"""requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
|
|
3328
3463
|
실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
|
|
3329
3464
|
"""
|
|
@@ -3340,7 +3475,7 @@ def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
|
|
|
3340
3475
|
f"requirements-discovery: validate_fanout import failed — {exc}"
|
|
3341
3476
|
)
|
|
3342
3477
|
return
|
|
3343
|
-
result = validate_fanout(_Path(run_dir))
|
|
3478
|
+
result = validate_fanout(_Path(run_dir), brief_path)
|
|
3344
3479
|
if not result.ok:
|
|
3345
3480
|
for err in result.errors:
|
|
3346
3481
|
failures.append(f"requirements-discovery: {err}")
|
|
@@ -3757,6 +3892,15 @@ def main() -> int:
|
|
|
3757
3892
|
_validate_conformance(report_path, failures, surface_patterns=_sp)
|
|
3758
3893
|
if task_type == "implementation-planning":
|
|
3759
3894
|
_validate_planning_conformance_declared(report_path, failures)
|
|
3895
|
+
brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
|
|
3896
|
+
planning_brief = (
|
|
3897
|
+
(project_root / brief_relative).resolve()
|
|
3898
|
+
if brief_relative
|
|
3899
|
+
else project_root / "__no-brief__" # absent path → brief_headings returns set()
|
|
3900
|
+
)
|
|
3901
|
+
planning_data = _load_final_report_data(report_path)
|
|
3902
|
+
_validate_requirement_provenance(planning_data, planning_brief, failures)
|
|
3903
|
+
_validate_stage_has_requirement(planning_data, failures)
|
|
3760
3904
|
if task_type == "improvement-discovery":
|
|
3761
3905
|
brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
|
|
3762
3906
|
brief_path = (
|
|
@@ -3767,8 +3911,14 @@ def main() -> int:
|
|
|
3767
3911
|
run_dir = report_path.parent.parent
|
|
3768
3912
|
_validate_improvement_discovery(report_path, run_dir, brief_path, failures)
|
|
3769
3913
|
if task_type == "requirements-discovery":
|
|
3914
|
+
brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
|
|
3915
|
+
brief_path = (
|
|
3916
|
+
(project_root / brief_relative).resolve()
|
|
3917
|
+
if brief_relative
|
|
3918
|
+
else project_root / "__no-brief__" # absent path → brief_headings returns set()
|
|
3919
|
+
)
|
|
3770
3920
|
run_dir = report_path.parent.parent
|
|
3771
|
-
_validate_requirements_discovery_fanout(run_dir, failures)
|
|
3921
|
+
_validate_requirements_discovery_fanout(run_dir, failures, brief_path)
|
|
3772
3922
|
# Phase-agnostic: convergence runs in every finding-producing phase.
|
|
3773
3923
|
_validate_convergence_states(report_path.parent.parent, failures)
|
|
3774
3924
|
validate_report_views(report_path, failures)
|
|
@@ -17,11 +17,67 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
17
17
|
|
|
18
18
|
from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
|
|
19
19
|
from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
|
|
20
|
+
from okstra_ctl.scope_provenance import ( # noqa: E402
|
|
21
|
+
brief_headings,
|
|
22
|
+
normalize_heading,
|
|
23
|
+
parse_source,
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
_NEXT_PHASES = ("error-analysis", "implementation-planning")
|
|
22
27
|
_UNIT_RE = re.compile(r"unit-\d{3}")
|
|
23
28
|
# index.md 번호목록 항목에서만 unit-NNN 을 추출 — 내러티브 문장 중복 방지
|
|
24
29
|
_INDEX_ENTRY_RE = re.compile(r"^\s*\d+\.\s+(unit-\d{3})\b", re.MULTILINE)
|
|
30
|
+
# A `###` subsection inside the section still ends it: its prose is commentary,
|
|
31
|
+
# not provenance, and parsing it as bullets rejects legitimate packets.
|
|
32
|
+
_PROVENANCE_SECTION_RE = re.compile(
|
|
33
|
+
r"^##\s+Requirement Provenance\s*$(?P<body>.*?)(?=^#{2,}\s|\Z)",
|
|
34
|
+
re.MULTILINE | re.DOTALL,
|
|
35
|
+
)
|
|
36
|
+
_PROVENANCE_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<src>.+?)\s*$", re.MULTILINE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_provenance(
|
|
40
|
+
pkt_name: str, text: str, errors: list[str], headings: set[str]
|
|
41
|
+
) -> None:
|
|
42
|
+
"""A fan-out unit must cite the brief line that demanded it.
|
|
43
|
+
|
|
44
|
+
`derived:` is not admissible here: cross-packet derivation cannot be
|
|
45
|
+
resolved from a single packet, so each unit anchors directly on the brief
|
|
46
|
+
or on an okstra contract rule.
|
|
47
|
+
|
|
48
|
+
`headings` empty means the brief was unavailable — the grammar still
|
|
49
|
+
applies, only the heading-existence half degrades to unverifiable.
|
|
50
|
+
"""
|
|
51
|
+
section = _PROVENANCE_SECTION_RE.search(text)
|
|
52
|
+
if not section:
|
|
53
|
+
errors.append(
|
|
54
|
+
f"{pkt_name}: missing `## Requirement Provenance` section — a unit must "
|
|
55
|
+
"cite the brief line that demanded it"
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
bullets = [
|
|
59
|
+
b for b in _PROVENANCE_BULLET_RE.findall(section.group("body")) if b.strip()
|
|
60
|
+
]
|
|
61
|
+
if not bullets:
|
|
62
|
+
errors.append(
|
|
63
|
+
f"{pkt_name}: `## Requirement Provenance` is empty — cite at least one "
|
|
64
|
+
"`brief:<heading>` or `contract:<rule>`"
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
for raw in bullets:
|
|
68
|
+
ref = parse_source(raw)
|
|
69
|
+
if ref.kind not in ("brief", "contract"):
|
|
70
|
+
errors.append(
|
|
71
|
+
f"{pkt_name}: unrecognized provenance `{raw}` — a fan-out unit's "
|
|
72
|
+
"source must be `brief:<heading>` or `contract:<rule>`"
|
|
73
|
+
)
|
|
74
|
+
elif ref.kind == "brief" and headings:
|
|
75
|
+
if normalize_heading(ref.value) not in headings:
|
|
76
|
+
errors.append(
|
|
77
|
+
f"{pkt_name}: cites brief heading `{ref.value}` which does not "
|
|
78
|
+
"exist in the brief. A fan-out unit becomes the brief for a whole "
|
|
79
|
+
"downstream task — it must trace to a line the reporter wrote."
|
|
80
|
+
)
|
|
25
81
|
|
|
26
82
|
|
|
27
83
|
@dataclass
|
|
@@ -54,16 +110,20 @@ def _parse_deps(raw: str) -> list[str]:
|
|
|
54
110
|
return _UNIT_RE.findall(raw or "")
|
|
55
111
|
|
|
56
112
|
|
|
57
|
-
def validate_fanout(run_dir: Path) -> ValidationResult:
|
|
113
|
+
def validate_fanout(run_dir: Path, brief_path: Path | None = None) -> ValidationResult:
|
|
58
114
|
run_dir = Path(run_dir)
|
|
59
115
|
fo = run_dir / "fan-out"
|
|
60
116
|
if not fo.is_dir():
|
|
61
117
|
return ValidationResult(ok=True)
|
|
62
118
|
|
|
119
|
+
headings = brief_headings(brief_path) if brief_path is not None else set()
|
|
120
|
+
|
|
63
121
|
errors: list[str] = []
|
|
64
122
|
units: dict[str, list[str]] = {}
|
|
65
123
|
for pkt in sorted(fo.glob("unit-*.md")):
|
|
66
|
-
|
|
124
|
+
text = pkt.read_text(encoding="utf-8")
|
|
125
|
+
fm = _frontmatter(text)
|
|
126
|
+
_check_provenance(pkt.name, text, errors, headings)
|
|
67
127
|
uid = fm.get("unit-id", "")
|
|
68
128
|
if uid != pkt.stem:
|
|
69
129
|
errors.append(f"{pkt.name}: unit-id {uid!r} != filename stem {pkt.stem!r}")
|
package/src/cli-registry.mjs
CHANGED
|
@@ -319,6 +319,17 @@ export const COMMAND_REGISTRY = [
|
|
|
319
319
|
category: "introspection",
|
|
320
320
|
summary: ["Render slim AI + self-contained HTML views of a final report"],
|
|
321
321
|
},
|
|
322
|
+
{
|
|
323
|
+
name: "report-finalize",
|
|
324
|
+
module: "./commands/report/finalize.mjs",
|
|
325
|
+
export: "run",
|
|
326
|
+
category: "introspection",
|
|
327
|
+
summary: [
|
|
328
|
+
"Run the Phase 7 post-report sequence (token-usage,",
|
|
329
|
+
"render-views, spawn-followups, validate-run) in its",
|
|
330
|
+
"contractual order against one final report.",
|
|
331
|
+
],
|
|
332
|
+
},
|
|
322
333
|
{
|
|
323
334
|
name: "render-final-report",
|
|
324
335
|
module: "./commands/report/render-final-report.mjs",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { runPythonModule } from "../../lib/python-helper.mjs";
|
|
2
|
+
import { resolvePaths } from "../../lib/paths.mjs";
|
|
3
|
+
|
|
4
|
+
const USAGE = `okstra report-finalize — run the Phase 7 post-report sequence
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
okstra report-finalize --project-root <dir> --run-manifest <path> \\
|
|
8
|
+
--report <final-report-<task-type>-<seq>.md> [--team-state <path>]
|
|
9
|
+
|
|
10
|
+
Runs the four Phase 7 steps in their contractual order against one final-report:
|
|
11
|
+
|
|
12
|
+
1. token-usage substitute real token/cost numbers into the data.json
|
|
13
|
+
2. render-views write the self-contained *.html sibling (skipped when the
|
|
14
|
+
report has no C-* clarification rows and is not a plan
|
|
15
|
+
approval target)
|
|
16
|
+
3. spawn-followups turn section 4 rows into task stubs
|
|
17
|
+
4. validate-run validate the finished run artifacts
|
|
18
|
+
|
|
19
|
+
Every step is idempotent, so re-running after a fixed failure is safe. The
|
|
20
|
+
sequence stops at the first non-zero exit and reports which step failed.
|
|
21
|
+
|
|
22
|
+
This is the same code path the Codex lead adapter runs automatically after its
|
|
23
|
+
report-writer completes, so a Claude-led and a Codex-led run finalize
|
|
24
|
+
identically.
|
|
25
|
+
|
|
26
|
+
--workspace-root is owned by this command.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const OWNED_FLAGS = new Set(["--workspace-root"]);
|
|
30
|
+
|
|
31
|
+
function isOwnedFlag(arg) {
|
|
32
|
+
if (OWNED_FLAGS.has(arg)) return true;
|
|
33
|
+
return [...OWNED_FLAGS].some((flag) => arg.startsWith(`${flag}=`));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildReportFinalizeArgs(args, paths) {
|
|
37
|
+
return ["--workspace-root", paths.workspace, ...args];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function run(args) {
|
|
41
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
42
|
+
process.stdout.write(USAGE);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (args.length === 0) {
|
|
46
|
+
process.stdout.write(USAGE);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const forbidden = args.find(isOwnedFlag);
|
|
51
|
+
if (forbidden) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`error: ${forbidden} is set by 'okstra report-finalize' itself — remove it from your args\n`,
|
|
54
|
+
);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const paths = await resolvePaths();
|
|
59
|
+
const result = await runPythonModule({
|
|
60
|
+
module: "okstra_ctl.report_finalize",
|
|
61
|
+
args: buildReportFinalizeArgs(args, paths),
|
|
62
|
+
stdio: "inherit-stdout",
|
|
63
|
+
});
|
|
64
|
+
return result.code;
|
|
65
|
+
}
|