@diegopetrucci/pi-extensions 0.1.58 → 0.1.60

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 (70) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
  3. package/extensions/agent-workflow-audit/package.json +1 -1
  4. package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -1
  5. package/extensions/annotate-git-diff/package.json +1 -1
  6. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  7. package/extensions/annotate-last-message/package.json +1 -1
  8. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  9. package/extensions/brrr/package.json +1 -1
  10. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  11. package/extensions/claude-fast/package.json +1 -1
  12. package/extensions/code-reviewer/.pi-fleet-tested-version +1 -1
  13. package/extensions/code-reviewer/index.ts +120 -37
  14. package/extensions/code-reviewer/package.json +1 -1
  15. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  16. package/extensions/confirm-destructive/README.md +1 -1
  17. package/extensions/confirm-destructive/index.ts +20 -5
  18. package/extensions/confirm-destructive/package.json +1 -1
  19. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  20. package/extensions/context-cap/package.json +1 -1
  21. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  22. package/extensions/context-inspector/package.json +1 -1
  23. package/extensions/contrarian/.pi-fleet-tested-version +1 -1
  24. package/extensions/contrarian/README.md +1 -1
  25. package/extensions/contrarian/index.ts +175 -32
  26. package/extensions/contrarian/package.json +1 -1
  27. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  28. package/extensions/dirty-repo-guard/README.md +1 -1
  29. package/extensions/dirty-repo-guard/package.json +1 -1
  30. package/extensions/dynamic-context-pruning/.pi-fleet-tested-version +1 -1
  31. package/extensions/dynamic-context-pruning/package.json +1 -1
  32. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  33. package/extensions/git-footer/package.json +1 -1
  34. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  35. package/extensions/gnosis/package.json +1 -1
  36. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  37. package/extensions/illustrations-to-explain-things/package.json +1 -1
  38. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  39. package/extensions/inline-bash/README.md +1 -1
  40. package/extensions/inline-bash/package.json +1 -1
  41. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  42. package/extensions/librarian/index.ts +385 -106
  43. package/extensions/librarian/package.json +1 -1
  44. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  45. package/extensions/minimal-footer/package.json +1 -1
  46. package/extensions/notify/.pi-fleet-tested-version +1 -1
  47. package/extensions/notify/README.md +1 -1
  48. package/extensions/notify/package.json +1 -1
  49. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  50. package/extensions/openai-fast/package.json +1 -1
  51. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  52. package/extensions/oracle/README.md +1 -1
  53. package/extensions/oracle/index.ts +176 -33
  54. package/extensions/oracle/package.json +1 -1
  55. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  56. package/extensions/permission-gate/README.md +1 -1
  57. package/extensions/permission-gate/index.ts +453 -2
  58. package/extensions/permission-gate/package.json +1 -1
  59. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  60. package/extensions/quiet-tools/package.json +1 -1
  61. package/extensions/review/.pi-fleet-tested-version +1 -1
  62. package/extensions/review/index.ts +53 -19
  63. package/extensions/review/package.json +1 -1
  64. package/extensions/todo/.pi-fleet-tested-version +1 -1
  65. package/extensions/todo/README.md +1 -1
  66. package/extensions/todo/package.json +1 -1
  67. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  68. package/extensions/triage-comments/index.ts +107 -33
  69. package/extensions/triage-comments/package.json +1 -1
  70. package/package.json +4 -4
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-agent-workflow-audit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A pi extension that runs an isolated repo workflow audit and returns only the final report to the main session.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-git-diff",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A standalone pi extension that adds /annotate-git-diff, a native Glimpse UI for annotating git diffs and sending feedback to the editor.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-last-message",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A standalone pi extension that adds /annotate-last-message, a native Glimpse UI for annotating the latest assistant reply.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-brrr",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A pi extension that sends brrr push notifications when pi is ready for input.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-claude-fast",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A pi extension that enables Anthropic Claude Fast mode for supported Claude Opus models by injecting speed=fast.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -11,6 +11,8 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
 
14
+ import type { Usage } from "@earendil-works/pi-ai";
15
+
14
16
  const MAX_TOOL_CALLS_TO_KEEP = 80;
15
17
  const MAX_TURNS = 8;
16
18
  const MAX_RUN_MS = 8 * 60 * 1000;
