token-pilot 0.19.2 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/hooks/hooks.json +30 -0
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +165 -0
- package/README.md +194 -313
- package/dist/agents/tp-audit-scanner.md +49 -0
- package/dist/agents/tp-commit-writer.md +41 -0
- package/dist/agents/tp-dead-code-finder.md +43 -0
- package/dist/agents/tp-debugger.md +45 -0
- package/dist/agents/tp-history-explorer.md +43 -0
- package/dist/agents/tp-impact-analyzer.md +44 -0
- package/dist/agents/tp-migration-scout.md +43 -0
- package/dist/agents/tp-onboard.md +40 -0
- package/dist/agents/tp-pr-reviewer.md +41 -0
- package/dist/agents/tp-refactor-planner.md +42 -0
- package/dist/agents/tp-run.md +48 -0
- package/dist/agents/tp-session-restorer.md +47 -0
- package/dist/agents/tp-test-triage.md +40 -0
- package/dist/agents/tp-test-writer.md +46 -0
- package/dist/cli/agent-frontmatter.d.ts +48 -0
- package/dist/cli/agent-frontmatter.js +189 -0
- package/dist/cli/bless-agents.d.ts +65 -0
- package/dist/cli/bless-agents.js +307 -0
- package/dist/cli/claudeignore.d.ts +33 -0
- package/dist/cli/claudeignore.js +88 -0
- package/dist/cli/claudemd-hygiene.d.ts +26 -0
- package/dist/cli/claudemd-hygiene.js +43 -0
- package/dist/cli/doctor-drift.d.ts +31 -0
- package/dist/cli/doctor-drift.js +130 -0
- package/dist/cli/doctor-env-check.d.ts +25 -0
- package/dist/cli/doctor-env-check.js +91 -0
- package/dist/cli/install-agents.d.ts +108 -0
- package/dist/cli/install-agents.js +402 -0
- package/dist/cli/save-doc.d.ts +42 -0
- package/dist/cli/save-doc.js +145 -0
- package/dist/cli/scan-agents.d.ts +46 -0
- package/dist/cli/scan-agents.js +227 -0
- package/dist/cli/stats.d.ts +36 -0
- package/dist/cli/stats.js +131 -0
- package/dist/cli/typo-guard.d.ts +27 -0
- package/dist/cli/typo-guard.js +119 -0
- package/dist/cli/unbless-agents.d.ts +33 -0
- package/dist/cli/unbless-agents.js +85 -0
- package/dist/cli/uninstall-agents.d.ts +36 -0
- package/dist/cli/uninstall-agents.js +117 -0
- package/dist/config/defaults.d.ts +1 -1
- package/dist/config/defaults.js +14 -8
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.js +105 -11
- package/dist/core/context-registry.d.ts +16 -1
- package/dist/core/context-registry.js +60 -28
- package/dist/core/event-log.d.ts +79 -0
- package/dist/core/event-log.js +190 -0
- package/dist/core/session-registry.d.ts +43 -0
- package/dist/core/session-registry.js +113 -0
- package/dist/core/session-savings.d.ts +19 -0
- package/dist/core/session-savings.js +60 -0
- package/dist/handlers/session-budget.d.ts +32 -0
- package/dist/handlers/session-budget.js +61 -0
- package/dist/handlers/session-snapshot-persist.d.ts +22 -0
- package/dist/handlers/session-snapshot-persist.js +76 -0
- package/dist/hooks/adaptive-threshold.d.ts +27 -0
- package/dist/hooks/adaptive-threshold.js +46 -0
- package/dist/hooks/format-deny-message.d.ts +21 -0
- package/dist/hooks/format-deny-message.js +147 -0
- package/dist/hooks/installer.js +130 -31
- package/dist/hooks/path-safety.d.ts +16 -0
- package/dist/hooks/path-safety.js +34 -0
- package/dist/hooks/post-bash.d.ts +46 -0
- package/dist/hooks/post-bash.js +77 -0
- package/dist/hooks/post-task.d.ts +67 -0
- package/dist/hooks/post-task.js +136 -0
- package/dist/hooks/session-start.d.ts +45 -0
- package/dist/hooks/session-start.js +179 -0
- package/dist/hooks/summary-ast-index.d.ts +28 -0
- package/dist/hooks/summary-ast-index.js +122 -0
- package/dist/hooks/summary-head-tail.d.ts +15 -0
- package/dist/hooks/summary-head-tail.js +78 -0
- package/dist/hooks/summary-pipeline.d.ts +35 -0
- package/dist/hooks/summary-pipeline.js +63 -0
- package/dist/hooks/summary-regex.d.ts +14 -0
- package/dist/hooks/summary-regex.js +130 -0
- package/dist/hooks/summary-types.d.ts +29 -0
- package/dist/hooks/summary-types.js +9 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +538 -149
- package/dist/integration/context-mode-detector.d.ts +7 -1
- package/dist/integration/context-mode-detector.js +51 -15
- package/dist/server/tool-definitions.d.ts +149 -0
- package/dist/server/tool-definitions.js +424 -202
- package/dist/server.d.ts +1 -1
- package/dist/server.js +456 -179
- package/dist/templates/agent-builder.d.ts +49 -0
- package/dist/templates/agent-builder.js +104 -0
- package/dist/types.d.ts +38 -4
- package/package.json +4 -2
- package/skills/stats/SKILL.md +13 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TP-q33(a) — PostToolUse:Task budget enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `Task` tool is how subagents are dispatched. When one
|
|
5
|
+
* returns, this hook:
|
|
6
|
+
* 1. Identifies the subagent by `tool_input.subagent_type`.
|
|
7
|
+
* 2. Loads its agent markdown (project first, then ~/.claude/agents).
|
|
8
|
+
* 3. Reads the `Response budget: ~N tokens` line from the body.
|
|
9
|
+
* 4. Counts tokens in the `tool_response` body (chars/4 heuristic).
|
|
10
|
+
* 5. If actual > budget × (1 + OVER_BUDGET_TOLERANCE), append a JSONL
|
|
11
|
+
* entry to `.token-pilot/over-budget.log`.
|
|
12
|
+
*
|
|
13
|
+
* Silent on every failure — telemetry must never break the agent loop.
|
|
14
|
+
* Non-tp-* subagents are ignored (we only enforce our own contracts).
|
|
15
|
+
*/
|
|
16
|
+
export declare const OVER_BUDGET_LOG = "over-budget.log";
|
|
17
|
+
/** Ratio above which we flag — 0.1 = 10 % grace. */
|
|
18
|
+
export declare const OVER_BUDGET_TOLERANCE = 0.1;
|
|
19
|
+
export declare function parseAgentBudget(body: string): number | null;
|
|
20
|
+
/**
|
|
21
|
+
* Count approx tokens in the `tool_response.content[*].text` blocks of a
|
|
22
|
+
* PostToolUse hook input for the Task tool. Returns null for anything
|
|
23
|
+
* other than a well-formed Task response.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractSubagentTokens(input: {
|
|
26
|
+
tool_name?: string;
|
|
27
|
+
tool_response?: unknown;
|
|
28
|
+
}): number | null;
|
|
29
|
+
export interface BudgetDecisionInput {
|
|
30
|
+
agentName: string;
|
|
31
|
+
budget: number | null;
|
|
32
|
+
actualTokens: number;
|
|
33
|
+
}
|
|
34
|
+
export interface BudgetDecisionResult {
|
|
35
|
+
overBudget: boolean;
|
|
36
|
+
overByRatio: number;
|
|
37
|
+
message: string | null;
|
|
38
|
+
}
|
|
39
|
+
export declare function decideBudgetAdvice(input: BudgetDecisionInput): BudgetDecisionResult;
|
|
40
|
+
export interface OverBudgetEntry {
|
|
41
|
+
ts: number;
|
|
42
|
+
agent: string;
|
|
43
|
+
budget: number;
|
|
44
|
+
actualTokens: number;
|
|
45
|
+
overByRatio: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function appendOverBudgetLog(projectRoot: string, entry: OverBudgetEntry): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Locate the markdown body for a `tp-*` subagent — project-level first,
|
|
50
|
+
* then user-level. Returns null when neither exists. Non-tp-* subagents
|
|
51
|
+
* are rejected up front so we never peek outside our namespace.
|
|
52
|
+
*/
|
|
53
|
+
export declare function loadAgentBody(projectRoot: string, homeDir: string, agentName: string): Promise<string | null>;
|
|
54
|
+
export interface PostTaskHookInput {
|
|
55
|
+
tool_name?: string;
|
|
56
|
+
tool_input?: {
|
|
57
|
+
subagent_type?: string;
|
|
58
|
+
};
|
|
59
|
+
tool_response?: unknown;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Full post-Task processing: read frontmatter, count tokens, log over-budget.
|
|
63
|
+
* Returns the advice message (or null) so the caller can optionally emit
|
|
64
|
+
* `additionalContext` — though the primary output channel is the log file.
|
|
65
|
+
*/
|
|
66
|
+
export declare function processPostTask(projectRoot: string, homeDir: string, input: PostTaskHookInput): Promise<string | null>;
|
|
67
|
+
//# sourceMappingURL=post-task.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TP-q33(a) — PostToolUse:Task budget enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `Task` tool is how subagents are dispatched. When one
|
|
5
|
+
* returns, this hook:
|
|
6
|
+
* 1. Identifies the subagent by `tool_input.subagent_type`.
|
|
7
|
+
* 2. Loads its agent markdown (project first, then ~/.claude/agents).
|
|
8
|
+
* 3. Reads the `Response budget: ~N tokens` line from the body.
|
|
9
|
+
* 4. Counts tokens in the `tool_response` body (chars/4 heuristic).
|
|
10
|
+
* 5. If actual > budget × (1 + OVER_BUDGET_TOLERANCE), append a JSONL
|
|
11
|
+
* entry to `.token-pilot/over-budget.log`.
|
|
12
|
+
*
|
|
13
|
+
* Silent on every failure — telemetry must never break the agent loop.
|
|
14
|
+
* Non-tp-* subagents are ignored (we only enforce our own contracts).
|
|
15
|
+
*/
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
export const OVER_BUDGET_LOG = "over-budget.log";
|
|
19
|
+
/** Ratio above which we flag — 0.1 = 10 % grace. */
|
|
20
|
+
export const OVER_BUDGET_TOLERANCE = 0.1;
|
|
21
|
+
const BUDGET_RE = /Response budget:\s*~?\s*(\d{2,6})\s*tokens?/i;
|
|
22
|
+
export function parseAgentBudget(body) {
|
|
23
|
+
const m = body.match(BUDGET_RE);
|
|
24
|
+
if (!m)
|
|
25
|
+
return null;
|
|
26
|
+
const n = Number.parseInt(m[1], 10);
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Count approx tokens in the `tool_response.content[*].text` blocks of a
|
|
31
|
+
* PostToolUse hook input for the Task tool. Returns null for anything
|
|
32
|
+
* other than a well-formed Task response.
|
|
33
|
+
*/
|
|
34
|
+
export function extractSubagentTokens(input) {
|
|
35
|
+
if (input.tool_name !== "Task")
|
|
36
|
+
return null;
|
|
37
|
+
const resp = input.tool_response;
|
|
38
|
+
if (!resp || typeof resp !== "object")
|
|
39
|
+
return null;
|
|
40
|
+
const parts = Array.isArray(resp.content) ? resp.content : [];
|
|
41
|
+
let chars = 0;
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
if (typeof p?.text === "string")
|
|
44
|
+
chars += p.text.length;
|
|
45
|
+
}
|
|
46
|
+
if (chars === 0)
|
|
47
|
+
return null;
|
|
48
|
+
return Math.ceil(chars / 4);
|
|
49
|
+
}
|
|
50
|
+
export function decideBudgetAdvice(input) {
|
|
51
|
+
if (input.budget == null || input.budget <= 0) {
|
|
52
|
+
return { overBudget: false, overByRatio: 0, message: null };
|
|
53
|
+
}
|
|
54
|
+
const allowed = input.budget * (1 + OVER_BUDGET_TOLERANCE);
|
|
55
|
+
if (input.actualTokens <= allowed) {
|
|
56
|
+
return {
|
|
57
|
+
overBudget: false,
|
|
58
|
+
overByRatio: input.actualTokens / input.budget - 1,
|
|
59
|
+
message: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const ratio = input.actualTokens / input.budget - 1;
|
|
63
|
+
const pct = Math.round(ratio * 100);
|
|
64
|
+
return {
|
|
65
|
+
overBudget: true,
|
|
66
|
+
overByRatio: ratio,
|
|
67
|
+
message: `${input.agentName} exceeded budget (~${input.actualTokens} tokens vs budget ${input.budget}, +${pct}%). See .token-pilot/over-budget.log.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function appendOverBudgetLog(projectRoot, entry) {
|
|
71
|
+
try {
|
|
72
|
+
const dir = join(projectRoot, ".token-pilot");
|
|
73
|
+
await fs.mkdir(dir, { recursive: true });
|
|
74
|
+
const line = JSON.stringify(entry) + "\n";
|
|
75
|
+
await fs.appendFile(join(dir, OVER_BUDGET_LOG), line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* silent — logging must never break the hook */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Locate the markdown body for a `tp-*` subagent — project-level first,
|
|
83
|
+
* then user-level. Returns null when neither exists. Non-tp-* subagents
|
|
84
|
+
* are rejected up front so we never peek outside our namespace.
|
|
85
|
+
*/
|
|
86
|
+
export async function loadAgentBody(projectRoot, homeDir, agentName) {
|
|
87
|
+
if (!agentName.startsWith("tp-"))
|
|
88
|
+
return null;
|
|
89
|
+
const candidates = [
|
|
90
|
+
join(projectRoot, ".claude", "agents", `${agentName}.md`),
|
|
91
|
+
join(homeDir, ".claude", "agents", `${agentName}.md`),
|
|
92
|
+
];
|
|
93
|
+
for (const p of candidates) {
|
|
94
|
+
try {
|
|
95
|
+
return await fs.readFile(p, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* try next */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Full post-Task processing: read frontmatter, count tokens, log over-budget.
|
|
105
|
+
* Returns the advice message (or null) so the caller can optionally emit
|
|
106
|
+
* `additionalContext` — though the primary output channel is the log file.
|
|
107
|
+
*/
|
|
108
|
+
export async function processPostTask(projectRoot, homeDir, input) {
|
|
109
|
+
if (input.tool_name !== "Task")
|
|
110
|
+
return null;
|
|
111
|
+
const agentName = input.tool_input?.subagent_type;
|
|
112
|
+
if (typeof agentName !== "string" || !agentName.startsWith("tp-")) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const actualTokens = extractSubagentTokens(input);
|
|
116
|
+
if (actualTokens == null)
|
|
117
|
+
return null;
|
|
118
|
+
const body = await loadAgentBody(projectRoot, homeDir, agentName);
|
|
119
|
+
const budget = body ? parseAgentBudget(body) : null;
|
|
120
|
+
const decision = decideBudgetAdvice({
|
|
121
|
+
agentName,
|
|
122
|
+
budget,
|
|
123
|
+
actualTokens,
|
|
124
|
+
});
|
|
125
|
+
if (decision.overBudget && budget != null) {
|
|
126
|
+
await appendOverBudgetLog(projectRoot, {
|
|
127
|
+
ts: Date.now(),
|
|
128
|
+
agent: agentName,
|
|
129
|
+
budget,
|
|
130
|
+
actualTokens,
|
|
131
|
+
overByRatio: decision.overByRatio,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return decision.message;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=post-task.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart reminder hook — Component 2 of the enforcement layer.
|
|
3
|
+
*
|
|
4
|
+
* On every session start / /clear / /compact, emits a compact additionalContext
|
|
5
|
+
* block containing the mandatory-tool rules and a list of tp-* subagents found
|
|
6
|
+
* in the project and user agent directories.
|
|
7
|
+
*
|
|
8
|
+
* Output contract: one JSON line on stdout, or exit 0 silent.
|
|
9
|
+
*/
|
|
10
|
+
export interface AgentEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionStartConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
showStats: boolean;
|
|
17
|
+
maxReminderTokens: number;
|
|
18
|
+
}
|
|
19
|
+
export interface HandleSessionStartOptions {
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
homeDir: string;
|
|
22
|
+
sessionStartConfig: SessionStartConfig;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Scan ~/.claude/agents/ and ./.claude/agents/ for tp-*.md agent definitions.
|
|
26
|
+
* Project directory takes precedence; duplicates (by name) are dropped.
|
|
27
|
+
*
|
|
28
|
+
* @param projectRoot - absolute path to the project root
|
|
29
|
+
* @param homeDir - home directory (injected for testability; defaults to os.homedir())
|
|
30
|
+
*/
|
|
31
|
+
export declare function scanAgents(projectRoot: string, homeDir: string): Promise<AgentEntry[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Build the reminder message combining the mandatory-tool rules and the
|
|
34
|
+
* tp-* agent list. Enforces the maxReminderTokens budget by trimming the
|
|
35
|
+
* delegating list with "… and N more" if needed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildReminderMessage(agents: AgentEntry[], maxReminderTokens: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Main handler for the hook-session-start CLI command.
|
|
40
|
+
*
|
|
41
|
+
* Returns the JSON string to write to stdout, or null for silent exit.
|
|
42
|
+
* Never throws — any error → null (fail-safe pass-through).
|
|
43
|
+
*/
|
|
44
|
+
export declare function handleSessionStart(opts: HandleSessionStartOptions): Promise<string | null>;
|
|
45
|
+
//# sourceMappingURL=session-start.d.ts.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart reminder hook — Component 2 of the enforcement layer.
|
|
3
|
+
*
|
|
4
|
+
* On every session start / /clear / /compact, emits a compact additionalContext
|
|
5
|
+
* block containing the mandatory-tool rules and a list of tp-* subagents found
|
|
6
|
+
* in the project and user agent directories.
|
|
7
|
+
*
|
|
8
|
+
* Output contract: one JSON line on stdout, or exit 0 silent.
|
|
9
|
+
*/
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { join, basename } from "node:path";
|
|
12
|
+
import { loadLatestSnapshot } from "./../handlers/session-snapshot-persist.js";
|
|
13
|
+
const SNAPSHOT_FRESH_MS = 2 * 3600 * 1000; // 2h — enough to cover compaction/restart, tight enough that a new day's unrelated work doesn't inherit yesterday's thread
|
|
14
|
+
function extractSnapshotGoal(body) {
|
|
15
|
+
const m = body.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/);
|
|
16
|
+
return m ? m[1].trim().slice(0, 100) : null;
|
|
17
|
+
}
|
|
18
|
+
// ─── Agent scanner (subtask 2.2) ─────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Parse YAML-style frontmatter from a markdown file.
|
|
21
|
+
* Only handles simple key: value pairs (no nested, no arrays).
|
|
22
|
+
* Returns an object with extracted string fields.
|
|
23
|
+
*/
|
|
24
|
+
function parseFrontmatter(content) {
|
|
25
|
+
const result = {};
|
|
26
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!match)
|
|
28
|
+
return result;
|
|
29
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
30
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
31
|
+
if (kv) {
|
|
32
|
+
result[kv[1]] = kv[2].trim();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Scan one agents directory for tp-*.md files and return parsed entries.
|
|
39
|
+
*/
|
|
40
|
+
async function scanDir(dir) {
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = await readdir(dir);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const agents = [];
|
|
49
|
+
for (const filename of names) {
|
|
50
|
+
if (!filename.startsWith("tp-") || !filename.endsWith(".md"))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(join(dir, filename), "utf-8");
|
|
54
|
+
const fm = parseFrontmatter(content);
|
|
55
|
+
const stem = basename(filename, ".md");
|
|
56
|
+
agents.push({
|
|
57
|
+
name: fm.name ?? stem,
|
|
58
|
+
description: fm.description ?? "",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Skip unreadable files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return agents;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Scan ~/.claude/agents/ and ./.claude/agents/ for tp-*.md agent definitions.
|
|
69
|
+
* Project directory takes precedence; duplicates (by name) are dropped.
|
|
70
|
+
*
|
|
71
|
+
* @param projectRoot - absolute path to the project root
|
|
72
|
+
* @param homeDir - home directory (injected for testability; defaults to os.homedir())
|
|
73
|
+
*/
|
|
74
|
+
export async function scanAgents(projectRoot, homeDir) {
|
|
75
|
+
const projectAgentsDir = join(projectRoot, ".claude", "agents");
|
|
76
|
+
const homeAgentsDir = join(homeDir, ".claude", "agents");
|
|
77
|
+
const [projectAgents, homeAgents] = await Promise.all([
|
|
78
|
+
scanDir(projectAgentsDir),
|
|
79
|
+
scanDir(homeAgentsDir),
|
|
80
|
+
]);
|
|
81
|
+
// Merge: project agents first; home agents fill in names not already present
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const merged = [];
|
|
84
|
+
for (const agent of [...projectAgents, ...homeAgents]) {
|
|
85
|
+
if (!seen.has(agent.name)) {
|
|
86
|
+
seen.add(agent.name);
|
|
87
|
+
merged.push(agent);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
// ─── Message builder (subtask 2.3) ───────────────────────────────────────────
|
|
93
|
+
const MANDATORY_BLOCK = `[token-pilot active]
|
|
94
|
+
|
|
95
|
+
MANDATORY — for code files, use these before raw Read:
|
|
96
|
+
mcp__token-pilot__smart_read(path) — structural overview
|
|
97
|
+
mcp__token-pilot__read_symbol(path, sym) — one function / class
|
|
98
|
+
mcp__token-pilot__read_for_edit(path, sym)— exact text for editing
|
|
99
|
+
mcp__token-pilot__outline(path) — symbol list
|
|
100
|
+
Raw Read allowed only with offset/limit or TOKEN_PILOT_BYPASS=1.`;
|
|
101
|
+
function estimateTokens(text) {
|
|
102
|
+
// Fast approximation: chars / 4, adjusted for whitespace
|
|
103
|
+
if (text.length === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
return Math.ceil(text.length / 4);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the reminder message combining the mandatory-tool rules and the
|
|
109
|
+
* tp-* agent list. Enforces the maxReminderTokens budget by trimming the
|
|
110
|
+
* delegating list with "… and N more" if needed.
|
|
111
|
+
*/
|
|
112
|
+
export function buildReminderMessage(agents, maxReminderTokens) {
|
|
113
|
+
const agentLines = agents.length === 0
|
|
114
|
+
? " none installed — run: npx token-pilot install-agents"
|
|
115
|
+
: agents.map((a) => ` ${a.name} — ${a.description}`).join("\n");
|
|
116
|
+
const delegatingSection = `WHEN DELEGATING — use the right token-pilot-native subagent:\n${agentLines}`;
|
|
117
|
+
const full = `${MANDATORY_BLOCK}\n\n${delegatingSection}`;
|
|
118
|
+
if (estimateTokens(full) <= maxReminderTokens) {
|
|
119
|
+
return full;
|
|
120
|
+
}
|
|
121
|
+
// Trim agent list until we fit. Distinguish "all agents trimmed due to
|
|
122
|
+
// budget" (count remained >0 in the original list) from "no agents at
|
|
123
|
+
// all" — they look the same to `trimmedAgents.length === 0` but mean
|
|
124
|
+
// very different things to the caller (one requires install-agents; the
|
|
125
|
+
// other just reports "N more hidden").
|
|
126
|
+
let trimmedAgents = [...agents];
|
|
127
|
+
while (trimmedAgents.length > 0) {
|
|
128
|
+
trimmedAgents = trimmedAgents.slice(0, trimmedAgents.length - 1);
|
|
129
|
+
const dropped = agents.length - trimmedAgents.length;
|
|
130
|
+
const trimmedLines = trimmedAgents.length === 0
|
|
131
|
+
? ` … and ${dropped} more (reminder budget exhausted)`
|
|
132
|
+
: trimmedAgents
|
|
133
|
+
.map((a) => ` ${a.name} — ${a.description}`)
|
|
134
|
+
.join("\n") + `\n … and ${dropped} more`;
|
|
135
|
+
const candidate = `${MANDATORY_BLOCK}\n\nWHEN DELEGATING — use the right token-pilot-native subagent:\n${trimmedLines}`;
|
|
136
|
+
if (estimateTokens(candidate) <= maxReminderTokens) {
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Last resort: just the mandatory block
|
|
141
|
+
return MANDATORY_BLOCK;
|
|
142
|
+
}
|
|
143
|
+
// ─── Handler (subtask 2.4) ───────────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Main handler for the hook-session-start CLI command.
|
|
146
|
+
*
|
|
147
|
+
* Returns the JSON string to write to stdout, or null for silent exit.
|
|
148
|
+
* Never throws — any error → null (fail-safe pass-through).
|
|
149
|
+
*/
|
|
150
|
+
export async function handleSessionStart(opts) {
|
|
151
|
+
try {
|
|
152
|
+
if (!opts.sessionStartConfig.enabled) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const agents = await scanAgents(opts.projectRoot, opts.homeDir);
|
|
156
|
+
let message = buildReminderMessage(agents, opts.sessionStartConfig.maxReminderTokens);
|
|
157
|
+
// TP-340: surface a fresh snapshot so the new session can resume.
|
|
158
|
+
const snap = await loadLatestSnapshot(opts.projectRoot);
|
|
159
|
+
if (snap && snap.ageMs < SNAPSHOT_FRESH_MS) {
|
|
160
|
+
const minutes = Math.round(snap.ageMs / 60000);
|
|
161
|
+
const age = minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
|
|
162
|
+
const goal = extractSnapshotGoal(snap.body);
|
|
163
|
+
const goalClause = goal ? ` (goal: "${goal}")` : "";
|
|
164
|
+
message += `\n\n[token-pilot] session_snapshot from ${age}${goalClause}. Read .token-pilot/snapshots/latest.md to resume — or ignore if unrelated.`;
|
|
165
|
+
}
|
|
166
|
+
const output = {
|
|
167
|
+
hookSpecificOutput: {
|
|
168
|
+
hookEventName: "SessionStart",
|
|
169
|
+
additionalContext: message,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return JSON.stringify(output);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Fail-safe: never block the session
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=session-start.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primary hook-summary parser: spawns the bundled `ast-index` binary with
|
|
3
|
+
* `ast-index outline <path>` and maps the returned outline entries to
|
|
4
|
+
* SignalLine[]. Returns null when the binary is unavailable or the
|
|
5
|
+
* subprocess fails — the pipeline then falls back to regex / head+tail.
|
|
6
|
+
*
|
|
7
|
+
* Short-lived: the hook process spawns the binary once per invocation.
|
|
8
|
+
* The long-running AstIndexClient used by the MCP server is intentionally
|
|
9
|
+
* NOT reused here to keep the hook's startup cost minimal.
|
|
10
|
+
*/
|
|
11
|
+
import type { HookSummary } from "./summary-types.js";
|
|
12
|
+
type ExecFn = (binary: string, args: string[], opts: {
|
|
13
|
+
timeout: number;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
export interface AstIndexSummaryOptions {
|
|
19
|
+
/** Explicit binary path. `null` means "no binary available" → returns null. Omit to resolve via findBinary. */
|
|
20
|
+
binaryPath?: string | null;
|
|
21
|
+
/** Subprocess timeout (ms). Default 4000. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Injectable spawner for tests. */
|
|
24
|
+
exec?: ExecFn;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseAstIndexSummary(content: string, filePath: string, options?: AstIndexSummaryOptions): Promise<HookSummary | null>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=summary-ast-index.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primary hook-summary parser: spawns the bundled `ast-index` binary with
|
|
3
|
+
* `ast-index outline <path>` and maps the returned outline entries to
|
|
4
|
+
* SignalLine[]. Returns null when the binary is unavailable or the
|
|
5
|
+
* subprocess fails — the pipeline then falls back to regex / head+tail.
|
|
6
|
+
*
|
|
7
|
+
* Short-lived: the hook process spawns the binary once per invocation.
|
|
8
|
+
* The long-running AstIndexClient used by the MCP server is intentionally
|
|
9
|
+
* NOT reused here to keep the hook's startup cost minimal.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { findBinary } from "../ast-index/binary-manager.js";
|
|
14
|
+
import { parseOutlineText } from "../ast-index/parser.js";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
17
|
+
const MAX_TEXT_LEN = 140;
|
|
18
|
+
function extractExtension(filePath) {
|
|
19
|
+
const lastDot = filePath.lastIndexOf(".");
|
|
20
|
+
if (lastDot === -1 || lastDot === filePath.length - 1)
|
|
21
|
+
return "";
|
|
22
|
+
const ext = filePath.slice(lastDot + 1).toLowerCase();
|
|
23
|
+
if (ext.includes("/") || ext.includes("\\"))
|
|
24
|
+
return "";
|
|
25
|
+
return ext;
|
|
26
|
+
}
|
|
27
|
+
function truncate(text) {
|
|
28
|
+
const trimmed = text.trim();
|
|
29
|
+
if (trimmed.length <= MAX_TEXT_LEN)
|
|
30
|
+
return trimmed;
|
|
31
|
+
return trimmed.slice(0, MAX_TEXT_LEN - 1) + "…";
|
|
32
|
+
}
|
|
33
|
+
function estimateTokens(text) {
|
|
34
|
+
if (text.length === 0)
|
|
35
|
+
return 0;
|
|
36
|
+
const charEstimate = Math.ceil(text.length / 4);
|
|
37
|
+
const whitespaceRatio = (text.match(/\s/g)?.length ?? 0) / text.length;
|
|
38
|
+
const adjustment = 1 - whitespaceRatio * 0.3;
|
|
39
|
+
return Math.ceil(charEstimate * adjustment);
|
|
40
|
+
}
|
|
41
|
+
const defaultExec = async (binary, args, opts) => {
|
|
42
|
+
const { stdout, stderr } = await execFileAsync(binary, args, {
|
|
43
|
+
timeout: opts.timeout,
|
|
44
|
+
});
|
|
45
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the binary path unless the caller already supplied one (including
|
|
49
|
+
* `null` to force "not available" for tests).
|
|
50
|
+
*/
|
|
51
|
+
async function resolveBinaryPath(explicit) {
|
|
52
|
+
if (explicit !== undefined)
|
|
53
|
+
return explicit;
|
|
54
|
+
try {
|
|
55
|
+
const status = await findBinary(null);
|
|
56
|
+
return status?.available ? status.path : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function flattenEntries(entries) {
|
|
63
|
+
const signals = [];
|
|
64
|
+
function walk(entry, depth) {
|
|
65
|
+
const indent = depth > 0 ? " ".repeat(depth) : "";
|
|
66
|
+
const label = entry.signature && entry.signature.length > 0
|
|
67
|
+
? entry.signature
|
|
68
|
+
: entry.name;
|
|
69
|
+
const text = truncate(`${indent}${entry.kind} ${label}`);
|
|
70
|
+
signals.push({
|
|
71
|
+
line: entry.start_line,
|
|
72
|
+
kind: entry.visibility === "public" ? "export" : "declaration",
|
|
73
|
+
text,
|
|
74
|
+
});
|
|
75
|
+
if (entry.children && entry.children.length > 0) {
|
|
76
|
+
for (const child of entry.children)
|
|
77
|
+
walk(child, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries)
|
|
81
|
+
walk(entry, 0);
|
|
82
|
+
return signals;
|
|
83
|
+
}
|
|
84
|
+
export async function parseAstIndexSummary(content, filePath, options = {}) {
|
|
85
|
+
const binaryPath = await resolveBinaryPath(options.binaryPath);
|
|
86
|
+
if (!binaryPath)
|
|
87
|
+
return null;
|
|
88
|
+
const exec = options.exec ?? defaultExec;
|
|
89
|
+
const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
90
|
+
let outlineText;
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await exec(binaryPath, ["outline", filePath], {
|
|
93
|
+
timeout,
|
|
94
|
+
});
|
|
95
|
+
outlineText = stdout;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = parseOutlineText(outlineText);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!entries || entries.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
const signals = flattenEntries(entries);
|
|
110
|
+
if (signals.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
const language = extractExtension(filePath);
|
|
113
|
+
const totalLines = content.split("\n").length;
|
|
114
|
+
const estimatedTokens = estimateTokens(content);
|
|
115
|
+
return {
|
|
116
|
+
signals,
|
|
117
|
+
totalLines,
|
|
118
|
+
estimatedTokens,
|
|
119
|
+
language,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=summary-ast-index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort head+tail summary.
|
|
3
|
+
*
|
|
4
|
+
* When both ast-index and the regex parser fail (crashed, unsupported language,
|
|
5
|
+
* malformed content), we still owe the caller *something* that respects the
|
|
6
|
+
* summary shape. This module produces a degraded HookSummary showing the first
|
|
7
|
+
* HEAD_LINES and last TAIL_LINES of the file as raw text, tagged with a note
|
|
8
|
+
* so the formatter can explain to the reader why the output is coarse.
|
|
9
|
+
*
|
|
10
|
+
* The function is intentionally total: empty input, unicode-heavy input, and
|
|
11
|
+
* absurdly large input all return a well-formed summary without throwing.
|
|
12
|
+
*/
|
|
13
|
+
import type { HookSummary } from "./summary-types.js";
|
|
14
|
+
export declare function parseHeadTailSummary(content: string, filePath: string): HookSummary;
|
|
15
|
+
//# sourceMappingURL=summary-head-tail.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort head+tail summary.
|
|
3
|
+
*
|
|
4
|
+
* When both ast-index and the regex parser fail (crashed, unsupported language,
|
|
5
|
+
* malformed content), we still owe the caller *something* that respects the
|
|
6
|
+
* summary shape. This module produces a degraded HookSummary showing the first
|
|
7
|
+
* HEAD_LINES and last TAIL_LINES of the file as raw text, tagged with a note
|
|
8
|
+
* so the formatter can explain to the reader why the output is coarse.
|
|
9
|
+
*
|
|
10
|
+
* The function is intentionally total: empty input, unicode-heavy input, and
|
|
11
|
+
* absurdly large input all return a well-formed summary without throwing.
|
|
12
|
+
*/
|
|
13
|
+
const HEAD_LINES = 40;
|
|
14
|
+
const TAIL_LINES = 20;
|
|
15
|
+
const MAX_TEXT_LEN = 140;
|
|
16
|
+
function extractExtension(filePath) {
|
|
17
|
+
const lastDot = filePath.lastIndexOf(".");
|
|
18
|
+
if (lastDot === -1 || lastDot === filePath.length - 1)
|
|
19
|
+
return "";
|
|
20
|
+
const ext = filePath.slice(lastDot + 1).toLowerCase();
|
|
21
|
+
if (ext.includes("/") || ext.includes("\\"))
|
|
22
|
+
return "";
|
|
23
|
+
return ext;
|
|
24
|
+
}
|
|
25
|
+
function truncate(text) {
|
|
26
|
+
const trimmed = text.trimEnd();
|
|
27
|
+
if (trimmed.length <= MAX_TEXT_LEN)
|
|
28
|
+
return trimmed;
|
|
29
|
+
return trimmed.slice(0, MAX_TEXT_LEN - 1) + "…";
|
|
30
|
+
}
|
|
31
|
+
function estimateTokens(text) {
|
|
32
|
+
if (text.length === 0)
|
|
33
|
+
return 0;
|
|
34
|
+
const charEstimate = Math.ceil(text.length / 4);
|
|
35
|
+
const whitespaceRatio = (text.match(/\s/g)?.length ?? 0) / text.length;
|
|
36
|
+
const adjustment = 1 - whitespaceRatio * 0.3;
|
|
37
|
+
return Math.ceil(charEstimate * adjustment);
|
|
38
|
+
}
|
|
39
|
+
export function parseHeadTailSummary(content, filePath) {
|
|
40
|
+
const lines = content.split("\n");
|
|
41
|
+
const totalLines = lines.length;
|
|
42
|
+
const language = extractExtension(filePath);
|
|
43
|
+
const estimatedTokens = estimateTokens(content);
|
|
44
|
+
// When the file fits within HEAD_LINES + TAIL_LINES we include everything
|
|
45
|
+
// and omit the degradation note — no truncation actually happened.
|
|
46
|
+
if (totalLines <= HEAD_LINES + TAIL_LINES) {
|
|
47
|
+
const signals = lines.map((line, i) => ({
|
|
48
|
+
line: i + 1,
|
|
49
|
+
kind: "raw",
|
|
50
|
+
text: truncate(line),
|
|
51
|
+
}));
|
|
52
|
+
return {
|
|
53
|
+
signals,
|
|
54
|
+
totalLines,
|
|
55
|
+
estimatedTokens,
|
|
56
|
+
language,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const head = lines.slice(0, HEAD_LINES).map((line, i) => ({
|
|
60
|
+
line: i + 1,
|
|
61
|
+
kind: "raw",
|
|
62
|
+
text: truncate(line),
|
|
63
|
+
}));
|
|
64
|
+
const tailStart = totalLines - TAIL_LINES;
|
|
65
|
+
const tail = lines.slice(tailStart).map((line, i) => ({
|
|
66
|
+
line: tailStart + i + 1,
|
|
67
|
+
kind: "raw",
|
|
68
|
+
text: truncate(line),
|
|
69
|
+
}));
|
|
70
|
+
return {
|
|
71
|
+
signals: [...head, ...tail],
|
|
72
|
+
totalLines,
|
|
73
|
+
estimatedTokens,
|
|
74
|
+
language,
|
|
75
|
+
note: `parser unavailable — showing head+tail (first ${HEAD_LINES} and last ${TAIL_LINES} lines of ${totalLines})`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=summary-head-tail.js.map
|