okstra 0.209.6 → 0.211.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 (40) hide show
  1. package/docs/cli.md +2 -2
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/wizard/prompts.ko.json +7 -6
  5. package/runtime/python/okstra_ctl/analysis_packet.py +7 -3
  6. package/runtime/python/okstra_ctl/incremental_scope.py +11 -2
  7. package/runtime/python/okstra_ctl/manager_launch.py +2 -1
  8. package/runtime/python/okstra_ctl/manager_paths.py +4 -0
  9. package/runtime/python/okstra_ctl/phases/final_verification/target.py +5 -0
  10. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
  11. package/runtime/python/okstra_ctl/phases/implementation/profile.md +1 -1
  12. package/runtime/python/okstra_ctl/phases/implementation/report.py +66 -19
  13. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +8 -7
  14. package/runtime/python/okstra_ctl/phases/implementation/wizard.py +1 -0
  15. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +8 -3
  16. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -2
  17. package/runtime/python/okstra_ctl/phases/implementation_planning/report_assets/implementation-planning-input.template.md +1 -1
  18. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +6 -0
  19. package/runtime/python/okstra_ctl/plan_items.py +24 -3
  20. package/runtime/python/okstra_ctl/report_html/run_usage.py +9 -0
  21. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -8
  22. package/runtime/python/okstra_ctl/report_projections.py +18 -2
  23. package/runtime/python/okstra_ctl/report_synthesis_packet.py +19 -0
  24. package/runtime/python/okstra_ctl/report_translation.py +27 -6
  25. package/runtime/python/okstra_ctl/stage_close.py +13 -2
  26. package/runtime/python/okstra_ctl/stage_map.py +25 -0
  27. package/runtime/python/okstra_ctl/stage_map_cli.py +3 -2
  28. package/runtime/python/okstra_ctl/stage_targets.py +17 -3
  29. package/runtime/python/okstra_ctl/wizard/sources.py +2 -1
  30. package/runtime/python/okstra_ctl/wizard/state.py +30 -1
  31. package/runtime/python/okstra_ctl/wizard/steps_options.py +19 -0
  32. package/runtime/python/okstra_ctl/wizard/steps_plan.py +4 -2
  33. package/runtime/python/okstra_ctl/wizard/steps_roles.py +7 -1
  34. package/runtime/schemas/final-report-v3.0.schema.json +18 -0
  35. package/runtime/templates/reports/html/assets/base.css +4 -2
  36. package/runtime/templates/reports/html/base.template.html +14 -9
  37. package/runtime/templates/reports/html/i18n/en.json +91 -2
  38. package/runtime/templates/reports/html/i18n/ko.json +91 -2
  39. package/runtime/templates/reports/html/macros/forms.html +8 -7
  40. package/runtime/validators/validate-implementation-plan-stages.py +46 -5
package/docs/cli.md CHANGED
@@ -868,8 +868,8 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
868
868
  | `okstra migrate [--apply] [--cwd <dir>] [--quiet]` | One-time migration of the project artifact root from `.project-docs/okstra/` to `.okstra/`. It is a dry run by default; `--apply` performs the move with `git mv` in a Git worktree, removes an empty `.project-docs/`, and synchronizes the `<PROJECT>/CLAUDE.md` import line, `.gitignore`, the project's rows in `~/.okstra/{recent,active}.jsonl`, and `~/.okstra/worktrees/registry.json`. It exits 1 if `.okstra/` already exists or the legacy directory is absent. Scheduled for removal by the end of v0.x |
869
869
  | `okstra task-list [--project-root <path>]` | Combine `list_project_tasks` and `read_latest_task` into JSON containing the task catalog and latest task |
870
870
  | `okstra task-show <task-key> [--project-root <path>]` | Summarize workflow, phase, status, and artifacts from the Task Read-Side Snapshot |
871
- | `okstra stage-map <task-key> [--cwd <dir>\|--project <dir>]` | Dump the task's implementation-planning Stage Map as JSON: `{ ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }`. `sourcePlanPath` is the task's latest implementation-planning report — the authority on which stages exist, so a plan amendment's added stages are selectable as soon as the amended plan lands. `state` is `ready` for a resolved source and `missing` when no Stage Map exists; a corrupt source returns a structured non-zero error instead of silently selecting another report. `doneStages` is read from the implementation-planning stage consumer state (with carry recovery). This is the read-side source `/okstra-schedule-gen [task-group]` uses to derive selectable unfinished stages and their completed dependency closure |
872
- | `okstra stage-close <task-key> --stage <N> --from-commit <sha> [--cwd <dir>\|--project <dir>]` | Record the `done` stage-consumer row an implementation run would have written, for a stage whose work is already committed but which never registered as done — the run ended before writing its carry sidecar, and `backfill_done_from_carry` recovers only from that file. Without it `stage-map` reports `doneStages: []` while the branch carries the commit, the next plan re-describes the stage, and every RED expectation it produces is unreachable. Refuses unless the Stage Map has that stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a commit in the project repo, and the stage's conformance gate permits progress — the same `decide_conformance_gate` the run validator uses, so a stage closed here is not one the validator would have blocked. Closing also moves the `stage-<N>-exit` tag and releases the stage reservation, exactly as a normal stage completion does. Emits `{ ok, taskKey, taskRoot, stage, headCommit, conformance, consumersPath }` |
871
+ | `okstra stage-map <task-key> [--cwd <dir>\|--project <dir>]` | Dump the task's implementation-planning Stage Map as JSON: `{ ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count,cancelled}], doneStages:[int] }`. Each stage row carries `cancelled: true` when the plan marks that Stage Map row `status: cancelled`; such a stage is never selected, cannot be closed, and is left out of whole-task final verification and release handoff. `sourcePlanPath` is the task's latest implementation-planning report — the authority on which stages exist, so a plan amendment's added stages are selectable as soon as the amended plan lands. `state` is `ready` for a resolved source and `missing` when no Stage Map exists; a corrupt source returns a structured non-zero error instead of silently selecting another report. `doneStages` is read from the implementation-planning stage consumer state (with carry recovery). This is the read-side source `/okstra-schedule-gen [task-group]` uses to derive selectable unfinished stages and their completed dependency closure |
872
+ | `okstra stage-close <task-key> --stage <N> --from-commit <sha> [--cwd <dir>\|--project <dir>]` | Record the `done` stage-consumer row an implementation run would have written, for a stage whose work is already committed but which never registered as done — the run ended before writing its carry sidecar, and `backfill_done_from_carry` recovers only from that file. Without it `stage-map` reports `doneStages: []` while the branch carries the commit, the next plan re-describes the stage, and every RED expectation it produces is unreachable. Refuses unless the Stage Map has that stage and does not mark it cancelled, no `done`/`failed` row exists for it, `--from-commit` resolves to a commit in the project repo, and the stage's conformance gate permits progress — the same `decide_conformance_gate` the run validator uses, so a stage closed here is not one the validator would have blocked. Closing also moves the `stage-<N>-exit` tag and releases the stage reservation, exactly as a normal stage completion does. Emits `{ ok, taskKey, taskRoot, stage, headCommit, conformance, consumersPath }` |
873
873
  | `okstra incremental-scope <args…>` | Decide re-verify vs carry-forward scope for an `implementation-planning` clarification re-run. Thin shim into `scripts/okstra_ctl/incremental_scope.py` (deterministic): it reads the dependency graph from the prior run `data.json`'s `implementationPlanning.stageMap` and returns `mode:"incremental"` only when the base-ref SHA is unchanged and the affected stages' `downstream_stage_closure` covers at most half of all stages; `--full-reason` (selected option / Stage Map / approach) still forces `mode:"full"`. An answered `C-NNN` that traces to no stage returns `mode:"unresolved"` rather than full — pass `--impacted` with the stage numbers or `--full-reason`. `--run-manifest <path>` is required for a decision (not for `--preview`): the same decision is written to the record that manifest names in `incrementalDecisionPath`, so the report writer's authoring contract and `okstra incremental-carry` read it instead of CSVs the lead re-typed. `mode: "unresolved"` is a question back to the lead and is deliberately not recorded. `--preview --prev-data <path> --answered-clarifications <csv>` runs the link half alone — no base SHA, no side effects — and prints `{wouldForceFull, unlinkedIds, reason}`; unlinked ids set `wouldForceFull: false` and fill `unlinkedIds` |
