pi-anti-doom-loop 0.0.4 → 0.0.6
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 +103 -0
- package/README.md +34 -20
- package/extensions/controller.ts +34 -3
- package/extensions/detector.ts +224 -29
- package/extensions/index.ts +14 -2
- package/package.json +7 -3
- package/scripts/guard-publish.ts +8 -8
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **pi-anti-doom-loop**.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Near-identical text cycle detection** — `checkText` now fires when near-identical assistant texts (token similarity ≥ 55%) accumulate to the text-repeat threshold within the sliding window, even when they are not identical and not consecutive. Catches a rotating set of rephrased commands ("Run the test." / "Run tests now." / "Let me run the test.") that never repeat verbatim. New block reason prefix: _"Assistant sent near-identical text N times within the last M messages"_.
|
|
10
|
+
- **Token-cost awareness** — the detector estimates tokens burned on redundant repeats (~4 chars/token) and reports `~N tokens burned on repeats.` in tool-call block reasons; the cumulative wasted-token count appears in `/loopcheck` status.
|
|
11
|
+
- **Time-windowed eviction** — optional `PI_ANTI_LOOP_TIME_WINDOW` (elapsed-time window in ms; default `0` = disabled, count-only) so slow chronic loops spread over a long session are caught.
|
|
12
|
+
- **Failure-rate window** — optional `PI_ANTI_LOOP_FAIL_RATE` (0..1, default `0` = disabled) blocks a tool when its error share of in-window calls reaches the threshold, with `PI_ANTI_LOOP_FAIL_RATE_MIN` (default `3`) as the minimum-calls gate. Catches flaky retries interleaved with successes that never form a consecutive streak.
|
|
13
|
+
- **Per-tool allowlist** — `PI_ANTI_LOOP_TOOLS_EXCLUDE` (comma-separated tool names) disables detection for those tools entirely: they never block and never enter the window.
|
|
14
|
+
- **Richer `/loopcheck` diagnostics** — status now shows the current window contents (most-repeated recent calls and texts) and the wasted-token count, plus the fail-rate/time-window/exclude config when enabled.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Same assistant text verbatim** now uses window semantics: it blocks on `3× within the last N messages` rather than strictly "in a row", consistent with the sliding-window repeat threshold.
|
|
19
|
+
|
|
20
|
+
### Tests
|
|
21
|
+
|
|
22
|
+
- Detection and regression coverage for the near-identical text cycle, token-cost reporting, time-windowed eviction, failure-rate window, per-tool exclusion, and the richer `/loopcheck` status.
|
|
23
|
+
|
|
24
|
+
## [0.0.5] — 2026-08-05
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`/loopcheck suspend` / `resume`** — escape hatch: pause detection until the next prompt when repetition is intentional (polling, retrying a deploy). No env restart needed.
|
|
29
|
+
- **Session counters** — `steers`/`aborts` appear in `/loopcheck` status; session-scoped (survive per-prompt resets, reset on `/loopcheck reset` or new session).
|
|
30
|
+
- Fixture for the live regex-repeat capture (`parameter\s+(\S+)\s+("` 3× in one message) — regression-covered via the within-message signal.
|
|
31
|
+
- `CHANGELOG.md` ships in the tarball.
|
|
32
|
+
|
|
33
|
+
### Tests
|
|
34
|
+
|
|
35
|
+
- 72 → 75: suspend/resume lifecycle, session counters, regex-repeat fixture, `/loopcheck` subcommands.
|
|
36
|
+
|
|
37
|
+
## [0.0.4] — 2026-08-05
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Near-identical text detection** — token-set Jaccard similarity ≥ 0.55 on consecutive assistant messages counts as a loop streak. Catches rephrasing loops where the model changes one or two words per turn ("inspect the failing test" → "examine the failing assertion") that exact matching missed.
|
|
42
|
+
- **Escalation ladder for text loops: steer → abort → bounded resume** — the first detection steers the agent mid-run (injects guidance, work continues); a persistent loop aborts the run and queues **one** fresh-resume directive (`followUp`) so work continues with a new approach; further looping aborts for real and hands control back. The auto-resume budget is session-scoped so a stuck model cannot cycle forever.
|
|
43
|
+
- Detector now fires on every message while a loop condition holds; the controller turns the 1st into a steer, later ones into aborts.
|
|
44
|
+
- Tests: 65 → 72 (token-similarity unit cases, near-identical streak + reset, steer→abort→resume ladder, budget persistence, adapter wiring, fuzz no-FP + injected-3× properties).
|
|
45
|
+
|
|
46
|
+
## [0.0.3] — 2026-08-05
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Within-message self-repetition detection** — a sentence repeating 3+ times inside ONE message (growing self-concatenation loops like "…X:…X:…X") now aborts/steers immediately. Liquid Antidoom's loop definition ("a section repeats at least N times").
|
|
51
|
+
- Segmenter ignores chunks < 16 chars so pasted logs with repeated one-word lines never false-positive.
|
|
52
|
+
- Fixtures for the production growing-loop capture (exact + spaced variants).
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Production gap: loops that grow by self-concatenation evaded both verbatim-streak (messages differ) and identical-call (args vary) detection. Now caught at the message itself.
|
|
57
|
+
|
|
58
|
+
### Tests
|
|
59
|
+
|
|
60
|
+
- 51 → 65: `repeatedSegment` unit cases, growing-pattern `checkText` cases, fixture controller paths, fuzz no-FP + injected-3× properties.
|
|
61
|
+
|
|
62
|
+
## [0.0.2] — 2026-08-05
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- **Full test suite** — Node's built-in `node:test`, no framework:
|
|
67
|
+
- unit (detector semantics, options clamping, helpers)
|
|
68
|
+
- fixtures (real doom-loop transcripts: CI-log loops, verbatim repeats; healthy sessions not flagged)
|
|
69
|
+
- fuzz (seeded: never throws, no false positives, injected loops always block)
|
|
70
|
+
- integration (controller + adapter driven through a fake `PiLike`)
|
|
71
|
+
- e2e (real subprocesses: detector self-check, version guard, tarball contents)
|
|
72
|
+
- **`controller.ts`** — extracted all event logic into a pure, pi-free, testable module; `index.ts` became a thin structural adapter.
|
|
73
|
+
- `npm test`, `engines >= 22.18`, `files` includes `scripts/`.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- Thresholds clamped to a minimum of 2 (a config of 1 would brick the agent — deepsec finding).
|
|
78
|
+
- GitHub Actions pinned to commit SHAs (NPM_TOKEN in scope — deepsec finding).
|
|
79
|
+
- Release job `concurrency` group (publish race / TOCTOU).
|
|
80
|
+
- `npm publish --dry-run` preflight before the real publish.
|
|
81
|
+
- Version guard requires the git tag `v$version` on the manual-dispatch path.
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- Leveraged `better-result` (detector decisions) and `effect` v4 (release guard).
|
|
86
|
+
|
|
87
|
+
## [0.0.1] — 2026-08-05
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- Initial release. Detects and breaks agent doom loops in pi:
|
|
92
|
+
- **identical (tool, args) repeated** 3× in the last 10 calls → block with an instructive reason
|
|
93
|
+
- **same tool failing** 3× consecutively → block with "stop retrying, fix the root cause"
|
|
94
|
+
- escalation: re-issuing a blocked call aborts the turn
|
|
95
|
+
- `/loopcheck` command (status + reset).
|
|
96
|
+
- Counters reset per user prompt (legit repeated tasks are never false positives).
|
|
97
|
+
- GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
|
|
98
|
+
- `pi-package` keyword + `pi` manifest for the pi.dev gallery.
|
|
99
|
+
|
|
100
|
+
[0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
|
|
101
|
+
[0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
|
|
102
|
+
[0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
|
|
103
|
+
[0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
|
package/README.md
CHANGED
|
@@ -15,13 +15,14 @@ pi install npm:pi-anti-doom-loop
|
|
|
15
15
|
|
|
16
16
|
## What it detects
|
|
17
17
|
|
|
18
|
-
| Signal
|
|
19
|
-
|
|
|
20
|
-
| Same `(tool, args)` repeated
|
|
21
|
-
| Same tool failing consecutively
|
|
22
|
-
| Same assistant text verbatim
|
|
23
|
-
| Same sentence inside ONE message
|
|
24
|
-
| Near-identical text (rephrasing)
|
|
18
|
+
| Signal | Default | Blocked when |
|
|
19
|
+
| ------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
|
|
21
|
+
| Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
|
|
22
|
+
| Same assistant text verbatim | 3× within the last N messages | The model re-emitted identical text `3` times inside the sliding window |
|
|
23
|
+
| Same sentence inside ONE message | 3× | A sentence repeats `3`+ times within a single message (growing self-concatenation loops) |
|
|
24
|
+
| Near-identical text (rephrasing) | 3× in a row | Consecutive messages share ≥55% tokens — the model is rephrasing the same step |
|
|
25
|
+
| Near-identical text cycle (rotating rephrased commands) | 3× within the last N messages | Near-identical assistant texts (≥55% token similarity) accumulate to the repeat threshold in the window, even when not identical and not consecutive |
|
|
25
26
|
|
|
26
27
|
Blocks hand the model an instructive reason ("change your approach, use a
|
|
27
28
|
different tool, or ask the user"). If the model ignores the block and re-issues
|
|
@@ -41,29 +42,42 @@ the same session is never a false positive.
|
|
|
41
42
|
|
|
42
43
|
Environment variables, read at session/prompt start:
|
|
43
44
|
|
|
44
|
-
| Variable
|
|
45
|
-
|
|
|
46
|
-
| `PI_ANTI_LOOP_REPEATS`
|
|
47
|
-
| `PI_ANTI_LOOP_FAILS`
|
|
48
|
-
| `PI_ANTI_LOOP_TEXT_REPEATS`
|
|
49
|
-
| `PI_ANTI_LOOP_WINDOW`
|
|
50
|
-
| `
|
|
45
|
+
| Variable | Default | Meaning |
|
|
46
|
+
| ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| `PI_ANTI_LOOP_REPEATS` | `3` | Identical-call block threshold |
|
|
48
|
+
| `PI_ANTI_LOOP_FAILS` | `3` | Consecutive-failure block threshold |
|
|
49
|
+
| `PI_ANTI_LOOP_TEXT_REPEATS` | `3` | Window/cycle repeat threshold for identical and near-identical assistant texts |
|
|
50
|
+
| `PI_ANTI_LOOP_WINDOW` | `10` | How many recent calls/results are inspected |
|
|
51
|
+
| `PI_ANTI_LOOP_TIME_WINDOW` | `0` | Elapsed-time window in ms (`0` = disabled, count-only): evicts window entries older than this so slow chronic loops over a long session are caught |
|
|
52
|
+
| `PI_ANTI_LOOP_FAIL_RATE` | `0` | Fail-rate block threshold `0..1` (`0` = disabled): block when a tool's error share of its in-window calls reaches this |
|
|
53
|
+
| `PI_ANTI_LOOP_FAIL_RATE_MIN` | `3` | Minimum calls before the fail-rate window can block |
|
|
54
|
+
| `PI_ANTI_LOOP_TOOLS_EXCLUDE` | — | Comma-separated tool names to disable detection for entirely (never block, never enter the window) |
|
|
55
|
+
| `PI_ANTI_LOOP_DISABLE` | — | Set to `1` to disable the extension entirely |
|
|
51
56
|
|
|
52
57
|
## Command
|
|
53
58
|
|
|
54
|
-
- `/loopcheck` — show current
|
|
59
|
+
- `/loopcheck` — show thresholds, counters (steers/aborts this session), suspend state, the current window contents (most-repeated recent calls and texts), wasted-token count, and the fail-rate/time-window/exclude config when enabled
|
|
55
60
|
- `/loopcheck reset` — clear counters
|
|
61
|
+
- `/loopcheck suspend` — pause detection until the next prompt (escape hatch for intentional repetition)
|
|
62
|
+
- `/loopcheck resume` — re-enable detection early
|
|
63
|
+
|
|
64
|
+
## Token-cost awareness
|
|
65
|
+
|
|
66
|
+
The detector estimates tokens burned on redundant repeats (~4 chars/token) and
|
|
67
|
+
reports `"~N tokens burned on repeats."` in tool-call block reasons. The
|
|
68
|
+
cumulative wasted-token count also appears in `/loopcheck` status, so you can
|
|
69
|
+
see how much a loop actually cost before it was stopped.
|
|
56
70
|
|
|
57
71
|
## How it works
|
|
58
72
|
|
|
59
|
-
Everything hooks into the `tool_call` / `tool_result` events;
|
|
60
|
-
small sliding-window counter (see `extensions/detector.ts`) with
|
|
61
|
-
|
|
62
|
-
often.
|
|
73
|
+
Everything hooks into the `tool_call` / `tool_result` / `message_end` events;
|
|
74
|
+
detection is a small sliding-window counter (see `extensions/detector.ts`) with
|
|
75
|
+
per-session counters (steers/aborts) tracked in `extensions/controller.ts`.
|
|
76
|
+
Works with any model — cheap models just trigger it more often.
|
|
63
77
|
|
|
64
78
|
## Development
|
|
65
79
|
|
|
66
|
-
Requires Node 22.
|
|
80
|
+
Requires Node 22.18+ (plain `node` runs the TS self-check).
|
|
67
81
|
|
|
68
82
|
```bash
|
|
69
83
|
npm install
|
package/extensions/controller.ts
CHANGED
|
@@ -63,6 +63,10 @@ export interface AntiLoopController {
|
|
|
63
63
|
onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
|
|
64
64
|
/** Full reset (session start, user prompt, /loopcheck reset). */
|
|
65
65
|
reset(): void;
|
|
66
|
+
/** Suspend detection until the next reset (escape hatch for intentional repetition). */
|
|
67
|
+
suspend(): void;
|
|
68
|
+
resume(): void;
|
|
69
|
+
isSuspended(): boolean;
|
|
66
70
|
/** Human-readable status with thresholds + counters for /loopcheck. */
|
|
67
71
|
status(): string;
|
|
68
72
|
}
|
|
@@ -72,9 +76,13 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
72
76
|
const blockedIds = new Set<string>();
|
|
73
77
|
let steered = false;
|
|
74
78
|
let resumes = 0;
|
|
79
|
+
let steers = 0;
|
|
80
|
+
let aborts = 0;
|
|
81
|
+
let suspended = false;
|
|
75
82
|
|
|
76
83
|
return {
|
|
77
84
|
onToolCall(toolName, input, toolCallId) {
|
|
85
|
+
if (suspended) return null;
|
|
78
86
|
const decision = detector.check(toolName, input);
|
|
79
87
|
if (decision.isErr()) {
|
|
80
88
|
detector.record(toolName, input);
|
|
@@ -96,6 +104,7 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
96
104
|
},
|
|
97
105
|
|
|
98
106
|
onMessageEnd(role, content) {
|
|
107
|
+
if (suspended) return null;
|
|
99
108
|
if (role !== "assistant") return null;
|
|
100
109
|
const text = extractText(content);
|
|
101
110
|
if (!text) return null;
|
|
@@ -105,12 +114,15 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
105
114
|
const reason = hit.value.reason;
|
|
106
115
|
if (!steered) {
|
|
107
116
|
steered = true;
|
|
117
|
+
steers++;
|
|
108
118
|
return { reason, action: "steer", resume: false };
|
|
109
119
|
}
|
|
110
120
|
if (resumes < RESUME_BUDGET) {
|
|
111
121
|
resumes++;
|
|
122
|
+
aborts++;
|
|
112
123
|
return { reason, action: "abort", resume: true };
|
|
113
124
|
}
|
|
125
|
+
aborts++;
|
|
114
126
|
return { reason, action: "abort", resume: false };
|
|
115
127
|
},
|
|
116
128
|
|
|
@@ -118,15 +130,34 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
118
130
|
detector = new LoopDetector(opts);
|
|
119
131
|
blockedIds.clear();
|
|
120
132
|
steered = false;
|
|
121
|
-
|
|
122
|
-
//
|
|
133
|
+
suspended = false;
|
|
134
|
+
// resumes/steers/aborts are intentionally NOT reset here: they are
|
|
135
|
+
// session-scoped so a stuck model cannot cycle steer→abort forever and
|
|
136
|
+
// /loopcheck can report lifetime counters.
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
suspend() {
|
|
140
|
+
suspended = true;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
resume() {
|
|
144
|
+
suspended = false;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
isSuspended() {
|
|
148
|
+
return suspended;
|
|
123
149
|
},
|
|
124
150
|
|
|
125
151
|
status() {
|
|
126
152
|
const o = detector.opts;
|
|
153
|
+
const s = suspended ? ", suspended" : "";
|
|
154
|
+
const rate = o.failRateThreshold > 0 ? `, failRate>=${o.failRateThreshold}` : "";
|
|
155
|
+
const time = o.timeWindowMs > 0 ? `, window ${o.timeWindowMs}ms` : "";
|
|
156
|
+
const excl = o.toolExclude.size ? `, exclude[${[...o.toolExclude].join(",")}]` : "";
|
|
127
157
|
return (
|
|
128
158
|
`anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
|
|
129
|
-
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}
|
|
159
|
+
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}${rate}${time}${excl}. ` +
|
|
160
|
+
`${detector.diagnostics()} steers=${steers} aborts=${aborts}${s}`
|
|
130
161
|
);
|
|
131
162
|
},
|
|
132
163
|
};
|
package/extensions/detector.ts
CHANGED
|
@@ -20,8 +20,21 @@ export interface LoopOptions {
|
|
|
20
20
|
failThreshold: number;
|
|
21
21
|
/** How many recent calls/results are inspected for repetition. */
|
|
22
22
|
windowSize: number;
|
|
23
|
-
/**
|
|
23
|
+
/** Verbatim/near-identical assistant-message repeats that trigger a block. */
|
|
24
24
|
textRepeatThreshold: number;
|
|
25
|
+
/** Evict window entries older than this many ms (0 = count-only window). */
|
|
26
|
+
timeWindowMs: number;
|
|
27
|
+
/**
|
|
28
|
+
* Failure-rate threshold (0..1). When a tool's error share of its calls in
|
|
29
|
+
* the window is >= this (and it has >= failRateMinCalls calls), block. This
|
|
30
|
+
* catches flaky/ungrounded retries that are interleaved with successes and
|
|
31
|
+
* never form a consecutive streak. 0 = disabled.
|
|
32
|
+
*/
|
|
33
|
+
failRateThreshold: number;
|
|
34
|
+
/** Minimum calls before the failure-rate signal applies. */
|
|
35
|
+
failRateMinCalls: number;
|
|
36
|
+
/** Tool names to skip detection for entirely (intentional repetition). */
|
|
37
|
+
toolExclude: Set<string>;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
export const DEFAULT_OPTIONS: LoopOptions = {
|
|
@@ -29,6 +42,10 @@ export const DEFAULT_OPTIONS: LoopOptions = {
|
|
|
29
42
|
failThreshold: 3,
|
|
30
43
|
windowSize: 10,
|
|
31
44
|
textRepeatThreshold: 3,
|
|
45
|
+
timeWindowMs: 0,
|
|
46
|
+
failRateThreshold: 0,
|
|
47
|
+
failRateMinCalls: 3,
|
|
48
|
+
toolExclude: new Set(),
|
|
32
49
|
};
|
|
33
50
|
|
|
34
51
|
export function readOptions(env: Record<string, string | undefined> = process.env): LoopOptions {
|
|
@@ -40,11 +57,23 @@ export function readOptions(env: Record<string, string | undefined> = process.en
|
|
|
40
57
|
const n = Number(raw);
|
|
41
58
|
return Number.isFinite(n) && n >= 2 ? n : fallback;
|
|
42
59
|
};
|
|
60
|
+
const timeWindow = Number(env["PI_ANTI_LOOP_TIME_WINDOW"] ?? "0");
|
|
61
|
+
const failRate = Number(env["PI_ANTI_LOOP_FAIL_RATE"] ?? "0");
|
|
62
|
+
const toolExclude = new Set(
|
|
63
|
+
(env["PI_ANTI_LOOP_TOOLS_EXCLUDE"] ?? "")
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter(Boolean),
|
|
67
|
+
);
|
|
43
68
|
return {
|
|
44
69
|
repeatThreshold: num("PI_ANTI_LOOP_REPEATS", DEFAULT_OPTIONS.repeatThreshold),
|
|
45
70
|
failThreshold: num("PI_ANTI_LOOP_FAILS", DEFAULT_OPTIONS.failThreshold),
|
|
46
71
|
windowSize: num("PI_ANTI_LOOP_WINDOW", DEFAULT_OPTIONS.windowSize),
|
|
47
72
|
textRepeatThreshold: num("PI_ANTI_LOOP_TEXT_REPEATS", DEFAULT_OPTIONS.textRepeatThreshold),
|
|
73
|
+
timeWindowMs: Number.isFinite(timeWindow) && timeWindow >= 0 ? timeWindow : 0,
|
|
74
|
+
failRateThreshold: Number.isFinite(failRate) ? Math.min(1, Math.max(0, failRate)) : 0,
|
|
75
|
+
failRateMinCalls: num("PI_ANTI_LOOP_FAIL_RATE_MIN", DEFAULT_OPTIONS.failRateMinCalls),
|
|
76
|
+
toolExclude,
|
|
48
77
|
};
|
|
49
78
|
}
|
|
50
79
|
|
|
@@ -77,25 +106,33 @@ export interface BlockDecision {
|
|
|
77
106
|
*/
|
|
78
107
|
export class LoopDetector {
|
|
79
108
|
readonly opts: LoopOptions;
|
|
80
|
-
private recentSigs: string[] = [];
|
|
81
|
-
private recentResults: { tool: string; error: boolean }[] = [];
|
|
109
|
+
private recentSigs: { sig: string; ts: number }[] = [];
|
|
110
|
+
private recentResults: { tool: string; error: boolean; ts: number }[] = [];
|
|
82
111
|
private blockedBySig = new Map<string, number>();
|
|
112
|
+
private recentTexts: { text: string; ts: number }[] = [];
|
|
83
113
|
private lastText: string | null = null;
|
|
84
114
|
private textStreak = 0;
|
|
85
|
-
private
|
|
115
|
+
private wastedTokens = 0;
|
|
86
116
|
|
|
87
117
|
constructor(opts: LoopOptions = DEFAULT_OPTIONS) {
|
|
88
118
|
this.opts = opts;
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
|
|
122
|
+
if (this.opts.toolExclude.has(toolName)) {
|
|
123
|
+
// record() is a no-op for excluded tools, so nothing enters the window.
|
|
124
|
+
return Result.err(undefined);
|
|
125
|
+
}
|
|
126
|
+
this.evictSigs();
|
|
92
127
|
const sig = signature(toolName, input);
|
|
93
|
-
const repeats = this.recentSigs.filter((s) => s === sig).length;
|
|
128
|
+
const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
|
|
94
129
|
const total = repeats + 1; // including this call
|
|
95
|
-
const
|
|
130
|
+
const consecutiveFails = this.consecutiveFails(toolName);
|
|
131
|
+
const rate = this.failRate(toolName);
|
|
96
132
|
|
|
97
|
-
|
|
98
|
-
|
|
133
|
+
// Rough cost accounting (feature B): every redundant repeat of an already
|
|
134
|
+
// present signature burns tokens with no new information.
|
|
135
|
+
if (repeats >= 1) this.wastedTokens += estimateTokens(stringify(input));
|
|
99
136
|
|
|
100
137
|
const reasons: string[] = [];
|
|
101
138
|
if (total >= this.opts.repeatThreshold) {
|
|
@@ -103,36 +140,49 @@ export class LoopDetector {
|
|
|
103
140
|
`"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
|
|
104
141
|
);
|
|
105
142
|
}
|
|
106
|
-
if (
|
|
107
|
-
reasons.push(`"${toolName}" failed ${
|
|
143
|
+
if (consecutiveFails >= this.opts.failThreshold) {
|
|
144
|
+
reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
|
|
108
145
|
}
|
|
146
|
+
if (
|
|
147
|
+
this.opts.failRateThreshold > 0 &&
|
|
148
|
+
rate.calls >= this.opts.failRateMinCalls &&
|
|
149
|
+
rate.rate >= this.opts.failRateThreshold
|
|
150
|
+
) {
|
|
151
|
+
reasons.push(
|
|
152
|
+
`"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (reasons.length === 0) return Result.err(undefined);
|
|
109
157
|
|
|
110
158
|
const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
|
|
111
159
|
this.blockedBySig.set(sig, blockedCount);
|
|
160
|
+
const cost = this.wastedTokens > 0 ? ` ~${this.wastedTokens} tokens burned on repeats.` : "";
|
|
112
161
|
|
|
113
162
|
return Result.ok({
|
|
114
163
|
reason:
|
|
115
164
|
reasons.join("; ") +
|
|
165
|
+
cost +
|
|
116
166
|
`. BLOCKED by anti-doom-loop — you appear to be looping. Change your approach, use a different tool, or ask the user.`,
|
|
117
167
|
escalate: blockedCount > 1,
|
|
118
168
|
});
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
record(toolName: string, input: unknown): void {
|
|
122
|
-
this.
|
|
123
|
-
|
|
172
|
+
if (this.opts.toolExclude.has(toolName)) return;
|
|
173
|
+
this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
|
|
174
|
+
this.evictSigs();
|
|
124
175
|
}
|
|
125
176
|
|
|
126
177
|
recordResult(toolName: string, error: boolean): void {
|
|
127
|
-
this.recentResults.push({ tool: toolName, error });
|
|
128
|
-
|
|
178
|
+
this.recentResults.push({ tool: toolName, error, ts: Date.now() });
|
|
179
|
+
this.evictResults();
|
|
129
180
|
}
|
|
130
181
|
|
|
131
182
|
/**
|
|
132
|
-
* Consecutive verbatim assistant text (whitespace-normalized).
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* $
|
|
183
|
+
* Consecutive verbatim/near-identical assistant text (whitespace-normalized).
|
|
184
|
+
* The controller turns the first detection into a steer, later ones into
|
|
185
|
+
* aborts. $
|
|
136
186
|
* Detects the text-only loop shape (model re-emits the same sentence
|
|
137
187
|
* forever, e.g. goal-function loops) that identical-tool-call detection
|
|
138
188
|
* never sees. Liquid.ai's Antidoom mines loops as 'a section repeats at
|
|
@@ -156,6 +206,24 @@ export class LoopDetector {
|
|
|
156
206
|
});
|
|
157
207
|
}
|
|
158
208
|
|
|
209
|
+
// Cross-message window repeat (exact): the same text reappearing
|
|
210
|
+
// textRepeatThreshold times within the recent-text window. Catches text
|
|
211
|
+
// CYCLES that never form a 3-consecutive streak and are too short for
|
|
212
|
+
// repeatedSegment. Mirrors the tool-signature window in check().
|
|
213
|
+
const exactCount = this.recentTexts.filter((t) => t.text === norm).length + 1;
|
|
214
|
+
this.recentTexts.push({ text: norm, ts: Date.now() });
|
|
215
|
+
this.evictTexts();
|
|
216
|
+
if (this.recentTexts.filter((t) => t.text === norm).length >= 2) {
|
|
217
|
+
this.wastedTokens += estimateTokens(norm);
|
|
218
|
+
}
|
|
219
|
+
if (exactCount >= this.opts.textRepeatThreshold) {
|
|
220
|
+
return Result.ok({
|
|
221
|
+
reason:
|
|
222
|
+
`Assistant sent identical text ${exactCount} times within the last ${this.opts.windowSize} messages ` +
|
|
223
|
+
`("${truncate(norm, 80)}"). You appear to be in a loop.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
159
227
|
// Cross-message streak: consecutive assistant texts that are identical
|
|
160
228
|
// OR near-identical (token-overlap similarity). Catches loops where the
|
|
161
229
|
// model slightly rephrases each turn ("inspect the failing test" →
|
|
@@ -172,6 +240,23 @@ export class LoopDetector {
|
|
|
172
240
|
`("${truncate(norm, 80)}"). You appear to be in a loop.`,
|
|
173
241
|
});
|
|
174
242
|
}
|
|
243
|
+
|
|
244
|
+
// Cross-message window repeat (near-identical): a rotating set of
|
|
245
|
+
// rephrased commands ("Run the test." / "Run tests now." / "Let me run
|
|
246
|
+
// the test.") that is never identical and never consecutive, so both the
|
|
247
|
+
// exact window check and the streak above miss it. Similar, non-identical
|
|
248
|
+
// texts accumulating to textRepeatThreshold within the window fire here.
|
|
249
|
+
const similarCount =
|
|
250
|
+
this.recentTexts.filter(
|
|
251
|
+
(t) => t.text !== norm && tokenSimilarity(norm, t.text) >= TEXT_SIMILARITY_THRESHOLD,
|
|
252
|
+
).length + 1;
|
|
253
|
+
if (similarCount >= this.opts.textRepeatThreshold) {
|
|
254
|
+
return Result.ok({
|
|
255
|
+
reason:
|
|
256
|
+
`Assistant sent near-identical text ${similarCount} times within the last ${this.opts.windowSize} messages ` +
|
|
257
|
+
`("${truncate(norm, 80)}"). You appear to be in a loop.`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
175
260
|
return Result.err(undefined);
|
|
176
261
|
}
|
|
177
262
|
|
|
@@ -186,13 +271,80 @@ export class LoopDetector {
|
|
|
186
271
|
return n;
|
|
187
272
|
}
|
|
188
273
|
|
|
274
|
+
/** Error share of all in-window results for a tool. */
|
|
275
|
+
private failRate(toolName: string): { calls: number; errors: number; rate: number } {
|
|
276
|
+
let calls = 0;
|
|
277
|
+
let errors = 0;
|
|
278
|
+
for (const r of this.recentResults) {
|
|
279
|
+
if (r.tool !== toolName) continue;
|
|
280
|
+
calls++;
|
|
281
|
+
if (r.error) errors++;
|
|
282
|
+
}
|
|
283
|
+
return { calls, errors, rate: calls ? errors / calls : 0 };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private evictSigs(): void {
|
|
287
|
+
if (this.opts.timeWindowMs > 0) {
|
|
288
|
+
const cutoff = Date.now() - this.opts.timeWindowMs;
|
|
289
|
+
this.recentSigs = this.recentSigs.filter((s) => s.ts >= cutoff);
|
|
290
|
+
}
|
|
291
|
+
while (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private evictResults(): void {
|
|
295
|
+
if (this.opts.timeWindowMs > 0) {
|
|
296
|
+
const cutoff = Date.now() - this.opts.timeWindowMs;
|
|
297
|
+
this.recentResults = this.recentResults.filter((r) => r.ts >= cutoff);
|
|
298
|
+
}
|
|
299
|
+
while (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private evictTexts(): void {
|
|
303
|
+
if (this.opts.timeWindowMs > 0) {
|
|
304
|
+
const cutoff = Date.now() - this.opts.timeWindowMs;
|
|
305
|
+
this.recentTexts = this.recentTexts.filter((t) => t.ts >= cutoff);
|
|
306
|
+
}
|
|
307
|
+
while (this.recentTexts.length > this.opts.windowSize) this.recentTexts.shift();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Estimated tokens burned on redundant repeats (feature B). */
|
|
311
|
+
wastedTokensCount(): number {
|
|
312
|
+
return this.wastedTokens;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Human-readable window introspection for /loopcheck (feature F). */
|
|
316
|
+
diagnostics(): string {
|
|
317
|
+
const sigCounts = new Map<string, number>();
|
|
318
|
+
for (const s of this.recentSigs) sigCounts.set(s.sig, (sigCounts.get(s.sig) ?? 0) + 1);
|
|
319
|
+
const topSigs = [...sigCounts.entries()]
|
|
320
|
+
.sort((a, b) => b[1] - a[1])
|
|
321
|
+
.slice(0, 3)
|
|
322
|
+
.map(([s, n]) => `${truncate(s, 40)} x${n}`)
|
|
323
|
+
.join(", ");
|
|
324
|
+
|
|
325
|
+
const textCounts = new Map<string, number>();
|
|
326
|
+
for (const t of this.recentTexts) textCounts.set(t.text, (textCounts.get(t.text) ?? 0) + 1);
|
|
327
|
+
const topTexts = [...textCounts.entries()]
|
|
328
|
+
.sort((a, b) => b[1] - a[1])
|
|
329
|
+
.slice(0, 3)
|
|
330
|
+
.map(([s, n]) => `"${truncate(s, 24)}" x${n}`)
|
|
331
|
+
.join(", ");
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
`calls[${this.recentSigs.length}] ${topSigs || "none"}; ` +
|
|
335
|
+
`texts[${this.recentTexts.length}] ${topTexts || "none"}; ` +
|
|
336
|
+
`wastedTokens=${this.wastedTokens}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
189
340
|
reset(): void {
|
|
190
341
|
this.recentSigs = [];
|
|
191
342
|
this.recentResults = [];
|
|
192
343
|
this.blockedBySig.clear();
|
|
344
|
+
this.recentTexts = [];
|
|
193
345
|
this.lastText = null;
|
|
194
346
|
this.textStreak = 0;
|
|
195
|
-
this.
|
|
347
|
+
this.wastedTokens = 0;
|
|
196
348
|
}
|
|
197
349
|
|
|
198
350
|
summary(): string {
|
|
@@ -218,6 +370,26 @@ export const MIN_REPEAT_CHUNK = 16;
|
|
|
218
370
|
/** Jaccard similarity threshold for "near-identical" consecutive texts. */
|
|
219
371
|
export const TEXT_SIMILARITY_THRESHOLD = 0.55;
|
|
220
372
|
|
|
373
|
+
/** Stable string form of an arbitrary tool input (used for token estimation). */
|
|
374
|
+
export function stringify(input: unknown): string {
|
|
375
|
+
if (typeof input === "string") return input;
|
|
376
|
+
try {
|
|
377
|
+
return JSON.stringify(input);
|
|
378
|
+
} catch {
|
|
379
|
+
return String(input);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Rough token estimate for cost accounting (feature B): ~4 chars per token,
|
|
385
|
+
* like the widely-used wc/4 rule. Purposely crude — it only needs to be
|
|
386
|
+
* monotonic so the same work always reports the same order of magnitude.
|
|
387
|
+
*/
|
|
388
|
+
export function estimateTokens(text: string): number {
|
|
389
|
+
if (!text) return 0;
|
|
390
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
391
|
+
}
|
|
392
|
+
|
|
221
393
|
/**
|
|
222
394
|
* Token-set Jaccard similarity of two texts (case/whitespace-insensitive).
|
|
223
395
|
* Short tokens (< 3 chars: "a", "me", "to") are ignored to reduce noise.
|
|
@@ -271,6 +443,10 @@ if (import.meta.main) {
|
|
|
271
443
|
failThreshold: 3,
|
|
272
444
|
windowSize: 10,
|
|
273
445
|
textRepeatThreshold: 3,
|
|
446
|
+
timeWindowMs: 0,
|
|
447
|
+
failRateThreshold: 0,
|
|
448
|
+
failRateMinCalls: 3,
|
|
449
|
+
toolExclude: new Set(),
|
|
274
450
|
};
|
|
275
451
|
const d: LoopDetector = new LoopDetector(opts);
|
|
276
452
|
|
|
@@ -330,7 +506,7 @@ if (import.meta.main) {
|
|
|
330
506
|
const textHit = d.checkText("Now update buildProgram.");
|
|
331
507
|
assert.ok(textHit.isOk(), "3rd identical text should fire");
|
|
332
508
|
if (textHit.isOk()) {
|
|
333
|
-
assert.match(textHit.value.reason, /identical
|
|
509
|
+
assert.match(textHit.value.reason, /identical text 3 times within the last/);
|
|
334
510
|
}
|
|
335
511
|
assert.ok(
|
|
336
512
|
d.checkText("Now update buildProgram.").isOk(),
|
|
@@ -345,23 +521,42 @@ if (import.meta.main) {
|
|
|
345
521
|
const wsHit = d.checkText(" Read the region: ");
|
|
346
522
|
assert.ok(wsHit.isOk(), "whitespace-normalized repeats should fire");
|
|
347
523
|
|
|
348
|
-
// 9.
|
|
524
|
+
// 9. window semantics: < textRepeatThreshold recurrences in the window do
|
|
525
|
+
// NOT fire, even with other messages interleaved; the 3rd recurrence does.
|
|
349
526
|
d.reset();
|
|
350
527
|
d.checkText("A");
|
|
351
|
-
d.checkText("
|
|
352
|
-
d.checkText("
|
|
353
|
-
d.checkText("
|
|
354
|
-
assert.ok(d.checkText("A").
|
|
355
|
-
const again = d.checkText("A");
|
|
356
|
-
assert.ok(again.isOk(), "3 consecutive As after the break fire");
|
|
528
|
+
d.checkText("B"); // different message
|
|
529
|
+
d.checkText("A"); // 2nd A in window
|
|
530
|
+
assert.ok(d.checkText("C").isErr(), "2 As in the window must not fire");
|
|
531
|
+
assert.ok(d.checkText("A").isOk(), "3rd A in the window fires");
|
|
357
532
|
|
|
358
533
|
// 10. empty/blank text is ignored as a loop signal
|
|
359
534
|
d.reset();
|
|
360
535
|
d.checkText("");
|
|
361
536
|
d.checkText(" ");
|
|
362
537
|
assert.ok(d.checkText("").isErr(), "blank text must not fire");
|
|
363
|
-
|
|
364
|
-
//
|
|
538
|
+
// 11. text CYCLES: a small set of short near-identical commands rotating
|
|
539
|
+
// ("Let me run. GO." / "Run. GO." / "GO.") never forms a consecutive
|
|
540
|
+
// streak, but the same text reappears >= threshold within the window.
|
|
541
|
+
d.reset();
|
|
542
|
+
const cycle = [
|
|
543
|
+
"Let me run. GO.",
|
|
544
|
+
"Run. GO.",
|
|
545
|
+
"GO.",
|
|
546
|
+
"Run. GO.",
|
|
547
|
+
"GO.",
|
|
548
|
+
"Let me run. GO.",
|
|
549
|
+
"Run. GO.",
|
|
550
|
+
"GO.",
|
|
551
|
+
"Run. GO.",
|
|
552
|
+
"GO.",
|
|
553
|
+
"Let me run. GO.",
|
|
554
|
+
];
|
|
555
|
+
let fired = false;
|
|
556
|
+
for (const m of cycle) if (d.checkText(m).isOk()) fired = true;
|
|
557
|
+
assert.ok(fired, "rotating near-identical cycle must fire");
|
|
558
|
+
|
|
559
|
+
// 12. threshold 1 is clamped away (would brick the agent)
|
|
365
560
|
const clamped = readOptions({
|
|
366
561
|
PI_ANTI_LOOP_REPEATS: "1",
|
|
367
562
|
PI_ANTI_LOOP_FAILS: "0",
|
package/extensions/index.ts
CHANGED
|
@@ -73,7 +73,8 @@ export default function (pi: PiLike): void {
|
|
|
73
73
|
pi.on("session_start", () => reset());
|
|
74
74
|
|
|
75
75
|
// Fresh counters per user prompt: only the loop happening *right now* counts.
|
|
76
|
-
|
|
76
|
+
// Internal reset keeps session-scoped steers/aborts/resume budget.
|
|
77
|
+
pi.on("before_agent_start", () => controller.reset());
|
|
77
78
|
|
|
78
79
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
79
80
|
const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
|
|
@@ -118,11 +119,22 @@ export default function (pi: PiLike): void {
|
|
|
118
119
|
pi.registerCommand("loopcheck", {
|
|
119
120
|
description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
|
|
120
121
|
handler: async (args: string, ctx: CommandCtxLite) => {
|
|
121
|
-
|
|
122
|
+
const arg = args.trim().toLowerCase();
|
|
123
|
+
if (arg === "reset") {
|
|
122
124
|
reset();
|
|
123
125
|
ctx.ui.notify("Anti-doom-loop: counters reset", "info");
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
128
|
+
if (arg === "suspend") {
|
|
129
|
+
controller.suspend();
|
|
130
|
+
ctx.ui.notify("Anti-doom-loop: suspended until the next prompt", "info");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (arg === "resume") {
|
|
134
|
+
controller.resume();
|
|
135
|
+
ctx.ui.notify("Anti-doom-loop: resumed", "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
126
138
|
ctx.ui.notify(controller.status(), "info");
|
|
127
139
|
},
|
|
128
140
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anti-doom-loop",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
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",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"extensions",
|
|
20
|
-
"scripts"
|
|
20
|
+
"scripts",
|
|
21
|
+
"CHANGELOG.md"
|
|
21
22
|
],
|
|
22
23
|
"type": "module",
|
|
23
24
|
"main": "extensions/index.ts",
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
"guard": "node scripts/guard-publish.ts",
|
|
36
37
|
"lint": "oxlint --deny-warnings",
|
|
37
38
|
"format": "oxfmt .",
|
|
38
|
-
"format:check": "oxfmt --check ."
|
|
39
|
+
"format:check": "oxfmt --check .",
|
|
40
|
+
"prepare": "effect-tsgo patch --no-typescript --oxlint"
|
|
39
41
|
},
|
|
40
42
|
"dependencies": {
|
|
41
43
|
"better-result": "^3.0.0",
|
|
@@ -43,9 +45,11 @@
|
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@effect/tsgo": "^0.36.4",
|
|
46
49
|
"@types/node": "^22.0.0",
|
|
47
50
|
"oxfmt": "^0.62.0",
|
|
48
51
|
"oxlint": "^1.77.0",
|
|
52
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
49
53
|
"typescript": "^5.6.0"
|
|
50
54
|
},
|
|
51
55
|
"peerDependencies": {
|
package/scripts/guard-publish.ts
CHANGED
|
@@ -63,8 +63,8 @@ const tagExists = (version: string): Effect.Effect<boolean> =>
|
|
|
63
63
|
return false; // no git repo / git missing → treat as no tag
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
|
-
catch: () =>
|
|
67
|
-
}).pipe(Effect.
|
|
66
|
+
catch: (): GuardError => ({ message: "git tag check failed" }),
|
|
67
|
+
}).pipe(Effect.orElseSucceed(() => false));
|
|
68
68
|
/**
|
|
69
69
|
* Latest published version on npm, or null when the package was never
|
|
70
70
|
* published or the registry is unreachable (warn-only on network failure —
|
|
@@ -83,9 +83,9 @@ const fetchPublished = (name: string): Effect.Effect<string | null> =>
|
|
|
83
83
|
console.warn(
|
|
84
84
|
"GUARD WARN: could not reach the npm registry; skipping published-version check.",
|
|
85
85
|
);
|
|
86
|
-
return
|
|
86
|
+
return { message: "registry unreachable" };
|
|
87
87
|
},
|
|
88
|
-
}).pipe(Effect.
|
|
88
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
89
89
|
|
|
90
90
|
const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
|
|
91
91
|
const raw = yield* readManifest;
|
|
@@ -94,10 +94,10 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
|
|
|
94
94
|
const expected = (process.argv[2] ?? "").replace(/^v/, ""); // tolerate "v0.0.1"
|
|
95
95
|
|
|
96
96
|
if (!semverLike(local)) {
|
|
97
|
-
yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
|
|
97
|
+
return yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
|
|
98
98
|
}
|
|
99
99
|
if (expected && expected !== local) {
|
|
100
|
-
yield* fail(
|
|
100
|
+
return yield* fail(
|
|
101
101
|
`GUARD FAIL: expected "${expected}" (tag/input) does not match package.json version "${local}". ` +
|
|
102
102
|
`Bump package.json to ${expected}, or tag ${local}.`,
|
|
103
103
|
);
|
|
@@ -105,7 +105,7 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
|
|
|
105
105
|
|
|
106
106
|
const published = yield* fetchPublished(pkg.name);
|
|
107
107
|
if (published === local) {
|
|
108
|
-
yield* fail(
|
|
108
|
+
return yield* fail(
|
|
109
109
|
`GUARD FAIL: ${pkg.name}@${local} is already published on npm. ` +
|
|
110
110
|
`Bump the version in package.json to cut a new release.`,
|
|
111
111
|
);
|
|
@@ -115,7 +115,7 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
|
|
|
115
115
|
// exist as a git tag — keeps the tag/manifest-sync invariant on both entry
|
|
116
116
|
// points (clawpatch finding).
|
|
117
117
|
if (expected && (yield* tagExists(expected)) === false) {
|
|
118
|
-
yield* fail(
|
|
118
|
+
return yield* fail(
|
|
119
119
|
`GUARD FAIL: expected version "${expected}" has no matching git tag v${expected}. Tag it first.`,
|
|
120
120
|
);
|
|
121
121
|
}
|