okstra 0.104.0 → 0.106.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.
@@ -21,10 +21,10 @@
21
21
  flowchart TD
22
22
  Start[/okstra-run/] --> Common[공통 task identity flow]
23
23
  Common --> Type[task-type = final-verification]
24
- Type --> Worktree{active task worktree?}
25
- Worktree -->|yes| Defaults[Use defaults / Customize]
26
- Worktree -->|no| BaseRef[base-ref pick/text]
27
- BaseRef --> Defaults
24
+ Type --> PlanPick[approved plan pick]
25
+ PlanPick --> Approved[approval marker confirm]
26
+ Approved --> Stage[stage pick<br/>whole-task or stage number]
27
+ Stage --> Defaults[Use defaults / Customize]
28
28
  Defaults -->|defaults| Workers[worker roster pick<br/>claude/codex/report-writer default]
29
29
  Defaults -->|customize| Models[lead + worker model prompts]
30
30
  Models --> Extras[directive, related tasks, clarification]
@@ -35,38 +35,47 @@ flowchart TD
35
35
 
36
36
  `final-verification`은 analyser roster가 있는 non-implementation phase다. 따라서 `Use defaults`를 골라도 worker roster prompt는 계속 나온다. 기본 required workers는 `claude`, `codex`, `report-writer`이고, `antigravity`는 opt-in이다.
37
37
 
38
+ 이 phase는 `base-ref`를 직접 묻지 않는다. wizard는 approved plan의 Stage Map을 기준으로 whole-task 또는 단일 stage를 선택하고, prepare가 registry/`consumers.jsonl`/git 상태에서 `VERIFICATION_TARGET`을 해소한다.
39
+
38
40
  ## 3. entry gate
39
41
 
40
42
  ```mermaid
41
43
  sequenceDiagram
42
- participant Lead as Claude lead
43
- participant Brief as task brief
44
- participant Impl as implementation final-report
44
+ participant P as prepare_task_bundle
45
+ participant C as consumers.jsonl
46
+ participant Reg as worktree registry
45
47
  participant Git as verification worktree
48
+ participant Lead as Claude lead
46
49
 
47
- Lead->>Brief: Source Implementation Report section read
48
- Lead->>Impl: required sections present?
49
- Lead->>Git: inspect same worktree named by report
50
- Lead->>Git: git rev-parse HEAD
51
- Lead->>Git: git status --short
52
- Lead->>Git: git diff --stat <implementation-base>..HEAD
53
- Git-->>Lead: verification target snapshot
54
- Lead->>Lead: commit list and diff summary match?
50
+ P->>C: backfill carry and read done rows
51
+ P->>Reg: resolve task or stage worktree
52
+ alt whole-task
53
+ P->>Git: merge done stage commits into task worktree
54
+ P->>Git: teardown clean stage worktrees/registry keys
55
+ else single-stage
56
+ P->>Git: reuse selected stage worktree
57
+ end
58
+ P->>Git: resolve base/head/diff stat
59
+ P-->>Lead: inject VERIFICATION_TARGET
60
+ Lead->>Lead: verify against injected target only
55
61
  ```
56
62
 
57
- lead는 worker dispatch 전에 verification target을 고정한다. 다음 중 하나라도 실패하면 검증하지 않고 `blocked`로 끝내며 `implementation` 또는 `implementation-planning`으로 되돌린다.
63
+ prepare는 worker dispatch 전에 verification target을 고정한다. 다음 중 하나라도 실패하면 lead prompt가 시작되지 않고 `PrepareError`로 멈춘다.
64
+
65
+ - approved plan의 Stage Map을 읽을 수 없다.
66
+ - whole-task인데 Stage Map의 모든 stage가 `done`이 아니다.
67
+ - whole-task 자동 통합 중 merge conflict가 난다.
68
+ - single-stage인데 선택 stage의 implementation stage worktree가 없거나 dirty다.
69
+ - verification worktree가 `.okstra/` 밖 dirty state다.
58
70
 
59
- - task brief에 `## Source Implementation Report`가 없다.
60
- - 인용된 implementation final report가 없거나 필수 section이 빠져 있다.
61
- - report가 named worktree를 가리키는데 lead가 다른 checkout을 보고 있다.
62
- - implementation report의 commit list / diff summary와 현재 checkout이 맞지 않는다.
63
- - delivered code change commit이 없다.
71
+ 시작된 lead는 `VERIFICATION_TARGET`을 권위로 취급한다. worktree/base/head/stage/source report를 brief에서 다시 고르지 않는다.
64
72
 
