okstra 0.71.2 → 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.
- package/docs/kr/architecture.md +14 -1
- package/docs/kr/cli.md +7 -1
- package/docs/superpowers/plans/2026-06-11-fix-cycle.md +1290 -0
- package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +94 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/wizard/prompts.ko.json +9 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
- package/runtime/python/okstra_ctl/render.py +28 -4
- package/runtime/python/okstra_ctl/run.py +93 -0
- package/runtime/python/okstra_ctl/run_context.py +15 -9
- package/runtime/python/okstra_ctl/wizard.py +64 -4
- package/runtime/schemas/final-report-v1.0.schema.json +25 -0
- package/runtime/skills/okstra-brief/SKILL.md +8 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -1
- package/runtime/templates/project-docs/task-index.template.md +1 -0
- package/runtime/templates/reports/final-report.template.md +14 -0
- package/runtime/validators/validate-run.py +41 -0
- 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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
;;
|
|
@@ -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
|
package/runtime/bin/okstra.sh
CHANGED
|
@@ -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")
|
|
@@ -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 []
|
|
@@ -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,
|
|
@@ -1247,6 +1253,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1247
1253
|
"renderOnly": ctx.get("RENDER_ONLY", ""),
|
|
1248
1254
|
"createdAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
1249
1255
|
}
|
|
1256
|
+
if ctx.get("FIX_CYCLE_ID"):
|
|
1257
|
+
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1250
1258
|
_write_json(Path(run_manifest_path), payload)
|
|
1251
1259
|
|
|
1252
1260
|
|
|
@@ -1287,8 +1295,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
|
|
|
1287
1295
|
else {}
|
|
1288
1296
|
)
|
|
1289
1297
|
workflow = workflow or {}
|
|
1290
|
-
|
|
1291
|
-
{
|
|
1298
|
+
entry = {
|
|
1292
1299
|
"runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
1293
1300
|
"runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
|
|
1294
1301
|
"runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
|
|
@@ -1329,8 +1336,10 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
|
|
|
1329
1336
|
"routingStatus": workflow.get("routingStatus", ""),
|
|
1330
1337
|
"lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
|
|
1331
1338
|
},
|
|
1332
|
-
|
|
1333
|
-
)
|
|
1339
|
+
}
|
|
1340
|
+
if ctx.get("FIX_CYCLE_ID"):
|
|
1341
|
+
entry["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1342
|
+
filtered.append(entry)
|
|
1334
1343
|
payload = {
|
|
1335
1344
|
"schemaVersion": "1.0",
|
|
1336
1345
|
"projectId": ctx.get("PROJECT_ID", ""),
|
|
@@ -1342,6 +1351,20 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
|
|
|
1342
1351
|
_write_json(path, payload)
|
|
1343
1352
|
|
|
1344
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
|
+
|
|
1345
1368
|
def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
|
|
1346
1369
|
template = Path(template_path).read_text(encoding="utf-8")
|
|
1347
1370
|
task_manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
|
|
@@ -1501,6 +1524,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
|
|
|
1501
1524
|
),
|
|
1502
1525
|
"{{WORKFLOW_PHASE_STATE_LINES}}": "\n".join(phase_state_lines),
|
|
1503
1526
|
"{{WORKFLOW_LAST_SAFE_CHECKPOINT_LINES}}": "\n".join(checkpoint_lines),
|
|
1527
|
+
"{{FIX_CYCLES_SUMMARY}}": _fix_cycles_index_line(task_manifest),
|
|
1504
1528
|
}
|
|
1505
1529
|
fm_ctx = dict(ctx)
|
|
1506
1530
|
fm_ctx.setdefault("DOC_TYPE", _doc_type_from_template_path(template_path))
|
|
@@ -28,6 +28,7 @@ from pathlib import Path
|
|
|
28
28
|
|
|
29
29
|
from okstra_project import project_json_path, upsert_project_json
|
|
30
30
|
from okstra_project.state import slugify
|
|
31
|
+
from . import fix_cycles
|
|
31
32
|
from .analysis_packet import build_analysis_packet
|
|
32
33
|
from .clarification_items import scan_approval_gate
|
|
33
34
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
@@ -311,6 +312,9 @@ class PrepareInputs:
|
|
|
311
312
|
# Only meaningful for `--task-type implementation-planning`; the manifest
|
|
312
313
|
# records the value for other phases too to keep the schema stable.
|
|
313
314
|
plan_verification_enabled: bool = True
|
|
315
|
+
# "" | "yes" | "no" — done(release-handoff) task 재진입을 fix-cycle 로
|
|
316
|
+
# 기록할지의 사용자 의사. "yes" 면 새 cycle 을 열고 이번 run 을 부착한다.
|
|
317
|
+
fix_cycle: str = ""
|
|
314
318
|
|
|
315
319
|
|
|
316
320
|
@dataclass
|
|
@@ -1793,6 +1797,8 @@ def _write_instruction_set_sources(
|
|
|
1793
1797
|
),
|
|
1794
1798
|
directive=inp.directive,
|
|
1795
1799
|
instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
|
|
1800
|
+
fix_history_text=fix_cycles.packet_summary(
|
|
1801
|
+
fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
|
|
1796
1802
|
)
|
|
1797
1803
|
(instruction_set / "analysis-packet.md").write_text(packet, encoding="utf-8")
|
|
1798
1804
|
return instruction_set
|
|
@@ -1882,6 +1888,77 @@ def _persist_run_inputs(
|
|
|
1882
1888
|
)
|
|
1883
1889
|
|
|
1884
1890
|
|
|
1891
|
+
def _read_existing_manifest(manifest_path: Path) -> dict:
|
|
1892
|
+
if not manifest_path.exists():
|
|
1893
|
+
return {}
|
|
1894
|
+
try:
|
|
1895
|
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
1896
|
+
except (OSError, json.JSONDecodeError):
|
|
1897
|
+
return {}
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
|
|
1901
|
+
now: str):
|
|
1902
|
+
"""--fix-cycle yes 검증 후 새 cycle 을 연다. 부적격이면 PrepareError."""
|
|
1903
|
+
if inp.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
|
|
1904
|
+
raise PrepareError(
|
|
1905
|
+
"--fix-cycle yes 는 entry phase("
|
|
1906
|
+
f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
|
|
1907
|
+
f"{inp.task_type}")
|
|
1908
|
+
workflow = existing.get("workflow") or {}
|
|
1909
|
+
if workflow.get("lastCompletedPhase") != "release-handoff":
|
|
1910
|
+
raise PrepareError(
|
|
1911
|
+
"--fix-cycle yes 는 release-handoff 까지 완료된 task 에만 허용됩니다 "
|
|
1912
|
+
"(workflow.lastCompletedPhase 확인)")
|
|
1913
|
+
brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
|
|
1914
|
+
fix_cycles.append_opened(
|
|
1915
|
+
task_root, target_report=str(existing.get("latestReportPath", "")),
|
|
1916
|
+
symptom=fix_cycles.derive_symptom(brief_text), opened_at=now)
|
|
1917
|
+
return fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
|
|
1921
|
+
"""fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
|
|
1922
|
+
|
|
1923
|
+
- lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
|
|
1924
|
+
lastCompletedPhase 가 release-handoff 면 닫는다. manifest 는 prepare 가
|
|
1925
|
+
이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
|
|
1926
|
+
- opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
|
|
1927
|
+
PrepareError.
|
|
1928
|
+
- run: open cycle 이 있으면 이번 run 을 무조건 부착.
|
|
1929
|
+
"""
|
|
1930
|
+
task_root = Path(ctx["TASK_MANIFEST_PATH"]).parent
|
|
1931
|
+
existing = _read_existing_manifest(Path(ctx["TASK_MANIFEST_PATH"]))
|
|
1932
|
+
workflow = existing.get("workflow") or {}
|
|
1933
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1934
|
+
|
|
1935
|
+
open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
|
|
1936
|
+
|
|
1937
|
+
if open_c and workflow.get("lastCompletedPhase") == "release-handoff":
|
|
1938
|
+
rows = fix_cycles.read_rows(task_root)
|
|
1939
|
+
handoff_attached = any(
|
|
1940
|
+
r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
|
|
1941
|
+
and r.get("task_type") == "release-handoff" for r in rows)
|
|
1942
|
+
if handoff_attached:
|
|
1943
|
+
fix_cycles.append_closed(
|
|
1944
|
+
task_root, cycle=open_c["cycle"], closed_by="release-handoff",
|
|
1945
|
+
report=str(existing.get("latestReportPath", "")), closed_at=now)
|
|
1946
|
+
open_c = None
|
|
1947
|
+
|
|
1948
|
+
if inp.fix_cycle == "yes" and open_c is None:
|
|
1949
|
+
open_c = _maybe_open_fix_cycle(inp, task_root, existing, now)
|
|
1950
|
+
|
|
1951
|
+
if open_c is None:
|
|
1952
|
+
return ""
|
|
1953
|
+
run_manifest_rel = os.path.relpath(
|
|
1954
|
+
ctx["RUN_MANIFEST_PATH"], str(Path(inp.project_root)))
|
|
1955
|
+
fix_cycles.append_run(
|
|
1956
|
+
task_root, cycle=open_c["cycle"], task_type=inp.task_type,
|
|
1957
|
+
run_seq=int(ctx.get("RUN_MANIFESTS_SEQ", 0) or 0),
|
|
1958
|
+
run_manifest=run_manifest_rel)
|
|
1959
|
+
return open_c["cycle"]
|
|
1960
|
+
|
|
1961
|
+
|
|
1885
1962
|
def _finalize_status_and_render_manifests(
|
|
1886
1963
|
inp: PrepareInputs, ctx: dict, task_index_template: Path
|
|
1887
1964
|
) -> None:
|
|
@@ -2183,6 +2260,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2183
2260
|
prompt_seq=ctx["RUN_PROMPTS_SEQ"],
|
|
2184
2261
|
)
|
|
2185
2262
|
|
|
2263
|
+
# ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
|
|
2264
|
+
# instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
|
|
2265
|
+
# cycle 도 analysis-packet 의 Fix History 에 보이도록 한다. finalize 가
|
|
2266
|
+
# 여전히 이후에 manifest 를 재작성하므로 lazy-close 의 "디스크 manifest 는
|
|
2267
|
+
# prepare 재작성 전 값" 불변식은 유지된다.
|
|
2268
|
+
ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
|
|
2269
|
+
|
|
2186
2270
|
# ---- write instruction-set scaffolding + lead prompt ----
|
|
2187
2271
|
instruction_set = _write_instruction_set_sources(
|
|
2188
2272
|
inp, ctx, profile_content, review_material
|
|
@@ -2256,6 +2340,14 @@ def main(argv: list[str]) -> int:
|
|
|
2256
2340
|
),
|
|
2257
2341
|
)
|
|
2258
2342
|
p.add_argument("--directive", default="")
|
|
2343
|
+
p.add_argument(
|
|
2344
|
+
"--fix-cycle", default="", choices=["", "yes", "no"], dest="fix_cycle",
|
|
2345
|
+
help=(
|
|
2346
|
+
"done(release-handoff)까지 완료된 task 에 entry phase 로 재진입할 때 "
|
|
2347
|
+
"이번 작업을 버그 픽스 사이클로 기록할지 결정. 'yes' = 새 cycle "
|
|
2348
|
+
"open + run 부착, 'no'/'' = 기록 안 함."
|
|
2349
|
+
),
|
|
2350
|
+
)
|
|
2259
2351
|
p.add_argument("--workers", default="", dest="workers_override")
|
|
2260
2352
|
p.add_argument("--lead-model", default="")
|
|
2261
2353
|
p.add_argument("--claude-model", default="")
|
|
@@ -2413,6 +2505,7 @@ def main(argv: list[str]) -> int:
|
|
|
2413
2505
|
approve_plan_ack=args.approve_plan_ack,
|
|
2414
2506
|
implementation_option=args.implementation_option,
|
|
2415
2507
|
plan_verification_enabled=args.plan_verification_enabled,
|
|
2508
|
+
fix_cycle=args.fix_cycle,
|
|
2416
2509
|
)
|
|
2417
2510
|
try:
|
|
2418
2511
|
out = prepare_task_bundle(inputs)
|