okstra 0.14.2 → 0.16.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/bin/okstra CHANGED
@@ -9,6 +9,11 @@ const COMMANDS = new Map([
9
9
  ["doctor", () => import("../src/doctor.mjs").then((m) => m.run)],
10
10
  ["setup", () => import("../src/setup.mjs").then((m) => m.run)],
11
11
  ["check-project", () => import("../src/check-project.mjs").then((m) => m.run)],
12
+ ["task-list", () => import("../src/task-list.mjs").then((m) => m.run)],
13
+ ["task-show", () => import("../src/task-show.mjs").then((m) => m.run)],
14
+ ["worktree-lookup", () => import("../src/worktree-lookup.mjs").then((m) => m.run)],
15
+ ["plan-validate", () => import("../src/plan-validate.mjs").then((m) => m.run)],
16
+ ["render-bundle", () => import("../src/render-bundle.mjs").then((m) => m.run)],
12
17
  ]);
13
18
 
14
19
  const USAGE = `okstra — multi-agent cross-verification orchestrator for Claude Code
@@ -37,6 +42,14 @@ Admin commands:
37
42
  check-project Verify the current project has been registered with setup
38
43
  paths Print runtime paths (workspace/agents/pythonpath/bin/home/version)
39
44
 
45
+ Introspection commands (JSON output, used by skills to avoid python heredocs):
46
+ task-list List tasks registered in the current project
47
+ task-show Summarize a task's manifest + workflow phase state
48
+ worktree-lookup Look up registered worktree for a task-key
49
+ plan-validate Check an approved-plan file for the approval marker
50
+ render-bundle Preview prepare_task_bundle() output (forwards to
51
+ python3 -m okstra_ctl.run --render-only)
52
+
40
53
  Global options:
41
54
  --version Print okstra version and exit
42
55
  --help Print this help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.14.2",
3
+ "version": "0.16.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.14.2",
3
- "builtAt": "2026-05-13T03:43:46.461Z",
2
+ "package": "0.16.0",
3
+ "builtAt": "2026-05-13T06:50:20.316Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -75,35 +75,23 @@ After Step 0 the following are guaranteed:
75
75
 
76
76
  ## Step 1: Resolve PROJECT_ROOT and projectId
77
77
 
78
+ Prefer `$OKSTRA_PROJECT_INFO` from Step 0 — it already carries `{ok, projectRoot, projectJsonPath, projectId}`. Only re-resolve when that JSON's `ok` is false (cwd outside an okstra project):
79
+
78
80
  ```bash
79
- python3 - <<'PY'
80
- import sys, json
81
- from okstra_project import resolve_project_root, ResolverError
82
- try:
83
- pr = resolve_project_root(explicit_root="", cwd=".")
84
- except ResolverError as e:
85
- print(f"FAIL\t{e}"); raise SystemExit(0)
86
- print(f"OK\t{pr}")
87
- PY
81
+ okstra check-project --cwd "$(pwd)"
88
82
  ```
89
83
 
90
- - If `OK`: read `<PROJECT_ROOT>/.project-docs/okstra/project.json` and extract `projectId`.
91
- - If `FAIL`: ask the user (`AskUserQuestion`, free text) for an absolute project-root path; rerun the resolver with `explicit_root=<their input>`.
84
+ - If `ok: true`: read `projectRoot` and `projectId` from the JSON.
85
+ - If `ok: false`: ask the user (`AskUserQuestion`, free text) for an absolute project-root path; rerun with `okstra check-project --cwd <their input>`.
92
86
 
93
87
  ## Step 2: Choose task — existing vs new
94
88
 
95
89
  ```bash