65
73
  ## 4. 검증 실행 흐름
66
74
 
67
75
  ```mermaid
68
76
  flowchart TD
69
- Gate[entry gate passed] --> Lead[Claude lead fixes target snapshot]
77
+ Gate[entry gate passed] --> Target[injected VERIFICATION_TARGET]
78
+ Target --> Lead[Claude lead confirms target snapshot]
70
79
  Lead --> CW[Claude verifier<br/>read-only]
71
80
  Lead --> XW[Codex verifier<br/>read-only]
72
81
  Lead --> GW{Antigravity opt-in?}
@@ -110,7 +119,7 @@ flowchart TD
110
119
 
111
120
  `## 7. Final Verdict`에는 `Verdict Token` field가 정확히 하나 들어가야 하며 값은 다음 셋 중 하나다.
112
121
 
113
- - `accepted`: release-handoff 넘겨도 되는 상태
122
+ - `accepted`: whole-task이면 plain `release-handoff`, single-stage이면 `release-handoff(stage-group)` 후보가 되는 상태
114
123
  - `conditional-accept`: 조건을 모두 명시해야 하며, 조건이 gate면 다음 phase를 막는다
115
124
  - `blocked`: acceptance blocker가 있어 release-handoff로 갈 수 없는 상태
116
125
 
@@ -132,6 +141,7 @@ flowchart TD
132
141
  final report에는 최소 다음이 필요하다.
133
142
 
134
143
  - originating implementation final-report path
144
+ - verification scope(`whole-task` 또는 `single-stage`)와 injected `VERIFICATION_TARGET`
135
145
  - inspected worktree path
136
146
  - implementation base ref와 run start head SHA
137
147
  - quoted commit list / diff summary
@@ -146,15 +156,16 @@ final report에는 최소 다음이 필요하다.
146
156
 
147
157
  ```mermaid
148
158
  flowchart TD
149
- FV[final-verification] --> Allowed[read-only inspect/test/validate]
159
+ FV[final-verification] --> Runtime[prepare-owned whole-task integration<br/>or single-stage target resolution]
160
+ FV --> Allowed[lead read-only inspect/test/validate]
150
161
  FV -. forbidden .-> Edit[source edit or config edit]
151
162
  FV -. forbidden .-> Fix[in-run bug fix]
152
- FV -. forbidden .-> Mutate[mutating command]
163
+ FV -. forbidden .-> Mutate[lead-owned mutating command]
153
164
  FV -. forbidden .-> Expand[scope expansion]
154
165
  FV -. forbidden .-> Hide[hide verifier dissent]
155
166
  ```
156
167
 
157
- source edit, follow-up fix, scope expansion은 전부 금지다. 결함을 발견하면 현재 run 안에서 고치지 않고 final report에 blocker로 기록한 뒤 새 `error-analysis` 또는 `implementation-planning` 입력으로 넘긴다.
168
+ whole-task mode의 stage merge/teardown은 prepare가 수행하는 runtime-owned 통합 단계다. 그 이후 lead 검증은 read-only다. source edit, follow-up fix, scope expansion은 전부 금지다. 결함을 발견하면 현재 run 안에서 고치지 않고 final report에 blocker로 기록한 뒤 새 `error-analysis` 또는 `implementation-planning` 입력으로 넘긴다.
158
169
 
159
170
  ## 8. 확인한 코드
160
171
 
@@ -27,8 +27,9 @@ flowchart TD
27
27
  BaseRef --> PlanPick
28
28
  PlanPick --> Approved{APPROVED marker present?}
29
29
  Approved -->|no| Retry[re-prompt same step]
30
- Approved -->|yes| Stage[stage pick<br/>auto or number]
31
- Stage --> Executor[executor pick<br/>claude/codex/antigravity]
30
+ Approved -->|yes| Stage[stage multi-pick<br/>ready/active markers]
31
+ Stage --> Chain[render-args<br/>stage + chain-stages]
32
+ Chain --> Executor[executor pick<br/>claude/codex/antigravity]
32
33
  Executor --> Defaults[Use defaults / Customize]
33
34
  Defaults -->|defaults| Confirm
34
35
  Defaults -->|customize| Models[lead + executor model + report-writer model]
@@ -37,7 +38,9 @@ flowchart TD
37
38
  Confirm --> Render[render-bundle]
