pi-anti-doom-loop 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to **pi-anti-doom-loop**.
4
4
 
5
+ ## [0.0.9] — 2026-09-07
6
+
7
+ ### Added
8
+
9
+ - **Full anti-slop rule set** — synced the vendored Oxlint plugin with upstream: 5 missing generic rules (`no-module-mocking`, `no-reflect-apply`, `no-reflect-get`, `no-unknown-returns`, `require-safety-comment-for-type-assertion`), the opt-in Effect plugin (`no-service-constructor-imports`, enabled — this repo declares `effect`), and the supporting shared helpers. All 15 generic rules plus the Effect rule run at `error`.
10
+ - **Complexity gate** — `eslint/complexity` with `{ max: 10 }`; anything in the 11+ "refactor now" band fails the build.
11
+
12
+ ### Fixed
13
+
14
+ - **Within-message false positive on status lists** — `repeatedSegment` no longer splits on `:` (status updates like "worker dispatched: a. worker dispatched: b. worker dispatched: c." stay whole and distinct). Separator-less concatenation (`S:S:S:`) and truncated repeats are still caught by the new `tandemPrefix` check.
15
+ - **Complexity + slop findings in owned code** — extracted `collectBlockReasons` (`check` CC 11 → 6); replaced `as` casts at I/O boundaries with `SAFETY:` invariants, real `isManifest` / `hasStringVersion` guards, a named `PiHandlerResult` contract, and assertion-free tests.
16
+
5
17
  ## [0.0.8] — 2026-08-24
6
18
 
7
19
  ### Added
@@ -122,6 +122,7 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
122
122
  // Within-message duplicate tool-call spam fires first: it aborts (the
123
123
  // calls are already emitted, steering cannot retract them), so it must
124
124
  // outrank the steer-able text ladder.
125
+ // SAFETY: tool-call arguments arrive as JSON-safe values matching ToolInput.
125
126
  const calls = content
126
127
  .filter((c) => c.type === "toolCall")
127
128
  .map((c) => ({ toolName: c.name ?? "", input: c.arguments as ToolInput }));
@@ -113,6 +113,35 @@ export interface BlockDecision {
113
113
  escalate: boolean;
114
114
  }
115
115
 
