okstra 0.178.0 → 0.179.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/execute/plan-verify.mjs +1 -1
  3. package/dist/commands/execute/worktree-status.mjs +8 -2
  4. package/dist/commands/execute/worktree-status.mjs.map +1 -1
  5. package/dist/commands/lifecycle/install.mjs +1 -1
  6. package/dist/commands/lifecycle/install.mjs.map +1 -1
  7. package/dist/commands/report/render-final-report.mjs +3 -3
  8. package/docs/architecture/storage-model.md +3 -3
  9. package/docs/architecture.md +10 -9
  10. package/docs/cli.md +11 -13
  11. package/docs/for-ai/skills/okstra-inspect.md +3 -3
  12. package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
  13. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  14. package/docs/project-structure-overview.md +10 -11
  15. package/docs/task-process/implementation-planning.md +1 -1
  16. package/docs/task-process/implementation.md +1 -1
  17. package/package.json +1 -1
  18. package/runtime/BUILD.json +2 -2
  19. package/runtime/agents/workers/report-writer-worker.md +11 -12
  20. package/runtime/bin/lib/okstra/globals.sh +2 -2
  21. package/runtime/bin/lib/okstra/interactive.sh +1 -1
  22. package/runtime/bin/lib/okstra/usage.sh +11 -9
  23. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  24. package/runtime/bin/okstra-central.sh +2 -2
  25. package/runtime/bin/okstra-render-final-report.py +1 -1
  26. package/runtime/bin/okstra-token-usage.py +1 -1
  27. package/runtime/prompts/launch.template.md +1 -1
  28. package/runtime/prompts/lead/adapters/cmux.md +6 -1
  29. package/runtime/prompts/lead/context-loader.md +3 -2
  30. package/runtime/prompts/lead/convergence.md +1 -1
  31. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  32. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  33. package/runtime/prompts/lead/report-writer.md +21 -20
  34. package/runtime/prompts/lead/team-contract.md +1 -1
  35. package/runtime/prompts/profiles/_common-contract.md +5 -4
  36. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  37. package/runtime/prompts/profiles/_implementation-executor.md +2 -1
  38. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  39. package/runtime/prompts/profiles/implementation-planning.md +9 -5
  40. package/runtime/prompts/profiles/implementation.md +4 -4
  41. package/runtime/prompts/profiles/improvement-discovery.md +2 -2
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  43. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  45. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  46. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  47. package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
  48. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  49. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  50. package/runtime/python/okstra_ctl/backfill.py +12 -5
  51. package/runtime/python/okstra_ctl/consumers.py +70 -3
  52. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  53. package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
  54. package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
  55. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  56. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  57. package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
  58. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  59. package/runtime/python/okstra_ctl/handoff.py +1 -2
  60. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  61. package/runtime/python/okstra_ctl/index.py +4 -4
  62. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  63. package/runtime/python/okstra_ctl/listing.py +4 -2
  64. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  65. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  66. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  67. package/runtime/python/okstra_ctl/paths.py +13 -10
  68. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  69. package/runtime/python/okstra_ctl/recap.py +3 -2
  70. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  71. package/runtime/python/okstra_ctl/render.py +22 -22
  72. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  73. package/runtime/python/okstra_ctl/rollup.py +1 -1
  74. package/runtime/python/okstra_ctl/run.py +139 -284
  75. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  76. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  77. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  78. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  79. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  80. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  81. package/runtime/python/okstra_ctl/user_response.py +97 -12
  82. package/runtime/python/okstra_ctl/wizard.py +43 -65
  83. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  84. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  85. package/runtime/python/okstra_ctl/workflow.py +1 -1
  86. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  87. package/runtime/python/okstra_ctl/write_policy.py +45 -6
  88. package/runtime/python/okstra_project/state.py +2 -2
  89. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  90. package/runtime/python/okstra_token_usage/cli.py +3 -3
  91. package/runtime/python/okstra_token_usage/report.py +7 -24
  92. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  93. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  94. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  95. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  96. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  97. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  98. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  99. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  100. package/runtime/skills/okstra-run/SKILL.md +1 -1
  101. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  102. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  103. package/runtime/templates/project-docs/task-index.template.md +1 -1
  104. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  105. package/runtime/validators/forbidden_actions.py +76 -5
  106. package/runtime/validators/lib/fixtures.sh +14 -10
  107. package/runtime/validators/lib/runners.sh +1 -1
  108. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  109. package/runtime/validators/validate-report-views.py +1 -1
  110. package/runtime/validators/validate-run.py +95 -37
