okstra 0.43.0 → 0.43.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.43.0",
3
+ "version": "0.43.1",
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.43.0",
3
- "builtAt": "2026-06-04T04:59:06.499Z",
2
+ "package": "0.43.1",
3
+ "builtAt": "2026-06-04T05:01:10.794Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -216,6 +216,8 @@ Use agent and subagent names that map cleanly to the selected worker roles. Do n
216
216
 
217
217
  Spawn **analysis workers only** in the same turn (Phase 4 in Teams mode; Phase 5 with `run_in_background: true` and no `team_name` when Teams unavailable). Preserve exact roster, role labels, assigned models from the task bundle.
218
218
 
219
+ **Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([okstra-report-writer](./skills/okstra-report-writer/SKILL.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`); implementation executor/verifier variants keep `<workerId>-worker` / `<workerId>-executor`.
220
+
219
221
  The no-`team_name` fallback (Phase 5) is only legal when team-state's `teamCreate.status` is `"error"` for this run. If `teamCreate` is missing or `attempted: false`, the correct action when an Agent dispatch is rejected for a missing team is to GO BACK to Phase 3 and call `TeamCreate` — never to strip `team_name` and continue.
220
222
 
221
223
  ### Errors log path wiring (BLOCKING)
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """OKSTRA follow-up task spawner.
3
+
4
+ Reads the ``followUpTasks[]`` array from a final-report ``data.json``
5
+ (the JSON SSOT for the final-report markdown) and creates stub task
6
+ directories for rows whose ``autoSpawn`` is ``yes`` AND whose ``origin``
7
+ is not the same-task-key ``phase-continuation`` marker.
8
+
9
+ Idempotent: rows whose target directory already exists are reported as
10
+ ``existing`` and skipped. Existing directories are NEVER mutated.
11
+
12
+ Output: writes new directories under
13
+ ``<project_root>/.okstra/tasks/<task-group>/<new-task-id>/`` with:
14
+ - ``task-manifest.json`` — minimal manifest (schemaVersion 1.0,
15
+ currentStatus ``todo``, workflow.currentPhase = suggestedTaskType,
16
+ workflow.currentPhaseState ``not-started``, parentTaskKey /
17
+ spawnedFromReport recorded under ``relatedTasks``).
18
+ - ``instruction-set/task-brief.md`` — stub brief naming the parent and
19
+ copying the Reason / Scope cells from the data.json row.
20
+ - ``task-index.md`` — short human-readable summary.
21
+
22
+ The script DOES NOT call the okstra runtime; it produces just enough
23
+ on-disk state for the next user-driven entry — ``/okstra-run
24
+ task-key=<new-key> task-type=<suggested>`` inside a Claude Code session,
25
+ or ``scripts/okstra.sh --task-key <new-key> --task-type <suggested>``
26
+ in a separate terminal — to pick the follow-up up and re-render a fully
27
+ canonical manifest on first execution.
28
+
29
+ Usage:
30
+ python3 scripts/okstra-spawn-followups.py \\
31
+ <final-report-data.json> \\
32
+ --project-root <abs-path> \\
33
+ --task-group <group-slug> \\
34
+ --parent-task-key <parent-task-key> \\
35
+ [--dry-run]
36
+
37
+ Exit codes:
38
+ 0 — at least one follow-up evaluated (including ``skipped`` /
39
+ ``existing`` only)
40
+ 1 — invocation / parsing failure, or any row failed validation
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import datetime as dt
46
+ import json
47
+ import re
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
52
+
53
+ from okstra_project.dirs import tasks_root # noqa: E402
54
+
55
+
56
+ SLUG_RE = re.compile(r"[^a-zA-Z0-9-]+")
57
+
58
+ ALLOWED_TASK_TYPES = {
59
+ "requirements-discovery",
60
+ "error-analysis",
61
+ "implementation-planning",
62
+ "implementation",
63
+ "final-verification",
64
+ "release-handoff",
65
+ }
66
+ ALLOWED_ORIGINS = {
67
+ "phase-continuation",
68
+ "out-of-plan",
69
+ "verifier-concern",
70
+ "scope-boundary",
71
+ "open-question",
72
+ "manual",
73
+ }
74
+ # Origins that point at the SAME task-key (next phase) and therefore
75
+ # must never spawn a new task directory — the user advances via
76
+ # /okstra-run.
77
+ NON_SPAWNING_ORIGINS = {"phase-continuation"}
78
+
79
+
80
+ def _slugify(value: str) -> str:
81
+ value = value.strip()
82
+ value = SLUG_RE.sub("-", value)
83
+ value = re.sub(r"-+", "-", value)
84
+ return value.strip("-").lower()
85
+
86
+
87
+ def _validate_row(row: dict) -> tuple[bool, str]:
88
+ origin = (row.get("origin") or "").strip()
89
+ if origin not in ALLOWED_ORIGINS:
90
+ return False, f"invalid origin: {origin!r}"
91
+ task_type = (row.get("suggestedTaskType") or "").strip()
92
+ if task_type not in ALLOWED_TASK_TYPES:
93
+ return False, f"invalid suggestedTaskType: {task_type!r}"
94
+ if not (row.get("title") or "").strip():
95
+ return False, "title is empty"
96
+ if not (row.get("reason") or "").strip():
97
+ return False, "reason is empty"
98
+ if not (row.get("newTaskId") or "").strip():
99
+ return False, "newTaskId is empty"
100
+ return True, ""
101
+
102
+
103
+ def _write_manifest(path: Path, payload: dict) -> None:
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(
106
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
107
+ encoding="utf-8",
108
+ )
109
+
110
+
111
+ def _data_to_report_path(data_path: Path) -> Path:
112
+ """Derive the markdown sibling path used in spawned manifests for
113
+ the `parentReportPath` field. Falls back to the data.json itself if
114
+ the suffix is not recognised.
115
+ """
116
+ name = data_path.name
117
+ if name.endswith(".data.json"):
118
+ return data_path.with_name(name[: -len(".data.json")] + ".md")
119
+ return data_path.with_suffix(".md")
120
+
121
+
122
+ def _spawn_one(
123
+ *,
124
+ project_root: Path,
125
+ task_group: str,
126
+ parent_task_key: str,
127
+ parent_report_relative: str,
128
+ row: dict,
129
+ dry_run: bool,
130
+ ) -> tuple[str, str]:
131
+ """Returns (status, target_relative_path).
132
+
133
+ status ∈ {created, existing, skipped, invalid}
134
+ """
135
+ ok, why = _validate_row(row)
136
+ if not ok:
137
+ return ("invalid", why)
138
+
139
+ new_task_id = _slugify(row["newTaskId"])
140
+ if not new_task_id:
141
+ return ("invalid", "newTaskId slug is empty after normalisation")
142
+
143
+ task_root = (
144
+ tasks_root(project_root)
145
+ / _slugify(task_group)
146
+ / new_task_id
147
+ )
148
+ rel = task_root.relative_to(project_root).as_posix()
149
+ if task_root.exists():
150
+ return ("existing", rel)
151
+ if dry_run:
152
+ return ("created", rel)
153
+
154
+ suggested = row["suggestedTaskType"].strip()
155
+ title = row["title"].strip()
156
+ scope = (row.get("scope") or "").strip()
157
+ reason = row["reason"].strip()
158
+ origin = row["origin"].strip()
159
+ priority = (row.get("priority") or "P1").strip()
160
+ ticket_id = (row.get("ticketId") or "").strip()
161
+ new_task_key = f"{task_group}/{new_task_id}"
162
+ now = dt.datetime.now(dt.timezone.utc).isoformat()
163
+
164
+ spawned_meta: dict = {
165
+ "parentTaskKey": parent_task_key,
166
+ "parentReportPath": parent_report_relative,
167
+ "origin": origin,
168
+ "rowId": row.get("id", ""),
169
+ "priority": priority,
170
+ "spawnedAt": now,
171
+ }
172
+ if ticket_id:
173
+ spawned_meta["ticketId"] = ticket_id
174
+
175
+ manifest_payload = {
176
+ "schemaVersion": "1.0",
177
+ "taskGroup": task_group,
178
+ "taskId": new_task_id,
179
+ "taskKey": new_task_key,
180
+ "taskGroupPathSegment": _slugify(task_group),
181
+ "taskIdPathSegment": new_task_id,
182
+ "taskType": suggested,
183
+ "workCategory": "unknown",
184
+ "currentStatus": "todo",
185
+ "spawnedFromFollowUp": spawned_meta,
186
+ "relatedTasks": [
187
+ {"taskKey": parent_task_key, "relation": "parent-followup-source"},
188
+ ],
189
+ "workflow": {
190
+ "currentPhase": suggested,
191
+ "currentPhaseState": "not-started",
192
+ "nextRecommendedPhase": suggested,
193
+ "phaseStates": {},
194
+ "awaitingApproval": False,
195
+ "routingStatus": "follow-up-spawned",
196
+ },
197
+ }
198
+ _write_manifest(task_root / "task-manifest.json", manifest_payload)
199
+
200
+ brief_path = task_root / "instruction-set" / "task-brief.md"
201
+ brief_path.parent.mkdir(parents=True, exist_ok=True)
202
+ ticket_line = f"- Ticket ID: `{ticket_id}`\n" if ticket_id else ""
203
+ brief_body = (
204
+ f"# Follow-up Task Brief — {new_task_key}\n\n"
205
+ f"- Spawned from: `{parent_task_key}`\n"
206
+ f"{ticket_line}"
207
+ f"- Origin: `{origin}`\n"
208
+ f"- Source report row: `{row.get('id', '')}` in `{parent_report_relative}`\n"
209
+ f"- Suggested task-type: `{suggested}`\n"
210
+ f"- Priority: `{priority}`\n"
211
+ f"- Spawned at: `{now}`\n\n"
212
+ f"## Title\n\n{title}\n\n"
213
+ f"## Scope (files / areas)\n\n{scope or '_(미지정)_'}\n\n"
214
+ f"## Reason / Why deferred from parent run\n\n{reason}\n\n"
215
+ f"## Next step\n\n"
216
+ f"이 stub은 사용자가 정식 진입할 때 자동 갱신됩니다. 다음 명령 중 하나로 시작하세요:\n\n"
217
+ f"- Claude Code 세션 안: `/okstra-run task-key={new_task_key} task-type={suggested}`\n"
218
+ f"- 별도 터미널: `scripts/okstra.sh --task-key {new_task_key} --task-type {suggested}`\n"
219
+ )
220
+ brief_path.write_text(brief_body, encoding="utf-8")
221
+
222
+ index_path = task_root / "task-index.md"
223
+ index_body = (
224
+ f"# {new_task_key} — Follow-up Task (todo)\n\n"
225
+ f"- Parent: `{parent_task_key}`\n"
226
+ f"{ticket_line}"
227
+ f"- Suggested task-type: `{suggested}`\n"
228
+ f"- Origin: `{origin}`\n"
229
+ f"- Priority: `{priority}`\n"
230
+ f"- Spawned from report: `{parent_report_relative}`\n"
231
+ f"- Stub brief: `instruction-set/task-brief.md`\n"
232
+ f"- Status: `todo`\n\n"
233
+ f"이 task는 자동 생성된 follow-up stub입니다. 정식 진입 시 manifest가 재렌더링됩니다.\n"
234
+ )
235
+ index_path.write_text(index_body, encoding="utf-8")
236
+
237
+ return ("created", rel)
238
+
239
+
240
+ def main(argv: list[str]) -> int:
241
+ parser = argparse.ArgumentParser(
242
+ description="Spawn follow-up task stubs from a final-report data.json.",
243
+ )
244
+ parser.add_argument(
245
+ "data_file",
246
+ type=Path,
247
+ help="Path to the final-report data.json (the JSON SSOT).",
248
+ )
249
+ parser.add_argument("--project-root", type=Path, required=True)
250
+ parser.add_argument(
251
+ "--task-group",
252
+ required=True,
253
+ help="Task-group slug of the parent task.",
254
+ )
255
+ parser.add_argument("--parent-task-key", required=True)
256
+ parser.add_argument(
257
+ "--dry-run",
258
+ action="store_true",
259
+ help="Parse and validate only; do not write files.",
260
+ )
261
+ args = parser.parse_args(argv)
262
+
263
+ if not args.data_file.exists():
264
+ print(f"data.json not found: {args.data_file}", file=sys.stderr)
265
+ return 1
266
+
267
+ try:
268
+ data = json.loads(args.data_file.read_text(encoding="utf-8"))
269
+ except json.JSONDecodeError as exc:
270
+ print(f"invalid JSON in {args.data_file}: {exc}", file=sys.stderr)
271
+ return 1
272
+
273
+ rows = data.get("followUpTasks") or []
274
+ if not rows:
275
+ print("followUpTasks is empty — nothing to do.")
276
+ return 0
277
+
278
+ # Manifests record the markdown sibling rather than data.json so the
279
+ # user-facing report (and not the SSOT) is the cite-able artifact.
280
+ parent_report = _data_to_report_path(args.data_file)
281
+ try:
282
+ parent_report_relative = (
283
+ parent_report.resolve().relative_to(args.project_root.resolve()).as_posix()
284
+ )
285
+ except ValueError:
286
+ parent_report_relative = str(parent_report)
287
+
288
+ results = []
289
+ for row in rows:
290
+ origin = (row.get("origin") or "").strip().lower()
291
+ if origin in NON_SPAWNING_ORIGINS:
292
+ results.append((
293
+ "skipped",
294
+ row.get("newTaskId", ""),
295
+ f"{origin} (advance via /okstra-run, no new task dir)",
296
+ ))
297
+ continue
298
+ if (row.get("autoSpawn") or "").strip().lower() != "yes":
299
+ results.append((
300
+ "skipped",
301
+ row.get("newTaskId", ""),
302
+ "autoSpawn != yes",
303
+ ))
304
+ continue
305
+ status, info = _spawn_one(
306
+ project_root=args.project_root,
307
+ task_group=args.task_group,
308
+ parent_task_key=args.parent_task_key,
309
+ parent_report_relative=parent_report_relative,
310
+ row=row,
311
+ dry_run=args.dry_run,
312
+ )
313
+ results.append((status, row.get("newTaskId", ""), info))
314
+
315
+ print(f"Follow-up spawn summary ({'dry-run' if args.dry_run else 'live'}):")
316
+ for status, task_id, info in results:
317
+ print(f" - [{status}] {task_id}: {info}")
318
+
319
+ if any(status == "invalid" for status, *_ in results):
320
+ return 1
321
+ return 0
322
+
323
+
324
+ if __name__ == "__main__":
325
+ raise SystemExit(main(sys.argv[1:]))
@@ -118,6 +118,14 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
118
118
  claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid)
119
119
  by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
120
120
  lead_path: Path | None = None
121
+ # Team-tagged non-lead sessions that carry no agentName. These are almost
122
+ # always a worker dispatched without the Agent `name` arg (so the harness
123
+ # recorded no agentName) — the session exists and is team-tagged, but there
124
+ # is nothing to match it to a workerId by. Surfacing them in usageSummary
125
+ # gives the "unavailable" worker a visible cause instead of vanishing
126
+ # silently (observed in dev-9692 error-analysis: claude/codex workers
127
+ # dispatched without `name` → both unavailable, report-writer named → fine).
128
+ unattributed_sessions: list[str] = []
121
129
  for sid, path in claude_sessions.items():
122
130
  if sid == lead_sid:
123
131
  lead_path = path
@@ -126,6 +134,8 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
126
134
  agent = totals.get("agentName")
127
135
  if agent:
128
136
  by_agent.setdefault(agent, []).append((sid, path, totals))
137
+ else:
138
+ unattributed_sessions.append(sid)
129
139
 
130
140
  # Lead.
131
141
  if lead_path is not None:
@@ -242,6 +252,7 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
242
252
  "teamName": team_name,
243
253
  "sessionsFound": len(claude_sessions),
244
254
  "unmatchedModels": sorted(set(unmatched_models)),
255
+ "unattributedTeamSessions": unattributed_sessions,
245
256
  "definitions": {
246
257
  "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
247
258
  "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
@@ -366,7 +366,7 @@ okstra token-usage /abs/path/to/run/state/team-state-<task-type>-<seq>.json --wr
366
366
  `okstra token-usage` is a thin Node-side wrapper around the python helper installed at `~/.okstra/bin/okstra-token-usage.py`. Calling the python script directly with `python3 "$HOME/..."` is forbidden — the `$HOME` expansion breaks the literal-token permission match and forces a confirmation prompt every call.
367
367
 
368
368
  The script reads:
369
- - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`).
369
+ - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
370
370
  - `~/.codex/sessions/Y/M/D/rollout-*.jsonl` for the underlying Codex CLI session (matched by `cwd` and timestamp window of the wrapper subagent). Last `event_msg.token_count.total_token_usage.total_tokens` is the session total.
371
371
  - `~/.gemini/tmp/<project>/chats/session-*.json` for the underlying Gemini CLI session. Sum of per-message `tokens.total`.
372
372
 
package/src/install.mjs CHANGED
@@ -28,6 +28,7 @@ const BIN_ENTRYPOINTS = [
28
28
  "okstra-render-report-views.py",
29
29
  "okstra-render-final-report.py",
30
30
  "okstra-wrapper-status.py",
31
+ "okstra-spawn-followups.py",
31
32
  ];
32
33
 
33
34
  const INSTALL_USAGE = `okstra install — install runtime into ~/.okstra