token-pilot 0.19.1 → 0.22.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 (94) hide show
  1. package/.claude-plugin/hooks/hooks.json +21 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +736 -580
  4. package/README.md +172 -315
  5. package/dist/agents/tp-commit-writer.md +41 -0
  6. package/dist/agents/tp-dead-code-finder.md +43 -0
  7. package/dist/agents/tp-debugger.md +45 -0
  8. package/dist/agents/tp-impact-analyzer.md +44 -0
  9. package/dist/agents/tp-migration-scout.md +43 -0
  10. package/dist/agents/tp-onboard.md +40 -0
  11. package/dist/agents/tp-pr-reviewer.md +41 -0
  12. package/dist/agents/tp-refactor-planner.md +42 -0
  13. package/dist/agents/tp-run.md +48 -0
  14. package/dist/agents/tp-test-triage.md +40 -0
  15. package/dist/agents/tp-test-writer.md +46 -0
  16. package/dist/ast-index/binary-manager.d.ts +3 -3
  17. package/dist/ast-index/binary-manager.js +74 -11
  18. package/dist/ast-index/client.d.ts +5 -1
  19. package/dist/ast-index/client.js +9 -2
  20. package/dist/cli/agent-frontmatter.d.ts +48 -0
  21. package/dist/cli/agent-frontmatter.js +189 -0
  22. package/dist/cli/bless-agents.d.ts +65 -0
  23. package/dist/cli/bless-agents.js +307 -0
  24. package/dist/cli/claudeignore.d.ts +33 -0
  25. package/dist/cli/claudeignore.js +88 -0
  26. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  27. package/dist/cli/claudemd-hygiene.js +43 -0
  28. package/dist/cli/doctor-drift.d.ts +31 -0
  29. package/dist/cli/doctor-drift.js +130 -0
  30. package/dist/cli/doctor-env-check.d.ts +25 -0
  31. package/dist/cli/doctor-env-check.js +91 -0
  32. package/dist/cli/install-agents.d.ts +108 -0
  33. package/dist/cli/install-agents.js +402 -0
  34. package/dist/cli/save-doc.d.ts +42 -0
  35. package/dist/cli/save-doc.js +145 -0
  36. package/dist/cli/scan-agents.d.ts +46 -0
  37. package/dist/cli/scan-agents.js +227 -0
  38. package/dist/cli/stats.d.ts +36 -0
  39. package/dist/cli/stats.js +131 -0
  40. package/dist/cli/unbless-agents.d.ts +33 -0
  41. package/dist/cli/unbless-agents.js +85 -0
  42. package/dist/cli/uninstall-agents.d.ts +36 -0
  43. package/dist/cli/uninstall-agents.js +117 -0
  44. package/dist/config/defaults.d.ts +1 -1
  45. package/dist/config/defaults.js +14 -8
  46. package/dist/config/loader.d.ts +1 -1
  47. package/dist/config/loader.js +105 -11
  48. package/dist/core/context-registry.d.ts +16 -1
  49. package/dist/core/context-registry.js +60 -28
  50. package/dist/core/event-log.d.ts +79 -0
  51. package/dist/core/event-log.js +190 -0
  52. package/dist/core/session-registry.d.ts +43 -0
  53. package/dist/core/session-registry.js +113 -0
  54. package/dist/core/session-savings.d.ts +19 -0
  55. package/dist/core/session-savings.js +60 -0
  56. package/dist/handlers/session-budget.d.ts +32 -0
  57. package/dist/handlers/session-budget.js +61 -0
  58. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  59. package/dist/handlers/session-snapshot-persist.js +76 -0
  60. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  61. package/dist/hooks/adaptive-threshold.js +46 -0
  62. package/dist/hooks/format-deny-message.d.ts +21 -0
  63. package/dist/hooks/format-deny-message.js +147 -0
  64. package/dist/hooks/installer.d.ts +7 -1
  65. package/dist/hooks/installer.js +175 -55
  66. package/dist/hooks/path-safety.d.ts +16 -0
  67. package/dist/hooks/path-safety.js +34 -0
  68. package/dist/hooks/post-bash.d.ts +46 -0
  69. package/dist/hooks/post-bash.js +77 -0
  70. package/dist/hooks/session-start.d.ts +45 -0
  71. package/dist/hooks/session-start.js +179 -0
  72. package/dist/hooks/summary-ast-index.d.ts +28 -0
  73. package/dist/hooks/summary-ast-index.js +122 -0
  74. package/dist/hooks/summary-head-tail.d.ts +15 -0
  75. package/dist/hooks/summary-head-tail.js +78 -0
  76. package/dist/hooks/summary-pipeline.d.ts +35 -0
  77. package/dist/hooks/summary-pipeline.js +63 -0
  78. package/dist/hooks/summary-regex.d.ts +14 -0
  79. package/dist/hooks/summary-regex.js +130 -0
  80. package/dist/hooks/summary-types.d.ts +29 -0
  81. package/dist/hooks/summary-types.js +9 -0
  82. package/dist/index.d.ts +15 -3
  83. package/dist/index.js +508 -131
  84. package/dist/integration/context-mode-detector.d.ts +7 -1
  85. package/dist/integration/context-mode-detector.js +51 -15
  86. package/dist/server/tool-definitions.d.ts +149 -0
  87. package/dist/server/tool-definitions.js +424 -202
  88. package/dist/server.d.ts +1 -1
  89. package/dist/server.js +456 -179
  90. package/dist/templates/agent-builder.d.ts +49 -0
  91. package/dist/templates/agent-builder.js +104 -0
  92. package/dist/types.d.ts +38 -4
  93. package/package.json +89 -87
  94. package/skills/stats/SKILL.md +13 -2