96
- python3 -c "
97
- import json, sys
98
- from pathlib import Path
99
- from okstra_project import list_project_tasks, read_latest_task
100
- pr = Path(sys.argv[1])
101
- tasks = list_project_tasks(pr)
102
- latest = read_latest_task(pr)
103
- print(json.dumps({'tasks': tasks, 'latest': latest}))
104
- " "$PROJECT_ROOT"
90
+ okstra task-list --project "$PROJECT_ROOT"
105
91
  ```
106
92
 
93
+ Output is JSON `{ok, projectRoot, tasks: [...], latest: {...}|null}`.
94
+
107
95
  Use `AskUserQuestion`:
108
96
 
109
97
  - **Label**: "Which task?"
@@ -151,21 +139,12 @@ silently inherits an unrelated branch you happen to be checked out on.
151
139
  First, decide whether to ask:
152
140
 
153
141
  ```bash
154
- python3 - <<PY
155
- import sys
156
- from pathlib import Path
157
- sys.path.insert(0, "$OKSTRA_PYTHONPATH".split(":")[0])
158
- from okstra_ctl import worktree_registry
159
- from okstra_ctl.ids import _safe_fs_segment
160
- entry = worktree_registry.lookup(
161
- _safe_fs_segment("<project-id>"),
162
- _safe_fs_segment("<task-group>"),
163
- _safe_fs_segment("<task-id>"),
164
- )
165
- print("REUSE" if (entry and entry.status == "active") else "ASK")
166
- PY
142
+ okstra worktree-lookup "<project-id>" "<task-group>" "<task-id>"
167
143
  ```
168
144
 
145
+ Output JSON: `{ok: true, entry: null}` means no active worktree → **ASK**. A
146
+ non-null `entry` with `status: "active"` → **REUSE**.
147
+
169
148
  - `REUSE` → the registered worktree is reused; set `base_ref=""` and skip the
170
149
  question (the registered base is authoritative).
171
150
  - `ASK` → this is the first phase for this task-key. Continue.
@@ -235,7 +214,7 @@ For prompts whose target worker is NOT in the resolved workers list (after overr
235
214
 
236
215
  ## Step 6.5: Confirm selections before rendering
237
216
 
238
- Before calling `prepare_task_bundle`, echo the resolved selections back to the user in a compact block so they can verify what will be passed. Show the **effective** values, not the raw input — i.e. when the user left a field blank, display `default` (and where known, the actual default such as `opus` / `sonnet`). Example for an `implementation` run:
217
+ Before invoking `okstra render-bundle`, echo the resolved selections back to the user in a compact block so they can verify what will be passed. Show the **effective** values, not the raw input — i.e. when the user left a field blank, display `default` (and where known, the actual default such as `opus` / `sonnet`). Example for an `implementation` run:
239
218
 
240
219
  ```
241
220
  선택 확인:
@@ -255,50 +234,40 @@ Before calling `prepare_task_bundle`, echo the resolved selections back to the u
255
234
 
256
235
  Then `AskUserQuestion`: `"이대로 진행할까요?"` with options `Proceed` / `Edit`. On `Edit`, return to the relevant Step 6 sub-prompt.
257
236
 
258
- ## Step 7: Call `prepare_task_bundle` directly
237
+ ## Step 7: Call `okstra render-bundle`
259
238
 
260
- This is the single line that materializes the entire task bundle. **Pass `render_only=True`** — the current claude session itself takes over as lead; we do not exec a new claude.
239
+ This is the single command that materializes the entire task bundle. The
240
+ subcommand auto-supplies `--workspace-root` (from `okstra paths --field
241
+ workspace`) and forces `--render-only`, so the current claude session itself
242
+ takes over as lead — no new claude is spawned.
261
243
 
