okstra 0.129.1 → 0.130.1
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/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 +1 -0
- package/runtime/bin/okstra-error-log.py +91 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -0
- package/runtime/prompts/profiles/implementation-planning.md +10 -2
- package/runtime/prompts/profiles/requirements-discovery.md +2 -1
- package/runtime/python/okstra_ctl/md_table.py +19 -10
- package/runtime/python/okstra_ctl/render_final_report.py +7 -5
- package/runtime/python/okstra_ctl/scope_provenance.py +129 -0
- 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/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -207,6 +207,13 @@ and the run-level error log staying empty.
|
|
|
207
207
|
--stderr-excerpt-file "<captured-stderr-path or omit>"
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
+
Keep `--message` to the error you actually observed (`HTTP 429`,
|
|
211
|
+
`connection refused`, `1045 access denied`) — asserting that a sandbox or
|
|
212
|
+
permission boundary blocked the call requires `--context-json` carrying
|
|
213
|
+
`cause` plus both `causeEvidence` probes, and an unevidenced block claim in
|
|
214
|
+
`--message` is rejected on the spot (and again on dump if you route it to
|
|
215
|
+
the sidecar instead).
|
|
216
|
+
|
|
210
217
|
The lead prompt provides `**Errors log path:**`, `<task-key>`, and
|
|
211
218
|
`<phase>` alongside the prompt history path. If any of these are
|
|
212
219
|
missing, fall back to logging to the worker errors sidecar instead —
|
|
@@ -207,6 +207,13 @@ and the run-level error log staying empty.
|
|
|
207
207
|
--stderr-excerpt-file "<captured-stderr-path or omit>"
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
+
Keep `--message` to the error you actually observed (`HTTP 429`,
|
|
211
|
+
`connection refused`, `1045 access denied`) — asserting that a sandbox or
|
|
212
|
+
permission boundary blocked the call requires `--context-json` carrying
|
|
213
|
+
`cause` plus both `causeEvidence` probes, and an unevidenced block claim in
|
|
214
|
+
`--message` is rejected on the spot (and again on dump if you route it to
|
|
215
|
+
the sidecar instead).
|
|
216
|
+
|
|
210
217
|
The lead prompt provides `**Errors log path:**`, `<task-key>`, and
|
|
211
218
|
`<phase>` alongside the prompt history path. If any of these are
|
|
212
219
|
missing, fall back to logging to the worker errors sidecar instead —
|
|
@@ -102,6 +102,7 @@ Rules (the schema enforces most of these — they are listed here so you know *w
|
|
|
102
102
|
- Cite file paths and line numbers in every `evidence.primary[].source` / `consensus[].evidence` cell.
|
|
103
103
|
- Preserve every analysis worker's ticket tagging — every row's `ticketId` field carries the ticket key or the task-fallback. For single-ticket runs, set `ticketCoverage` to `{"singleTicket": "<ticket>"}`. For runs that do not require ticket tagging (`release-handoff`, `final-verification`), set `ticketCoverage` to `{"omit": true}`.
|
|
104
104
|
- For `implementation-planning`, populate `implementationPlanning.requirementCoverage` with one row per concrete requirement from the brief / packet, using IDs `R-001`, `R-002`, ... in source order. `coveredBy` MUST name the specific Option Candidate plus Stage/Step that satisfies the requirement. Use `status: "covered"` only when the report's plan actually covers it; otherwise use `gap` or `blocked C-NNN` and ensure the corresponding `Clarification Items` row blocks approval. Do not collapse this into `ticketCoverage`; ticket coverage is not requirement coverage.
|
|
105
|
+
- For `implementation-planning`, each `requirementCoverage` row's `source` is a graded cell, not prose — free text like `"carry-in from requirements-discovery C-001"` is rejected. Write exactly one of: `brief:<heading>`, where the heading literally exists in the task brief; `derived:R-NNN — <one-line reason>`, whose chain must terminate at a `brief:` or `contract:` row of the same table without cycling; or `contract:<rule>`, for artifacts okstra's own phase contract mandates, whose allowlist is exactly the two tokens `decision-record-step` (the §5.4 Decision Drafts materialization step) and `glossary-step` (the glossary proposal step) — any other rule name is rejected, so never invent one. (Maintainer SSOT for that allowlist: `scripts/okstra_ctl/scope_provenance.py` in the okstra repo.) A requirement you cannot source this way does not belong in the table: put it in `clarificationItems[]` with `Blocks=approval`. **Enforced:** `validators/validate-run.py` `_validate_requirement_provenance`. In the same table, anchor every stage number in `coveredBy` to a `Stage` / `Stages` word (`Stage 2`, `Stages 1-3`) — `_validate_stage_has_requirement` reads that cell as prose and fails the plan when a Stage Map stage is cited by no row.
|
|
105
106
|
- For `implementation-planning`, also populate `implementationPlanning.decisionDrafts` (one row per decision meeting all three decision-record criteria; `[]` otherwise) and `implementationPlanning.skippedAdrCandidates` (evaluated-but-dropped adr-candidates; `[]` otherwise). The schema excerpt enumerates the row shape; the renderer emits §5.4 `### Decision Drafts`. When `decisionDrafts` is non-empty, the plan's stages MUST carry a stepwise step that creates `.okstra/decisions/<NNNN>-<slug>.md` (validate-run gates this).
|
|
106
107
|
- When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate_improvement_report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes. `improvement-discovery` is NOT in the data.json schema enum, so author its markdown directly (not via `okstra-render-final-report.py`). Immediately after writing the markdown, run (`Bash`): `okstra inject-report-index <markdown path> --report-language <en|ko>`. That adds the top-of-report Index plus `I-NNN` / `C-NNN` scroll anchors; the run validator fails the report when the Index anchor is absent.
|
|
107
108
|
|
|
@@ -23,6 +23,26 @@ ALLOWED_AGENTS = {
|
|
|
23
23
|
ALLOWED_AGENT_ROLES = {"lead", "worker", "report-writer"}
|
|
24
24
|
SUPPORTED_SIDECAR_SCHEMA_VERSIONS = {1}
|
|
25
25
|
|
|
26
|
+
ALLOWED_CAUSES = {
|
|
27
|
+
"sandbox-denied", "service-unavailable", "auth-failed", "unknown",
|
|
28
|
+
}
|
|
29
|
+
# A `sandbox-denied` claim is only admissible with both probes attached.
|
|
30
|
+
CAUSE_EVIDENCE_FIELDS = ("targetProbe", "controlProbe")
|
|
31
|
+
# Both probes share a record's PIPE_BUF_BYTES budget with stderrExcerpt, so
|
|
32
|
+
# they cannot reuse the 2048 cap that assumes stderrExcerpt owns it alone.
|
|
33
|
+
CAUSE_PROBE_MAX_BYTES = 256
|
|
34
|
+
# Backstop vocabulary: scanned in `message` only — never in stderrExcerpt,
|
|
35
|
+
# where a kernel's real "Operation not permitted" is legitimate content.
|
|
36
|
+
# Deliberately excludes bare "blocked"/"blocks": everyday English that would
|
|
37
|
+
# reject honest records like "test blocked on upstream dependency".
|
|
38
|
+
_BLOCKING_CLAIM_TERMS = (
|
|
39
|
+
"sandbox", "not permitted", "permission denied", "eperm",
|
|
40
|
+
)
|
|
41
|
+
# The backstop targets *unclassified* blocking claims. A worker that declared
|
|
42
|
+
# a specific cause has already done the honest work — `auth-failed` legitimately
|
|
43
|
+
# reads "permission denied" (MySQL 1045).
|
|
44
|
+
_UNCLASSIFIED_CAUSES = (None, "unknown")
|
|
45
|
+
|
|
26
46
|
|
|
27
47
|
def _now_utc():
|
|
28
48
|
return dt.datetime.now(dt.timezone.utc)
|
|
@@ -32,24 +52,80 @@ def _iso(t):
|
|
|
32
52
|
return t.isoformat()
|
|
33
53
|
|
|
34
54
|
|
|
35
|
-
def
|
|
36
|
-
"""Truncate
|
|
55
|
+
def _truncate_utf8(s, limit):
|
|
56
|
+
"""Truncate to `limit` bytes without splitting a multibyte character."""
|
|
37
57
|
if s is None:
|
|
38
58
|
return None
|
|
39
59
|
encoded = s.encode("utf-8")
|
|
40
|
-
if len(encoded) <=
|
|
60
|
+
if len(encoded) <= limit:
|
|
41
61
|
return s
|
|
42
|
-
cut = encoded[:
|
|
43
|
-
# 멀티바이트 경계 보호: 디코드 가능해질 때까지 끝 바이트 제거
|
|
62
|
+
cut = encoded[:limit]
|
|
44
63
|
while cut:
|
|
45
64
|
try:
|
|
46
|
-
|
|
47
|
-
return decoded + TRUNCATION_SUFFIX
|
|
65
|
+
return cut.decode("utf-8") + TRUNCATION_SUFFIX
|
|
48
66
|
except UnicodeDecodeError:
|
|
49
67
|
cut = cut[:-1]
|
|
50
68
|
return TRUNCATION_SUFFIX
|
|
51
69
|
|
|
52
70
|
|
|
71
|
+
def truncate_stderr(s):
|
|
72
|
+
"""Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
|
|
73
|
+
return _truncate_utf8(s, STDERR_EXCERPT_MAX_BYTES)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_cause_context(context, *, message):
|
|
77
|
+
"""Validate a record's cause claim and return the normalized context.
|
|
78
|
+
|
|
79
|
+
Raises ValueError on three conditions:
|
|
80
|
+
- `cause` is set to a value outside ALLOWED_CAUSES;
|
|
81
|
+
- `cause` is 'sandbox-denied' but the two probes are missing or blank —
|
|
82
|
+
those probes are what distinguish a real denial from an unreachable
|
|
83
|
+
or auth-gated target, the misdiagnosis this gate exists to stop;
|
|
84
|
+
- `message` asserts a block in prose while the record left its cause
|
|
85
|
+
unclassified, which would smuggle the same claim past the gate.
|
|
86
|
+
"""
|
|
87
|
+
cause = context.get("cause") if isinstance(context, dict) else None
|
|
88
|
+
|
|
89
|
+
if cause is not None and cause not in ALLOWED_CAUSES:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"invalid cause: {cause!r} (allowed: {sorted(ALLOWED_CAUSES)})"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if cause == "sandbox-denied":
|
|
95
|
+
evidence = context.get("causeEvidence")
|
|
96
|
+
if not isinstance(evidence, dict):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"cause 'sandbox-denied' requires context.causeEvidence with "
|
|
99
|
+
f"{list(CAUSE_EVIDENCE_FIELDS)}"
|
|
100
|
+
)
|
|
101
|
+
normalized_evidence = {}
|
|
102
|
+
for field in CAUSE_EVIDENCE_FIELDS:
|
|
103
|
+
value = evidence.get(field)
|
|
104
|
+
if not isinstance(value, str) or not value.strip():
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"cause 'sandbox-denied' requires a non-empty "
|
|
107
|
+
f"context.causeEvidence.{field}: record the command and "
|
|
108
|
+
f"its raw output that proves the claim"
|
|
109
|
+
)
|
|
110
|
+
normalized_evidence[field] = _truncate_utf8(
|
|
111
|
+
value, CAUSE_PROBE_MAX_BYTES
|
|
112
|
+
)
|
|
113
|
+
return {**context, "causeEvidence": normalized_evidence}
|
|
114
|
+
|
|
115
|
+
if message and cause in _UNCLASSIFIED_CAUSES:
|
|
116
|
+
lowered = message.lower()
|
|
117
|
+
hit = next((t for t in _BLOCKING_CLAIM_TERMS if t in lowered), None)
|
|
118
|
+
if hit:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"message asserts a blocking claim ({hit!r}) without "
|
|
121
|
+
"context.cause='sandbox-denied' + context.causeEvidence. "
|
|
122
|
+
"Either attach the two probes, or state the cause you "
|
|
123
|
+
"actually verified."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return context
|
|
127
|
+
|
|
128
|
+
|
|
53
129
|
def append_jsonl_line(path, record):
|
|
54
130
|
"""Append a single JSON record as one line to ``path``.
|
|
55
131
|
|
|
@@ -119,6 +195,9 @@ def append_observed(
|
|
|
119
195
|
raise ValueError(f"invalid agent: {agent!r}")
|
|
120
196
|
if agent_role not in ALLOWED_AGENT_ROLES:
|
|
121
197
|
raise ValueError(f"invalid agentRole: {agent_role!r}")
|
|
198
|
+
# Runs before append_jsonl_line so a rejected claim leaves no trace in the
|
|
199
|
+
# log: a written-then-flagged record is still a record someone can cite.
|
|
200
|
+
context = normalize_cause_context(context, message=message)
|
|
122
201
|
ts = _iso(now or _now_utc())
|
|
123
202
|
rec = {
|
|
124
203
|
"ts": ts,
|
|
@@ -159,6 +238,7 @@ def dump_from_worker_sidecar(
|
|
|
159
238
|
- ``agent`` or ``agent_role`` is not in the allow-lists
|
|
160
239
|
- sidecar ``schemaVersion`` is not in ``SUPPORTED_SIDECAR_SCHEMA_VERSIONS``
|
|
161
240
|
- any entry's ``errorType`` is not in ``ALLOWED_ERROR_TYPES``
|
|
241
|
+
- any entry asserts a blocking cause without its required evidence
|
|
162
242
|
|
|
163
243
|
Returns 0 (no-op) if the sidecar file does not exist or its
|
|
164
244
|
``errors`` list is empty.
|
|
@@ -187,6 +267,9 @@ def dump_from_worker_sidecar(
|
|
|
187
267
|
et = e.get("errorType")
|
|
188
268
|
if et not in ALLOWED_ERROR_TYPES:
|
|
189
269
|
raise ValueError(f"invalid errorType in sidecar: {et!r}")
|
|
270
|
+
entry_context = normalize_cause_context(
|
|
271
|
+
e.get("context"), message=e.get("message")
|
|
272
|
+
)
|
|
190
273
|
rec = {
|
|
191
274
|
"ts": e.get("ts"),
|
|
192
275
|
"recordedAt": recorded_at,
|
|
@@ -203,7 +286,7 @@ def dump_from_worker_sidecar(
|
|
|
203
286
|
"durationMs": e.get("durationMs"),
|
|
204
287
|
"message": e.get("message"),
|
|
205
288
|
"stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
|
|
206
|
-
"context":
|
|
289
|
+
"context": entry_context,
|
|
207
290
|
}
|
|
208
291
|
append_jsonl_line(out_path, rec)
|
|
209
292
|
count += 1
|
|
@@ -271,6 +271,8 @@ okstra error-log append-observed \
|
|
|
271
271
|
--stderr-excerpt "<last stderr lines, or use --stderr-excerpt-file>"
|
|
272
272
|
```
|
|
273
273
|
|
|
274
|
+
Keep `--message` to the error actually observed — asserting that a sandbox or permission boundary blocked the call requires `--context-json` carrying `cause` plus both `causeEvidence` probes, and an unevidenced block claim in `--message` is rejected. If an `append-from-worker` dump is rejected for that reason, correct the offending sidecar entry and re-run the dump instead of skipping it: the dump aborts at the rejected entry, so every later entry in that sidecar never reaches the run log.
|
|
275
|
+
|
|
274
276
|
The wrapper subagent records this through its selected adapter — Lead does NOT need to re-record. Token usage is not inferred from dispatch return values; call `collect_usage` at the start of Phase 7.
|
|
275
277
|
|
|
276
278
|
## Phase 5.5: Convergence loop
|
|
@@ -120,7 +120,15 @@
|
|
|
120
120
|
- validation checklist (pre / mid / post) — each item is an exact command or observable outcome
|
|
121
121
|
- rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
|
|
122
122
|
- **Requirement admissibility (scope boundary):** a brief line becomes a Requirement Coverage row only when **a stage can satisfy it by changing files in this repository** — source, tests, config, or deployment *manifest files*. A line whose satisfaction needs a person's approval, a ticket status change, or an action against live infrastructure (applying a manifest, a cutover, creating a dashboard or alert, validating in staging/production) is NOT a requirement for this phase: it belongs to the brief's `## External Gates`, and this plan neither creates a stage for it nor cites it in coverage. Briefs generated by `okstra-brief-gen` pre-split these into `## Acceptance Criteria` (admissible) and `## External Gates` (not); when reading an older brief that carries a raw Definition-of-Done checklist, apply the same test line by line. The boundary is the *action*, not the topic — "add the flag to `values-prod.yaml`" is admissible, "apply that manifest to prod" is not. Planning an operational stage this phase cannot execute (see the run-scope rule above forbidding deployments) produces steps whose commands never resolve, which the §5.5.9 gate then correctly blocks — the plan must not create that deadlock in the first place.
|
|
123
|
-
- **
|
|
123
|
+
- **Scope provenance (BLOCKING):** every Requirement Coverage row's `Source` cell must be exactly one of three forms, and every Stage Map stage must be cited by at least one coverage row's `Covered by`. The forms:
|
|
124
|
+
- `brief:<heading>` — a heading that literally exists in the task brief. A heading the brief does not contain is a fabricated requirement.
|
|
125
|
+
- `derived:R-NNN — <one-line rationale>` — derived from another row in the same table. The chain must terminate at a `brief:` or `contract:` row and must not cycle. Use this for genuine technical consequences: a migration stage is `derived:R-003 — R-003 cannot be satisfied without a schema change`.
|
|
126
|
+
- `contract:<rule>` — an artifact okstra's own phase contract mandates, so it has no brief line to cite. The allowlist is exactly `decision-record-step` (the §5.4 Decision Drafts materialization step) and `glossary-step` (the glossary proposal step); the SSOT is `scripts/okstra_ctl/scope_provenance.py`. Never widen this form to launder work the brief did not ask for.
|
|
127
|
+
An item you can give none of these three sources to is **not a requirement and not a stage**. Its only admissible outlet is a `## 1. Clarification Items` row with `Blocks=approval`, carrying the recommendation format from `_clarification-recommendation.md`. Do not fold it into an option, a stage, or a step "while we are in here" — that is the scope expansion this rule exists to stop. This makes concrete the planning-input rule that any change beyond what `Requirement Summary` explicitly demands is out of scope by default. **Enforced:** `validators/validate-run.py` `_validate_requirement_provenance` (source resolution) and `_validate_stage_has_requirement` (no stage without a requirement).
|
|
128
|
+
- **The reach of this gate — do not over-trust it.** What is mechanically enforced is the *form* of each source, that a `brief:` heading literally exists, that a `derived:` chain terminates without cycling, and that no stage is uncited. What is **not** enforced is whether the cited source genuinely demands the requirement. Every brief carries the same generic headings (`## Acceptance Criteria`, `## Context`, …), so attaching invented work to a real heading still parses clean. The gate's value is that it forces every item to name an origin and makes fabrication explicit and auditable — judging whether that origin actually demands the item remains a reviewer / `DISAGREE(f)` responsibility, and passing this gate is never evidence that the scope is justified.
|
|
129
|
+
- **Stage citation format:** the reverse check reads each `Covered by` cell as prose, so a stage counts as cited only when its number is anchored to a `Stage` / `Stages` word on the same line. These all read: `Stage 2`; `Stage 1, Stage 2, Stage 3`; `Stages 1, 2, 3`; `Stages 1, 2, and 3`; ranges (`Stages 1-3`, `Stages 1 to 3`, `Stages 1 through 3`); and `and` / `&` conjunctions. A bare number with no `stage` word anchoring it is NOT read as a citation, so `covered by the recommended option, step 4` cites nothing. **Enforced:** `validators/validate-run.py` `_validate_stage_has_requirement` fails the plan when any Stage Map stage is cited by no coverage row.
|
|
130
|
+
- Because that reader only sees prose, it cannot tell a citation from a mention: `Stage 1 (superseded by Stage 2)` still counts Stage 1 as cited, and one row citing `Stages 1-64` rubber-stamps every stage in the map. Cite the stages a requirement is actually satisfied by — not stages merely mentioned, and never a blanket range standing in for the work of checking.
|
|
131
|
+
- **Requirement Coverage (mandatory, §5.5.8):** one row per concrete requirement from the task brief / packet. Assign stable IDs `R-001`, `R-002`, ... in source order. Columns: `ID | Source | Requirement | Covered by option / stage / step | Status`. `Source` follows the three-form grammar defined in the **Scope provenance** rule above — a free-form `file:line` is not one of the three forms and is rejected. When this run's brief is a fan-out packet, the task manifest's `taskBriefPath` points at that packet file, so `brief:` cites the packet's own headings (`## Scope`, `## Evidence`, `## Requirement Provenance`) — not the headings of the upstream user brief the packet came from. `Covered by` must name the specific Option Candidate and Stage/Step that satisfies it, not just "recommended option". **Enforced:** `validators/validate-run.py` `_validate_requirement_coverage_covered_by` fails a `covered` row whose `coveredBy` is bare "recommended option", names no Option/Stage/Step anchor, or cites a Stage number absent from the Stage Map (whether the cited step *actually satisfies* the requirement remains a worker `DISAGREE(f)` judgment). `Status` is one of `covered`, `gap`, or `blocked C-NNN`. If any row is `gap` or `blocked C-NNN`, the Plan Body Verification gate MUST NOT be `passed` / `passed-with-dissent`; add a matching `Blocks=approval` row for the blocker and keep `approved: false`.
|
|
124
132
|
- **Review-rule compliance plan:** when a project-local review rule pack is found, each Option Candidate MUST include the design implication of those rules in its File Structure / interfaces / blast-radius notes. For any helper or data transform used by more than one changed service, the plan must either place it in a shared module or explicitly justify why duplication is intentional. For any test step, the plan must state the observable behavior being asserted, not the internal collaborator call being pinned. For any exported/public method added or renamed, the step must carry the intended noun/side-effect semantics so implementation names can be reviewed before code is written.
|
|
125
133
|
- the YAML frontmatter MUST include the line `approved: false` (report-writer always emits the unflipped value). The user authorises the next `implementation` run by flipping it to `approved: true` (manual edit or `--approve` CLI). Do NOT recreate any `User Approval Request` body block — the validator fails reports that contain one (see `validators/validate-run.py` deprecated patterns).
|
|
126
134
|
- the YAML frontmatter MUST include the line `implementation-option:` directly under `approved:` (report-writer always emits it with an **empty value**). The user selects which Option Candidate the next `implementation` run executes by filling this line with that option's name (manual edit or `--implementation-option <name>` CLI). When left empty, the `implementation` run falls back to the `Recommended Option`.
|
|
@@ -144,7 +152,7 @@
|
|
|
144
152
|
2. **Placeholder scan** — search the report for the patterns in the No-placeholder rule above and fix inline.
|
|
145
153
|
3. **Internal consistency** — option file lists, trade-off matrix, and recommended step list must agree on file paths, names, and signatures. A symbol called `clearLayers()` in the matrix and `clearFullLayers()` in the steps is a bug.
|
|
146
154
|
4. **Ambiguity check** — any requirement that could be read two ways must be made explicit or moved to the `## 1. Clarification Items` table as a `Blocks=approval` row.
|
|
147
|
-
5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan.
|
|
155
|
+
5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan. Then walk the plan in the expansion direction: for every stage, name the Requirement Coverage row that demanded it, and for every requirement row, read its `Source` cell as a skeptic — does the cited brief heading actually exist, and does a `derived:` rationale state a real technical consequence rather than a preference? Move anything that fails to a `Blocks=approval` clarification row.
|
|
148
156
|
6. **Review-rule preflight check** — if a project review rule pack exists, map each relevant rule to the recommended option. Reject the draft if it knowingly creates a violation that the later PR reviewer would flag, unless the plan records a specific rationale and follow-up. In particular, scan for repeated helper stacks across planned files, tests that assert delegation to the same calculator/helper they exercise, public names that hide side effects, domain rules placed in repositories/adapters, and APIs made dead by this change.
|
|
149
157
|
7. **Plan-body verification reconciliation (BLOCKING for implementation-planning).** For every §5.5.9 `planItems[]` entry whose verdicts make it `majority-disagree`, set that item's `clarificationId` to a `C-<N>` row that MUST exist in `## 1. Clarification Items` with `Kind` chosen per the standard policy and `Blocks=approval`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item has no `clarificationId`, or its `clarificationId` is dangling / points at a non-`approval` row. For `partial-consensus` and `dissent-isolated` plan-items, the dissenting opinion lives in §5.5.9 `Dissent log` and is NOT promoted to §5.
|
|
150
158
|
8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 8. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
`domain`(work-category 5-enum: bugfix / feature / refactor / ops / improvement),
|
|
36
36
|
`depends-on`(an inline list of unit-ids within the same fan-out `[unit-001]`, or `[]` if none),
|
|
37
37
|
`recommended-next-phase`(error-analysis | implementation-planning) are filled in.
|
|
38
|
+
Each packet MUST carry a non-empty `## Requirement Provenance` section whose every bullet is `brief:<heading>` (a heading that literally exists in the task brief) or `contract:<rule>`. `derived:` is not admissible in a packet — cross-packet derivation cannot be resolved from a single packet, so each unit anchors directly on the brief. A unit you cannot source that way is not a work item: raise it as a clarification row instead of publishing a packet for it. **Enforced:** `validators/validate_fanout.py` `_check_provenance`.
|
|
38
39
|
- in `runs/requirements-discovery/fan-out/index.md`, list the packets in depends-on topological order
|
|
39
40
|
as a numbered list (`1. unit-001`) (a generated view; explicitly do not hand-edit). The depends-on graph
|
|
40
41
|
must be a DAG — `validate_fanout` rejects a cycle as a validation failure, so if a cycle appears,
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
- if any blocking input is missing at the time of writing the final report, populate `## 1. Clarification Items` in `final-report-template.md` (a single unified table; `Blocks=next-phase` for items the next run cannot start without)
|
|
62
63
|
- prefer concrete questions whose answers map directly to a routing decision (`bugfix` vs `feature`, `error-analysis` vs `implementation-planning`, etc.). State each option in plain language with one sentence describing what choosing it would mean for the next phase.
|
|
63
64
|
{{INCLUDE:_clarification-recommendation.md}}
|
|
64
|
-
- **Codebase-first ambiguity resolution (defect rule)**: any ambiguity that can be answered by `Read` / `Grep` / file inspection MUST be resolved that way and recorded with file:line evidence. Writing a clarification row for something the codebase already answers is a defect of this phase.
|
|
65
|
+
- **Codebase-first ambiguity resolution (defect rule)**: any ambiguity that can be answered by `Read` / `Grep` / file inspection MUST be resolved that way and recorded with file:line evidence. Writing a clarification row for something the codebase already answers is a defect of this phase. **Boundary — facts only, never intent:** this rule governs questions of *fact* ("what does this code do", "where is this called", "does this field exist"). It never governs questions of *intent* ("should we do this at all", "is this in scope", "which outcome does the reporter want"). Scope and intent are not the kind of question a codebase can answer, so inspecting files never discharges them — raise them as clarification rows. Resolving a scope question by inference and building on that inference is the mirror defect, and a more expensive one.
|
|
65
66
|
- **Evidence note required inside `Statement`**: every clarification row includes `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>` in the `Statement` cell. `none` is allowed ONLY when the row's nature is "only a human can answer this" (reporter intent, business priority, external authority). A row with `none` that *could* have been answered by the codebase is a defect.
|
|
66
67
|
- Cross-verification mode:
|
|
67
68
|
- Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker's finding by directly re-inspecting the cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode". A single evidence-backed refutation prevents a finding from reaching consensus.
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
producer/consumer of final-report (and schedule) tables.
|
|
3
3
|
|
|
4
4
|
GFM treats every unescaped ``|`` inside a cell as a column boundary — even
|
|
5
|
-
inside inline-code spans — and renders ``\\|`` back as a literal ``|``.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
with ``
|
|
5
|
+
inside inline-code spans — and renders ``\\|`` back as a literal ``|``. A row
|
|
6
|
+
is also line-terminated, so a newline inside a value truncates the row at that
|
|
7
|
+
point and drops every column after it. Producers therefore normalise cell text
|
|
8
|
+
with ``to_cell_text`` (exposed as the ``mdcell`` Jinja filter in
|
|
9
|
+
``render_final_report``), and consumers split rows with ``split_pipe_row``,
|
|
10
|
+
which honours the same ``\\|`` convention.
|
|
9
11
|
"""
|
|
10
12
|
from __future__ import annotations
|
|
11
13
|
|
|
@@ -17,17 +19,24 @@ UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
|
17
19
|
|
|
18
20
|
_SEPARATOR_CELL_RE = re.compile(r"\s*:?-+:?\s*")
|
|
19
21
|
|
|
22
|
+
_NEWLINE_RE = re.compile(r"\r\n|\r|\n")
|
|
20
23
|
|
|
21
|
-
def escape_pipes(value: Any) -> str:
|
|
22
|
-
"""Escape literal ``|`` as ``\\|`` for use inside a markdown table cell.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
def to_cell_text(value: Any) -> str:
|
|
26
|
+
"""Render a value as one markdown table cell: no newlines, no bare ``|``.
|
|
27
|
+
|
|
28
|
+
Newlines fold to ``<br>`` (a blank line becomes ``<br><br>``, keeping the
|
|
29
|
+
paragraph break visible) and literal ``|`` is escaped as ``\\|``.
|
|
30
|
+
|
|
31
|
+
Idempotent: pipes that are already escaped are left alone and the folded
|
|
32
|
+
``<br>`` carries no newline, so re-rendering (Phase 7) never stacks
|
|
33
|
+
backslashes or breaks. ``None`` renders as the empty string — the same
|
|
34
|
+
contract as the template's ``or ''`` fallbacks.
|
|
27
35
|
"""
|
|
28
36
|
if value is None:
|
|
29
37
|
return ""
|
|
30
|
-
|
|
38
|
+
folded = _NEWLINE_RE.sub("<br>", str(value).strip())
|
|
39
|
+
return UNESCAPED_PIPE_RE.sub(r"\\|", folded)
|
|
31
40
|
|
|
32
41
|
|
|
33
42
|
def split_pipe_row(line: str) -> list[str]:
|
|
@@ -50,7 +50,7 @@ from jinja2 import ChainableUndefined, Environment, FileSystemLoader
|
|
|
50
50
|
|
|
51
51
|
from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
|
-
from okstra_ctl.md_table import UNESCAPED_PIPE_RE,
|
|
53
|
+
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
|
|
54
54
|
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
55
55
|
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
56
56
|
from okstra_ctl.seeding import installed_version
|
|
@@ -437,10 +437,12 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
437
437
|
env.filters["yaml_scalar"] = _yaml_scalar
|
|
438
438
|
env.filters["yaml_inline_list"] = _yaml_inline_list
|
|
439
439
|
env.filters["model_detail"] = _model_detail
|
|
440
|
-
# `mdcell`
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
|
|
440
|
+
# `mdcell` neutralises the two things in worker prose that can break a
|
|
441
|
+
# markdown table row: a literal `|` (splits the row) and a newline
|
|
442
|
+
# (truncates it, dropping every later column). Table-cell interpolations
|
|
443
|
+
# only — never code blocks / headings / prose, where `\|` and `<br>` would
|
|
444
|
+
# render verbatim.
|
|
445
|
+
env.filters["mdcell"] = to_cell_text
|
|
444
446
|
# `mdquote` re-marks continuation lines of a blockquote nested in a list
|
|
445
447
|
# item; the fenced siblings use Jinja's builtin `indent` for the same
|
|
446
448
|
# reason. Both exist because only the first line of an interpolation
|
|
@@ -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
|
|
@@ -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}")
|