@@ -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
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Summary-generation pipeline for the deny-enhanced hook.
3
+ *
4
+ * Tries three parsers in order — ast-index subprocess → regex → head+tail —
5
+ * and returns the first result with non-empty signals. If every parser
6
+ * fails to produce useful output (or throws), the pipeline reports
7
+ * `pass-through`, at which point the handler lets the original Read
8
+ * proceed unmodified rather than emitting an empty / misleading denial.
9
+ *
10
+ * Each parser is injectable for tests. The default wiring uses the
11
+ * real implementations from sibling modules.
12
+ */
13
+ import type { HookSummary } from "./summary-types.js";
14
+ export type PipelineTier = "ast-index" | "regex" | "head-tail";
15
+ export type PipelineResult = {
16
+ kind: "summary";
17
+ summary: HookSummary;
18
+ tier: PipelineTier;
19
+ } | {
20
+ kind: "pass-through";
21
+ reason: string;
22
+ };
23
+ type AstIndexFn = (content: string, filePath: string) => Promise<HookSummary | null>;
24
+ type SyncSummaryFn = (content: string, filePath: string) => HookSummary;
25
+ export interface PipelineOptions {
26
+ /** Primary parser — ast-index subprocess. Returns null on soft fail. */
27
+ astIndex?: AstIndexFn;
28
+ /** Fallback parser — regex. Expected to always return a HookSummary. */
29
+ regex?: SyncSummaryFn;
30
+ /** Last-resort parser — head+tail. Expected to always return a HookSummary. */
31
+ headTail?: SyncSummaryFn;
32
+ }
33
+ export declare function runSummaryPipeline(content: string, filePath: string, options?: PipelineOptions): Promise<PipelineResult>;
34
+ export {};
35
+ //# sourceMappingURL=summary-pipeline.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Summary-generation pipeline for the deny-enhanced hook.
3
+ *
4
+ * Tries three parsers in order — ast-index subprocess → regex → head+tail —
5
+ * and returns the first result with non-empty signals. If every parser
6
+ * fails to produce useful output (or throws), the pipeline reports
7
+ * `pass-through`, at which point the handler lets the original Read
8
+ * proceed unmodified rather than emitting an empty / misleading denial.
9
+ *
10
+ * Each parser is injectable for tests. The default wiring uses the
11
+ * real implementations from sibling modules.
12
+ */
13
+ import { parseAstIndexSummary } from "./summary-ast-index.js";
14
+ import { parseRegexSummary } from "./summary-regex.js";
15
+ import { parseHeadTailSummary } from "./summary-head-tail.js";
16
+ const defaultAstIndex = async (content, filePath) => parseAstIndexSummary(content, filePath);
17
+ const defaultRegex = parseRegexSummary;
18
+ const defaultHeadTail = parseHeadTailSummary;
19
+ function hasSignals(summary) {
20
+ return !!summary && summary.signals.length > 0;
21
+ }
22
+ async function tryAsync(fn) {
23
+ try {
24
+ return await fn();
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function trySync(fn) {
31
+ try {
32
+ return fn();
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ export async function runSummaryPipeline(content, filePath, options = {}) {
39
+ const astIndex = options.astIndex ?? defaultAstIndex;
40
+ const regex = options.regex ?? defaultRegex;
41
+ const headTail = options.headTail ?? defaultHeadTail;
42
+ // Tier 1 — ast-index. Soft-fails on null or throw.
43
+ const astResult = await tryAsync(() => astIndex(content, filePath));
44
+ if (hasSignals(astResult)) {
45
+ return { kind: "summary", summary: astResult, tier: "ast-index" };
46
+ }
47
+ // Tier 2 — regex. Empty signals means "nothing useful here, try next".
48
+ const regexResult = trySync(() => regex(content, filePath));
49
+ if (hasSignals(regexResult)) {
50
+ return { kind: "summary", summary: regexResult, tier: "regex" };
51
+ }
52
+ // Tier 3 — head+tail. Always produces *something*, unless the parser
53
+ // itself crashes (in which case we pass-through).
54
+ const headTailResult = trySync(() => headTail(content, filePath));
55
+ if (hasSignals(headTailResult)) {
56
+ return { kind: "summary", summary: headTailResult, tier: "head-tail" };
57
+ }
58
+ return {
59
+ kind: "pass-through",
60
+ reason: "all parsers returned empty or threw",
61
+ };
62
+ }
63
+ //# sourceMappingURL=summary-pipeline.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * In-process regex-based structural summary parser.
3
+ *
4
+ * Fallback when the bundled ast-index binary is unavailable. Intentionally
5
+ * coarse: extracts imports, exports, and major top-level declarations per
6
+ * language using line-oriented regex. Never throws — worst case returns
7
+ * empty signals.
8
+ *
9
+ * Used by the hook summary pipeline (Phase 1 subtask 1.6).
10
+ */
11
+ import type { HookSummary, SignalKind, SignalLine } from "./summary-types.js";
12
+ export type { HookSummary, SignalKind, SignalLine };
13
+ export declare function parseRegexSummary(content: string, filePath: string): HookSummary;
14
+ //# sourceMappingURL=summary-regex.d.ts.map
@@ -0,0 +1,130 @@
1
+ /**
2
+ * In-process regex-based structural summary parser.
3
+ *
4
+ * Fallback when the bundled ast-index binary is unavailable. Intentionally
5
+ * coarse: extracts imports, exports, and major top-level declarations per
6
+ * language using line-oriented regex. Never throws — worst case returns
7
+ * empty signals.
8
+ *
9
+ * Used by the hook summary pipeline (Phase 1 subtask 1.6).
10
+ */
11
+ const MAX_TEXT_LEN = 140;
12
+ const EXTENSIONS = {
13
+ ts: tsJsPattern(),
14
+ tsx: tsJsPattern(),
15
+ js: tsJsPattern(),
16
+ jsx: tsJsPattern(),
17
+ mjs: tsJsPattern(),
18
+ cjs: tsJsPattern(),
19
+ py: pythonPattern(),
20
+ go: goPattern(),
21
+ rs: rustPattern(),
22
+ };
23
+ function tsJsPattern() {
24
+ return {
25
+ // Covers ES modules and CommonJS: `import ...`, `const x = require(...)`
26
+ import: /^\s*(import\s|const\s+\w+\s*=\s*require\s*\()/,
27
+ // export keyword, module.exports, exports.foo =
28
+ export: /^\s*(export\s|module\.exports\s*=|exports\.\w+\s*=)/,
29
+ // top-level declarations not prefixed with export — function/class/interface/type/enum
30
+ declaration: /^\s*(async\s+)?(function|class|interface|type|enum)\s+\w+/,
31
+ };
32
+ }
33
+ function pythonPattern() {
34
+ return {
35
+ import: /^\s*(import\s|from\s+\S+\s+import\s)/,
36
+ declaration: /^\s*(async\s+)?(def\s+\w+|class\s+\w+)/,
37
+ };
38
+ }
39
+ function goPattern() {
40
+ return {
41
+ import: /^\s*import\s/,
42
+ declaration: /^\s*(func\s|type\s+\w+\s+(struct|interface|func))/,
43
+ };
44
+ }
45
+ function rustPattern() {
46
+ return {
47
+ import: /^\s*use\s/,
48
+ export: /^\s*pub\s+(fn|struct|trait|enum|type|mod|const|static)\s/,
49
+ declaration: /^\s*(async\s+)?(fn|struct|trait|enum|type|mod)\s+\w+/,
50
+ };
51
+ }
52
+ /**
53
+ * Derive the lower-case extension for a file path.
54
+ * Returns an empty string if the path has no dot.
55
+ */
56
+ function extractExtension(filePath) {
57
+ const lastDot = filePath.lastIndexOf(".");
58
+ if (lastDot === -1 || lastDot === filePath.length - 1)
59
+ return "";
60
+ const ext = filePath.slice(lastDot + 1).toLowerCase();
61
+ // Guard against extensions containing path separators (e.g., "/foo.d/bar").
62
+ if (ext.includes("/") || ext.includes("\\"))
63
+ return "";
64
+ return ext;
65
+ }
66
+ function truncate(text) {
67
+ const trimmed = text.trim();
68
+ if (trimmed.length <= MAX_TEXT_LEN)
69
+ return trimmed;
70
+ return trimmed.slice(0, MAX_TEXT_LEN - 1) + "…";
71
+ }
72
+ /**
73
+ * Very rough token estimate mirroring the project-wide heuristic
74
+ * (see src/core/token-estimator.ts). Duplicated here to avoid a hard
75
+ * dependency in the hook hot-path.
76
+ */
77
+ function estimateTokens(text) {
78
+ if (text.length === 0)
79
+ return 0;
80
+ const charEstimate = Math.ceil(text.length / 4);
81
+ const whitespaceRatio = (text.match(/\s/g)?.length ?? 0) / text.length;
82
+ const adjustment = 1 - whitespaceRatio * 0.3;
83
+ return Math.ceil(charEstimate * adjustment);
84
+ }
85
+ export function parseRegexSummary(content, filePath) {
86
+ const language = extractExtension(filePath);
87
+ const totalLines = content.split("\n").length;
88
+ const estimatedTokens = estimateTokens(content);
89
+ const patterns = EXTENSIONS[language];
90
+ if (!patterns) {
91
+ return {
92
+ signals: [],
93
+ totalLines,
94
+ estimatedTokens,
95
+ language,
96
+ };
97
+ }
98
+ const lines = content.split("\n");
99
+ const signals = [];
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i];
102
+ if (!line || line.trim().length === 0)
103
+ continue;
104
+ // Classification order: import → export → declaration. First match wins.
105
+ let kind = null;
106
+ if (patterns.import?.test(line)) {
107
+ kind = "import";
108
+ }
109
+ else if (patterns.export?.test(line)) {
110
+ kind = "export";
111
+ }
112
+ else if (patterns.declaration?.test(line)) {
113
+ kind = "declaration";
114
+ }
115
+ if (kind !== null) {
116
+ signals.push({
117
+ line: i + 1,
118
+ kind,
119
+ text: truncate(line),
120
+ });
121
+ }
122
+ }
123
+ return {
124
+ signals,
125
+ totalLines,
126
+ estimatedTokens,
127
+ language,
128
+ };
129
+ }
130
+ //# sourceMappingURL=summary-regex.js.map