@@ -131,6 +131,7 @@ def build_invocation_write_contract(
131
131
  artifact_paths: Sequence[Path],
132
132
  maximum_precision: BoundaryPrecision,
133
133
  planned_paths: Sequence[str] = (),
134
+ planned_paths_declared: bool = True,
134
135
  scratch_roots: Sequence[Path] = (),
135
136
  generated_paths: Sequence[str] = (),
136
137
  auxiliary_roots: Sequence[Path] = (),
@@ -146,6 +147,7 @@ def build_invocation_write_contract(
146
147
  "projectRoot": str(root),
147
148
  "artifactPaths": list(relative_artifacts),
148
149
  "plannedPaths": list(planned_paths),
150
+ "plannedPathsDeclared": planned_paths_declared,
149
151
  "protectedPaths": [".okstra", ".git"],
150
152
  "generatedPaths": list(generated_paths),
151
153
  "scratchRoots": [str(path) for path in scratch_roots],
@@ -198,6 +200,14 @@ def planned_paths_from_run_manifest(
198
200
  return _planned_paths_from_report(Path(approved), int(stage_value))
199
201
 
200
202
 
203
+ def planned_paths_declared(project_root: Path, manifest: Mapping[str, Any]) -> bool:
204
+ """Whether this run's approved plan states its paths in the declared column."""
205
+ try:
206
+ return planned_paths_from_run_manifest(project_root, manifest)[1]
207
+ except WritePolicyError:
208
+ return False
209
+
210
+
201
211
  def derive_write_enforcement(
202
212
  policy: WritePolicy,
203
213
  maximum_precision: BoundaryPrecision,
@@ -466,7 +476,11 @@ def _relative_to_root(path: Path, root: Path, label: str) -> str:
466
476
  return relative
467
477
 
468
478
 
469
- def _planned_paths_from_report(report_path: Path, stage: int) -> tuple[str, ...]:
479
+ def _planned_paths_from_report(
480
+ report_path: Path, stage: int
481
+ ) -> tuple[tuple[str, ...], bool]:
482
+ """(paths, declared). `declared` is False for a plan written before the
483
+ structured `plannedPaths` column existed."""
470
484
  payload = _read_json(report_path.with_suffix(".data.json"), "approved plan data")
471
485
  planning = payload.get("implementationPlanning")
472
486
  stages = planning.get("stages") if isinstance(planning, Mapping) else None
@@ -478,15 +492,40 @@ def _planned_paths_from_report(report_path: Path, stage: int) -> tuple[str, ...]
478
492
  None,
479
493
  ) if isinstance(stages, list) else None
480
494
  steps = selected.get("stepwiseExecution") if isinstance(selected, Mapping) else None
495
+ # `plannedPaths`, not `files`: the latter is prose written for a reader and
496
+ # splitting it on commas produced entries like `.npmrc (template only` and
497
+ # `no literal token)`, while a single cell naming a glob migration counted
498
+ # as one literal path. Every file the plan actually asked for then read as
499
+ # an unauthorized source change, which is a failure the executor cannot
500
+ # avoid by following the plan.
501
+ rows = steps if isinstance(steps, list) else []
481
502
  paths = {
503
+ str(path).strip().strip("`")
504
+ for step in rows if isinstance(step, Mapping)
505
+ for path in (step.get("plannedPaths") or [])
506
+ if isinstance(path, str) and path.strip()
507
+ }
508
+ if paths:
509
+ return _relative_paths(tuple(sorted(paths))), True
510
+ # A plan approved before `plannedPaths` existed carries its paths only in
511
+ # the prose `files` cell. Deriving them there is exactly what this change
512
+ # removed — it mis-splits globs and parentheticals — so the derived set is
513
+ # not used to reject anything. It travels with `plannedPathsDeclared:
514
+ # false`, which turns the path comparison off for that stage rather than
515
+ # failing every file the plan asked for. An in-flight task keeps running;
516
+ # the next planning run produces a plan the audit can actually enforce.
517
+ derived = {
482
518
  part.strip().strip("`")
483
- for step in steps if isinstance(step, Mapping)
519
+ for step in rows if isinstance(step, Mapping)
484
520
  for part in str(step.get("files") or "").replace("<br>", ",").split(",")
485
521
  if part.strip()
486
- } if isinstance(steps, list) else set()
487
- if not paths:
488
- raise WritePolicyError("implementer write policy stage has no planned paths")
489
- return _relative_paths(tuple(sorted(paths)))
522
+ }
523
+ if not derived:
524
+ raise WritePolicyError(
525
+ "implementer write policy stage has no planned paths: the approved "
526
+ "plan's stepwise rows carry neither `plannedPaths` nor `files`"
527
+ )
528
+ return _relative_paths(tuple(sorted(derived))), False
490
529
 
491
530
 
492
531
  def _rooted(project_root: Path, value: str) -> Path:
@@ -104,7 +104,7 @@ def read_task_manifest(task_root: Path) -> Optional[dict]:
104
104
 
105
105
  **디스크는 건드리지 않는다.** 승격한 값을 여기서 되적으면 lock 없는
106
106
  read-modify-write 가 되어, 파싱과 쓰기 사이에 다른 프로세스가 적은
107
- `currentPhase` · `phaseStates` · `latestReportPath` 를 되돌린다
107
+ `currentPhase` · `phaseStates` · `latestReportRecordPath` 를 되돌린다
108
108
  (`os.replace` 는 찢어진 읽기만 막고 갱신 유실은 못 막는다). 이 분기는
109
109
  디스크 포인터가 아직 문자열일 때 — 즉 업그레이드 직후 진행 중인 task 가
110
110
  살아 있을 때 — 만 발화하므로 되돌릴 수 있는 값이 바로 phase 진행 상태다.
@@ -155,7 +155,7 @@ _DERIVED_STATUS_STRING_FIELDS = (
155
155
  "workStatus",
156
156
  "currentStatus",
157
157
  "latestRunStatus",
158
- "latestReportPath",
158
+ "latestReportRecordPath",
159
159
  )
160
160
 
161
161
 
@@ -25,6 +25,6 @@ from .pricing import (
25
25
  )
26
26
  from .report import (
27
27
  SubstituteRefusedError,
28
- populate_and_render,
28
+ populate_token_cells,
29
29
  populate_data_token_cells,
30
30
  )
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from .collect import collect
10
10
  from .report import (
11
11
  SubstituteRefusedError,
12
- populate_and_render,
12
+ populate_token_cells,
13
13
  )
14
14
 
15
15
 
@@ -130,7 +130,7 @@ def main() -> int:
130
130
 
131
131
  if args.substitute_data is not None:
132
132
  try:
133
- cells_changed, bytes_written = populate_and_render(
133
+ cells_changed = populate_token_cells(
134
134
  args.substitute_data,
135
135
  updated,
136
136
  )
@@ -142,7 +142,7 @@ def main() -> int:
142
142
  return 2
143
143
  print(
144
144
  f"final-report substitution: populated {cells_changed} cell(s) in "
145
- f"{args.substitute_data}, re-rendered {bytes_written} bytes of markdown.",
145
+ f"{args.substitute_data}. The full reading copy is on-demand.",
146
146
  file=sys.stderr,
147
147
  )
148
148
 
@@ -18,11 +18,6 @@ from okstra_ctl.design_prep import ( # noqa: E402
18
18
  DesignPrepError,
19
19
  materialize_design_prep_requests,
20
20
  )
21
- from okstra_ctl.final_report_paths import final_report_markdown_path # noqa: E402
22
- from okstra_ctl.render_final_report import ( # noqa: E402
23
- FinalReportRenderError,
24
- render_to_file,
25
- )
26
21
 
27
22
  from .task_totals import task_cumulative_usage # noqa: E402
28
23
 
@@ -270,28 +265,16 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
270
265
  return changes
271
266
 
272
267
 
273
- def populate_and_render(
268
+ def populate_token_cells(
274
269
  data_path: Path,
275
270
  team_state: dict,
276
- *,
277
- output_path: Path | None = None,
278
- ) -> tuple[int, int]:
279
- """Mutate data.json + render the markdown sibling. Returns
280
- ``(cells_changed, bytes_written)``.
281
-
282
- The caller is normally Phase 7 step 1 (token-usage CLI with the
283
- ``--substitute-data`` flag). The rendered markdown is the canonical
284
- user-facing artifact; data.json is the SSOT.
271
+ ) -> int:
272
+ """Mutate data.json token cells and materialize design-prep requests.
273
+
274
+ The full reading copy is rendered on demand with
275
+ ``okstra render-final-report``.
285
276
  """
286
277
  changes = populate_data_token_cells(data_path, team_state)
287
-
288
- if output_path is None:
289
- output_path = final_report_markdown_path(data_path)
290
-
291
- try:
292
- bytes_written = render_to_file(data_path, output_path)
293
- except FinalReportRenderError as exc:
294
- raise SubstituteRefusedError(f"render after substitute failed: {exc}") from exc
295
278
  if data_path.name.startswith("final-report-implementation-planning-"):
296
279
  try:
297
280
  materialize_design_prep_requests(data_path)
@@ -299,4 +282,4 @@ def populate_and_render(
299
282
  raise SubstituteRefusedError(
300
283
  f"design-prep request materialization failed: {exc}"
301
284
  ) from exc
302
- return changes, bytes_written
285
+ return changes
@@ -38,7 +38,7 @@
38
38
  "additionalProperties": false,
39
39
  "properties": {
40
40
  "workerId": { "type": "string", "pattern": "\\S" },
41
- "audience": { "enum": ["analysis", "report-writer"] }
41
+ "audience": { "enum": ["analysis", "lead", "report-writer"] }
42
42
  }
43
43
  },
44
44
  "Group": {
@@ -56,7 +56,7 @@
56
56
  "additionalProperties": false,
57
57
  "properties": {
58
58
  "workerId": { "type": "string", "pattern": "\\S" },
59
- "audience": { "enum": ["analysis", "report-writer"] },
59
+ "audience": { "enum": ["analysis", "lead", "report-writer"] },
60
60
  "participantRef": { "type": "string", "pattern": "\\S" },
61
61
  "sourceRoleExecutionRef": { "type": "string", "pattern": "\\S" }
62
62
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://okstra.dev/schemas/final-report-v2.0.json",
4
4
  "title": "OKSTRA Final Report Data (v2.0)",
5
- "description": "JSON SSOT for okstra final reports. Written by report-writer-worker in Phase 6, then consumed independently by the AI handoff Markdown renderer and the task-specific human HTML renderer.",
5
+ "description": "JSON SSOT for okstra final reports. Written by report-writer-worker in Phase 6, then consumed independently by the on-demand full reading copy Markdown renderer and the task-specific human HTML renderer.",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "schemaVersion",
@@ -8627,6 +8627,7 @@
8627
8627
  "step",
8628
8628
  "action",
8629
8629
  "files",
8630
+ "plannedPaths",
8630
8631
  "command",
8631
8632
  "outcome",
8632
8633
  "expectedDetail"
@@ -8645,6 +8646,15 @@
8645
8646
  "type": "string",
8646
8647
  "minLength": 1
8647
8648
  },
8649
+ "plannedPaths": {
8650
+ "description": "Repository-relative paths this step may write, one per entry. `files` is prose for a human reader and cannot serve as a path ledger — it carries globs, exclusions and counts — so the implementer write policy reads this list instead.",
8651
+ "type": "array",
8652
+ "minItems": 1,
8653
+ "items": {
8654
+ "type": "string",
8655
+ "minLength": 1
8656
+ }
8657
+ },
8648
8658
  "command": {
8649
8659
  "type": "string",
8650
8660
  "minLength": 1
@@ -17,14 +17,14 @@ Trigger phrases: "okstra history", "past runs", "run history", "re-run", "list t
17
17
  - `--task-group <group>` → keep entries whose `taskGroup` matches.
18
18
  3. Sort by `updatedAt` desc.
19
19
  4. Page: default `--limit 20`. If truncated, add `... <N> more (pass --limit <N> to see all)`.
20
- 5. Extract: `taskKey`, `taskType`, `currentStatus`, `latestRunStatus`, `latestRunManifestPath`, `updatedAt`, `latestReportPath`, `latestResumeCommandPath`, `historyTimelinePath`.
20
+ 5. Extract: `taskKey`, `taskType`, `currentStatus`, `latestRunStatus`, `latestRunManifestPath`, `updatedAt`, `latestReportRecordPath`, `latestResumeCommandPath`, `historyTimelinePath`.
21
21
 
22
22
  ```markdown
23
23
  ## okstra Task History — <project-id>
24
24
 
25
25
  | # | Task Key | Type | currentStatus | latestRunStatus | Last Run | Report |
26
26
  |---|----------|------|---------------|------------------|----------|--------|
27
- | 1 | proj:group:id | error-analysis | completed | completed | 2026-04-05 22:59 | .project-docs/.../final-report-*.md |
27
+ | 1 | proj:group:id | error-analysis | completed | completed | 2026-04-05 22:59 | .project-docs/.../final-report-*.data.json |
28
28
  | 2 | proj:group:id2 | final-verification | todo | error | 2026-04-04 15:30 | -- |
29
29
  ```
30
30
 
@@ -47,7 +47,7 @@ When a user selects a specific task:
47
47
 
48
48
  | # | Timestamp | Type | Status | Report |
49
49
  |---|-----------|------|--------|--------|
50
- | 1 | 2026-04-05 22:59 | error-analysis | completed | .../final-report-*.md |
50
+ | 1 | 2026-04-05 22:59 | error-analysis | completed | .../final-report-*.data.json |
51
51
  | 2 | 2026-04-04 15:30 | error-analysis | error | -- |
52
52
  ```
53
53
 
@@ -26,7 +26,7 @@ Parse the stdout JSON (`{taskKey, runCount, transitions[], latestPhaseStates}`)
26
26
 
27
27
  `nextRecommendedPhase` in each entry is an **object** `{phase, status, rationale}`, not a phase-name string. Narrate it as `phase` when `phase` is non-empty, and as what `status` says otherwise — `pending` (that run did not settle the route), `blocked` (it stopped on something outside the run), `terminal` (the lifecycle ended there). Printing the object itself puts a raw dict in front of the reader.
28
28
 
29
- For a run that has a `reportPath`, read that final-report to enrich the summary only when the user wants a deeper one (do not read them all automatically).
29
+ For a run that has a `reportPath`, read that report record (`.data.json`) to enrich the summary only when the user wants a deeper one (do not read them all automatically).
30
30
 
31
31
  ### recap.3 — Free-form Q&A loop
32
32
 
@@ -14,20 +14,20 @@ task-key format: `<project-id>:<task-group>:<task-id>`.
14
14
 
15
15
  Lookup methods (in priority order):
16
16
 
17
- A. **`task-catalog.json` (fast):** read `.okstra/discovery/task-catalog.json`, match `taskKey` lowercase. `latestReportPath` is task-type-agnostic "most recent report".
17
+ A. **`task-catalog.json` (fast):** read `.okstra/discovery/task-catalog.json`, match `taskKey` lowercase. `latestReportRecordPath` is task-type-agnostic "most recent report".
18
18
 
19
- B. **`task-manifest.json` (direct):** if catalog missing, slugify task-group / task-id, read `.okstra/tasks/<group-segment>/<task-id-segment>/task-manifest.json`, use `latestReportPath` (task-type-agnostic).
19
+ B. **`task-manifest.json` (direct):** if catalog missing, slugify task-group / task-id, read `.okstra/tasks/<group-segment>/<task-id-segment>/task-manifest.json`, use `latestReportRecordPath` (task-type-agnostic).
20
20
 
21
21
  C. **`timeline.json` (specific run):** for a specific date or run, read `.okstra/tasks/<group-segment>/<id-segment>/history/timeline.json`, filter `runs[]` by `runTimestamp` / `status` / `taskType`, use `runs[].reportPath`.
22
22
 
23
- D. **Specific task-type (fallback):** `latestReportPath` is task-type-agnostic. For a specific task-type's latest report, look under `.okstra/tasks/<group-segment>/<id-segment>/runs/<task-type-segment>/reports/`, filename pattern `final-report-<task-type-segment>-<NNN>.md` per `scripts/okstra_ctl/sequence.py:30`. Highest seq is latest. Stage-isolated runs (`implementation`, single-stage `final-verification`) keep their reports one level deeper at `runs/<task-type-segment>/stage-<N>/reports/` — scan those subdirectories too; seq is independent per stage. Cross-verify with `timeline.json`'s `runs[].taskType` filter.
23
+ D. **Specific task-type (fallback):** `latestReportRecordPath` is task-type-agnostic. For a specific task-type's latest report, look under `.okstra/tasks/<group-segment>/<id-segment>/runs/<task-type-segment>/reports/`, filename pattern `final-report-<task-type-segment>-<NNN>.data.json`. Highest seq is latest. Stage-isolated runs (`implementation`, single-stage `final-verification`) keep their reports one level deeper at `runs/<task-type-segment>/stage-<N>/reports/` — scan those subdirectories too; seq is independent per stage. Cross-verify with `timeline.json`'s `runs[].taskType` filter. If the user wants the full reading copy, render it with `okstra render-final-report <that data.json>`.
24
24
 
25
25
  ### report.2 — Confirm existence
26
26
 
27
- 1. Verify `latestReportPath` is non-empty AND the file exists on disk. Either signal indicates report presence (tolerant).
27
+ 1. Verify `latestReportRecordPath` is non-empty AND the file exists on disk. Either signal indicates report presence (tolerant).
28
28
  2. If present, display the path and ask the user whether to read it.
29
29
  3. If absent, check `task-manifest.json` signals:
30
- - `latestReportPath` empty/missing AND `currentStatus != completed` AND `workStatus != done` AND `workflow.nextRecommendedPhase.status != "terminal"` → `This task is not yet complete (currentStatus: <currentStatus>, workStatus: <workStatus>).`
30
+ - `latestReportRecordPath` empty/missing AND `currentStatus != completed` AND `workStatus != done` AND `workflow.nextRecommendedPhase.status != "terminal"` → `This task is not yet complete (currentStatus: <currentStatus>, workStatus: <workStatus>).`
31
31
  - Any signal indicates completion but the file is missing → `Report file does not exist: <path>`
32
32
 
33
33
  `workStatus` enum: `todo | in-progress | blocked | done`. `currentStatus`: `completed` / `contract-violated` etc. `"completed"` string does NOT exist in `workStatus` — do not confuse the two.
@@ -21,7 +21,7 @@ Read `.okstra/discovery/task-catalog.json`. The catalog is the authoritative sou
21
21
  | `nextRecommendedPhase` | next-phase pointer — an **object** `{phase, status, rationale}`, never a string. `status` is `ready` / `pending` / `blocked` / `terminal`. The lead writes `phase` only under `ready`, but that is an authoring rule, not a constraint the struct enforces — `prepare` lowers a `ready` pointer to `pending` and keeps its `phase`, so a non-`ready` pointer that still names a phase is a normal state to read. Decide launchability from `status` alone. Render `phase` (or `--` when it is empty) — never the object itself. |
22
22
  | `awaitingApproval` | whether awaiting approval |
23
23
  | `latestRunStatus` | latest run status |
24
- | `latestReportPath` | latest report path |
24
+ | `latestReportRecordPath` | latest report path |
25
25
  | `latestResumeCommandPath` | latest resume command |
26
26
  | `workStatus` | user-managed work status (todo / in-progress / blocked / done; default in-progress) |
27
27
  | `updatedAt` | last update time |
@@ -51,7 +51,7 @@ Given a specific `task-key` or `task-group + task-id`:
51
51
  2. If necessary, read `.okstra/tasks/<task-group>/<task-id>/task-manifest.json` directly.
52
52
  3. If you need the latest run information, read `history/timeline.json` along with the latest run manifest.
53
53
 
54
- Required fields: `taskKey`, `taskType`, `workCategory`, `currentStatus`, `latestRunStatus`, `workflow.{currentPhase, currentPhaseState, phaseStates, lastCompletedPhase, nextRecommendedPhase, awaitingApproval, lastSafeCheckpoint}`, `workStatus`, `workStatusUpdatedAt`, `workStatusNote`, `latestReportPath`, `latestResumeCommandPath`, `historyTimelinePath`.
54
+ Required fields: `taskKey`, `taskType`, `workCategory`, `currentStatus`, `latestRunStatus`, `workflow.{currentPhase, currentPhaseState, phaseStates, lastCompletedPhase, nextRecommendedPhase, awaitingApproval, lastSafeCheckpoint}`, `workStatus`, `workStatusUpdatedAt`, `workStatusNote`, `latestReportRecordPath`, `latestResumeCommandPath`, `historyTimelinePath`.
55
55
 
56
56
  `workflow.nextRecommendedPhase` is the `{phase, status, rationale}` object described in `status.1` — read its three fields, do not print it whole. A `task-manifest.json` written before the object existed still holds a bare string there; `scripts/okstra_ctl/next_phase.py::promote` is what turns that string into the object, and every CLI-projected surface you can read instead (`task-catalog.json`, `okstra rollup`, `okstra recap`) has already applied it. Prefer those over a raw manifest rather than promoting a legacy string yourself.
57
57
 
@@ -112,7 +112,7 @@ okstra's own artifact identifiers are out of the allowlist — drop them:
112
112
 
113
113
  - report item ids — `F-001`, `C-001`, `R-001`, `D-0001`, `PREP-001`
114
114
  - run artifact names and their `<task-type>-<seq>` suffixes —
115
- `final-report-implementation-3.md`, `run-manifest-implementation-3.json`
115
+ `final-report-implementation-3.data.json`, `run-manifest-implementation-3.json`
116
116
  - phase / stage / worker labels — `final-verification`, `stage-2`, `codex-worker`
117
117
  - any path under `.okstra/` — reports, decisions, glossary, user-response sidecars
118
118
 
@@ -384,7 +384,7 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
384
384
  | `No module named okstra_ctl.wizard` | Install predates wizard module | `npx okstra@latest install` to refresh. |
385
385
  | `wizard step` returns `ok: false` repeatedly | User keeps giving invalid answers | Echo the error verbatim and re-prompt the same step — do not advance. |
386
386
  | `task root not found for <key>` | catalog entry stale or task-key typo | Restart the wizard (`okstra wizard init`) to refresh the pick list. |
387
- | `approved plan is not yet approved (frontmatter ...)` | `implementation` without proper approval | Ask the user to flip the plan's frontmatter `approved` field to `true` (manual edit or `--approve`), or pick a different task-type. |
387
+ | `approved plan is not yet approved (frontmatter ...)` | `implementation` without proper approval | Ask the user to re-run with `--approve` or confirm approval in the in-session wizard, or pick a different task-type. Editing the full reading copy does not approve the plan. |
388
388
 
389
389
  ## Output Rules
390
390
 
@@ -96,7 +96,7 @@ If 0 tasks remain, output `All tasks in this task-group are done. There is no sc
96
96
 
97
97
  ### Step 3: Per-task stage extraction (Stage Map source)
98
98
 
99
- For each in-scope task, the **authoritative source is its `implementation-planning` Stage Map resolved by the CLI**. Do not hand-parse it or use `latestReportPath` to choose its source. Call:
99
+ For each in-scope task, the **authoritative source is its `implementation-planning` Stage Map resolved by the CLI**. Do not hand-parse it or use `latestReportRecordPath` to choose its source. Call:
100
100
 
101
101
  ```bash
102
102
  okstra stage-map <task-key> --json
@@ -75,7 +75,7 @@ Returns `{reportPath, rows: [{id, kind, blocks, status, statement, expectedForm,
75
75
 
76
76
  Each entry of `options[]` is `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. `role` is `recommended` for exactly one entry and `alternative` for the rest; `scopeImpact` is a token list drawn from `in-repo` / `cross-repo` / `new-schema` / `deferrable`; `addedWork` and `directionChange` are one sentence each. Activity-contract v1 approval options also carry the gate action in `disposition`. A legacy schema-v1 report has nowhere to record impact or a gate action, so those fields arrive empty — the CLI reconstructs only the answers from the report's `Expected form` cell.
77
77
 
78
- `resolvedRefs` is `[{ref, definition}]` — the CLI has already looked up what each internal token (`RB-002`, `FU-001`, `§4.7`, …) means in the report body; `definition` is `null` only when the report text alone could not resolve it (e.g. a `path:line` pointer).
78
+ `resolvedRefs` is `[{ref, definition}]` — the CLI has already looked up what each record coordinate (`RB-002`, `FU-001`) means in the report record; `definition` is `null` when the record has no such row, when the citation is a section number (`§4.7`, leftover from an older report), or when the citation is a `path:line` pointer.
79
79
 
80
80
  This call is a **data fetch, not a presentation step**. Do not print `rows` at the user, and do not paste a raw `statement` as a question — a `statement` like `Rewrite RB-002 rollback (see §4.7)` is meaningless on its own, which is the whole reason this skill exists. Announce only the count and the plan:
81
81
 
@@ -100,7 +100,7 @@ Walk the open rows in report order. **One item per `AskUserQuestion` call** —
100
100
  Where the background comes from — in this order:
101
101
 
102
102
  - `resolvedRefs[].definition`, which is already resolved and needs no file read;
103
- - when a `definition` is `null`, or the three blocks would otherwise be empty, **Read** the report `§`/`path:line` that this item's `contextRefs[]` points at and take it from there;
103
+ - when a `definition` is `null`, or the three blocks would otherwise be empty, **Read** the report record (the `.data.json` at `reportPath`) and take the cited row's `statement` / `summary` / `item` from there. Do not open the full reading copy to chase a `§` number — that number is not a record coordinate;
104
104
  - **never invent it.** If the report genuinely does not say why, write `The report states nothing beyond the statement itself.` and go straight to the question. Fabricated background corrupts the answer it collects.
105
105
 
106
106
  Close the background with the raw source on one line, so the mapping back to the report stays visible:
@@ -133,7 +133,7 @@ Record one entry — `{id, kind, value, rationale?, disposition}`, `kind` copied
133
133
 
134
134
  A reframe is not an answer, so it does not satisfy the approval gate.
135
135
 
136
- If the user replies with a question instead of an answer ("what does this mean?"), record nothing: **Read** the `§`/`path:line` from `contextRefs[]`, explain it in plain language, and re-ask the same item with the same options. Explain only — **do not resolve it for them**; the decision goes back to the user.
136
+ If the user replies with a question instead of an answer ("what does this mean?"), record nothing: **Read** the report record for the cited row id from `contextRefs[]`, explain it in plain language, and re-ask the same item with the same options. Explain only — **do not resolve it for them**; the decision goes back to the user.
137
137
 
138
138
  Echo one line per finished item (`[2/5] C-014 → answer: 60s`), then ask the next one. Do not summarize the whole set until Step 4.
139
139
 
@@ -24,7 +24,7 @@ taskType: "{{FM_TASK_TYPE}}"
24
24
  - Worker Roster: `{{RECOMMENDED_ANALYSERS}}`
25
25
  - Lead Model: `{{LEAD_MODEL}}`
26
26
  - Latest Run: `{{LATEST_RUN_RELATIVE_PATH}}`
27
- - Latest Report: `{{LATEST_REPORT_RELATIVE_PATH}}`
27
+ - Latest Report: `{{LATEST_REPORT_RECORD_RELATIVE_PATH}}`
28
28
  - Team State: `{{TEAM_STATE_RELATIVE_PATH}}`
29
29
  - Validation Status: `{{VALIDATION_STATUS}}`
30
30
  - Resume Command: `{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}`
@@ -14,7 +14,7 @@ Write `- PROGRESS: <stage> <ISO-8601-UTC>` there before reading and at least eve
14
14
 
15
15
  - Author the data.json at `**Result Path:**` and the audit file at `**Audit sidecar path:**`.
16
16
  - Follow the task-type schema excerpt and Phase 6 report-writer contract. Do not perform independent analysis, edit source code, or load implementation coding-preflight resources.
17
- - Invoke `okstra render-final-report <Result Path>` after writing data.json and verify the markdown sibling exists before returning.
17
+ - Do not invoke `okstra render-final-report`. The full reading copy is rendered on demand.
18
18
  - Preserve source item IDs, convergence classifications, round history, and unresolved dissent; do not recompute them from intuition.
19
19
  - Every `clarificationItems[]` row you leave `status: open` with `blocks: approval` carries `origin` and `userConfirmation`. `origin` is `worker-finding` only when an analyser or verifier reached it on its own evidence — when the lead's dispatch prompt told you to raise it, it is `lead-directed`, whatever the workers then agreed. `userConfirmation` is what the lead reports having done about it (`asked-and-answered` / `asked-awaiting` / `deferred-no-interactive-session`); if the dispatch does not say, write `asked-awaiting` and name the gap in your return message rather than guessing. An open approval blocker stops the whole task, so a row that misreports where it came from costs a re-run. **Enforced:** `validators/validate-run.py` `_validate_open_approval_blocker_provenance`.
20
20
 
@@ -15,10 +15,12 @@ Bash tool_use 명령을 phase deny-list 와 대조해 위반을 기계적으로
15
15
  한계 1: claude-code 세션 jsonl 만 스캔한다. codex/antigravity lead·worker 는 동일 형식의
16
16
  tool_use transcript 를 ~/.claude/projects 에 남기지 않으므로 이 스캐너의 사정권 밖이며,
17
17
  해당 런타임의 금지 행위는 여전히 수동 감사에 의존한다.
18
- 한계 2: deny-list 는 heredoc 본문을 걷어낸(strip_heredoc_bodies) 명령 문자열 전체를
19
- 검색하므로, 금지 토큰이 인자/메시지에 등장하는 경우(예: `git commit -m "... npm
20
- publish ..."`)는 여전히 위반으로 잡힐 수 있다. `cd <path> && <cmd>` 형태 때문에 명령
21
- head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
18
+ 한계 2: deny-list 는 heredoc 본문과 검색 명령 절을 걷어낸(strip_heredoc_bodies,
19
+ strip_search_clauses) 명령 문자열 전체를 검색하므로, 금지 토큰이 인자/메시지에
20
+ 등장하는 경우(예: `git commit -m "... npm publish ..."`)는 여전히 위반으로 잡힐 수
21
+ 있다. `cd <path> && <cmd>` 형태 때문에 명령 head 앵커링은 불가하므로 이 잔여 오탐은
22
+ 감수한다. 검색 절을 예외로 둔 것은 그 오탐이 우연이 아니라 계약이 시키는 감사에서
23
+ 반드시 발생하기 때문이다.
22
24
  """
23
25
  from __future__ import annotations
24
26
 
@@ -86,6 +88,75 @@ def strip_heredoc_bodies(command: str) -> str:
86
88
  return "\n".join(kept)
87
89
 
88
90
 
91
+ _SEARCH_HEADS = frozenset({"grep", "egrep", "fgrep", "rg", "ag", "ack"})
92
+
93
+
94
+ def _shell_clauses(command: str) -> list[str]:
95
+ """쉘 구분자로 자른 절 목록. 인용 안의 구분자는 자르지 않는다.
96
+
97
+ 검색 패턴은 거의 언제나 `'git push|npm publish'` 처럼 인용된 교대(alternation)
98
+ 이므로, 인용을 무시하고 자르면 패턴이 절 경계로 쪼개져 뒷조각이 그대로 명령처럼
99
+ 남는다.
100
+ """
101
+ clauses: list[str] = []
102
+ buffer: list[str] = []
103
+ quote = ""
104
+ index = 0
105
+ while index < len(command):
106
+ char = command[index]
107
+ if quote:
108
+ buffer.append(char)
109
+ if char == quote:
110
+ quote = ""
111
+ index += 1
112
+ continue
113
+ if char in "'\"":
114
+ quote = char
115
+ buffer.append(char)
116
+ index += 1
117
+ continue
118
+ if command.startswith(("&&", "||"), index):
119
+ clauses.append("".join(buffer))
120
+ buffer = []
121
+ index += 2
122
+ continue
123
+ if char in ";|\n":
124
+ clauses.append("".join(buffer))
125
+ buffer = []
126
+ index += 1
127
+ continue
128
+ buffer.append(char)
129
+ index += 1
130
+ clauses.append("".join(buffer))
131
+ return clauses
132
+
133
+
134
+ def strip_search_clauses(command: str) -> str:
135
+ """검색 명령 절을 걷어낸 명령 문자열.
136
+
137
+ `grep -E 'git push|npm publish' <logs>` 의 패턴은 실행되는 명령이 아니라
138
+ **찾는 대상**이다. 그리고 그 감사는 우연이 아니라 계약이 시킨다 —
139
+ `_implementation-deliverable.md` 의 자체 리뷰 항목이 리드에게 세션
140
+ 트랜스크립트에서 배포 명령을 스캔하라고 요구한다. 걷어내지 않으면 계약을
141
+ 이행한 런이 매번 그 이행 때문에 실패했다.
142
+
143
+ 절 단위로 자르므로 `cd <path> && grep …` 에서도 grep 절만 빠지고, 같은 줄
144
+ 뒤에 이어지는 실제 명령은 계속 스캔된다.
145
+ """
146
+ kept: list[str] = []
147
+ for clause in _shell_clauses(command):
148
+ tokens = clause.split()
149
+ if not tokens:
150
+ continue
151
+ head = tokens[0].rsplit("/", 1)[-1]
152
+ if head in _SEARCH_HEADS:
153
+ continue
154
+ if head == "git" and len(tokens) > 1 and tokens[1] == "grep":
155
+ continue
156
+ kept.append(clause)
157
+ return "\n".join(kept)
158
+
159
+
89
160
  def forbidden_patterns_for(task_type: str) -> list[tuple[str, re.Pattern]]:
90
161
  """task_type(=phase profile) 의 deny-list. 모든 phase 가 _UNIVERSAL 을 받고,
91
162
  release-handoff 를 제외한 모든 phase 는 bare `git push` 도 금지한다
@@ -142,7 +213,7 @@ def _scan_one(
142
213
  if block.get("type") != "tool_use" or block.get("name") != "Bash":
143
214
  continue
144
215
  command = (block.get("input") or {}).get("command") or ""
145
- executed = strip_heredoc_bodies(command)
216
+ executed = strip_search_clauses(strip_heredoc_bodies(command))
146
217
  for label, pattern in patterns:
147
218
  if pattern.search(executed):
148
219
  hits.append((label, command))
@@ -197,7 +197,7 @@ run_manifest_path = project_root / latest_run["runManifestPath"]
197
197
  run_manifest = load_json(run_manifest_path)
198
198
  team_state_path = project_root / run_manifest["teamStatePath"]
199
199
  team_state = load_json(team_state_path)
200
- report_path = project_root / run_manifest["expectedReportPath"]
200
+ report_path = project_root / run_manifest["expectedReportRecordPath"]
201
201
  final_status_path = project_root / run_manifest["expectedStatusPath"]
202
202
 
203
203
  status_by_worker_id = {
@@ -412,7 +412,17 @@ report_lines.extend(
412
412
  ]
413
413
  )
414
414
  report_path.parent.mkdir(parents=True, exist_ok=True)
415
- report_path.write_text("\n".join(report_lines) + "\n")
415
+ _report_name = report_path.name
416
+ if _report_name.endswith(".data.json"):
417
+ data_path = report_path
418
+ md_path = report_path.with_name(_report_name[: -len(".data.json")] + ".md")
419
+ elif _report_name.endswith(".md"):
420
+ md_path = report_path
421
+ data_path = report_path.with_name(_report_name[: -len(".md")] + ".data.json")
422
+ else:
423
+ md_path = report_path
424
+ data_path = report_path.with_suffix(".data.json")
425
+ md_path.write_text("\n".join(report_lines) + "\n")
416
426
 
417
427
  # Phase 7 step 1.5 (BLOCKING) — render the html sibling artifact next
418
428
  # to the final-report so validate-run.py's report-views hook passes.
@@ -460,12 +470,6 @@ if WORKSPACE_ROOT:
460
470
  {**_rows[min(i, len(_rows) - 1)], "role": role}
461
471
  for i, role in enumerate(_required)
462
472
  ]
463
- name = report_path.name
464
- data_path = (
465
- report_path.with_name(name[:-3] + ".data.json")
466
- if name.endswith(".md")
467
- else report_path.with_suffix(".data.json")
468
- )
469
473
  data_path.write_text(
470
474
  json.dumps(sample, indent=2, ensure_ascii=False) + "\n",
471
475
  encoding="utf-8",
@@ -481,7 +485,7 @@ if WORKSPACE_ROOT:
481
485
  try:
482
486
  from okstra_ctl.render_final_report import render_to_file
483
487
 
484
- render_to_file(data_path, report_path)
488
+ render_to_file(data_path, md_path)
485
489
  except Exception as exc: # pragma: no cover — fixture path only
486
490
  raise SystemExit(f"failed to render final report in fixture: {exc}")
487
491
  # Go through the same CLI the run uses, so the fixture picks the schema's
@@ -491,7 +495,7 @@ if WORKSPACE_ROOT:
491
495
  [
492
496
  _sys.executable,
493
497
  str(Path(WORKSPACE_ROOT) / "scripts" / "okstra-render-report-views.py"),
494
- str(report_path),
498
+ str(data_path),
495
499
  "--task-key", str(task_manifest.get("taskKey", "validation/fixture")),
496
500
  "--task-type", str(task_manifest.get("taskType", "validation")),
497
501
  "--seq", "001",
@@ -70,7 +70,7 @@ if latest_run is None:
70
70
  run_manifest_path = project_root / latest_run["runManifestPath"]
71
71
  run_manifest = json.loads(run_manifest_path.read_text())
72
72
  team_state_path = project_root / run_manifest["teamStatePath"]
73
- report_path = project_root / run_manifest["expectedReportPath"]
73
+ report_path = project_root / run_manifest["expectedReportRecordPath"]
74
74
  final_status_path = project_root / run_manifest["expectedStatusPath"]
75
75
 
76
76
  process = subprocess.run(
@@ -523,6 +523,9 @@ def _check_data_step_counts(
523
523
  errs.append(ValidationError("S4", meta.stage_number,
524
524
  f"stageMap stepCount {meta.step_count} != "
525
525
  f"{actual} stepwiseExecution rows"))
526
+ if actual > HARD_STEP_CAP:
527
+ errs.append(ValidationError("S5", meta.stage_number,
528
+ f"effective step count {actual} exceeds {HARD_STEP_CAP}"))
526
529
  for number in sorted(n for n in by_number if n not in {m.stage_number for m in stage_map}):
527
530
  errs.append(ValidationError("S3", number,
528
531
  "stages[] entry has no matching stageMap row"))
@@ -188,7 +188,7 @@ def validate(report_path: Path) -> list[str]:
188
188
  """Validate the html view of the report named by *report_path*.
189
189
 
190
190
  A schema-v2 report is checked against its data.json and the rendered html;
191
- the AI-handoff markdown is a sibling rendering of the same record and is
191
+ the full reading copy markdown is a sibling rendering of the same record and is
192
192
  never read here, so it does not have to exist. Schema-v1 reports keep the
193
193
  markdown as their source and still require it.
194
194
  """