@@ -463,26 +465,72 @@ function applyModelAttemptDetails(details: ReviewDetails, model: PiModel, thinki
463
465
  details.thinkingLevelNote = thinking.note;
464
466
  }
465
467
 
466
- function extractLastAssistantText(messages: unknown[]): string {
468
+ function createEmptyUsage(): Usage {
469
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
470
+ }
471
+
472
+ function addUsage(total: Usage, usage: Usage): void {
473
+ total.input += usage.input || 0;
474
+ total.output += usage.output || 0;
475
+ total.cacheRead += usage.cacheRead || 0;
476
+ total.cacheWrite += usage.cacheWrite || 0;
477
+ if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
478
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
479
+ total.totalTokens += usage.totalTokens || 0;
480
+ total.cost.input += usage.cost?.input || 0;
481
+ total.cost.output += usage.cost?.output || 0;
482
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
483
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
484
+ total.cost.total += usage.cost?.total || 0;
485
+ }
486
+
487
+ type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
488
+
489
+ function addAssistantMessageUsage(total: Usage, message: unknown): void {
490
+ const assistant = message as AssistantLikeMessage;
491
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
492
+ addUsage(total, assistant.usage as Usage);
493
+ }
494
+
495
+ function aggregateAssistantUsage(messages: unknown[]): Usage {
496
+ const total = createEmptyUsage();
497
+ for (const message of messages) addAssistantMessageUsage(total, message);
498
+ return total;
499
+ }
500
+
501
+ function addSessionEventUsage(total: Usage, event: unknown): void {
502
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
503
+ if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
504
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
505
+ }
506
+
507
+ function inspectFinalAssistant(messages: unknown[]):
508
+ | { ok: true; answer: string; stopReason?: string }
509
+ | { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
467
510
  for (let i = messages.length - 1; i >= 0; i -= 1) {
468
- const message = messages[i] as { role?: string; content?: unknown };
469
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
470
- const parts: string[] = [];
471
- for (const part of message.content) {
472
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
473
- const text = (part as { text?: unknown }).text;
474
- if (typeof text === "string") parts.push(text);
475
- }
476
- }
477
- if (parts.length > 0) return parts.join("").trim();
511
+ const message = messages[i] as AssistantLikeMessage;
512
+ if (message?.role !== "assistant") continue;
513
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
514
+ const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
515
+ const answer = Array.isArray(message.content)
516
+ ? message.content
517
+ .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
518
+ ? (part as { text: string }).text
519
+ : ""))
520
+ .join("")
521
+ .trim()
522
+ : "";
523
+ if (stopReason === "error") return { ok: false, reason: `code_reviewer subagent error: ${errorMessage || "provider/model error"}`, stopReason, errorMessage };
524
+ if (stopReason === "aborted") return { ok: false, reason: "code_reviewer subagent aborted before producing a usable answer", stopReason };
525
+ if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
526
+ return { ok: false, reason: stopReason ? `code_reviewer subagent produced no final assistant text (stopReason: ${stopReason})` : "code_reviewer subagent produced no final assistant text", stopReason: stopReason || undefined, errorMessage: errorMessage || undefined };
478
527
  }
479
- return "";
528
+ return { ok: false, reason: "code_reviewer subagent produced no assistant message" };
480
529
  }
481
530
 
