okstra 0.96.1 → 0.97.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/kr/architecture/storage-model.md +273 -0
- package/docs/kr/architecture.md +6 -277
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
- package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
- package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
- package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
- package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
- package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
- package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
- package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +30 -4
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
- package/runtime/bin/okstra-render-final-report.py +4 -15
- package/runtime/bin/okstra.sh +5 -2
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +10 -4
- package/runtime/python/okstra_ctl/__init__.py +3 -2
- package/runtime/python/okstra_ctl/backfill.py +12 -21
- package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
- package/runtime/python/okstra_ctl/conformance.py +21 -3
- package/runtime/python/okstra_ctl/consumers.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
- package/runtime/python/okstra_ctl/doctor.py +29 -12
- package/runtime/python/okstra_ctl/error_log_core.py +72 -0
- package/runtime/python/okstra_ctl/error_report.py +8 -58
- package/runtime/python/okstra_ctl/error_zip.py +319 -0
- package/runtime/python/okstra_ctl/fanout.py +5 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
- package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
- package/runtime/python/okstra_ctl/handoff.py +125 -26
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
- package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
- package/runtime/python/okstra_ctl/index.py +34 -91
- package/runtime/python/okstra_ctl/jsonl.py +29 -18
- package/runtime/python/okstra_ctl/listing.py +30 -5
- package/runtime/python/okstra_ctl/md_table.py +1 -1
- package/runtime/python/okstra_ctl/migrate.py +17 -7
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/recap.py +12 -3
- package/runtime/python/okstra_ctl/reconcile.py +13 -11
- package/runtime/python/okstra_ctl/render.py +49 -12
- package/runtime/python/okstra_ctl/render_final_report.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +47 -6
- package/runtime/python/okstra_ctl/run.py +128 -100
- package/runtime/python/okstra_ctl/run_index_row.py +118 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
- package/runtime/python/okstra_ctl/sequence.py +2 -2
- package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
- package/runtime/python/okstra_ctl/stage_targets.py +2 -8
- package/runtime/python/okstra_ctl/task_target.py +4 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
- package/runtime/python/okstra_ctl/wizard.py +27 -12
- package/runtime/python/okstra_ctl/workflow.py +9 -1
- package/runtime/python/okstra_ctl/worktree.py +93 -4
- package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
- package/runtime/python/okstra_token_usage/collect.py +43 -38
- package/runtime/skills/okstra-brief/SKILL.md +43 -12
- package/runtime/skills/okstra-inspect/SKILL.md +44 -1
- package/runtime/templates/reports/final-report.template.md +2 -2
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/validators/forbidden_actions.py +8 -1
- package/runtime/validators/validate-brief.py +13 -2
- package/runtime/validators/validate-run.py +27 -6
- package/runtime/validators/validate-schedule.py +1 -2
- package/runtime/validators/validate_improvement_report.py +14 -0
- package/src/cli-registry.mjs +45 -31
- package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
- package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
- package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
- package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
- package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
- package/src/commands/execute/integrate-stages.mjs +25 -0
- package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
- package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
- package/src/{run.mjs → commands/execute/run.mjs} +4 -4
- package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
- package/src/{team.mjs → commands/execute/team.mjs} +3 -3
- package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
- package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
- package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
- package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
- package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
- package/src/commands/inspect/error-zip.mjs +25 -0
- package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
- package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
- package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
- package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
- package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
- package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
- package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
- package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
- package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
- package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
- package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
- package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
- package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
- package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
- package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
- package/src/lib/paths.mjs +60 -0
- package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
- package/src/{version.mjs → lib/version.mjs} +2 -2
- /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
- /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
- /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
- /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
- /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
|
@@ -242,7 +242,7 @@ The final-report template `templates/reports/final-report.template.md` Section 7
|
|
|
242
242
|
|
|
243
243
|
### Release-handoff section contract (release-handoff runs only)
|
|
244
244
|
|
|
245
|
-
When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 5.6 Release Handoff Deliverables` with all eight sub-sections (`5.6.1` Source Verification Report, `5.6.2` Feature Branch & Working-Tree State, `5.6.3` User Selections, `5.6.4` Executed Commands, `5.6.5` Commit List, `5.6.6` Merge Conflict Probe, `5.6.7` Pull Request Outcome, `5.6.8` Routing Recommendation). Every entry is dictated by the lead's recorded git/gh command log and the user's verbatim answers to the H1/H2/H3 menu prompts. H1 choices are `local
|
|
245
|
+
When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 5.6 Release Handoff Deliverables` with all eight sub-sections (`5.6.1` Source Verification Report, `5.6.2` Feature Branch & Working-Tree State, `5.6.3` User Selections, `5.6.4` Executed Commands, `5.6.5` Commit List, `5.6.6` Merge Conflict Probe, `5.6.7` Pull Request Outcome, `5.6.8` Routing Recommendation). Every entry is dictated by the lead's recorded git/gh command log and the user's verbatim answers to the H1/H2/H3 menu prompts. H1 choices are `local checkout`, `push + PR`, or `skip`; release-handoff records existing implementation commits and MUST NOT create new commits. If the user picked `skip` (H1) or `cancel` (H3), keep 5.6.3 populated but leave 5.6.4–5.6.6 explicitly empty per the template's empty-state lines.
|
|
246
246
|
|
|
247
247
|
**Single-lead authorship (release-handoff only):** release-handoff has no worker roster (no `Report writer worker`, no `Claude worker` drafter). The Claude lead authors the final-report file directly — there is no `Report writer worker` dispatch to perform in Phase 6, no resume-safe dispatch concern, and no mandatory worker-results file for a report-writer role. The rest of this contract's dispatch / resume / fallback machinery applies ONLY when `Report writer worker` is in the roster (i.e. every task-type other than `release-handoff`).
|
|
248
248
|
|
|
@@ -424,7 +424,7 @@ The script reads:
|
|
|
424
424
|
"totalTokens": 2274011,
|
|
425
425
|
"source": "claude-jsonl",
|
|
426
426
|
"sessionId": "<wrapper-session-id>",
|
|
427
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
141
|
-
records, skipped =
|
|
142
|
-
agg =
|
|
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(
|