token-pilot 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -4
  3. package/README.md +24 -0
  4. package/agents/tp-api-surface-tracker.md +1 -1
  5. package/agents/tp-audit-scanner.md +1 -1
  6. package/agents/tp-commit-writer.md +1 -1
  7. package/agents/tp-context-engineer.md +1 -1
  8. package/agents/tp-dead-code-finder.md +1 -1
  9. package/agents/tp-debugger.md +1 -1
  10. package/agents/tp-dep-health.md +1 -1
  11. package/agents/tp-doc-writer.md +1 -1
  12. package/agents/tp-history-explorer.md +1 -1
  13. package/agents/tp-impact-analyzer.md +1 -1
  14. package/agents/tp-incident-timeline.md +1 -1
  15. package/agents/tp-incremental-builder.md +1 -1
  16. package/agents/tp-migration-scout.md +1 -1
  17. package/agents/tp-onboard.md +1 -1
  18. package/agents/tp-performance-profiler.md +1 -1
  19. package/agents/tp-pr-reviewer.md +1 -1
  20. package/agents/tp-refactor-planner.md +1 -1
  21. package/agents/tp-review-impact.md +1 -1
  22. package/agents/tp-run.md +1 -1
  23. package/agents/tp-session-restorer.md +1 -1
  24. package/agents/tp-ship-coordinator.md +1 -1
  25. package/agents/tp-spec-writer.md +1 -1
  26. package/agents/tp-test-coverage-gapper.md +1 -1
  27. package/agents/tp-test-triage.md +1 -1
  28. package/agents/tp-test-writer.md +1 -1
  29. package/dist/ast-index/client.d.ts +17 -2
  30. package/dist/ast-index/client.js +233 -107
  31. package/dist/core/edit-prep-state.d.ts +42 -0
  32. package/dist/core/edit-prep-state.js +108 -0
  33. package/dist/handlers/explore-area.js +6 -1
  34. package/dist/handlers/read-for-edit.d.ts +5 -5
  35. package/dist/handlers/read-for-edit.js +188 -110
  36. package/dist/hooks/installer.js +18 -0
  37. package/dist/hooks/pre-bash.d.ts +9 -0
  38. package/dist/hooks/pre-bash.js +48 -0
  39. package/dist/hooks/pre-edit.d.ts +69 -0
  40. package/dist/hooks/pre-edit.js +104 -0
  41. package/dist/hooks/pre-grep.d.ts +10 -0
  42. package/dist/hooks/pre-grep.js +38 -2
  43. package/dist/index.d.ts +30 -0
  44. package/dist/index.js +83 -20
  45. package/dist/server/tool-definitions.js +18 -6
  46. package/dist/server.js +21 -5
  47. package/docs/installation.md +27 -1
  48. package/hooks/hooks.json +18 -0
  49. package/package.json +1 -1
  50. package/start.sh +19 -9