38
39
  ```
39
40
 
40
- `implementation`은 worker override 질문이 없다. wizard `render_args()`가 `workers`를 빈 문자열로 내보내고, runtime이 profile default roster를 사용한다. 현재 okstra-run wizard 경로는 승인 checkbox를 직접 flip하는 `--approve`를 노출하지 않는다. plan file에는 이미 recognized approval marker가 있어야 한다.
41
+ `implementation`은 worker override 질문이 없다. wizard `render_args()`가 `workers`를 빈 문자열로 내보내고, runtime이 profile default roster를 사용한다. `stage_pick`은 완료/진행중/준비됨/대기 상태를 보여주는 multi-pick이다. 선택한 stage 집합은 의존성 closure와 위상정렬을 거쳐 `chain-stages` CSV가 되고, 실제 run은 그중 stage만 실행한다.
42
+
43
+ 현재 okstra-run wizard 경로는 승인 checkbox를 직접 flip하는 `--approve`를 노출하지 않는다. plan file에는 이미 recognized approval marker가 있어야 한다.
41
44
 
42
45
  ## 3. runtime gate
43
46
 
@@ -47,16 +50,21 @@ sequenceDiagram
47
50
  participant P as prepare_task_bundle
48
51
  participant Plan as approved final-report
49
52
  participant QA as project.json qaCommands
50
- participant Stage as stage validator
53
+ participant Stage as stage target policy
54
+ participant Reg as worktree registry
55
+ participant WT as stage worktree
51
56
 
52
57
  W->>P: task-type=implementation, approved-plan, stage, executor
53
58
  P->>Plan: file exists?
54
59
  P->>Plan: approval marker regex matches?
55
60
  P->>Plan: unresolved Blocks=approval rows?
56
- P->>Stage: validate stage structure
61
+ P->>Stage: parse Stage Map and recover carry/done rows
62
+ P->>Reg: read active stage-key reservations
63
+ P->>Stage: select exactly one ready stage
64
+ P->>WT: provision stage-N worktree + branch
57
65
  P->>QA: validate qaCommands deny-list
58
66
  P->>P: executor provider in resolved roster?
59
- P->>P: provision/reuse worktree
67
+ P->>P: namespace run artifacts under stage-N
60
68
  P-->>W: prepared implementation prompt or PrepareError
61
69
  ```
62
70
 
@@ -86,16 +94,21 @@ Executor만 project file을 mutate할 수 있다. verifier는 같은 worktree에
86
94
  ```mermaid
87
95
  flowchart LR
88
96
  Plan[approved plan<br/>Stage Map] --> Parse[parse stage map]
89
- Parse --> Done[read consumers.jsonl<br/>done stages]
90
- Done --> Resolve{stage arg}
91
- Resolve -->|auto| Next[lowest unblocked unfinished stage]
97
+ Parse --> Done[backfill carry<br/>read consumers.jsonl]
98
+ Done --> Reserved[read registry<br/>active stage reservations]
99
+ Reserved --> Resolve{stage arg}
100
+ Resolve -->|auto| Next[lowest ready<br/>not done/started/reserved]
92
101
  Resolve -->|number| Forced[selected stage]
93
- Next --> Started[append consumer status=started]
94
- Forced --> Started
95
- Started --> Run[implementation executes selected stage]
102
+ Next --> Base[resolve stage base commit]
103
+ Forced --> Base
104
+ Base --> WT[create/reuse stage worktree]
105
+ WT --> Started[append consumer status=started]
106
+ Started --> Run[implementation executes one selected stage]
96
107
  ```
97
108
 
98
- stage 선택은 `auto` 또는 숫자다. 다른 task-type에서 `--stage`가 오면 `PrepareError`다. wizard는 approved plan에서 Stage Map을 parse해 stage picker에 `auto`와 stage를 보여준다.
109
+ stage 선택은 `auto` 또는 숫자다. 다른 task-type에서 `--stage`가 오면 `PrepareError`다. runtime은 `consumers.jsonl`의 `done`/`started`와 registry의 active stage-key를 함께 보고 점유 stage를 제외한다.
110
+
111
+ stage worktree base는 dependency 형태로 정한다. 독립 stage는 첫 implementation 진입 때 고정한 task-key worktree HEAD를 anchor로 삼고, 단일 의존 stage는 선행 stage의 done `head_commit`에서 분기한다. 다중 의존 stage는 모든 선행 done commit이 task-key worktree HEAD의 ancestor인지 확인한 뒤 그 HEAD에서 분기한다.
99
112
 
