okstra 0.71.1 → 0.72.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 (33) hide show
  1. package/docs/kr/architecture.md +14 -1
  2. package/docs/kr/cli.md +7 -1
  3. package/docs/superpowers/plans/2026-06-11-fix-cycle.md +1290 -0
  4. package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +94 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/SKILL.md +1 -1
  8. package/runtime/bin/lib/okstra/cli.sh +5 -1
  9. package/runtime/bin/lib/okstra/globals.sh +1 -0
  10. package/runtime/bin/lib/okstra/usage.sh +6 -1
  11. package/runtime/bin/okstra.sh +1 -0
  12. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  13. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  14. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  15. package/runtime/prompts/wizard/prompts.ko.json +9 -0
  16. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  17. package/runtime/python/okstra_ctl/conformance.py +5 -0
  18. package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
  19. package/runtime/python/okstra_ctl/render.py +45 -5
  20. package/runtime/python/okstra_ctl/run.py +93 -0
  21. package/runtime/python/okstra_ctl/run_context.py +15 -9
  22. package/runtime/python/okstra_ctl/wizard.py +64 -4
  23. package/runtime/python/okstra_token_usage/claude.py +64 -8
  24. package/runtime/python/okstra_token_usage/collect.py +30 -1
  25. package/runtime/schemas/final-report-v1.0.schema.json +25 -0
  26. package/runtime/skills/okstra-brief/SKILL.md +8 -0
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
  28. package/runtime/skills/okstra-run/SKILL.md +2 -1
  29. package/runtime/templates/project-docs/task-index.template.md +1 -0
  30. package/runtime/templates/reports/final-report.template.md +14 -0
  31. package/runtime/validators/validate-run.py +81 -4
  32. package/runtime/validators/validate_session_conformance.py +7 -1
  33. package/src/render-bundle.mjs +4 -1
