okstra 0.38.1 → 0.40.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/README.kr.md +1 -1
- package/README.md +1 -1
- package/docs/kr/architecture.md +18 -2
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +2 -3
- package/docs/superpowers/plans/2026-06-02-final-verification-protocol-hardening.md +326 -0
- package/docs/superpowers/plans/2026-06-02-okstra-run-branch-confirm-step.md +337 -0
- package/docs/superpowers/plans/2026-06-02-okstra-run-phase-pane-cleanup.md +410 -0
- package/docs/superpowers/plans/2026-06-02-requirements-discovery-fanout.md +728 -0
- package/docs/superpowers/specs/2026-06-02-okstra-run-branch-confirm-step-design.md +113 -0
- package/docs/superpowers/specs/2026-06-02-okstra-run-phase-pane-cleanup-design.md +173 -0
- package/docs/superpowers/specs/2026-06-02-requirements-discovery-fanout-design.md +154 -0
- package/docs/task-process/requirements-discovery.md +1 -1
- package/package.json +3 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/{python → bin}/lib/okstra/usage.sh +3 -2
- package/runtime/bin/okstra-codex-exec.sh +3 -3
- package/runtime/bin/okstra-trace-cleanup.sh +64 -26
- package/runtime/prompts/profiles/_common-contract.md +9 -5
- package/runtime/prompts/profiles/final-verification.md +18 -16
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/requirements-discovery.md +18 -1
- package/runtime/prompts/wizard/prompts.ko.json +11 -0
- package/runtime/python/okstra_ctl/consumers.py +1 -1
- package/runtime/python/okstra_ctl/fanout.py +35 -0
- package/runtime/python/okstra_ctl/migrate.py +21 -42
- package/runtime/python/okstra_ctl/reconcile.py +2 -2
- package/runtime/python/okstra_ctl/render_final_report.py +0 -1
- package/runtime/python/okstra_ctl/run.py +0 -29
- package/runtime/python/okstra_ctl/run_context.py +9 -12
- package/runtime/python/okstra_ctl/seeding.py +0 -192
- package/runtime/python/okstra_ctl/wizard.py +70 -5
- package/runtime/python/okstra_ctl/work_categories.py +21 -0
- package/runtime/python/okstra_ctl/worktree.py +74 -77
- package/runtime/python/okstra_project/__init__.py +0 -6
- package/runtime/python/okstra_project/dirs.py +0 -8
- package/runtime/schemas/final-report-v1.0.schema.json +34 -27
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +1 -1
- package/runtime/skills/okstra-inspect/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +2 -0
- package/runtime/templates/prd/brief.template.md +1 -1
- package/runtime/templates/reports/fan-out-unit.template.md +25 -0
- package/runtime/templates/reports/final-report.template.md +24 -13
- package/runtime/templates/reports/final-verification-input.template.md +16 -5
- package/runtime/templates/reports/i18n/en.json +6 -3
- package/runtime/templates/reports/i18n/ko.json +6 -3
- package/runtime/templates/worker-prompt-preamble.md +7 -0
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/lib/validate-assets.sh +9 -0
- package/runtime/validators/validate-implementation-plan-stages.py +19 -11
- package/runtime/validators/validate-run.py +114 -0
- package/runtime/validators/validate-schedule.py +4 -4
- package/runtime/validators/validate_fanout.py +99 -0
- package/src/_proc.mjs +31 -0
- package/src/check-project.mjs +1 -25
- package/src/config.mjs +7 -31
- package/src/doctor.mjs +10 -29
- package/src/install.mjs +8 -36
- package/src/migrate.mjs +1 -18
- package/src/okstra-dirs.mjs +0 -11
- package/src/setup.mjs +1 -154
- package/src/uninstall.mjs +6 -13
- package/runtime/templates/okstra.CLAUDE.md +0 -104
- /package/runtime/{python → bin}/lib/okstra/cli.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra/globals.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra/interactive.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra/project-resolver.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-batch.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-list.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-open.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-projects.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reconcile.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reindex.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-rerun.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-show.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/cmd-tail.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/main.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/prepare.sh +0 -0
- /package/runtime/{python → bin}/lib/okstra-ctl/usage.sh +0 -0
|
@@ -14,12 +14,6 @@ import time
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Optional
|
|
16
16
|
|
|
17
|
-
from okstra_project.dirs import (
|
|
18
|
-
CLAUDE_MD_IMPORT_LINE,
|
|
19
|
-
CLAUDE_MD_SYMLINK_RELATIVE,
|
|
20
|
-
OKSTRA_DIR_NAME,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
17
|
|
|
24
18
|
class InstallationError(Exception):
|
|
25
19
|
"""okstra 가 깔아둔 런타임 자산이 누락됨."""
|
|
@@ -29,14 +23,6 @@ class SettingsLinkError(Exception):
|
|
|
29
23
|
"""`<project>/.claude/settings.local.json` symlink provisioning 실패."""
|
|
30
24
|
|
|
31
25
|
|
|
32
|
-
class ClaudeMdLinkError(Exception):
|
|
33
|
-
"""okstra-relative `CLAUDE.md` symlink or import-block provisioning 실패."""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class AgentsMdLinkError(Exception):
|
|
37
|
-
"""`<project>/AGENTS.md` symlink provisioning 실패."""
|
|
38
|
-
|
|
39
|
-
|
|
40
26
|
def installed_version() -> str:
|
|
41
27
|
"""Read the version stamp written by `okstra install` to `~/.okstra/version`.
|
|
42
28
|
|
|
@@ -209,181 +195,3 @@ def _backup_and_replace(target: Path, template: Path) -> None:
|
|
|
209
195
|
raise SettingsLinkError(
|
|
210
196
|
f"failed to create symlink {target} -> {template} after backup: {exc}"
|
|
211
197
|
) from exc
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def installed_claude_md_template_path() -> Path:
|
|
215
|
-
"""okstra install 이 만들어 둔 okstra.CLAUDE.md template 의 절대경로."""
|
|
216
|
-
return _okstra_home() / "templates" / "okstra.CLAUDE.md"
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
_CLAUDE_MD_SYMLINK_REL = CLAUDE_MD_SYMLINK_RELATIVE
|
|
220
|
-
_CLAUDE_MD_IMPORT_LINE = CLAUDE_MD_IMPORT_LINE
|
|
221
|
-
_CLAUDE_MD_MARKER_BEGIN = (
|
|
222
|
-
"<!-- okstra:claude-md:begin (managed by okstra setup — do not edit) -->"
|
|
223
|
-
)
|
|
224
|
-
_CLAUDE_MD_MARKER_END = "<!-- okstra:claude-md:end -->"
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def ensure_project_claude_md(*, project_root: Path) -> Optional[Path]:
|
|
228
|
-
"""okstra-relative `CLAUDE.md` 를 `~/.okstra/templates/okstra.CLAUDE.md`
|
|
229
|
-
로 가리키는 symlink 로 provisioning 하고, `<project_root>/CLAUDE.md` 에
|
|
230
|
-
import block 을 멱등하게 주입한다.
|
|
231
|
-
|
|
232
|
-
Claude Code 가 해당 프로젝트에서 host 세션으로 실행될 때
|
|
233
|
-
`<project_root>/CLAUDE.md` 가 자동 로드되므로, okstra 가 관리하는 본문
|
|
234
|
-
(slash command catalog, workflow guidance, ...) 도 같이 surface 된다.
|
|
235
|
-
|
|
236
|
-
반환값:
|
|
237
|
-
- symlink Path: 신규 생성됐거나 이미 올바른 target 을 가리키고 있을 때.
|
|
238
|
-
- None: install 이 아직 CLAUDE.md template 을 깔지 않았을 때 (구버전
|
|
239
|
-
okstra install). 상위에서 경고로 흘려보낸다.
|
|
240
|
-
|
|
241
|
-
상위 호출자는 `ClaudeMdLinkError` 만 처리하면 된다.
|
|
242
|
-
"""
|
|
243
|
-
project_root = Path(project_root)
|
|
244
|
-
template = installed_claude_md_template_path()
|
|
245
|
-
if not template.exists():
|
|
246
|
-
return None
|
|
247
|
-
|
|
248
|
-
target = project_root / _CLAUDE_MD_SYMLINK_REL
|
|
249
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
-
|
|
251
|
-
if target.is_symlink():
|
|
252
|
-
try:
|
|
253
|
-
current = os.readlink(target)
|
|
254
|
-
except OSError as exc:
|
|
255
|
-
raise ClaudeMdLinkError(
|
|
256
|
-
f"failed to read existing symlink {target}: {exc}"
|
|
257
|
-
) from exc
|
|
258
|
-
current_path = Path(current)
|
|
259
|
-
if current_path == template or (target.parent / current_path).resolve() == template.resolve():
|
|
260
|
-
_ensure_claude_md_import(project_root)
|
|
261
|
-
return target
|
|
262
|
-
_backup_and_replace_claude_md(target, template)
|
|
263
|
-
_ensure_claude_md_import(project_root)
|
|
264
|
-
return target
|
|
265
|
-
|
|
266
|
-
if target.exists():
|
|
267
|
-
_backup_and_replace_claude_md(target, template)
|
|
268
|
-
_ensure_claude_md_import(project_root)
|
|
269
|
-
return target
|
|
270
|
-
|
|
271
|
-
try:
|
|
272
|
-
target.symlink_to(template)
|
|
273
|
-
except OSError as exc:
|
|
274
|
-
raise ClaudeMdLinkError(
|
|
275
|
-
f"failed to create symlink {target} -> {template}: {exc}"
|
|
276
|
-
) from exc
|
|
277
|
-
_ensure_claude_md_import(project_root)
|
|
278
|
-
return target
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def _ensure_claude_md_import(project_root: Path) -> bool:
|
|
282
|
-
"""`<project_root>/CLAUDE.md` 에 import block 이 없으면 append, 있으면 no-op.
|
|
283
|
-
|
|
284
|
-
반환: 새로 주입했을 때 True, 이미 있었을 때 False.
|
|
285
|
-
"""
|
|
286
|
-
claude_md = project_root / "CLAUDE.md"
|
|
287
|
-
block = f"{_CLAUDE_MD_MARKER_BEGIN}\n{_CLAUDE_MD_IMPORT_LINE}\n{_CLAUDE_MD_MARKER_END}\n"
|
|
288
|
-
|
|
289
|
-
try:
|
|
290
|
-
existing = claude_md.read_text(encoding="utf-8")
|
|
291
|
-
except FileNotFoundError:
|
|
292
|
-
try:
|
|
293
|
-
claude_md.write_text(block, encoding="utf-8")
|
|
294
|
-
except OSError as exc:
|
|
295
|
-
raise ClaudeMdLinkError(
|
|
296
|
-
f"failed to create {claude_md}: {exc}"
|
|
297
|
-
) from exc
|
|
298
|
-
return True
|
|
299
|
-
except OSError as exc:
|
|
300
|
-
raise ClaudeMdLinkError(f"failed to read {claude_md}: {exc}") from exc
|
|
301
|
-
|
|
302
|
-
if _CLAUDE_MD_MARKER_BEGIN in existing and _CLAUDE_MD_MARKER_END in existing:
|
|
303
|
-
return False
|
|
304
|
-
|
|
305
|
-
separator = _block_separator_for(existing)
|
|
306
|
-
try:
|
|
307
|
-
claude_md.write_text(existing + separator + block, encoding="utf-8")
|
|
308
|
-
except OSError as exc:
|
|
309
|
-
raise ClaudeMdLinkError(f"failed to update {claude_md}: {exc}") from exc
|
|
310
|
-
return True
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def _block_separator_for(existing: str) -> str:
|
|
314
|
-
"""기존 파일 끝과 import block 사이의 공백 결정 — 가독성 보장 목적."""
|
|
315
|
-
if not existing:
|
|
316
|
-
return ""
|
|
317
|
-
if existing.endswith("\n\n"):
|
|
318
|
-
return ""
|
|
319
|
-
if existing.endswith("\n"):
|
|
320
|
-
return "\n"
|
|
321
|
-
return "\n\n"
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _backup_and_replace_claude_md(target: Path, template: Path) -> None:
|
|
325
|
-
"""기존 파일/심볼릭링크를 timestamped backup 으로 옮기고 새 symlink 생성."""
|
|
326
|
-
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
327
|
-
backup = target.with_name(f"{target.name}.bak.{stamp}")
|
|
328
|
-
try:
|
|
329
|
-
target.rename(backup)
|
|
330
|
-
except OSError as exc:
|
|
331
|
-
raise ClaudeMdLinkError(
|
|
332
|
-
f"failed to back up existing {target} to {backup}: {exc}"
|
|
333
|
-
) from exc
|
|
334
|
-
try:
|
|
335
|
-
target.symlink_to(template)
|
|
336
|
-
except OSError as exc:
|
|
337
|
-
raise ClaudeMdLinkError(
|
|
338
|
-
f"failed to create symlink {target} -> {template} after backup: {exc}"
|
|
339
|
-
) from exc
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def ensure_project_agents_md(*, project_root: Path) -> Optional[Path]:
|
|
343
|
-
"""`<project_root>/AGENTS.md` 가 없을 때만 `~/.okstra/templates/okstra.CLAUDE.md`
|
|
344
|
-
로의 심링크로 생성한다.
|
|
345
|
-
|
|
346
|
-
AGENTS.md 는 codex / aider / 기타 agent 가 읽는 파일이고 @import 같은
|
|
347
|
-
부분 포함 메커니즘이 없어, 파일 전체 내용이 곧 agent 가 보는 콘텐츠가
|
|
348
|
-
된다. 그래서 CLAUDE.md (`@.okstra/CLAUDE.md` 마커 블록
|
|
349
|
-
주입) 와 달리 "AGENTS.md 가 비어 있을 때만 만들고, 존재하면 절대
|
|
350
|
-
건드리지 않는" 정책을 사용한다 — 사용자가 직접 작성한 AGENTS.md 를
|
|
351
|
-
덮어쓰지 않는다.
|
|
352
|
-
|
|
353
|
-
반환값:
|
|
354
|
-
- target Path: 신규 심링크 생성, 또는 이미 우리 템플릿을 가리키고
|
|
355
|
-
있던 심링크가 idempotent 하게 확인된 경우.
|
|
356
|
-
- None: install 이 아직 CLAUDE.md template 을 깔지 않았거나,
|
|
357
|
-
AGENTS.md 가 이미 사용자 콘텐츠 (regular file) 거나 우리가
|
|
358
|
-
만들지 않은 다른 심링크로 존재하는 경우. 후자 두 케이스는
|
|
359
|
-
사용자의 의도로 간주하고 건드리지 않는다.
|
|
360
|
-
|
|
361
|
-
상위 호출자는 `AgentsMdLinkError` 만 처리하면 된다.
|
|
362
|
-
"""
|
|
363
|
-
project_root = Path(project_root)
|
|
364
|
-
template = installed_claude_md_template_path()
|
|
365
|
-
if not template.exists():
|
|
366
|
-
return None
|
|
367
|
-
|
|
368
|
-
target = project_root / "AGENTS.md"
|
|
369
|
-
|
|
370
|
-
if target.is_symlink():
|
|
371
|
-
try:
|
|
372
|
-
current = os.readlink(target)
|
|
373
|
-
except OSError:
|
|
374
|
-
return None
|
|
375
|
-
current_path = Path(current)
|
|
376
|
-
if current_path == template or (target.parent / current_path).resolve() == template.resolve():
|
|
377
|
-
return target
|
|
378
|
-
return None # foreign symlink — respect user
|
|
379
|
-
|
|
380
|
-
if target.exists():
|
|
381
|
-
return None # regular file — respect user content
|
|
382
|
-
|
|
383
|
-
try:
|
|
384
|
-
target.symlink_to(template)
|
|
385
|
-
except OSError as exc:
|
|
386
|
-
raise AgentsMdLinkError(
|
|
387
|
-
f"failed to create symlink {target} -> {template}: {exc}"
|
|
388
|
-
) from exc
|
|
389
|
-
return target
|
|
@@ -31,6 +31,7 @@ from okstra_ctl.models import (
|
|
|
31
31
|
UnknownModelError,
|
|
32
32
|
resolve_model_metadata,
|
|
33
33
|
)
|
|
34
|
+
from okstra_ctl.clarification_items import unresolved_approval_blockers
|
|
34
35
|
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
35
36
|
from okstra_ctl.run import (
|
|
36
37
|
APPROVED_FRONTMATTER_PATTERN,
|
|
@@ -45,6 +46,7 @@ from okstra_ctl.workers import (
|
|
|
45
46
|
validate_workers_against_profile,
|
|
46
47
|
)
|
|
47
48
|
from okstra_ctl import worktree_registry
|
|
49
|
+
from okstra_ctl.worktree import preview_worktree_decision
|
|
48
50
|
from okstra_project.dirs import project_json_path, tasks_root
|
|
49
51
|
from okstra_project.state import (
|
|
50
52
|
StateError,
|
|
@@ -196,6 +198,7 @@ S_CLARIFICATION = "clarification"
|
|
|
196
198
|
S_PR_TEMPLATE_PICK = "pr_template_pick"
|
|
197
199
|
S_PR_TEMPLATE = "pr_template"
|
|
198
200
|
S_PR_TEMPLATE_SCOPE = "pr_template_scope"
|
|
201
|
+
S_BRANCH_CONFIRM = "branch_confirm"
|
|
199
202
|
S_CONFIRM = "confirm"
|
|
200
203
|
S_EDIT_TARGET = "edit_target"
|
|
201
204
|
S_DONE = "done"
|
|
@@ -267,6 +270,7 @@ class WizardState:
|
|
|
267
270
|
last_pr_template_cached: str = ""
|
|
268
271
|
|
|
269
272
|
# confirm / edit
|
|
273
|
+
branch_confirmed: Optional[bool] = None
|
|
270
274
|
confirmed: Optional[bool] = None
|
|
271
275
|
edit_target: str = ""
|
|
272
276
|
|
|
@@ -365,6 +369,18 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
|
365
369
|
" edit the report and change the line to `approved: true`, or re-run "
|
|
366
370
|
"okstra with `--approve` to flip it from the CLI."
|
|
367
371
|
)
|
|
372
|
+
# frontmatter approved == true 라도 §5 의 Blocks=approval 행이 미해결이면
|
|
373
|
+
# 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
|
|
374
|
+
blockers = unresolved_approval_blockers(body)
|
|
375
|
+
if blockers:
|
|
376
|
+
lines = [
|
|
377
|
+
f"approved plan frontmatter has `approved: true` but §5 has {len(blockers)} "
|
|
378
|
+
f"unresolved `Blocks=approval` row(s); resolve them or mark them obsolete first:",
|
|
379
|
+
]
|
|
380
|
+
for b in blockers:
|
|
381
|
+
lines.append(f" - {b.row_id} (Status={b.raw_status})")
|
|
382
|
+
lines.append(f" file: {p}")
|
|
383
|
+
raise WizardError("\n".join(lines))
|
|
368
384
|
return p
|
|
369
385
|
|
|
370
386
|
|
|
@@ -1160,10 +1176,12 @@ def _build_stage_pick(state: WizardState) -> Prompt:
|
|
|
1160
1176
|
/ "validate-implementation-plan-stages.py"
|
|
1161
1177
|
)
|
|
1162
1178
|
spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
|
|
1163
|
-
|
|
1179
|
+
if spec is None or spec.loader is None:
|
|
1180
|
+
raise WizardError(f"cannot load stage validator at {validator_path}")
|
|
1181
|
+
mod = _ilu.module_from_spec(spec)
|
|
1164
1182
|
_sys.modules["_ip_stage_v_wizard"] = mod
|
|
1165
1183
|
try:
|
|
1166
|
-
spec.loader.exec_module(mod)
|
|
1184
|
+
spec.loader.exec_module(mod)
|
|
1167
1185
|
stages, _errs = mod._parse_stage_map(plan_text)
|
|
1168
1186
|
finally:
|
|
1169
1187
|
_sys.modules.pop("_ip_stage_v_wizard", None)
|
|
@@ -1698,6 +1716,49 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
|
|
|
1698
1716
|
return f"pr-template-scope: {value}"
|
|
1699
1717
|
|
|
1700
1718
|
|
|
1719
|
+
def _build_branch_confirm(state: WizardState) -> Prompt:
|
|
1720
|
+
decision = preview_worktree_decision(
|
|
1721
|
+
project_root=Path(state.project_root),
|
|
1722
|
+
project_id=state.project_id,
|
|
1723
|
+
task_group_segment=state.task_group,
|
|
1724
|
+
task_id_segment=state.task_id,
|
|
1725
|
+
# okstra-run 경로는 --work-category 를 넘기지 않으므로 provision 도 ""(→ "task-" prefix)로
|
|
1726
|
+
# 브랜치를 만든다. 이 단계에서 미리보기 브랜치명은 실제 생성 브랜치와 정확히 일치한다.
|
|
1727
|
+
work_category="",
|
|
1728
|
+
base_ref=state.base_ref,
|
|
1729
|
+
)
|
|
1730
|
+
key = {
|
|
1731
|
+
"new": "new", "reused": "reuse",
|
|
1732
|
+
"skipped-in-worktree": "in_worktree", "skipped-not-git": "not_git",
|
|
1733
|
+
}[decision.status]
|
|
1734
|
+
# Fetch raw step data first so we can compute the label before calling _p.
|
|
1735
|
+
raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
|
|
1736
|
+
label = raw["labels"][key].format(
|
|
1737
|
+
branch=decision.branch or "(none)",
|
|
1738
|
+
base_ref=decision.base_ref or "(HEAD)",
|
|
1739
|
+
path=decision.path,
|
|
1740
|
+
)
|
|
1741
|
+
# Pass the computed label as `summary` so _p's placeholder interpolation works.
|
|
1742
|
+
t = _p(state.workspace_root, "branch_confirm", summary=label)
|
|
1743
|
+
opts = t["options"]
|
|
1744
|
+
options = [_opt("proceed", opts["proceed"])]
|
|
1745
|
+
if decision.status == "new":
|
|
1746
|
+
options.append(_opt("edit", opts["edit"]))
|
|
1747
|
+
return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
|
|
1748
|
+
options=options, echo_template=t["echo_template"])
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
1752
|
+
if value == "edit":
|
|
1753
|
+
_reset_from(state, S_BASE_REF_PICK)
|
|
1754
|
+
state.branch_confirmed = None
|
|
1755
|
+
return "branch-confirm: edit"
|
|
1756
|
+
if value != "proceed":
|
|
1757
|
+
raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
|
|
1758
|
+
state.branch_confirmed = True
|
|
1759
|
+
return "branch-confirm: proceed"
|
|
1760
|
+
|
|
1761
|
+
|
|
1701
1762
|
def _build_confirm(state: WizardState) -> Prompt:
|
|
1702
1763
|
t = _p(state.workspace_root, "confirm")
|
|
1703
1764
|
return Prompt(
|
|
@@ -1720,7 +1781,7 @@ def _build_edit_target(state: WizardState) -> Prompt:
|
|
|
1720
1781
|
# offer every step that has been answered.
|
|
1721
1782
|
options: list[Option] = []
|
|
1722
1783
|
for sid in state.answered:
|
|
1723
|
-
if sid in (S_CONFIRM, S_EDIT_TARGET):
|
|
1784
|
+
if sid in (S_BRANCH_CONFIRM, S_CONFIRM, S_EDIT_TARGET):
|
|
1724
1785
|
continue
|
|
1725
1786
|
options.append(_opt(sid, sid))
|
|
1726
1787
|
return Prompt(
|
|
@@ -1976,8 +2037,12 @@ STEPS: list[Step] = [
|
|
|
1976
2037
|
and S_PR_TEMPLATE_SCOPE not in s.answered),
|
|
1977
2038
|
build=_build_pr_template_scope, submit=_submit_pr_template_scope,
|
|
1978
2039
|
owns=("pr_template_scope",)),
|
|
2040
|
+
Step(S_BRANCH_CONFIRM,
|
|
2041
|
+
applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is None,
|
|
2042
|
+
build=_build_branch_confirm, submit=_submit_branch_confirm,
|
|
2043
|
+
owns=("branch_confirmed",)),
|
|
1979
2044
|
Step(S_CONFIRM,
|
|
1980
|
-
applies=lambda s: _ready_for_confirm(s) and s.confirmed is None,
|
|
2045
|
+
applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is True and s.confirmed is None,
|
|
1981
2046
|
build=_build_confirm, submit=_submit_confirm,
|
|
1982
2047
|
owns=("confirmed", "edit_target")),
|
|
1983
2048
|
Step(S_EDIT_TARGET,
|
|
@@ -2062,7 +2127,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
2062
2127
|
"clarification_response_path": "", "clarification_pending_text": False,
|
|
2063
2128
|
"pr_template_path": "", "pr_template_pending_text": False,
|
|
2064
2129
|
"pr_template_scope": "",
|
|
2065
|
-
"confirmed": None, "edit_target": "",
|
|
2130
|
+
"branch_confirmed": None, "confirmed": None, "edit_target": "",
|
|
2066
2131
|
}
|
|
2067
2132
|
|
|
2068
2133
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""requirements-discovery work-category(domain) SSOT.
|
|
2
|
+
|
|
3
|
+
profile / validators / wizard 는 이 모듈에서 enum 을 import 해야 한다. 다른 곳에서
|
|
4
|
+
재정의하면 single-reference-point 위반이며 tests/test_work_categories.py 가 거부한다.
|
|
5
|
+
|
|
6
|
+
다섯째 값은 `ops` 가 canonical 이다(`--work-category` CLI help 및 workflow.PHASE_RULES
|
|
7
|
+
와 일치). 구버전 비표준 철자는 폐기된다.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
WORK_CATEGORIES: tuple[str, ...] = (
|
|
12
|
+
"bugfix",
|
|
13
|
+
"feature",
|
|
14
|
+
"refactor",
|
|
15
|
+
"ops",
|
|
16
|
+
"improvement",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_valid_category(value: str) -> bool:
|
|
21
|
+
return value in WORK_CATEGORIES
|
|
@@ -46,11 +46,7 @@ from okstra_project.dirs import project_json_path
|
|
|
46
46
|
from .ids import _safe_fs_segment
|
|
47
47
|
from . import worktree_registry
|
|
48
48
|
from .seeding import (
|
|
49
|
-
AgentsMdLinkError,
|
|
50
|
-
ClaudeMdLinkError,
|
|
51
49
|
SettingsLinkError,
|
|
52
|
-
ensure_project_agents_md,
|
|
53
|
-
ensure_project_claude_md,
|
|
54
50
|
ensure_project_settings_symlink,
|
|
55
51
|
)
|
|
56
52
|
|
|
@@ -145,6 +141,60 @@ class WorktreeProvision:
|
|
|
145
141
|
note: str = "" # human-readable explanation, surfaced in team-state / manifests
|
|
146
142
|
|
|
147
143
|
|
|
144
|
+
@dataclass
|
|
145
|
+
class WorktreeDecision:
|
|
146
|
+
"""Side-effect-free preview of what `provision_task_worktree` would do.
|
|
147
|
+
|
|
148
|
+
status:
|
|
149
|
+
- "new": no active registry entry; a fresh worktree would be created
|
|
150
|
+
- "reused": registry already has this task-key; existing path/branch returned
|
|
151
|
+
- "skipped-in-worktree": project_root is itself a non-main worktree
|
|
152
|
+
- "skipped-not-git": project_root has no .git
|
|
153
|
+
"""
|
|
154
|
+
status: str
|
|
155
|
+
path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
|
|
156
|
+
branch: str = "" # new: prospective branch; reused: existing branch
|
|
157
|
+
base_ref: str = "" # new: requested base_ref; reused: existing base
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def preview_worktree_decision(
|
|
161
|
+
*,
|
|
162
|
+
project_root,
|
|
163
|
+
project_id: str,
|
|
164
|
+
task_group_segment: str,
|
|
165
|
+
task_id_segment: str,
|
|
166
|
+
work_category: str,
|
|
167
|
+
base_ref: str = "",
|
|
168
|
+
) -> "WorktreeDecision":
|
|
169
|
+
"""Side-effect-free: what provision_task_worktree WOULD do, without touching disk.
|
|
170
|
+
|
|
171
|
+
Mirrors provision's decision branches exactly; reuses the same read-only
|
|
172
|
+
helpers so preview never diverges from the actual provisioning result.
|
|
173
|
+
"""
|
|
174
|
+
project_root = Path(project_root)
|
|
175
|
+
if not _is_git_repo(project_root):
|
|
176
|
+
return WorktreeDecision(status="skipped-not-git", path=str(project_root))
|
|
177
|
+
if _is_inside_non_main_worktree(project_root):
|
|
178
|
+
return WorktreeDecision(status="skipped-in-worktree", path=str(project_root))
|
|
179
|
+
safe_project = _safe_segment(project_id)
|
|
180
|
+
safe_group = _safe_segment(task_group_segment)
|
|
181
|
+
safe_task = _safe_segment(task_id_segment)
|
|
182
|
+
existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
|
|
183
|
+
if existing is not None and existing.status == "active":
|
|
184
|
+
return WorktreeDecision(
|
|
185
|
+
status="reused", path=existing.worktree_path,
|
|
186
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
187
|
+
)
|
|
188
|
+
return WorktreeDecision(
|
|
189
|
+
status="new",
|
|
190
|
+
path=str(compute_worktree_path(
|
|
191
|
+
project_id=safe_project, task_group_segment=safe_group,
|
|
192
|
+
task_id_segment=safe_task)),
|
|
193
|
+
branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
|
|
194
|
+
base_ref=base_ref,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
148
198
|
def _safe_segment(value: str) -> str:
|
|
149
199
|
"""Sanitise a single path/branch segment.
|
|
150
200
|
|
|
@@ -256,11 +306,6 @@ def _read_project_json_field(project_root: Path, field: str) -> Optional[tuple[s
|
|
|
256
306
|
return cleaned
|
|
257
307
|
|
|
258
308
|
|
|
259
|
-
def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
|
|
260
|
-
"""Back-compat shim — preserves the old name used by external callers."""
|
|
261
|
-
return _read_project_json_field(project_root, "worktreeSyncDirs")
|
|
262
|
-
|
|
263
|
-
|
|
264
309
|
def _resolve_entries(
|
|
265
310
|
*,
|
|
266
311
|
env_var: str,
|
|
@@ -399,48 +444,6 @@ def _seed_worktree_settings_symlink(worktree_path: Path) -> None:
|
|
|
399
444
|
)
|
|
400
445
|
|
|
401
446
|
|
|
402
|
-
def _seed_worktree_claude_md(worktree_path: Path) -> None:
|
|
403
|
-
"""Seed `.okstra/CLAUDE.md` symlink + `<worktree>/CLAUDE.md`
|
|
404
|
-
import block in the worker worktree so dispatched Claude sessions auto-load
|
|
405
|
-
okstra's runtime guidance. Mirrors `_seed_worktree_settings_symlink` —
|
|
406
|
-
needed because the worktree's `CLAUDE.md` comes from the git checkout (no
|
|
407
|
-
marker block unless committed) and `.project-docs/` is only dir-symlinked,
|
|
408
|
-
not its contents.
|
|
409
|
-
"""
|
|
410
|
-
try:
|
|
411
|
-
link = ensure_project_claude_md(project_root=worktree_path)
|
|
412
|
-
except ClaudeMdLinkError as exc:
|
|
413
|
-
print(
|
|
414
|
-
f"okstra-claude-md: failed to seed worker worktree CLAUDE.md at "
|
|
415
|
-
f"{worktree_path} — dispatched Claude sessions will not auto-load "
|
|
416
|
-
f"okstra guidance. ({exc})",
|
|
417
|
-
file=__import__("sys").stderr,
|
|
418
|
-
)
|
|
419
|
-
return
|
|
420
|
-
if link is None:
|
|
421
|
-
print(
|
|
422
|
-
"okstra-claude-md: ~/.okstra/templates/okstra.CLAUDE.md missing — "
|
|
423
|
-
"re-run 'npx okstra@latest install' to provision the symlink target.",
|
|
424
|
-
file=__import__("sys").stderr,
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _seed_worktree_agents_md(worktree_path: Path) -> None:
|
|
429
|
-
"""Seed `<worktree>/AGENTS.md` symlink so codex / aider sessions dispatched
|
|
430
|
-
into this worktree auto-load okstra's guidance. Only acts when AGENTS.md
|
|
431
|
-
is absent — never overwrites user content.
|
|
432
|
-
"""
|
|
433
|
-
try:
|
|
434
|
-
ensure_project_agents_md(project_root=worktree_path)
|
|
435
|
-
except AgentsMdLinkError as exc:
|
|
436
|
-
print(
|
|
437
|
-
f"okstra-agents-md: failed to seed worker worktree AGENTS.md at "
|
|
438
|
-
f"{worktree_path} — dispatched codex / aider sessions will not "
|
|
439
|
-
f"auto-load okstra guidance. ({exc})",
|
|
440
|
-
file=__import__("sys").stderr,
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
|
|
444
447
|
def _copy_snapshot_files(source_root: Path, worktree_path: Path) -> list[str]:
|
|
445
448
|
"""Copy fixture files from MAIN → task worktree as read-only snapshots
|
|
446
449
|
(FU-V3).
|
|
@@ -537,20 +540,26 @@ def provision_task_worktree(
|
|
|
537
540
|
(`run.py`) catches and re-raises as PrepareError to keep a
|
|
538
541
|
single error surface.
|
|
539
542
|
"""
|
|
540
|
-
|
|
543
|
+
decision = preview_worktree_decision(
|
|
544
|
+
project_root=project_root, project_id=project_id,
|
|
545
|
+
task_group_segment=task_group_segment, task_id_segment=task_id_segment,
|
|
546
|
+
work_category=work_category, base_ref=base_ref,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if decision.status == "skipped-not-git":
|
|
541
550
|
return WorktreeProvision(
|
|
542
551
|
status="skipped-not-git",
|
|
543
|
-
path=
|
|
552
|
+
path=decision.path,
|
|
544
553
|
note=(
|
|
545
554
|
"worktree provisioning skipped: project_root is not inside a git "
|
|
546
555
|
"repository; task will operate directly on project_root"
|
|
547
556
|
),
|
|
548
557
|
)
|
|
549
558
|
|
|
550
|
-
if
|
|
559
|
+
if decision.status == "skipped-in-worktree":
|
|
551
560
|
return WorktreeProvision(
|
|
552
561
|
status="skipped-in-worktree",
|
|
553
|
-
path=
|
|
562
|
+
path=decision.path,
|
|
554
563
|
note=(
|
|
555
564
|
"worktree provisioning skipped: project_root is already inside a "
|
|
556
565
|
"non-main git worktree; task reuses the caller's worktree"
|
|
@@ -561,33 +570,23 @@ def provision_task_worktree(
|
|
|
561
570
|
safe_group = _safe_segment(task_group_segment)
|
|
562
571
|
safe_task = _safe_segment(task_id_segment)
|
|
563
572
|
|
|
564
|
-
|
|
565
|
-
existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
|
|
566
|
-
if existing is not None and existing.status == "active":
|
|
573
|
+
if decision.status == "reused":
|
|
567
574
|
worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
|
|
568
|
-
_seed_worktree_settings_symlink(Path(
|
|
569
|
-
_seed_worktree_claude_md(Path(existing.worktree_path))
|
|
570
|
-
_seed_worktree_agents_md(Path(existing.worktree_path))
|
|
575
|
+
_seed_worktree_settings_symlink(Path(decision.path))
|
|
571
576
|
return WorktreeProvision(
|
|
572
577
|
status="reused",
|
|
573
|
-
path=
|
|
574
|
-
branch=
|
|
575
|
-
base_ref=
|
|
578
|
+
path=decision.path,
|
|
579
|
+
branch=decision.branch,
|
|
580
|
+
base_ref=decision.base_ref,
|
|
576
581
|
note=(
|
|
577
|
-
f"task worktree reused at {
|
|
578
|
-
f"{
|
|
582
|
+
f"task worktree reused at {decision.path} on branch "
|
|
583
|
+
f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
|
|
579
584
|
),
|
|
580
585
|
)
|
|
581
586
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
task_id_segment=safe_task,
|
|
586
|
-
)
|
|
587
|
-
branch = compute_branch_name(
|
|
588
|
-
work_category=work_category,
|
|
589
|
-
task_id_segment=safe_task,
|
|
590
|
-
)
|
|
587
|
+
# decision.status == "new" — proceed with creation
|
|
588
|
+
worktree_path = Path(decision.path)
|
|
589
|
+
branch = decision.branch
|
|
591
590
|
|
|
592
591
|
if worktree_path.exists():
|
|
593
592
|
raise RuntimeError(
|
|
@@ -670,8 +669,6 @@ def provision_task_worktree(
|
|
|
670
669
|
raise
|
|
671
670
|
|
|
672
671
|
_seed_worktree_settings_symlink(worktree_path)
|
|
673
|
-
_seed_worktree_claude_md(worktree_path)
|
|
674
|
-
_seed_worktree_agents_md(worktree_path)
|
|
675
672
|
|
|
676
673
|
base_label = (
|
|
677
674
|
f"{base_origin} @ {resolved_base_ref[:12]}"
|
|
@@ -9,8 +9,6 @@ PROJECT_ROOT 를 해석하고, 실행 시점에 upsert 한다. 과거 모델
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from .dirs import (
|
|
12
|
-
CLAUDE_MD_IMPORT_LINE,
|
|
13
|
-
CLAUDE_MD_SYMLINK_RELATIVE,
|
|
14
12
|
DISCOVERY_RELATIVE,
|
|
15
13
|
LATEST_TASK_RELATIVE,
|
|
16
14
|
OKSTRA_DIR_NAME,
|
|
@@ -18,7 +16,6 @@ from .dirs import (
|
|
|
18
16
|
PROJECT_JSON_RELATIVE,
|
|
19
17
|
TASK_CATALOG_RELATIVE,
|
|
20
18
|
TASKS_RELATIVE,
|
|
21
|
-
claude_md_symlink_path,
|
|
22
19
|
discovery_dir,
|
|
23
20
|
okstra_root,
|
|
24
21
|
project_json_path,
|
|
@@ -42,8 +39,6 @@ from .state import (
|
|
|
42
39
|
)
|
|
43
40
|
|
|
44
41
|
__all__ = [
|
|
45
|
-
"CLAUDE_MD_IMPORT_LINE",
|
|
46
|
-
"CLAUDE_MD_SYMLINK_RELATIVE",
|
|
47
42
|
"DISCOVERY_RELATIVE",
|
|
48
43
|
"LATEST_TASK_RELATIVE",
|
|
49
44
|
"OKSTRA_DIR_NAME",
|
|
@@ -53,7 +48,6 @@ __all__ = [
|
|
|
53
48
|
"StateError",
|
|
54
49
|
"TASKS_RELATIVE",
|
|
55
50
|
"TASK_CATALOG_RELATIVE",
|
|
56
|
-
"claude_md_symlink_path",
|
|
57
51
|
"discovery_dir",
|
|
58
52
|
"find_task_root",
|
|
59
53
|
"list_project_tasks",
|
|
@@ -37,9 +37,6 @@ TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
|
|
|
37
37
|
DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
|
|
38
38
|
TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
|
|
39
39
|
LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
|
|
40
|
-
CLAUDE_MD_SYMLINK_RELATIVE = OKSTRA_RELATIVE / "CLAUDE.md"
|
|
41
|
-
CLAUDE_MD_IMPORT_LINE = f"@{OKSTRA_DIR_NAME}/CLAUDE.md"
|
|
42
|
-
LEGACY_CLAUDE_MD_IMPORT_LINE = f"@{LEGACY_OKSTRA_DIR_NAME}/CLAUDE.md"
|
|
43
40
|
|
|
44
41
|
|
|
45
42
|
def okstra_root(project_root: Path) -> Path:
|
|
@@ -60,8 +57,3 @@ def tasks_root(project_root: Path) -> Path:
|
|
|
60
57
|
def discovery_dir(project_root: Path) -> Path:
|
|
61
58
|
"""`<project_root>/.okstra/discovery` 절대 path."""
|
|
62
59
|
return Path(project_root) / DISCOVERY_RELATIVE
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def claude_md_symlink_path(project_root: Path) -> Path:
|
|
66
|
-
"""`<project_root>/.okstra/CLAUDE.md` 절대 path."""
|
|
67
|
-
return Path(project_root) / CLAUDE_MD_SYMLINK_RELATIVE
|