874
874
  | `okstra incremental-carry <args…>` | Merge carried-forward plan-item verdicts into an incremental re-run. Contract v3 takes `--prev-data`, `--cur-narrative`, and the convergence-owned `--state`; it verifies carried stage rows and writes only `--out-state`, tagging copied verdicts with `carriedForwardFromSeq`. Pass `--decision <incrementalDecisionPath>` for the two stage sets — the same record the report writer's authoring contract was built from — instead of `--carry-stages` / `--reverify-stages`, which the lead re-typed off stdout; the two forms cannot be combined, and a record whose `mode` is not `incremental` is refused. Unchanged `P-Val-*` / `P-Req-*` / `P-Rb-*` rows whose extract hash still matches are carried the same way, and the sibling `plan-items-*.json` `dispatchQueue` is rewritten to match. The historical v2 `--cur-data --out` form remains readable. Ownership, scope, item, or schema drift raises `CarryError` and forces a full fallback. |
875
875
  | `okstra code-review target --task-key <k> --stage <N> [--project-root <dir>] [--cwd <dir>] [--json]` / `okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] [--cwd <dir>] [--json]` | Resolve what a code review reads and where its result file goes. Output is always JSON, so `--json` only makes that explicit. `--project-root` and `--cwd` are shared pre-dispatch arguments and apply to both modes; `--cwd` is only consulted when `--project-root` is absent. Both modes return `{ ok, projectRoot, mode, worktreePath, branch, baseCommit, headCommit, reviewPath, round }`; stage mode additionally returns `taskKey`, `taskRoot`, and `stage`. Stage mode takes the diff base from the `base_ref` recorded on that stage's worktree-registry row when it was provisioned — not from a rule re-applied at review time — and names the result `.okstra/tasks/<task-group>/<task-id>/code-reviews/stage-<NN>.md`, where a re-review of the same stage becomes `-r2`, `-r3`, … (the `round` field). Only a legacy row provisioned before `base_ref` was recorded falls back to re-deriving the base through `stage_targets`, and a failure there is reported as `stage_base_unresolved`. `worktreePath` comes back empty whenever the stage worktree is not usable as a live checkout — the registry row is no longer `active` (whole-task final-verification released it), the row never carried a path, or the recorded directory is gone — and the review then reads the `branch` ref instead. Branch mode uses `--base` when given, otherwise the merge-base with the default branch (`refs/remotes/origin/HEAD`, else `main`/`master`), and names the result `.project-docs/code-reviews/<branch>/<YYYY-MM-DD>-<NN>.md`, where `<NN>` (the `round` field) is the next sequence number for that date — the highest already on disk plus one. Read-only: it resolves paths and creates no directory and no file, so the review directory does not exist until the caller writes the report. Backend for the okstra-code-review skill |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.209.6",
3
+ "version": "0.211.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.209.6",
3
- "builtAt": "2026-10-05T11:18:29.866Z",
2
+ "package": "0.211.0",
3
+ "builtAt": "2026-10-06T01:25:20.915Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -354,16 +354,17 @@
354
354
  "mark_done": "[완료]",
355
355
  "mark_active": "[진행중]",
356
356
  "mark_ready": "[준비됨]",
357
- "mark_blocked": "[대기]"
357
+ "mark_blocked": "[대기]",
358
+ "mark_cancelled": "[취소됨]"
358
359
  },
359
360
  "errors": {
360
361
  "none_selected": "stage 를 하나 이상 선택하세요.",
361
362
  "whole_task_impl": "전체 task 검증은 final-verification 에서만 선택할 수 있습니다.",
362
363
  "all_exclusive": "'전체' 는 단독으로만 선택할 수 있습니다.",
363
- "nothing_selectable": "선택 가능한 stage 가 없습니다 (모두 완료/진행중).",
364
+ "nothing_selectable": "선택 가능한 stage 가 없습니다 (모두 완료/진행중/취소됨).",
364
365
  "bad_number": "stage 번호가 올바르지 않습니다: {answer}",
365
366
  "unknown_stage": "Stage Map 에 없는 stage 입니다: {bad}",
366
- "occupied": "이미 완료/진행중인 stage 는 선택할 수 없습니다: {bad}"
367
+ "occupied": "이미 완료/진행중이거나 취소된 stage 는 선택할 수 없습니다: {bad}"
367
368
  },
368
369
  "echo_variants": {
369
370
  "plain": "stages: {stages}",
@@ -371,14 +372,14 @@
371
372
  }
372
373
  },
373
374
  "directive_pick": {
374
- "label": "추가 directive 가 있나요?",
375
+ "label": "이번 run 에 덧붙일 추가 지시(directive)를 고르세요. directive 는 instruction-set/directive.txt 로 저장되어 리드와 워커가 읽습니다. 브리프와 이전 리포트에 없는 새 결정(직전 run 이후 정한 사항 등)은 여기에 넣어야 계획에 반영됩니다.",
375
376
  "echo_template": "directive(pick): {value}",
376
377
  "options": {
377
378
  "__skip__": "없음 (건너뛰기)",
378
379
  "__free_input__": "직접 입력"
379
380
  },
380
381
  "labels": {
381
- "reuse_last": "이전 run 의 directive 재사용: {snippet}"
382
+ "reuse_last": "이전 run 의 directive 재사용: {snippet}", "context_file": " — 이 directive 는 파일 {path} 을 읽으라는 지시이고, 그 파일은 {date} 에 마지막으로 바뀌었습니다. 이후 정한 결정은 들어 있지 않습니다", "context_missing": " — 이 directive 가 가리키는 파일 {path} 이 없습니다"
382
383
  },
383
384
  "echo_suffixes": {
384
385
  "reuse": "directive: {value} (재사용)",
@@ -476,7 +477,7 @@
476
477
  }
477
478
  },