262
244
  ```bash
263
- python3 - <<PY
264
- import os
265
- from pathlib import Path
266
- from okstra_ctl.run import PrepareInputs, prepare_task_bundle
267
- from okstra_ctl.path_resolve import resolve_user_file
268
-
269
- project_root = Path("<project-root>")
270
- brief_abs = resolve_user_file("<brief-path-from-user>", project_root)
271
- clarification_abs = resolve_user_file("<clarification-or-empty>", project_root) if "<clarification-or-empty>" else None
272
-
273
- out = prepare_task_bundle(PrepareInputs(
274
- workspace_root=Path(os.environ["OKSTRA_WORKSPACE"]),
275
- project_root=project_root,
276
- project_id="<project-id>",
277
- task_group="<task-group>",
278
- task_id="<task-id>",
279
- task_type="<task-type>",
280
- brief_path=brief_abs,
281
- directive="<directive or empty>",
282
- workers_override="<comma-separated worker list, or empty for profile default; MUST be empty for implementation>",
283
- lead_model="...", claude_model="...", codex_model="...",
284
- gemini_model="...", report_writer_model="...",
285
- related_tasks_raw="...",
286
- executor="<claude|codex|gemini or empty>", # implementation only; empty → default (claude / OKSTRA_DEFAULT_EXECUTOR)
287
- base_ref="<chosen-ref-from-step-4.6 or empty when reusing existing worktree>",
288
- approved_plan_path="<approved-plan-or-empty>",
289
- clarification_response_path=str(clarification_abs) if clarification_abs else "",
290
- render_only=True,
291
- ))
292
-
293
- # Print key paths so the next step can read them.
294
- ctx = out.ctx
295
- print("TASK_ROOT", ctx["TASK_ROOT"])
296
- print("INSTRUCTION_SET_DIR", ctx["INSTRUCTION_SET_DIR"])
297
- print("LEAD_PROMPT", str(Path(ctx["INSTRUCTION_SET_DIR"]) / "claude-execution-prompt.md"))
298
- PY
245
+ okstra render-bundle \
246
+ --project-root "<project-root>" \
247
+ --project-id "<project-id>" \
248
+ --task-group "<task-group>" \
249
+ --task-id "<task-id>" \
250
+ --task-type "<task-type>" \
251
+ --task-brief "<brief-path-from-user>" \
252
+ --executor "<claude|codex|gemini or empty for default>" \
253
+ --approved-plan "<approved-plan-or-empty>" \
254
+ --base-ref "<chosen-ref-from-step-4.6 or empty when reusing existing worktree>" \
255
+ --workers "<comma-separated worker list, or empty for profile default; MUST be empty for implementation>" \
256
+ --directive "<directive or empty>" \
257
+ --lead-model "..." --claude-model "..." --codex-model "..." \
258
+ --gemini-model "..." --report-writer-model "..." \
259
+ --related-tasks "..." \
260
+ --clarification-response "<clarification-or-empty>"
299
261
  ```
300
262
 
301
- The python function is mutex-protected (`~/.okstra/.locks/<task-key>.lock`), writes `run-context-*.json` + `run-inputs-*.json` + all manifests + discovery files, and registers the run in `~/.okstra/recent.jsonl` with status `prepared`.
263
+ Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full
264
+ rendered lead-prompt text (because `--render-only` is on). Parse the labelled
265
+ lines to get `TASK_ROOT`, `INSTRUCTION_SET_DIR`, and from there the
266
+ `claude-execution-prompt.md` path used by Step 8.
267
+
268
+ The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.lock`),
269
+ writes `run-context-*.json` + `run-inputs-*.json` + all manifests + discovery
270
+ files, and registers the run in `~/.okstra/recent.jsonl` with status `prepared`.
302
271
 
303
272
  ## Step 8: Take over as Claude lead
304
273
 
@@ -327,7 +296,7 @@ Inform the user with one short line:
327
296
  |---|---|---|
328
297
  | `okstra runtime missing: ...` | First run on this machine, or stale install | `npx okstra@latest install` once, retry. |
329
298
  | `OKSTRA_PYTHONPATH unbound` / `ModuleNotFoundError: okstra_project` | Step 0 was skipped or env vars dropped | Re-run Step 0; never invoke python without exporting `PYTHONPATH=$OKSTRA_PYTHONPATH`. |
330
- | `task root not found for <key>` | catalog entry stale or task-key typo | Re-run Step 2; show available keys from `list_project_tasks` |
299
+ | `task root not found for <key>` | catalog entry stale or task-key typo | Re-run Step 2 (`okstra task-list`) and show available keys |
331
300
  | `PROJECT_ROOT 를 해석할 수 없습니다` | cwd outside okstra project, no git toplevel | Ask user for absolute path |
332
301
  | `approved plan has no recognised user-approval marker` | `implementation` without proper approval | Ask user to add `APPROVED` to the plan, or pick a different task-type |
333
302
  | `task brief not found` | brief-path doesn't resolve relative to cwd or project-root | Re-ask Step 5 |
@@ -72,19 +72,12 @@ them from the env vars.
72
72
  ## Step 3: Resolve PROJECT_ROOT
73
73
 
74
74
  ```bash
