okstra 0.109.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.kr.md +2 -1
  2. package/README.md +2 -1
  3. package/docs/for-ai/skills/okstra-run.md +7 -5
  4. package/docs/kr/architecture.md +11 -12
  5. package/docs/kr/cli.md +4 -4
  6. package/docs/project-structure-overview.md +12 -12
  7. package/docs/task-process/README.md +4 -4
  8. package/docs/task-process/common-flow.md +8 -5
  9. package/docs/task-process/implementation.md +4 -5
  10. package/docs/task-process/release-handoff.md +3 -3
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/antigravity-worker.md +1 -0
  14. package/runtime/agents/workers/codex-worker.md +1 -0
  15. package/runtime/prompts/lead/convergence.md +7 -7
  16. package/runtime/prompts/profiles/_common-contract.md +1 -1
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +43 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +7 -9
  19. package/runtime/prompts/profiles/_implementation-self-check.md +11 -5
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +3 -0
  21. package/runtime/python/okstra_ctl/consumers.py +4 -0
  22. package/runtime/python/okstra_ctl/handoff.py +5 -23
  23. package/runtime/python/okstra_ctl/implementation_stage.py +11 -11
  24. package/runtime/python/okstra_ctl/report_views.py +76 -26
  25. package/runtime/python/okstra_ctl/stage_targets.py +152 -0
  26. package/runtime/python/okstra_ctl/wizard.py +47 -0
  27. package/runtime/python/okstra_ctl/worktree.py +14 -13
  28. package/runtime/python/okstra_project/__init__.py +2 -0
  29. package/runtime/python/okstra_project/state.py +44 -2
  30. package/runtime/skills/okstra-run/SKILL.md +21 -17
  31. package/src/commands/execute/wizard.mjs +3 -1
  32. package/src/commands/inspect/task-show.mjs +11 -31