116
+ /** Block reasons for one tool call: identical repeats, failure streak, failure rate. */
117
+ function collectBlockReasons(
118
+ toolName: string,
119
+ total: number,
120
+ consecutiveFails: number,
121
+ rate: { calls: number; errors: number; rate: number },
122
+ opts: LoopOptions,
123
+ ): string[] {
124
+ const reasons: string[] = [];
125
+ if (total >= opts.repeatThreshold) {
126
+ reasons.push(
127
+ `"${toolName}" was called with identical arguments ${total} times in the last ${opts.windowSize} tool calls with no change`,
128
+ );
129
+ }
130
+ if (consecutiveFails >= opts.failThreshold) {
131
+ reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
132
+ }
133
+ if (
134
+ opts.failRateThreshold > 0 &&
135
+ rate.calls >= opts.failRateMinCalls &&
136
+ rate.rate >= opts.failRateThreshold
137
+ ) {
138
+ reasons.push(
139
+ `"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
140
+ );
141
+ }
142
+ return reasons;
143
+ }
144
+
116
145
  /**
117
146
  * Call `check` in `tool_call` (before executing). If it returns a decision,
118
147
  * block the call. Call `record` only for calls that were NOT blocked, and
@@ -141,31 +170,18 @@ export class LoopDetector {
141
170
  const sig = signature(toolName, input);
142
171
  const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
143
172
  const total = repeats + 1; // including this call
144
- const consecutiveFails = this.consecutiveFails(toolName);
145
- const rate = this.failRate(toolName);
146
173
 
147
174
  // Rough cost accounting (feature B): every redundant repeat of an already
148
175
  // present signature burns tokens with no new information.
149
176
  if (repeats >= 1) this.wastedTokens += estimateTokens(stringify(input));
150
177
 
151
- const reasons: string[] = [];
152
- if (total >= this.opts.repeatThreshold) {
153
- reasons.push(
154
- `"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
155
- );
156
- }
157
- if (consecutiveFails >= this.opts.failThreshold) {
158
- reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
159
- }
160
- if (
161
- this.opts.failRateThreshold > 0 &&
162
- rate.calls >= this.opts.failRateMinCalls &&
163
- rate.rate >= this.opts.failRateThreshold
164
- ) {
165
- reasons.push(
166
- `"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
167
- );
168
- }
178
+ const reasons = collectBlockReasons(
179
+ toolName,
180
+ total,
181
+ this.consecutiveFails(toolName),
182
+ this.failRate(toolName),
183
+ this.opts,
184
+ );
169
185
 
170
186
  if (reasons.length === 0) return Result.err(undefined);
171
187
 
@@ -456,15 +472,20 @@ export function tokenSimilarity(a: string, b: string): number {
456
472
  * within a single normalized message, or null.
457
473
  *
458
474
  * Catches growing doom loops where the model self-concatenates the same
459
- * sentence ("…X:…X:…X") — the pattern that evaded cross-message verbatim
475
+ * sentence ("…X…X…X") — the pattern that evaded cross-message verbatim
460
476
  * detection in production (each message differs, so no streak forms).
461
477
  * Short segments (< MIN_REPEAT_CHUNK) are ignored so pasted logs with
462
478
  * repeated one-word lines never false-positive.
479
+ *
480
+ * ':' is deliberately NOT a sentence boundary: status lists like
481
+ * "worker dispatched: a. worker dispatched: b. worker dispatched: c."
482
+ * must stay whole so distinct sentences never count as repeats.
483
+ * Separator-less concatenation ("S:S:S:") is caught by tandemPrefix instead.
463
484
  */
464
485
  export function repeatedSegment(normalized: string, threshold: number): string | null {
465
486
  const segments = normalized
466
- .split(/(?<=[.:!?])\s*/)
467
- .map((s) => s.trim().replace(/[.:!?]+$/, ""))
487
+ .split(/(?<=[.!?])\s+/)
488
+ .map((s) => s.trim().replace(/[.!?]+$/, ""))
468
489
  .filter((s) => s.length >= MIN_REPEAT_CHUNK);
469
490
  const counts = new Map<string, number>();
470
491
  for (const seg of segments) {
@@ -472,9 +493,47 @@ export function repeatedSegment(normalized: string, threshold: number): string |
472
493
  if (n >= threshold) return seg;
473
494
  counts.set(seg, n);
474
495
  }
496
+ return tandemPrefix(normalized, threshold);
497
+ }
498
+
499
+ /** Messages longer than this are pasted logs, not loop utterances — skip. */
500
+ const TANDEM_MAX_LEN = 2000;
501
+ /** Longest repeated block worth scanning (real loop sentences are < 200 chars). */
502
+ const TANDEM_MAX_CHUNK = 500;
503
+
504
+ /**
505
+ * Whole-message consecutive repetition anchored at the start ("S:S:S:",
506
+ * "S: S: S:", regex fragments "X X X"). Returns the stripped block or null.
507
+ * Skips long inputs (pasted logs) and pure-separator blocks.
508
+ */
509
+ export function tandemPrefix(normalized: string, threshold: number): string | null {
510
+ if (normalized.length < MIN_REPEAT_CHUNK * threshold) return null;
511
+ if (normalized.length > TANDEM_MAX_LEN) return null;
512
+ const padded = normalized.endsWith(" ") ? normalized : `${normalized} `;
513
+ const maxL = Math.min(TANDEM_MAX_CHUNK, Math.floor((padded.length - 1) / (threshold - 1)));
514
+ for (let len = MIN_REPEAT_CHUNK; len <= maxL; len++) {
515
+ const first = stripBlock(padded.slice(0, len));
516
+ if (first.length < MIN_REPEAT_CHUNK) continue;
517
+ let ok = true;
518
+ for (let k = 1; k < threshold; k++) {
519
+ if (stripBlock(padded.slice(k * len, (k + 1) * len)) !== first) {
520
+ ok = false;
521
+ break;
522
+ }
523
+ }
524
+ if (ok) return first;
525
+ }
475
526
  return null;
476
527
  }
477
528
 
529
+ /** Compare blocks ignoring trailing separators so "X:" and "X" unify. */
530
+ function stripBlock(block: string): string {
531
+ return block
532
+ .trim()
533
+ .replace(/[.:!?\s]+$/, "")
534
+ .trim();
535
+ }
536
+
478
537
  // --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
479
538
  if (import.meta.main) {
480
539
  const opts: LoopOptions = {
@@ -612,7 +671,7 @@ if (import.meta.main) {
612
671
  const spamHit = d.checkDuplicateCalls(
613
672
  Array.from({ length: 3 }, () => ({
614
673
  toolName: "bash",
615
- input: { command: "true" } as ToolInput,
674
+ input: { command: "true" } satisfies ToolInput,
616
675
  })),
617
676
  );
618
677
  assert.ok(spamHit.isOk(), "3 identical calls in one message should fire");
@@ -38,9 +38,18 @@ import {
38
38
  } from "./controller.ts";
39
39
  import { readOptions, type ToolInput } from "./detector.ts";
40
40
 
41
+ /** What an event handler hands back to pi: a tool-call block decision, or nothing.
42
+ * Mirrors pi's own `ExtensionHandler<E, R>` contract (`Promise<R | void> | R | void`)
43
+ * narrowed to this extension's result; handlers stay synchronous here.
44
+ */
45
+ export type PiHandlerResult = { block: true; reason: string } | undefined;
46
+
41
47
  /** The subset of pi's ExtensionAPI this extension uses (structural). */
42
48
  export interface PiLike {
43
- on<E = unknown, C = unknown>(event: string, handler: (event: E, ctx: C) => unknown): void;
49
+ on<E = unknown, C = unknown>(
50
+ event: string,
51
+ handler: (event: E, ctx: C) => PiHandlerResult | void,
52
+ ): void;
44
53
  registerCommand(
45
54
  name: string,
46
55
  opts: {
@@ -78,8 +87,8 @@ export default function (pi: PiLike): void {
78
87
  pi.on("before_agent_start", () => controller.reset());
79
88
 
80
89
  pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
81
- // The pi event delivers untyped tool arguments; decode them into the
82
- // ToolInput domain type at this I/O boundary before the controller sees them.
90
+ // SAFETY: pi delivers JSON-safe tool arguments, which is exactly the ToolInput domain.
91
+ // Decode them into the ToolInput domain type at this I/O boundary before the controller sees them.
83
92
  const outcome = controller.onToolCall(
84
93
  event.toolName,
85
94
  event.input as ToolInput,
@@ -101,6 +110,7 @@ export default function (pi: PiLike): void {
101
110
  // tool calls) never reach tool_call. Steer first, abort as escalation,
102
111
  // then a bounded auto-resume so the work continues.
103
112
  pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
113
+ // SAFETY: pi delivers message content as JSON-safe blocks matching MessageContent.
104
114
  // Decode the untyped message content into MessageContent at this boundary.
105
115
  const outcome = controller.onMessageEnd(
106
116
  event.message.role,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anti-doom-loop",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
5
5
  "keywords": [
6
6
  "anti-doom-loop",
@@ -46,10 +46,10 @@
46
46
  "devDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
48
48
  "@effect/tsgo": "^0.36.4",
49
- "@oxlint/plugins": "^1.77.0",
49
+ "@oxlint/plugins": "1.77.0",
50
50
  "@types/node": "^22.0.0",
51
51
  "oxfmt": "^0.62.0",
52
- "oxlint": "^1.77.0",
52
+ "oxlint": "1.77.0",
53
53
  "oxlint-tsgolint": "^7.0.2001",
54
54
  "typescript": "^5.6.0"
55
55
  },
@@ -35,14 +35,31 @@ const readManifest: Effect.Effect<string, GuardError> = Effect.tryPromise({
35
35
  catch: () => ({ message: "GUARD FAIL: could not read package.json" }),
36
36
  });
37
37
 
38
+ /** True when an unknown JSON value has the manifest shape this guard needs. */
39
+ const isManifest = (value: unknown): value is Manifest => {
40
+ if (typeof value !== "object" || value === null) return false;
41
+ if (!("name" in value) || !("version" in value)) return false;
42
+ return typeof value.name === "string" && typeof value.version === "string";
43
+ };
44
+
45
+ /** True when an unknown JSON value carries a string version field. */
46
+ const hasStringVersion = (value: unknown): value is { version: string } => {
47
+ if (typeof value !== "object" || value === null) return false;
48
+ if (!("version" in value)) return false;
49
+ return typeof value.version === "string";
50
+ };
51
+
38
52
  const parseManifest = (raw: string): Effect.Effect<Manifest, GuardError> => {
39
- let manifest: Manifest;
53
+ let parsed: unknown;
40
54
  try {
41
- manifest = JSON.parse(raw) as Manifest;
55
+ parsed = JSON.parse(raw);
42
56
  } catch {
43
57
  return Effect.fail({ message: "GUARD FAIL: package.json is not valid JSON" });
44
58
  }
45
- return Effect.succeed(manifest);
59
+ if (!isManifest(parsed)) {
60
+ return Effect.fail({ message: "GUARD FAIL: package.json has no string name and version" });
61
+ }
62
+ return Effect.succeed(parsed);
46
63
  };
47
64
  const semverLike = (value: string): boolean => /^\d+\.\d+\.\d+$/.test(value);
48
65
 
@@ -77,7 +94,8 @@ const fetchPublished = (name: string): Effect.Effect<string | null> =>
77
94
  headers: { accept: "application/json" },
78
95
  });
79
96
  if (!res.ok) return null;
80
- return ((await res.json()) as { version?: string }).version ?? null;
97
+ const body: unknown = await res.json();
98
+ return hasStringVersion(body) ? body.version : null;
81
99
  },
82
100
  catch: () => {
83
101
  console.warn(