75
- python3 - <<'PY'
76
- from okstra_project import resolve_project_root, ResolverError
77
- try:
78
- pr = resolve_project_root(explicit_root="", cwd=".")
79
- print(f"OK\t{pr}")
80
- except ResolverError as e:
81
- print(f"FAIL\t{e}")
82
- PY
75
+ okstra check-project --cwd "$(pwd)"
83
76
  ```
84
77
 
85
- - `OK` line use that as `PROJECT_ROOT`.
86
- - `FAIL` line → ask the user (`AskUserQuestion`, free text) for an absolute
87
- project root. Re-run with `explicit_root=<their answer>`.
78
+ The JSON includes `projectRoot` on success. On failure (`ok: false`,
79
+ `stage: "resolve"`) ask the user (`AskUserQuestion`, free text) for an
80
+ absolute project root and rerun with `--cwd <their answer>`.
88
81
 
89
82
  ## Step 4: Inspect or create `project.json`
90
83
 
@@ -108,12 +101,7 @@ If the file does NOT exist, ask via `AskUserQuestion`:
108
101
  Then create the file:
109
102
 
110
103
  ```bash
111
- python3 - <<PY
112
- from pathlib import Path
113
- from okstra_project import upsert_project_json
114
- result = upsert_project_json(Path("$PROJECT_ROOT"), "$PROJECT_ID")
115
- print(result)
116
- PY
104
+ okstra setup --yes --project-root "$PROJECT_ROOT" --project-id "$PROJECT_ID"
117
105
  ```
118
106
 
119
107
  ## Step 4.5 (optional): customise worktree sync dirs
@@ -46,6 +46,8 @@
46
46
  "Bash(pytest:*)",
47
47
  "Bash(bash:*)",
48
48
  "Bash(sh:*)",
49
+ "Bash(eval:*)",
50
+ "Bash(cd:*)",
49
51
  "Bash(printf:*)",
50
52
  "Bash(echo:*)",
51
53
  "Bash(export:*)",
@@ -57,6 +59,7 @@
57
59
  "Bash(false)",
58
60
  "Bash(codex:*)",
59
61
  "Bash(codex exec:*)",
62
+ "Bash(okstra:*)",
60
63
  "Bash($HOME/.okstra/bin/okstra-codex-exec.sh:*)",
61
64
  "Bash(gemini:*)",
62
65
  "Bash($HOME/.okstra/bin/okstra-gemini-exec.sh:*)",
@@ -0,0 +1,42 @@
1
+ import { spawn } from "node:child_process";
2
+ import { resolvePaths } from "./paths.mjs";
3
+
4
+ export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
5
+ const paths = await resolvePaths();
6
+ return new Promise((resolve) => {
7
+ const child = spawn("python3", ["-c", script, ...args], {
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
10
+ });
11
+ let stdout = "";
12
+ let stderr = "";
13
+ child.stdout.on("data", (b) => (stdout += b.toString()));
14
+ child.stderr.on("data", (b) => (stderr += b.toString()));
15
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
16
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
17
+ });
18
+ }
19
+
20
+ export async function runPythonModule({ module, args = [], extraEnv = {}, stdio = "inherit-stdout" }) {
21
+ const paths = await resolvePaths();
22
+ return new Promise((resolve) => {
23
+ const child = spawn("python3", ["-m", module, ...args], {
24
+ stdio: stdio === "capture" ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
25
+ env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
26
+ });
27
+ let stdout = "";
28
+ let stderr = "";
29
+ if (stdio === "capture") {
30
+ child.stdout.on("data", (b) => (stdout += b.toString()));
31
+ child.stderr.on("data", (b) => (stderr += b.toString()));
32
+ }
33
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
34
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
35
+ });
36
+ }
37
+
38
+ export function emitJsonError({ stage, reason, extra = {} }) {
39
+ process.stdout.write(
40
+ JSON.stringify({ ok: false, stage, reason, ...extra }, null, 2) + "\n",
41
+ );
42
+ }
@@ -0,0 +1,68 @@
1
+ import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra plan-validate — verify an approved-plan file has a recognised approval marker
4
+
5
+ Usage:
6
+ okstra plan-validate <plan-path>
7
+
8
+ Output: JSON { ok: true, planPath } on success.
9
+ On failure: { ok: false, reason } with non-zero exit code.
10
+
11
+ Replaces the \`from okstra_ctl.run import _validate_approved_plan\` heredoc
12
+ pattern. Use this in flows that need to confirm a plan was approved
13
+ without invoking the full prepare_task_bundle pipeline.
14
+ `;
15
+
16
+ function parseArgs(args) {
17
+ const positional = args.filter((a) => !a.startsWith("--"));
18
+ const unknown = args.filter((a) => a.startsWith("--"));
19
+ if (unknown.length) throw new Error(`unknown argument '${unknown[0]}'`);
20
+ if (positional.length !== 1) {
21
+ throw new Error("expected exactly one positional argument: <plan-path>");
22
+ }
23
+ return { planPath: positional[0] };
24
+ }
25
+
26
+ const SCRIPT = `
27
+ import json, sys
28
+ from okstra_ctl.run import _validate_approved_plan, PrepareError
29
+
30
+ path = sys.argv[1]
31
+ try:
32
+ _validate_approved_plan(path)
33
+ except PrepareError as e:
34
+ print(json.dumps({"ok": False, "stage": "validation", "reason": str(e), "planPath": path}))
35
+ sys.exit(1)
36
+
37
+ print(json.dumps({"ok": True, "planPath": path}))
38
+ `;
39
+
40
+ export async function run(args) {
41
+ if (args.includes("--help") || args.includes("-h")) {
42
+ process.stdout.write(USAGE);
43
+ return 0;
44
+ }
45
+ let opts;
46
+ try {
47
+ opts = parseArgs(args);
48
+ } catch (err) {
49
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
50
+ return 2;
51
+ }
52
+
53
+ const result = await runPythonSnippet({
54
+ script: SCRIPT,
55
+ args: [opts.planPath],
56
+ });
57
+
58
+ if (result.code !== 0 && !result.stdout.trim()) {
59
+ emitJsonError({
60
+ stage: "python",
61
+ reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
62
+ });
63
+ return 1;
64
+ }
65
+
66
+ process.stdout.write(result.stdout);
67
+ return result.code === 0 ? 0 : result.code;
68
+ }
@@ -0,0 +1,60 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+ import { resolvePaths } from "./paths.mjs";
3
+
4
+ const USAGE = `okstra render-bundle — preview the task bundle without launching claude
5
+
6
+ This is a thin shim over \`python3 -m okstra_ctl.run\` that forces
7
+ \`--render-only\`. Use it to preview where prepare_task_bundle() would
8
+ materialize files (INSTRUCTION_SET_DIR, RUN_DIR, lead prompt path,
9
+ final report template path, etc.) for a given task/phase, without
10
+ actually creating the run-state or spawning the launcher.
11
+
12
+ Usage:
13
+ okstra render-bundle --project-root <dir> --project-id <id> \\
14
+ --task-group <tg> --task-id <tid> --task-type <type> \\
15
+ --task-brief <path-or-rel> [--executor <name>] \\
16
+ [--approved-plan <path>] [--workers <list>] [--directive <text>] \\
17
+ [--lead-model <m>] [--claude-model <m>] [--codex-model <m>] \\
18
+ [--gemini-model <m>] [--report-writer-model <m>] \\
19
+ [--related-tasks <list>] [--base-ref <ref>] \\
20
+ [--clarification-response <path>] [--work-category <cat>]
21
+
22
+ All flags pass through unchanged to \`python3 -m okstra_ctl.run\`. The
23
+ shim auto-supplies \`--workspace-root\` (from \`okstra paths --field workspace\`)
24
+ and \`--render-only\`, so callers do not need to set those.
25
+
26
+ Replaces the long \`from okstra_ctl.run import PrepareInputs,
27
+ prepare_task_bundle\` heredoc pattern.
28
+ `;
29
+
30
+ export async function run(args) {
31
+ if (args.includes("--help") || args.includes("-h")) {
32
+ process.stdout.write(USAGE);
33
+ return 0;
34
+ }
35
+ const paths = await resolvePaths();
36
+
37
+ // Callers should not pass these; we own them.
38
+ const forbidden = ["--workspace-root", "--render-only"];
39
+ for (const f of forbidden) {
40
+ if (args.includes(f)) {
41
+ process.stderr.write(
42
+ `error: ${f} is set by 'okstra render-bundle' itself — remove it from your args\n`,
43
+ );
44
+ return 2;
45
+ }
46
+ }
47
+
48
+ const finalArgs = [
49
+ "--workspace-root", paths.workspace,
50
+ "--render-only",
51
+ ...args,
52
+ ];
53
+
54
+ const result = await runPythonModule({
55
+ module: "okstra_ctl.run",
56
+ args: finalArgs,
57
+ stdio: "inherit-stdout",
58
+ });
59
+ return result.code ?? 0;
60
+ }
@@ -0,0 +1,86 @@
1
+ import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra task-list — list okstra tasks registered in this project
4
+
5
+ Usage:
6
+ okstra task-list Resolve PROJECT_ROOT from cwd, list tasks
7
+ okstra task-list --cwd <dir> Start resolution from <dir> instead of cwd
8
+ okstra task-list --project <dir> Use <dir> directly as PROJECT_ROOT
9
+
10
+ Output: JSON { ok, projectRoot, tasks: [...], latest: {...}|null }.
11
+
12
+ Replaces the common pattern of loading okstra_project and calling
13
+ list_project_tasks + read_latest_task from a python heredoc.
14
+ `;
15
+
16
+ function parseArgs(args) {
17
+ const opts = { cwd: process.cwd(), projectRoot: "" };
18
+ for (let i = 0; i < args.length; i++) {
19
+ const a = args[i];
20
+ if (a === "--cwd") {
21
+ opts.cwd = args[++i];
22
+ if (!opts.cwd) throw new Error("--cwd requires a path");
23
+ } else if (a === "--project") {
24
+ opts.projectRoot = args[++i];
25
+ if (!opts.projectRoot) throw new Error("--project requires a path");
26
+ } else {
27
+ throw new Error(`unknown argument '${a}'`);
28
+ }
29
+ }
30
+ return opts;
31
+ }
32
+
33
+ const SCRIPT = `
34
+ import json, sys
35
+ from pathlib import Path
36
+ from okstra_project import (
37
+ list_project_tasks, read_latest_task,
38
+ resolve_project_root, ResolverError,
39
+ )
40
+
41
+ explicit = sys.argv[1]
42
+ cwd = sys.argv[2]
43
+ try:
44
+ pr = resolve_project_root(explicit_root=explicit, cwd=cwd)
45
+ except ResolverError as e:
46
+ print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
47
+ sys.exit(2)
48
+
49
+ pr_path = Path(pr)
50
+ print(json.dumps({
51
+ "ok": True,
52
+ "projectRoot": str(pr_path),
53
+ "tasks": list_project_tasks(pr_path),
54
+ "latest": read_latest_task(pr_path),
55
+ }, default=str))
56
+ `;
57
+
58
+ export async function run(args) {
59
+ if (args.includes("--help") || args.includes("-h")) {
60
+ process.stdout.write(USAGE);
61
+ return 0;
62
+ }
63
+ let opts;
64
+ try {
65
+ opts = parseArgs(args);
66
+ } catch (err) {
67
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
68
+ return 2;
69
+ }
70
+
71
+ const result = await runPythonSnippet({
72
+ script: SCRIPT,
73
+ args: [opts.projectRoot, opts.cwd],
74
+ });
75
+
76
+ if (result.code !== 0 && !result.stdout.trim()) {
77
+ emitJsonError({
78
+ stage: "python",
79
+ reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
80
+ });
81
+ return 1;
82
+ }
83
+
84
+ process.stdout.write(result.stdout);
85
+ return result.code === 0 ? 0 : result.code;
86
+ }
@@ -0,0 +1,120 @@
1
+ import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra task-show — summarize a task's manifest and workflow state
4
+
5
+ Usage:
6
+ okstra task-show <task-key> task-key is project-id:task-group:task-id
7
+ okstra task-show <task-key> --cwd <dir> Resolve PROJECT_ROOT from <dir>
8
+ okstra task-show <task-key> --project <dir> Use <dir> directly as PROJECT_ROOT
9
+
10
+ Output: JSON with taskKey, taskType, taskRoot, brief path, workflow phases,
11
+ artifacts, modelAssignments, latestRunPath. Replaces the
12
+ \`cat task-manifest.json | python3 -c ...\` summarization pattern.
13
+ `;
14
+
15
+ function parseArgs(args) {
16
+ const opts = { cwd: process.cwd(), projectRoot: "", taskKey: "" };
17
+ const positional = [];
18
+ for (let i = 0; i < args.length; i++) {
19
+ const a = args[i];
20
+ if (a === "--cwd") {
21
+ opts.cwd = args[++i];
22
+ if (!opts.cwd) throw new Error("--cwd requires a path");
23
+ } else if (a === "--project") {
24
+ opts.projectRoot = args[++i];
25
+ if (!opts.projectRoot) throw new Error("--project requires a path");
26
+ } else if (a.startsWith("--")) {
27
+ throw new Error(`unknown argument '${a}'`);
28
+ } else {
29
+ positional.push(a);
30
+ }
31
+ }
32
+ if (positional.length !== 1) {
33
+ throw new Error("expected exactly one positional argument: <task-key>");
34
+ }
35
+ opts.taskKey = positional[0];
36
+ return opts;
37
+ }
38
+
39
+ const SCRIPT = `
40
+ import json, sys
41
+ from pathlib import Path
42
+ from okstra_project import (
43
+ resolve_project_root, find_task_root, read_task_manifest,
44
+ ResolverError,
45
+ )
46
+
47
+ explicit = sys.argv[1]
48
+ cwd = sys.argv[2]
49
+ task_key = sys.argv[3]
50
+
51
+ try:
52
+ pr = resolve_project_root(explicit_root=explicit, cwd=cwd)
53
+ except ResolverError as e:
54
+ print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
55
+ sys.exit(2)
56
+
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}"}))
66
+ sys.exit(1)
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
+ }
89
+ print(json.dumps(out, ensure_ascii=False, default=str, indent=2))
90
+ `;
91
+
92
+ export async function run(args) {
93
+ if (args.includes("--help") || args.includes("-h")) {
94
+ process.stdout.write(USAGE);
95
+ return 0;
96
+ }
97
+ let opts;
98
+ try {
99
+ opts = parseArgs(args);
100
+ } catch (err) {
101
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
102
+ return 2;
103
+ }
104
+
105
+ const result = await runPythonSnippet({
106
+ script: SCRIPT,
107
+ args: [opts.projectRoot, opts.cwd, opts.taskKey],
108
+ });
109
+
110
+ if (result.code !== 0 && !result.stdout.trim()) {
111
+ emitJsonError({
112
+ stage: "python",
113
+ reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
114
+ });
115
+ return 1;
116
+ }
117
+
118
+ process.stdout.write(result.stdout);
119
+ return result.code === 0 ? 0 : result.code;
120
+ }
@@ -0,0 +1,91 @@
1
+ import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra worktree-lookup — look up registered worktree for a task-key
4
+
5
+ Usage:
6
+ okstra worktree-lookup <project-id> <task-group> <task-id>
7
+
8
+ The three identifiers are slugified by the runtime before lookup, so
9
+ case/whitespace variants are tolerated.
10
+
11
+ Output: JSON with the registry entry (worktreePath, branch, baseRef,
12
+ status, lastPhase, createdAt). Emits {"ok": true, "entry": null} when
13
+ the task-key has no active worktree.
14
+
15
+ Replaces the okstra_ctl.worktree_registry.lookup() heredoc pattern.
16
+ `;
17
+
18
+ function parseArgs(args) {
19
+ const positional = args.filter((a) => !a.startsWith("--"));
20
+ const unknown = args.filter((a) => a.startsWith("--"));
21
+ if (unknown.length) throw new Error(`unknown argument '${unknown[0]}'`);
22
+ if (positional.length !== 3) {
23
+ throw new Error("expected exactly three positional arguments: <project-id> <task-group> <task-id>");
24
+ }
25
+ return { projectId: positional[0], taskGroup: positional[1], taskId: positional[2] };
26
+ }
27
+
28
+ const SCRIPT = `
29
+ import json, sys
30
+ from dataclasses import asdict
31
+ from okstra_ctl import worktree_registry
32
+ from okstra_ctl.ids import _safe_fs_segment
33
+
34
+ pid, tg, tid = sys.argv[1], sys.argv[2], sys.argv[3]
35
+ entry = worktree_registry.lookup(
36
+ _safe_fs_segment(pid),
37
+ _safe_fs_segment(tg),
38
+ _safe_fs_segment(tid),
39
+ )
40
+ if entry is None:
41
+ print(json.dumps({"ok": True, "entry": None}))
42
+ else:
43
+ d = asdict(entry)
44
+ # rename for camelCase API symmetry
45
+ out = {
46
+ "ok": True,
47
+ "entry": {
48
+ "taskKey": d.get("task_key"),
49
+ "projectId": d.get("project_id"),
50
+ "taskGroup": d.get("task_group"),
51
+ "taskId": d.get("task_id"),
52
+ "worktreePath": d.get("worktree_path"),
53
+ "branch": d.get("branch"),
54
+ "baseRef": d.get("base_ref"),
55
+ "createdAt": d.get("created_at"),
56
+ "lastPhase": d.get("last_phase"),
57
+ "status": d.get("status"),
58
+ },
59
+ }
60
+ print(json.dumps(out, ensure_ascii=False, default=str, indent=2))
61
+ `;
62
+
63
+ export async function run(args) {
64
+ if (args.includes("--help") || args.includes("-h")) {
65
+ process.stdout.write(USAGE);
66
+ return 0;
67
+ }
68
+ let opts;
69
+ try {
70
+ opts = parseArgs(args);
71
+ } catch (err) {
72
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
73
+ return 2;
74
+ }
75
+
76
+ const result = await runPythonSnippet({
77
+ script: SCRIPT,
78
+ args: [opts.projectId, opts.taskGroup, opts.taskId],
79
+ });
80
+
81
+ if (result.code !== 0 && !result.stdout.trim()) {
82
+ emitJsonError({
83
+ stage: "python",
84
+ reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
85
+ });
86
+ return 1;
87
+ }
88
+
89
+ process.stdout.write(result.stdout);
90
+ return result.code === 0 ? 0 : result.code;
91
+ }