@@ -105,17 +105,16 @@ DEFAULT_WORKTREE_SYNC_FILES: tuple[str, ...] = (
105
105
  DEFAULT_WORKTREE_SNAPSHOT_FILES: tuple[str, ...] = ()
106
106
 
107
107
 
108
- # Work-category → short branch prefix. Mirrors the values accepted by
109
- # `--work-category` (bugfix / feature / refactor / ops / improvement) and
110
- # falls back to `task` when the category is unset or unrecognised.
111
- _WORK_CATEGORY_PREFIX = {
112
- "feature": "feat",
108
+ # Work-category → branch namespace (slash-prefixed). Mirrors the values
109
+ # accepted by `--work-category` (bugfix / feature / refactor / ops /
110
+ # improvement); `feature` and `improvement` share the `feature/` namespace,
111
+ # and any unset/unrecognised category falls back to `task/`.
112
+ _WORK_CATEGORY_NAMESPACE = {
113
+ "feature": "feature",
114
+ "improvement": "feature",
113
115
  "bugfix": "fix",
114
116
  "refactor": "refactor",
115
117
  "ops": "ops",
116
- "improvement": "improve",
117
- "docs": "doc",
118
- "doc": "doc",
119
118
  }
120
119
 
121
120
 
@@ -241,9 +240,9 @@ def _safe_segment(value: str) -> str:
241
240
  return _safe_fs_segment(value)
242
241
 
243
242
 
244
- def _work_category_prefix(work_category: str) -> str:
243
+ def _work_category_namespace(work_category: str) -> str:
245
244
  key = (work_category or "").strip().lower()
246
- return _WORK_CATEGORY_PREFIX.get(key, "task")
245
+ return _WORK_CATEGORY_NAMESPACE.get(key, "task")
247
246
 
248
247
 
249
248
  def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
@@ -663,11 +662,13 @@ def compute_branch_name(
663
662
  stage_number: Optional[int] = None,
664
663
  group_id: Optional[str] = None,
665
664
  ) -> str:
666
- """One branch per task-key, or `<prefix>-<task-id>-s<N>` for an
667
- implementation stage worktree."""
665
+ """One branch per task-key as `<namespace>/<task-id>`, or
666
+ `<namespace>/<task-id>-s<N>` for an implementation stage worktree. The
667
+ namespace is a controlled constant so its slash is preserved; only the
668
+ task-id segment is sanitised."""
668
669
  if stage_number is not None and group_id is not None:
669
670
  raise ValueError("stage_number and group_id are mutually exclusive")
670
- name = f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
671
+ name = f"{_work_category_namespace(work_category)}/{_safe_segment(task_id_segment)}"
671
672
  if stage_number is not None:
672
673
  name = f"{name}-s{stage_number}"
673
674
  if group_id is not None:
@@ -38,6 +38,7 @@ from .state import (
38
38
  resolve_task_identity,
39
39
  resolve_task_reference,
40
40
  slugify,
41
+ task_read_side_snapshot,
41
42
  )
42
43
 
43
44
  __all__ = [
@@ -64,6 +65,7 @@ __all__ = [
64
65
  "resolve_task_identity",
65
66
  "resolve_task_reference",
66
67
  "slugify",
68
+ "task_read_side_snapshot",
67
69
  "tasks_root",
68
70
  "upsert_project_json",
69
71
  ]
@@ -37,6 +37,10 @@ from .dirs import (
37
37
  class StateError(Exception):
38
38
  """state file 읽기/파싱 실패 — 호출자가 surface 해야 할 오류."""
39
39
 
40
+ def __init__(self, message: str, *, stage: Optional[str] = None):
41
+ super().__init__(message)
42
+ self.stage = stage
43
+
40
44
 
41
45
  def _load_json(path: Path) -> Optional[dict]:
42
46
  if not path.is_file():
@@ -268,10 +272,16 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
268
272
  """
269
273
  task_root = find_task_root(project_root, task_key)
270
274
  if task_root is None:
271
- raise StateError(f"task root not found for {task_key!r}")
275
+ raise StateError(
276
+ f"task root not found for {task_key!r}",
277
+ stage="task_root_missing",
278
+ )
272
279
  manifest = read_task_manifest(task_root)
273
280
  if manifest is None:
274
- raise StateError(f"task-manifest.json missing under {task_root}")
281
+ raise StateError(
282
+ f"task-manifest.json missing under {task_root}",
283
+ stage="manifest_missing",
284
+ )
275
285
  workflow = manifest.get("workflow") or {}
276
286
  if not isinstance(workflow, dict):
277
287
  workflow = {}
@@ -292,3 +302,35 @@ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
292
302
  "taskBriefPath": manifest.get("taskBriefPath") or "",
293
303
  "manifest": manifest,
294
304
  }
305
+
306
+
307
+ def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
308
+ """task-show / inspect 용 curated task 상태 view 를 반환한다.
309
+
310
+ resolve_task_identity 가 내부 raw manifest 를 들고 있지만, 이 accessor 는
311
+ caller 에게 manifest shape 를 노출하지 않는 read-side 계약이다.
312
+ """
313
+ identity = resolve_task_identity(project_root, task_key)
314
+ manifest = identity["manifest"]
315
+ workflow = manifest.get("workflow")
316
+ if not isinstance(workflow, dict):
317
+ workflow = {}
318
+ return {
319
+ "projectRoot": identity["projectRoot"],
320
+ "taskKey": manifest.get("taskKey"),
321
+ "taskType": manifest.get("taskType"),
322
+ "taskRoot": identity["taskRoot"],
323
+ "taskBriefPath": manifest.get("taskBriefPath"),
324
+ "workflow": {
325
+ "currentPhase": workflow.get("currentPhase"),
326
+ "currentPhaseState": workflow.get("currentPhaseState"),
327
+ "lastCompletedPhase": workflow.get("lastCompletedPhase"),
328
+ "nextRecommendedPhase": workflow.get("nextRecommendedPhase"),
329
+ "routingStatus": workflow.get("routingStatus"),
330
+ "phaseStates": workflow.get("phaseStates"),
331
+ },
332
+ "resultContract": manifest.get("resultContract"),
333
+ "artifacts": manifest.get("artifacts"),
334
+ "modelAssignments": manifest.get("modelAssignments"),
335
+ "latestRunPath": manifest.get("latestRunPath"),
336
+ }
@@ -145,13 +145,30 @@ Output: `{ok: true, text: "선택 확인:\n task-type : ...\n ..."}`. Prin
145
145
 
146
146
  ## Step 5: Render the task bundle
147
147
 
148
- When `next.kind == "done"`, fetch the final args:
148
+ When `next.kind == "done"`, fetch the public wizard outcome:
149
149
 
150
150
  ```bash
151
- okstra wizard render-args --state-file /var/folders/.../okstra-wizard.AbCd.json
151
+ okstra wizard outcome --state-file /var/folders/.../okstra-wizard.AbCd.json
152
152
  ```
153
153
 
154
- Output: `{ok: true, args: {"project-root": "...", "task-type": "...", ...}}`. Build the `okstra render-bundle` invocation from `args`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
154
+ Output: `{ok: true, outcome: {renderArgs: {...}, persistActions: [...], confirmationText: "..."}}`.
155
+
156
+ Run every `outcome.persistActions[]` entry BEFORE `render-bundle`. The only supported action is:
157
+
158
+ ```json
159
+ {"command":"config.set","key":"pr-template-path","scope":"project|global","value":"<path>"}
160
+ ```
161
+
162
+ Execute the matching command for `scope`:
163
+
164
+ ```bash
165
+ okstra config set pr-template-path "<value>" --scope project
166
+ okstra config set pr-template-path "<value>" --scope global
167
+ ```
168
+
169
+ If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
170
+
171
+ Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
155
172
 
156
173
  **Empty-value rule (same as Step 3)**: every flag whose value is the empty string MUST still be passed explicitly as `--<key> ""`. For example: `--workers ""`, `--directive ""`, `--related-tasks ""`. Omitting the flag is forbidden — `render-bundle` distinguishes "flag absent" from "flag present with empty value", and the wizard's intent is always the latter.
157
174
 
@@ -295,20 +312,7 @@ stale-SHA 재조정(git-reconcile 분기)을 띄우면, **그 stage 에서 연
295
312
 
296
313
  ## Persisting the PR template scope (release-handoff)
297
314
 
298
- When `wizard render-args` returns a non-empty `pr-template-path` AND the state has `pr_template_scope == "project"` or `"global"`, run the matching config command BEFORE `render-bundle`:
299
-
300
- ```bash
301
- # project scope
302
- okstra config set pr-template-path "<path>" --scope project
303
- # global scope (must be absolute or ~/-prefixed)
304
- okstra config set pr-template-path "<path>" --scope global
305
- ```
306
-
307
- The scope is held in the wizard state but is not yet exposed by any `okstra wizard` subcommand. Until the subcommand below ships, read the JSON state file directly with the `Read` tool (literal path captured in Step 2) and inspect the `pr_template_scope` field — it is a plain serialized `WizardState`. Do not shell out (`python3 -c`, `jq`, etc.); the literal-token Bash rule rejects them.
308
-
309
- ## Out-of-scope backlog
310
-
311
- - **`okstra wizard pr-template-scope --state-file PATH`**: add a thin subcommand to `scripts/okstra_ctl/wizard.py` that prints `{ok: true, scope: "once" | "project" | "global"}` so this skill can drop the `Read`-the-raw-state-file detour. The subcommand should reuse the existing `load_state_file` path; no schema changes required.
315
+ Do not read the wizard state file directly. `okstra wizard outcome` exposes any release-handoff PR template write as `outcome.persistActions[]`; execute those actions before `render-bundle`.
312
316
 
313
317
  ## Concurrency
314
318
 
@@ -16,6 +16,7 @@ Subcommands:
16
16
  step submit an answer (or fetch the current prompt) and emit next
17
17
  render-args emit the final --flag/value map for 'okstra render-bundle'
18
18
  confirmation emit the multi-line confirmation echo block
19
+ outcome emit render args, persistence actions, and confirmation text
19
20
 
20
21
  Usage:
21
22
  okstra wizard new-state-file
@@ -23,6 +24,7 @@ Usage:
23
24
  okstra wizard step --state-file <path> [--answer <value>]
24
25
  okstra wizard render-args --state-file <path>
25
26
  okstra wizard confirmation --state-file <path>
27
+ okstra wizard outcome --state-file <path>
26
28
 
27
29
  'init' auto-fills --workspace-root from 'okstra paths --field workspace',
28
30
  so callers do not pass it.
@@ -65,7 +67,7 @@ export async function run(args) {
65
67
  }
66
68
 
67
69
  const [sub, ...rest] = args;
68
- if (!["new-state-file", "init", "step", "render-args", "confirmation"].includes(sub)) {
70
+ if (!["new-state-file", "init", "step", "render-args", "confirmation", "outcome"].includes(sub)) {
69
71
  process.stderr.write(`error: unknown wizard subcommand '${sub}'\n\n${USAGE}`);
70
72
  return 2;
71
73
  }
@@ -40,8 +40,8 @@ const SCRIPT = `
40
40
  import json, sys
41
41
  from pathlib import Path
42
42
  from okstra_project import (
43
- resolve_project_root, find_task_root, read_task_manifest,
44
- ResolverError,
43
+ resolve_project_root, task_read_side_snapshot,
44
+ ResolverError, StateError,
45
45
  )
46
46
 
47
47
  explicit = sys.argv[1]
@@ -55,37 +55,17 @@ except ResolverError as e:
55
55
  sys.exit(2)
56
56
 
57
57
  pr_path = Path(pr)
58
- task_root = find_task_root(pr_path, task_key)
59
- if task_root is None:
60
- print(json.dumps({"ok": False, "stage": "task_root_missing", "reason": f"no task root for {task_key}"}))
61
- sys.exit(1)
62
-
63
- manifest = read_task_manifest(task_root)
64
- if manifest is None:
65
- print(json.dumps({"ok": False, "stage": "manifest_missing", "reason": f"task-manifest.json missing in {task_root}"}))
58
+ try:
59
+ snapshot = task_read_side_snapshot(pr_path, task_key)
60
+ except StateError as e:
61
+ print(json.dumps({
62
+ "ok": False,
63
+ "stage": getattr(e, "stage", None) or "task_snapshot",
64
+ "reason": str(e),
65
+ }))
66
66
  sys.exit(1)
67
67
 
68
- wf = manifest.get("workflow", {}) or {}
69
- out = {
70
- "ok": True,
71
- "projectRoot": str(pr_path),
72
- "taskKey": manifest.get("taskKey"),
73
- "taskType": manifest.get("taskType"),
74
- "taskRoot": str(task_root),
75
- "taskBriefPath": manifest.get("taskBriefPath"),
76
- "workflow": {
77
- "currentPhase": wf.get("currentPhase"),
78
- "currentPhaseState": wf.get("currentPhaseState"),
79
- "lastCompletedPhase": wf.get("lastCompletedPhase"),
80
- "nextRecommendedPhase": wf.get("nextRecommendedPhase"),
81
- "routingStatus": wf.get("routingStatus"),
82
- "phaseStates": wf.get("phaseStates"),
83
- },
84
- "resultContract": manifest.get("resultContract"),
85
- "artifacts": manifest.get("artifacts"),
86
- "modelAssignments": manifest.get("modelAssignments"),
87
- "latestRunPath": manifest.get("latestRunPath"),
88
- }
68
+ out = {"ok": True, **snapshot}
89
69
  print(json.dumps(out, ensure_ascii=False, default=str, indent=2))
90
70
  `;
91
71