token-pilot 0.36.0 → 0.39.2

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 (46) 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/installer.js +11 -2
  38. package/dist/hooks/post-task.d.ts +18 -3
  39. package/dist/hooks/post-task.js +44 -11
  40. package/dist/hooks/pre-task.d.ts +9 -4
  41. package/dist/hooks/pre-task.js +23 -8
  42. package/dist/hooks/session-start.js +12 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +129 -1
  45. package/hooks/hooks.json +1 -2
  46. package/package.json +1 -1
@@ -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
@@ -130,12 +130,21 @@ function createHookConfig(options) {
130
130
  PostToolUse: [
131
131
  {
132
132
  matcher: "Bash",
133
- // v0.35.0 — async: true keeps telemetry off the hot path
133
+ // v0.35.0 — async: true keeps the advisory off the hot path.
134
+ // post-bash writes NO telemetry (advisory-only), so detached
135
+ // execution is safe here.
134
136
  hooks: [hookEntry("hook-post-bash", options, { async: true })],
135
137
  },
136
138
  {
137
139
  matcher: "Task",
138
- hooks: [hookEntry("hook-post-task", options, { async: true })],
140
+ // v0.39.2 — post-task MUST run synchronously. It writes the
141
+ // `event:"task"` record via appendEvent (mkdir + stat +
142
+ // appendFile). Under `async: true` Claude Code fires the hook
143
+ // detached and may reap the process before those writes flush
144
+ // — the suspected cause of persistently zero task events in
145
+ // hook-events.jsonl despite subagents being dispatched.
146
+ // Telemetry integrity > the ~5ms saved on a non-hot-path hook.
147
+ hooks: [hookEntry("hook-post-task", options)],
139
148
  },
140
149
  ],
141
150
  },
@@ -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/hooks/hooks.json CHANGED
@@ -92,8 +92,7 @@
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
95
- "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-post-task",
96
- "async": true
95
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-post-task"
97
96
  }
98
97
  ]
99
98
  }