token-pilot 0.36.0 → 0.39.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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +42 -0
  4. package/agents/tp-api-surface-tracker.md +1 -1
  5. package/agents/tp-audit-scanner.md +1 -1
  6. package/agents/tp-commit-writer.md +1 -1
  7. package/agents/tp-context-engineer.md +1 -1
  8. package/agents/tp-dead-code-finder.md +1 -1
  9. package/agents/tp-debugger.md +1 -1
  10. package/agents/tp-dep-health.md +1 -1
  11. package/agents/tp-doc-writer.md +1 -1
  12. package/agents/tp-history-explorer.md +1 -1
  13. package/agents/tp-impact-analyzer.md +1 -1
  14. package/agents/tp-incident-timeline.md +1 -1
  15. package/agents/tp-incremental-builder.md +1 -1
  16. package/agents/tp-migration-scout.md +1 -1
  17. package/agents/tp-onboard.md +1 -1
  18. package/agents/tp-performance-profiler.md +1 -1
  19. package/agents/tp-pr-reviewer.md +1 -1
  20. package/agents/tp-refactor-planner.md +1 -1
  21. package/agents/tp-review-impact.md +1 -1
  22. package/agents/tp-run.md +1 -1
  23. package/agents/tp-session-restorer.md +1 -1
  24. package/agents/tp-ship-coordinator.md +1 -1
  25. package/agents/tp-spec-writer.md +1 -1
  26. package/agents/tp-test-coverage-gapper.md +1 -1
  27. package/agents/tp-test-triage.md +1 -1
  28. package/agents/tp-test-writer.md +1 -1
  29. package/dist/cli/stats.d.ts +2 -0
  30. package/dist/cli/stats.js +32 -0
  31. package/dist/cli/typo-guard.d.ts +1 -1
  32. package/dist/cli/typo-guard.js +2 -0
  33. package/dist/core/event-log.d.ts +7 -0
  34. package/dist/core/event-log.js +10 -1
  35. package/dist/core/workflow.d.ts +117 -0
  36. package/dist/core/workflow.js +269 -0
  37. package/dist/hooks/post-task.d.ts +18 -3
  38. package/dist/hooks/post-task.js +44 -11
  39. package/dist/hooks/pre-task.d.ts +9 -4
  40. package/dist/hooks/pre-task.js +23 -8
  41. package/dist/hooks/session-start.js +12 -0
  42. package/dist/index.d.ts +14 -0
  43. package/dist/index.js +129 -1
  44. package/package.json +1 -1
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Token Pilot \u2014 save 60-90% tokens when AI reads code",
9
- "version": "0.36.0"
9
+ "version": "0.39.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "token-pilot",
14
14
  "source": "./",
15
15
  "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 22 MCP tools + 19 subagents + budget watchdog hooks.",