100
113
  ## 6. 산출물
101
114
 
@@ -113,12 +126,14 @@ flowchart TD
113
126
  final report에는 최소 다음이 필요하다.
114
127
 
115
128
  - approved plan path와 quoted approval marker
129
+ - selected stage, isolated stage worktree path, run artifact path(`runs/implementation/stage-<N>/`)
116
130
  - commit SHA, message, plan step mapping
117
131
  - diff summary와 per-file summary
118
132
  - out-of-plan edits block
119
133
  - plan validation command의 실제 stdout/stderr와 exit code
120
134
  - TDD failing-then-passing evidence
121
135
  - verifier별 independent validation rerun result
136
+ - `carry/stage-<N>.json` evidence sidecar와 `consumers.jsonl` started/done row
122
137
  - rollback verification
123
138
  - follow-up tasks table
124
139
 
@@ -146,4 +161,3 @@ flowchart TD
146
161
  - [`validators/validate-implementation-plan-stages.py`](../../validators/validate-implementation-plan-stages.py)
147
162
  - [`scripts/okstra_ctl/qa_commands.py`](../../scripts/okstra_ctl/qa_commands.py)
148
163
  - [`prompts/lead/okstra-lead-contract.md`](../../prompts/lead/okstra-lead-contract.md)
149
-
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 1. 목적
16
16
 
17
- `release-handoff`는 `accepted` verdict가 나온 already-committed implementation branch를 push하거나 PR로 넘기는 terminal phase다. commit을 만들지 않고, 검증된 diff를 그대로 포장한다.
17
+ `release-handoff`는 `accepted` verdict가 나온 already-committed implementation 결과를 push하거나 PR로 넘기는 terminal phase다. whole-task mode는 검증된 task branch를 그대로 포장한다. stage-group mode는 선택 stage들을 collector branch에 assemble해 한 PR로 묶을 수 있으며, 이때 생성되는 merge commit은 `okstra handoff assemble`만 만든다.
18
18
 
19
19
  이 phase는 worker dispatch가 없다. `claude`, `codex`, `report-writer` roster를 쓰지 않으며 Claude lead가 inline으로 git/gh inspection, 사용자 질문, PR draft, final report 작성을 모두 수행한다.
20
20
 
@@ -24,7 +24,9 @@
24
24
  flowchart TD
25
25
  Start[/okstra-run/] --> Common[공통 task identity flow]
26
26
  Common --> Type[task-type = release-handoff]
27
- Type --> Worktree{active task worktree?}
27
+ Type --> Plan[approved plan auto/pick]
28
+ Plan --> Scope[handoff stage pick<br/>whole-task or eligible stages]
29
+ Scope --> Worktree{active task worktree?}
28
30
  Worktree -->|yes| Defaults[Use defaults / Customize]
29
31
  Worktree -->|no| BaseRef[base-ref pick/text]
30
32
  BaseRef --> Defaults
@@ -32,14 +34,14 @@ flowchart TD
32
34
  Defaults -->|customize| Models[lead model prompt]
33
35
  Models --> Extras[directive, related tasks, clarification]
34
36
  Extras --> Template[PR template override?]
35
- Template --> Scope[save template to project/global?]
36
- Scope --> Confirm
37
+ Template --> TemplateScope[save template to project/global?]
38
+ TemplateScope --> Confirm
37
39
  Confirm --> Render[render-bundle]
