okstra 0.96.0 → 0.97.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 (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +22 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
@@ -424,7 +424,7 @@ The script reads:
424
424
  "totalTokens": 2274011,
425
425
  "source": "claude-jsonl",
426
426
  "sessionId": "<wrapper-session-id>",
427
- "cliSessionPath": "/Users/.../.codex/sessions/.../rollout-*.jsonl",
427
+ "cliSessionPaths": ["/Users/.../.codex/sessions/.../rollout-*.jsonl"],
428
428
  "cliTotalTokens": 5261833,
429
429
  "cliModel": "gpt-5.5"
430
430
  }
@@ -26,6 +26,7 @@
26
26
  - Pre-verification entry gate (resolved & enforced by `okstra render-bundle` prep — the lead does NOT recompute it):
27
27
  - the verification target (scope / worktree / base / head / stages / source reports / diff stat) is injected as the `VERIFICATION_TARGET` block. The lead MUST treat it as authoritative and MUST NOT re-pick a target from the brief.
28
28
  - **whole-task scope** (`--stage auto`, default): prep has already verified every Stage Map stage is `status:done` in `consumers.jsonl`, every done stage's `head_commit` is an ancestor of the task worktree HEAD (all stage branches merged), and the worktree is clean outside `.okstra/`. If any check failed the run never started (PrepareError); a started whole-task run is therefore a fully-merged, clean target.
29
+ - **whole-task 은 read-only 가 아니라 mutating phase 다.** whole-task 모드는 진입 시 아직 task 브랜치에 머지되지 않은 done stage 들을 `--no-ff` 로 자동 머지해 통합 커밋을 생성하고, 이어서 정리 가능한 stage worktree·registry stage-key·stage 브랜치를 제거한다. 머지 충돌이 발생하면 충돌 파일을 보고하고 중단한다(사용자가 수동 해소 후 재시도). 미커밋 변경이 남은 stage worktree 는 보존한다. 따라서 위 entry gate 가 말하는 "fully-merged, clean target" 은 이 자동 통합 단계가 끝난 뒤의 상태이며, whole-task final-verification 은 통합 커밋을 만드는 mutating phase 로 취급해야 한다.
29
30
  - **single-stage scope** (`--stage N`): prep verified stage N is `status:done` and its isolated stage worktree exists and is clean. Other stages' state is irrelevant. A single-stage run is a partial verification: it MUST NOT recommend plain `release-handoff`, but MAY recommend `release-handoff(stage-group)` when the verdict is `accepted` — the stage becomes PR-eligible for a stage-group handoff.
30
31
  - the lead still captures `git rev-parse HEAD` / `git status --short` from the injected worktree to confirm the analysis ran against the injected head; a mismatch is a `tool-failure`, not a silent proceed.
31
32
  - Required deliverable shape (final report, in addition to the standard sections):
@@ -57,7 +57,7 @@
57
57
  "bypassing repo safeguards: `--no-verify` / `-n` on `git push`, bypassing GPG signing, disabling safeguards via equivalent flags, or any hook bypass.",
58
58
  "release-publishing commands: `gh release create`, `gh release edit`, `npm publish`, `cargo publish`, `pip publish`, `twine upload`, `docker push`, `terraform apply`, `kubectl apply` against any non-local cluster.",
59
59
  "source-code edits, refactors, or any modification to files outside the run's own artifact directories (`reports/`, `prompts/`, `state/`, `manifests/`, `worker-results/`, `status/`, `sessions/`). The diff being shipped MUST be exactly what the prior `implementation` run produced; release-handoff packages it, it does not re-author it.",
60
- "executing any mutating command the user did NOT select. Examples: opening a PR when the user picked `local only`; pushing when the user picked `skip`; switching the PR base branch silently after the user already chose one.",
60
+ "executing any mutating command the user did NOT select. Examples: opening a PR when the user picked `local checkout`; pushing when the user picked `skip`; switching the PR base branch silently after the user already chose one.",
61
61
  "retrying a failed git / gh command with weaker safety flags. If `git push` fails with non-fast-forward, the lead MUST stop, explain the failure to the user, and ask for instructions — it MUST NOT add `--force`.",
