okstra 0.141.3 → 0.143.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +11 -2
- package/docs/cli.md +15 -0
- package/docs/for-ai/skills/okstra-setup.md +8 -0
- package/docs/project-structure-overview.md +6 -0
- package/docs/task-process/error-analysis.md +9 -4
- 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/architectures/hexagonal.md +3 -3
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/adapters/claude-code.md +2 -2
- package/runtime/prompts/lead/context-loader.md +2 -2
- package/runtime/prompts/lead/convergence.md +5 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +20 -9
- package/runtime/prompts/lead/report-writer.md +4 -3
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
- package/runtime/prompts/profiles/error-analysis.md +5 -1
- package/runtime/prompts/profiles/forbidden-actions.json +0 -1
- package/runtime/prompts/profiles/implementation-planning.md +7 -2
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +28 -3
- package/runtime/python/okstra_ctl/brief_frontmatter.py +56 -0
- package/runtime/python/okstra_ctl/clarification_items.py +99 -5
- package/runtime/python/okstra_ctl/convergence_engine.py +66 -15
- package/runtime/python/okstra_ctl/paths.py +34 -7
- package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
- package/runtime/python/okstra_ctl/plan_items.py +38 -0
- package/runtime/python/okstra_ctl/run.py +81 -33
- package/runtime/python/okstra_ctl/schema_excerpt.py +5 -3
- package/runtime/python/okstra_ctl/wizard.py +18 -44
- package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_project/resolver.py +25 -0
- package/runtime/schemas/final-report-v1.0.schema.json +162 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/references/project-config.md +47 -0
- package/runtime/templates/reports/final-report.template.md +51 -0
- package/runtime/templates/reports/i18n/en.json +32 -3
- package/runtime/templates/reports/i18n/ko.json +32 -3
- package/runtime/templates/reports/implementation-input.template.md +1 -2
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/validate-brief.py +5 -1
- package/runtime/validators/validate-run.py +430 -48
- package/src/cli-registry.mjs +10 -0
- package/src/commands/execute/phase-cleanup.mjs +38 -0
|
@@ -66,6 +66,19 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
|
|
|
66
66
|
return max(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
|
|
70
|
+
"""한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
|
|
71
|
+
|
|
72
|
+
stage-isolated task-type(implementation / final-verification)만 stage 하위를
|
|
73
|
+
갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
|
|
74
|
+
`latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
|
|
75
|
+
"""
|
|
76
|
+
dirs = [type_dir / "reports"]
|
|
77
|
+
if include_stages and type_dir.name in _STAGED_TASK_TYPES:
|
|
78
|
+
dirs += sorted(type_dir.glob("stage-*/reports"))
|
|
79
|
+
return dirs
|
|
80
|
+
|
|
81
|
+
|
|
69
82
|
def _project_root_of(task_root: Path) -> Optional[Path]:
|
|
70
83
|
"""canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
|
|
71
84
|
|
|
@@ -257,34 +270,48 @@ class RunRef:
|
|
|
257
270
|
task_group: str,
|
|
258
271
|
task_id: str,
|
|
259
272
|
task_types: Optional[tuple[str, ...]] = None,
|
|
273
|
+
*,
|
|
274
|
+
include_stages: bool = False,
|
|
260
275
|
) -> Optional["RunRef"]:
|
|
261
276
|
"""여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
|
|
262
277
|
|
|
263
278
|
`task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
|
|
264
279
|
훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
|
|
280
|
+
|
|
281
|
+
`include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
|
|
282
|
+
도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
|
|
265
283
|
"""
|
|
266
284
|
return cls.latest_under(
|
|
267
|
-
task_dir(project_root, task_group, task_id),
|
|
285
|
+
task_dir(project_root, task_group, task_id),
|
|
286
|
+
task_types,
|
|
287
|
+
include_stages=include_stages,
|
|
268
288
|
)
|
|
269
289
|
|
|
270
290
|
@classmethod
|
|
271
291
|
def latest_under(
|
|
272
|
-
cls,
|
|
292
|
+
cls,
|
|
293
|
+
task_root: Path,
|
|
294
|
+
task_types: Optional[tuple[str, ...]] = None,
|
|
295
|
+
*,
|
|
296
|
+
include_stages: bool = False,
|
|
273
297
|
) -> Optional["RunRef"]:
|
|
274
298
|
"""`latest_across` 의 task_root 진입점.
|
|
275
299
|
|
|
276
300
|
task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
|
|
277
|
-
(bash resume-clarification)가 쓴다.
|
|
301
|
+
(bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
|
|
302
|
+
넘기므로 기본값(`include_stages=False`)이 그 범위를 그대로 보존한다.
|
|
278
303
|
"""
|
|
279
304
|
runs_dir = runs_dir_of(task_root)
|
|
280
305
|
if task_types is None:
|
|
281
306
|
if not runs_dir.is_dir():
|
|
282
307
|
return None
|
|
283
308
|
task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
|
|
284
|
-
candidates = [
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
309
|
+
candidates: list[Path] = []
|
|
310
|
+
for task_type in task_types:
|
|
311
|
+
for reports_dir in _report_dirs(runs_dir / task_type, include_stages):
|
|
312
|
+
found = _newest_report(reports_dir)
|
|
313
|
+
if found is not None:
|
|
314
|
+
candidates.append(found)
|
|
288
315
|
if not candidates:
|
|
289
316
|
return None
|
|
290
317
|
return cls.from_report_path(
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Phase-transition resource cleanup orchestrator.
|
|
2
|
+
|
|
3
|
+
Reuses the existing pane-reclaim (okstra-trace-cleanup.sh) and teammate-reconcile
|
|
4
|
+
(okstra-team-reconcile.sh) primitives; this module only decides tmux-vs-in-process
|
|
5
|
+
mode, finds the prior run dir, and sequences the two scripts. It never
|
|
6
|
+
re-implements pane kill or completion detection.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from okstra_project import StateError, parse_task_key
|
|
18
|
+
|
|
19
|
+
from .paths import RunRef, okstra_home
|
|
20
|
+
from .tmux import resolve_caller_pane, tmux_available
|
|
21
|
+
|
|
22
|
+
_TRACE_SCRIPT = "okstra-trace-cleanup.sh"
|
|
23
|
+
_RECONCILE_SCRIPT = "okstra-team-reconcile.sh"
|
|
24
|
+
_MODE_TMUX = "tmux"
|
|
25
|
+
_MODE_IN_PROCESS = "in-process"
|
|
26
|
+
_RUNNER_TIMEOUT_SECONDS = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _recorded_lead_pane(prev_run_dir: Optional[Path]) -> Optional[str]:
|
|
30
|
+
"""The pane the prior run's lead recorded, or None when unrecorded.
|
|
31
|
+
|
|
32
|
+
'<run_dir>/state/lead-pane.id' is written once at run start by the lead
|
|
33
|
+
adapter: a non-empty pane id means that run was inside a tmux pane, an empty
|
|
34
|
+
(0-byte) file means the run resolved to in-process. This on-disk fact
|
|
35
|
+
outlives the cleanup process's own context, which -- when okstra runs as a
|
|
36
|
+
daemon child -- can no longer walk its ancestry back to a pane.
|
|
37
|
+
"""
|
|
38
|
+
if prev_run_dir is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return (prev_run_dir / "state" / "lead-pane.id").read_text().strip()
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_mode(
|
|
47
|
+
*,
|
|
48
|
+
prev_run_dir: Optional[Path] = None,
|
|
49
|
+
pane_probe: Callable[[], str] = resolve_caller_pane,
|
|
50
|
+
tmux_probe: Callable[[], bool] = tmux_available,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""'tmux' when the prior run had a pane to reclaim, else 'in-process'.
|
|
53
|
+
|
|
54
|
+
The prior run's recorded lead pane is authoritative: it captured that run's
|
|
55
|
+
tmux-ness at run start and survives a cleanup process that can no longer walk
|
|
56
|
+
its ancestry to a pane (daemon child). Only when nothing was recorded does
|
|
57
|
+
this fall back to the live ancestor-walk probe.
|
|
58
|
+
"""
|
|
59
|
+
recorded = _recorded_lead_pane(prev_run_dir)
|
|
60
|
+
if recorded is not None:
|
|
61
|
+
return _MODE_TMUX if recorded else _MODE_IN_PROCESS
|
|
62
|
+
if pane_probe():
|
|
63
|
+
return _MODE_TMUX
|
|
64
|
+
if prev_run_dir is not None and tmux_probe():
|
|
65
|
+
# A prior run exists to reclaim from, but neither its recording nor the
|
|
66
|
+
# live probe could confirm tmux -- yet tmux is reachable. Surface the
|
|
67
|
+
# ambiguity instead of silently skipping pane reclaim.
|
|
68
|
+
sys.stderr.write(
|
|
69
|
+
"phase-cleanup: prior run has no recorded lead pane and no caller "
|
|
70
|
+
"pane could be resolved, but tmux is reachable; treating as "
|
|
71
|
+
"in-process and skipping pane reclaim\n"
|
|
72
|
+
)
|
|
73
|
+
return _MODE_IN_PROCESS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_prev_run_dir(
|
|
77
|
+
*,
|
|
78
|
+
run_dir: Optional[str],
|
|
79
|
+
project_root: Path,
|
|
80
|
+
task_group: str,
|
|
81
|
+
task_id: str,
|
|
82
|
+
) -> Optional[Path]:
|
|
83
|
+
"""Explicit --run-dir wins; otherwise the newest completed run across phases.
|
|
84
|
+
|
|
85
|
+
Auto-discovery walks both FLAT runs/<type>/reports/ and staged
|
|
86
|
+
runs/<type>/stage-N/reports/ (implementation / final-verification), so a
|
|
87
|
+
staged prior run is found without an explicit run_dir; --run-dir still
|
|
88
|
+
overrides when a specific prior run is wanted.
|
|
89
|
+
"""
|
|
90
|
+
if run_dir:
|
|
91
|
+
return Path(run_dir)
|
|
92
|
+
ref = RunRef.latest_across(
|
|
93
|
+
Path(project_root), task_group, task_id, include_stages=True
|
|
94
|
+
)
|
|
95
|
+
return ref.run_dir if ref is not None else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _default_runner(cmd: list[str]) -> str:
|
|
99
|
+
# Cleanup must never block the next phase, so a spawn failure or a missing
|
|
100
|
+
# script degrades to "nothing to report" instead of propagating.
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
cmd,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
check=False,
|
|
107
|
+
timeout=_RUNNER_TIMEOUT_SECONDS,
|
|
108
|
+
)
|
|
109
|
+
return proc.stdout
|
|
110
|
+
except (OSError, subprocess.SubprocessError):
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _script_path(name: str) -> str:
|
|
115
|
+
# repo layout: scripts/<name>; installed layout: ~/.okstra/bin/<name>
|
|
116
|
+
candidates = [
|
|
117
|
+
Path(__file__).resolve().parent.parent / name,
|
|
118
|
+
okstra_home() / "bin" / name,
|
|
119
|
+
]
|
|
120
|
+
for candidate in candidates:
|
|
121
|
+
if candidate.is_file():
|
|
122
|
+
return str(candidate)
|
|
123
|
+
return str(candidates[-1]) # a missing script is tolerated by _default_runner
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reclaim_panes(prev_run_dir: Path, runner: Callable[[list[str]], str]) -> int:
|
|
127
|
+
listing = runner(
|
|
128
|
+
[
|
|
129
|
+
_script_path(_TRACE_SCRIPT),
|
|
130
|
+
"--run-dir",
|
|
131
|
+
str(prev_run_dir),
|
|
132
|
+
"--reclaim-completed",
|
|
133
|
+
"--list",
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
count = len([ln for ln in listing.splitlines() if ln.strip()])
|
|
137
|
+
runner(
|
|
138
|
+
[_script_path(_TRACE_SCRIPT), "--run-dir", str(prev_run_dir), "--reclaim-completed"]
|
|
139
|
+
)
|
|
140
|
+
return count
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _dismissible_teammates(
|
|
144
|
+
project_root: Path,
|
|
145
|
+
runner: Callable[[list[str]], str],
|
|
146
|
+
fallback_team: str = "",
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
# The live team dir is keyed by the current session id, which Claude Code
|
|
149
|
+
# re-issues on resume/compaction; without the caller's label the resolver
|
|
150
|
+
# finds no live roster and reports nothing to dismiss.
|
|
151
|
+
cmd = [_script_path(_RECONCILE_SCRIPT), "--project-root", str(project_root)]
|
|
152
|
+
if fallback_team:
|
|
153
|
+
cmd += ["--fallback-team", fallback_team]
|
|
154
|
+
out = runner(cmd)
|
|
155
|
+
prefix = "dismissible-member:"
|
|
156
|
+
return [ln[len(prefix):].strip() for ln in out.splitlines() if ln.startswith(prefix)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_cleanup(
|
|
160
|
+
*,
|
|
161
|
+
mode: str,
|
|
162
|
+
prev_run_dir: Optional[Path],
|
|
163
|
+
project_root: Path,
|
|
164
|
+
runner: Callable[[list[str]], str] = _default_runner,
|
|
165
|
+
fallback_team: str = "",
|
|
166
|
+
) -> dict:
|
|
167
|
+
# Cleanup is performed here (real pane kill + reconcile), not merely logged;
|
|
168
|
+
# the CLI's execution IS the enforcement (a caller cannot emit a "cleaned"
|
|
169
|
+
# line without this actually running).
|
|
170
|
+
panes = 0
|
|
171
|
+
if mode == _MODE_TMUX and prev_run_dir is not None:
|
|
172
|
+
panes = _reclaim_panes(prev_run_dir, runner)
|
|
173
|
+
teammates = _dismissible_teammates(project_root, runner, fallback_team)
|
|
174
|
+
return {"mode": mode, "panesReclaimed": panes, "dismissibleTeammates": teammates}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str]) -> int:
|
|
178
|
+
ap = argparse.ArgumentParser(prog="okstra phase-cleanup")
|
|
179
|
+
ap.add_argument(
|
|
180
|
+
"--task-key",
|
|
181
|
+
help="finds the newest completed run across phases, including staged "
|
|
182
|
+
"(implementation / final-verification) stage-N runs",
|
|
183
|
+
)
|
|
184
|
+
ap.add_argument("--run-dir")
|
|
185
|
+
ap.add_argument("--project-root", required=True)
|
|
186
|
+
ap.add_argument(
|
|
187
|
+
"--fallback-team",
|
|
188
|
+
default="",
|
|
189
|
+
help="team label to resolve the roster from when the live session dir is "
|
|
190
|
+
"gone (session id re-issued by resume/compaction)",
|
|
191
|
+
)
|
|
192
|
+
ap.add_argument("--json", action="store_true")
|
|
193
|
+
args = ap.parse_args(argv)
|
|
194
|
+
|
|
195
|
+
task_group = task_id = ""
|
|
196
|
+
if args.task_key:
|
|
197
|
+
try:
|
|
198
|
+
_pid, task_group, task_id = parse_task_key(args.task_key)
|
|
199
|
+
except StateError as exc:
|
|
200
|
+
sys.stderr.write(f"phase-cleanup: {exc}\n")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
prev = resolve_prev_run_dir(
|
|
204
|
+
run_dir=args.run_dir,
|
|
205
|
+
project_root=Path(args.project_root),
|
|
206
|
+
task_group=task_group,
|
|
207
|
+
task_id=task_id,
|
|
208
|
+
)
|
|
209
|
+
if prev is None and not args.run_dir:
|
|
210
|
+
sys.stderr.write(
|
|
211
|
+
"phase-cleanup: no prior run found for auto-discovery "
|
|
212
|
+
"(normal on a task's first phase); pass --run-dir to point at a "
|
|
213
|
+
"specific prior run\n"
|
|
214
|
+
)
|
|
215
|
+
result = run_cleanup(
|
|
216
|
+
mode=resolve_mode(prev_run_dir=prev),
|
|
217
|
+
prev_run_dir=prev,
|
|
218
|
+
project_root=Path(args.project_root),
|
|
219
|
+
fallback_team=args.fallback_team,
|
|
220
|
+
)
|
|
221
|
+
if args.json:
|
|
222
|
+
sys.stdout.write(json.dumps(result) + "\n")
|
|
223
|
+
else:
|
|
224
|
+
sys.stdout.write(f"mode: {result['mode']}\n")
|
|
225
|
+
sys.stdout.write(f"panes-reclaimed: {result['panesReclaimed']}\n")
|
|
226
|
+
sys.stdout.write(
|
|
227
|
+
"dismissible-teammates: "
|
|
228
|
+
+ ", ".join(result["dismissibleTeammates"])
|
|
229
|
+
+ "\n"
|
|
230
|
+
)
|
|
231
|
+
return 0 # cleanup never blocks the next phase
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -189,12 +189,50 @@ def _extract_prep_items(
|
|
|
189
189
|
)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def _extract_variation_point_items(
|
|
193
|
+
planning: Mapping[str, Any], items: list[dict[str, Any]]
|
|
194
|
+
) -> None:
|
|
195
|
+
analysis = planning.get("variationPointAnalysis")
|
|
196
|
+
if not isinstance(analysis, Mapping):
|
|
197
|
+
raise PlanItemContractError("variationPointAnalysis must be an object")
|
|
198
|
+
points = analysis.get("points") or []
|
|
199
|
+
if not analysis.get("hasMultipleImplementations") or not points:
|
|
200
|
+
_add_item(
|
|
201
|
+
items,
|
|
202
|
+
{
|
|
203
|
+
"id": "P-Var-0",
|
|
204
|
+
"subject": "No variation point declared",
|
|
205
|
+
"sourceSection": "5.5.11",
|
|
206
|
+
"ticketId": "",
|
|
207
|
+
"payload": _row_payload(dict(analysis)),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
for index, point in enumerate(points, start=1):
|
|
212
|
+
if not isinstance(point, Mapping):
|
|
213
|
+
raise PlanItemContractError(
|
|
214
|
+
"variationPointAnalysis.points row must be an object"
|
|
215
|
+
)
|
|
216
|
+
_add_item(
|
|
217
|
+
items,
|
|
218
|
+
{
|
|
219
|
+
"id": f"P-Var-{index}",
|
|
220
|
+
"subject": _non_empty_string(point.get("behavior"))
|
|
221
|
+
or f"variation point {index}",
|
|
222
|
+
"sourceSection": "5.5.11",
|
|
223
|
+
"ticketId": "",
|
|
224
|
+
"payload": _row_payload(dict(point)),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
192
229
|
def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
193
230
|
"""Return deterministic, lossless P-* items in contract order."""
|
|
194
231
|
if not isinstance(implementation_planning, Mapping):
|
|
195
232
|
raise PlanItemContractError("implementationPlanning must be an object")
|
|
196
233
|
items = _extract_standard_items(implementation_planning)
|
|
197
234
|
_extract_prep_items(implementation_planning, items)
|
|
235
|
+
_extract_variation_point_items(implementation_planning, items)
|
|
198
236
|
return items
|
|
199
237
|
|
|
200
238
|
|
|
@@ -102,6 +102,10 @@ from .worktree import (
|
|
|
102
102
|
WorktreeProvision,
|
|
103
103
|
provision_task_worktree,
|
|
104
104
|
)
|
|
105
|
+
from .brief_frontmatter import (
|
|
106
|
+
has_reporter_confirmation_contract,
|
|
107
|
+
read_brief_frontmatter,
|
|
108
|
+
)
|
|
105
109
|
|
|
106
110
|
# Frontmatter approval-flag matcher.
|
|
107
111
|
#
|
|
@@ -854,6 +858,7 @@ class _ResolvedAssets:
|
|
|
854
858
|
final_report_template: Path
|
|
855
859
|
lead_contract: Path
|
|
856
860
|
run_validator: Path
|
|
861
|
+
brief_validator: Path
|
|
857
862
|
|
|
858
863
|
|
|
859
864
|
def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _ResolvedAssets:
|
|
@@ -877,7 +882,14 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
|
|
|
877
882
|
)
|
|
878
883
|
lead_contract = workspace_root / "prompts" / "lead" / "okstra-lead-contract.md"
|
|
879
884
|
run_validator = workspace_root / "validators" / "validate-run.py"
|
|
880
|
-
|
|
885
|
+
brief_validator = workspace_root / "validators" / "validate-brief.py"
|
|
886
|
+
for required in (
|
|
887
|
+
task_index_template,
|
|
888
|
+
final_report_template,
|
|
889
|
+
run_validator,
|
|
890
|
+
brief_validator,
|
|
891
|
+
lead_contract,
|
|
892
|
+
):
|
|
881
893
|
if not required.is_file():
|
|
882
894
|
raise PrepareError(
|
|
883
895
|
f"required okstra template or lead contract missing: {required}.{_INSTALL_HINT}"
|
|
@@ -889,12 +901,49 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
|
|
|
889
901
|
final_report_template=final_report_template,
|
|
890
902
|
lead_contract=lead_contract,
|
|
891
903
|
run_validator=run_validator,
|
|
904
|
+
brief_validator=brief_validator,
|
|
892
905
|
)
|
|
893
906
|
|
|
894
907
|
|
|
908
|
+
def _validate_task_brief_preflight(
|
|
909
|
+
project_root: Path,
|
|
910
|
+
brief_path: Path,
|
|
911
|
+
validator_path: Path,
|
|
912
|
+
) -> None:
|
|
913
|
+
"""Validate canonical briefs before any prepare-time side effects."""
|
|
914
|
+
frontmatter = read_brief_frontmatter(brief_path)
|
|
915
|
+
if not has_reporter_confirmation_contract(frontmatter):
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
proc = _subprocess.run(
|
|
919
|
+
[
|
|
920
|
+
sys.executable,
|
|
921
|
+
str(validator_path),
|
|
922
|
+
str(brief_path),
|
|
923
|
+
"--briefs-root",
|
|
924
|
+
str(project_root / ".okstra" / "briefs"),
|
|
925
|
+
],
|
|
926
|
+
capture_output=True,
|
|
927
|
+
text=True,
|
|
928
|
+
check=False,
|
|
929
|
+
)
|
|
930
|
+
if proc.returncode != 0:
|
|
931
|
+
detail = " ".join(
|
|
932
|
+
line.strip()
|
|
933
|
+
for output in (proc.stdout, proc.stderr)
|
|
934
|
+
for line in output.splitlines()
|
|
935
|
+
if line.strip()
|
|
936
|
+
)
|
|
937
|
+
raise PrepareError(f"task brief failed validation: {detail}")
|
|
938
|
+
if frontmatter["reporter-confirmations"] == "pending":
|
|
939
|
+
raise PrepareError(
|
|
940
|
+
"task brief reporter-confirmations is pending; rerun okstra-brief-gen "
|
|
941
|
+
"Step 6.5 before starting error-analysis"
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
|
|
895
945
|
def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
896
|
-
"""
|
|
897
|
-
를 검증하고, implementation 일 때 stage map 을 파싱해 돌려준다 (그 외엔 빈 리스트)."""
|
|
946
|
+
"""Validate pure prepare inputs and return a final-verification stage map."""
|
|
898
947
|
if not project_root.is_dir():
|
|
899
948
|
raise PrepareError(f"project root not found: {project_root}")
|
|
900
949
|
if inp.stages and inp.task_type != "release-handoff":
|
|
@@ -911,41 +960,14 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
911
960
|
raise PrepareError(f"task brief not found: {inp.brief_path}")
|
|
912
961
|
ctx_stage_map: list = []
|
|
913
962
|
# implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
|
|
914
|
-
# 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope).
|
|
915
|
-
# stage-map 파싱은 공유하고, frontmatter 승인/option 주입/구조 검증 같은
|
|
916
|
-
# implementation 전용 단계만 따로 게이트한다.
|
|
963
|
+
# 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope).
|
|
917
964
|
if inp.task_type in ("implementation", "final-verification"):
|
|
918
965
|
if not inp.approved_plan_path:
|
|
919
966
|
raise PrepareError(
|
|
920
967
|
f"task-type {inp.task_type} requires "
|
|
921
968
|
"--approved-plan <path-to-final-report.md>"
|
|
922
969
|
)
|
|
923
|
-
if inp.task_type == "
|
|
924
|
-
# --approve / --implementation-option 은 공유 approved-plan 파일에
|
|
925
|
-
# read-modify-write 한다. 같은 task-key 의 동시 stage run 둘이 둘 다
|
|
926
|
-
# 이 플래그를 주면 후발 write 가 선발의 frontmatter 변경을 덮어쓴다
|
|
927
|
-
# (lost update). 두 mutation 을 per-task-key 락으로 직렬화한다.
|
|
928
|
-
if inp.approve_plan_ack or inp.implementation_option:
|
|
929
|
-
with worktree_provision_mutex(
|
|
930
|
-
okstra_home(), inp.project_id,
|
|
931
|
-
slugify(inp.task_group), slugify(inp.task_id),
|
|
932
|
-
):
|
|
933
|
-
if inp.approve_plan_ack:
|
|
934
|
-
# 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로
|
|
935
|
-
# 모델링한다. frontmatter approved 를 true 로 toggle 한 뒤
|
|
936
|
-
# 동일한 검증 경로(`_validate_approved_plan`)를 통과시킨다.
|
|
937
|
-
_apply_cli_approval(inp.approved_plan_path)
|
|
938
|
-
if inp.implementation_option:
|
|
939
|
-
# 유저가 고른 Option Candidate 이름을 approved-plan
|
|
940
|
-
# frontmatter 의 `implementation-option:` 라인에 기록한다.
|
|
941
|
-
# 빈 값이면 implementation 이 plan 의 `Recommended Option`
|
|
942
|
-
# 으로 폴백하므로 호출하지 않는다.
|
|
943
|
-
_apply_cli_implementation_option(
|
|
944
|
-
inp.approved_plan_path, inp.implementation_option
|
|
945
|
-
)
|
|
946
|
-
_validate_approved_plan(inp.approved_plan_path)
|
|
947
|
-
_validate_stage_structure(inp.approved_plan_path)
|
|
948
|
-
else:
|
|
970
|
+
if inp.task_type == "final-verification":
|
|
949
971
|
# final-verification 에서 --approve / --implementation-option 은
|
|
950
972
|
# 의미가 없다 (승인은 implementation 진입 시 이미 끝났다).
|
|
951
973
|
if inp.approve_plan_ack:
|
|
@@ -958,7 +980,7 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
958
980
|
"--implementation-option is only meaningful with --task-type "
|
|
959
981
|
"implementation and --approved-plan <path>"
|
|
960
982
|
)
|
|
961
|
-
|
|
983
|
+
ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
|
|
962
984
|
else:
|
|
963
985
|
if inp.approve_plan_ack:
|
|
964
986
|
# implementation 외 task-type 에서 `--approve` 는 의미가 없다. 사용자에게
|
|
@@ -988,6 +1010,24 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
988
1010
|
return ctx_stage_map
|
|
989
1011
|
|
|
990
1012
|
|
|
1013
|
+
def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
|
|
1014
|
+
"""Apply approved-plan inputs only after canonical brief preflight succeeds."""
|
|
1015
|
+
if inp.approve_plan_ack or inp.implementation_option:
|
|
1016
|
+
with worktree_provision_mutex(
|
|
1017
|
+
okstra_home(), inp.project_id,
|
|
1018
|
+
slugify(inp.task_group), slugify(inp.task_id),
|
|
1019
|
+
):
|
|
1020
|
+
if inp.approve_plan_ack:
|
|
1021
|
+
_apply_cli_approval(inp.approved_plan_path)
|
|
1022
|
+
if inp.implementation_option:
|
|
1023
|
+
_apply_cli_implementation_option(
|
|
1024
|
+
inp.approved_plan_path, inp.implementation_option
|
|
1025
|
+
)
|
|
1026
|
+
_validate_approved_plan(inp.approved_plan_path)
|
|
1027
|
+
_validate_stage_structure(inp.approved_plan_path)
|
|
1028
|
+
return _parse_stage_map_into_ctx(inp.approved_plan_path)
|
|
1029
|
+
|
|
1030
|
+
|
|
991
1031
|
def _collect_handoff_source_report_rows(
|
|
992
1032
|
rows: list, nums: list,
|
|
993
1033
|
) -> list:
|
|
@@ -2072,6 +2112,14 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2072
2112
|
task_index_template = assets.task_index_template
|
|
2073
2113
|
final_report_template = assets.final_report_template
|
|
2074
2114
|
ctx_stage_map = _validate_prepare_inputs(project_root, inp)
|
|
2115
|
+
if inp.task_type != "release-handoff":
|
|
2116
|
+
_validate_task_brief_preflight(
|
|
2117
|
+
project_root,
|
|
2118
|
+
inp.brief_path,
|
|
2119
|
+
assets.brief_validator,
|
|
2120
|
+
)
|
|
2121
|
+
if inp.task_type == "implementation":
|
|
2122
|
+
ctx_stage_map = _prepare_implementation_approved_plan(inp)
|
|
2075
2123
|
|
|
2076
2124
|
# release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
|
|
2077
2125
|
# 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Build a task-type-scoped excerpt of the final-report schema.
|
|
2
2
|
|
|
3
3
|
The full schema (``schemas/final-report-v1.0.schema.json``) carries the
|
|
4
|
-
deliverable property blocks for ALL task-types (``
|
|
5
|
-
``releaseHandoff``, ``implementation``,
|
|
4
|
+
deliverable property blocks for ALL task-types (``errorAnalysis``,
|
|
5
|
+
``implementationPlanning``, ``releaseHandoff``, ``implementation``,
|
|
6
|
+
``finalVerification``) plus a
|
|
6
7
|
``$defs`` library (~38% of the file) shared across them. A single run only
|
|
7
8
|
authors ONE task-type's data.json, so the report-writer worker only needs
|
|
8
9
|
the common structure + its own task-type's block + the ``$defs`` those
|
|
@@ -24,10 +25,11 @@ import json
|
|
|
24
25
|
import re
|
|
25
26
|
|
|
26
27
|
# task-type → the per-type deliverable property key it owns. task-types
|
|
27
|
-
# absent from this map (requirements-discovery,
|
|
28
|
+
# absent from this map (requirements-discovery,
|
|
28
29
|
# improvement-discovery) have no per-type block; their excerpt keeps only
|
|
29
30
|
# the common properties.
|
|
30
31
|
_TASK_TYPE_PROPERTY = {
|
|
32
|
+
"error-analysis": "errorAnalysis",
|
|
31
33
|
"implementation-planning": "implementationPlanning",
|
|
32
34
|
"release-handoff": "releaseHandoff",
|
|
33
35
|
"implementation": "implementation",
|
|
@@ -30,6 +30,7 @@ from pathlib import Path
|
|
|
30
30
|
from typing import Any, Callable, Optional
|
|
31
31
|
|
|
32
32
|
from okstra_ctl.ids import slugify_task_segment
|
|
33
|
+
from okstra_ctl.brief_frontmatter import read_brief_frontmatter
|
|
33
34
|
from okstra_ctl.models import (
|
|
34
35
|
PROVIDER_MAPPINGS,
|
|
35
36
|
UnknownModelError,
|
|
@@ -155,49 +156,6 @@ _RECENT_PREFIX = "__recent:"
|
|
|
155
156
|
_REPORT_PREFIX = "__report:"
|
|
156
157
|
_BRIEF_PREFIX = "__brief:"
|
|
157
158
|
|
|
158
|
-
# Lines of `key: value` we pull from a brief markdown frontmatter. The
|
|
159
|
-
# parser is intentionally lightweight (no yaml dep) and tolerant — a
|
|
160
|
-
# malformed brief returns an empty dict.
|
|
161
|
-
_BRIEF_FRONTMATTER_LINE_RE = re.compile(r"^([a-zA-Z0-9_\-]+)\s*:\s*(.*)$")
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _parse_brief_frontmatter(path: Path) -> dict[str, str]:
|
|
165
|
-
"""Read the YAML-style frontmatter at the top of a brief markdown file
|
|
166
|
-
and return a flat ``{key: value}`` map.
|
|
167
|
-
|
|
168
|
-
Returns ``{}`` if the file is unreadable, has no frontmatter, or the
|
|
169
|
-
frontmatter is malformed. Comments (``# ...``) and quoted values are
|
|
170
|
-
stripped. Placeholder values like ``<task-group>`` are kept verbatim;
|
|
171
|
-
callers decide whether to treat them as a real suggestion.
|
|
172
|
-
"""
|
|
173
|
-
try:
|
|
174
|
-
text = path.read_text(encoding="utf-8")
|
|
175
|
-
except OSError:
|
|
176
|
-
return {}
|
|
177
|
-
if not text.startswith("---"):
|
|
178
|
-
return {}
|
|
179
|
-
lines = text.splitlines()
|
|
180
|
-
if not lines or lines[0].strip() != "---":
|
|
181
|
-
return {}
|
|
182
|
-
out: dict[str, str] = {}
|
|
183
|
-
for line in lines[1:]:
|
|
184
|
-
if line.strip() == "---":
|
|
185
|
-
break
|
|
186
|
-
# strip trailing inline comment
|
|
187
|
-
comment_idx = line.find("#")
|
|
188
|
-
if comment_idx >= 0:
|
|
189
|
-
line = line[:comment_idx]
|
|
190
|
-
m = _BRIEF_FRONTMATTER_LINE_RE.match(line.strip())
|
|
191
|
-
if not m:
|
|
192
|
-
continue
|
|
193
|
-
key, val = m.group(1), m.group(2).strip()
|
|
194
|
-
# strip matching quotes
|
|
195
|
-
if (len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"')):
|
|
196
|
-
val = val[1:-1]
|
|
197
|
-
out[key] = val
|
|
198
|
-
return out
|
|
199
|
-
|
|
200
|
-
|
|
201
159
|
def _looks_like_template_placeholder(value: str) -> bool:
|
|
202
160
|
"""Treat ``<task-group>``, ``<...>``, empty strings, and ``self`` as
|
|
203
161
|
non-suggestions. Anything else (a real slug-like value) is honored."""
|
|
@@ -223,7 +181,7 @@ def _brief_suggestions(path: Path) -> tuple[str, str]:
|
|
|
223
181
|
A brief without frontmatter, or with placeholder values, yields two
|
|
224
182
|
empty strings — callers fall back to plain-text input.
|
|
225
183
|
"""
|
|
226
|
-
fm =
|
|
184
|
+
fm = read_brief_frontmatter(path)
|
|
227
185
|
tg_raw = fm.get("task-group", "")
|
|
228
186
|
bid_raw = fm.get("brief-id", "")
|
|
229
187
|
tg = "" if _looks_like_template_placeholder(tg_raw) else tg_raw
|
|
@@ -2402,6 +2360,15 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
2402
2360
|
stages=state.handoff_stages)
|
|
2403
2361
|
|
|
2404
2362
|
|
|
2363
|
+
def _same_file(a: Path, b_str: str) -> bool:
|
|
2364
|
+
"""Whether two paths resolve to the same file. False when either path
|
|
2365
|
+
cannot be resolved (missing intermediate dir, permission, etc.)."""
|
|
2366
|
+
try:
|
|
2367
|
+
return a.resolve() == Path(b_str).resolve()
|
|
2368
|
+
except OSError:
|
|
2369
|
+
return False
|
|
2370
|
+
|
|
2371
|
+
|
|
2405
2372
|
def _suggest_latest_final_report(state: WizardState) -> str:
|
|
2406
2373
|
"""clarification carry-in 으로 추천할 직전 final-report 의 relpath.
|
|
2407
2374
|
|
|
@@ -2436,6 +2403,13 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
2436
2403
|
best = _newest("*/reports/final-report-*.md")
|
|
2437
2404
|
if best is None:
|
|
2438
2405
|
return ""
|
|
2406
|
+
# The approved plan is already wired via --approved-plan. On the first run
|
|
2407
|
+
# of an approved-plan consuming phase (final-verification / implementation)
|
|
2408
|
+
# the newest cross-phase report IS that plan, so the fallback would
|
|
2409
|
+
# re-recommend it as a clarification answer — injecting it twice, read as a
|
|
2410
|
+
# user clarification it is not. Never carry the approved plan back in here.
|
|
2411
|
+
if state.approved_plan_path and _same_file(best, state.approved_plan_path):
|
|
2412
|
+
return ""
|
|
2439
2413
|
try:
|
|
2440
2414
|
return str(best.relative_to(Path(state.project_root)))
|
|
2441
2415
|
except ValueError:
|