pi-agent-toolkit 0.1.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.
Files changed (53) hide show
  1. package/dist/dotfiles/AGENTS.md +197 -0
  2. package/dist/dotfiles/APPEND_SYSTEM.md +78 -0
  3. package/dist/dotfiles/agent-modes.json +12 -0
  4. package/dist/dotfiles/agent-skills/exa-search/.env.example +4 -0
  5. package/dist/dotfiles/agent-skills/exa-search/SKILL.md +234 -0
  6. package/dist/dotfiles/agent-skills/exa-search/scripts/exa-api.cjs +197 -0
  7. package/dist/dotfiles/auth.json.template +5 -0
  8. package/dist/dotfiles/damage-control-rules.yaml +318 -0
  9. package/dist/dotfiles/extensions/btw.ts +1031 -0
  10. package/dist/dotfiles/extensions/commit-approval.ts +590 -0
  11. package/dist/dotfiles/extensions/context.ts +578 -0
  12. package/dist/dotfiles/extensions/control.ts +1748 -0
  13. package/dist/dotfiles/extensions/damage-control/index.ts +543 -0
  14. package/dist/dotfiles/extensions/damage-control/node_modules/.package-lock.json +22 -0
  15. package/dist/dotfiles/extensions/damage-control/package-lock.json +28 -0
  16. package/dist/dotfiles/extensions/damage-control/package.json +7 -0
  17. package/dist/dotfiles/extensions/dirty-repo-guard.ts +56 -0
  18. package/dist/dotfiles/extensions/exa-enforce.ts +51 -0
  19. package/dist/dotfiles/extensions/exa-search-tool.ts +384 -0
  20. package/dist/dotfiles/extensions/execute-command/index.ts +82 -0
  21. package/dist/dotfiles/extensions/files.ts +1112 -0
  22. package/dist/dotfiles/extensions/loop.ts +446 -0
  23. package/dist/dotfiles/extensions/pr-approval.ts +730 -0
  24. package/dist/dotfiles/extensions/qna-interactive.ts +532 -0
  25. package/dist/dotfiles/extensions/question-mode.ts +242 -0
  26. package/dist/dotfiles/extensions/require-session-name-on-exit.ts +141 -0
  27. package/dist/dotfiles/extensions/review.ts +2091 -0
  28. package/dist/dotfiles/extensions/session-breakdown.ts +1629 -0
  29. package/dist/dotfiles/extensions/term-notify.ts +150 -0
  30. package/dist/dotfiles/extensions/tilldone.ts +527 -0
  31. package/dist/dotfiles/extensions/todos.ts +2082 -0
  32. package/dist/dotfiles/extensions/tools.ts +146 -0
  33. package/dist/dotfiles/extensions/uv.ts +123 -0
  34. package/dist/dotfiles/global-skills/brainstorm/SKILL.md +10 -0
  35. package/dist/dotfiles/global-skills/cli-detector/SKILL.md +192 -0
  36. package/dist/dotfiles/global-skills/gh-issue-creator/SKILL.md +173 -0
  37. package/dist/dotfiles/global-skills/google-chat-cards-v2/SKILL.md +237 -0
  38. package/dist/dotfiles/global-skills/google-chat-cards-v2/references/bridge_tap_implementation.md +466 -0
  39. package/dist/dotfiles/global-skills/technical-docs/SKILL.md +204 -0
  40. package/dist/dotfiles/global-skills/technical-docs/references/diagrams.md +168 -0
  41. package/dist/dotfiles/global-skills/technical-docs/references/examples.md +449 -0
  42. package/dist/dotfiles/global-skills/technical-docs/scripts/validate_docs.py +352 -0
  43. package/dist/dotfiles/global-skills/whats-new/SKILL.md +159 -0
  44. package/dist/dotfiles/intercepted-commands/pip +7 -0
  45. package/dist/dotfiles/intercepted-commands/pip3 +7 -0
  46. package/dist/dotfiles/intercepted-commands/poetry +10 -0
  47. package/dist/dotfiles/intercepted-commands/python +104 -0
  48. package/dist/dotfiles/intercepted-commands/python3 +104 -0
  49. package/dist/dotfiles/mcp.json.template +32 -0
  50. package/dist/dotfiles/models.json +27 -0
  51. package/dist/dotfiles/settings.json +25 -0
  52. package/dist/index.js +1344 -0
  53. package/package.json +34 -0
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Damage Control Extension
3
+ *
4
+ * Real-time safety auditing that intercepts dangerous bash patterns and
5
+ * enforces path-based access controls. Rules are loaded from YAML config.
6
+ *
7
+ * Config locations (merged, project-local extends global):
8
+ * ~/.pi/agent/damage-control-rules.yaml (global)
9
+ * .pi/damage-control-rules.yaml (project-local)
10
+ *
11
+ * Commands:
12
+ * /dc - Show loaded rule counts and last block/ask events
13
+ * /dc rules - Show all loaded rules in detail
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from "@mariozechner/pi-coding-agent";
17
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
18
+ import { readFileSync, existsSync } from "node:fs";
19
+ import { resolve, basename } from "node:path";
20
+ import { homedir } from "node:os";
21
+ import { parse as parseYaml } from "yaml";
22
+
23
+ // -- Types ------------------------------------------------------------------
24
+
25
+ interface BashPattern {
26
+ pattern: string;
27
+ reason: string;
28
+ ask?: boolean;
29
+ allow?: boolean;
30
+ _compiled?: RegExp;
31
+ }
32
+
33
+ interface DamageControlRules {
34
+ bashToolPatterns: BashPattern[];
35
+ zeroAccessPaths: string[];
36
+ askAccessPaths: string[];
37
+ readOnlyPaths: string[];
38
+ noDeletePaths: string[];
39
+ }
40
+
41
+ interface BlockEvent {
42
+ timestamp: number;
43
+ type: "bash" | "path";
44
+ detail: string;
45
+ action: "blocked" | "asked" | "allowed";
46
+ }
47
+
48
+ // -- Helpers ----------------------------------------------------------------
49
+
50
+ const HOME = homedir();
51
+
52
+ function expandHome(p: string): string {
53
+ if (p.startsWith("~/")) return resolve(HOME, p.slice(2));
54
+ if (p === "~") return HOME;
55
+ return p;
56
+ }
57
+
58
+ /**
59
+ * Simple glob matcher for path rules. Supports:
60
+ * * - matches any sequence (non-slash for basename, any for full)
61
+ * ~/ - home directory expansion
62
+ * trailing / - directory prefix match
63
+ */
64
+ function pathMatches(filePath: string, pattern: string): boolean {
65
+ const expanded = expandHome(pattern);
66
+
67
+ // Directory rule: match the directory itself or anything beneath it
68
+ if (expanded.endsWith("/")) {
69
+ const dir = expanded.replace(/\/+$/, "");
70
+ const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
71
+ return (
72
+ norm === dir ||
73
+ norm === `${dir}/` ||
74
+ norm.startsWith(`${dir}/`) ||
75
+ norm.includes(`/${dir}/`) ||
76
+ norm.endsWith(`/${dir}`)
77
+ );
78
+ }
79
+
80
+ // Glob pattern (contains *)
81
+ if (expanded.includes("*")) {
82
+ const regexStr = expanded
83
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
84
+ .replace(/\*/g, ".*");
85
+ const regex = new RegExp(`(^|/)${regexStr}$`);
86
+ const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
87
+ // Match against full path and also just the basename
88
+ return regex.test(norm) || regex.test(basename(filePath));
89
+ }
90
+
91
+ // Exact match against basename or full path
92
+ const norm = filePath.startsWith("/") ? filePath : resolve(filePath);
93
+ const name = basename(filePath);
94
+ return name === expanded || norm === expanded || norm.endsWith("/" + expanded);
95
+ }
96
+
97
+ function loadRulesFile(path: string): Partial<DamageControlRules> | null {
98
+ if (!existsSync(path)) return null;
99
+ try {
100
+ const content = readFileSync(path, "utf-8");
101
+ return parseYaml(content) as Partial<DamageControlRules>;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function mergeRules(...sources: (Partial<DamageControlRules> | null)[]): DamageControlRules {
108
+ const merged: DamageControlRules = {
109
+ bashToolPatterns: [],
110
+ zeroAccessPaths: [],
111
+ askAccessPaths: [],
112
+ readOnlyPaths: [],
113
+ noDeletePaths: [],
114
+ };
115
+
116
+ for (const src of sources) {
117
+ if (!src) continue;
118
+ if (src.bashToolPatterns) merged.bashToolPatterns.push(...src.bashToolPatterns);
119
+ if (src.zeroAccessPaths) merged.zeroAccessPaths.push(...src.zeroAccessPaths);
120
+ if (src.askAccessPaths) merged.askAccessPaths.push(...src.askAccessPaths);
121
+ if (src.readOnlyPaths) merged.readOnlyPaths.push(...src.readOnlyPaths);
122
+ if (src.noDeletePaths) merged.noDeletePaths.push(...src.noDeletePaths);
123
+ }
124
+
125
+ // Deduplicate paths
126
+ merged.zeroAccessPaths = [...new Set(merged.zeroAccessPaths)];
127
+ merged.askAccessPaths = [...new Set(merged.askAccessPaths)];
128
+ merged.readOnlyPaths = [...new Set(merged.readOnlyPaths)];
129
+ merged.noDeletePaths = [...new Set(merged.noDeletePaths)];
130
+
131
+ // Compile regexes
132
+ for (const bp of merged.bashToolPatterns) {
133
+ try {
134
+ bp._compiled = new RegExp(bp.pattern);
135
+ } catch {
136
+ // Skip invalid patterns
137
+ }
138
+ }
139
+
140
+ return merged;
141
+ }
142
+
143
+ // -- Extension --------------------------------------------------------------
144
+
145
+ export default function (pi: ExtensionAPI) {
146
+ let rules: DamageControlRules = mergeRules();
147
+ const recentEvents: BlockEvent[] = [];
148
+ const MAX_RECENT = 20;
149
+
150
+ function recordEvent(evt: BlockEvent) {
151
+ recentEvents.unshift(evt);
152
+ if (recentEvents.length > MAX_RECENT) recentEvents.pop();
153
+ }
154
+
155
+ function loadAllRules(cwd: string) {
156
+ const globalPath = resolve(HOME, ".pi/agent/damage-control-rules.yaml");
157
+ const projectPath = resolve(cwd, ".pi/damage-control-rules.yaml");
158
+
159
+ const globalRules = loadRulesFile(globalPath);
160
+ const projectRules = loadRulesFile(projectPath);
161
+
162
+ rules = mergeRules(globalRules, projectRules);
163
+ }
164
+
165
+ // -- Bash pattern checking ------------------------------------------------
166
+
167
+ function checkBash(command: string): { match: BashPattern; isAsk: boolean } | null {
168
+ // Pass 1: allow rules -- if any match, the command is explicitly permitted.
169
+ // Allow always wins over block/ask regardless of rule order in YAML.
170
+ for (const bp of rules.bashToolPatterns) {
171
+ if (!bp._compiled || !bp.allow) continue;
172
+ if (bp._compiled.test(command)) return null;
173
+ }
174
+
175
+ // Pass 2: block/ask rules
176
+ let askMatch: BashPattern | null = null;
177
+
178
+ for (const bp of rules.bashToolPatterns) {
179
+ if (!bp._compiled || bp.allow) continue;
180
+ if (!bp._compiled.test(command)) continue;
181
+
182
+ // Hard-block wins immediately -- no need to check further
183
+ if (!bp.ask) return { match: bp, isAsk: false };
184
+
185
+ // Remember the first ask match as fallback
186
+ if (!askMatch) askMatch = bp;
187
+ }
188
+
189
+ if (askMatch) return { match: askMatch, isAsk: true };
190
+ return null;
191
+ }
192
+
193
+ function extractCommandPathCandidates(command: string): string[] {
194
+ const candidates = new Set<string>();
195
+ const tokens = command.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g) ?? [];
196
+
197
+ for (const token of tokens) {
198
+ const stripped = token
199
+ .trim()
200
+ .replace(/^["'`]+|["'`]+$/g, "")
201
+ .replace(/[;,]+$/g, "");
202
+
203
+ if (!stripped || stripped === "-" || stripped.startsWith("-")) continue;
204
+
205
+ candidates.add(stripped);
206
+
207
+ const equalsIndex = stripped.indexOf("=");
208
+ if (equalsIndex > 0 && equalsIndex < stripped.length - 1) {
209
+ candidates.add(stripped.slice(equalsIndex + 1));
210
+ }
211
+ }
212
+
213
+ return [...candidates];
214
+ }
215
+
216
+ function getBashPathAccess(command: string): { path: string; access: PathAccess } | null {
217
+ for (const candidate of extractCommandPathCandidates(command)) {
218
+ const access = checkPathAccess(candidate);
219
+ if (access === "zero") return { path: candidate, access };
220
+ if (shouldAskPathAccess(candidate)) return { path: candidate, access: "ask" };
221
+ }
222
+ return null;
223
+ }
224
+
225
+ // -- Path access checking -------------------------------------------------
226
+
227
+ type PathAccess = "zero" | "ask" | "readOnly" | "noDelete" | "allowed";
228
+
229
+ function isNodeModulesPath(filePath: string): boolean {
230
+ return pathMatches(filePath, "node_modules/");
231
+ }
232
+
233
+ function shouldSkipReadConfirmation(filePath: string): boolean {
234
+ return isNodeModulesPath(filePath);
235
+ }
236
+
237
+ function shouldAskPathAccess(filePath: string): boolean {
238
+ return rules.askAccessPaths.some((p) => pathMatches(filePath, p));
239
+ }
240
+
241
+ function checkPathAccess(filePath: string): PathAccess {
242
+ if (rules.zeroAccessPaths.some((p) => pathMatches(filePath, p))) return "zero";
243
+ if (rules.readOnlyPaths.some((p) => pathMatches(filePath, p))) return "readOnly";
244
+ if (rules.noDeletePaths.some((p) => pathMatches(filePath, p))) return "noDelete";
245
+ return "allowed";
246
+ }
247
+
248
+ async function confirmPathAccess(kind: "read" | "bash", target: string, preview: string, ctx: ExtensionContext): Promise<boolean> {
249
+ recordEvent({
250
+ timestamp: Date.now(),
251
+ type: "path",
252
+ detail: `ask ${kind}: ${target}`,
253
+ action: "asked",
254
+ });
255
+
256
+ if (!ctx.hasUI) return false;
257
+
258
+ const noun = kind === "read" ? "read from" : "access via bash";
259
+ const choice = await ctx.ui.select(
260
+ `[Damage Control] ${target} requires confirmation before the agent can ${noun} it.\n\n ${preview}\n\nAllow this access?`,
261
+ ["Yes, allow once", "No, block it"],
262
+ );
263
+
264
+ if (choice === "Yes, allow once") {
265
+ recordEvent({
266
+ timestamp: Date.now(),
267
+ type: "path",
268
+ detail: `ask ${kind}: ${target}`,
269
+ action: "allowed",
270
+ });
271
+ return true;
272
+ }
273
+
274
+ return false;
275
+ }
276
+
277
+ // Determine if the tool operation is a "delete" (rm via bash is handled separately)
278
+ function isDeleteOperation(toolName: string, input: Record<string, unknown>): boolean {
279
+ // write with empty content to a noDelete path could be destructive
280
+ // but the main delete vector is bash rm, which is caught by bash patterns
281
+ return false;
282
+ }
283
+
284
+ // -- Load rules on session start ------------------------------------------
285
+
286
+ pi.on("session_start", async (_event, ctx) => {
287
+ loadAllRules(ctx.cwd);
288
+ const total =
289
+ rules.bashToolPatterns.length +
290
+ rules.zeroAccessPaths.length +
291
+ rules.askAccessPaths.length +
292
+ rules.readOnlyPaths.length +
293
+ rules.noDeletePaths.length;
294
+ if (total > 0) {
295
+ ctx.ui.setStatus("damage-control", ctx.ui.theme.fg("success", "DC"));
296
+ }
297
+ });
298
+
299
+ // -- Intercept tool calls -------------------------------------------------
300
+
301
+ pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | undefined> => {
302
+ // --- Bash commands ---
303
+ if (isToolCallEventType("bash", event)) {
304
+ const cmd = event.input.command;
305
+ const pathAccess = getBashPathAccess(cmd);
306
+ if (pathAccess?.access === "zero") {
307
+ recordEvent({
308
+ timestamp: Date.now(),
309
+ type: "path",
310
+ detail: `zero-access bash target: ${pathAccess.path}`,
311
+ action: "blocked",
312
+ });
313
+ ctx.ui.notify(`[DC] Blocked bash access: ${pathAccess.path} (zero-access)`, "error");
314
+ return {
315
+ block: true,
316
+ reason: `Damage Control: bash command targets zero-access path "${pathAccess.path}". Access is not permitted.`,
317
+ };
318
+ }
319
+
320
+ if (pathAccess?.access === "ask") {
321
+ const allowed = await confirmPathAccess("bash", pathAccess.path, cmd, ctx);
322
+ if (!allowed) {
323
+ recordEvent({
324
+ timestamp: Date.now(),
325
+ type: "path",
326
+ detail: `ask bash: ${pathAccess.path}`,
327
+ action: "blocked",
328
+ });
329
+ ctx.ui.notify(`[DC] Blocked bash access: ${pathAccess.path} (confirmation required)`, "error");
330
+ return {
331
+ block: true,
332
+ reason: `Damage Control: bash command targets protected path "${pathAccess.path}" and was not approved.`,
333
+ };
334
+ }
335
+ }
336
+
337
+ const result = checkBash(cmd);
338
+
339
+ if (!result) return undefined;
340
+
341
+ const { match, isAsk } = result;
342
+
343
+ if (isAsk && ctx.hasUI) {
344
+ recordEvent({
345
+ timestamp: Date.now(),
346
+ type: "bash",
347
+ detail: match.reason,
348
+ action: "asked",
349
+ });
350
+
351
+ const choice = await ctx.ui.select(
352
+ `[Damage Control] ${match.reason}\n\n ${cmd}\n\nAllow this command?`,
353
+ ["Yes, proceed", "No, block it"],
354
+ );
355
+
356
+ if (choice === "Yes, proceed") {
357
+ recordEvent({
358
+ timestamp: Date.now(),
359
+ type: "bash",
360
+ detail: match.reason,
361
+ action: "allowed",
362
+ });
363
+ return undefined;
364
+ }
365
+ }
366
+
367
+ recordEvent({
368
+ timestamp: Date.now(),
369
+ type: "bash",
370
+ detail: match.reason,
371
+ action: "blocked",
372
+ });
373
+ ctx.ui.notify(`[DC] Blocked: ${match.reason}`, "error");
374
+ return {
375
+ block: true,
376
+ reason: `Damage Control: ${match.reason}. Command blocked by safety rules.`,
377
+ };
378
+ }
379
+
380
+ // --- Read tool: check protected paths ---
381
+ if (isToolCallEventType("read", event)) {
382
+ const filePath = event.input.path;
383
+ const access = checkPathAccess(filePath);
384
+
385
+ if (access === "zero") {
386
+ recordEvent({
387
+ timestamp: Date.now(),
388
+ type: "path",
389
+ detail: `zero-access read: ${filePath}`,
390
+ action: "blocked",
391
+ });
392
+ ctx.ui.notify(`[DC] Blocked read: ${filePath} (zero-access)`, "error");
393
+ return {
394
+ block: true,
395
+ reason: `Damage Control: "${filePath}" is a zero-access path. Reading is not permitted.`,
396
+ };
397
+ }
398
+
399
+ if (!shouldSkipReadConfirmation(filePath) && shouldAskPathAccess(filePath)) {
400
+ const allowed = await confirmPathAccess("read", filePath, filePath, ctx);
401
+ if (!allowed) {
402
+ recordEvent({
403
+ timestamp: Date.now(),
404
+ type: "path",
405
+ detail: `ask read: ${filePath}`,
406
+ action: "blocked",
407
+ });
408
+ ctx.ui.notify(`[DC] Blocked read: ${filePath} (confirmation required)`, "error");
409
+ return {
410
+ block: true,
411
+ reason: `Damage Control: "${filePath}" requires explicit approval before it can be read.`,
412
+ };
413
+ }
414
+ }
415
+ }
416
+
417
+ // --- Write tool: check zero-access and read-only paths ---
418
+ if (event.toolName === "write") {
419
+ const filePath = (event.input as Record<string, unknown>).path as string;
420
+ const access = checkPathAccess(filePath);
421
+
422
+ if (access === "zero") {
423
+ recordEvent({
424
+ timestamp: Date.now(),
425
+ type: "path",
426
+ detail: `zero-access write: ${filePath}`,
427
+ action: "blocked",
428
+ });
429
+ ctx.ui.notify(`[DC] Blocked write: ${filePath} (zero-access)`, "error");
430
+ return {
431
+ block: true,
432
+ reason: `Damage Control: "${filePath}" is a zero-access path. Writing is not permitted.`,
433
+ };
434
+ }
435
+
436
+ if (access === "readOnly") {
437
+ recordEvent({
438
+ timestamp: Date.now(),
439
+ type: "path",
440
+ detail: `read-only write: ${filePath}`,
441
+ action: "blocked",
442
+ });
443
+ ctx.ui.notify(`[DC] Blocked write: ${filePath} (read-only)`, "error");
444
+ return {
445
+ block: true,
446
+ reason: `Damage Control: "${filePath}" is read-only. Writing is not permitted.`,
447
+ };
448
+ }
449
+ }
450
+
451
+ // --- Edit tool: check zero-access and read-only paths ---
452
+ if (isToolCallEventType("edit", event)) {
453
+ const filePath = event.input.path;
454
+ const access = checkPathAccess(filePath);
455
+
456
+ if (access === "zero") {
457
+ recordEvent({
458
+ timestamp: Date.now(),
459
+ type: "path",
460
+ detail: `zero-access edit: ${filePath}`,
461
+ action: "blocked",
462
+ });
463
+ ctx.ui.notify(`[DC] Blocked edit: ${filePath} (zero-access)`, "error");
464
+ return {
465
+ block: true,
466
+ reason: `Damage Control: "${filePath}" is a zero-access path. Editing is not permitted.`,
467
+ };
468
+ }
469
+
470
+ if (access === "readOnly") {
471
+ recordEvent({
472
+ timestamp: Date.now(),
473
+ type: "path",
474
+ detail: `read-only edit: ${filePath}`,
475
+ action: "blocked",
476
+ });
477
+ ctx.ui.notify(`[DC] Blocked edit: ${filePath} (read-only)`, "error");
478
+ return {
479
+ block: true,
480
+ reason: `Damage Control: "${filePath}" is read-only. Editing is not permitted.`,
481
+ };
482
+ }
483
+ }
484
+
485
+ return undefined;
486
+ });
487
+
488
+ // -- /dc command ----------------------------------------------------------
489
+
490
+ pi.registerCommand("dc", {
491
+ description: "Show damage control status and rules",
492
+ handler: async (args, ctx) => {
493
+ // Reload rules in case the YAML was edited
494
+ loadAllRules(ctx.cwd);
495
+
496
+ if (args?.trim() === "rules") {
497
+ const lines: string[] = [];
498
+ lines.push("--- Bash patterns ---");
499
+ for (const bp of rules.bashToolPatterns) {
500
+ const tag = bp.allow ? " [allow]" : bp.ask ? " [ask]" : " [block]";
501
+ lines.push(` ${bp.pattern}${tag} -- ${bp.reason}`);
502
+ }
503
+ lines.push("");
504
+ lines.push("--- Zero-access paths ---");
505
+ for (const p of rules.zeroAccessPaths) lines.push(` ${p}`);
506
+ lines.push("");
507
+ lines.push("--- Ask-before-access paths ---");
508
+ for (const p of rules.askAccessPaths) lines.push(` ${p}`);
509
+ lines.push("");
510
+ lines.push("--- Read-only paths ---");
511
+ for (const p of rules.readOnlyPaths) lines.push(` ${p}`);
512
+ lines.push("");
513
+ lines.push("--- No-delete paths ---");
514
+ for (const p of rules.noDeletePaths) lines.push(` ${p}`);
515
+
516
+ ctx.ui.notify(lines.join("\n"), "info");
517
+ return;
518
+ }
519
+
520
+ const lines: string[] = [];
521
+ const allowCount = rules.bashToolPatterns.filter((r) => r.allow).length;
522
+ const askCount = rules.bashToolPatterns.filter((r) => !r.allow && r.ask).length;
523
+ const blockCount = rules.bashToolPatterns.filter((r) => !r.allow && !r.ask).length;
524
+ lines.push(`Bash patterns: ${rules.bashToolPatterns.length} (${allowCount} allow, ${askCount} ask, ${blockCount} block)`);
525
+ lines.push(`Zero-access paths: ${rules.zeroAccessPaths.length}`);
526
+ lines.push(`Ask-access paths: ${rules.askAccessPaths.length}`);
527
+ lines.push(`Read-only paths: ${rules.readOnlyPaths.length}`);
528
+ lines.push(`No-delete paths: ${rules.noDeletePaths.length}`);
529
+
530
+ if (recentEvents.length > 0) {
531
+ lines.push("");
532
+ lines.push("Recent events:");
533
+ for (const evt of recentEvents.slice(0, 10)) {
534
+ const time = new Date(evt.timestamp).toLocaleTimeString();
535
+ const icon = evt.action === "blocked" ? "[x]" : evt.action === "asked" ? "[?]" : "[>]";
536
+ lines.push(` ${time} ${icon} ${evt.detail}`);
537
+ }
538
+ }
539
+
540
+ ctx.ui.notify(lines.join("\n"), "info");
541
+ },
542
+ });
543
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "damage-control",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "node_modules/yaml": {
7
+ "version": "2.8.2",
8
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
9
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
10
+ "license": "ISC",
11
+ "bin": {
12
+ "yaml": "bin.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">= 14.6"
16
+ },
17
+ "funding": {
18
+ "url": "https://github.com/sponsors/eemeli"
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "damage-control",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "damage-control",
8
+ "dependencies": {
9
+ "yaml": "^2.7.0"
10
+ }
11
+ },
12
+ "node_modules/yaml": {
13
+ "version": "2.8.2",
14
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
15
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
16
+ "license": "ISC",
17
+ "bin": {
18
+ "yaml": "bin.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">= 14.6"
22
+ },
23
+ "funding": {
24
+ "url": "https://github.com/sponsors/eemeli"
25
+ }
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "damage-control",
3
+ "private": true,
4
+ "dependencies": {
5
+ "yaml": "^2.7.0"
6
+ }
7
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Dirty Repo Guard Extension
3
+ *
4
+ * Prevents session changes when there are uncommitted git changes.
5
+ * Useful to ensure work is committed before switching context.
6
+ */
7
+
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+
10
+ async function checkDirtyRepo(
11
+ pi: ExtensionAPI,
12
+ ctx: ExtensionContext,
13
+ action: string,
14
+ ): Promise<{ cancel: boolean } | undefined> {
15
+ // Check for uncommitted changes
16
+ const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
17
+
18
+ if (code !== 0) {
19
+ // Not a git repo, allow the action
20
+ return;
21
+ }
22
+
23
+ const hasChanges = stdout.trim().length > 0;
24
+ if (!hasChanges) {
25
+ return;
26
+ }
27
+
28
+ if (!ctx.hasUI) {
29
+ // In non-interactive mode, block by default
30
+ return { cancel: true };
31
+ }
32
+
33
+ // Count changed files
34
+ const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
35
+
36
+ const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
37
+ "Yes, proceed anyway",
38
+ "No, let me commit first",
39
+ ]);
40
+
41
+ if (choice !== "Yes, proceed anyway") {
42
+ ctx.ui.notify("Commit your changes first", "warning");
43
+ return { cancel: true };
44
+ }
45
+ }
46
+
47
+ export default function (pi: ExtensionAPI) {
48
+ pi.on("session_before_switch", async (event, ctx) => {
49
+ const action = event.reason === "new" ? "new session" : "switch session";
50
+ return checkDirtyRepo(pi, ctx, action);
51
+ });
52
+
53
+ pi.on("session_before_fork", async (_event, ctx) => {
54
+ return checkDirtyRepo(pi, ctx, "fork");
55
+ });
56
+ }