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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -0
- package/agents/tp-api-surface-tracker.md +1 -1
- package/agents/tp-audit-scanner.md +1 -1
- package/agents/tp-commit-writer.md +1 -1
- package/agents/tp-context-engineer.md +1 -1
- package/agents/tp-dead-code-finder.md +1 -1
- package/agents/tp-debugger.md +1 -1
- package/agents/tp-dep-health.md +1 -1
- package/agents/tp-doc-writer.md +1 -1
- package/agents/tp-history-explorer.md +1 -1
- package/agents/tp-impact-analyzer.md +1 -1
- package/agents/tp-incident-timeline.md +1 -1
- package/agents/tp-incremental-builder.md +1 -1
- package/agents/tp-migration-scout.md +1 -1
- package/agents/tp-onboard.md +1 -1
- package/agents/tp-performance-profiler.md +1 -1
- package/agents/tp-pr-reviewer.md +1 -1
- package/agents/tp-refactor-planner.md +1 -1
- package/agents/tp-review-impact.md +1 -1
- package/agents/tp-run.md +1 -1
- package/agents/tp-session-restorer.md +1 -1
- package/agents/tp-ship-coordinator.md +1 -1
- package/agents/tp-spec-writer.md +1 -1
- package/agents/tp-test-coverage-gapper.md +1 -1
- package/agents/tp-test-triage.md +1 -1
- package/agents/tp-test-writer.md +1 -1
- package/dist/cli/stats.d.ts +2 -0
- package/dist/cli/stats.js +32 -0
- package/dist/cli/typo-guard.d.ts +1 -1
- package/dist/cli/typo-guard.js +2 -0
- package/dist/core/event-log.d.ts +7 -0
- package/dist/core/event-log.js +10 -1
- package/dist/core/workflow.d.ts +117 -0
- package/dist/core/workflow.js +269 -0
- package/dist/hooks/post-task.d.ts +18 -3
- package/dist/hooks/post-task.js +44 -11
- package/dist/hooks/pre-task.d.ts +9 -4
- package/dist/hooks/pre-task.js +23 -8
- package/dist/hooks/session-start.js +12 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +129 -1
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
12
|
+
token_pilot_version: "0.39.1"
|
|
13
13
|
token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085
|
|
14
14
|
requiredMcpServers:
|
|
15
15
|
- "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.
|
|
11
|
+
token_pilot_version: "0.39.1"
|
|
12
12
|
token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428
|
|
13
13
|
requiredMcpServers:
|
|
14
14
|
- "token-pilot"
|
package/agents/tp-debugger.md
CHANGED
package/agents/tp-dep-health.md
CHANGED
package/agents/tp-doc-writer.md
CHANGED
|
@@ -12,7 +12,7 @@ tools:
|
|
|
12
12
|
- mcp__token-pilot__read_symbols
|
|
13
13
|
- Read
|
|
14
14
|
model: sonnet
|
|
15
|
-
token_pilot_version: "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.
|
|
11
|
+
token_pilot_version: "0.39.1"
|
|
12
12
|
token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb
|
|
13
13
|
requiredMcpServers:
|
|
14
14
|
- "token-pilot"
|
package/agents/tp-onboard.md
CHANGED
|
@@ -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.
|
|
13
|
+
token_pilot_version: "0.39.1"
|
|
14
14
|
token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927
|
|
15
15
|
requiredMcpServers:
|
|
16
16
|
- "token-pilot"
|
package/agents/tp-pr-reviewer.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
package/agents/tp-spec-writer.md
CHANGED
|
@@ -10,7 +10,7 @@ tools:
|
|
|
10
10
|
- mcp__token-pilot__test_summary
|
|
11
11
|
- Glob
|
|
12
12
|
- Grep
|
|
13
|
-
token_pilot_version: "0.
|
|
13
|
+
token_pilot_version: "0.39.1"
|
|
14
14
|
token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11
|
|
15
15
|
requiredMcpServers:
|
|
16
16
|
- "token-pilot"
|
package/agents/tp-test-triage.md
CHANGED
|
@@ -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.
|
|
11
|
+
token_pilot_version: "0.39.1"
|
|
12
12
|
token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90
|
|
13
13
|
requiredMcpServers:
|
|
14
14
|
- "token-pilot"
|
package/agents/tp-test-writer.md
CHANGED
package/dist/cli/stats.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/typo-guard.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/typo-guard.js
CHANGED
package/dist/core/event-log.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/event-log.js
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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;
|
package/dist/hooks/post-task.js
CHANGED
|
@@ -30,9 +30,24 @@ export function parseAgentBudget(body) {
|
|
|
30
30
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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) {
|
package/dist/hooks/pre-task.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
68
|
-
*
|
|
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
|
package/dist/hooks/pre-task.js
CHANGED
|
@@ -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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|