@@ -0,0 +1,94 @@
1
+ # Fix Cycle — 완료된 task 의 사후 버그 핫픽스 이력 설계
2
+
3
+ - 날짜: 2026-06-11
4
+ - 상태: 설계 승인 대기
5
+ - 배경: okstra 로 완료(release-handoff 까지)한 task 의 산출물에서 이후 버그가 발견되는 경우, 같은 task-id 위에서 핫픽스를 수행하고 그 수정 이력을 1급 레코드로 등록하며, 이후 brief / final-report / task 브라우징에서 해당 이력이 인지되어야 한다.
6
+
7
+ ## 결정 요약
8
+
9
+ | 결정 지점 | 선택 |
10
+ |---|---|
11
+ | lifecycle 형태 | 기존 phase 재진입 (error-analysis → planning → implementation → …) + 이력 계층만 신설. 전용 hotfix task-type 없음 |
12
+ | 진입 식별 | 자동 감지 + okstra-run wizard 확인 단계 (`fix_cycle_confirm`), CLI 는 `--fix-cycle <yes|no>` |
13
+ | 이력 SSOT | `<task_root>/history/fix-cycles.jsonl` append-only 이벤트 행 (consumers.jsonl idiom) |
14
+ | 소비처 | ① 후속 run 의 analysis-packet ② okstra-brief 인용 ③ final-report `## 5.10 Fix History` ④ task-manifest / task-index / task-catalog 요약 |
15
+
16
+ ## 1. 개념 모델
17
+
18
+ **fix cycle** 은 "완료된 task 의 기존 산출물에서 발견된 버그 1건을 고치는 재진입 run 묶음"이다.
19
+
20
+ - id: `fc-<NN>` (task 내 단조 증가, 2자리 zero-pad).
21
+ - 상태: `open` → `closed` 둘뿐. 이벤트 행으로 표현하며 별도 mutable 상태 파일은 두지 않는다.
22
+ - 대상 링크: 픽스 대상인 이전 final-report 의 project-relative 경로 (`target_report`). open 시점 task-manifest 의 `latestReportPath` 에서 채운다.
23
+ - 증상 한 줄(`symptom`): 재진입 brief 의 `Request Summary` 첫 문장에서 파생한다. 사용자 추가 입력 없음.
24
+ - 동시 open cycle 은 task 당 1개로 제한한다. 두 번째 버그는 첫 cycle 종료 후에 연다 (KISS).
25
+
26
+ ## 2. SSOT 파일 계약 — `history/fix-cycles.jsonl`
27
+
28
+ 위치: `<task_root>/history/fix-cycles.jsonl`. reader/writer 는 신규 모듈 `scripts/okstra_ctl/fix_cycles.py` 단 한 곳이다. [consumers.py](../../../scripts/okstra_ctl/consumers.py) 의 idiom 을 그대로 따른다 — append-only + mutex(`run_context` 의 mutex 헬퍼 재사용) + last-wins 읽기.
29
+
30
+ 이벤트 행 3종:
31
+
32
+ ```jsonl
33
+ {"event":"opened","cycle":"fc-01","target_report":"<상대경로>","symptom":"...","opened_at":"<ISO>"}
34
+ {"event":"run","cycle":"fc-01","task_type":"error-analysis","run_seq":3,"run_manifest":"<상대경로>"}
35
+ {"event":"closed","cycle":"fc-01","closed_by":"release-handoff","report":"<상대경로>","closed_at":"<ISO>"}
36
+ ```
37
+
38
+ - idempotency 키: `(cycle, event, run_manifest|None)` 튜플. 같은 행은 중복 append 되지 않는다.
39
+ - 경로 필드는 run-manifest 와 같은 규칙으로 프로젝트 루트 기준 상대경로만 저장한다.
40
+ - 파생 읽기 API: `open_cycle(rows) -> dict|None`, `summarize(rows) -> dict` (소비처들이 공유). `summarize` 의 `latest` 는 `{cycle, symptom, targetReport, closedAt}`.
41
+
42
+ ## 3. 진입: 자동 감지 + wizard 확인
43
+
44
+ 감지 조건 (prepare 시점):
45
+
46
+ - 대상 task 의 `workflow.lastCompletedPhase == "release-handoff"` (done 계열 상태), **그리고**
47
+ - 이번 run 의 task-type 이 분석 entry phase (`requirements-discovery` / `error-analysis` / `implementation-planning`) 중 하나일 때.
48
+ - `improvement-discovery` sidetrack 은 제외.
49
+
50
+ 확인 UI:
51
+
52
+ - okstra-run wizard 에 `fix_cycle_confirm` 단계 신설 — [wizard.py](../../../scripts/okstra_ctl/wizard.py) 의 `branch_confirm` 과 같은 step-registry 패턴 (`required` 술어 = 감지 조건, `owns` = `fix_cycle_confirmed`).
53
+ - 옵션: "버그 픽스 사이클로 기록 (추천)" / "일반 후속 작업" / "직접 입력" — okstra-run 프롬프트 3-옵션 picker 규칙 준수.
54
+ - CLI 비대화 경로(okstra.sh / Node CLI)는 `--fix-cycle <yes|no>` 플래그를 [prepare_task_bundle()](../../../scripts/okstra_ctl/run.py) 입력(`PrepareInputs`)으로 전달한다. 플래그가 주어지면 wizard 단계는 건너뛴다. 세 entry-point 모두 `prepare_task_bundle` 로 수렴하는 단일 참조 규칙을 유지하며, 환경 변수는 사용하지 않는다.
55
+
56
+ 확인되면 prepare 가 `opened` 행을 기록한다.
57
+
58
+ ## 4. run 부착과 종료
59
+
60
+ - cycle 이 `open` 인 동안 같은 task 에서 도는 **모든** run 의 prepare 가 `run` 행을 append 한다 (task-type 불문 — 핫픽스 사이클의 implementation / final-verification / release-handoff 포함).
61
+ - run-manifest 와 timeline 항목에 `fixCycleId` 필드를 추가한다 (open cycle 없으면 필드 생략).
62
+ - 종료: release-handoff 결과가 기록되어 workflow 가 `done-or-follow-up` 에 도달하는 시점에 `closed` 행을 append 한다 ([workflow.py](../../../scripts/okstra_ctl/workflow.py) 의 `DEFAULT_NEXT_PHASE`). final-verification `accepted` 만으로는 닫지 않는다 — 아직 release 가 나가지 않은 상태이기 때문.
63
+
64
+ ## 5. 소비처 4곳 — 전부 파생 뷰
65
+
66
+ 쓰기 1곳(`fix_cycles.py`), 읽기 N곳 원칙. 모든 소비처는 `fix_cycles.summarize()` 가 만든 같은 요약을 사용한다.
67
+
68
+ 1. **analysis-packet**: [analysis_packet.py](../../../scripts/okstra_ctl/analysis_packet.py) 의 `build_analysis_packet()` 이 fix-cycles.jsonl 존재 시 `## Fix History` 섹션을 packet 에 주입한다 — open cycle 상세(대상 보고서·증상·부착 run 목록) + closed cycle 한 줄 목록. lead / worker 가 분석 입력으로 받는다.
69
+ 2. **task-manifest / task-index / task-catalog**: manifest 에 파생 요약 필드 `fixCycles` (`{count, openCycleId|null, latest: {cycle, symptom, targetReport, closedAt}}`), task-index 와 `.okstra/discovery/task-catalog.json` 항목에 같은 요약 1줄. manifest/index 작성 경로([render.py](../../../scripts/okstra_ctl/render.py))에서 함께 갱신한다.
70
+ 3. **final-report**: [final-report.template.md](../../../templates/reports/final-report.template.md) 에 조건부 `## 5.10 Fix History` 섹션 신설 (5.4~5.9 사용 중 확인). fix cycle 에 부착된 run 의 보고서에 cycle id · 대상 보고서 · 증상 · cycle 내 선행 run 목록이 들어간다. `final_report_schema.py` 에 대응 필드를 추가한다.
71
+ 4. **okstra-brief**: [skills/okstra-brief/SKILL.md](../../../skills/okstra-brief/SKILL.md) Step 3 (domain alignment scan) 에 "대상 task 의 `history/fix-cycles.jsonl` 이 존재하면 `summarize` 요약을 읽어 brief 의 `Task Continuity Notes` 에 인용" 지시를 추가한다.
72
+
73
+ ## 6. 강제 지점 (선언 ≠ 강제)
74
+
75
+ | 계약 | 강제 위치 |
76
+ |---|---|
77
+ | 행 스키마 · idempotency · 단일 open cycle | `fix_cycles.py` 코드 + `tests/test_okstra_fix_cycles.py` |
78
+ | `## 5.10 Fix History` 섹션 형태 | 기존 report validator 경로 (`validators/`) 에 검사 추가 |
79
+ | wizard 단계 · `--fix-cycle` 플래그 | 기존 wizard / prepare 단위 테스트 패턴으로 커버 |
80
+ | 감지 조건 (done + entry phase) | `prepare_task_bundle` 단위 테스트 |
81
+
82
+ ## 7. 문서
83
+
84
+ - `docs/kr/architecture.md` — "Phase 간 정보 전달" 인근에 "Fix cycle (사후 버그 핫픽스 이력)" 절 추가, storage model 트리에 `history/fix-cycles.jsonl` 반영.
85
+ - `docs/kr/cli.md` — `--fix-cycle` 플래그.
86
+ - `CHANGES.md` — `사용자 영향:` 라인 포함 항목 추가.
87
+ - 전부 한국어.
88
+
89
+ ## 비목표
90
+
91
+ - 전용 hotfix task-type / fast path 신설 (phase gate 우회 없음).
92
+ - task 당 동시 다중 open cycle.
93
+ - 기존 timeline.json / run-manifest 의 구조 변경 (`fixCycleId` 필드 1개 추가 외).
94
+ - 과거 완료 task 의 이력 소급 backfill.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.71.1",
3
- "builtAt": "2026-06-11T03:33:31.499Z",
2
+ "package": "0.72.0",
3
+ "builtAt": "2026-06-11T07:01:57.638Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -89,7 +89,7 @@ Required checkpoints:
89
89
  - `PROGRESS: phase-1-intake reading task bundle` — at the start of Phase 1, before issuing parallel Read calls.