@@ -1,121 +1,184 @@
1
- import { readFile, stat, access } from 'node:fs/promises';
2
- import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
- import { createHash } from 'node:crypto';
5
- import { relative, join, extname } from 'node:path';
6
- import { parseMarkdownSections, findSection, extractSectionContent } from './markdown-sections.js';
7
- import { parseYamlSections, findYamlSection, extractYamlSectionContent } from './yaml-sections.js';
8
- import { parseJsonSections, findJsonSection, extractJsonSectionContent } from './json-sections.js';
9
- import { parseCsvOutline, parseCsvSectionSpec, extractCsvSectionContent } from './csv-sections.js';
10
- import { estimateTokens } from '../core/token-estimator.js';
11
- import { resolveSafePath } from '../core/validation.js';
12
- import { assessConfidence, formatConfidence } from '../core/confidence.js';
1
+ import { readFile, stat, access } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { createHash } from "node:crypto";
5
+ import { relative, join, extname } from "node:path";
6
+ import { parseMarkdownSections, findSection, extractSectionContent, } from "./markdown-sections.js";
7
+ import { parseYamlSections, findYamlSection, extractYamlSectionContent, } from "./yaml-sections.js";
8
+ import { parseJsonSections, findJsonSection, extractJsonSectionContent, } from "./json-sections.js";
9
+ import { parseCsvOutline, parseCsvSectionSpec, extractCsvSectionContent, } from "./csv-sections.js";
10
+ import { estimateTokens } from "../core/token-estimator.js";
11
+ import { resolveSafePath } from "../core/validation.js";
12
+ import { markEditPrepared } from "../core/edit-prep-state.js";
13
+ import { assessConfidence, formatConfidence } from "../core/confidence.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  const DEFAULT_CONTEXT = 5;
15
16
  export async function handleReadForEdit(args, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex, options) {
16
17
  const absPath = resolveSafePath(projectRoot, args.path);
18
+ // Record intent BEFORE any downstream failure: if the agent explicitly
19
+ // called read_for_edit on this path, they have declared they want to
20
+ // edit it next. The PreToolUse:Edit hook reads this to decide whether
21
+ // to allow or deny the follow-up Edit. Best-effort — never throws.
22
+ markEditPrepared(projectRoot, absPath);
17
23
  const ctx = args.context ?? DEFAULT_CONTEXT;
18
24
  // Section mode: markdown/YAML section extraction for edit
19
25
  if (args.section) {
20
26
  const ext = extname(absPath).toLowerCase();
21
- const supportedExts = new Set(['.md', '.markdown', '.yaml', '.yml', '.json', '.csv']);
27
+ const supportedExts = new Set([
28
+ ".md",
29
+ ".markdown",
30
+ ".yaml",
31
+ ".yml",
32
+ ".json",
33
+ ".csv",
34
+ ]);
22
35
  if (!supportedExts.has(ext)) {
23
36
  return {
24
- content: [{
25
- type: 'text',
37
+ content: [
38
+ {
39
+ type: "text",
26
40
  text: `"section" parameter only works with Markdown, YAML, or JSON files. Got: ${ext}. Use "symbol" for code files.`,
27
- }],
41
+ },
42
+ ],
28
43
  };
29
44
  }
30
- const fileContent = await readFile(absPath, 'utf-8');
31
- const fileLines = fileContent.split('\n');
45
+ const fileContent = await readFile(absPath, "utf-8");
46
+ const fileLines = fileContent.split("\n");
32
47
  // Cache file in fileCache for read_diff baseline
33
48
  if (!fileCache.get(absPath)) {
34
49
  const fileStat = await stat(absPath);
35
- const hash = createHash('sha256').update(fileContent).digest('hex');
36
- const language = ext === '.csv' ? 'csv' : ext === '.json' ? 'json' : (ext === '.md' || ext === '.markdown') ? 'markdown' : 'yaml';
50
+ const hash = createHash("sha256").update(fileContent).digest("hex");
51
+ const language = ext === ".csv"
52
+ ? "csv"
53
+ : ext === ".json"
54
+ ? "json"
55
+ : ext === ".md" || ext === ".markdown"
56
+ ? "markdown"
57
+ : "yaml";
37
58
  fileCache.set(absPath, {
38
- structure: { path: absPath, language, meta: { lines: fileLines.length, bytes: fileContent.length, lastModified: fileStat.mtimeMs, contentHash: hash }, imports: [], exports: [], symbols: [] },
39
- content: fileContent, lines: fileLines, mtime: fileStat.mtimeMs, hash, lastAccess: Date.now(),
59
+ structure: {
60
+ path: absPath,
61
+ language,
62
+ meta: {
63
+ lines: fileLines.length,
64
+ bytes: fileContent.length,
65
+ lastModified: fileStat.mtimeMs,
66
+ contentHash: hash,
67
+ },
68
+ imports: [],
69
+ exports: [],
70
+ symbols: [],
71
+ },
72
+ content: fileContent,
73
+ lines: fileLines,
74
+ mtime: fileStat.mtimeMs,
75
+ hash,
76
+ lastAccess: Date.now(),
40
77
  });
41
78
  }
42
79
  let sectionResult = null;
43
- if (ext === '.md' || ext === '.markdown') {
80
+ if (ext === ".md" || ext === ".markdown") {
44
81
  const sections = parseMarkdownSections(fileContent);
45
82
  const section = findSection(sections, args.section);
46
83
  if (!section) {
47
- const available = sections.map(s => s.heading).join(', ');
84
+ const available = sections.map((s) => s.heading).join(", ");
48
85
  return {
49
- content: [{
50
- type: 'text',
86
+ content: [
87
+ {
88
+ type: "text",
51
89
  text: `Section "${args.section}" not found in ${args.path}.\nAvailable: ${available}`,
52
- }],
90
+ },
91
+ ],
53
92
  };
54
93
  }
55
- const hashes = '#'.repeat(section.level);
56
- sectionResult = { ...section, rawContent: extractSectionContent(fileLines, section), label: `${hashes} ${section.heading}` };
94
+ const hashes = "#".repeat(section.level);
95
+ sectionResult = {
96
+ ...section,
97
+ rawContent: extractSectionContent(fileLines, section),
98
+ label: `${hashes} ${section.heading}`,
99
+ };
57
100
  }
58
- else if (ext === '.yaml' || ext === '.yml') {
101
+ else if (ext === ".yaml" || ext === ".yml") {
59
102
  const sections = parseYamlSections(fileContent);
60
103
  const section = findYamlSection(sections, args.section);
61
104
  if (!section) {
62
- const available = sections.map(s => s.heading).join(', ');
105
+ const available = sections.map((s) => s.heading).join(", ");
63
106
  return {
64
- content: [{
65
- type: 'text',
107
+ content: [
108
+ {
109
+ type: "text",
66
110
  text: `Section "${args.section}" not found in ${args.path}.\nAvailable: ${available}`,
67
- }],
111
+ },
112
+ ],
68
113
  };
69
114
  }
70
- sectionResult = { ...section, rawContent: extractYamlSectionContent(fileLines, section), label: section.heading };
115
+ sectionResult = {
116
+ ...section,
117
+ rawContent: extractYamlSectionContent(fileLines, section),
118
+ label: section.heading,
119
+ };
71
120
  }
72
- else if (ext === '.json') {
121
+ else if (ext === ".json") {
73
122
  const sections = parseJsonSections(fileContent);
74
123
  const section = findJsonSection(sections, args.section);
75
124
  if (!section) {
76
- const available = sections.map(s => s.heading).join(', ');
125
+ const available = sections.map((s) => s.heading).join(", ");
77
126
  return {
78
- content: [{
79
- type: 'text',
127
+ content: [
128
+ {
129
+ type: "text",
80
130
  text: `Section "${args.section}" not found in ${args.path}.\nAvailable: ${available}`,
81
- }],
131
+ },
132
+ ],
82
133
  };
83
134
  }
84
- sectionResult = { ...section, rawContent: extractJsonSectionContent(fileLines, section), label: section.heading };
135
+ sectionResult = {
136
+ ...section,
137
+ rawContent: extractJsonSectionContent(fileLines, section),
138
+ label: section.heading,
139
+ };
85
140
  }
86
- else if (ext === '.csv') {
141
+ else if (ext === ".csv") {
87
142
  const outline = parseCsvOutline(fileContent);
88
143
  const section = parseCsvSectionSpec(args.section, outline.rowCount);
89
144
  if (!section) {
90
145
  return {
91
- content: [{
92
- type: 'text',
146
+ content: [
147
+ {
148
+ type: "text",
93
149
  text: `Invalid section "${args.section}" for CSV. Use: rows:1-50 or row:5\nTotal rows: ${outline.rowCount}`,
94
- }],
150
+ },
151
+ ],
95
152
  };
96
153
  }
97
- sectionResult = { ...section, rawContent: extractCsvSectionContent(fileLines, section), label: section.heading };
154
+ sectionResult = {
155
+ ...section,
156
+ rawContent: extractCsvSectionContent(fileLines, section),
157
+ label: section.heading,
158
+ };
98
159
  }
99
160
  if (!sectionResult) {
100
- return { content: [{ type: 'text', text: `Unsupported file type: ${ext}` }] };
161
+ return {
162
+ content: [{ type: "text", text: `Unsupported file type: ${ext}` }],
163
+ };
101
164
  }
102
165
  const outputLines = [
103
166
  `FILE: ${args.path}`,
104
167
  `EDIT SECTION: ${sectionResult.label} [L${sectionResult.startLine}-${sectionResult.endLine}] (${sectionResult.lineCount} lines)`,
105
- '',
168
+ "",
106
169
  sectionResult.rawContent,
107
- '',
170
+ "",
108
171
  `AFTER EDIT: Use read_diff("${args.path}") to verify changes (90% cheaper than re-reading).`,
109
172
  ];
110
- const output = outputLines.join('\n');
173
+ const output = outputLines.join("\n");
111
174
  const tokens = estimateTokens(output);
112
175
  contextRegistry.trackLoad(absPath, {
113
- type: 'range',
176
+ type: "range",
114
177
  startLine: sectionResult.startLine,
115
178
  endLine: sectionResult.endLine,
116
179
  tokens,
117
180
  });
118
- return { content: [{ type: 'text', text: output }] };
181
+ return { content: [{ type: "text", text: output }] };
119
182
  }
120
183
  // Get file content — also cache for read_diff baseline
121
184
  const cached = fileCache.get(absPath);
@@ -124,16 +187,21 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
124
187
  lines = cached.lines;
125
188
  }
126
189
  else {
127
- const content = await readFile(absPath, 'utf-8');
128
- lines = content.split('\n');
190
+ const content = await readFile(absPath, "utf-8");
191
+ lines = content.split("\n");
129
192
  // Cache the full file so read_diff can use it as baseline after edits
130
193
  const fileStat = await stat(absPath);
131
- const hash = createHash('sha256').update(content).digest('hex');
194
+ const hash = createHash("sha256").update(content).digest("hex");
132
195
  fileCache.set(absPath, {
133
196
  structure: {
134
197
  path: absPath,
135
- language: 'unknown',
136
- meta: { lines: lines.length, bytes: content.length, lastModified: fileStat.mtimeMs, contentHash: hash },
198
+ language: "unknown",
199
+ meta: {
200
+ lines: lines.length,
201
+ bytes: content.length,
202
+ lastModified: fileStat.mtimeMs,
203
+ contentHash: hash,
204
+ },
137
205
  imports: [],
138
206
  exports: [],
139
207
  symbols: [],
@@ -149,19 +217,19 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
149
217
  if (args.symbols && args.symbols.length > 0) {
150
218
  let structure = cached?.structure;
151
219
  if (!structure) {
152
- structure = await astIndex.outline(absPath) ?? undefined;
220
+ structure = (await astIndex.outline(absPath)) ?? undefined;
153
221
  }
154
222
  const sections = [];
155
223
  sections.push(`--- EDIT CONTEXT (BATCH: ${args.symbols.length} symbols) ---`);
156
224
  sections.push(`FILE: ${args.path}`);
157
- sections.push('');
225
+ sections.push("");
158
226
  let resolved_count = 0;
159
227
  for (let i = 0; i < args.symbols.length; i++) {
160
228
  const symName = args.symbols[i];
161
229
  const resolved = await symbolResolver.resolve(symName, structure);
162
230
  if (!resolved) {
163
231
  sections.push(`=== SYMBOL ${i + 1}/${args.symbols.length}: ${symName} — NOT FOUND ===`);
164
- sections.push('');
232
+ sections.push("");
165
233
  continue;
166
234
  }
167
235
  resolved_count++;
@@ -180,22 +248,22 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
180
248
  }
181
249
  const rangeStart = Math.max(1, effStart - ctx);
182
250
  const rangeEnd = Math.min(lines.length, effEnd + ctx);
183
- const rawCode = lines.slice(rangeStart - 1, rangeEnd).join('\n');
251
+ const rawCode = lines.slice(rangeStart - 1, rangeEnd).join("\n");
184
252
  sections.push(`=== SYMBOL ${i + 1}/${args.symbols.length}: ${label} ===`);
185
- sections.push('');
253
+ sections.push("");
186
254
  sections.push(rawCode);
187
- sections.push('');
255
+ sections.push("");
188
256
  // Track each symbol
189
257
  contextRegistry.trackLoad(absPath, {
190
- type: 'symbol',
258
+ type: "symbol",
191
259
  symbolName: symName,
192
260
  startLine: rangeStart,
193
261
  endLine: rangeEnd,
194
262
  tokens: estimateTokens(rawCode),
195
263
  });
196
264
  }
197
- sections.push('--- END EDIT CONTEXT ---');
198
- sections.push('');
265
+ sections.push("--- END EDIT CONTEXT ---");
266
+ sections.push("");
199
267
  sections.push(`To edit: use exact text from each section as old_string in Edit tool.`);
200
268
  if (resolved_count < args.symbols.length) {
201
269
  sections.push(`WARNING: ${args.symbols.length - resolved_count} symbol(s) not found. Use smart_read to see available symbols.`);
@@ -207,8 +275,8 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
207
275
  astAvailable: true,
208
276
  });
209
277
  sections.push(formatConfidence(confidenceMeta));
210
- const output = sections.join('\n');
211
- return { content: [{ type: 'text', text: output }] };
278
+ const output = sections.join("\n");
279
+ return { content: [{ type: "text", text: output }] };
212
280
  }
213
281
  let startLine;
214
282
  let endLine;
@@ -217,15 +285,17 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
217
285
  // Resolve symbol via AST
218
286
  let structure = cached?.structure;
219
287
  if (!structure) {
220
- structure = await astIndex.outline(absPath) ?? undefined;
288
+ structure = (await astIndex.outline(absPath)) ?? undefined;
221
289
  }
222
290
  const resolved = await symbolResolver.resolve(args.symbol, structure);
223
291
  if (!resolved) {
224
292
  return {
225
- content: [{
226
- type: 'text',
293
+ content: [
294
+ {
295
+ type: "text",
227
296
  text: `Symbol "${args.symbol}" not found in ${args.path}.\nHINT: Use smart_read("${args.path}") to see available symbols.`,
228
- }],
297
+ },
298
+ ],
229
299
  };
230
300
  }
231
301
  const symbolLines = resolved.endLine - resolved.startLine + 1;
@@ -243,10 +313,12 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
243
313
  else if (args.line) {
244
314
  if (args.line < 1 || args.line > lines.length) {
245
315
  return {
246
- content: [{
247
- type: 'text',
316
+ content: [
317
+ {
318
+ type: "text",
248
319
  text: `Line ${args.line} out of range (file has ${lines.length} lines).`,
249
- }],
320
+ },
321
+ ],
250
322
  };
251
323
  }
252
324
  startLine = args.line;
@@ -255,10 +327,12 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
255
327
  }
256
328
  else {
257
329
  return {
258
- content: [{
259
- type: 'text',
330
+ content: [
331
+ {
332
+ type: "text",
260
333
  text: 'Either "symbol" or "line" must be provided.',
261
- }],
334
+ },
335
+ ],
262
336
  };
263
337
  }
264
338
  // Apply context padding
@@ -266,17 +340,17 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
266
340
  const rangeEnd = Math.min(lines.length, endLine + ctx);
267
341
  const rangeCount = rangeEnd - rangeStart + 1;
268
342
  // Extract RAW code (no line number prefixes — ready for Edit old_string)
269
- const rawCode = lines.slice(rangeStart - 1, rangeEnd).join('\n');
343
+ const rawCode = lines.slice(rangeStart - 1, rangeEnd).join("\n");
270
344
  const outputLines = [
271
345
  `--- EDIT CONTEXT ---`,
272
346
  `FILE: ${args.path}`,
273
347
  `TARGET: ${targetLabel}`,
274
348
  `SHOWING: L${rangeStart}-${rangeEnd} (${rangeCount} lines)`,
275
- '',
349
+ "",
276
350
  rawCode,
277
- '',
351
+ "",
278
352
  `--- END EDIT CONTEXT ---`,
279
- '',
353
+ "",
280
354
  `To edit: use exact text above as old_string in Edit tool.`,
281
355
  `For Read requirement: Read("${args.path}", offset=${rangeStart}, limit=${rangeCount})`,
282
356
  ];
@@ -287,17 +361,17 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
287
361
  const refs = await astIndex.refs(args.symbol, 10);
288
362
  const callers = refs.usages.slice(0, 5);
289
363
  if (callers.length > 0) {
290
- outputLines.push('');
364
+ outputLines.push("");
291
365
  outputLines.push(`CALLERS (${callers.length}):`);
292
366
  for (const c of callers) {
293
367
  const relPath = relative(projectRoot, c.path);
294
- const ctx = c.context ? ` — ${c.context.trim().slice(0, 80)}` : '';
368
+ const ctx = c.context ? ` — ${c.context.trim().slice(0, 80)}` : "";
295
369
  outputLines.push(` ${relPath}:${c.line}${ctx}`);
296
370
  }
297
371
  }
298
372
  else {
299
- outputLines.push('');
300
- outputLines.push('CALLERS: none found');
373
+ outputLines.push("");
374
+ outputLines.push("CALLERS: none found");
301
375
  }
302
376
  }
303
377
  catch {
@@ -307,13 +381,13 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
307
381
  // include_tests: find related test file and list test names
308
382
  if (args.include_tests) {
309
383
  const testSection = await findTestSection(absPath, args.path, projectRoot, astIndex);
310
- outputLines.push('');
384
+ outputLines.push("");
311
385
  outputLines.push(...testSection);
312
386
  }
313
387
  // include_changes: git diff filtered to target region
314
388
  if (args.include_changes) {
315
389
  const diffSection = await findChangesSection(absPath, projectRoot, rangeStart, rangeEnd);
316
- outputLines.push('');
390
+ outputLines.push("");
317
391
  outputLines.push(...diffSection);
318
392
  }
319
393
  // Confidence metadata
@@ -328,37 +402,37 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
328
402
  outputLines.push(formatConfidence(confidenceMeta));
329
403
  // Add post-edit hint (config-gated)
330
404
  if (options?.actionableHints !== false) {
331
- outputLines.push('');
405
+ outputLines.push("");
332
406
  outputLines.push(`AFTER EDIT: Use read_diff("${args.path}") to verify changes (90% cheaper than re-reading the file).`);
333
407
  }
334
- const output = outputLines.join('\n');
408
+ const output = outputLines.join("\n");
335
409
  const tokens = estimateTokens(output);
336
410
  // Track in context
337
411
  contextRegistry.trackLoad(absPath, {
338
- type: 'symbol',
412
+ type: "symbol",
339
413
  symbolName: args.symbol ?? `line:${args.line}`,
340
414
  startLine: rangeStart,
341
415
  endLine: rangeEnd,
342
416
  tokens,
343
417
  });
344
- return { content: [{ type: 'text', text: output }] };
418
+ return { content: [{ type: "text", text: output }] };
345
419
  }
346
420
  // --- Helper: find related test file and extract test names ---
347
421
  async function findTestSection(absPath, relPath, projectRoot, astIndex) {
348
422
  // Derive test file path from source path using common conventions
349
423
  // src/handlers/foo.ts → tests/handlers/foo.test.ts
350
424
  // src/core/bar.ts → tests/core/bar.test.ts
351
- const srcPrefix = 'src/';
425
+ const srcPrefix = "src/";
352
426
  let testRelPath;
353
427
  if (relPath.startsWith(srcPrefix)) {
354
428
  const rest = relPath.slice(srcPrefix.length);
355
- const ext = rest.match(/\.[^.]+$/)?.[0] ?? '.ts';
356
- const base = rest.replace(/\.[^.]+$/, '');
429
+ const ext = rest.match(/\.[^.]+$/)?.[0] ?? ".ts";
430
+ const base = rest.replace(/\.[^.]+$/, "");
357
431
  testRelPath = `tests/${base}.test${ext}`;
358
432
  }
359
433
  else {
360
- const ext = relPath.match(/\.[^.]+$/)?.[0] ?? '.ts';
361
- const base = relPath.replace(/\.[^.]+$/, '');
434
+ const ext = relPath.match(/\.[^.]+$/)?.[0] ?? ".ts";
435
+ const base = relPath.replace(/\.[^.]+$/, "");
362
436
  testRelPath = `${base}.test${ext}`;
363
437
  }
364
438
  const testAbsPath = join(projectRoot, testRelPath);
@@ -395,10 +469,10 @@ async function findChangesSection(absPath, projectRoot, rangeStart, rangeEnd) {
395
469
  const MAX_DIFF_LINES = 30;
396
470
  try {
397
471
  // Try unstaged changes first
398
- let diffOutput = '';
399
- let diffLabel = 'unstaged';
472
+ let diffOutput = "";
473
+ let diffLabel = "unstaged";
400
474
  try {
401
- const { stdout } = await execFileAsync('git', ['diff', 'HEAD', '--', absPath], {
475
+ const { stdout } = await execFileAsync("git", ["diff", "HEAD", "--", absPath], {
402
476
  cwd: projectRoot,
403
477
  timeout: 5000,
404
478
  });
@@ -406,29 +480,29 @@ async function findChangesSection(absPath, projectRoot, rangeStart, rangeEnd) {
406
480
  }
407
481
  catch {
408
482
  // git not available or not a repo
409
- return ['RECENT CHANGES: unavailable (not a git repo)'];
483
+ return ["RECENT CHANGES: unavailable (not a git repo)"];
410
484
  }
411
485
  // If no unstaged changes, try last commit
412
486
  if (!diffOutput.trim()) {
413
487
  try {
414
- const { stdout } = await execFileAsync('git', ['diff', 'HEAD~1', '--', absPath], {
488
+ const { stdout } = await execFileAsync("git", ["diff", "HEAD~1", "--", absPath], {
415
489
  cwd: projectRoot,
416
490
  timeout: 5000,
417
491
  });
418
492
  diffOutput = stdout;
419
- diffLabel = 'last commit';
493
+ diffLabel = "last commit";
420
494
  }
421
495
  catch {
422
496
  // no previous commit
423
497
  }
424
498
  }
425
499
  if (!diffOutput.trim()) {
426
- return ['RECENT CHANGES: none (file unchanged)'];
500
+ return ["RECENT CHANGES: none (file unchanged)"];
427
501
  }
428
502
  // Filter hunks to those overlapping with target range
429
503
  const relevantLines = filterDiffHunks(diffOutput, rangeStart, rangeEnd);
430
504
  if (relevantLines.length === 0) {
431
- return ['RECENT CHANGES: none in target region'];
505
+ return ["RECENT CHANGES: none in target region"];
432
506
  }
433
507
  const lines = [`RECENT CHANGES (${diffLabel}):`];
434
508
  const trimmed = relevantLines.slice(0, MAX_DIFF_LINES);
@@ -441,12 +515,12 @@ async function findChangesSection(absPath, projectRoot, rangeStart, rangeEnd) {
441
515
  return lines;
442
516
  }
443
517
  catch {
444
- return ['RECENT CHANGES: unavailable'];
518
+ return ["RECENT CHANGES: unavailable"];
445
519
  }
446
520
  }
447
521
  /** Filter diff output to only hunks overlapping [rangeStart, rangeEnd]. */
448
522
  function filterDiffHunks(diff, rangeStart, rangeEnd) {
449
- const allLines = diff.split('\n');
523
+ const allLines = diff.split("\n");
450
524
  const result = [];
451
525
  let inRelevantHunk = false;
452
526
  for (const line of allLines) {
@@ -454,7 +528,7 @@ function filterDiffHunks(diff, rangeStart, rangeEnd) {
454
528
  const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
455
529
  if (hunkMatch) {
456
530
  const hunkStart = parseInt(hunkMatch[1], 10);
457
- const hunkLen = parseInt(hunkMatch[2] ?? '1', 10);
531
+ const hunkLen = parseInt(hunkMatch[2] ?? "1", 10);
458
532
  const hunkEnd = hunkStart + hunkLen - 1;
459
533
  // Check overlap with target range
460
534
  inRelevantHunk = hunkStart <= rangeEnd && hunkEnd >= rangeStart;
@@ -464,10 +538,14 @@ function filterDiffHunks(diff, rangeStart, rangeEnd) {
464
538
  continue;
465
539
  }
466
540
  // Skip diff metadata lines (diff --git, index, ---, +++)
467
- if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
541
+ if (line.startsWith("diff ") ||
542
+ line.startsWith("index ") ||
543
+ line.startsWith("--- ") ||
544
+ line.startsWith("+++ ")) {
468
545
  continue;
469
546
  }
470
- if (inRelevantHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
547
+ if (inRelevantHunk &&
548
+ (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
471
549
  result.push(line);
472
550
  }
473
551
  }
@@ -34,6 +34,24 @@ function createHookConfig(options) {
34
34
  },
35
35
  ],
36
36
  },
37
+ {
38
+ matcher: "MultiEdit",
39
+ hooks: [
40
+ {
41
+ type: "command",
42
+ command: buildHookCommand("hook-edit", options),
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ matcher: "Write",
48
+ hooks: [
49
+ {
50
+ type: "command",
51
+ command: buildHookCommand("hook-edit", options),
52
+ },
53
+ ],
54
+ },
37
55
  {
38
56
  matcher: "Bash",
39
57
  hooks: [
@@ -34,6 +34,9 @@ export interface PreBashInput {
34
34
  }
35
35
  export type PreBashDecision = {
36
36
  kind: "allow";
37
+ } | {
38
+ kind: "advise";
39
+ reason: string;
37
40
  } | {
38
41
  kind: "deny";
39
42
  reason: string;
@@ -50,6 +53,12 @@ export type PreBashDecision = {
50
53
  */
51
54
  export declare function extractWrappedCommands(command: string): string[];
52
55
  export declare function detectHeavyPattern(command: string): PreBashDecision;
56
+ /**
57
+ * Detect common test-runner invocations. Returns true for anything we'd
58
+ * route through `test_summary`. Kept as a pure string test so it's unit-
59
+ * testable without spinning up child processes.
60
+ */
61
+ export declare function isTestRunnerCommand(cmd: string): boolean;
53
62
  export declare function decidePreBash(input: PreBashInput, mode?: EnforcementMode): PreBashDecision;
54
63
  export declare function renderPreBashOutput(decision: PreBashDecision): string | null;
55
64
  //# sourceMappingURL=pre-bash.d.ts.map