478
479
  "reuse_previous": {
479
- "label": "이 task·phase 의 직전 run 설정이 남아 있습니다. 그대로 재사용할까요?\n· 예 — 직전 run 의 워커 구성·역할별 모델·directive·관련 task 를 그대로 가져오고, 가장 최근 final-report 를 clarification 입력으로 자동 선택해 바로 확인 단계로 넘어갑니다.\n· 아니오 — 모델/워커/directive 등을 단계별로 다시 입력합니다.",
480
+ "label": "이 task·phase 의 직전 run 설정이 남아 있습니다. 그대로 재사용할까요?\n· 예 — 직전 run 의 워커 구성·역할별 모델·directive·관련 task 를 가져오고, 가장 최근 final-report 를 clarification 입력으로 자동 선택합니다. 가져온 항목은 다시 묻지 않고, 재사용 대상이 아닌 단계(기준 브랜치·재검증 범위 등)는 이어서 묻습니다.\n· 아니오 — 모델/워커/directive 등을 단계별로 다시 입력합니다.\n재사용할 directive: {directive}\n직전 run 이후 새로 정한 결정이 있으면 이 directive 에 없습니다. 확인 단계의 수정에서 directive_pick 을 고르면 directive 부터 다시 고를 수 있습니다(역할별 모델은 유지).",
480
481
  "echo_template": "reuse-previous: {value}",
481
482
  "options": {
482
483
  "yes": "예 — 직전 설정 그대로 진행",
@@ -535,9 +535,13 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
535
535
  "",
536
536
  "`stages` lists every stage the latest plan declares, so every number",
537
537
  "in it is taken. Never reuse or renumber one: a new stage takes the",
538
- "next number after the highest listed here, and reworking a completed",
539
- "stage means cancelling it and adding a new number, never editing it",
540
- "in place. The new plan declares every stage listed here as well as the",
538
+ "next number after the highest listed here. Changing a `done` stage's",
539
+ "work means keeping it and adding a correction stage with a new number,",
540
+ "never editing it in place. Only a `ready` or `blocked` stage may be",
541
+ "cancelled: keep its row and body where they are and set its",
542
+ "`stageMap[]` row's `status` to `cancelled`. A `done` or `active` stage",
543
+ "is never cancelled, and no remaining stage may depend on a cancelled",
544
+ "one. The new plan declares every stage listed here as well as the",
541
545
  "ones it adds — its rows run 1..N with no gap — because a plan whose",
542
546
  "rows start above 1 is refused by every consumer that parses a Stage",
543
547
  "Map. `sourcePlan` is the plan the completed stages were built",
@@ -102,8 +102,17 @@ def _parse_depends_on(cell: str) -> list[int]:
102
102
 
103
103
 
104
104
  def parse_stage_graph(data: dict) -> list[tuple[int, list[int]]]:
105
+ """재검증·이월 대상이 될 수 있는 stage 그래프 — 취소 행은 뺀다.
106
+
107
+ 취소 stage 는 다시 계획되지도 이월되지도 않는다. 세면 컷오프 분모가 부풀어
108
+ 좁혀야 할 재실행이 증분으로 남거나, 지목할 수 없는 번호가 선택지에 오른다.
109
+ """
105
110
  stage_map = data.get("implementationPlanning", {}).get("stageMap", [])
106
- return [(int(row["stage"]), _parse_depends_on(row.get("dependsOn", ""))) for row in stage_map]
111
+ return [
112
+ (int(row["stage"]), _parse_depends_on(row.get("dependsOn", "")))
113
+ for row in stage_map
114
+ if row.get("status") != "cancelled"
115
+ ]
107
116
 
108
117
 
109
118
  def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
@@ -363,7 +372,7 @@ def decide_scope(
363
372
  )
364
373
  all_stages = {num for num, _ in stages}
365
374
  closure = downstream_stage_closure(stages, set(impacted_stages))
366
- if len(closure) * 2 > len(all_stages):
375
+ if len(closure) > cutoff_ratio * len(all_stages):
367
376
  return IncrementalDecision(
368
377
  "full", [], [],
369
378
  f"the answers reach {len(closure)} of the plan's {len(all_stages)} stages — "
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from okstra_ctl.jsonl import append_jsonl, read_jsonl
8
8
 
9
9
  from .manager_paths import (
10
+ MANAGER_CONTEXT_DIRECTIVE_PREFIX,
10
11
  child_context_path,
11
12
  children_json_path,
12
13
  directives_jsonl_path,
@@ -244,7 +245,7 @@ def build_launch_packet(
244
245
  run_args.extend(["--task-brief", str(child["briefPath"])])
245
246
  run_args += [
246
247
  "--directive",
247
- f"Read manager child context: {context_path}",
248
+ f"{MANAGER_CONTEXT_DIRECTIVE_PREFIX}{context_path}",
248
249
  ]
249
250
  packet = {
250
251
  "managerId": manager_id,
@@ -118,3 +118,7 @@ def child_context_path(
118
118
 
119
119
  def view_html_path(home: Path, manager_id: str) -> Path:
120
120
  return manager_root(home, manager_id) / "view" / "index.html"
121
+
122
+
123
+ # manager 가 자식 run 에 넘기는 directive 의 앞부분. 위저드는 이 접두로 컨텍스트 파일을 찾는다.
124
+ MANAGER_CONTEXT_DIRECTIVE_PREFIX = "Read manager child context: "
@@ -24,6 +24,7 @@ from okstra_ctl.locks import worktree_provision_mutex
24
24
  from okstra_ctl.plan_run_root import plan_run_root_from_approved_plan
25
25
  from okstra_ctl.prepare_error import PrepareError
26
26
  from okstra_ctl.stage_integrate import IntegrateResult
27
+ from okstra_ctl.stage_map import active_stage_records
27
28
  from okstra_ctl.stage_reconcile import auto_reconcile_best_effort
28
29
  from okstra_ctl.stage_targets import (
29
30
  FinalVerificationTarget,
@@ -274,6 +275,10 @@ def acquire_final_verification_target(
274
275
  slugify(request.task_group),
275
276
  slugify(request.task_id),
276
277
  ):
278
+ # 취소된 stage 는 완료를 요구받지 않고 통합·tip 선택에서도 빠진다.
279
+ request = replace(
280
+ request, stage_map=tuple(active_stage_records(request.stage_map)),
281
+ )
277
282
  registry_coordinates = _final_verification_registry_coordinates(request)
278
283
  done_rows = _read_final_verification_done_rows(
279
284
  request,
@@ -12,11 +12,12 @@ are collected and convergence finished. Phase 1-5 do not need it.
12
12
 
13
13
  - **Plan link & approval evidence**: path to the approved `final-report.md`, the exact quoted approval marker, AND the executed stage number / title quoted from the Stage Map row. For a selected-direction plan, also quote `selectedDirectionRef.optionId`, `snapshotPath`, and the validated snapshot digest; for a legacy plan, quote the effective `implementation-option` or the Recommended Option fallback.
14
14
  - **Commit list**: each commit's SHA (or short SHA), message, and the plan step(s) / TDD cycle it satisfies
15
- - **Diff summary**: capture `git diff --stat <base>..<captured-head>` and `git diff --numstat <base>..<captured-head>` for the full implementation stage, including after a fix run. Supply both outputs to the report writer; populate each `diffSummary.files[].lines` with its added/deleted counts and a one-line change summary. Do not substitute a fix-run aggregate or carry forward older statistics. If the captured commits are unavailable, identify the missing reference in the evidence instead of inventing counts.
15
+ - **Diff summary**: capture `git diff --stat <base>..<captured-head>` and `git diff --numstat <base>..<captured-head>` for the full implementation stage, including after a fix run. Supply both outputs to the report writer; put each file's added/deleted counts alone in `diffSummary.files[].lines` (`+150/-0`) and one sentence on what the change in that file does in `diffSummary.files[].summary` — the report renders `summary` as the file's "what it does" column and `lines` as a separate number column. Do not substitute a fix-run aggregate or carry forward older statistics. If the captured commits are unavailable, identify the missing reference in the evidence instead of inventing counts.
16
+ - **Change narrative scope**: `userNarrative.changeExplanation` describes the same scope as `diffSummary` — the whole stage from its base, every file the table lists. On a fix run, state what the fix run itself changed (its commits and files) as a separate sentence; never present the fix-run delta as the stage's change.
16
17
  - **Out-of-plan edits block**: every file edited that was not in the approved plan's file list, with rationale (empty block is acceptable and preferred)
17
18
  - **Clarification answers carried in**: when `instruction-set/clarification-response.md` exists, it holds the user's answers to the approved plan's `## 1. Clarification Items` rows. The report MUST render the `## 0. Clarification Response Carried In From Previous Run` section (set `clarificationCarryIn.sourceFile` to that path) and, for every carried-over `## 1.` row whose answer appears there, record the answer in the `User input` cell and flip `Status` to `resolved`. A row left `open`/`answered` despite a matching user answer is a contract violation; an answer that materially changes scope beyond the approved plan is routed to a new `implementation-planning` run, not silently executed.
18
- - **Stage sidecar evidence**: the JSON payload of `runs/<impl-task-key>/carry/stage-<N>.json` is embedded verbatim in a fenced ```json``` block, AND the `consumers.jsonl` rows this run appended are quoted line-by-line, so reviewers can audit the carry surface without grepping artifact directories.
19
- - **Validation evidence**: actual command output (stdout/stderr) for every `pre / mid / post` validation command from the plan. Truncated output is acceptable but the command line and exit code MUST be exact. No paraphrasing of test results.
19
+ - **Stage sidecar evidence**: set `stageSidecarEvidence.carryJson` to the path `runs/<impl-task-key>/carry/stage-<N>.json` followed by a one-line summary of its payload — stage number, step results by status, stage commit range, number of files changed. Do not paste the payload itself: the file on disk is the record (`_validate_stage_carry_sidecar_exists` checks that it exists), and a pasted one-line JSON is unreadable in the report. Quote the `consumers.jsonl` rows this run appended line-by-line in `consumerRows`.
20
+ - **Validation evidence**: actual command output (stdout/stderr) for every `pre / mid / post` validation command from the plan. Truncated output is acceptable but the command line and exit code MUST be exact. No paraphrasing of test results. Set `checkId` to the plan's `validationChecklist` id (`VC-007`) when the row runs a checklist command, and `advisory: true` on a row whose check is advisory (an external Tier 3 conformance entry, for example) so a non-zero exit there is not shown as a failure.
20
21
  - **TDD evidence (when applicable)**: for steps that should be TDD-ordered, show the failing-test output captured BEFORE the merged commit and the passing-test output after, citing the single `feat|fix` commit SHA (test and implementation share one commit).
21
22
  - **Verifier results**: a section per verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, and `Antigravity verifier` when opted in) containing:
22
23
  - their independent verdict (PASS / CONCERNS / FAIL),
@@ -66,4 +67,4 @@ are collected and convergence finished. Phase 1-5 do not need it.
66
67
  - On a non-`FAIL` verdict, for this run's single stage: append a `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` with `completed_at`, `carry_path`, `report_path` (this run's final-report path relative to the run root), and the SHA of HEAD. Append it with `okstra_ctl.consumers.append_consumer` (NOT a raw filesystem write) — that call honours the consumers lock AND releases this stage's worktree-registry occupancy, so later runs stop seeing a finished stage as a concurrent run. `report_path` lets `final-verification` cite each stage's originating report when assembling its Source Implementation Report list.
67
68
  - **Reopening a settled stage.** Appending a `status:"failed"` row after a `done` one withdraws that stage: `--stage N` accepts it again, its dependents stop resolving a base from the withdrawn head, and whole-task final-verification blocks until it is settled again. Use the same `okstra_ctl.consumers.append_consumer` call with a `reason`, and re-run the stage rather than repairing the tree outside okstra — a stage reverted outside the ledger leaves the recorded `head_commit` and the carry sidecar naming a tree that no longer exists, and the next stage branches from it.
68
69
  - The verifier round, Phase 5.5 convergence, and this Phase 6 report run **once per run** over this stage's diff — NOT per step.
69
- - Quote this stage's new contents (the sidecar JSON in full and the new consumers row by itself) in the final report's `Stage sidecar evidence` deliverable section.
70
+ - Record this stage's new contents in the final report's `Stage sidecar evidence` deliverable section: the sidecar by path with its one-line summary, and the new consumers row quoted by itself.
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Purpose: realise the approved `implementation-planning` deliverable as actual source changes, with cross-model verification, while keeping the run reversible
4
4
  - **Run-level fixed cost:** the verifier set, Phase 5.5 convergence, and the Phase 6 report-writer run exactly once per implementation run, over this run's single stage diff — never once per step.
5
- - **Fix run (profile carries a "Fix-Run Carry" block):** the executor's scope is the carried blocking findings plus the previous routing recommendation — it MUST NOT re-execute plan steps the previous run completed. Verifiers apply the "Fix-run incremental scope" section of `_implementation-verifier.md`; the report writer applies "Fix-run incremental authoring" in `report-writer.md`. The full validation-command re-run is NOT reduced.
5
+ - **Fix run (profile carries a "Fix-Run Carry" block):** the executor's scope is the carried blocking findings plus the previous routing recommendation — it MUST NOT re-execute plan steps the previous run completed. Verifiers apply the "Fix-run incremental scope" section of `_implementation-verifier.md`. The full validation-command re-run is NOT reduced.
6
6
  - **Executor binding (resolved at run-prep time, fixed for this run):**
7
7
  - Executor display name: `{{EXECUTOR_DISPLAY_NAME}}`
8
8
  - Executor worker ID: `{{EXECUTOR_WORKER_ID}}`
@@ -1,9 +1,12 @@
1
1
  """Human-first implementation delivery view model."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
5
+ import re
6
+ from collections import Counter
7
+
4
8
  from okstra_ctl.report_html.common import evidence_index
5
- from okstra_ctl.report_html.models import HumanReportView, VisualNode
6
- from okstra_ctl.report_html.visualizations import change_map_figure
9
+ from okstra_ctl.report_html.models import HumanReportView
7
10
 
8
11
  # Record fields this template anchors as `id-<row id>` (see
9
12
  # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
@@ -13,39 +16,83 @@ ANCHORED_FIELDS = (
13
16
  )
14
17
 
15
18
 
16
- _FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
19
+ def _carry_payload(text: str) -> dict | None:
20
+ start = text.find("{")
21
+ if start < 0:
22
+ return None
23
+ try:
24
+ payload, _ = json.JSONDecoder().raw_decode(text[start:])
25
+ except json.JSONDecodeError:
26
+ # expected-miss: carryJson 은 경로+요약 산문일 수 있다. 그때는 본문에 글 그대로 보인다.
27
+ return None
28
+ return payload if isinstance(payload, dict) else None
17
29
 
18
30
 
19
- def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
20
- return tuple(
21
- VisualNode(
22
- row["file"],
23
- row["file"],
24
- row["planStep"],
25
- row["action"],
26
- row["lines"],
27
- note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
28
- )
29
- for row in implementation["diffSummary"]["files"]
31
+ def _carry_view(evidence: dict | None) -> dict | None:
32
+ if not evidence:
33
+ return None
34
+ text = evidence["carryJson"]
35
+ payload = _carry_payload(text)
36
+ if payload is None:
37
+ return {"text": text}
38
+ commit_range = payload.get("stageCommitRange") or {}
39
+ base, head = commit_range.get("base"), commit_range.get("head")
40
+ steps = Counter(
41
+ str(row.get("status"))
42
+ for row in payload.get("stepResults") or ()
43
+ if isinstance(row, dict)
30
44
  )
45
+ files = payload.get("filesChanged")
46
+ return {
47
+ "path": f"carry/stage-{evidence['stageNumber']}.json",
48
+ "commitRange": f"{base[:8]}..{head[:8]}" if base and head else None,
49
+ "steps": ", ".join(f"{status} {count}" for status, count in steps.items()),
50
+ "filesChanged": len(files) if isinstance(files, list) else None,
51
+ "raw": json.dumps(payload, indent=2, ensure_ascii=False),
52
+ }
53
+
54
+
55
+ _LEADING_CHECK_ID = re.compile(r"^(VC-\d{3,})\b")
56
+ _COMMAND_PREVIEW = 60
57
+
58
+
59
+ def _validation_card(row: dict) -> dict:
60
+ # checkId 가 없는 진행 중 task 는 writer 가 outputTail 머리에 붙인 VC id 를 보여 준다(표시 전용).
61
+ check_id = row.get("checkId")
62
+ if not check_id:
63
+ match = _LEADING_CHECK_ID.match(row["outputTail"])
64
+ check_id = match.group(1) if match else None
65
+ command = row["command"]
66
+ preview = command if len(command) <= _COMMAND_PREVIEW else command[: _COMMAND_PREVIEW - 1] + "…"
67
+ if not row["exitCode"]:
68
+ tone = "neutral"
69
+ elif row.get("advisory"):
70
+ tone = "medium"
71
+ else:
72
+ tone = "important"
73
+ return {**row, "checkId": check_id, "commandPreview": preview, "tone": tone}
31
74
 
32
75
 
33
76
  def build_implementation_view(data: dict) -> HumanReportView:
34
77
  implementation = data["implementation"]
35
- figure = change_map_figure(
36
- nodes=_change_nodes(implementation), title="Delivered change areas"
37
- )
38
78
  context = {
39
79
  "humanSummary": data["humanSummary"],
40
80
  "implementation": implementation,
41
81
  "narrative": implementation["userNarrative"],
42
- "changeFigure": figure,
82
+ # 진행 중인 task 의 data.json 에는 파일별 summary 가 없다. 빈 열을 그리지 않는다.
83
+ "diffHasSummary": any(
84
+ row.get("summary") for row in implementation["diffSummary"]["files"]
85
+ ),
86
+ "validationCards": [
87
+ _validation_card(row) for row in implementation["validationEvidence"]
88
+ ],
89
+ "carry": _carry_view(implementation.get("stageSidecarEvidence")),
43
90
  "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
44
91
  }
45
92
  return HumanReportView(
46
93
  "implementation",
47
94
  "html/tasks/implementation.template.html",
48
95
  context,
49
- (figure,),
96
+ (),
50
97
  anchored_fields=ANCHORED_FIELDS,
51
98
  )
@@ -1,6 +1,5 @@
1
1
  {% extends "html/base.template.html" %}
2
2
  {% from "html/macros/layout.html" import narrative as render_narrative, summary_card, row_key, file_paths %}
3
- {% from "html/macros/visualizations.html" import figure %}
4
3
 
5
4
  {% block human_content %}
6
5
  <section data-report-section="delivered-outcome">
@@ -12,19 +11,19 @@
12
11
  <section data-report-section="change-map" data-report-field="implementation.diffSummary">
13
12
  <h2>{{ t('tasks.implementation.what-changed') }}</h2>
14
13
  {{ render_narrative(narrative.changeExplanation, "implementation.userNarrative.changeExplanation") }}
15
- {{ figure(changeFigure) }}
14
+ <table><thead><tr><th>{{ t('tasks.implementation.file') }}</th><th>{{ t('tasks.implementation.change') }}</th>{% if diffHasSummary %}<th>{{ t('tasks.implementation.what-it-does') }}</th>{% endif %}<th class="figure">{{ t('tasks.implementation.lines') }}</th><th>{{ t('tasks.implementation.plan-step') }}</th></tr></thead><tbody>{% for row in implementation.diffSummary.files %}<tr><td><code>{{ row.file }}</code></td><td>{{ t('tasks.implementation.action-' ~ row.action) }}</td>{% if diffHasSummary %}<td>{{ row.get('summary', '') | inline_code }}</td>{% endif %}<td class="figure">{{ row.lines }}</td><td>{{ row.planStep | inline_code }}</td></tr>{% endfor %}</tbody></table>
16
15
  </section>
17
16
 
18
17
  <section data-report-section="requirement-coverage">
19
18
  <h2>{{ t('tasks.implementation.requirement-coverage') }}</h2>
20
- <table data-report-field="implementation.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation.requirement') }}</th><th>{{ t('tasks.implementation.body') }}</th><th>{{ t('tasks.implementation.implemented-by') }}</th></tr></thead><tbody>{% for row in implementation.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id)], status_name=t('macros.layout.status'), status_raw=row.status, status_text=row.status) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
19
+ <table data-report-field="implementation.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation.requirement') }}</th><th>{{ t('tasks.implementation.body') }}</th><th>{{ t('tasks.implementation.implemented-by') }}</th></tr></thead><tbody>{% for row in implementation.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id)], status_name=t('macros.layout.status'), status_raw=row.status, status_text=(row.status.split(' ')[0] | enum_label('requirementStatus')) ~ ((' ' ~ row.status.split(' ', 1)[1]) if ' ' in row.status else '')) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
21
20
  </section>
22
21
 
23
22
  <section data-report-section="validation" data-report-field="implementation.validationEvidence">
24
23
  <h2>{{ t('tasks.implementation.verification-result') }}</h2>
25
24
  {{ render_narrative(narrative.validationExplanation, "implementation.userNarrative.validationExplanation") }}
26
- <div class="summary-grid">{% for row in implementation.validationEvidence %}{{ summary_card(row.phase ~ " · " ~ t('tasks.implementation.exit-code') ~ " " ~ row.exitCode, row.outputTail, "important" if row.exitCode else "neutral") }}{% endfor %}</div>
27
- {% for row in implementation.verifierResults %}<article class="summary-card tone-{{ row.verdict | lower }}"><p class="eyebrow">{{ row.verifier | inline_code }} · <span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span></p>{% if row.get("independentValidationRerun") %}<p><strong>{{ t('tasks.implementation.independent-rerun') }}</strong> {{ row.independentValidationRerun | inline_code }}</p>{% endif %}{% if row.get("readOnlyCommandLog") %}<p><strong>{{ t('tasks.implementation.command-log') }}</strong> {{ row.readOnlyCommandLog | inline_code }}</p>{% endif %}{% if row.get("discrepancy") %}<p class="evidence-refs">{{ t('tasks.implementation.discrepancy') }} {{ row.discrepancy | inline_code }}</p>{% endif %}{% if row.get("declinedFixRecommendations") %}<p>{{ row.declinedFixRecommendations | inline_code }}</p>{% endif %}</article>{% endfor %}
25
+ <div class="summary-grid">{% for row in validationCards %}<article class="summary-card tone-{{ row.tone }}"><h3>{% if row.checkId %}{{ row.checkId }} · {% endif %}{{ row.phase }} · {{ t('tasks.implementation.exit-code') }} {{ row.exitCode }}{% if row.get("advisory") %} · {{ t('tasks.implementation.advisory') }}{% endif %}</h3><p><code title="{{ row.command }}">{{ row.commandPreview }}</code></p><p>{{ row.outputTail | inline_code }}</p></article>{% endfor %}</div>
26
+ {% for row in implementation.verifierResults %}<article class="summary-card tone-{{ row.verdict | lower }}"><p class="eyebrow">{{ row.verifier | inline_code }} · <span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span></p>{% for label, text in ((t('tasks.implementation.independent-rerun'), row.get("independentValidationRerun")), (t('tasks.implementation.command-log'), row.get("readOnlyCommandLog"))) if text %}<details><summary><strong>{{ label }}</strong> {{ t('tasks.implementation.entry-count') | replace('{count}', text.strip().splitlines() | select | list | length | string) }}</summary><pre>{{ text }}</pre></details>{% endfor %}{% if row.get("discrepancy") %}<p class="evidence-refs">{{ t('tasks.implementation.discrepancy') }} {{ row.discrepancy | inline_code }}</p>{% endif %}{% if row.get("declinedFixRecommendations") %}<p>{{ row.declinedFixRecommendations | inline_code }}</p>{% endif %}</article>{% endfor %}
28
27
  </section>
29
28
 
30
29
  <section data-report-section="remaining-issues">
@@ -42,8 +41,10 @@
42
41
 
43
42
  <section data-report-section="rollback" data-report-field="implementation.rollbackVerification">
44
43
  <h2>{{ t('tasks.implementation.can-this-be-rolled-back') }}</h2>
45
- <table><thead><tr><th>{{ t('tasks.implementation.subject') }}</th><th>{{ t('tasks.implementation.rollback-command') }}</th><th>{{ t('tasks.implementation.how-to-verify') }}</th><th>{{ t('tasks.implementation.result') }}</th></tr></thead><tbody>{% for row in implementation.rollbackVerification %}<tr><td>{{ row.category | inline_code }}</td><td><code>{{ row.rollbackCommand }}</code></td><td>{{ row.verification | inline_code }}</td><td>{{ row.result | inline_code }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation.no-rollback-check-was-recorded') }}</td></tr>{% endfor %}</tbody></table>
46
- {% if implementation.stageSidecarEvidence %}<p data-report-field="implementation.stageSidecarEvidence"><strong>{{ t('tasks.implementation.stage-carry-over') }}</strong> — Stage {{ implementation.stageSidecarEvidence.stageNumber }} {{ implementation.stageSidecarEvidence.stageTitle | inline_code }} · <code>{{ implementation.stageSidecarEvidence.carryJson }}</code></p>{% endif %}
44
+ <table><thead><tr><th>{{ t('tasks.implementation.subject') }}</th><th>{{ t('tasks.implementation.rollback-command') }}</th><th>{{ t('tasks.implementation.how-to-verify') }}</th><th>{{ t('tasks.implementation.result') }}</th></tr></thead><tbody>{% for row in implementation.rollbackVerification %}<tr><td>{{ row.category | enum_label('rollbackCategory') }}</td><td><code>{{ row.rollbackCommand }}</code></td><td>{{ row.verification | inline_code }}</td><td>{{ row.result | enum_label('rollbackResult') }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation.no-rollback-check-was-recorded') }}</td></tr>{% endfor %}</tbody></table>
45
+ {% if implementation.stageSidecarEvidence %}<div data-report-field="implementation.stageSidecarEvidence"><p><strong>{{ t('tasks.implementation.stage-carry-over') }}</strong> — Stage {{ implementation.stageSidecarEvidence.stageNumber }} {{ implementation.stageSidecarEvidence.stageTitle | inline_code }}</p>
46
+ {% if carry.get("text") %}<p>{{ carry.text | inline_code }}</p>{% else %}<table><tbody><tr><th>{{ t('tasks.implementation.carry-sidecar') }}</th><td><code>{{ carry.path }}</code></td></tr>{% if carry.commitRange %}<tr><th>{{ t('tasks.implementation.carry-commit-range') }}</th><td><code>{{ carry.commitRange }}</code></td></tr>{% endif %}{% if carry.steps %}<tr><th>{{ t('tasks.implementation.carry-steps') }}</th><td>{{ carry.steps }}</td></tr>{% endif %}{% if carry.filesChanged is not none %}<tr><th>{{ t('tasks.implementation.carry-files-changed') }}</th><td>{{ carry.filesChanged }}</td></tr>{% endif %}</tbody></table>
47
+ <details><summary>{{ t('tasks.implementation.carry-raw') }}</summary><pre>{{ carry.raw }}</pre></details>{% endif %}</div>{% endif %}
47
48
  </section>
48
49
 
49
50
  <section data-report-section="manual-test" data-report-field="implementation.manualUserTest">
@@ -9,6 +9,7 @@ _STAGE_MARKER_KEYS = {
9
9
  "active": "mark_active",
10
10
  "ready": "mark_ready",
11
11
  "blocked": "mark_blocked",
12
+ "cancelled": "mark_cancelled",
12
13
  }
13
14
 
14
15
 
@@ -1284,7 +1284,8 @@ def _stage_ledger_snapshot(run_manifest: Path | None) -> dict[str, str] | None:
1284
1284
  """`{스테이지 번호: 상태}`. 원장을 못 읽으면 ``None``.
1285
1285
 
1286
1286
  게이트가 범위를 좁히려면 어느 스테이지가 지금 시작 가능한지 알아야 한다. 그
1287
- 사실은 Stage 원장에 있고, 상태 어휘(`done` / `active` / `ready` / `blocked`)는
1287
+ 사실은 Stage 원장에 있고, 상태 어휘(`done` / `cancelled` / `active` / `ready` /
1288
+ `blocked`)는
1288
1289
  `stage_targets.StageLifecycle.status` 의 것을 그대로 쓴다 — 원장이 자기 어휘를
1289
1290
  따로 가지면 같은 stage 가 소비처마다 다르게 읽힌다.
1290
1291
 
@@ -1644,6 +1645,8 @@ def _replace_item_verdicts(
1644
1645
  ) -> None:
1645
1646
  item["verdicts"] = _stamped_verdicts(incoming, round_number, project_root)
1646
1647
  _remember_verified_hash(item)
1648
+ # 이번 run 의 표로 바뀌었으니 더는 직전 run 에서 이월된 항목이 아니다.
1649
+ item.pop("carriedForwardFromSeq", None)
1647
1650
 
1648
1651
 
1649
1652
  def _append_item_verdicts(
@@ -1768,11 +1771,13 @@ def _reject_uncompleted_round_loss(
1768
1771
  for item in recorded:
1769
1772
  if not isinstance(item, Mapping) or item.get("id") not in rows:
1770
1773
  continue
1774
+ # `seed --prior-state` 는 이월 표시를 항목에 찍는다. 이번 run 이 항목을
1775
+ # 다시 판정하면 그 표시는 지워지므로, 표시가 남은 항목의 표는 모두 직전 run 것이다.
1776
+ if str(item.get("carriedForwardFromSeq") or "").strip():
1777
+ continue
1771
1778
  for row in item.get("verdicts") or []:
1772
1779
  if not isinstance(row, Mapping):
1773
1780
  continue
1774
- if str(row.get("carriedForwardFromSeq") or "").strip():
1775
- continue
1776
1781
  recorded_round = row.get("round")
1777
1782
  if (
1778
1783
  isinstance(recorded_round, int)
@@ -42,9 +42,9 @@ Plan for the actual worktree layout before approval. Shared documentation direct
42
42
  - **directive-first ambiguity resolution** (the same rule, pointed at the user instead of the code): any ambiguity the run's directive, the brief, the carried-in `user-responses/` sidecars, or the user's in-session instruction already answers MUST be resolved that way and recorded with the quoted instruction. Writing a clarification row for something the user already decided is the same defect as writing one for something the code already answers — and it costs more, because the row withholds approval until a whole separate answer cycle closes it. When an instruction points at a document, treat every item in that document as decided, including the ones the document itself flagged as needing a decision (shared rule: `_common-contract.md` "User instruction outranks the material it points at").
43
43
  - flag any requirement that is ambiguous, contradictory, or missing success criteria — register each one as a row in the report's `## 1. Clarification Items` table with `Blocks=approval` instead of guessing
44
44
  - read `<PROJECT_ROOT>/.okstra/glossary.md` and `<PROJECT_ROOT>/.okstra/decisions/` titles if present. Absent okstra memory files are the normal state — do not error. Treat the brief's `terminology:*` resolutions from `requirements-discovery` (if any) as authoritative; if missing, resolve any remaining fuzzy term as a `Blocks=approval` clarification row.
45
- - **Stage Ledger (read before drafting the Stage Map):** when this task already has a plan on disk, the analysis packet carries a `## Stage Ledger` JSON block listing every stage with its `status` (`done` / `active` / `ready` / `blocked`), `dependsOn`, and done commit. It states what exists, not what to plan. Two rules follow from it:
45
+ - **Stage Ledger (read before drafting the Stage Map):** when this task already has a plan on disk, the analysis packet carries a `## Stage Ledger` JSON block listing every stage with its `status` (`done` / `active` / `cancelled` / `ready` / `blocked`), `dependsOn`, and done commit. It states what exists, not what to plan. Two rules follow from it:
46
46
  - A stage whose `status` is `done` is already implemented and will not be executed again. Declare it in this report's `stages[]` and `stageMap[]` with its plan body copied forward as written; do not rewrite its steps, and do not fold its work into a new stage. Its plan items are `observed`, not `in-scope` (`okstra_ctl.plan_items.stage_scope_bucket`), so re-declaring it adds no verification load. A carried body is also not re-judged: the planning conformance gate skips a stage the consumer ledger records as `done`, and report assembly issues no design-prep request for one (`okstra_ctl.design_prep.materialize_design_prep_requests`). A rule that widened since that stage shipped cannot be satisfied by a body you are forbidden to rewrite.
47
- - Every stage number in the ledger is taken. This report declares every one of them — rows run `1..N` with no gap — and a new stage takes the next number after the highest one listed; numbers are never reused or reordered. A report that declares only its new stages satisfies neither rule and is refused twice over. **Enforced:** `okstra_ctl.stage_map._validate_stage_numbers` rejects the gap in every published plan it parses, so `okstra prepare` refuses the implementation run (`okstra_ctl.run._parse_stage_map_into_ctx`) and the Stage Ledger of every later run reads as `unreadable`; `validators/validate-implementation-plan-stages.py` check **S2** reports the same defect at authoring time. Cancelling a stage you no longer want is not yet expressible — that lands with the plan-amendment feature — so an unstarted stage you drop still keeps its number and body here.
47
+ - Every stage number in the ledger is taken. This report declares every one of them — rows run `1..N` with no gap — and a new stage takes the next number after the highest one listed; numbers are never reused or reordered. A report that declares only its new stages satisfies neither rule and is refused twice over. **Enforced:** `okstra_ctl.stage_map._validate_stage_numbers` rejects the gap in every published plan it parses, so `okstra prepare` refuses the implementation run (`okstra_ctl.run._parse_stage_map_into_ctx`) and the Stage Ledger of every later run reads as `unreadable`; `validators/validate-implementation-plan-stages.py` check **S2** reports the same defect at authoring time. To drop a stage that has not started — `ready` or `blocked` in the ledger — keep its row and body in place in both `stageMap[]` and `stages[]` and set that `stageMap[]` row's `status` to `cancelled`; its number stays taken, and stage selection, whole-task final-verification, integration and release-handoff skip it. A `done` or `active` stage is never cancelled: to change finished work, keep that stage and add a correction stage numbered after the highest one. No remaining stage may depend on a cancelled one — point it at the stage that replaces it. **Enforced:** `validators/validate-implementation-plan-stages.py` check **S14** rejects a cancelled stage the ledger records `done` or `active` and a dependency on a cancelled stage.
48
48
  - The ledger answers two questions from two sources, and the block names both. `sourcePlan` is the plan the completed stages were actually built against; `latestPlan` is the plan the `stages` list came from and is therefore the numbering authority. When they differ, the completed work followed the former and the highest taken number comes from the latter.
49
49
  - A `planDivergence` entry means one of two things: the two plans disagree about a stage that is already `done` — the same number naming different work, or a completed stage the latest plan no longer declares — or the plan the completed stages were built against could not be read at all, so that comparison never ran. Both block the same way: do not pick one of the two plans yourself; register it as a `Blocks=approval` clarification row and assign no new stage number until it is resolved. Completed stages built against an *earlier* plan are not a divergence — that is the normal shape of an amended plan and the ledger folds it silently.
50
50
  - The block is absent ONLY on a task's first planning run. Its absence then means there is no prior plan, not that no stage is done. When the ledger could not be read, the packet names the source and reason. Continue planning to repair unambiguous dependency notation in the new report, retaining every stage number and title and every completed stage body. Record the original and corrected values with their source. Do not overwrite the prior report or select an older plan. Assign no new stage number until the corrected Stage Map validates; unresolved dependency meaning remains a blocker. The preparation behavior is covered by `tests/run/test_stage_ledger_prepare.py`; preservation of author intent remains a review guideline.
@@ -127,7 +127,7 @@ Enforced: `scripts/okstra_ctl/run.py` `_validate_approved_plan` reads that front
127
127
 
128
128
  ## Stage Output Shape (reference)
129
129
 
130
- **Enforced:** `validators/validate-implementation-plan-stages.py` fails a plan missing `## 5.5 Stage Map` and checks each `## 5.5.<i> Stage <i>` section against it (S1–S13).
130
+ **Enforced:** `validators/validate-implementation-plan-stages.py` fails a plan missing `## 5.5 Stage Map` and checks each `## 5.5.<i> Stage <i>` section against it (S1–S14).
131
131
 
132
132
  This run's final report MUST emit `## 5.5 Stage Map` and `## 5.5.<i> Stage <i>` sections per the implementation-planning profile §"Required deliverable shape". Two illustrative Stage Map tables:
133
133
 
@@ -58,6 +58,12 @@ def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedPr
58
58
 
59
59
 
60
60
  def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
61
+ cancelled = sorted(
62
+ s["stage_number"] for s in stage_map
63
+ if s.get("cancelled") and s["stage_number"] in stages)
64
+ if cancelled:
65
+ raise HandoffError(
66
+ f"stages cancelled in the approved plan have no PR: {cancelled}")
61
67
  elig = {e["stage"]: e for e in compute_eligibility(stage_map, rows)}
62
68
  unknown = [n for n in stages if n not in elig]
63
69
  if unknown:
@@ -396,6 +396,8 @@ def expected_plan_item_ids(implementation_planning: Mapping[str, Any]) -> list[s
396
396
 
397
397
 
398
398
  _STARTABLE_STATUSES = frozenset({"ready", "active"})
399
+ # 다시 실행되지 않는 stage. 그 항목은 관찰만 하고 착수를 막지 않는다.
400
+ _FROZEN_STATUSES = frozenset({"done", "cancelled"})
399
401
 
400
402
  # 계획 전체를 판정하는 항목. 스테이지 하나를 고쳐도 요청하지 않은 작업이
401
403
  # 들어왔는지 다시 봐야 한다. P-Val / P-Req / P-Rb 는 여기 넣지 않는다 —
@@ -454,7 +456,7 @@ def stage_scope_bucket(
454
456
  statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
455
457
  if statuses & _STARTABLE_STATUSES:
456
458
  return "in-scope"
457
- return "observed" if "done" in statuses else "deferred"
459
+ return "observed" if statuses & _FROZEN_STATUSES else "deferred"
458
460
 
459
461
 
460
462
  def _depends_on(value: object) -> tuple[int, ...]:
@@ -504,15 +506,30 @@ def _planning_stage_rows(planning: Mapping[str, Any]) -> list[tuple[int, tuple[i
504
506
  return rows
505
507
 
506
508
 
509
+ def _cancelled_stage_numbers(planning: Mapping[str, Any]) -> set[int]:
510
+ stage_map = planning.get("stageMap")
511
+ if not isinstance(stage_map, list):
512
+ return set()
513
+ return {
514
+ row["stage"] for row in stage_map
515
+ if isinstance(row, Mapping)
516
+ and isinstance(row.get("stage"), int)
517
+ and row.get("status") == "cancelled"
518
+ }
519
+
520
+
507
521
  def planning_stage_ledger(
508
522
  planning: Mapping[str, Any],
509
523
  disk_status: Mapping[str, str] | None = None,
510
524
  ) -> dict[str, str]:
511
- """지금 계획의 스테이지를 ``done`` / ``active`` / ``ready`` / ``blocked`` 로.
525
+ """지금 계획의 스테이지를 ``done`` / ``active`` / ``cancelled`` / ``ready`` /
526
+ ``blocked`` 로.
512
527
 
513
528
  디스크 원장은 이미 구현된 것만 안다. 첫 계획 run 에는 원장이 없어서
514
529
  게이트가 전 항목을 in-scope 로 읽었다. 이 함수는 현재 계획의 depends-on 으로
515
- 그 빈칸을 채운다. 디스크에 ``done`` / ``active`` 가 있으면 그쪽이 이긴다.
530
+ 그 빈칸을 채운다. 디스크에 ``done`` / ``active`` 가 있으면 그쪽이 이긴다 —
531
+ 그런 stage 를 계획이 취소했다면 그것은 검증기(S14)가 거부할 결함이고, 원장은
532
+ 실제 상태를 그대로 보여야 한다.
516
533
  """
517
534
  recorded = {
518
535
  key: value
@@ -520,6 +537,7 @@ def planning_stage_ledger(
520
537
  if value in {"done", "active", "ready", "blocked"}
521
538
  }
522
539
  done = {key for key, value in recorded.items() if value == "done"}
540
+ cancelled = _cancelled_stage_numbers(planning)
523
541
  ledger: dict[str, str] = {}
524
542
  for number, depends in _planning_stage_rows(planning):
525
543
  key = str(number)
@@ -527,6 +545,9 @@ def planning_stage_ledger(
527
545
  if status in {"done", "active"}:
528
546
  ledger[key] = status
529
547
  continue
548
+ if number in cancelled:
549
+ ledger[key] = "cancelled"
550
+ continue
530
551
  ready = all(str(dep) in done for dep in depends)
531
552
  ledger[key] = "ready" if ready else "blocked"
532
553
  return ledger
@@ -37,6 +37,10 @@ def _agent_row(row: dict) -> dict[str, str]:
37
37
  there would read as a missing measurement instead of an absent charge.
38
38
  """
39
39
  cli_tokens = _number(row.get("cliTotalTokens")) or 0
40
+ if cli_tokens == _number(row.get("totalTokens")):
41
+ # A CLI-only agent's raw figure is its CLI figure; a second line would
42
+ # print the same number twice.
43
+ cli_tokens = 0
40
44
  cli_cost = _number(row.get("cliCostUsd")) or 0
41
45
  cost = _number(row.get("costUsd"))
42
46
  if cost is None and cli_cost:
@@ -124,10 +128,15 @@ def run_usage(data: dict) -> dict[str, object] | None:
124
128
  if not measured:
125
129
  return None
126
130
  cli_cost = _number((usage.get("cli") or {}).get("costUsd")) or 0
131
+ grand_cost = _number((usage.get("grand") or {}).get("costUsd")) or 0
132
+ # The total holds API spend only, while a CLI-only agent's charge sits in
133
+ # its row's cost cell; this line is what the cost column adds up to.
134
+ grand_with_cli = grand_cost + cli_cost if cli_cost else 0
127
135
  return {
128
136
  "rows": [_agent_row(row) for row in rows],
129
137
  "unaccounted": _unaccounted(rows, usage.get("grand") or {}),
130
138
  **totals,
131
139
  "taskCumulative": _task_cumulative(usage.get("taskCumulative")),
132
140
  "cliCost": format_usd(cli_cost) if cli_cost else "",
141
+ "grandWithCli": format_usd(grand_with_cli) if grand_with_cli else "",
133
142
  }