okstra 0.139.0 → 0.141.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/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +4 -2
- package/runtime/prompts/coding-preflight/clean-code.md +55 -0
- package/runtime/prompts/coding-preflight/overview.md +3 -2
- package/runtime/prompts/launch.template.md +7 -5
- package/runtime/prompts/lead/convergence.md +22 -0
- package/runtime/prompts/lead/plan-body-verification.md +45 -6
- package/runtime/prompts/lead/report-writer.md +2 -0
- package/runtime/prompts/profiles/_implementation-diff-review.md +3 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +6 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -1
- package/runtime/prompts/profiles/requirements-discovery.md +2 -1
- package/runtime/python/okstra_ctl/build_tools.py +95 -0
- package/runtime/python/okstra_ctl/incremental_scope.py +95 -0
- package/runtime/python/okstra_ctl/render.py +26 -11
- package/runtime/python/okstra_ctl/render_final_report.py +24 -1
- package/runtime/python/okstra_ctl/rollup.py +49 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +98 -1
- package/runtime/python/okstra_ctl/stage_citations.py +47 -0
- package/runtime/python/okstra_ctl/workflow.py +7 -4
- package/runtime/schemas/final-report-v1.0.schema.json +60 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +38 -11
- package/runtime/skills/okstra-code-review/SKILL.md +1 -1
- package/runtime/skills/okstra-rollup/SKILL.md +3 -2
- package/runtime/templates/reports/brief.template.md +42 -11
- package/runtime/templates/reports/fan-out-unit.template.md +9 -4
- package/runtime/templates/reports/final-report.template.md +20 -0
- package/runtime/templates/reports/final-verification-input.template.md +4 -2
- package/runtime/templates/reports/i18n/en.json +11 -4
- package/runtime/templates/reports/i18n/ko.json +11 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +2 -2
- package/runtime/validators/validate-brief.py +171 -26
- package/runtime/validators/validate-run.py +450 -78
- package/runtime/validators/validate_fanout.py +19 -10
- package/runtime/validators/validate_improvement_report.py +17 -4
|
@@ -35,7 +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
|
|
38
|
+
Each packet MUST carry a non-empty `## Requirement Provenance` section whose every bullet is `brief:EB-001` / `brief:PB-001` / `brief:EO-001` — an end-state id the brief declares — or `contract:<rule>`. When the brief pins ids, citing a heading is rejected: every brief carries the same generic headings, so a heading cannot say WHICH reporter line demanded this unit, and a fan-out unit becomes the brief for a whole downstream task. Only a brief authored before the end-state sections existed still takes the older `brief:<heading>` form, and there the heading must literally exist in it. `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`.
|
|
39
39
|
- in `runs/requirements-discovery/fan-out/index.md`, list the packets in depends-on topological order
|
|
40
40
|
as a numbered list (`1. unit-001`) (a generated view; explicitly do not hand-edit). The depends-on graph
|
|
41
41
|
must be a DAG — `validate_fanout` rejects a cycle as a validation failure, so if a cycle appears,
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
- uncertainty boundaries and missing inputs
|
|
57
57
|
- next recommended phase and safe resume guidance
|
|
58
58
|
- canonical-term resolution for every `terminology:*` brief item as `<term> = <definition>`, plus whether `<PROJECT_ROOT>/.okstra/glossary.md` should be updated
|
|
59
|
+
- one `endStateCoverage` row per brief end-state id (`EB-NNN` / `PB-NNN` / `EO-NNN`). `coveredBy` names the routing decision, the fan-out unit id, or the `C-NNN` clarification that accounts for it. This phase does NOT author a goal of its own — the brief already pinned the end state, and restating it here is how four phases end up with four different targets. **Enforced:** `validators/validate-run.py` `_validate_end_state_coverage`.
|
|
59
60
|
- Report assembly instructions:
|
|
60
61
|
- write canonical-term resolutions in a new `Domain Alignment` subsection of the final report; actual glossary writes happen via `okstra-brief-gen` Step 4.5 on a subsequent run
|
|
61
62
|
- Clarification request policy (phase-specific addenda — shared policy is in `_common-contract.md`):
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Decide whether a plan's command cell invokes the project's build toolchain.
|
|
2
|
+
|
|
3
|
+
The planning worktree has no dependencies installed, so a stage that calls the
|
|
4
|
+
toolchain needs its precondition declared once and referenced (see the
|
|
5
|
+
plan-body contract's "Planning-time environment gap"). This module owns the
|
|
6
|
+
set of commands that counts as such a call.
|
|
7
|
+
|
|
8
|
+
An allowlist, not a denylist: a tool nobody listed goes undetected, which
|
|
9
|
+
weakens one advisory check, whereas treating every unknown leading token as a
|
|
10
|
+
build call would fire on `grep` and `sed` in every plan and train readers to
|
|
11
|
+
ignore the warning.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shlex
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
DEFAULT_BUILD_TOOL_TOKENS: tuple[str, ...] = (
|
|
20
|
+
"npm",
|
|
21
|
+
"yarn",
|
|
22
|
+
"pnpm",
|
|
23
|
+
"npx",
|
|
24
|
+
"bun",
|
|
25
|
+
"pytest",
|
|
26
|
+
"tox",
|
|
27
|
+
"poetry",
|
|
28
|
+
"uv",
|
|
29
|
+
"cargo",
|
|
30
|
+
"go",
|
|
31
|
+
"gradle",
|
|
32
|
+
"mvn",
|
|
33
|
+
"make",
|
|
34
|
+
"bundle",
|
|
35
|
+
"composer",
|
|
36
|
+
"dotnet",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Prefixes that stand in front of the real command without being it.
|
|
40
|
+
_TRANSPARENT_LEADERS = frozenset({"sudo", "env", "time", "nice", "exec", "command"})
|
|
41
|
+
_CLAUSE_SEPARATORS = ("&&", "||", ";", "|", "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_build_tool_tokens(project_root: Path) -> tuple[str, ...]:
|
|
45
|
+
"""Project override, else the built-in list.
|
|
46
|
+
|
|
47
|
+
`buildToolTokens` in `.okstra/project.json` REPLACES the defaults and an
|
|
48
|
+
empty array disables detection — the same precedence `worktreeSyncDirs`
|
|
49
|
+
uses, so one rule covers both.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(
|
|
53
|
+
(Path(project_root) / ".okstra" / "project.json").read_text(encoding="utf-8")
|
|
54
|
+
)
|
|
55
|
+
except (OSError, json.JSONDecodeError):
|
|
56
|
+
return DEFAULT_BUILD_TOOL_TOKENS
|
|
57
|
+
configured = payload.get("buildToolTokens") if isinstance(payload, dict) else None
|
|
58
|
+
if not isinstance(configured, list):
|
|
59
|
+
return DEFAULT_BUILD_TOOL_TOKENS
|
|
60
|
+
return tuple(str(token).strip() for token in configured if str(token).strip())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _clauses(command: str) -> list[str]:
|
|
64
|
+
parts = [command]
|
|
65
|
+
for separator in _CLAUSE_SEPARATORS:
|
|
66
|
+
parts = [piece for part in parts for piece in part.split(separator)]
|
|
67
|
+
return [part.strip() for part in parts if part.strip()]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _leading_token(clause: str) -> str:
|
|
71
|
+
try:
|
|
72
|
+
words = shlex.split(clause)
|
|
73
|
+
except ValueError:
|
|
74
|
+
words = clause.split()
|
|
75
|
+
for word in words:
|
|
76
|
+
# `cd <dir>` and `VAR=value` lead a clause without being the command;
|
|
77
|
+
# step past them rather than reading the directory as the tool.
|
|
78
|
+
if word == "cd" or "=" in word.split("/")[0] and not word.startswith("-"):
|
|
79
|
+
continue
|
|
80
|
+
if word in _TRANSPARENT_LEADERS:
|
|
81
|
+
continue
|
|
82
|
+
if word.startswith("-"):
|
|
83
|
+
continue
|
|
84
|
+
return Path(word).name
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def command_invokes_build_tool(
|
|
89
|
+
command: str, *, tokens: tuple[str, ...] | None = None
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""True when any clause of *command* starts with an allowlisted tool."""
|
|
92
|
+
allowed = DEFAULT_BUILD_TOOL_TOKENS if tokens is None else tokens
|
|
93
|
+
if not allowed:
|
|
94
|
+
return False
|
|
95
|
+
return any(_leading_token(clause) in allowed for clause in _clauses(command))
|
|
@@ -10,14 +10,28 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import json
|
|
13
|
+
import re
|
|
13
14
|
import sys
|
|
14
15
|
from dataclasses import asdict, dataclass
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
18
|
+
from okstra_ctl.stage_citations import cited_stage_numbers
|
|
17
19
|
from okstra_ctl.stage_targets import downstream_stage_closure
|
|
18
20
|
|
|
19
21
|
CUTOFF_RATIO = 0.5
|
|
20
22
|
|
|
23
|
+
# Prefix that marks a full decision the lead *chose* rather than fell back to.
|
|
24
|
+
# Both cases return `mode: full`, and without this the two are one string —
|
|
25
|
+
# which is what made a re-run's "no impacted stages resolved" unreadable as
|
|
26
|
+
# either a structural judgement or a mapping the lead never made.
|
|
27
|
+
DECLARED_FULL_PREFIX = "declared structural change:"
|
|
28
|
+
|
|
29
|
+
# `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
|
|
30
|
+
# id itself. Every other prefix (`P-Opt`, `P-Dep`, `P-Val`, `P-Rb`, `P-Req`) is
|
|
31
|
+
# numbered by position in its own array, so its stage is only recoverable from
|
|
32
|
+
# the prose the planner wrote.
|
|
33
|
+
_STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")
|
|
34
|
+
|
|
21
35
|
|
|
22
36
|
@dataclass
|
|
23
37
|
class IncrementalDecision:
|
|
@@ -73,6 +87,60 @@ def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
|
|
|
73
87
|
return impacted
|
|
74
88
|
|
|
75
89
|
|
|
90
|
+
def _plan_item_stages(item: dict) -> set[int]:
|
|
91
|
+
structural = _STRUCTURAL_STAGE_IN_ID_RE.match(str(item.get("id") or ""))
|
|
92
|
+
if structural:
|
|
93
|
+
return {int(structural.group(1) or structural.group(2))}
|
|
94
|
+
return cited_stage_numbers(str(item.get("subject") or ""))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
|
|
98
|
+
blocked = f"blocked {clarification_id}"
|
|
99
|
+
stages: set[int] = set()
|
|
100
|
+
for row in coverage if isinstance(coverage, list) else []:
|
|
101
|
+
if not isinstance(row, dict):
|
|
102
|
+
continue
|
|
103
|
+
if str(row.get("status") or "").strip() != blocked:
|
|
104
|
+
continue
|
|
105
|
+
stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
|
|
106
|
+
return stages
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def clarification_impacted_stages(
|
|
110
|
+
data: dict, clarification_ids: set[str]
|
|
111
|
+
) -> set[int]:
|
|
112
|
+
"""Stage Map stages the answered clarifications touch, read off the prior run.
|
|
113
|
+
|
|
114
|
+
Two links the prior report already recorded: the `P-*` plan item that blocked
|
|
115
|
+
on the clarification, and the requirement-coverage row blocked on the same
|
|
116
|
+
id. Resolving them here removes the discretionary mapping step that made the
|
|
117
|
+
lead fall back to full even for an answer that changed nothing.
|
|
118
|
+
|
|
119
|
+
Raises when any id resolves to no stage — a partially-resolved set would
|
|
120
|
+
narrow the re-run past an answer whose blast radius nobody established.
|
|
121
|
+
"""
|
|
122
|
+
planning = data.get("implementationPlanning")
|
|
123
|
+
if not isinstance(planning, dict):
|
|
124
|
+
raise ValueError("implementationPlanning is missing")
|
|
125
|
+
verification = planning.get("planBodyVerification")
|
|
126
|
+
plan_items = (verification or {}).get("planItems") if isinstance(verification, dict) else []
|
|
127
|
+
coverage = planning.get("requirementCoverage")
|
|
128
|
+
|
|
129
|
+
impacted: set[int] = set()
|
|
130
|
+
for clarification_id in sorted(clarification_ids):
|
|
131
|
+
stages = _stages_blocked_on(coverage, clarification_id)
|
|
132
|
+
for item in plan_items if isinstance(plan_items, list) else []:
|
|
133
|
+
if isinstance(item, dict) and item.get("clarificationId") == clarification_id:
|
|
134
|
+
stages |= _plan_item_stages(item)
|
|
135
|
+
if not stages:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"answered clarification {clarification_id} traces to no stage "
|
|
138
|
+
"(no plan item or coverage row cites one)"
|
|
139
|
+
)
|
|
140
|
+
impacted |= stages
|
|
141
|
+
return impacted
|
|
142
|
+
|
|
143
|
+
|
|
76
144
|
def decide_scope(
|
|
77
145
|
*,
|
|
78
146
|
stages: list[tuple[int, list[int]]],
|
|
@@ -107,14 +175,41 @@ def main(argv: list[str]) -> int:
|
|
|
107
175
|
ap.add_argument("--prev-base-sha", required=True)
|
|
108
176
|
ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
|
|
109
177
|
ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
|
|
178
|
+
ap.add_argument(
|
|
179
|
+
"--answered-clarifications",
|
|
180
|
+
default="",
|
|
181
|
+
help="comma-separated C-NNN ids answered since the prior run; their "
|
|
182
|
+
"stages are resolved from that run's plan-item and coverage links",
|
|
183
|
+
)
|
|
184
|
+
ap.add_argument(
|
|
185
|
+
"--full-reason",
|
|
186
|
+
default="",
|
|
187
|
+
help="declare that an answer changes the selected option, Stage Map, or "
|
|
188
|
+
"recommended approach. Forces full and records the judgement, so it "
|
|
189
|
+
"is distinguishable from a re-run that simply resolved no stage",
|
|
190
|
+
)
|
|
110
191
|
args = ap.parse_args(argv)
|
|
111
192
|
|
|
193
|
+
declared = args.full_reason.strip()
|
|
194
|
+
if declared:
|
|
195
|
+
# The lead's structural judgement is exactly what the back-trace cannot
|
|
196
|
+
# make, so a successful trace must not override it.
|
|
197
|
+
print(json.dumps(asdict(IncrementalDecision(
|
|
198
|
+
"full", [], [], f"{DECLARED_FULL_PREFIX} {declared}",
|
|
199
|
+
)), ensure_ascii=False))
|
|
200
|
+
return 0
|
|
201
|
+
|
|
112
202
|
try:
|
|
113
203
|
data = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
|
|
114
204
|
stages = parse_stage_graph(data)
|
|
115
205
|
impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
|
|
116
206
|
prep_ids = {t.strip() for t in args.prep_items.split(",") if t.strip()}
|
|
117
207
|
impacted.update(design_prep_impacted_stages(data, prep_ids))
|
|
208
|
+
answered = {
|
|
209
|
+
t.strip() for t in args.answered_clarifications.split(",") if t.strip()
|
|
210
|
+
}
|
|
211
|
+
if answered:
|
|
212
|
+
impacted.update(clarification_impacted_stages(data, answered))
|
|
118
213
|
unknown_stages = impacted - {stage for stage, _ in stages}
|
|
119
214
|
if unknown_stages:
|
|
120
215
|
unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
|
|
@@ -104,23 +104,38 @@ def _runtime_resolution(ctx: dict) -> dict:
|
|
|
104
104
|
return payload if isinstance(payload, dict) else {}
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
# The phases a `{% if header.taskType ... %}` block may target: the lifecycle
|
|
108
|
+
# sequence itself, kept as an allowlist rather than `[a-z-]+` so a typo'd phase
|
|
109
|
+
# name fails to match and the block survives the copy — caught by eye instead of
|
|
110
|
+
# silently vanishing from every phase's excerpt. Longest-first so
|
|
111
|
+
# `implementation-planning` is never matched as `implementation`.
|
|
112
|
+
_PHASE_ALT = "|".join(
|
|
113
|
+
re.escape(phase) for phase in sorted(PHASE_SEQUENCE, key=len, reverse=True)
|
|
114
|
+
)
|
|
115
|
+
# Two forms: a single-phase block (`== 'X'`) and a multi-phase one
|
|
116
|
+
# (`in ('X', 'Y')`). The multi form exists because a section can belong to a
|
|
117
|
+
# set of phases — `### 2.3 End State Coverage` is written by the three
|
|
118
|
+
# pre-implementation phases and by no other.
|
|
107
119
|
_PHASE_BLOCK_RE = re.compile(
|
|
108
|
-
r"\{% if header\.taskType
|
|
120
|
+
r"\{% if header\.taskType (?:"
|
|
121
|
+
rf"== '(?P<one>{_PHASE_ALT})'"
|
|
122
|
+
rf"|in \((?P<many>'(?:{_PHASE_ALT})'(?:\s*,\s*'(?:{_PHASE_ALT})')*),?\s*\)"
|
|
123
|
+
r") %\}\n(?P<body>.*?)\{% endif %\}\n",
|
|
109
124
|
re.DOTALL,
|
|
110
125
|
)
|
|
126
|
+
_PHASE_NAME_RE = re.compile(r"'([a-z-]+)'")
|
|
111
127
|
|
|
112
128
|
|
|
113
129
|
def _strip_phase_blocks(text: str, current_phase: str) -> str:
|
|
114
130
|
"""Resolve phase-conditional blocks (`{% if header.taskType == 'X' %}
|
|
115
131
|
... {% endif %}`) against *current_phase*.
|
|
116
132
|
|
|
117
|
-
Blocks whose target
|
|
118
|
-
markers dropped); blocks targeting
|
|
119
|
-
entirely.
|
|
120
|
-
block
|
|
121
|
-
`
|
|
122
|
-
|
|
123
|
-
there.
|
|
133
|
+
Blocks whose target set contains *current_phase* keep their body (jinja
|
|
134
|
+
markers dropped); blocks targeting only other phases are removed
|
|
135
|
+
entirely. A phase that no block names keeps none of them — and when
|
|
136
|
+
*current_phase* is empty that is every block. The `## 5.5` / `5.6` /
|
|
137
|
+
`5.7` / `5.8` deliverable sections stay single-phase, so they still
|
|
138
|
+
apply to exactly one task-type each.
|
|
124
139
|
|
|
125
140
|
Observed (fontsninja-classifier-v2 RD run): the raw final-report
|
|
126
141
|
template copied into instruction-set/final-report-template.md was
|
|
@@ -135,9 +150,9 @@ def _strip_phase_blocks(text: str, current_phase: str) -> str:
|
|
|
135
150
|
"""
|
|
136
151
|
|
|
137
152
|
def repl(m: "re.Match[str]") -> str:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return body if
|
|
153
|
+
one = m.group("one")
|
|
154
|
+
targets = {one} if one else set(_PHASE_NAME_RE.findall(m.group("many")))
|
|
155
|
+
return m.group("body") if current_phase in targets else ""
|
|
141
156
|
|
|
142
157
|
return _PHASE_BLOCK_RE.sub(repl, text)
|
|
143
158
|
|
|
@@ -577,6 +577,29 @@ def resolve_report_language(data: dict, *, override: str | None) -> str:
|
|
|
577
577
|
return candidate
|
|
578
578
|
|
|
579
579
|
|
|
580
|
+
# Schema-optional top-level arrays, and the value a template may assume when
|
|
581
|
+
# the report omits them. The vendored jinja2 does not resolve a missing
|
|
582
|
+
# top-level name to `Undefined`: `| default([])` passes the sentinel straight
|
|
583
|
+
# through, `| length` raises on it, and both `x` and `not x` evaluate true. So a
|
|
584
|
+
# template cannot test for absence at all, and the value has to arrive filled.
|
|
585
|
+
_OPTIONAL_ARRAY_DEFAULTS = ("endStateCoverage",)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _with_optional_defaults(data: dict) -> dict:
|
|
589
|
+
"""Render context with schema-optional arrays filled in.
|
|
590
|
+
|
|
591
|
+
Keeps the schema field optional — an omitted `endStateCoverage` stays absent
|
|
592
|
+
in data.json, which is what the run validator reads to tell a legacy brief
|
|
593
|
+
apart from a dropped requirement — while giving the template a value it can
|
|
594
|
+
branch on.
|
|
595
|
+
"""
|
|
596
|
+
filled = dict(data)
|
|
597
|
+
for key in _OPTIONAL_ARRAY_DEFAULTS:
|
|
598
|
+
if not isinstance(filled.get(key), list):
|
|
599
|
+
filled[key] = []
|
|
600
|
+
return filled
|
|
601
|
+
|
|
602
|
+
|
|
580
603
|
def render(
|
|
581
604
|
data: dict,
|
|
582
605
|
*,
|
|
@@ -606,7 +629,7 @@ def render(
|
|
|
606
629
|
|
|
607
630
|
try:
|
|
608
631
|
template = env.get_template(template_path.name)
|
|
609
|
-
rendered = template.render(**data)
|
|
632
|
+
rendered = template.render(**_with_optional_defaults(data))
|
|
610
633
|
rendered = _inject_index_and_anchors(rendered, dictionary)
|
|
611
634
|
return _ventilate_prose(rendered)
|
|
612
635
|
except I18nError as exc:
|
|
@@ -23,6 +23,38 @@ from okstra_project import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
_CRITIC_COUNTERS = (
|
|
27
|
+
"gapsProposed",
|
|
28
|
+
"gapsMerged",
|
|
29
|
+
"gapsRejected",
|
|
30
|
+
"gapsUnverified",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collect_critic(task_root: Path) -> dict:
|
|
35
|
+
"""task 의 모든 run 에서 커버리지 크리틱 결과를 합산.
|
|
36
|
+
|
|
37
|
+
크리틱은 opt-in 이라 대부분의 run 에 `config.critic` 이 없다. 그런 run 은
|
|
38
|
+
`runsWithCritic` 에 세지 않는다 — "쓰지 않았다" 와 "썼는데 0건" 은 정책 판단이
|
|
39
|
+
갈리는 서로 다른 사실이다. 읽기 전용이므로 깨진 state 파일은 건너뛴다.
|
|
40
|
+
"""
|
|
41
|
+
totals = {"runsWithCritic": 0} | {name: 0 for name in _CRITIC_COUNTERS}
|
|
42
|
+
for state_path in sorted(task_root.glob("runs/*/state/convergence-*.json")):
|
|
43
|
+
try:
|
|
44
|
+
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, json.JSONDecodeError):
|
|
46
|
+
continue
|
|
47
|
+
critic = (payload.get("config") or {}).get("critic")
|
|
48
|
+
if not isinstance(critic, dict):
|
|
49
|
+
continue
|
|
50
|
+
totals["runsWithCritic"] += 1
|
|
51
|
+
for name in _CRITIC_COUNTERS:
|
|
52
|
+
value = critic.get(name)
|
|
53
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
54
|
+
totals[name] += value
|
|
55
|
+
return totals
|
|
56
|
+
|
|
57
|
+
|
|
26
58
|
def _collect_task(entry: dict, project_root: Path) -> dict:
|
|
27
59
|
"""list_project_tasks entry → roll-up 한 줄. catalog 상태 필드 + time/error 측정.
|
|
28
60
|
|
|
@@ -33,7 +65,13 @@ def _collect_task(entry: dict, project_root: Path) -> dict:
|
|
|
33
65
|
runs, _ = load_runs(task_root)
|
|
34
66
|
time_agg = aggregate_time(runs, project_root)
|
|
35
67
|
records, _ = parse_records(glob_error_logs(task_root))
|
|
68
|
+
critic = _collect_critic(task_root)
|
|
36
69
|
return {
|
|
70
|
+
"criticRuns": critic["runsWithCritic"],
|
|
71
|
+
"criticGapsProposed": critic["gapsProposed"],
|
|
72
|
+
"criticGapsMerged": critic["gapsMerged"],
|
|
73
|
+
"criticGapsRejected": critic["gapsRejected"],
|
|
74
|
+
"criticGapsUnverified": critic["gapsUnverified"],
|
|
37
75
|
"taskKey": entry.get("taskKey", ""),
|
|
38
76
|
"taskGroup": entry.get("taskGroup", ""),
|
|
39
77
|
"taskId": entry.get("taskId", ""),
|
|
@@ -62,10 +100,21 @@ def _tally(tasks: list[dict], key: str) -> dict:
|
|
|
62
100
|
return dict(sorted(counts.items()))
|
|
63
101
|
|
|
64
102
|
|
|
103
|
+
def _critic_totals(tasks: list[dict]) -> dict:
|
|
104
|
+
return {
|
|
105
|
+
"runsWithCritic": sum(t["criticRuns"] for t in tasks),
|
|
106
|
+
"gapsProposed": sum(t["criticGapsProposed"] for t in tasks),
|
|
107
|
+
"gapsMerged": sum(t["criticGapsMerged"] for t in tasks),
|
|
108
|
+
"gapsRejected": sum(t["criticGapsRejected"] for t in tasks),
|
|
109
|
+
"gapsUnverified": sum(t["criticGapsUnverified"] for t in tasks),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
65
113
|
def build_rollup(project_root: Path, task_group: str | None) -> dict:
|
|
66
114
|
entries = list_project_tasks(project_root, task_group=task_group or None)
|
|
67
115
|
tasks = [_collect_task(e, project_root) for e in entries]
|
|
68
116
|
totals = {
|
|
117
|
+
"critic": _critic_totals(tasks),
|
|
69
118
|
"runs": sum(t["runCount"] for t in tasks),
|
|
70
119
|
"cpuSumMs": sum(t["cpuSumMs"] for t in tasks),
|
|
71
120
|
"wallClockMs": sum(t["wallClockMs"] for t in tasks),
|
|
@@ -29,6 +29,17 @@ _CONTRACT_RE = re.compile(r"^contract:\s*(?P<rule>.+?)\s*$")
|
|
|
29
29
|
_BRIEF_HEADING_RE = re.compile(r"^(#{1,6}\s+.+?)\s*$")
|
|
30
30
|
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
31
31
|
|
|
32
|
+
_END_STATE_ID_RE = re.compile(r"^(?:EB|PB|EO)-\d{3}$")
|
|
33
|
+
_END_STATE_HEADINGS = frozenset(
|
|
34
|
+
{"Expected Behavior", "Preserved Behavior", "Expected Outcome"}
|
|
35
|
+
)
|
|
36
|
+
_END_STATE_BULLET_RE = re.compile(r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b")
|
|
37
|
+
# Only `## ` closes a section, mirroring validate-brief.py's section_body
|
|
38
|
+
# lookahead. `_BRIEF_HEADING_RE` above matches `#` through `######` and is for
|
|
39
|
+
# heading citations, which are checked against a different reader.
|
|
40
|
+
_SECTION_HEADING_RE = re.compile(r"^##\s+(?P<heading>.+?)\s*$")
|
|
41
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
42
|
+
|
|
32
43
|
|
|
33
44
|
def normalize_heading(text: str) -> str:
|
|
34
45
|
"""Heading text stripped of its `#` markers and surrounding whitespace.
|
|
@@ -37,6 +48,11 @@ def normalize_heading(text: str) -> str:
|
|
|
37
48
|
Criteria` and `brief:## Acceptance Criteria` both name the same heading.
|
|
38
49
|
The comparison stays exact after this — a heading the brief lacks must
|
|
39
50
|
still fail.
|
|
51
|
+
|
|
52
|
+
Heading citations are the legacy form, reachable only for briefs authored
|
|
53
|
+
before the end-state sections existed (`brief_end_state_ids` returns an
|
|
54
|
+
empty set for those). A brief that pins ids takes `brief:EB-001` instead,
|
|
55
|
+
and `brief_citation_problem` rejects a heading there.
|
|
40
56
|
"""
|
|
41
57
|
return " ".join(text.lstrip("#").split())
|
|
42
58
|
|
|
@@ -73,12 +89,62 @@ def brief_headings(brief_path: Path) -> set[str]:
|
|
|
73
89
|
return headings
|
|
74
90
|
|
|
75
91
|
|
|
92
|
+
def brief_end_state_ids(brief_path: Path) -> set[str]:
|
|
93
|
+
"""End-state ids the brief declares, across all three sections.
|
|
94
|
+
|
|
95
|
+
The reading rules mirror validators/validate-brief.py exactly, because that
|
|
96
|
+
file checks the very same lines for their format and the two readers must
|
|
97
|
+
not disagree about what the brief declared. Concretely: only `## ` closes a
|
|
98
|
+
section (its `section_body`), fenced regions are NOT skipped (its
|
|
99
|
+
`meaningful_bullets` knows nothing about fences), and HTML comments are
|
|
100
|
+
removed (it strips them file-wide before any check).
|
|
101
|
+
|
|
102
|
+
This is the one function here that does not follow brief_headings' fence
|
|
103
|
+
discipline, and the asymmetry is deliberate. A heading citation and an
|
|
104
|
+
end-state item are validated by different code; each reader has to match its
|
|
105
|
+
own checker. Diverging the other way — skipping a fenced item the checker
|
|
106
|
+
accepted — is the worse failure: the brief passes, the id is absent from the
|
|
107
|
+
declared set, and no phase is ever asked to account for it.
|
|
108
|
+
|
|
109
|
+
An empty set means the brief predates the end-state sections, which is what
|
|
110
|
+
the downstream conditional gate keys on: a legacy brief keeps the legacy
|
|
111
|
+
path instead of being wedged by a contract it was never written against.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
text = _HTML_COMMENT_RE.sub("", Path(brief_path).read_text(encoding="utf-8"))
|
|
115
|
+
except OSError:
|
|
116
|
+
return set()
|
|
117
|
+
|
|
118
|
+
ids: set[str] = set()
|
|
119
|
+
section = ""
|
|
120
|
+
for line in text.splitlines():
|
|
121
|
+
if m := _SECTION_HEADING_RE.match(line):
|
|
122
|
+
heading = normalize_heading(m.group("heading"))
|
|
123
|
+
section = heading if heading in _END_STATE_HEADINGS else ""
|
|
124
|
+
continue
|
|
125
|
+
if not section:
|
|
126
|
+
continue
|
|
127
|
+
if m := _END_STATE_BULLET_RE.match(line.strip()):
|
|
128
|
+
ids.add(m.group("id"))
|
|
129
|
+
return ids
|
|
130
|
+
|
|
131
|
+
|
|
76
132
|
@dataclass(frozen=True)
|
|
77
133
|
class SourceRef:
|
|
78
134
|
kind: Literal["brief", "derived", "contract", "invalid"]
|
|
79
135
|
value: str = ""
|
|
80
136
|
reason: str = ""
|
|
81
137
|
|
|
138
|
+
@property
|
|
139
|
+
def is_end_state_id(self) -> bool:
|
|
140
|
+
"""True when a `brief:` citation names an end-state id rather than a heading.
|
|
141
|
+
|
|
142
|
+
Heading citations survive only for briefs authored before the end-state
|
|
143
|
+
sections existed; the id form is what makes a citation checkable against
|
|
144
|
+
a specific reporter line instead of a heading every brief carries.
|
|
145
|
+
"""
|
|
146
|
+
return self.kind == "brief" and bool(_END_STATE_ID_RE.match(self.value))
|
|
147
|
+
|
|
82
148
|
|
|
83
149
|
def parse_source(raw: str) -> SourceRef:
|
|
84
150
|
text = (raw or "").strip()
|
|
@@ -97,6 +163,36 @@ def parse_source(raw: str) -> SourceRef:
|
|
|
97
163
|
return SourceRef("invalid")
|
|
98
164
|
|
|
99
165
|
|
|
166
|
+
def brief_citation_problem(
|
|
167
|
+
ref: SourceRef, headings: set[str], end_state: set[str]
|
|
168
|
+
) -> str | None:
|
|
169
|
+
"""Why a `brief:` citation is inadmissible, or None when it is fine.
|
|
170
|
+
|
|
171
|
+
Shared by validators/validate-run.py (requirementCoverage) and
|
|
172
|
+
validators/validate_fanout.py (fan-out packets): both decide admissibility
|
|
173
|
+
the same way and differ only in how they word the failure, so the decision
|
|
174
|
+
lives here and the wording stays with the caller.
|
|
175
|
+
|
|
176
|
+
A brief that pins ids refuses heading citations outright: every brief
|
|
177
|
+
carries the same generic headings, so a heading cannot say WHICH reporter
|
|
178
|
+
line the item came from — that is the loophole the id form closes.
|
|
179
|
+
"""
|
|
180
|
+
if ref.kind != "brief":
|
|
181
|
+
return None
|
|
182
|
+
if end_state:
|
|
183
|
+
if not ref.is_end_state_id:
|
|
184
|
+
return (
|
|
185
|
+
f"cites `brief:{ref.value}`, but this brief pins end-state ids. "
|
|
186
|
+
"Cite the end-state id the item came from (`brief:EB-001`)"
|
|
187
|
+
)
|
|
188
|
+
if ref.value not in end_state:
|
|
189
|
+
return f"cites `brief:{ref.value}` which the brief does not declare"
|
|
190
|
+
return None
|
|
191
|
+
if headings and normalize_heading(ref.value) not in headings:
|
|
192
|
+
return f"cites brief heading `{ref.value}` which does not exist in the brief"
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
100
196
|
def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
|
|
101
197
|
"""Walk each row's derivation chain to its terminator.
|
|
102
198
|
|
|
@@ -123,7 +219,8 @@ def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
|
|
|
123
219
|
continue
|
|
124
220
|
result[rid] = (
|
|
125
221
|
f"unrecognized source on `{cursor}` — must be one of "
|
|
126
|
-
"`brief
|
|
222
|
+
"`brief:EB-001` (or the legacy `brief:<heading>`), "
|
|
223
|
+
"`derived:R-NNN — <reason>`, `contract:<rule>`"
|
|
127
224
|
)
|
|
128
225
|
break
|
|
129
226
|
return result
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Read the Stage Map stage numbers a prose cell cites.
|
|
2
|
+
|
|
3
|
+
Two readers depend on this grammar and must not drift apart: the coverage
|
|
4
|
+
validator proving every stage traces back to a requirement, and the
|
|
5
|
+
incremental-scope back-trace resolving which stages an answered clarification
|
|
6
|
+
touches. A second copy would let one side accept a citation the other rejects,
|
|
7
|
+
which decides whether a re-run narrows or stays full.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
|
|
14
|
+
_ITEM = rf"\d+(?:\s*{_RANGE}\s*\d+)?"
|
|
15
|
+
# `, and` / `, &` is one separator, not a separator followed by a stray word:
|
|
16
|
+
# `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
|
|
17
|
+
_SEP = r"(?:,\s*(?:and|&)?|and|&)"
|
|
18
|
+
_LIST_RE = re.compile(
|
|
19
|
+
rf"\bstages?[ \t]*({_ITEM}(?:\s*{_SEP}\s*{_ITEM})*)",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
_ITEM_RE = re.compile(rf"(\d+)(?:\s*{_RANGE}\s*(\d+))?", re.IGNORECASE)
|
|
23
|
+
RANGE_MAX_SPAN = 64
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cited_stage_numbers(text: str) -> set[int]:
|
|
27
|
+
"""Stage numbers *text* cites, in every prose form a planner writes.
|
|
28
|
+
|
|
29
|
+
Numbers stay anchored to a word-initial `stage`/`stages` token on the same
|
|
30
|
+
line; harvesting bare numbers — or letting the anchor reach across a line
|
|
31
|
+
break, or match the tail of `Substage`/`Backstage` — would let any prose,
|
|
32
|
+
including a row disclaiming every stage, justify any stage.
|
|
33
|
+
"""
|
|
34
|
+
cited: set[int] = set()
|
|
35
|
+
for span in _LIST_RE.finditer(text):
|
|
36
|
+
for item in _ITEM_RE.finditer(span.group(1)):
|
|
37
|
+
start = int(item.group(1))
|
|
38
|
+
cited.add(start)
|
|
39
|
+
if item.group(2) is None:
|
|
40
|
+
continue
|
|
41
|
+
end = int(item.group(2))
|
|
42
|
+
cited.add(end)
|
|
43
|
+
# A reversed or absurdly wide range is a typo, not a citation of
|
|
44
|
+
# everything between its endpoints.
|
|
45
|
+
if 0 <= end - start <= RANGE_MAX_SPAN:
|
|
46
|
+
cited.update(range(start, end))
|
|
47
|
+
return cited
|
|
@@ -43,14 +43,15 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
43
43
|
" - work-category classification (bugfix / feature / refactor / ops / improvement)\n"
|
|
44
44
|
" - routing decision toward the next safe lifecycle phase\n"
|
|
45
45
|
" - missing-input list and clarification requests\n"
|
|
46
|
-
" - approval / confirmation checkpoints recorded for the next phase"
|
|
46
|
+
" - approval / confirmation checkpoints recorded for the next phase\n"
|
|
47
|
+
" - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
|
|
47
48
|
),
|
|
48
49
|
},
|
|
49
50
|
"improvement-discovery": {
|
|
50
51
|
"allowed": (
|
|
51
52
|
" - improvement candidate discovery within the lens whitelist defined in `scripts/okstra_ctl/improvement_lenses.py`\n"
|
|
52
53
|
" - top-N ranking (default 8, brief `candidate-cap` overrides within 1..12)\n"
|
|
53
|
-
" - per-candidate columns Lens / Scope / Severity / Effort / Consensus / Source workers / Recommended next-phase / Evidence (path:line)\n"
|
|
54
|
+
" - per-candidate columns Lens / Scope / Severity / Effort / Consensus / Source workers / Recommended next-phase / Expected behavior after / Evidence (path:line)\n"
|
|
54
55
|
" - Phase 1.5 reflect-back grilling log at `runs/improvement-discovery/<seq>/state/phase-1.5-grilling.md`\n"
|
|
55
56
|
" - Phase 5.5 consensus classification (full / partial / contested / worker-unique)"
|
|
56
57
|
),
|
|
@@ -60,7 +61,8 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
60
61
|
" - symptom and trigger evidence\n"
|
|
61
62
|
" - root-cause hypotheses with supporting citations\n"
|
|
62
63
|
" - reproduction gaps and missing observability\n"
|
|
63
|
-
" - validation paths to confirm or refute hypotheses"
|
|
64
|
+
" - validation paths to confirm or refute hypotheses\n"
|
|
65
|
+
" - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
|
|
64
66
|
),
|
|
65
67
|
},
|
|
66
68
|
"implementation-planning": {
|
|
@@ -72,7 +74,8 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
72
74
|
" - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
|
|
73
75
|
" - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
|
|
74
76
|
" - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
|
|
75
|
-
" - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)"
|
|
77
|
+
" - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)\n"
|
|
78
|
+
" - one endStateCoverage row per brief end-state id, each mapped to the R-NNN row that carries it"
|
|
76
79
|
),
|
|
77
80
|
},
|
|
78
81
|
"implementation": {
|