62
62
  "`TeamCreate`, `Agent(...)` dispatch of any kind, or any other parallel sub-agent fan-out. This phase runs entirely under the Claude lead.",
63
63
  "silently treating an unrecognised user reply as one of the menu options. If the user's answer does not match a presented choice, re-ask the question verbatim."
@@ -20,10 +20,13 @@
20
20
  - the lead MUST confirm `git log --oneline <base>..HEAD` contains at least one implementation commit. If it is empty, the run MUST end with status `blocked` and route back to `implementation`.
21
21
  - User interaction protocol (Claude lead — performed in order, using `AskUserQuestion` or the equivalent interactive prompt):
22
22
  1. **Action selection** — present three choices and capture exactly one:
23
- - `local only` — complete the handoff as a local delivery record: record the accepted, already-committed branch and end without push or PR.
23
+ - `local checkout` (whole-task mode only) bring the verified task branch into the MAIN worktree for local testing (no push, no PR). See step 1c. When `HANDOFF_MODE` is `stage-group`, do NOT offer this option tell the user "`local checkout` is whole-task mode only" and re-ask.
24
24
  - `push + PR` — push the feature branch, then open or reuse a pull request.
25
25
  - `skip` — cancel delivery actions for now: record that release-handoff was intentionally skipped and end the run without any git command.
26
26
  If the user picks `skip`, route directly to the final-report self-review pass.
27
+ 1c. **Local checkout execution** (only when the user picked `local checkout`) — confirm with the user that okstra will remove the okstra task worktree and `git checkout <feature branch>` in the MAIN worktree, and that the base branch is never modified. On confirmation run:
28
+ `okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t>`
29
+ Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra task worktree no longer exists — the lead's cwd is gone, so EVERY subsequent step (final-report authoring included) MUST use absolute paths or run from the MAIN worktree. Then route to the final-report self-review pass.
27
30
  - **stage-group mode order**: G1 base branch first (same options as Q2 — the dependency-closure check needs `origin/<base>`), then G2 stage confirmation, then assemble, then Q2b/Q3 as usual with the collector branch as the PR head.
28
31
  1g. **G2 — stage confirmation**: the stage selection already happened before the run (wizard `handoff_stage_pick`, or the CLI `--stages` flag) and is fixed in `HANDOFF_STAGES`. Display it as a one-line confirmation (`PR 대상 stage: <csv> — 진행합니다`) and proceed; do NOT re-ask the multi-select. Only if `HANDOFF_MODE` is `stage-group` but `HANDOFF_STAGES` is empty (defensive, should not happen) run `okstra handoff eligible --plan-run-root <plan-run-root> --approved-plan <approved plan path>` and ask the user to pick from the eligible stages.
29
32
  2g. **assemble**: run `okstra handoff assemble --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <csv> --base <chosen-base>`. Exit 2 means a stage-vs-stage merge conflict: show the `conflicts` paths and stop (route: reshape the group or resolve manually). Exit 1 means an eligibility/closure violation: show the error verbatim and re-ask G2. On success the returned `branch` is the PR head branch for every subsequent step.
@@ -65,21 +68,24 @@
65
68
  - **Source Verification Report**: relative path of the originating `final-verification` final-report file plus the literal quoted `Verdict Token` row whose value is `accepted`.
66
69
  - **Feature Branch & Working-Tree State**: branch name from `git rev-parse --abbrev-ref HEAD`, output of `git status --short` at run start.
67
70
  - **User Selections**: a block recording each prompt and the user's verbatim answer.
68
- - Q1 action: `local only` | `push + PR` | `skip`.
71
+ - Q1 action: `local checkout` | `push + PR` | `skip`.
69
72
  - Q2 PR base (if applicable): the chosen branch and how it was selected (menu pick vs free-form input).
70
73
  - Q2b merge-conflict probe (if applicable): `not-run` | `clean` (no conflict, no prompt shown) | `proceed anyway` | `change base branch` | `cancel`. Record it in both the `User Selections` row `H2b` and the `Merge Conflict Probe` deliverable. When a conflict was detected, list the conflicting paths.