16
- "version": "0.36.0",
16
+ "version": "0.39.1",
17
17
  "author": {
18
18
  "name": "Digital-Threads"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.36.0",
3
+ "version": "0.39.1",
4
4
  "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.",
5
5
  "author": {
6
6
  "name": "Digital-Threads",
package/README.md CHANGED
@@ -269,6 +269,48 @@ tp-* agents that already declared `model: haiku` keep their cheaper
269
269
  tier (90 %+ of the agent roster); the few sonnet/opus-tier ones
270
270
  ride the upgrade automatically.
271
271
 
272
+ ## Fleet workflows (v0.38.0)
273
+
274
+ When you fan a task across many subagents — via Claude Code's
275
+ `/workflow`, the Agent tool, or your own orchestration — token-pilot
276
+ can treat the whole run as one budgeted, telemetry-tagged unit.
277
+
278
+ token-pilot **owns** the workflow boundary, so this works regardless
279
+ of whether Claude Code propagates a workflow id. You wrap the batch:
280
+
281
+ ```bash
282
+ # Start a workflow — prints an export line you eval into your shell
283
+ eval "$(token-pilot workflow start "review every PR from last sprint" --budget=2000000)"
284
+
285
+ # ...now run your fan-out work. Every hook event is tagged with the
286
+ # workflow id automatically (TOKEN_PILOT_WORKFLOW_ID is set).
287
+
288
+ token-pilot workflow status # live budget + task counts
289
+ token-pilot workflow list # all recorded workflows
290
+ token-pilot workflow end # stamp it finished + print summary
291
+ ```
292
+
293
+ While a workflow is active:
294
+
295
+ - Every `event:"task"` / `denied` / `diagnostic` row in
296
+ `hook-events.jsonl` carries `workflow_id`, so you can slice one
297
+ fan-out run out of the global log.
298
+ - The PreToolUse:Task hook watches the token ceiling. At ≥90 % it
299
+ appends a wind-down note to its routing advice ("finish in-flight
300
+ work rather than starting new branches") and logs a
301
+ `workflow_near_budget` diagnostic — visible in `workflow status`.
302
+ Dispatch is never hard-blocked on budget (a half-finished fan-out
303
+ is worse than a small overrun).
304
+ - The window title switches to `[TP] wf · N tasks · X%` so a long run
305
+ shows live progress.
306
+
307
+ Claude Code's own `/workflow` (2.1.154+) does **not** expose a
308
+ per-workflow id env var to subagents (verified against the 2.1.161
309
+ bundle — it has only a `CLAUDE_CODE_WORKFLOWS` feature flag). So
310
+ token-pilot's workflows are independent: they rely on our own
311
+ `TOKEN_PILOT_WORKFLOW_ID`. If CC adds a per-workflow env var later,
312
+ `activeWorkflowId()` already probes for it — no config change needed.
313
+
272
314
  ## Troubleshooting
273
315
 
274
316
  ```bash
@@ -9,7 +9,7 @@ tools:
9
9
  - mcp__token-pilot__read_symbol
10
10
  - Bash
11
11
  model: haiku
12
- token_pilot_version: "0.36.0"
12
+ token_pilot_version: "0.39.1"
13
13
  token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085
14
14
  requiredMcpServers:
15
15
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - Grep
12
12
  - Read
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -8,7 +8,7 @@ tools:
8
8
  - mcp__token-pilot__test_summary
9
9
  - mcp__token-pilot__outline
10
10
  - Bash
11
- token_pilot_version: "0.36.0"
11
+ token_pilot_version: "0.39.1"
12
12
  token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428
13
13
  requiredMcpServers:
14
14
  - "token-pilot"
@@ -13,7 +13,7 @@ tools:
13
13
  - Edit
14
14
  - Glob
15
15
  model: sonnet
16
- token_pilot_version: "0.36.0"
16
+ token_pilot_version: "0.39.1"
17
17
  token_pilot_body_hash: 68b32af2dacd82ebe52c4eec93edb903d452688274c3065218270627c564d8b0
18
18
  requiredMcpServers:
19
19
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - Grep
12
12
  - Read
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -12,7 +12,7 @@ tools:
12
12
  - Read
13
13
  - Bash
14
14
  model: sonnet
15
- token_pilot_version: "0.36.0"
15
+ token_pilot_version: "0.39.1"
16
16
  token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500
17
17
  requiredMcpServers:
18
18
  - "token-pilot"
@@ -9,7 +9,7 @@ tools:
9
9
  - Bash
10
10
  - Read
11
11
  model: haiku
12
- token_pilot_version: "0.36.0"
12
+ token_pilot_version: "0.39.1"
13
13
  token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7
14
14
  requiredMcpServers:
15
15
  - "token-pilot"
@@ -13,7 +13,7 @@ tools:
13
13
  - Edit
14
14
  - Glob
15
15
  model: haiku
16
- token_pilot_version: "0.36.0"
16
+ token_pilot_version: "0.39.1"
17
17
  token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf
18
18
  requiredMcpServers:
19
19
  - "token-pilot"
@@ -10,7 +10,7 @@ tools:
10
10
  - Bash
11
11
  - Read
12
12
  model: haiku
13
- token_pilot_version: "0.36.0"
13
+ token_pilot_version: "0.39.1"
14
14
  token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904
15
15
  requiredMcpServers:
16
16
  - "token-pilot"
@@ -12,7 +12,7 @@ tools:
12
12
  - mcp__token-pilot__read_symbols
13
13
  - Read
14
14
  model: sonnet
15
- token_pilot_version: "0.36.0"
15
+ token_pilot_version: "0.39.1"
16
16
  token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f
17
17
  requiredMcpServers:
18
18
  - "token-pilot"
@@ -8,7 +8,7 @@ tools:
8
8
  - mcp__token-pilot__read_symbol
9
9
  - Bash
10
10
  model: inherit
11
- token_pilot_version: "0.36.0"
11
+ token_pilot_version: "0.39.1"
12
12
  token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb
13
13
  requiredMcpServers:
14
14
  - "token-pilot"
@@ -13,7 +13,7 @@ tools:
13
13
  - Edit
14
14
  - Bash
15
15
  model: sonnet
16
- token_pilot_version: "0.36.0"
16
+ token_pilot_version: "0.39.1"
17
17
  token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60
18
18
  requiredMcpServers:
19
19
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - Grep
12
12
  - Glob
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -10,7 +10,7 @@ tools:
10
10
  - mcp__token-pilot__smart_read
11
11
  - mcp__token-pilot__smart_read_many
12
12
  - mcp__token-pilot__read_section
13
- token_pilot_version: "0.36.0"
13
+ token_pilot_version: "0.39.1"
14
14
  token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927
15
15
  requiredMcpServers:
16
16
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - Bash
12
12
  - Read
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - mcp__token-pilot__read_for_edit
12
12
  - Read
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -8,7 +8,7 @@ tools:
8
8
  - mcp__token-pilot__outline
9
9
  - mcp__token-pilot__read_symbol
10
10
  model: sonnet
11
- token_pilot_version: "0.36.0"
11
+ token_pilot_version: "0.39.1"
12
12
  token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550
13
13
  requiredMcpServers:
14
14
  - "token-pilot"
@@ -9,7 +9,7 @@ tools:
9
9
  - mcp__token-pilot__module_info
10
10
  - Bash
11
11
  model: sonnet
12
- token_pilot_version: "0.36.0"
12
+ token_pilot_version: "0.39.1"
13
13
  token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c
14
14
  requiredMcpServers:
15
15
  - "token-pilot"
package/agents/tp-run.md CHANGED
@@ -16,7 +16,7 @@ tools:
16
16
  - Glob
17
17
  - Bash
18
18
  model: haiku
19
- token_pilot_version: "0.36.0"
19
+ token_pilot_version: "0.39.1"
20
20
  token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4
21
21
  requiredMcpServers:
22
22
  - "token-pilot"
@@ -9,7 +9,7 @@ tools:
9
9
  - mcp__token-pilot__session_budget
10
10
  - Bash
11
11
  - Read
12
- token_pilot_version: "0.36.0"
12
+ token_pilot_version: "0.39.1"
13
13
  token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767
14
14
  requiredMcpServers:
15
15
  - "token-pilot"
@@ -11,7 +11,7 @@ tools:
11
11
  - Read
12
12
  - Grep
13
13
  model: sonnet
14
- token_pilot_version: "0.36.0"
14
+ token_pilot_version: "0.39.1"
15
15
  token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b
16
16
  requiredMcpServers:
17
17
  - "token-pilot"
@@ -9,7 +9,7 @@ tools:
9
9
  - Read
10
10
  - Write
11
11
  model: sonnet
12
- token_pilot_version: "0.36.0"
12
+ token_pilot_version: "0.39.1"
13
13
  token_pilot_body_hash: c7a4e8b39228fd5158528f389c924c5ff2d98c4b9b05ee0106d54a26c5dc1350
14
14
  requiredMcpServers:
15
15
  - "token-pilot"
@@ -10,7 +10,7 @@ tools:
10
10
  - mcp__token-pilot__test_summary
11
11
  - Glob
12
12
  - Grep
13
- token_pilot_version: "0.36.0"
13
+ token_pilot_version: "0.39.1"
14
14
  token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11
15
15
  requiredMcpServers:
16
16
  - "token-pilot"
@@ -8,7 +8,7 @@ tools:
8
8
  - mcp__token-pilot__find_usages
9
9
  - mcp__token-pilot__read_symbol
10
10
  model: sonnet
11
- token_pilot_version: "0.36.0"
11
+ token_pilot_version: "0.39.1"
12
12
  token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90
13
13
  requiredMcpServers:
14
14
  - "token-pilot"
@@ -13,7 +13,7 @@ tools:
13
13
  - Edit
14
14
  - Bash
15
15
  model: sonnet
16
- token_pilot_version: "0.36.0"
16
+ token_pilot_version: "0.39.1"
17
17
  token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9
18
18
  requiredMcpServers:
19
19
  - "token-pilot"
@@ -22,6 +22,8 @@ export interface StatsOptions {
22
22
  byAgent?: boolean;
23
23
  /** v0.31.0 — Task-routing view: subagent_type usage + miss-rate. */
24
24
  tasks?: boolean;
25
+ /** v0.39.0 — aggregate of `event:"workflow"` completion rows. */
26
+ workflows?: boolean;
25
27
  }
26
28
  /**
27
29
  * Pure formatter. Takes the full event list and options; returns the
package/dist/cli/stats.js CHANGED
@@ -67,6 +67,36 @@ export function formatStats(events, opts) {
67
67
  const total = sumSaved(scope);
68
68
  const sessionSuffix = sessionLabel ? ` (session ${sessionLabel})` : "";
69
69
  lines.push(`token-pilot stats${sessionSuffix} — ${scope.length} event${scope.length === 1 ? "" : "s"}, ~${total} tokens saved`);
70
+ if (opts.workflows) {
71
+ // v0.39.0 — workflow completion view. Each `event:"workflow"` row is
72
+ // a frozen summary written by `workflow end`.
73
+ const wf = scope.filter((e) => e.event === "workflow");
74
+ if (wf.length === 0) {
75
+ return lines[0] + "\n\nNo completed workflows yet.";
76
+ }
77
+ let totalUsed = 0;
78
+ let totalTasks = 0;
79
+ let totalOver = 0;
80
+ const rows = [];
81
+ for (const e of wf) {
82
+ const d = (e.detail ?? {});
83
+ const used = d.used_tokens ?? e.estTokens ?? 0;
84
+ const tasks = d.task_count ?? 0;
85
+ const over = d.over_budget_workers ?? 0;
86
+ totalUsed += used;
87
+ totalTasks += tasks;
88
+ totalOver += over;
89
+ const pct = d.pct != null ? ` (${d.pct}%)` : "";
90
+ rows.push(` ${e.workflow_id ?? "?"} ${tasks} tasks · ~${used} tok${pct}` +
91
+ (over > 0 ? ` · ${over} over-budget` : "") +
92
+ (d.goal ? ` — ${d.goal}` : ""));
93
+ }
94
+ lines.push("");
95
+ lines.push(`Completed workflows: ${wf.length} · ${totalTasks} tasks · ~${totalUsed} tokens · ${totalOver} over-budget`);
96
+ lines.push("");
97
+ lines.push(...rows);
98
+ return lines.join("\n");
99
+ }
70
100
  if (opts.tasks) {
71
101
  // v0.31.0 — Task-routing view. Scope to event:"task" records only.
72
102
  const taskEvents = scope.filter((e) => e.event === "task");
@@ -172,10 +202,12 @@ export async function handleStats(argv, opts) {
172
202
  const session = parseFlag(argv, "session");
173
203
  const byAgent = parseFlag(argv, "by-agent");
174
204
  const tasks = parseFlag(argv, "tasks");
205
+ const workflows = parseFlag(argv, "workflows");
175
206
  const rendered = formatStats(events, {
176
207
  session: session === undefined ? undefined : session,
177
208
  byAgent: byAgent === true,
178
209
  tasks: tasks === true,
210
+ workflows: workflows === true,
179
211
  });
180
212
  process.stdout.write(rendered + "\n");
181
213
  return 0;
@@ -17,7 +17,7 @@
17
17
  * Everything else passes through untouched — a real project root like
18
18
  * `/home/user/my-project` or `./subdir` goes to startServer as before.
19
19
  */
20
- export declare const KNOWN_COMMANDS: readonly ["hook-read", "hook-edit", "hook-pre-bash", "hook-pre-grep", "hook-pre-task", "hook-post-bash", "hook-post-task", "hook-session-start", "hook-bootstrap", "install-hook", "uninstall-hook", "install-ast-index", "doctor", "bless-agents", "unbless-agents", "install-agents", "uninstall-agents", "stats", "tool-audit", "save-doc", "list-docs", "init", "migrate-hooks", "errors", "--version", "-v", "--help", "-h"];
20
+ export declare const KNOWN_COMMANDS: readonly ["hook-read", "hook-edit", "hook-pre-bash", "hook-pre-grep", "hook-pre-task", "hook-post-bash", "hook-post-task", "hook-session-start", "hook-bootstrap", "install-hook", "uninstall-hook", "install-ast-index", "doctor", "bless-agents", "unbless-agents", "install-agents", "uninstall-agents", "stats", "tool-audit", "save-doc", "list-docs", "init", "migrate-hooks", "errors", "workflow", "--version", "-v", "--help", "-h"];
21
21
  export interface TypoGuardResult {
22
22
  kind: "pass-through" | "typo";
23
23
  suggestion?: string;
@@ -51,6 +51,8 @@ export const KNOWN_COMMANDS = [
51
51
  // v0.33.0
52
52
  "migrate-hooks",
53
53
  "errors",
54
+ // v0.38.0 — fleet workflow lifecycle
55
+ "workflow",
54
56
  "--version",
55
57
  "-v",
56
58
  "--help",
@@ -38,6 +38,13 @@ export interface HookEvent {
38
38
  * never populate it; events stay shape-compatible.
39
39
  */
40
40
  parent_agent_id?: string | null;
41
+ /**
42
+ * v0.38.0 — id of the token-pilot workflow this event belongs to,
43
+ * when one is active (TOKEN_PILOT_WORKFLOW_ID set). Lets fleet-level
44
+ * budget + telemetry slice a fan-out run out of the global log.
45
+ * Optional — absent when no workflow is active.
46
+ */
47
+ workflow_id?: string;
41
48
  event: "denied" | "allowed" | "bypass" | "pass-through" | "task" | "diagnostic" | string;
42
49
  file: string;
43
50
  lines: number;
@@ -108,9 +108,18 @@ async function rotateIfNeeded(projectRoot, thresholdBytes = ROTATION_THRESHOLD_B
108
108
  */
109
109
  export async function appendEvent(projectRoot, event) {
110
110
  try {
111
+ // v0.38.0 — auto-tag with the active workflow id so every event
112
+ // emitted inside a `token-pilot workflow` boundary is sliceable.
113
+ // Read the env var directly here to keep call sites unchanged; an
114
+ // explicit workflow_id on the event always wins.
115
+ const wf = event.workflow_id ??
116
+ process.env.TOKEN_PILOT_WORKFLOW_ID ??
117
+ process.env.CLAUDE_CODE_WORKFLOW_ID ??
118
+ undefined;
119
+ const tagged = wf ? { ...event, workflow_id: wf } : event;
111
120
  await ensureLogDir(projectRoot);
112
121
  await rotateIfNeeded(projectRoot);
113
- const line = JSON.stringify(event) + "\n";
122
+ const line = JSON.stringify(tagged) + "\n";
114
123
  await fs.appendFile(currentLogPath(projectRoot), line);
115
124
  }
116
125
  catch {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * v0.38.0 — fleet workflow lifecycle.
3
+ *
4
+ * The fleet design note (docs/design/2026-06-tp-fleet-dynamic-workflows.md)
5
+ * flagged one blocker: it assumed Claude Code's `/workflow` would set a
6
+ * propagated workflow-id env var on dispatched subagents. The 2.1.131
7
+ * bundle has no such variable, so building tagging/budget against it
8
+ * would be the v0.34.0-args mistake again (shipping against an
9
+ * interface that may not exist).
10
+ *
11
+ * Resolution: token-pilot OWNS the workflow boundary. A user wraps a
12
+ * batch of fan-out work with `token-pilot workflow start` / `end`,
13
+ * which writes an envelope file and exports `TOKEN_PILOT_WORKFLOW_ID`.
14
+ * Every hook reads that env var and tags its events. No dependency on
15
+ * Claude Code's `/workflow` — and if CC ever propagates its own
16
+ * workflow id env var, we read that too (see activeWorkflowId).
17
+ *
18
+ * State lives in `<projectRoot>/.token-pilot/workflows/`:
19
+ * <id>.json — the envelope (goal, budget, started/ended)
20
+ * Events stay in the normal hook-events.jsonl, tagged with workflow_id.
21
+ *
22
+ * Pure-ish: all filesystem helpers swallow errors (telemetry must
23
+ * never break a hook). The `now`/`id` injection points keep the unit
24
+ * tests deterministic.
25
+ */
26
+ import { type HookEvent } from "./event-log.js";
27
+ export declare const WORKFLOW_SUBDIR = ".token-pilot/workflows";
28
+ export interface WorkflowEnvelope {
29
+ workflow_id: string;
30
+ started_at: number;
31
+ ended_at: number | null;
32
+ goal: string;
33
+ budget_tokens: number | null;
34
+ max_parallel: number | null;
35
+ }
36
+ export interface WorkflowBudgetStatus {
37
+ workflow_id: string;
38
+ goal: string;
39
+ budget_tokens: number | null;
40
+ used_tokens: number;
41
+ pct: number | null;
42
+ event_count: number;
43
+ task_count: number;
44
+ over_budget_workers: number;
45
+ ended: boolean;
46
+ }
47
+ /**
48
+ * Resolve the active workflow id. token-pilot's own env var takes
49
+ * precedence.
50
+ *
51
+ * Note (verified against CC 2.1.161): Claude Code's `/workflow` does
52
+ * NOT export a per-workflow id env var to dispatched subagents — the
53
+ * bundle only has the `CLAUDE_CODE_WORKFLOWS` feature flag and an
54
+ * internal `WorkflowId`. The extra names below are a harmless
55
+ * forward-compat probe (they return null today); token-pilot's fleet
56
+ * workflows are independent and rely solely on our own
57
+ * `TOKEN_PILOT_WORKFLOW_ID`. Returns null when none is set.
58
+ */
59
+ export declare function activeWorkflowId(env?: NodeJS.ProcessEnv): string | null;
60
+ /**
61
+ * Build a workflow id. Format `wf-<base36 ts>-<suffix>`. `now` and
62
+ * `suffix` are injectable for deterministic tests; production passes a
63
+ * timestamp + a short random token.
64
+ */
65
+ export declare function makeWorkflowId(now: number, suffix: string): string;
66
+ export interface StartWorkflowInput {
67
+ projectRoot: string;
68
+ goal: string;
69
+ budgetTokens?: number | null;
70
+ maxParallel?: number | null;
71
+ /** Injectable for tests. */
72
+ now?: number;
73
+ /** Injectable for tests. */
74
+ idSuffix?: string;
75
+ }
76
+ /**
77
+ * Create a workflow envelope and return it. The caller is responsible
78
+ * for exporting `TOKEN_PILOT_WORKFLOW_ID=<id>` into the environment of
79
+ * the work that follows (the CLI prints an `export` line for this).
80
+ */
81
+ export declare function startWorkflow(input: StartWorkflowInput): Promise<WorkflowEnvelope>;
82
+ export declare function loadWorkflow(projectRoot: string, id: string): Promise<WorkflowEnvelope | null>;
83
+ /**
84
+ * Mark a workflow ended (stamps ended_at) and emit a frozen
85
+ * `event:"workflow"` completion record into hook-events.jsonl.
86
+ *
87
+ * v0.39.0 — the envelope stores only the static plan (goal, budget,
88
+ * timestamps); the aggregates (tokens used, task count) are computed
89
+ * live from tagged events. Freezing them in a single summary row at
90
+ * end time makes historical analysis cheap and survives event-log
91
+ * rotation (which could otherwise drop the underlying per-task rows).
92
+ * Returns the updated envelope, or null when the id is unknown.
93
+ */
94
+ export declare function endWorkflow(projectRoot: string, id: string, now?: number): Promise<WorkflowEnvelope | null>;
95
+ /** List all workflow envelopes, newest first. */
96
+ export declare function listWorkflows(projectRoot: string): Promise<WorkflowEnvelope[]>;
97
+ /**
98
+ * Pure aggregation of a workflow's status from its envelope + the set
99
+ * of events tagged with its id. Separated from I/O so tests drive it
100
+ * directly.
101
+ */
102
+ export declare function computeWorkflowStatus(envelope: WorkflowEnvelope, events: HookEvent[]): WorkflowBudgetStatus;
103
+ /**
104
+ * Load a workflow's live status from disk (envelope + tagged events
105
+ * across the repo tree). Returns null when the id is unknown.
106
+ */
107
+ export declare function workflowStatus(projectRoot: string, id: string): Promise<WorkflowBudgetStatus | null>;
108
+ /**
109
+ * Returns true when the workflow's used tokens are within `nearPct`
110
+ * percent of its ceiling (or over). Used by the pre-task hook to warn
111
+ * the fleet to wind down before the ceiling is breached. False when no
112
+ * budget is set.
113
+ */
114
+ export declare function isWorkflowNearBudget(status: WorkflowBudgetStatus, nearPct?: number): boolean;
115
+ export declare function formatWorkflowStatus(status: WorkflowBudgetStatus): string;
116
+ export declare function formatWorkflowList(workflows: WorkflowEnvelope[]): string;
117
+ //# sourceMappingURL=workflow.d.ts.map
@@ -0,0 +1,269 @@
1
+ /**
2
+ * v0.38.0 — fleet workflow lifecycle.
3
+ *
4
+ * The fleet design note (docs/design/2026-06-tp-fleet-dynamic-workflows.md)
5
+ * flagged one blocker: it assumed Claude Code's `/workflow` would set a
6
+ * propagated workflow-id env var on dispatched subagents. The 2.1.131
7
+ * bundle has no such variable, so building tagging/budget against it
8
+ * would be the v0.34.0-args mistake again (shipping against an
9
+ * interface that may not exist).
10
+ *
11
+ * Resolution: token-pilot OWNS the workflow boundary. A user wraps a
12
+ * batch of fan-out work with `token-pilot workflow start` / `end`,
13
+ * which writes an envelope file and exports `TOKEN_PILOT_WORKFLOW_ID`.
14
+ * Every hook reads that env var and tags its events. No dependency on
15
+ * Claude Code's `/workflow` — and if CC ever propagates its own
16
+ * workflow id env var, we read that too (see activeWorkflowId).
17
+ *
18
+ * State lives in `<projectRoot>/.token-pilot/workflows/`:
19
+ * <id>.json — the envelope (goal, budget, started/ended)
20
+ * Events stay in the normal hook-events.jsonl, tagged with workflow_id.
21
+ *
22
+ * Pure-ish: all filesystem helpers swallow errors (telemetry must
23
+ * never break a hook). The `now`/`id` injection points keep the unit
24
+ * tests deterministic.
25
+ */
26
+ import { promises as fs } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { appendEvent, loadEventsTree, } from "./event-log.js";
29
+ export const WORKFLOW_SUBDIR = ".token-pilot/workflows";
30
+ // ─── env access ──────────────────────────────────────────────────────
31
+ /**
32
+ * Resolve the active workflow id. token-pilot's own env var takes
33
+ * precedence.
34
+ *
35
+ * Note (verified against CC 2.1.161): Claude Code's `/workflow` does
36
+ * NOT export a per-workflow id env var to dispatched subagents — the
37
+ * bundle only has the `CLAUDE_CODE_WORKFLOWS` feature flag and an
38
+ * internal `WorkflowId`. The extra names below are a harmless
39
+ * forward-compat probe (they return null today); token-pilot's fleet
40
+ * workflows are independent and rely solely on our own
41
+ * `TOKEN_PILOT_WORKFLOW_ID`. Returns null when none is set.
42
+ */
43
+ export function activeWorkflowId(env = process.env) {
44
+ return (env.TOKEN_PILOT_WORKFLOW_ID ||
45
+ env.CLAUDE_CODE_WORKFLOW_ID ||
46
+ env.CLAUDE_WORKFLOW_ID ||
47
+ null);
48
+ }
49
+ // ─── id generation ───────────────────────────────────────────────────
50
+ /**
51
+ * Build a workflow id. Format `wf-<base36 ts>-<suffix>`. `now` and
52
+ * `suffix` are injectable for deterministic tests; production passes a
53
+ * timestamp + a short random token.
54
+ */
55
+ export function makeWorkflowId(now, suffix) {
56
+ return `wf-${now.toString(36)}-${suffix}`;
57
+ }
58
+ // ─── paths ───────────────────────────────────────────────────────────
59
+ function workflowDir(projectRoot) {
60
+ return join(projectRoot, WORKFLOW_SUBDIR);
61
+ }
62
+ function envelopePath(projectRoot, id) {
63
+ return join(workflowDir(projectRoot), `${id}.json`);
64
+ }
65
+ /**
66
+ * Create a workflow envelope and return it. The caller is responsible
67
+ * for exporting `TOKEN_PILOT_WORKFLOW_ID=<id>` into the environment of
68
+ * the work that follows (the CLI prints an `export` line for this).
69
+ */
70
+ export async function startWorkflow(input) {
71
+ const now = input.now ?? Date.now();
72
+ const suffix = input.idSuffix ?? Math.floor(now % 1_000_000).toString(36).padStart(4, "0");
73
+ const envelope = {
74
+ workflow_id: makeWorkflowId(now, suffix),
75
+ started_at: now,
76
+ ended_at: null,
77
+ goal: input.goal,
78
+ budget_tokens: input.budgetTokens ?? null,
79
+ max_parallel: input.maxParallel ?? null,
80
+ };
81
+ try {
82
+ await fs.mkdir(workflowDir(input.projectRoot), { recursive: true });
83
+ await fs.writeFile(envelopePath(input.projectRoot, envelope.workflow_id), JSON.stringify(envelope, null, 2) + "\n");
84
+ }
85
+ catch {
86
+ /* best-effort — never block the caller */
87
+ }
88
+ return envelope;
89
+ }
90
+ export async function loadWorkflow(projectRoot, id) {
91
+ try {
92
+ const raw = await fs.readFile(envelopePath(projectRoot, id), "utf-8");
93
+ const parsed = JSON.parse(raw);
94
+ if (parsed && typeof parsed.workflow_id === "string")
95
+ return parsed;
96
+ return null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * Mark a workflow ended (stamps ended_at) and emit a frozen
104
+ * `event:"workflow"` completion record into hook-events.jsonl.
105
+ *
106
+ * v0.39.0 — the envelope stores only the static plan (goal, budget,
107
+ * timestamps); the aggregates (tokens used, task count) are computed
108
+ * live from tagged events. Freezing them in a single summary row at
109
+ * end time makes historical analysis cheap and survives event-log
110
+ * rotation (which could otherwise drop the underlying per-task rows).
111
+ * Returns the updated envelope, or null when the id is unknown.
112
+ */
113
+ export async function endWorkflow(projectRoot, id, now = Date.now()) {
114
+ const env = await loadWorkflow(projectRoot, id);
115
+ if (!env)
116
+ return null;
117
+ env.ended_at = now;
118
+ try {
119
+ await fs.writeFile(envelopePath(projectRoot, id), JSON.stringify(env, null, 2) + "\n");
120
+ }
121
+ catch {
122
+ /* best-effort */
123
+ }
124
+ // Freeze the aggregates into a completion event.
125
+ try {
126
+ const events = await loadEventsTree(projectRoot);
127
+ const status = computeWorkflowStatus(env, events);
128
+ const summary = {
129
+ ts: now,
130
+ session_id: "workflow",
131
+ agent_type: null,
132
+ agent_id: null,
133
+ workflow_id: id,
134
+ event: "workflow",
135
+ file: "",
136
+ lines: 0,
137
+ estTokens: status.used_tokens,
138
+ summaryTokens: 0,
139
+ savedTokens: 0,
140
+ level: "info",
141
+ code: "workflow_complete",
142
+ detail: {
143
+ goal: env.goal,
144
+ budget_tokens: env.budget_tokens,
145
+ used_tokens: status.used_tokens,
146
+ pct: status.pct,
147
+ task_count: status.task_count,
148
+ over_budget_workers: status.over_budget_workers,
149
+ duration_ms: env.ended_at - env.started_at,
150
+ },
151
+ };
152
+ // Pass the workflow_id explicitly so appendEvent keeps it even if
153
+ // the env var has already been unset by the time `end` runs.
154
+ await appendEvent(projectRoot, summary);
155
+ }
156
+ catch {
157
+ /* completion telemetry is best-effort */
158
+ }
159
+ return env;
160
+ }
161
+ /** List all workflow envelopes, newest first. */
162
+ export async function listWorkflows(projectRoot) {
163
+ let entries;
164
+ try {
165
+ entries = await fs.readdir(workflowDir(projectRoot));
166
+ }
167
+ catch {
168
+ return [];
169
+ }
170
+ const out = [];
171
+ for (const name of entries) {
172
+ if (!name.endsWith(".json"))
173
+ continue;
174
+ const env = await loadWorkflow(projectRoot, name.replace(/\.json$/, ""));
175
+ if (env)
176
+ out.push(env);
177
+ }
178
+ out.sort((a, b) => b.started_at - a.started_at);
179
+ return out;
180
+ }
181
+ // ─── budget + telemetry ──────────────────────────────────────────────
182
+ /**
183
+ * Pure aggregation of a workflow's status from its envelope + the set
184
+ * of events tagged with its id. Separated from I/O so tests drive it
185
+ * directly.
186
+ */
187
+ export function computeWorkflowStatus(envelope, events) {
188
+ const mine = events.filter((e) => e.workflow_id === envelope.workflow_id);
189
+ let used = 0;
190
+ let taskCount = 0;
191
+ let overWorkers = 0;
192
+ for (const e of mine) {
193
+ if (e.event === "task") {
194
+ taskCount++;
195
+ used += e.estTokens || 0;
196
+ if (e.overBudget)
197
+ overWorkers++;
198
+ }
199
+ }
200
+ const pct = envelope.budget_tokens && envelope.budget_tokens > 0
201
+ ? Math.round((used / envelope.budget_tokens) * 100)
202
+ : null;
203
+ return {
204
+ workflow_id: envelope.workflow_id,
205
+ goal: envelope.goal,
206
+ budget_tokens: envelope.budget_tokens,
207
+ used_tokens: used,
208
+ pct,
209
+ event_count: mine.length,
210
+ task_count: taskCount,
211
+ over_budget_workers: overWorkers,
212
+ ended: envelope.ended_at != null,
213
+ };
214
+ }
215
+ /**
216
+ * Load a workflow's live status from disk (envelope + tagged events
217
+ * across the repo tree). Returns null when the id is unknown.
218
+ */
219
+ export async function workflowStatus(projectRoot, id) {
220
+ const envelope = await loadWorkflow(projectRoot, id);
221
+ if (!envelope)
222
+ return null;
223
+ const events = await loadEventsTree(projectRoot);
224
+ return computeWorkflowStatus(envelope, events);
225
+ }
226
+ /**
227
+ * Returns true when the workflow's used tokens are within `nearPct`
228
+ * percent of its ceiling (or over). Used by the pre-task hook to warn
229
+ * the fleet to wind down before the ceiling is breached. False when no
230
+ * budget is set.
231
+ */
232
+ export function isWorkflowNearBudget(status, nearPct = 90) {
233
+ if (status.budget_tokens == null || status.budget_tokens <= 0)
234
+ return false;
235
+ return status.used_tokens >= status.budget_tokens * (nearPct / 100);
236
+ }
237
+ // ─── formatting ──────────────────────────────────────────────────────
238
+ function humanTokens(n) {
239
+ if (n >= 1_000_000)
240
+ return `${(n / 1_000_000).toFixed(1)}M`;
241
+ if (n >= 1000)
242
+ return `${Math.round(n / 1000)}k`;
243
+ return `${n}`;
244
+ }
245
+ export function formatWorkflowStatus(status) {
246
+ const lines = [];
247
+ lines.push(`workflow ${status.workflow_id}${status.ended ? " (ended)" : ""}`);
248
+ lines.push(` goal: ${status.goal}`);
249
+ const budget = status.budget_tokens != null
250
+ ? `${humanTokens(status.budget_tokens)} ceiling`
251
+ : "no ceiling";
252
+ const pct = status.pct != null ? ` (${status.pct}%)` : "";
253
+ lines.push(` budget: ${budget} · ${humanTokens(status.used_tokens)} used${pct}`);
254
+ lines.push(` tasks: ${status.task_count} dispatched · ${status.over_budget_workers} over-budget`);
255
+ lines.push(` events: ${status.event_count} tagged`);
256
+ return lines.join("\n");
257
+ }
258
+ export function formatWorkflowList(workflows) {
259
+ if (workflows.length === 0)
260
+ return "No workflows recorded.";
261
+ const lines = [`${workflows.length} workflow(s):`];
262
+ for (const w of workflows) {
263
+ const state = w.ended_at ? "ended " : "active";
264
+ const budget = w.budget_tokens != null ? `${humanTokens(w.budget_tokens)} ceiling` : "—";
265
+ lines.push(` [${state}] ${w.workflow_id} ${budget} ${w.goal}`);
266
+ }
267
+ return lines.join("\n");
268
+ }
269
+ //# sourceMappingURL=workflow.js.map
@@ -19,9 +19,24 @@ export declare const OVER_BUDGET_LOG = "over-budget.log";
19
19
  export declare const OVER_BUDGET_TOLERANCE = 0.1;
20
20
  export declare function parseAgentBudget(body: string): number | null;
21
21
  /**
22
- * Count approx tokens in the `tool_response.content[*].text` blocks of a
23
- * PostToolUse hook input for the Task tool. Returns null for anything
24
- * other than a well-formed Task response.
22
+ * Extract the subagent's token count from a PostToolUse:Task hook input.
23
+ *
24
+ * v0.37.0 the Task tool's `tool_response` carries an authoritative
25
+ * `totalTokens` field. Verified by inspecting the Claude Code 2.1.131
26
+ * bundle: the Task result object is
27
+ * { agentId, agentType, content, totalDurationMs, totalTokens, totalToolUseCount, usage }
28
+ * Earlier versions of this function only summed `content[*].text`
29
+ * lengths / 4 — a rough heuristic that returned 0 whenever the
30
+ * response wasn't a `{content:[{text}]}` array, leaving the budget
31
+ * watchdog and task token-weighting permanently at zero.
32
+ *
33
+ * Resolution order:
34
+ * 1. `tool_response.totalTokens` (exact, preferred)
35
+ * 2. `tool_response.usage.output_tokens` (exact, alternate shape)
36
+ * 3. char/4 over `content[*].text` (legacy heuristic fallback)
37
+ * 4. char/4 over a plain string `content`
38
+ *
39
+ * Returns null only when no signal at all is available.
25
40
  */
26
41
  export declare function extractSubagentTokens(input: {
27
42
  tool_name?: string;
@@ -30,9 +30,24 @@ export function parseAgentBudget(body) {
30
30
  return Number.isFinite(n) && n > 0 ? n : null;
31
31
  }
32
32
  /**
33
- * Count approx tokens in the `tool_response.content[*].text` blocks of a
34
- * PostToolUse hook input for the Task tool. Returns null for anything
35
- * other than a well-formed Task response.
33
+ * Extract the subagent's token count from a PostToolUse:Task hook input.
34
+ *
35
+ * v0.37.0 the Task tool's `tool_response` carries an authoritative
36
+ * `totalTokens` field. Verified by inspecting the Claude Code 2.1.131
37
+ * bundle: the Task result object is
38
+ * { agentId, agentType, content, totalDurationMs, totalTokens, totalToolUseCount, usage }
39
+ * Earlier versions of this function only summed `content[*].text`
40
+ * lengths / 4 — a rough heuristic that returned 0 whenever the
41
+ * response wasn't a `{content:[{text}]}` array, leaving the budget
42
+ * watchdog and task token-weighting permanently at zero.
43
+ *
44
+ * Resolution order:
45
+ * 1. `tool_response.totalTokens` (exact, preferred)
46
+ * 2. `tool_response.usage.output_tokens` (exact, alternate shape)
47
+ * 3. char/4 over `content[*].text` (legacy heuristic fallback)
48
+ * 4. char/4 over a plain string `content`
49
+ *
50
+ * Returns null only when no signal at all is available.
36
51
  */
37
52
  export function extractSubagentTokens(input) {
38
53
  if (input.tool_name !== "Task")
@@ -40,15 +55,33 @@ export function extractSubagentTokens(input) {
40
55
  const resp = input.tool_response;
41
56
  if (!resp || typeof resp !== "object")
42
57
  return null;
43
- const parts = Array.isArray(resp.content) ? resp.content : [];
44
- let chars = 0;
45
- for (const p of parts) {
46
- if (typeof p?.text === "string")
47
- chars += p.text.length;
58
+ // 1. Authoritative totalTokens.
59
+ if (typeof resp.totalTokens === "number" && resp.totalTokens > 0) {
60
+ return Math.round(resp.totalTokens);
48
61
  }
49
- if (chars === 0)
50
- return null;
51
- return Math.ceil(chars / 4);
62
+ // 2. usage shape (alternate / future-proof).
63
+ const usage = resp.usage;
64
+ if (usage && typeof usage === "object") {
65
+ const out = usage.total_tokens ?? usage.output_tokens;
66
+ if (typeof out === "number" && out > 0)
67
+ return Math.round(out);
68
+ }
69
+ // 3. Legacy heuristic over content[*].text.
70
+ if (Array.isArray(resp.content)) {
71
+ let chars = 0;
72
+ for (const p of resp.content) {
73
+ if (p && typeof p.text === "string") {
74
+ chars += p.text.length;
75
+ }
76
+ }
77
+ if (chars > 0)
78
+ return Math.ceil(chars / 4);
79
+ }
80
+ // 4. Plain-string content.
81
+ if (typeof resp.content === "string" && resp.content.length > 0) {
82
+ return Math.ceil(resp.content.length / 4);
83
+ }
84
+ return null;
52
85
  }
53
86
  export function decideBudgetAdvice(input) {
54
87
  if (input.budget == null || input.budget <= 0) {
@@ -63,9 +63,14 @@ export declare function decidePreTask(input: PreTaskInput, ctx: PreTaskContext):
63
63
  /**
64
64
  * Render the Claude Code hook JSON response.
65
65
  *
66
- * - allow → no output (pass-through)
67
- * - advise permissionDecision=allow + additionalContext
68
- * - deny → permissionDecision=deny + reason
66
+ * - allow → no output (pass-through), UNLESS `append` carries a fleet
67
+ * budget note then emit an allow + additionalContext so the
68
+ * note still reaches the agent.
69
+ * - advise → permissionDecision=allow + additionalContext (+ append)
70
+ * - deny → permissionDecision=deny + reason (+ append)
71
+ *
72
+ * v0.38.0 — `append` is an optional trailing string (the workflow
73
+ * near-budget wind-down note). Empty / omitted leaves output unchanged.
69
74
  */
70
- export declare function renderPreTaskOutput(decision: PreTaskDecision): string | null;
75
+ export declare function renderPreTaskOutput(decision: PreTaskDecision, append?: string): string | null;
71
76
  //# sourceMappingURL=pre-task.d.ts.map
@@ -132,19 +132,34 @@ export function decidePreTask(input, ctx) {
132
132
  /**
133
133
  * Render the Claude Code hook JSON response.
134
134
  *
135
- * - allow → no output (pass-through)
136
- * - advise permissionDecision=allow + additionalContext
137
- * - deny → permissionDecision=deny + reason
135
+ * - allow → no output (pass-through), UNLESS `append` carries a fleet
136
+ * budget note then emit an allow + additionalContext so the
137
+ * note still reaches the agent.
138
+ * - advise → permissionDecision=allow + additionalContext (+ append)
139
+ * - deny → permissionDecision=deny + reason (+ append)
140
+ *
141
+ * v0.38.0 — `append` is an optional trailing string (the workflow
142
+ * near-budget wind-down note). Empty / omitted leaves output unchanged.
138
143
  */
139
- export function renderPreTaskOutput(decision) {
140
- if (decision.kind === "allow")
141
- return null;
144
+ export function renderPreTaskOutput(decision, append = "") {
145
+ const extra = append || "";
146
+ if (decision.kind === "allow") {
147
+ if (!extra)
148
+ return null;
149
+ return JSON.stringify({
150
+ hookSpecificOutput: {
151
+ hookEventName: "PreToolUse",
152
+ permissionDecision: "allow",
153
+ additionalContext: extra.trimStart(),
154
+ },
155
+ });
156
+ }
142
157
  if (decision.kind === "advise") {
143
158
  return JSON.stringify({
144
159
  hookSpecificOutput: {
145
160
  hookEventName: "PreToolUse",
146
161
  permissionDecision: "allow",
147
- additionalContext: decision.message,
162
+ additionalContext: decision.message + extra,
148
163
  },
149
164
  });
150
165
  }
@@ -152,7 +167,7 @@ export function renderPreTaskOutput(decision) {
152
167
  hookSpecificOutput: {
153
168
  hookEventName: "PreToolUse",
154
169
  permissionDecision: "deny",
155
- permissionDecisionReason: decision.reason,
170
+ permissionDecisionReason: decision.reason + extra,
156
171
  },
157
172
  });
158
173
  }
@@ -318,6 +318,18 @@ export async function handleSessionStart(opts) {
318
318
  : `${surfaced}`;
319
319
  sessionTitle = `[TP] ${human} saved`;
320
320
  }
321
+ // v0.38.0 — when a fleet workflow is active, prefer a
322
+ // workflow-progress title so a long fan-out run shows live
323
+ // task count + budget in the window title.
324
+ const { activeWorkflowId, workflowStatus } = await import("../core/workflow.js");
325
+ const wfId = activeWorkflowId();
326
+ if (wfId) {
327
+ const st = await workflowStatus(opts.projectRoot, wfId);
328
+ if (st) {
329
+ const pct = st.pct != null ? ` · ${st.pct}%` : "";
330
+ sessionTitle = `[TP] wf · ${st.task_count} tasks${pct}`;
331
+ }
332
+ }
321
333
  }
322
334
  catch {
323
335
  /* sessionTitle is best-effort decoration; never block startup */
package/dist/index.d.ts CHANGED
@@ -58,6 +58,20 @@ export declare function runHookReadDispatch(filePathArg: string | undefined, mod
58
58
  * (file existence, prep-state lookup, env vars).
59
59
  */
60
60
  export declare function handleHookEdit(): void;
61
+ /**
62
+ * v0.38.0 — `token-pilot workflow <subcommand>` CLI.
63
+ *
64
+ * start <goal> [--budget=N] [--max-parallel=N]
65
+ * Create a workflow envelope and print an `export
66
+ * TOKEN_PILOT_WORKFLOW_ID=<id>` line. Wrap a fan-out batch with
67
+ * this so every hook event gets tagged with the id.
68
+ * end [<id>] Stamp the workflow ended (defaults to active env id).
69
+ * status [<id>] Show live budget + task counts (defaults to env id).
70
+ * list All recorded workflows, newest first.
71
+ *
72
+ * Returns a process exit code.
73
+ */
74
+ export declare function handleWorkflowCli(argv: string[]): Promise<number>;
61
75
  export declare function handleInstallHook(projectRoot: string): Promise<void>;
62
76
  export declare function handleUninstallHook(projectRoot: string): Promise<void>;
63
77
  export declare function handleInstallAstIndex(): Promise<void>;
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { installHook, uninstallHook, cleanStaleHookEntries, isTokenPilotPluginEn
27
27
  import { runHookEntryPoint } from "./hooks/safe-runner.js";
28
28
  import { loadErrors, formatErrorList } from "./core/error-log.js";
29
29
  import { appendDiagnostic } from "./core/event-log.js";
30
+ import { startWorkflow, endWorkflow, listWorkflows, workflowStatus, formatWorkflowStatus, formatWorkflowList, } from "./core/workflow.js";
30
31
  import { findBinary, installBinary, checkBinaryUpdate, isNewerVersion, } from "./ast-index/binary-manager.js";
31
32
  import { loadConfig } from "./config/loader.js";
32
33
  import { isDangerousRoot } from "./core/validation.js";
@@ -229,7 +230,30 @@ export async function main(cliArgs = process.argv.slice(2)) {
229
230
  /* never block dispatch on telemetry */
230
231
  });
231
232
  }
232
- const rendered = renderPreTaskOutput(decision);
233
+ // v0.38.0 fleet budget guard. When a workflow is active and
234
+ // its token ceiling is within reach, append a wind-down note
235
+ // to whatever the routing decision produced. The dispatch is
236
+ // never hard-blocked on budget (a half-finished fan-out is
237
+ // worse than a small overrun) — we advise, and surface an
238
+ // over-budget diagnostic so `workflow status` reflects it.
239
+ const { activeWorkflowId, workflowStatus, isWorkflowNearBudget } = await import("./core/workflow.js");
240
+ const wfId = activeWorkflowId();
241
+ let budgetNote = "";
242
+ if (wfId) {
243
+ const st = await workflowStatus(process.cwd(), wfId);
244
+ if (st && isWorkflowNearBudget(st)) {
245
+ budgetNote =
246
+ `\n\n[token-pilot] workflow ${wfId} is at ${st.pct ?? "~"}% of its ` +
247
+ `${st.budget_tokens} token ceiling — finish in-flight work and ` +
248
+ `report rather than starting new branches.`;
249
+ appendDiagnostic(process.cwd(), {
250
+ code: "workflow_near_budget",
251
+ level: "warn",
252
+ detail: { workflow_id: wfId, pct: st.pct, used: st.used_tokens },
253
+ }).catch(() => { });
254
+ }
255
+ }
256
+ const rendered = renderPreTaskOutput(decision, budgetNote);
233
257
  if (rendered)
234
258
  process.stdout.write(rendered);
235
259
  });
@@ -348,6 +372,15 @@ export async function main(cliArgs = process.argv.slice(2)) {
348
372
  process.stdout.write(formatErrorList(records) + "\n");
349
373
  return;
350
374
  }
375
+ case "workflow": {
376
+ // v0.38.0 — fleet workflow lifecycle. token-pilot owns the
377
+ // workflow boundary (we set TOKEN_PILOT_WORKFLOW_ID ourselves),
378
+ // so this works regardless of whether Claude Code's /workflow
379
+ // propagates an env var. Subcommands: start / end / status / list.
380
+ const code = await handleWorkflowCli(cliArgs.slice(1));
381
+ process.exit(code);
382
+ return;
383
+ }
351
384
  case "migrate-hooks": {
352
385
  // v0.33.0 — clean stale npx-cache / pinned-version token-pilot
353
386
  // hook entries from user-level + project-level settings.json so
@@ -903,6 +936,101 @@ export function handleHookEdit() {
903
936
  process.stdout.write(rendered);
904
937
  process.exit(0);
905
938
  }
939
+ /**
940
+ * v0.38.0 — `token-pilot workflow <subcommand>` CLI.
941
+ *
942
+ * start <goal> [--budget=N] [--max-parallel=N]
943
+ * Create a workflow envelope and print an `export
944
+ * TOKEN_PILOT_WORKFLOW_ID=<id>` line. Wrap a fan-out batch with
945
+ * this so every hook event gets tagged with the id.
946
+ * end [<id>] Stamp the workflow ended (defaults to active env id).
947
+ * status [<id>] Show live budget + task counts (defaults to env id).
948
+ * list All recorded workflows, newest first.
949
+ *
950
+ * Returns a process exit code.
951
+ */
952
+ export async function handleWorkflowCli(argv) {
953
+ const projectRoot = process.cwd();
954
+ const sub = argv[0];
955
+ const flag = (k) => {
956
+ for (const a of argv) {
957
+ if (a.startsWith(`--${k}=`))
958
+ return a.slice(k.length + 3);
959
+ }
960
+ return undefined;
961
+ };
962
+ const envId = process.env.TOKEN_PILOT_WORKFLOW_ID ||
963
+ process.env.CLAUDE_CODE_WORKFLOW_ID ||
964
+ undefined;
965
+ switch (sub) {
966
+ case "start": {
967
+ const goal = argv.slice(1).filter((a) => !a.startsWith("--")).join(" ");
968
+ if (!goal) {
969
+ process.stderr.write('workflow start: a goal is required — `token-pilot workflow start "review last sprint"`\n');
970
+ return 1;
971
+ }
972
+ const budgetRaw = flag("budget");
973
+ const parallelRaw = flag("max-parallel");
974
+ const env = await startWorkflow({
975
+ projectRoot,
976
+ goal,
977
+ budgetTokens: budgetRaw ? Number(budgetRaw) : null,
978
+ maxParallel: parallelRaw ? Number(parallelRaw) : null,
979
+ });
980
+ // The id goes to stdout as an `export` line so a user can do
981
+ // eval "$(token-pilot workflow start '...')"
982
+ // and have the env var set for the fan-out that follows.
983
+ process.stdout.write(`export TOKEN_PILOT_WORKFLOW_ID=${env.workflow_id}\n`);
984
+ process.stderr.write(`[token-pilot] workflow ${env.workflow_id} started` +
985
+ (env.budget_tokens ? ` · ${env.budget_tokens} token ceiling` : "") +
986
+ `\n`);
987
+ return 0;
988
+ }
989
+ case "end": {
990
+ const id = argv[1] && !argv[1].startsWith("--") ? argv[1] : envId;
991
+ if (!id) {
992
+ process.stderr.write("workflow end: no id given and TOKEN_PILOT_WORKFLOW_ID not set.\n");
993
+ return 1;
994
+ }
995
+ const env = await endWorkflow(projectRoot, id);
996
+ if (!env) {
997
+ process.stderr.write(`workflow end: unknown workflow "${id}".\n`);
998
+ return 1;
999
+ }
1000
+ const status = await workflowStatus(projectRoot, id);
1001
+ if (status)
1002
+ process.stdout.write(formatWorkflowStatus(status) + "\n");
1003
+ process.stderr.write(`[token-pilot] workflow ${id} ended.\n`);
1004
+ return 0;
1005
+ }
1006
+ case "status": {
1007
+ const id = argv[1] && !argv[1].startsWith("--") ? argv[1] : envId;
1008
+ if (!id) {
1009
+ process.stderr.write("workflow status: no id given and TOKEN_PILOT_WORKFLOW_ID not set.\n");
1010
+ return 1;
1011
+ }
1012
+ const status = await workflowStatus(projectRoot, id);
1013
+ if (!status) {
1014
+ process.stderr.write(`workflow status: unknown workflow "${id}".\n`);
1015
+ return 1;
1016
+ }
1017
+ process.stdout.write(formatWorkflowStatus(status) + "\n");
1018
+ return 0;
1019
+ }
1020
+ case "list": {
1021
+ const workflows = await listWorkflows(projectRoot);
1022
+ process.stdout.write(formatWorkflowList(workflows) + "\n");
1023
+ return 0;
1024
+ }
1025
+ default:
1026
+ process.stderr.write("Usage: token-pilot workflow <start|end|status|list>\n" +
1027
+ ' start "<goal>" [--budget=N] [--max-parallel=N]\n' +
1028
+ " end [<id>]\n" +
1029
+ " status [<id>]\n" +
1030
+ " list\n");
1031
+ return sub ? 1 : 0;
1032
+ }
1033
+ }
906
1034
  export async function handleInstallHook(projectRoot) {
907
1035
  // v0.26.5 — plugin-aware early-return. If we're running as a Claude
908
1036
  // Code plugin (CLAUDE_PLUGIN_ROOT set) the hooks are already declared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.36.0",
3
+ "version": "0.39.1",
4
4
  "description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",