90
90
  - `PROGRESS: phase-1-intake complete` — after all intake reads return.
91
91
  - `PROGRESS: phase-2-prompts preparing <N> worker prompts` — at the start of Phase 2, before any `Write` to the assigned prompt paths.
92
- - `PROGRESS: phase-3-team-create attempting TeamCreate` — immediately before the `TeamCreate` call.
92
+ - `PROGRESS: phase-3-team-create attempting TeamCreate` — immediately before the `TeamCreate` call. When the launch prompt's "Concurrent-run: no-team background" gate forbids TeamCreate, emit `PROGRESS: phase-3-team-create skipped (concurrent-run)` instead, immediately after recording `teamCreate: { attempted: false, status: "skipped", reason: "concurrent-run" }` in team-state — the checkpoint line itself is still required.
93
93
  - `PROGRESS: phase-4-dispatch worker=<role> model=<model>` — once per worker, immediately before the `Agent` / wrapper call.
94
94
  - `PROGRESS: phase-5-poll pending=<n> done=<m>` — emitted on each wakeup while the pending set is non-empty.
95
95
  - `PROGRESS: phase-5-collect worker=<role> status=<terminal-status>` — once per worker, immediately after the result file is verified.
@@ -142,6 +142,10 @@ while [[ $# -gt 0 ]]; do
142
142
  DIRECTIVE="$(require_option_value --directive "${2-}")"
143
143
  shift 2
144
144
  ;;
145
+ --fix-cycle)
146
+ FIX_CYCLE="$(require_option_value --fix-cycle "${2-}")"
147
+ shift 2
148
+ ;;
145
149
  --clarification-response)
146
150
  CLARIFICATION_RESPONSE_PATH="$(require_option_value --clarification-response "${2-}")"
147
151
  shift 2