38
40
  ```
39
41
 
40
- `release-handoff`는 worker roster prompt가 없다. wizard의 `render_args()`는 `pr-template-path`를 release-handoff일 때만 포함하고, runtime은 worker list를 강제로 empty로 만든다.
42
+ `release-handoff`는 worker roster prompt가 없다. wizard의 `render_args()`는 `pr-template-path`를 release-handoff일 때만 포함하고, runtime은 worker list를 강제로 empty로 만든다. scope 선택은 prepare 전에 끝난다. whole-task는 accepted whole-task verification report가 있어야 하고, stage-group은 accepted single-stage verification으로 `verified` 처리됐지만 아직 `pr`로 덮이지 않은 stage만 후보가 된다.
41
43
 
42
- 주의할 점은 이 phase도 task worktree provisioning 대상이라는 것이다. 정상 흐름은 같은 task-key의 implementation/final-verification worktree를 재사용한다. 새 task로 시작하면 새 branch가 만들어질 수 있고, entry gate의 "implementation commit exists" 조건에서 막힐 가능성이 높다.
44
+ 주의할 점은 이 phase도 task worktree provisioning 대상이라는 것이다. 정상 흐름은 같은 task-key의 implementation/final-verification 결과를 재사용한다. 새 task로 시작하면 새 branch가 만들어질 수 있고, entry gate의 "implementation commit exists" 조건에서 막힐 가능성이 높다.
43
45
 
44
46
  ## 3. prepare 단계
45
47
 
@@ -72,7 +74,10 @@ flowchart TD
72
74
  Source -->|no| Block[blocked<br/>route final-verification]
73
75
  Source -->|yes| Verdict{Verdict Token == accepted?}
74
76
  Verdict -->|no| Block
75
- Verdict -->|yes| Status{git status --short clean?}
77
+ Verdict -->|yes| Mode{HANDOFF_MODE}
78
+ Mode -->|stage-group| Eligible[each selected stage<br/>verified and not in PR]
79
+ Mode -->|whole-task| Status{git status --short clean?}
80
+ Eligible --> Status
76
81
  Status -->|no| Dirty[blocked<br/>dirty tree]
77
82
  Status -->|yes| Branch{current branch is base branch?}
78
83
  Branch -->|yes| BaseBlock[blocked<br/>never operate on base branch]
@@ -84,7 +89,8 @@ flowchart TD
84
89
  lead는 사용자에게 push/PR 여부를 묻기 전에 다음을 확인한다.
85
90
 
86
91
  - prepare 가 생성한 input 문서(`release-handoff-input.md`)의 `## Source Verification Report` 가 모드(`HANDOFF_MODE`)와 인용 보고서 표를 담고 있다. brief 는 entry phase 의 입력물이라 release-handoff 에는 없다 — 사용자의 stage 선택은 wizard `handoff_stage_pick` 또는 CLI `--stages` 로 prepare 전에 끝난다.
87
- - 인용된 report `## 7. Final Verdict`에 `Verdict Token = accepted`가 정확히 있다.
92
+ - whole-task mode에서는 인용 report `verificationScope=whole-task`이고 `Verdict Token = accepted`여야 한다.
93
+ - stage-group mode에서는 인용된 각 single-stage report가 `Verdict Token = accepted`여야 하며, prepare/`okstra handoff assemble`이 eligibility와 dependency closure를 다시 강제한다.
88
94
  - working tree가 clean이다.
89
95
  - 현재 branch가 `main`, `master`, `prod`, `preprod`, `staging`, `dev` 같은 base branch가 아니다.
90
96
  - `<base>..HEAD` commit range가 비어 있지 않다.
@@ -99,7 +105,11 @@ stateDiagram-v2
99
105
  Gate --> Q1: action selection
100
106
  Q1 --> LocalCheckout: local checkout
101
107
  Q1 --> Skip: skip
102
- Q1 --> Q2: push + PR
108
+ Q1 --> Q2: push + PR whole-task
109
+ Q1 --> G2: push + PR stage-group
110
+ state "base select + stage confirmation / okstra handoff assemble" as Assemble
111
+ G2 --> Assemble
112
+ Assemble --> Q3: collector branch ready
103
113
  Q2 --> Probe: choose PR base
104
114
  Probe --> Q3: no conflict
105
115
  Probe --> Conflict: conflict detected
@@ -124,6 +134,8 @@ stateDiagram-v2
124
134
 
125
135
  `push + PR`에서만 merge-conflict probe를 한다.
126
136
 