71
74
  - Q3 title/body: `use as-is` | `edit then proceed` (with a diff between the lead's draft and the final text) | `cancel`.
72
75
  - **Executed Commands**: every git / gh command the lead actually ran, with its exit code and a one-line stdout/stderr summary. Read-only inspection commands MAY be summarised; mutating commands MUST be listed verbatim.
73
76
  - **Commit List**: each existing implementation commit in `git log <base>..HEAD`, with short/full SHA, subject line, and touched files. Release-handoff MUST NOT create new commits.
74
77
  - **Merge Conflict Probe**: one of
75
- - `- Not run (user picked local only or skip).`
78
+ - `- Not run (user picked local checkout or skip).`
76
79
  - `- Clean — no conflicts against <base> at <origin/base SHA>.`
77
80
  - `- Conflicts detected against <base> at <origin/base SHA>; user chose <proceed anyway | change base branch | cancel>. Conflicting paths: <list>.`
78
81
  - **Pull Request Outcome**: one of
79
- - `- No PR action requested.` (user picked `local only` or `skip`)
82
+ - `- No PR action requested.` (user picked `local checkout` or `skip`)
80
83
  - `- PR created: <url>` with title and base branch
81
84
  - `- PR reused: <url>` when an existing PR was found via `gh pr list`
82
85
  - `- PR creation skipped: <reason>` for any user-driven cancellation
86
+ - **Local Checkout Outcome**: one of
87
+ - `- Not run (user picked push + PR or skip).`
88
+ - `- Checked out <branch> into <main worktree path>; okstra task worktree <path> removed. Next: 로컬 테스트 후 PR 하려면 okstra-run 으로 release-handoff 재진입 → push + PR.`
83
89
  - **Stage Group** (stage-group mode only): selected stages, each stage's single-stage verification report path + quoted `Verdict Token` row, collector branch name, merge commit SHAs from assemble, and the dependency-closure verdict (from the assemble output / error).
84
90
  - **Routing recommendation**: explicit `done` token, since release-handoff is the terminal lifecycle phase. If the run ended in `skip` or `cancel`, the recommendation MUST also state whether re-entry into release-handoff is appropriate.
85
91
  - Self-review pass before finalising the report (`Claude lead` runs this):
@@ -37,8 +37,7 @@ from .qa_commands import (
37
37
  validate_qa_commands,
38
38
  )
39
39
  from .index import (
40
- _replace_or_append_active_row,
41
- _replace_or_append_project_row,
40
+ _replace_or_append_row,
42
41
  record_start,
43
42
  reserve_run_in_active,
44
43
  remove_reservation,
@@ -65,6 +64,7 @@ from .listing import (
65
64
  list_runs,
66
65
  )
67
66
  from .resolver import ResolveError, _all_run_ids, resolve_last, resolve_run_id
67
+ from .run_index_row import read_run_index
68
68
  from .sequence import predict_next_run_seq
69
69
  from .tmux import _shell_quote, build_tmux_command
70
70
  from .batch import expand_selectors, make_batch_id, write_batch_meta
@@ -113,6 +113,7 @@ __all__ = [
113
113
  "parse_run_id",
114
114
  "predict_next_run_seq",
115
115
  "read_jsonl",
116
+ "read_run_index",
116
117
  "read_run_context",
117
118
  "read_run_inputs",
118
119
  "reconcile_active",
@@ -10,10 +10,11 @@ from typing import List
10
10
  from okstra_project.dirs import tasks_root
11
11
 
12
12
  from .ids import build_run_id
13
- from .invocation import invocation_path, save_invocation
13
+ from .invocation import save_invocation
14
14
  from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
15
15
  from .project_meta import _project_meta_path
16
16
  from .reconcile import _now_iso, normalize_central_status
17
+ from .run_index_row import build_run_index_row
17
18
 
18
19
  _STAGE_DIR_RE = _re.compile(r"^stage-\d+$")
19
20
 
@@ -196,9 +197,6 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
196
197
  validation_status = raw_validation
197
198
  else:
198
199
  validation_status = "not-run"
199
- inv_rel = invocation_path(
200
- home, project_id, actual_group, actual_task,
201
- actual_type, seq).relative_to(home)
202
200
  # okstra.sh manifest 는 createdAt/updatedAt 만 쓴다(P2 회귀).
203
201
  # 별도 startedAt/finishedAt 가 없으면 createdAt/updatedAt 으로 보강한다.
204
202
  started_at = (manifest.get("startedAt")
@@ -216,23 +214,16 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
216
214
  # 단계에서 recent 로 직접 보낸다.
217
215
  is_non_terminal = normalized_status in {
218
216
  "running", "in-progress"}
219
- row = {
220
- "runId": run_id, "projectId": project_id,
221
- "projectRoot": str(project_root),
222
- "taskGroup": actual_group, "taskId": actual_task,
223
- "taskKey": f"{actual_group}/{actual_task}",
224
- "taskType": actual_type, "runSeq": seq,
225
- "status": normalized_status,
226
- "startedAt": started_at,
227
- "finishedAt": None if is_non_terminal else finished_at,
228
- "workers": raw_workers,
229
- "leadModel": raw_lead_model,
230
- "validation": validation_status,
231
- "runDirRel": run_dir_rel,
232
- "finalReportRel": final_rel,
233
- "finalStatusRel": final_status_rel,
234
- "invocationFile": str(inv_rel),
235
- }
217
+ row = build_run_index_row(
218
+ project_id=project_id, project_root=str(project_root),
219
+ task_group=actual_group, task_id=actual_task,
220
+ task_type=actual_type, run_seq=seq,
221
+ status=normalized_status, started_at=started_at,
222
+ finished_at=None if is_non_terminal else finished_at,
223
+ workers=raw_workers, lead_model=raw_lead_model,
224
+ validation=validation_status, run_dir_rel=run_dir_rel,
225
+ final_report_rel=final_rel,
226
+ final_status_rel=final_status_rel)
236
227
  if is_non_terminal:
237
228
  append_jsonl(home / "active.jsonl", row)
238
229
  else:
@@ -1498,8 +1498,13 @@ def _utc_now() -> str:
1498
1498
 
1499
1499
 
1500
1500
  def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
1501
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1502
- encoding="utf-8")
1501
+ # Write via temp file + os.replace so a crash mid-write cannot leave a
1502
+ # truncated team-state.json that every later _load_json_object rejects;
1503
+ # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
1504
+ tmp = path.with_suffix(path.suffix + ".tmp")
1505
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1506
+ encoding="utf-8")
1507
+ os.replace(tmp, path)
1503
1508
 
1504
1509
 
1505
1510
  def _print_json(payload: Mapping[str, Any]) -> None:
@@ -144,11 +144,29 @@ class ConformanceVerdict:
144
144
  message: str
145
145
 
146
146
 
147
+ def _waiver_covers_entry(waiver: dict, entry: dict) -> bool:
148
+ """waiver 가 이 entry 의 conformance 를 통째로 면해 주는지 판정.
149
+
150
+ spec §7.2 의 capability allowlist 의미: `scope` 가 빈 배열이면 stage 전체
151
+ waiver(blanket). 비어 있지 않으면 그 capability 들만 waive 하므로, entry 가
152
+ 요구하는 모든 capability(`requires`)가 scope 안에 들어올 때만 entry 가 완전히
153
+ 가려진다. scope 밖 capability 를 entry 가 건드리면 waiver 가 덮지 못하므로
154
+ 결과로 정상 게이트해야 한다(예: `db` 만 waive 한 run 에서 `http` 는 계속 게이트)."""
155
+ scope = waiver.get("scope")
156
+ if not isinstance(scope, list) or not scope:
157
+ return True
158
+ requires = entry.get("requires")
159
+ required = {c for c in requires if isinstance(c, str)} if isinstance(requires, list) else set()
160
+ return required.issubset({c for c in scope if isinstance(c, str)})
161
+
162
+
147
163
  def decide_conformance_gate(entry: dict, result: object) -> ConformanceVerdict:
148
164
  """단일 stage entry + 실행 결과(`QaResult | None`)로 게이트 판정.
149
165
 
150
- 우선순위: exemption → waiver → 결과 평가. 미실행/MISSING/FAIL 은 BLOCKING.
151
- 면제·waiver 의 형태 검증은 `validate_conformance_manifest` 가 이미 보장한다.
166
+ 우선순위: exemption → (entry 를 덮는) waiver → 결과 평가. 미실행/MISSING/FAIL
167
+ 은 BLOCKING. 면제·waiver 의 형태 검증은 `validate_conformance_manifest` 가
168
+ 이미 보장한다. capability-scoped waiver 는 scope 가 entry.requires 를 전부
169
+ 덮을 때만 적용되고, 그렇지 않으면 결과로 정상 게이트한다(`_waiver_covers_entry`).
152
170
  """
153
171
  key = entry.get("stageKey", "<unknown>")
154
172
  exemption = entry.get("exemption")
@@ -158,7 +176,7 @@ def decide_conformance_gate(entry: dict, result: object) -> ConformanceVerdict:
158
176
  f"conformance exempted: {exemption.get('reason', '')}",
159
177
  )
160
178
  waiver = entry.get("waiver")
161
- if waiver:
179
+ if waiver and _waiver_covers_entry(waiver, entry):
162
180
  return ConformanceVerdict(
163
181
  key, "WAIVED", True, True,
164
182
  f"conformance waived by {waiver.get('acknowledgedBy', '?')}: "
@@ -30,7 +30,13 @@ def read_consumers(plan_run_root: Path) -> List[Dict[str, Any]]:
30
30
  line = line.strip()
31
31
  if not line:
32
32
  continue
33
- out.append(json.loads(line))
33
+ # 일반 open('a') append 는 crash/동시쓰기로 마지막 줄이 잘릴 수 있다.
34
+ # 깨진 줄 하나가 stage 선택·reconcile 전체를 무너뜨리지 않도록,
35
+ # worktree_registry._load 와 같은 방식으로 해당 줄만 건너뛴다.
36
+ try:
37
+ out.append(json.loads(line))
38
+ except json.JSONDecodeError:
39
+ continue
34
40
  return out
35
41
 
36
42
 
@@ -192,6 +192,12 @@ def build_dispatch_plan(
192
192
 
193
193
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
194
194
  if wait:
195
+ if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
196
+ raise DispatchError(
197
+ "wait=True dispatch does not support tmux-pane workers: "
198
+ "the per-job blocking loop would serialize panes instead of "
199
+ "running them concurrently; dispatch tmux panes with wait=False"
200
+ )
195
201
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
196
202
  for job in plan.jobs:
197
203
  result = _dispatch_job_with_retry(plan, job)
@@ -671,7 +677,7 @@ def _retry_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dict
671
677
 
672
678
  def _outcome_from_status(record: Mapping[str, Any], status) -> WorkerOutcome:
673
679
  return WorkerOutcome(
674
- returncode=status.exit_code or 0,
680
+ returncode=status.exit_code if status.exit_code is not None else 1,
675
681
  missing_completion_paths=tuple(path for path in _record_completion_paths(record) if not path.is_file()),
676
682
  pane_id=_string_value(record.get("paneId")),
677
683
  status_sidecar_path=status.path,
@@ -917,4 +923,6 @@ def _utc_now() -> str:
917
923
 
918
924
 
919
925
  def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
920
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
926
+ tmp = path.with_suffix(path.suffix + ".tmp")
927
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
928
+ os.replace(tmp, path)
@@ -1,6 +1,7 @@
1
1
  """Phase-aware diagnostics for ``okstra doctor --phase``."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import subprocess
5
6
  from dataclasses import asdict, dataclass
6
7
  from pathlib import Path
@@ -196,12 +197,19 @@ def _worker_agents_check(home: Path, workspace: Path, phase: str) -> DoctorCheck
196
197
 
197
198
 
198
199
  def _worktree_registry_check() -> DoctorCheck:
199
- if callable(worktree_registry.lookup) and callable(worktree_registry.task_key):
200
- return _ok(
201
- "worktree lookup helper",
202
- "lookup helper available; target-specific lookup requires a task key",
203
- )
204
- return _fail("worktree lookup helper", "lookup helper unavailable")
200
+ # final-verification resolves stage worktrees through the registry. A corrupt
201
+ # registry.json is silently swallowed by _load() (returns empty), masking every
202
+ # reservation, so the readiness signal must actually parse the on-disk file.
203
+ path = worktree_registry._registry_path()
204
+ if not path.exists():
205
+ return _ok("worktree registry", f"no registry yet ({path})")
206
+ try:
207
+ data = json.loads(path.read_text())
208
+ except (OSError, json.JSONDecodeError) as exc:
209
+ return _fail("worktree registry", f"{path} is unreadable: {exc}")
210
+ if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
211
+ return _fail("worktree registry", f"{path} is missing a valid 'tasks' table")
212
+ return _ok("worktree registry", f"{len(data['tasks'])} task entries ({path})")
205
213
 
206
214
 
207
215
  def _gh_auth_check(project_root: Path) -> DoctorCheck:
@@ -212,12 +220,21 @@ def _gh_auth_check(project_root: Path) -> DoctorCheck:
212
220
 
213
221
 
214
222
  def _git_clean_check(project_root: Path) -> DoctorCheck:
215
- result = _run(["git", "-C", str(project_root), "status", "--short"])
216
- if result.returncode != 0:
217
- return _fail("git clean tree", _command_detail(result))
218
- if result.stdout.strip():
219
- return _fail("git clean tree", result.stdout.strip().splitlines()[0])
220
- return _ok("git clean tree", "working tree clean")
223
+ # okstra-owned outputs (.okstra, synced dirs, nested stage worktrees) are not
224
+ # readiness blockers, but untracked SOURCE still is. Reuse the shared dirty
225
+ # notion (worktree.is_dirty_excluding_okstra) instead of a blunter second
226
+ # definition of "clean".
227
+ from .worktree import is_dirty_excluding_okstra
228
+
229
+ try:
230
+ dirty = is_dirty_excluding_okstra(project_root)
231
+ except Exception as exc: # git missing / not a repo
232
+ return _fail("git clean tree", str(exc))
233
+ if dirty:
234
+ return _fail(
235
+ "git clean tree", "changes present outside okstra-owned paths"
236
+ )
237
+ return _ok("git clean tree", "working tree clean (excluding okstra-owned paths)")
221
238
 
222
239
 
223
240
  def _git_remote_check(project_root: Path) -> DoctorCheck:
@@ -0,0 +1,72 @@
1
+ """errors-*.jsonl 글롭·파싱·집계 공용 코어 (read-only).
2
+
3
+ error_report(단일 task)와 error_zip(cross-project)이 공유한다.
4
+ errorType 값에 분기하지 않는 data-driven 집계.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def glob_error_logs(task_root: Path) -> list[Path]:
13
+ runs = task_root / "runs"
14
+ if not runs.exists():
15
+ return []
16
+ flat = runs.glob("*/logs/errors-*.jsonl")
17
+ staged = runs.glob("*/stage-*/logs/errors-*.jsonl")
18
+ return sorted(set(flat) | set(staged))
19
+
20
+
21
+ def parse_records(paths: list[Path]) -> tuple[list[dict], int]:
22
+ records: list[dict] = []
23
+ skipped = 0
24
+ for path in paths:
25
+ # cross-project zip 은 글로벌 run-index 를 순회하므로, 깨지거나 비-UTF8/잘린
26
+ # 로그 한 개가 전체 수집을 중단시키지 않도록 해당 파일만 건너뛴다.
27
+ try:
28
+ text = path.read_text(encoding="utf-8")
29
+ except (OSError, UnicodeDecodeError):
30
+ skipped += 1
31
+ continue
32
+ for line in text.splitlines():
33
+ line = line.strip()
34
+ if not line:
35
+ continue
36
+ try:
37
+ rec = json.loads(line)
38
+ except Exception:
39
+ skipped += 1
40
+ continue
41
+ if isinstance(rec, dict):
42
+ rec["_sourceLog"] = str(path)
43
+ records.append(rec)
44
+ else:
45
+ skipped += 1
46
+ return records, skipped
47
+
48
+
49
+ def tally(records: list[dict], key: str) -> dict:
50
+ out: dict[str, int] = {}
51
+ for rec in records:
52
+ name = str(rec.get(key, ""))
53
+ out[name] = out.get(name, 0) + 1
54
+ return out
55
+
56
+
57
+ def rows(counts: dict, label: str) -> list[dict]:
58
+ return [
59
+ {label: name, "count": n}
60
+ for name, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
61
+ ]
62
+
63
+
64
+ def aggregate(records: list[dict]) -> dict:
65
+ return {
66
+ "errorCount": len(records),
67
+ "runCount": len({rec.get("_sourceLog", "") for rec in records}),
68
+ "byErrorType": tally(records, "errorType"),
69
+ "bySource": tally(records, "source"),
70
+ "byPhase": rows(tally(records, "phase"), "phase"),
71
+ "byAgent": rows(tally(records, "agent"), "agent"),
72
+ }
@@ -13,61 +13,11 @@ import sys
13
13
  from pathlib import Path
14
14
 
15
15
  from okstra_ctl.task_target import resolve_task_root, project_rel
16
-
17
-
18
- def _glob_error_logs(task_root: Path) -> list[Path]:
19
- runs = task_root / "runs"
20
- if not runs.exists():
21
- return []
22
- flat = runs.glob("*/logs/errors-*.jsonl")
23
- staged = runs.glob("*/stage-*/logs/errors-*.jsonl")
24
- return sorted(set(flat) | set(staged))
25
-
26
-
27
- def _parse_records(paths: list[Path]) -> tuple[list[dict], int]:
28
- records: list[dict] = []
29
- skipped = 0
30
- for path in paths:
31
- for line in path.read_text(encoding="utf-8").splitlines():
32
- line = line.strip()
33
- if not line:
34
- continue
35
- try:
36
- rec = json.loads(line)
37
- except Exception:
38
- skipped += 1
39
- continue
40
- if isinstance(rec, dict):
41
- rec["_sourceLog"] = str(path)
42
- records.append(rec)
43
- else:
44
- skipped += 1
45
- return records, skipped
46
-
47
-
48
- def _tally(records: list[dict], key: str) -> dict:
49
- out: dict[str, int] = {}
50
- for rec in records:
51
- out[str(rec.get(key, ""))] = out.get(str(rec.get(key, "")), 0) + 1
52
- return out
53
-
54
-
55
- def _rows(counts: dict, label: str) -> list[dict]:
56
- return [
57
- {label: name, "count": n}
58
- for name, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
59
- ]
60
-
61
-
62
- def _aggregate(records: list[dict]) -> dict:
63
- return {
64
- "errorCount": len(records),
65
- "runCount": len({rec.get("_sourceLog", "") for rec in records}),
66
- "byErrorType": _tally(records, "errorType"),
67
- "bySource": _tally(records, "source"),
68
- "byPhase": _rows(_tally(records, "phase"), "phase"),
69
- "byAgent": _rows(_tally(records, "agent"), "agent"),
70
- }
16
+ from okstra_ctl.error_log_core import (
17
+ glob_error_logs,
18
+ parse_records,
19
+ aggregate,
20
+ )
71
21
 
72
22
 
73
23
  def _escape_cell(value: object) -> str:
@@ -137,9 +87,9 @@ def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> di
137
87
  task_key = json.loads(manifest_path.read_text(encoding="utf-8")).get("taskKey", "")
138
88
  except Exception:
139
89
  task_key = ""
140
- paths = _glob_error_logs(task_root)
141
- records, skipped = _parse_records(paths)
142
- agg = _aggregate(records)
90
+ paths = glob_error_logs(task_root)
91
+ records, skipped = parse_records(paths)
92
+ agg = aggregate(records)
143
93
  report_rel = ""
144
94
  if paths:
145
95
  md = render_markdown(