@@ -204,7 +208,7 @@ while [[ $# -gt 0 ]]; do
204
208
  printf ' hint: did you mean --task-id?\n' >&2
205
209
  ;;
206
210
  esac
207
- printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --clarification-response --approved-plan --approve --implementation-option --stage --qa-waiver --no-plan-verification -h|--help\n' >&2
211
+ printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --fix-cycle --clarification-response --approved-plan --approve --implementation-option --stage --qa-waiver --no-plan-verification -h|--help\n' >&2
208
212
  usage
209
213
  exit 1
210
214
  ;;
@@ -37,6 +37,7 @@ TASK_KEY_INPUT=""
37
37
  TASK_KEY=""
38
38
  ANALYSIS_PROFILE=""
39
39
  DIRECTIVE=""
40
+ FIX_CYCLE=""
40
41
  CLARIFICATION_RESPONSE_PATH=""
41
42
  APPROVED_PLAN_PATH=""
42
43
  APPROVE_PLAN_ACK="false"
@@ -3,7 +3,7 @@
3
3
  usage() {
4
4
  cat >&2 <<USAGE_EOF
5
5
  usage:
6
- $DISPLAY_COMMAND_NAME [--render-only] [--yes] [--no-plan-verification] --task-type <task-type> [--workers worker1,worker2] [--lead-model <model>] [--claude-model <model>] [--codex-model <model>] [--gemini-model <model>] [--report-writer-model <model>] [--executor claude|codex|gemini] [--critic off|claude|codex|gemini] [--related-tasks taskA,taskB] --project-id <project-id> [--project-root <path>] --task-group <task-group> --task-id <task-id> --task-brief <brief-path> [--directive <directive>]
6
+ $DISPLAY_COMMAND_NAME [--render-only] [--yes] [--no-plan-verification] --task-type <task-type> [--workers worker1,worker2] [--lead-model <model>] [--claude-model <model>] [--codex-model <model>] [--gemini-model <model>] [--report-writer-model <model>] [--executor claude|codex|gemini] [--critic off|claude|codex|gemini] [--related-tasks taskA,taskB] --project-id <project-id> [--project-root <path>] --task-group <task-group> --task-id <task-id> --task-brief <brief-path> [--directive <directive>] [--fix-cycle <yes|no>]
7
7
 
8
8
  summary:
9
9
  $DISPLAY_TOOL_NAME prepares a task-keyed instruction bundle for Claude Code and launches an interactive Claude session by default.
@@ -32,6 +32,11 @@ optional arguments:
32
32
  inside instruction-set/analysis-material.md. Lead, workers, and skills (e.g. okstra-schedule)
33
33
  may treat this as a hard hint that overrides default heuristics. Use to express intent
34
34
  like "render a Gantt even with single XL task" or "emphasize rollout risk".
35
+ --fix-cycle <yes|no> When re-entering an entry phase (requirements-discovery / error-analysis /
36
+ implementation-planning) on a task that already completed through
37
+ release-handoff, decide whether to record this work as a bug-fix cycle.
38
+ 'yes' opens a new cycle and attaches this run to it; 'no' or omitted records
39
+ nothing.
35
40
  --clarification-response
36
41
  Low-level path argument. Carries an edited final-report.md from a prior
37
42
  requirements-discovery or error-analysis run into this run as Section 0
@@ -108,6 +108,7 @@ PY_ARGS=(
108
108
  --task-brief "$BRIEF_PATH"
109
109
  )
110
110
  [[ -n "${DIRECTIVE-}" ]] && PY_ARGS+=(--directive "$DIRECTIVE")
111
+ [[ -n "${FIX_CYCLE-}" ]] && PY_ARGS+=(--fix-cycle "$FIX_CYCLE")
111
112
  [[ -n "${WORKERS_OVERRIDE-}" ]] && PY_ARGS+=(--workers "$WORKERS_OVERRIDE")
112
113
  [[ -n "${LEAD_MODEL_OVERRIDE-}" ]] && PY_ARGS+=(--lead-model "$LEAD_MODEL_OVERRIDE")
113
114
  [[ -n "${CLAUDE_MODEL_OVERRIDE-}" ]] && PY_ARGS+=(--claude-model "$CLAUDE_MODEL_OVERRIDE")
@@ -30,7 +30,7 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
30
30
  - Doc-only / config-only / pure-rename steps that have no observable runtime behaviour are exempt from the failing-test requirement, but the executor MUST cite the exemption per step in the final report (`TDD exemption: <reason>`).
31
31
  - When the touched area has no existing test harness, the executor MUST stand up the minimum harness needed to host one regression test for this run rather than skipping TDD entirely. Record the harness-bootstrap step as an `Out-of-plan edit` if it is not in the plan.
32
32
  - **DB / IO / SQL changes require real execution — mock-only is NOT validation evidence:** when this run's diff touches DB/IO/SQL (ORM / query-builder code — sequelize / typeorm / prisma / knex / raw SQL — `*.repository.*`, model/entity files, `migrations/**`, `*.sql`, or any changed query string), a mocked unit test cannot observe the SQL the query builder actually emits — a mocked suite once passed while `count({ col: 'FontFamily.fontFamily' })` threw `Unknown column` on the real DB. The executor MUST run the change against a real (or faithful-replica) datastore — the `db-test` validation step (plan `validation` db step, else `project.json.qaCommands.db-test`), targeting a **local / replica** DB — and cite its exact command + exit code in the final report's `Validation evidence`. If no real DB / `db-test` command is reachable, do NOT claim the change verified: label the DB portion `정적 분석상 …, 미검증(실행 안 함)` in the report, surface it in the routing recommendation, and never downplay the real run as "too heavy". `git push` stays forbidden (universal list); the unverified DB state is carried forward so `final-verification` cannot accept it and `release-handoff` cannot push.
33
- - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa directory `<task_root>/qa/` (the `TASK_QA_PATH` token; same directory that holds the Tier 3 conformance manifest). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
33
+ - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa scripts directory `<task_root>/qa/scripts/` (`<TASK_QA_PATH>/scripts`; the `qa/` root itself holds only data sidecars — the Tier 3 conformance manifest and `result-*.json`). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/scripts/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **Author qa specs with the project's own test framework — never hand-roll `describe`/`it`/`expect`.** When the project ships a test runner as a devDependency (jest / vitest / pytest …), the qa spec uses it, invoked with the project config plus a discovery override pointing at the qa scripts dir (jest: `npx jest --config <project jest config> --roots <task_root>/qa/scripts --runInBand <spec-name>`) — the project config keeps module aliases resolving while the default sweep never collects the file; never widen the project's own test config to include qa paths. For TypeScript qa specs also write `<task_root>/qa/scripts/tsconfig.json` (`extends` the project tsconfig, adds the runner's `types` entry, `"include": ["**/*.ts"]`) so editors resolve path aliases and test globals — it is a qa artifact like the rest (untracked). **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
34
34
  - re-read the approved plan end-to-end and parse the `## 5.5 Stage Map`. Read the **Stage** injected in the launch prompt (`Stage for this implementation run`): the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`.
35
35
  - load every `runs/<plan-key>/carry/stage-<i>.json` for `i ∈ depends-on(this stage)` and inject them into the executor's working context as "runtime carry-in". For a `depends-on (none)` stage, no sidecar load — task-brief only.
36
36
  - this stage's `depends-on` are all already `status:done`. Its file list, step order, Stage Validation commands, Stage Exit Contract, and rollback path are the authoritative scope.
@@ -97,7 +97,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
97
97
  - **Untruthful name:** a read-named function (`get*` / `find*` / `load*`) that writes/inserts/mutates; an adapter or repository name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*` / `findActive*`).
98
98
  - **Hexagonal (only when the overlay is loaded):** business logic inside a port body; an adapter method that is not pure I/O (post-fetch JS filtering on domain state, domain-rule evaluation); a domain object declared outside the `domain/` boundary.
99
99
  - **okstra artifact committed to the branch:** any path in the `git diff --name-only <base>...HEAD` enumeration that lives under `.okstra/` (or `.project-docs/` when the legacy symlink is present). `.okstra/**` is gitignored, so a committed okstra file means the executor force-staged it (`git add -f`) — leaking verification artifacts (qa scripts, conformance results) into the eventual PR. Cite the path; recommend `git rm --cached <path>` to untrack it while keeping the file on disk. Conformance/qa evidence belongs in the carry sidecar / verifier result, never in git history.
100
- - **Real-IO test in source tree:** a changed/added test under the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*` — that opens a **real** DB connection / DSN, makes a real `fetch` / `axios` / `http` request, or otherwise hits real external IO without mocking the injected collaborator (a live handle, not a stub/spy). Real-IO tests MUST live under `<task_root>/qa/` per the executor's *Real-IO test isolation* rule — a live-IO test in source silently breaks the project's CI suite and violates the artifact-home rule. Cite the test file + the real-IO line; recommend moving it to `<task_root>/qa/` (or declaring it as a Tier 3 conformance script). Mock-only unit tests in source are NOT a hit.
100
+ - **Real-IO test in source tree:** a changed/added test under the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*` — that opens a **real** DB connection / DSN, makes a real `fetch` / `axios` / `http` request, or otherwise hits real external IO without mocking the injected collaborator (a live handle, not a stub/spy). Real-IO tests MUST live under `<task_root>/qa/scripts/` per the executor's *Real-IO test isolation* rule — a live-IO test in source silently breaks the project's CI suite and violates the artifact-home rule. Cite the test file + the real-IO line; recommend moving it to `<task_root>/qa/scripts/` (or declaring it as a Tier 3 conformance script). Mock-only unit tests in source are NOT a hit.
101
101
  - **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, newly orphaned private/public code that is safe to remove but not on a critical path, or weak-but-not-misleading names. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
102
102
  - **Output.** Every finding — blocking or advisory — is a structured item in the verifier's worker result (`path:line`, rule, severity, suggested fix) so it carries into Phase 5.5 convergence and the final report. A blocking hit sets the verifier verdict to `FAIL` with the rule cited, using the same verdict machinery as the Discrepancy rule above. `Claude lead` MUST NOT silently downgrade a cited blocking finding to advisory during synthesis; an override requires a concrete cited reason, exactly as for the Discrepancy rule.
103
103
 
@@ -72,9 +72,9 @@
72
72
  - `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
73
73
  - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | expected`. **Effective row count ≤ 6** (excluding header / divider / blank). Each step is one action completable in 2–5 minutes; for code steps include actual code or diff sketch. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test that captures this stage's `Acceptance` (`expected` = FAIL); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`expected` = PASS); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section (mirrors the executor's per-step exemption in `_implementation-executor.md`). Validator S10c enforces RED-first + GREEN, or the exemption line.
74
74
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
75
- - `Conformance tests: stage-<N> — <task_root>/qa/stage-<N>.<ext> (requires=[db|io|http|external,...])` — a Tier3 verification script that proves this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. When you emit this line you MUST also (a) write the script to `<task_root>/qa/stage-<N>.<ext>` and (b) add a matching entry to `<task_root>/qa/conformance-manifest.json` with fields `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (subset of `{db, io, http, external}`), `passContract`, `exemption: null`, `waiver: null`. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <근거>` line per requirement.
75
+ - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — a Tier3 verification script that proves this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. When you emit this line you MUST also (a) write the script to `<task_root>/qa/scripts/stage-<N>.<ext>` and (b) add a matching entry to `<task_root>/qa/conformance-manifest.json` with fields `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (subset of `{db, io, http, external}`), `passContract`, `exemption: null`, `waiver: null`. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <근거>` line per requirement. When the verification body is a test spec, author it with the project's own test framework (devDependency) invoked via a discovery override at `<task_root>/qa/scripts/` (jest: `--config <project config> --roots <task_root>/qa/scripts`) — never hand-roll `describe`/`expect` and never widen the project's own test config; for TypeScript specs also write `<task_root>/qa/scripts/tsconfig.json` extending the project tsconfig with the runner's `types` entry so editors resolve the file.
76
76
  - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
77
- The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. This declaration is enforced at three layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; the manifest JSON structure is enforced by `validate_conformance_manifest` (run / validate-run); and the result gate (each script's `QA-RESULT`) is enforced by the verifier Tier3 + validate-run.
77
+ The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. Layout split: executable scripts (conformance + any real-IO test) live under `<task_root>/qa/scripts/`; data sidecars (`conformance-manifest.json`, `result-*.json`) stay at the `qa/` root. This declaration is enforced at three layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; the manifest JSON structure — including each entry's `script` living under `qa/scripts/` — is enforced by `validate_conformance_manifest` (run / validate-run); and the result gate (each script's `QA-RESULT`) is enforced by the verifier Tier3 + validate-run.
78
78
  - `### Stage Exit Contract` — predicted added/modified files, newly exposed identifiers/types/endpoints, downstream-usable resources.
79
79
  - `### Stage Validation` — pre / mid / post exact commands or observable outcomes for this stage only.
80
80
  - **Vertical-slice-first partition rule (1st-class):** the grouping anchor is a **thin end-to-end vertical slice** — one stage delivers a single user-observable increment, crossing whatever layers are needed (data → service → API → UI) to make that one increment work. File/module proximity is demoted to the **intra-slice grouping rule**: within a slice, keep steps touching the same file/directory/module together so the diff, PR, and rollback unit stay cohesive. **Horizontal layer-splitting is forbidden** — never carve "the DB layer" into one stage and "the service layer" into the next; that produces stages that ship no standalone user value. A stage is split ONLY when (a) a real `depends-on` data/contract dependency exists, (b) effective steps would exceed 6, or (c) it is a distinct vertical slice (a different user-value increment). Maximising the number of parallel stages is NOT a reason to split — parallelism is an emergent property of independent stages, never a partitioning goal.
@@ -181,6 +181,15 @@
181
181
  "options": { "proceed": "진행", "edit": "base-ref 다시 고르기", "abort": "중단" },
182
182
  "echo_template": "branch-confirm: {value}"
183
183
  },
184
+ "fix_cycle_confirm": {
185
+ "label": "이 task 는 release-handoff 까지 완료된 task 입니다. 이번 재진입을 기존 산출물에 대한 버그 픽스 사이클로 기록할까요?",
186
+ "options": {
187
+ "yes": "버그 픽스 사이클로 기록 (추천)",
188
+ "no": "일반 후속 작업 (기록 안 함)",
189
+ "abort": "중단"
190
+ },
191
+ "echo_template": "fix-cycle: {value}"
192
+ },
184
193
  "base_ref_text": {
185
194
  "label": "base ref 를 입력해주세요 (branch, tag, 또는 short/full SHA)",
186
195
  "echo_template": "base-ref: {value}"
@@ -51,6 +51,7 @@ def build_analysis_packet(
51
51
  clarification_response_path: Path | None,
52
52
  directive: str,
53
53
  instruction_set_relative_path: str,
54
+ fix_history_text: str = "",
54
55
  ) -> str:
55
56
  """Return the primary compact input for Claude/Codex/Gemini analysers."""
56
57
  brief_text = task_brief_path.read_text(encoding="utf-8")
@@ -71,6 +72,7 @@ def build_analysis_packet(
71
72
  parts.extend(_brief_block(brief_text))
72
73
  parts.extend(_profile_block(task_type, profile_text))
73
74
  parts.extend(_reference_block(reference_text))
75
+ parts.extend(_fix_history_block(fix_history_text))
74
76
  parts.extend(_clarification_block(clarification_text))
75
77
  parts.extend(_directive_block(directive))
76
78
  return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
@@ -158,6 +160,21 @@ def _reference_block(reference_text: str) -> list[str]:
158
160
  return ["", "## Reference Expectations", "", body]
159
161
 
160
162
 
163
+ def _fix_history_block(fix_history_text: str) -> list[str]:
164
+ if not fix_history_text.strip():
165
+ return []
166
+ return [
167
+ "",
168
+ "## Fix History",
169
+ "",
170
+ "Past bug-fix cycles registered on this task. Treat the open cycle's",
171
+ "symptom as a prior-defect signal when analysing.",
172
+ "",
173
+ fix_history_text,
174
+ "",
175
+ ]
176
+
177
+
161
178
  def _clarification_block(clarification_text: str) -> list[str]:
162
179
  if not clarification_text.strip():
163
180
  return []
@@ -66,6 +66,11 @@ def _check_entry(entry: object, idx: int, errors: list[str]) -> None:
66
66
  return
67
67
  _check_nonempty_str(entry.get("stageKey"), f"{path}.stageKey", errors)
68
68
  _check_nonempty_str(entry.get("script"), f"{path}.script", errors)
69
+ script = entry.get("script")
70
+ # 실행 스크립트는 qa/scripts/ 하위 격리가 계약(implementation-planning §conformance);
71
+ # qa/ 루트는 manifest·result-*.json 데이터 사이드카 전용이다.
72
+ if isinstance(script, str) and script.strip() and "qa/scripts/" not in script:
73
+ errors.append(f"{path}.script must live under the task qa scripts dir (qa/scripts/), got {script!r}")
69
74
  _check_nonempty_str(entry.get("runCommand"), f"{path}.runCommand", errors)
70
75
  _check_nonempty_str(entry.get("passContract"), f"{path}.passContract", errors)
71
76
  req_ids = entry.get("requirementIds")
@@ -0,0 +1,172 @@
1
+ """Append-only reader/writer for `<task_root>/history/fix-cycles.jsonl`.
2
+
3
+ 완료(release-handoff)된 task 에 재진입하는 버그 핫픽스 run 묶음(fix cycle)의
4
+ SSOT. consumers.jsonl 과 같은 idiom — append-only + dir flock + last-wins 읽기.
5
+ 이 모듈이 유일한 reader/writer 이며, 소비처(analysis-packet / manifest /
6
+ final-report / okstra-brief)는 모두 summarize()/packet_summary() 파생 뷰를 쓴다.
7
+
8
+ 행 3종 (event 필드로 구분):
9
+ {"event":"opened","cycle":"fc-01","target_report":...,"symptom":...,"opened_at":...}
10
+ {"event":"run","cycle":"fc-01","task_type":...,"run_seq":...,"run_manifest":...}
11
+ {"event":"closed","cycle":"fc-01","closed_by":...,"report":...,"closed_at":...}
12
+
13
+ idempotency 키: (cycle, event, run_manifest|None). open cycle 은 task 당 1개.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from .run_context import dir_flock
23
+
24
+ FIX_CYCLES_FILENAME = "fix-cycles.jsonl"
25
+ _LOCK_FILENAME = ".fix-cycles.lock"
26
+
27
+ # 완료(release-handoff) task 에 fix-cycle 로 재진입할 수 있는 entry phase 들.
28
+ # prepare(run.py) 의 게이트와 wizard 의 감지 술어가 공유하는 SSOT.
29
+ FIX_CYCLE_ENTRY_PHASES = (
30
+ "requirements-discovery", "error-analysis", "implementation-planning")
31
+
32
+
33
+ def fix_cycles_path(task_root: Path) -> Path:
34
+ return Path(task_root) / "history" / FIX_CYCLES_FILENAME
35
+
36
+
37
+ def read_rows(task_root: Path) -> List[Dict[str, Any]]:
38
+ p = fix_cycles_path(task_root)
39
+ if not p.exists():
40
+ return []
41
+ out: List[Dict[str, Any]] = []
42
+ for line in p.read_text(encoding="utf-8").splitlines():
43
+ line = line.strip()
44
+ if line:
45
+ out.append(json.loads(line))
46
+ return out
47
+
48
+
49
+ def open_cycle(rows: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
50
+ """closed 행이 없는 마지막 opened 행. open 은 동시 1개가 불변식."""
51
+ closed = {r["cycle"] for r in rows if r.get("event") == "closed"}
52
+ for r in reversed(rows):
53
+ if r.get("event") == "opened" and r["cycle"] not in closed:
54
+ return r
55
+ return None
56
+
57
+
58
+ def _next_cycle_id(rows: List[Dict[str, Any]]) -> str:
59
+ n = sum(1 for r in rows if r.get("event") == "opened")
60
+ return f"fc-{n + 1:02d}"
61
+
62
+
63
+ def append_opened(task_root: Path, *, target_report: str, symptom: str,
64
+ opened_at: str) -> str:
65
+ """새 cycle 을 열고 id 를 반환. open cycle 이 이미 있으면 그 id 반환(멱등)."""
66
+ history = fix_cycles_path(task_root).parent
67
+ with dir_flock(history, _LOCK_FILENAME):
68
+ rows = read_rows(task_root)
69
+ existing = open_cycle(rows)
70
+ if existing:
71
+ return existing["cycle"]
72
+ cycle = _next_cycle_id(rows)
73
+ _append_row(task_root, {
74
+ "event": "opened", "cycle": cycle,
75
+ "target_report": target_report, "symptom": symptom,
76
+ "opened_at": opened_at,
77
+ })
78
+ return cycle
79
+
80
+
81
+ def append_run(task_root: Path, *, cycle: str, task_type: str, run_seq: int,
82
+ run_manifest: str) -> None:
83
+ history = fix_cycles_path(task_root).parent
84
+ with dir_flock(history, _LOCK_FILENAME):
85
+ for r in read_rows(task_root):
86
+ if (r.get("event") == "run" and r.get("cycle") == cycle
87
+ and r.get("run_manifest") == run_manifest):
88
+ return
89
+ _append_row(task_root, {
90
+ "event": "run", "cycle": cycle, "task_type": task_type,
91
+ "run_seq": run_seq, "run_manifest": run_manifest,
92
+ })
93
+
94
+
95
+ def append_closed(task_root: Path, *, cycle: str, closed_by: str,
96
+ report: str, closed_at: str) -> None:
97
+ history = fix_cycles_path(task_root).parent
98
+ with dir_flock(history, _LOCK_FILENAME):
99
+ for r in read_rows(task_root):
100
+ if r.get("event") == "closed" and r.get("cycle") == cycle:
101
+ return
102
+ _append_row(task_root, {
103
+ "event": "closed", "cycle": cycle, "closed_by": closed_by,
104
+ "report": report, "closed_at": closed_at,
105
+ })
106
+
107
+
108
+ def _append_row(task_root: Path, record: Dict[str, Any]) -> None:
109
+ p = fix_cycles_path(task_root)
110
+ p.parent.mkdir(parents=True, exist_ok=True)
111
+ with p.open("a", encoding="utf-8") as f:
112
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
113
+
114
+
115
+ def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
116
+ """소비처 공용 요약: {count, openCycleId, latest:{cycle,symptom,targetReport,closedAt}}."""
117
+ opened = [r for r in rows if r.get("event") == "opened"]
118
+ if not opened:
119
+ return {"count": 0, "openCycleId": None, "latest": None}
120
+ closed_at = {r["cycle"]: r.get("closed_at")
121
+ for r in rows if r.get("event") == "closed"}
122
+ open_row = open_cycle(rows)
123
+ latest = opened[-1]
124
+ return {
125
+ "count": len(opened),
126
+ "openCycleId": open_row["cycle"] if open_row else None,
127
+ "latest": {
128
+ "cycle": latest["cycle"],
129
+ "symptom": latest.get("symptom", ""),
130
+ "targetReport": latest.get("target_report", ""),
131
+ "closedAt": closed_at.get(latest["cycle"]),
132
+ },
133
+ }
134
+
135
+
136
+ def packet_summary(rows: List[Dict[str, Any]]) -> str:
137
+ """analysis-packet 의 `## Fix History` 섹션 본문 (마크다운). 행이 없으면 ''."""
138
+ opened = [r for r in rows if r.get("event") == "opened"]
139
+ if not opened:
140
+ return ""
141
+ closed = {r["cycle"]: r for r in rows if r.get("event") == "closed"}
142
+ lines: List[str] = []
143
+ for o in opened:
144
+ state = "closed" if o["cycle"] in closed else "open"
145
+ lines.append(
146
+ f"- `{o['cycle']}` ({state}) — {o.get('symptom', '')} "
147
+ f"(target: `{o.get('target_report', '')}`)")
148
+ for r in rows:
149
+ if r.get("event") == "run" and r.get("cycle") == o["cycle"]:
150
+ lines.append(
151
+ f" - run: {r.get('task_type', '')} seq {r.get('run_seq', '')}"
152
+ f" (`{r.get('run_manifest', '')}`)")
153
+ return "\n".join(lines)
154
+
155
+
156
+ _REQUEST_SUMMARY_RE = re.compile(
157
+ r"^## Request Summary\s*$", re.MULTILINE)
158
+
159
+
160
+ def derive_symptom(brief_text: str) -> str:
161
+ """brief 의 `## Request Summary` 첫 비어있지 않은 줄 (불릿 마커 제거)."""
162
+ m = _REQUEST_SUMMARY_RE.search(brief_text)
163
+ if not m:
164
+ return ""
165
+ for line in brief_text[m.end():].splitlines():
166
+ line = line.strip()
167
+ if not line:
168
+ continue
169
+ if line.startswith("#"):
170
+ return ""
171
+ return line.lstrip("-* ").strip()
172
+ return ""
@@ -27,6 +27,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
27
27
  # phase 시퀀스 / 기본 next-phase 매핑의 SSOT 는 workflow 모듈이다. 과거
28
28
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
29
29
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
30
+ from . import fix_cycles
30
31
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
31
32
 
32
33
 
@@ -597,6 +598,8 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
597
598
  "latestResumeCommandPath": s(manifest, "latestResumeCommandPath")
598
599
  or s(latest_run, "resumeCommandPath"),
599
600
  "historyTimelinePath": timeline_relative or rel(timeline_path),
601
+ "fixCycles": manifest.get("fixCycles")
602
+ or {"count": 0, "openCycleId": None, "latest": None},
600
603
  }
601
604
  )
602
605
  entries.sort(
@@ -945,6 +948,9 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
945
948
  "latestReportPath": latest_report_relative,
946
949
  "latestResumeCommandPath": latest_resume_command_relative,
947
950
  "teamStatePath": latest_team_state_relative,
951
+ "fixCycles": fix_cycles.summarize(
952
+ fix_cycles.read_rows(Path(manifest_path).parent)
953
+ ),
948
954
  "workflow": {
949
955
  "phaseSequence": PHASE_SEQUENCE,
950
956
  "currentPhase": current_phase,
@@ -1127,6 +1133,14 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1127
1133
  if isinstance(task_manifest.get("workflow"), dict)
1128
1134
  else {}
1129
1135
  )
1136
+ # prepare 가 감지한 동시-run 사실의 영속 앵커. validator 는 이 prepare-측
1137
+ # 기록이 있을 때만 no-team(teamCreate skipped) 경로를 legal 로 인정한다 —
1138
+ # lead 의 team-state 자기 선언만으로는 열리지 않는다.
1139
+ concurrent_run_stages = [
1140
+ int(s)
1141
+ for s in str(ctx.get("CONCURRENT_RUN_STAGES", "") or "").split(",")
1142
+ if s.strip().isdigit()
1143
+ ]
1130
1144
  payload = {
1131
1145
  "schemaVersion": "1.0",
1132
1146
  "okstraVersion": ctx.get("OKSTRA_VERSION", ""),
@@ -1173,6 +1187,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1173
1187
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
1174
1188
  "claudeSessionId": ctx.get("CLAUDE_SESSION_ID", ""),
1175
1189
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
1190
+ "concurrentRun": {
1191
+ "detected": bool(concurrent_run_stages),
1192
+ "activeStages": concurrent_run_stages,
1193
+ },
1176
1194
  "workflowSnapshot": {
1177
1195
  "phaseSequence": workflow.get("phaseSequence", []),
1178
1196
  "currentPhase": workflow.get(
@@ -1235,6 +1253,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1235
1253
  "renderOnly": ctx.get("RENDER_ONLY", ""),
1236
1254
  "createdAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
1237
1255
  }
1256
+ if ctx.get("FIX_CYCLE_ID"):
1257
+ payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1238
1258
  _write_json(Path(run_manifest_path), payload)
1239
1259
 
1240
1260
 
@@ -1275,8 +1295,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1275
1295
  else {}
1276
1296
  )
1277
1297
  workflow = workflow or {}
1278
- filtered.append(
1279
- {
1298
+ entry = {
1280
1299
  "runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
1281
1300
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
1282
1301
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
@@ -1317,8 +1336,10 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1317
1336
  "routingStatus": workflow.get("routingStatus", ""),
1318
1337
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
1319
1338
  },
1320
- }
1321
- )
1339
+ }
1340
+ if ctx.get("FIX_CYCLE_ID"):
1341
+ entry["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1342
+ filtered.append(entry)
1322
1343
  payload = {
1323
1344
  "schemaVersion": "1.0",
1324
1345
  "projectId": ctx.get("PROJECT_ID", ""),
@@ -1330,6 +1351,20 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1330
1351
  _write_json(path, payload)
1331
1352
 
1332
1353
 
1354
+ def _fix_cycles_index_line(manifest: dict) -> str:
1355
+ fc = manifest.get("fixCycles") or {}
1356
+ if not fc.get("count"):
1357
+ return "none"
1358
+ latest = fc.get("latest") or {}
1359
+ state = (
1360
+ f"open: {fc.get('openCycleId')}" if fc.get("openCycleId") else "all closed"
1361
+ )
1362
+ return (
1363
+ f"{fc.get('count')} cycle(s), {state} — "
1364
+ f"latest `{latest.get('cycle', '')}`: {latest.get('symptom', '')}"
1365
+ )
1366
+
1367
+
1333
1368
  def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1334
1369
  template = Path(template_path).read_text(encoding="utf-8")
1335
1370
  task_manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
@@ -1489,6 +1524,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1489
1524
  ),
1490
1525
  "{{WORKFLOW_PHASE_STATE_LINES}}": "\n".join(phase_state_lines),
1491
1526
  "{{WORKFLOW_LAST_SAFE_CHECKPOINT_LINES}}": "\n".join(checkpoint_lines),
1527
+ "{{FIX_CYCLES_SUMMARY}}": _fix_cycles_index_line(task_manifest),
1492
1528
  }
1493
1529
  fm_ctx = dict(ctx)
1494
1530
  fm_ctx.setdefault("DOC_TYPE", _doc_type_from_template_path(template_path))
@@ -1674,7 +1710,11 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1674
1710
  "2. Before any dispatch, record in team-state:\n"
1675
1711
  ' `teamCreate: { attempted: false, status: "skipped",'
1676
1712
  f' reason: "concurrent-run", concurrentStages: [{concurrent_stages}] }}`.\n'
1677
- "3. Dispatch every worker with `run_in_background: true` and NO\n"
1713
+ "3. Immediately after recording it, emit the checkpoint line\n"
1714
+ " `PROGRESS: phase-3-team-create skipped (concurrent-run)` — the\n"
1715
+ " phase-3 checkpoint is still required in no-team mode and the\n"
1716
+ " session-conformance validator fails the run without it.\n"
1717
+ "4. Dispatch every worker with `run_in_background: true` and NO\n"
1678
1718
  " `team_name` (the Phase 5 fallback). Worker completion is detected by\n"
1679
1719
  " result-file polling, so analysis output is equivalent — only the\n"
1680
1720
  " Teams split-pane view is lost."