137
+ stage-group mode에서는 `local checkout`을 제공하지 않는다. `push + PR`을 고른 뒤 PR base를 먼저 선택하고, 이미 고정된 `HANDOFF_STAGES`를 확인한 다음 `okstra handoff assemble`이 collector branch를 만든다. 이후 conflict probe와 PR title/body 확인은 collector branch를 head로 삼는다.
138
+
127
139
  ```mermaid
128
140
  flowchart TD
129
141
  PushPR[push + PR selected] --> Fetch[git fetch origin chosen-base]
@@ -170,11 +182,13 @@ flowchart TD
170
182
  final report에는 최소 다음이 필요하다.
171
183
 
172
184
  - originating final-verification report path와 quoted `accepted` verdict row
185
+ - handoff mode(`whole-task` 또는 `stage-group`)와 selected stages
173
186
  - feature branch와 run start `git status --short`
174
187
  - 사용자 선택 기록
175
188
  - 실행한 모든 git/gh command와 exit code
176
189
  - implementation commit list
177
190
  - merge-conflict probe 결과
191
+ - stage-group이면 collector branch, merge commit SHA, dependency-closure 결과
178
192
  - PR 생성, 재사용, 생략 결과
179
193
  - routing recommendation `done`
180
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.104.0",
3
+ "version": "0.106.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.104.0",
3
- "builtAt": "2026-06-27T12:43:11.068Z",
2
+ "package": "0.106.0",
3
+ "builtAt": "2026-06-27T16:35:08.703Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -4,54 +4,33 @@ docker 라벨은 컨테이너 존재 여부의 기준값(SSOT)이지만 tmux ses
4
4
  findings 파일 경로 같은 운영 메타데이터는 추적하지 못한다. 이 모듈은 task root
5
5
  하위 ``container/registry.json`` 에 그런 보조 정보를 가벼운 dict 로 보관한다.
6
6
 
7
- flock + tmp+``os.replace`` 원자 쓰기 idiom 은 ``worktree_registry`` 의
8
- ``_registry_lock``/``_load``/``_save``그대로 복제했고, lock/registry 경로만
9
- ``paths.container_paths()`` 로 해소해 전역 ``~/.okstra/worktrees/registry.json``
10
- 과 분리한다. dict 구조는 ``{"services": {<svc>: {session_name, pane_id,
11
- findings_path}}}`` 한 가지뿐 — worktree_registry 의 dataclass 는 복제하지 않는다.
7
+ flock + tmp+``os.replace`` 원자 쓰기 idiom 은 ``json_registry`` 의 공용
8
+ Interface사용하고, lock/registry 경로만 ``paths.container_paths()`` 로
9
+ 해소해 전역 ``~/.okstra/worktrees/registry.json`` 과 분리한다. dict 구조는
10
+ ``{"services": {<svc>: {session_name, pane_id, findings_path}}}`` 한 가지뿐 —
11
+ worktree_registry 의 dataclass 는 복제하지 않는다.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- import contextlib
16
- import fcntl
17
- import json
18
- import os
19
15
  from pathlib import Path
20
16
 
17
+ from .json_registry import load_registry_json, registry_lock, save_registry_json
21
18
  from . import paths
22
19
 
23
20
 
24
- @contextlib.contextmanager
25
21
  def _registry_lock(lock_path: Path):
26
- """``container/registry.json.lock`` 에 대한 배타 flock. worktree_registry 의
27
- ``_registry_lock`` 과 동일한 패턴이되 경로를 인자로 받는다(전역 lock 과 분리)."""
28
- lock_path.parent.mkdir(parents=True, exist_ok=True)
29
- if not lock_path.exists():
30
- lock_path.touch()
31
- f = lock_path.open("r+")
32
- try:
33
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
34
- yield
35
- finally:
36
- f.close()
22
+ """``container/registry.json.lock`` 에 대한 배타 flock."""
23
+ return registry_lock(lock_path)
37
24
 
38
25
 
39
26
  def _load(registry_path: Path) -> dict:
40
- if not registry_path.exists():
41
- return {"services": {}}
42
- try:
43
- data = json.loads(registry_path.read_text())
44
- except (OSError, json.JSONDecodeError):
45
- return {"services": {}}
27
+ data = load_registry_json(registry_path, lambda: {"services": {}})
46
28
  data.setdefault("services", {})
47
29
  return data
48
30
 
49
31
 
50
32
  def _save(registry_path: Path, data: dict) -> None:
51
- registry_path.parent.mkdir(parents=True, exist_ok=True)
52
- tmp = registry_path.with_suffix(".json.tmp")
53
- tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
54
- os.replace(tmp, registry_path)
33
+ save_registry_json(registry_path, data)
55
34
 
56
35
 
57
36
  def reserve(
@@ -0,0 +1,43 @@
1
+ """Shared flock + atomic JSON persistence for small okstra registries."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import fcntl
6
+ import json
7
+ import os
8
+ from collections.abc import Callable, Iterator
9
+ from pathlib import Path
10
+
11
+
12
+ @contextlib.contextmanager
13
+ def registry_lock(lock_path: Path) -> Iterator[None]:
14
+ """Hold an exclusive flock on ``lock_path``."""
15
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
16
+ if not lock_path.exists():
17
+ lock_path.touch()
18
+ handle = lock_path.open("r+")
19
+ try:
20
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
21
+ yield
22
+ finally:
23
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
24
+ handle.close()
25
+
26
+
27
+ def load_registry_json(path: Path, default_factory: Callable[[], dict]) -> dict:
28
+ """Load a registry dict, or a fresh default when absent/corrupt."""
29
+ if not path.exists():
30
+ return default_factory()
31
+ try:
32
+ data = json.loads(path.read_text(encoding="utf-8"))
33
+ except (OSError, json.JSONDecodeError):
34
+ return default_factory()
35
+ return data if isinstance(data, dict) else default_factory()
36
+
37
+
38
+ def save_registry_json(path: Path, data: dict) -> None:
39
+ """Persist registry JSON via temp file + ``os.replace``."""
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = path.with_suffix(".json.tmp")
42
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
43
+ os.replace(tmp, path)
@@ -0,0 +1,113 @@
1
+ """Read-side cross-task roll-up for a task-group (or the whole project).
2
+
3
+ 단일 task 집계기(time-report / error-report / recap)는 task-root 하나만 받는다.
4
+ 이 모듈은 그 위에서 catalog 를 task-group 단위로 fan-out 해 task 별 run 수·소요
5
+ 시간·에러 수·report 경로를 모으고, group 차원의 합계/분포를 deterministic 하게
6
+ roll-up 한다. report 본문 종합(자연어 요약)은 LLM(skill)에 남긴다 — 여기서는
7
+ report 경로만 노출한다. time-report 와 동일하게 raw ms 를 출력하고 HH:MM:SS
8
+ 포맷·Markdown 표 렌더는 호출자(skill)에 위임한다.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from okstra_ctl.error_log_core import glob_error_logs, parse_records
18
+ from okstra_ctl.time_report import _load_runs, aggregate_time
19
+ from okstra_project import (
20
+ ResolverError,
21
+ list_project_tasks,
22
+ resolve_project_root,
23
+ )
24
+
25
+
26
+ def _collect_task(entry: dict, project_root: Path) -> dict:
27
+ """list_project_tasks entry → roll-up 한 줄. catalog 상태 필드 + time/error 측정.
28
+
29
+ runCount 는 timeline 의 전체 run 수(usable durationMs 없는 run 도 포함)이고
30
+ cpuSumMs/wallClockMs 는 durationMs 가 있는 run 만 반영하므로 0 일 수 있다.
31
+ """
32
+ task_root = Path(entry["_resolvedTaskRoot"])
33
+ runs = _load_runs(task_root)
34
+ time_agg = aggregate_time(runs, project_root)
35
+ records, _ = parse_records(glob_error_logs(task_root))
36
+ return {
37
+ "taskKey": entry.get("taskKey", ""),
38
+ "taskGroup": entry.get("taskGroup", ""),
39
+ "taskId": entry.get("taskId", ""),
40
+ "taskType": entry.get("taskType", ""),
41
+ "workCategory": entry.get("workCategory", "") or "unknown",
42
+ "workStatus": entry.get("workStatus", "") or "unknown",
43
+ "currentPhase": entry.get("currentPhase", ""),
44
+ "currentPhaseState": entry.get("currentPhaseState", ""),
45
+ "nextRecommendedPhase": entry.get("nextRecommendedPhase", ""),
46
+ "latestRunStatus": entry.get("latestRunStatus", ""),
47
+ "updatedAt": entry.get("updatedAt", ""),
48
+ "reportPath": entry.get("latestReportPath", ""),
49
+ "runCount": len(runs),
50
+ "cpuSumMs": time_agg["grandTotal"]["cpuSumMs"],
51
+ "wallClockMs": sum(r["wallClockMs"] for r in time_agg["perRunWallClock"]),
52
+ "errorCount": len(records),
53
+ }
54
+
55
+
56
+ def _tally(tasks: list[dict], key: str) -> dict:
57
+ """task 들의 한 필드를 값별 개수로. 빈 값은 'unknown' 으로 접는다."""
58
+ counts: dict[str, int] = {}
59
+ for task in tasks:
60
+ bucket = task.get(key) or "unknown"
61
+ counts[bucket] = counts.get(bucket, 0) + 1
62
+ return dict(sorted(counts.items()))
63
+
64
+
65
+ def build_rollup(project_root: Path, task_group: str | None) -> dict:
66
+ entries = list_project_tasks(project_root, task_group=task_group or None)
67
+ tasks = [_collect_task(e, project_root) for e in entries]
68
+ totals = {
69
+ "runs": sum(t["runCount"] for t in tasks),
70
+ "cpuSumMs": sum(t["cpuSumMs"] for t in tasks),
71
+ "wallClockMs": sum(t["wallClockMs"] for t in tasks),
72
+ "errors": sum(t["errorCount"] for t in tasks),
73
+ "byWorkStatus": _tally(tasks, "workStatus"),
74
+ "byWorkCategory": _tally(tasks, "workCategory"),
75
+ "byCurrentPhase": _tally(tasks, "currentPhase"),
76
+ "byTaskType": _tally(tasks, "taskType"),
77
+ }
78
+ return {
79
+ "ok": True,
80
+ "projectRoot": str(project_root),
81
+ "taskGroup": task_group or None,
82
+ "taskCount": len(tasks),
83
+ "tasks": tasks,
84
+ "totals": totals,
85
+ }
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ parser = argparse.ArgumentParser(
90
+ prog="okstra rollup",
91
+ description="Roll up run results across a task-group's tasks (raw ms).")
92
+ parser.add_argument("--task-group", default="",
93
+ help="scope to this task-group (default: whole project catalog)")
94
+ parser.add_argument("--project-root", default="")
95
+ parser.add_argument("--cwd", default=".")
96
+ parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
97
+ args = parser.parse_args(argv)
98
+
99
+ try:
100
+ project_root = Path(
101
+ resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
102
+ ).resolve()
103
+ except ResolverError as exc:
104
+ print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
105
+ return 2
106
+
107
+ result = build_rollup(project_root, args.task_group)
108
+ print(json.dumps(result, ensure_ascii=False, indent=2))
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main(sys.argv[1:]))
@@ -23,10 +23,6 @@ file is useful for debugging.
23
23
  """
24
24
  from __future__ import annotations
25
25
 
26
- import contextlib
27
- import fcntl
28
- import json
29
- import os
30
26
  import time
31
27
  from dataclasses import dataclass
32
28
  from pathlib import Path
@@ -34,6 +30,8 @@ from typing import Optional
34
30
 
35
31
  from okstra_project.dirs import okstra_home
36
32
 
33
+ from .json_registry import load_registry_json, registry_lock, save_registry_json
34
+
37
35
 
38
36
  REGISTRY_FILENAME = "registry.json"
39
37
  LOCK_FILENAME = "registry.lock"
@@ -77,23 +75,9 @@ class WorktreeEntry:
77
75
  stages: Optional[list] = None
78
76
 
79
77
 
80
- @contextlib.contextmanager
81
78
  def _registry_lock():
82
- """Exclusive flock on `<worktrees>/registry.lock`. Mirrors the
83
- `central_lock` pattern from `locks.py` but scoped to the registry
84
- so we do not serialise unrelated central operations.
85
- """
86
- root = _okstra_worktrees_dir()
87
- root.mkdir(parents=True, exist_ok=True)
88
- lockfile = root / LOCK_FILENAME
89
- if not lockfile.exists():
90
- lockfile.touch()
91
- f = lockfile.open("r+")
92
- try:
93
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
94
- yield
95
- finally:
96
- f.close()
79
+ """Exclusive flock on `<worktrees>/registry.lock`."""
80
+ return registry_lock(_okstra_worktrees_dir() / LOCK_FILENAME)
97
81
 
98
82
 
99
83
  def _registry_path() -> Path:
@@ -101,24 +85,14 @@ def _registry_path() -> Path:
101
85
 
102
86
 
103
87
  def _load() -> dict:
104
- p = _registry_path()
105
- if not p.exists():
106
- return {"tasks": {}, "branches": {}}
107
- try:
108
- data = json.loads(p.read_text())
109
- except (OSError, json.JSONDecodeError):
110
- return {"tasks": {}, "branches": {}}
88
+ data = load_registry_json(_registry_path(), lambda: {"tasks": {}, "branches": {}})
111
89
  data.setdefault("tasks", {})
112
90
  data.setdefault("branches", {})
113
91
  return data
114
92
 
115
93
 
116
94
  def _save(data: dict) -> None:
117
- p = _registry_path()
118
- p.parent.mkdir(parents=True, exist_ok=True)
119
- tmp = p.with_suffix(".json.tmp")
120
- tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
121
- os.replace(tmp, p)
95
+ save_registry_json(_registry_path(), data)
122
96
 
123
97
 
124
98
  def lookup(