482
- function isAbortLikeError(error: unknown): boolean {
483
- if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
484
- const message = error instanceof Error ? error.message : String(error);
485
- return /aborted|cancelled|canceled/i.test(message);
531
+ function classifyRunFailure(error: unknown, aborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
532
+ const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
533
+ return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
486
534
  }
487
535
 
488
536
  function isInside(parent: string, child: string): boolean {
@@ -1086,9 +1134,21 @@ export const __test__ = {
1086
1134
  normalizeThinkingLevel,
1087
1135
  resolveThinkingLevel,
1088
1136
  selectCodeReviewerModel,
1137
+ aggregateAssistantUsage,
1138
+ addAssistantMessageUsage,
1139
+ addSessionEventUsage,
1140
+ inspectFinalAssistant,
1141
+ classifyRunFailure,
1089
1142
  };
1090
1143
 
1091
1144
  export default function codeReviewerExtension(pi: ExtensionAPI) {
1145
+ pi.on("tool_result", async (event) => {
1146
+ if (event.toolName !== "code_reviewer") return undefined;
1147
+ const details = event.details as ReviewDetails | undefined;
1148
+ if (details?.status !== "error") return undefined;
1149
+ return { isError: true };
1150
+ });
1151
+
1092
1152
  pi.registerTool({
1093
1153
  name: "code_reviewer",
1094
1154
  label: "Code reviewer",
@@ -1126,24 +1186,29 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1126
1186
  let unsubscribe: (() => void) | undefined;
1127
1187
  let runTimeout: NodeJS.Timeout | undefined;
1128
1188
  let abortListenerAdded = false;
1129
- let aborted = Boolean(signal?.aborted);
1189
+ let callerAborted = Boolean(signal?.aborted);
1190
+ const usage = createEmptyUsage();
1130
1191
 
1131
1192
  const emit = () => {
1132
1193
  onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1133
1194
  };
1134
1195
 
1135
- const abort = () => {
1136
- aborted = true;
1196
+ const abortForCleanup = () => {
1197
+ void session?.abort();
1198
+ };
1199
+
1200
+ const abortFromCaller = () => {
1201
+ callerAborted = true;
1137
1202
  details.status = "aborted";
1138
1203
  details.endedAt = Date.now();
1139
1204
  lastContent = "Aborted";
1140
1205
  emit();
1141
- void session?.abort();
1206
+ abortForCleanup();
1142
1207
  };
1143
1208
 
1144
- if (signal?.aborted) abort();
1209
+ if (signal?.aborted) abortFromCaller();
1145
1210
  if (signal && !signal.aborted) {
1146
- signal.addEventListener("abort", abort);
1211
+ signal.addEventListener("abort", abortFromCaller);
1147
1212
  abortListenerAdded = true;
1148
1213
  }
1149
1214
 
@@ -1199,6 +1264,7 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1199
1264
  attemptSession = created.session;
1200
1265
  session = attemptSession;
1201
1266
  attemptUnsubscribe = attemptSession.subscribe((event) => {
1267
+ addSessionEventUsage(usage, event);
1202
1268
  switch (event.type) {
1203
1269
  case "turn_end":
1204
1270
  details.turns += 1;
@@ -1235,20 +1301,29 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1235
1301
  });
1236
1302
  unsubscribe = attemptUnsubscribe;
1237
1303
 
1238
- if (!aborted) {
1304
+ if (!callerAborted) {
1239
1305
  const promptPromise = attemptSession.prompt(buildUserPrompt(input, cwd), { expandPromptTemplates: false });
1240
1306
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
1241
1307
  runTimeout = setTimeout(() => {
1242
- abort();
1308
+ abortForCleanup();
1243
1309
  reject(new Error(`code_reviewer timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1244
1310
  }, MAX_RUN_MS);
1245
1311
  });
1246
1312
  await Promise.race([promptPromise, timeoutPromise]);
1247
1313
  }
1248
1314
 
1249
- answer = extractLastAssistantText(attemptSession.state.messages);
1250
- succeeded = true;
1251
- break;
1315
+ const outcome = inspectFinalAssistant(attemptSession.state.messages);
1316
+ if (outcome.ok) {
1317
+ answer = outcome.answer;
1318
+ succeeded = true;
1319
+ break;
1320
+ }
1321
+ lastAttemptError = outcome.reason;
1322
+ const canFallBack =
1323
+ index < modelSelection.ordered.length - 1 &&
1324
+ outcome.stopReason === "error" &&
1325
+ isModelAvailabilityError(outcome.errorMessage || outcome.reason);
1326
+ if (!canFallBack) break;
1252
1327
  } catch (error) {
1253
1328
  const message = error instanceof Error ? error.message : String(error);
1254
1329
  lastAttemptError = message;
@@ -1267,26 +1342,34 @@ export default function codeReviewerExtension(pi: ExtensionAPI) {
1267
1342
  }
1268
1343
  }
1269
1344
  }
1270
- if (!answer && !session && lastAttemptError) throw new Error(lastAttemptError);
1271
- lastContent = answer || (aborted ? "Aborted" : "(no output)");
1272
- details.status = aborted ? "aborted" : "done";
1345
+ if (!answer) {
1346
+ if (callerAborted) {
1347
+ lastContent = "Aborted";
1348
+ details.status = "aborted";
1349
+ } else {
1350
+ throw new Error(lastAttemptError || "the isolated code_reviewer session finished without a usable answer");
1351
+ }
1352
+ } else {
1353
+ lastContent = answer;
1354
+ details.status = "done";
1355
+ }
1273
1356
  details.endedAt = Date.now();
1274
1357
  const report = appendRunDetails(lastContent, details);
1275
1358
  lastContent = report;
1276
1359
  emit();
1277
- return { content: [{ type: "text", text: report }], details };
1360
+ return { content: [{ type: "text", text: report }], details, usage };
1278
1361
  } catch (error) {
1279
- const wasAbort = aborted || isAbortLikeError(error);
1280
- const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1281
- details.status = wasAbort ? "aborted" : "error";
1282
- details.error = wasAbort ? undefined : message;
1362
+ const failure = classifyRunFailure(error, callerAborted);
1363
+ const { message } = failure;
1364
+ details.status = failure.status;
1365
+ details.error = failure.error;
1283
1366
  details.endedAt = Date.now();
1284
1367
  lastContent = appendRunDetails(`## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- failure: ${message}`, details);
1285
1368
  emit();
1286
- return { content: [{ type: "text", text: lastContent }], details };
1369
+ return { content: [{ type: "text", text: lastContent }], details, usage };
1287
1370
  } finally {
1288
1371
  if (runTimeout) clearTimeout(runTimeout);
1289
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abort);
1372
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1290
1373
  unsubscribe?.();
1291
1374
  session?.dispose();
1292
1375
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-code-reviewer",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A standalone pi extension that adds a read-only code_reviewer subagent tool for isolated code reviews.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -2,7 +2,7 @@
2
2
 
3
3
  A small pi extension that asks for confirmation before destructive session actions.
4
4
 
5
- This is adapted from the original `confirm-destructive.ts` example in [`earendil-works/pi-mono`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/examples/extensions/confirm-destructive.ts) and kept basically the same.
5
+ This is adapted from the original `confirm-destructive.ts` example in [`earendil-works/pi`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/confirm-destructive.ts) and kept basically the same.
6
6
 
7
7
  ## What it checks
8
8
 
@@ -5,7 +5,24 @@
5
5
  * Demonstrates how to cancel session events using the before_* events.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
8
+ import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionEntry } from "@earendil-works/pi-coding-agent";
9
+
10
+ function hasUnsavedUserWork(entries: SessionEntry[]): boolean {
11
+ let sawAssistant = false;
12
+
13
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
14
+ const entry = entries[index];
15
+ if (entry.type !== "message") continue;
16
+ if (entry.message.role === "assistant") {
17
+ sawAssistant = true;
18
+ break;
19
+ }
20
+ if (entry.message.role === "user") return true;
21
+ }
22
+
23
+ if (sawAssistant) return false;
24
+ return entries.some((entry) => entry.type === "message" && entry.message.role === "user");
25
+ }
9
26
 
10
27
  export default function (pi: ExtensionAPI) {
11
28
  pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
@@ -24,11 +41,9 @@ export default function (pi: ExtensionAPI) {
24
41
  return;
25
42
  }
26
43
 
27
- // reason === "resume" - check if there are unsaved changes (messages since last assistant response)
44
+ // reason === "resume" - check if there are unsaved changes (user messages newer than the last assistant response)
28
45
  const entries = ctx.sessionManager.getEntries();
29
- const hasUnsavedWork = entries.some(
30
- (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
31
- );
46
+ const hasUnsavedWork = hasUnsavedUserWork(entries);
32
47
 
33
48
  if (hasUnsavedWork) {
34
49
  const confirmed = await ctx.ui.confirm(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-confirm-destructive",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A pi extension that confirms destructive session actions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-cap",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A pi extension that caps effective model context windows at 200k tokens for earlier auto-compaction.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-inspector",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A pi extension that opens a local HTML dashboard explaining where the current session context is going.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.80.6
1
+ 0.82.0
@@ -1,6 +1,6 @@
1
1
  # contrarian
2
2
 
3
- A read-only contrarian subagent for [pi](https://github.com/earendil-works/pi-mono).
3
+ A read-only contrarian subagent for [pi](https://github.com/earendil-works/pi).
4
4
 
5
5
  It adds a `contrarian` tool that spins up a separate pi subprocess to stress-test plans, designs, assumptions, bug hypotheses, review conclusions, and product directions by steelmanning the strongest credible opposing case.
6
6