acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/stuck.mjs ADDED
@@ -0,0 +1,712 @@
1
+ /**
2
+ * ── ⚠️⭐⭐ KNOWING WHEN IT IS GOING IN CIRCLES ────────────────────────────────
3
+ *
4
+ * This module is the other half of removing the round cap, and it is not
5
+ * optional. "Keep going until the task is done" without a loop detector is not
6
+ * ambition, it is "burn the whole budget rewriting the same file" — strictly
7
+ * worse than stopping early, because the user pays for the circles too.
8
+ *
9
+ * ⚠️⚠️ THE TWO FAILURE MODES ARE NOT SYMMETRIC, AND THE WHOLE DESIGN FOLLOWS
10
+ * FROM THAT.
11
+ *
12
+ * · MISSING a loop costs a few more rounds — cents, on a task measured at
13
+ * ~$0.0007. Annoying. Recoverable. The budget guard catches it eventually.
14
+ * · CALLING A WORKING RUN STUCK costs the user THE WORK AND THE MONEY. Every
15
+ * round already paid for is thrown away, possibly one round before it would
16
+ * have succeeded, and the user has nothing to show for any of it.
17
+ *
18
+ * So every threshold here is set to the CONSERVATIVE side, every pattern
19
+ * requires a positive signature rather than the absence of one, and five
20
+ * legitimate shapes that look superficially like loops are pinned as negatives
21
+ * in the tests:
22
+ *
23
+ * 1. reading one file repeatedly while editing different parts of it
24
+ * 2. a test failing the same way while the code genuinely changes each run
25
+ * 3. a long research phase — many reads, no writes, no commands
26
+ * 4. retrying after a transient failure that then succeeds
27
+ * 5. the same command failing DIFFERENTLY each time (that is a descent, not
28
+ * a circle — the model is peeling errors off one at a time)
29
+ *
30
+ * ⭐ `suggestion` IS THE PRODUCT, NOT `stuck`. This text is appended to the
31
+ * conversation as a nudge to the MODEL, so "stuck: true" is worthless to it and
32
+ * a scolding is worse than worthless — a model told it has failed will often
33
+ * wrap up and hand back half a job, which is the exact behaviour an unattended
34
+ * loop exists to prevent. Every suggestion therefore names the concrete
35
+ * artifact (the path, the command, the message) and proposes a next move.
36
+ *
37
+ * ⚠️ PURE, AND DELIBERATELY SO. No clock, no randomness, no I/O, no state
38
+ * between calls. It takes the round history the loop already keeps and returns
39
+ * data. That is why it can be tested exhaustively without spending a cent on a
40
+ * real model — and untestable time is why half the bugs in this package were
41
+ * invisible for so long.
42
+ *
43
+ * WIRING: see the note at the bottom of this file. It is ten lines in
44
+ * `runSession`, right after `rounds.push(...)`.
45
+ */
46
+
47
+ /**
48
+ * Every pattern this module can return, in the order it prefers to report them.
49
+ *
50
+ * ⚠️ THE ORDER IS THE DIAGNOSIS, NOT A PREFERENCE. `A → B → A` also contains two
51
+ * identical writes of A; reporting that as "you wrote the same thing twice"
52
+ * would send the model to check its file path when the real problem is that it
53
+ * is alternating between two rejected answers. The more specific pattern must
54
+ * win, or the nudge actively misleads.
55
+ */
56
+ export const STUCK_PATTERNS = [
57
+ 'thrashing',
58
+ 'repeated-identical-edit',
59
+ 'tool-error-loop',
60
+ 'repeated-command-failure',
61
+ 'no-progress',
62
+ /**
63
+ * ⚠️ LAST ON PURPOSE. A period-1 cycle IS a repeated identical edit, and every
64
+ * shorter pattern above states the same fact more precisely. This one exists
65
+ * only for the loops the others structurally cannot see.
66
+ */
67
+ 'long-cycle',
68
+ ];
69
+
70
+ /** How many of the most recent rounds are examined. */
71
+ const DEFAULT_WINDOW = 4;
72
+
73
+ /**
74
+ * ⚠️ TWO IS THE THRESHOLD FOR AN IDENTICAL WRITE AND THREE FOR EVERYTHING ELSE,
75
+ * and that is not an inconsistency. Writing byte-identical content to the same
76
+ * path twice is a PROVEN no-op — the second write changed nothing, which is a
77
+ * fact, not an inference. A command failing twice is just a command failing
78
+ * twice; it takes a third to be a pattern.
79
+ */
80
+ const IDENTICAL_WRITE_LIMIT = 2;
81
+ const COMMAND_FAILURE_LIMIT = 3;
82
+ const TOOL_ERROR_LIMIT = 3;
83
+ const INERT_ROUND_LIMIT = 3;
84
+
85
+ /**
86
+ * Tools that mean "a process ran".
87
+ *
88
+ * ⚠️ KEYED ON WHAT HAPPENED, NOT ON THE FAMOUS NAME. turn.mjs has now had the
89
+ * same bug three times — `evaluate`, then `check_acceptance`, then
90
+ * `run_program` were each missing from a list of "things that count as a run",
91
+ * and each time the honest line in the summary lied. Anything that spawns a
92
+ * process belongs here.
93
+ */
94
+ const RUN_TOOLS = new Set(['run_command', 'run_program', 'evaluate', 'check_acceptance']);
95
+
96
+ /** Tools whose success means a file on disk is different afterwards. */
97
+ const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'delete_file']);
98
+
99
+ /* ══════════════════════════════════════════════════════════════════════════
100
+ * normalisation — every comparison below depends on these being boring
101
+ * ══════════════════════════════════════════════════════════════════════════ */
102
+
103
+ /**
104
+ * `src\x.js`, `./src/x.js` and `src//x.js` are one file.
105
+ *
106
+ * ⚠️ CASE IS DELIBERATELY LEFT ALONE even though Windows would fold it. Folding
107
+ * MERGES two paths, and merging is the direction that manufactures a false
108
+ * "you wrote the same file twice". Every ambiguity here resolves towards
109
+ * not-stuck.
110
+ */
111
+ function normPath(value) {
112
+ if (typeof value !== 'string') return null;
113
+ let s = value.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
114
+ while (s.startsWith('./')) s = s.slice(2);
115
+ s = s.replace(/\/+$/, '');
116
+ return s.length ? s : null;
117
+ }
118
+
119
+ /** Collapse whitespace so two renderings of one message compare equal. */
120
+ function normMessage(value, cap = 300) {
121
+ if (typeof value !== 'string') return null;
122
+ const s = value.replace(/\s+/g, ' ').trim();
123
+ return s.length ? s.slice(0, cap) : null;
124
+ }
125
+
126
+ /**
127
+ * The first non-empty line of a failure — stderr first, then stdout.
128
+ *
129
+ * ⭐ THE FIRST LINE IS THE SIGNATURE, NOT THE WHOLE OUTPUT. Test runners print
130
+ * timings, paths and a duration that differ on every single run; comparing full
131
+ * output would make two identical failures look different and this pattern
132
+ * would never fire at all.
133
+ */
134
+ function firstErrorLine(result) {
135
+ const pick = (text) => {
136
+ if (typeof text !== 'string') return '';
137
+ for (const line of text.split(/\r?\n/)) {
138
+ const trimmed = line.trim();
139
+ if (trimmed) return trimmed;
140
+ }
141
+ return '';
142
+ };
143
+ return (pick(result?.stderr) || pick(result?.stdout) || '').slice(0, 200);
144
+ }
145
+
146
+ /** Deterministic identity for a read-only call, used to ask "is this new?". */
147
+ function probeKey(name, args) {
148
+ try {
149
+ const keys = Object.keys(args ?? {}).sort();
150
+ const body = JSON.stringify(keys.map((k) => [k, args[k]]));
151
+ if (typeof body !== 'string') return null;
152
+ return `${name} ${body.length} ${body.slice(0, 4096)}`;
153
+ } catch {
154
+ // circular or otherwise unserialisable — treat it as its own probe rather
155
+ // than as a repeat, which is the not-stuck direction.
156
+ body = `unserialisable:${name}:${keys_fallback()}`;
157
+ }
158
+ // Cap rather than hash: a hash collision would MERGE two probes and could
159
+ // manufacture an inert round, and probe arguments are short by nature.
160
+ return `${name}\u0000${body.length}\u0000${body.slice(0, 4096)}`;
161
+ }
162
+ let keysFallbackCounter = 0;
163
+ function keys_fallback() { return String(keysFallbackCounter++); }
164
+
165
+ /** The command string behind a run record, whatever tool produced it. */
166
+ function commandOf(ev) {
167
+ if (typeof ev.result.command === 'string') return ev.result.command;
168
+ if (typeof ev.args.command === 'string') return ev.args.command;
169
+ if (Array.isArray(ev.result.argv)) return ev.result.argv.join(' ');
170
+ if (Array.isArray(ev.args.argv)) return ev.args.argv.join(' ');
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * ⚠️ `ok: true` MEANS THE COMMAND RAN, NOT THAT IT PASSED — command.mjs says so
176
+ * itself. Reading `ok` as success here would mean every failing test looked
177
+ * like a success and this whole pattern would be dead code.
178
+ */
179
+ function runFailed(result) {
180
+ if (result?.timedOut === true) return true;
181
+ if (result?.passed === false) return true;
182
+ return Number.isFinite(result?.exitCode) && result.exitCode !== 0;
183
+ }
184
+
185
+ /* ══════════════════════════════════════════════════════════════════════════
186
+ * flattening — turn `rounds` into one ordered event stream
187
+ * ══════════════════════════════════════════════════════════════════════════ */
188
+
189
+ /**
190
+ * @param {Array} rounds records in the shape turn.mjs pushes:
191
+ * `{ round, note, executed: [{ id, name, args, result, mutated }], usage }`
192
+ *
193
+ * ⚠️ EVERY FIELD IS TREATED AS ABSENT UNTIL PROVEN PRESENT. A provider can emit
194
+ * a tool call whose arguments do not parse, and tools.mjs hands back
195
+ * `args: {}` with an error result; a resumed session can carry a round from an
196
+ * older shape. A detector that throws inside the loop it is meant to protect is
197
+ * worse than no detector.
198
+ */
199
+ function flatten(rounds) {
200
+ const events = [];
201
+ const roundMeta = [];
202
+ if (!Array.isArray(rounds)) return { events, roundMeta };
203
+
204
+ rounds.forEach((round, roundIndex) => {
205
+ const label = Number.isFinite(round?.round) ? round.round : roundIndex + 1;
206
+ roundMeta.push({ roundIndex, label });
207
+ const executed = Array.isArray(round?.executed) ? round.executed : [];
208
+ for (const record of executed) {
209
+ if (!record || typeof record !== 'object') continue;
210
+ if (typeof record.name !== 'string' || !record.name) continue;
211
+ events.push({
212
+ roundIndex,
213
+ label,
214
+ name: record.name,
215
+ args: (record.args && typeof record.args === 'object') ? record.args : {},
216
+ result: (record.result && typeof record.result === 'object') ? record.result : {},
217
+ mutated: record.mutated === true,
218
+ });
219
+ }
220
+ });
221
+ return { events, roundMeta };
222
+ }
223
+
224
+ /**
225
+ * Annotate each event with the two facts every pattern needs: did it change a
226
+ * file, and had we seen this exact probe before?
227
+ *
228
+ * ⚠️ THIS WALKS THE WHOLE HISTORY, NOT THE WINDOW. "Is this content new?" and
229
+ * "have I asked this before?" are questions about everything that came before,
230
+ * and answering them from the window alone would call a re-read of something
231
+ * fetched ten rounds ago a brand-new observation.
232
+ */
233
+ function annotate(events) {
234
+ const lastWritten = new Map(); // path → content most recently written there
235
+ const seenProbes = new Set();
236
+
237
+ for (const ev of events) {
238
+ ev.path = null;
239
+ ev.content = null;
240
+ ev.changedDisk = false;
241
+ ev.newProbe = false;
242
+ ev.isRun = RUN_TOOLS.has(ev.name);
243
+
244
+ const ok = ev.result.ok === true;
245
+
246
+ if (ev.name === 'write_file' && ok) {
247
+ const path = normPath(ev.args.path);
248
+ const content = ev.args.content;
249
+ // ⚠️ a non-string content is NOT "the same as the last non-string
250
+ // content" — two unknowns are not a match, they are two unknowns.
251
+ if (path && typeof content === 'string') {
252
+ ev.path = path;
253
+ ev.content = content;
254
+ ev.changedDisk = !lastWritten.has(path) || lastWritten.get(path) !== content;
255
+ lastWritten.set(path, content);
256
+ } else {
257
+ ev.changedDisk = true;
258
+ }
259
+ } else if ((ev.name === 'edit_file' || ev.name === 'delete_file') && ok) {
260
+ ev.path = normPath(ev.args.path);
261
+ // edit.mjs refuses an edit whose old_string equals its new_string, so a
262
+ // successful edit is by construction a real change. After it, whatever we
263
+ // thought was on disk is stale — forget it rather than compare against it.
264
+ ev.changedDisk = true;
265
+ if (ev.path) lastWritten.delete(ev.path);
266
+ } else if (!ev.isRun && !MUTATING_TOOLS.has(ev.name)) {
267
+ const key = probeKey(ev.name, ev.args);
268
+ ev.newProbe = !seenProbes.has(key);
269
+ seenProbes.add(key);
270
+ } else if (!ok && MUTATING_TOOLS.has(ev.name)) {
271
+ // A refused write is not a change, but the refusal itself is information
272
+ // the first time it arrives.
273
+ const key = probeKey(ev.name, ev.args);
274
+ ev.newProbe = !seenProbes.has(key);
275
+ seenProbes.add(key);
276
+ }
277
+ }
278
+ return events;
279
+ }
280
+
281
+ /* ══════════════════════════════════════════════════════════════════════════
282
+ * the patterns
283
+ * ══════════════════════════════════════════════════════════════════════════ */
284
+
285
+ /** Writes to one path, in order, within the window. */
286
+ function writesByPath(windowEvents) {
287
+ const byPath = new Map();
288
+ for (const ev of windowEvents) {
289
+ if (ev.name !== 'write_file' || !ev.path || typeof ev.content !== 'string') continue;
290
+ if (!byPath.has(ev.path)) byPath.set(ev.path, []);
291
+ byPath.get(ev.path).push(ev);
292
+ }
293
+ return byPath;
294
+ }
295
+
296
+ /** A → B → A. The file is being flipped between two answers already tried. */
297
+ function findThrashing(windowEvents) {
298
+ for (const [path, writes] of writesByPath(windowEvents)) {
299
+ for (let i = 2; i < writes.length; i += 1) {
300
+ if (writes[i].content === writes[i - 2].content && writes[i].content !== writes[i - 1].content) {
301
+ return {
302
+ pattern: 'thrashing',
303
+ evidence: {
304
+ key: `thrashing:${path}`,
305
+ path,
306
+ rounds: [writes[i - 2].label, writes[i - 1].label, writes[i].label],
307
+ count: 3,
308
+ },
309
+ };
310
+ }
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /** The same bytes written to the same path twice — the second one did nothing. */
317
+ function findIdenticalWrite(windowEvents) {
318
+ for (const [path, writes] of writesByPath(windowEvents)) {
319
+ const buckets = new Map();
320
+ for (const ev of writes) {
321
+ if (!buckets.has(ev.content)) buckets.set(ev.content, []);
322
+ buckets.get(ev.content).push(ev);
323
+ }
324
+ for (const [content, group] of buckets) {
325
+ if (group.length < IDENTICAL_WRITE_LIMIT) continue;
326
+ return {
327
+ pattern: 'repeated-identical-edit',
328
+ evidence: {
329
+ key: `repeated-identical-edit:${path}`,
330
+ path,
331
+ count: group.length,
332
+ bytes: content.length,
333
+ rounds: group.map((ev) => ev.label),
334
+ },
335
+ };
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+
341
+ /**
342
+ * The same command, same exit code, same first error line, three times.
343
+ *
344
+ * ⚠️⚠️ THE CHAIN RESETS ON A REAL FILE CHANGE, and that single rule is what
345
+ * keeps the most common legitimate shape in the world out of here: a test
346
+ * failing with the identical assertion while the model rewrites the code
347
+ * between every run. That is iteration, and it is what success looks like right
348
+ * up until the last round. Only failures with NOTHING changed between them are
349
+ * a circle.
350
+ *
351
+ * ⚠️ A DIFFERENT FIRST LINE STARTS A NEW CHAIN — "same command failing
352
+ * differently" is the model peeling errors off one at a time, which is the
353
+ * definition of progress.
354
+ */
355
+ function findCommandFailureLoop(windowEvents) {
356
+ let chain = null;
357
+ for (const ev of windowEvents) {
358
+ if (ev.changedDisk) { chain = null; continue; }
359
+ if (!ev.isRun || ev.result.ok !== true) continue;
360
+
361
+ const command = commandOf(ev);
362
+ if (!command) continue;
363
+
364
+ if (!runFailed(ev.result)) {
365
+ // The same command working is the clearest possible "not stuck".
366
+ if (chain && chain.command === command) chain = null;
367
+ continue;
368
+ }
369
+
370
+ const exitCode = Number.isFinite(ev.result.exitCode) ? ev.result.exitCode : null;
371
+ const errorLine = firstErrorLine(ev.result);
372
+ const same = chain && chain.command === command && chain.exitCode === exitCode && chain.errorLine === errorLine;
373
+ if (same) {
374
+ chain.count += 1;
375
+ chain.rounds.push(ev.label);
376
+ } else {
377
+ chain = { command, exitCode, errorLine, count: 1, rounds: [ev.label] };
378
+ }
379
+
380
+ if (chain.count >= COMMAND_FAILURE_LIMIT) {
381
+ return {
382
+ pattern: 'repeated-command-failure',
383
+ evidence: {
384
+ key: `repeated-command-failure:${command}:${exitCode}:${errorLine}`,
385
+ command, exitCode, errorLine,
386
+ count: chain.count,
387
+ rounds: chain.rounds.slice(),
388
+ },
389
+ };
390
+ }
391
+ }
392
+ return null;
393
+ }
394
+
395
+ /**
396
+ * The same tool refusing with the same message, three times running.
397
+ *
398
+ * ⚠️ THE SAME TOOL SUCCEEDING CLEARS THE CHAIN. An `edit_file` that misses,
399
+ * lands, misses again is an ordinary editing session with two typos in it — not
400
+ * a loop. Only an unbroken run of identical refusals counts.
401
+ */
402
+ function findToolErrorLoop(windowEvents) {
403
+ let chain = null;
404
+ for (const ev of windowEvents) {
405
+ // A failing command is not a refusing tool: it RAN. That is
406
+ // findCommandFailureLoop's business, and counting it twice would double the
407
+ // nudges for one problem.
408
+ if (ev.isRun && ev.result.ok === true) continue;
409
+
410
+ if (ev.result.ok === true) {
411
+ if (chain && chain.tool === ev.name) chain = null;
412
+ continue;
413
+ }
414
+ if (ev.result.ok !== false) continue;
415
+
416
+ const error = normMessage(ev.result.error);
417
+ if (!error) continue;
418
+
419
+ if (chain && chain.tool === ev.name && chain.error === error) {
420
+ chain.count += 1;
421
+ chain.rounds.push(ev.label);
422
+ } else {
423
+ chain = { tool: ev.name, error, count: 1, rounds: [ev.label] };
424
+ }
425
+
426
+ if (chain.count >= TOOL_ERROR_LIMIT) {
427
+ return {
428
+ pattern: 'tool-error-loop',
429
+ evidence: {
430
+ key: `tool-error-loop:${ev.name}:${error}`,
431
+ tool: ev.name,
432
+ error,
433
+ count: chain.count,
434
+ rounds: chain.rounds.slice(),
435
+ },
436
+ };
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * Rounds that wrote nothing, ran nothing, and asked nothing new.
444
+ *
445
+ * ⚠️⚠️ "NO WRITES" IS NOT THE TEST, AND THIS IS THE TRAP THE BRIEF WARNS ABOUT
446
+ * DIRECTLY. A long research phase writes nothing for many rounds and is exactly
447
+ * how a good agent starts a hard task. What makes a round inert is that it
448
+ * produced NO NEW OBSERVATION EITHER — every read, search and listing in it had
449
+ * already been made earlier in the same run. Reading ten different files is
450
+ * work; reading the same file for the fourth time while writing nothing is not.
451
+ */
452
+ function findNoProgress(events, roundMeta, windowRounds, inertLimit) {
453
+ if (roundMeta.length < inertLimit) return null;
454
+
455
+ const activeByRound = new Map();
456
+ for (const ev of events) {
457
+ const active = ev.changedDisk || ev.mutated || ev.isRun || ev.newProbe;
458
+ if (active) activeByRound.set(ev.roundIndex, true);
459
+ }
460
+
461
+ const tail = roundMeta.slice(-inertLimit);
462
+ if (tail.length < inertLimit) return null;
463
+ if (tail[0].roundIndex < roundMeta.length - windowRounds) return null;
464
+ for (const meta of tail) {
465
+ if (activeByRound.get(meta.roundIndex)) return null;
466
+ }
467
+
468
+ return {
469
+ pattern: 'no-progress',
470
+ evidence: {
471
+ key: `no-progress:from-${tail[0].label}`,
472
+ count: tail.length,
473
+ rounds: tail.map((m) => m.label),
474
+ },
475
+ };
476
+ }
477
+
478
+ /* ══════════════════════════════════════════════════════════════════════════
479
+ * suggestions — the field that is actually worth anything
480
+ * ══════════════════════════════════════════════════════════════════════════ */
481
+
482
+ /**
483
+ * ⭐ WRITTEN FOR A MODEL TO ACT ON, NOT FOR A HUMAN TO READ IN A LOG.
484
+ *
485
+ * Three rules, all learned the hard way in this repo:
486
+ * · NAME THE ARTIFACT. "You appear to be stuck" is unactionable; "you have
487
+ * written the identical bytes to lib/mode.js twice" points at a thing.
488
+ * · PROPOSE A MOVE. An observation with no next step gets acknowledged and
489
+ * then ignored.
490
+ * · NEVER SCOLD, AND NEVER IMPLY THE RUN IS OVER. An error string is an
491
+ * instruction — this package already watched "try once more" make a model
492
+ * retry a dead service until the session died. "You have failed" reads as
493
+ * permission to hand back half a job.
494
+ */
495
+ function suggestionFor(pattern, evidence) {
496
+ switch (pattern) {
497
+ case 'long-cycle':
498
+ return `The same ${evidence.length} steps have now repeated ${evidence.repeats} times without changing `
499
+ + `(${evidence.verbs.join(' → ')}), on the same arguments each time. Repeating them again will produce the `
500
+ + `same result, because nothing between the rounds is different. Stop and state what you EXPECTED to change `
501
+ + `and what actually did — then either read the last failure output properly, or try a different approach `
502
+ + `entirely rather than another pass of the same loop.`;
503
+
504
+ case 'thrashing':
505
+ return `\`${evidence.path}\` has just been flipped back to a version already tried `
506
+ + `(A → B → A across rounds ${evidence.rounds.join(', ')}). Alternating between two answers will not settle it, `
507
+ + `because whatever rejected the first version has not been addressed yet. Read the most recent failure output `
508
+ + `closely and change one specific thing on purpose, rather than reverting.`;
509
+
510
+ case 'repeated-identical-edit':
511
+ return `The identical ${evidence.bytes} bytes have now been written to \`${evidence.path}\` ${evidence.count} times, `
512
+ + `so the later write changed nothing on disk. Before writing it again, check that this is really the path the `
513
+ + `failing command loads — a near-miss path, or a build output directory, would look exactly like this — and read `
514
+ + `the file back to confirm what is actually there.`;
515
+
516
+ case 'repeated-command-failure':
517
+ return `\`${evidence.command}\` has failed ${evidence.count} times with the same exit code (${evidence.exitCode}) `
518
+ + `and the same first line: "${evidence.errorLine}". No file changed between those runs, so running it again `
519
+ + `will print the same thing. Read further into the output than the first line, or run something smaller that `
520
+ + `isolates which part fails.`;
521
+
522
+ case 'tool-error-loop':
523
+ if (evidence.tool === 'edit_file') {
524
+ return `\`edit_file\` has refused ${evidence.count} times in a row with the same message: "${evidence.error}". `
525
+ + `The call is what needs to change, not the number of attempts: read the file first and copy the exact text `
526
+ + `into old_string — including indentation and line endings — or write the whole file if the span is hard to `
527
+ + `quote precisely.`;
528
+ }
529
+ return `\`${evidence.tool}\` has refused ${evidence.count} times in a row with the same message: "${evidence.error}". `
530
+ + `Repeating the same call will get the same answer, so read that message closely and change the arguments, or `
531
+ + `reach the same goal with a different tool.`;
532
+
533
+ case 'no-progress':
534
+ return `The last ${evidence.count} rounds wrote no files, ran no commands, and only repeated lookups already made `
535
+ + `earlier in this run. If there is enough information to act, make the change now; if something is genuinely `
536
+ + `blocking it, say what is blocking it before looking again.`;
537
+
538
+ default:
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /* ══════════════════════════════════════════════════════════════════════════
544
+ * the entry point
545
+ * ══════════════════════════════════════════════════════════════════════════ */
546
+
547
+ const CLEAN = Object.freeze({ stuck: false, pattern: null, evidence: null, suggestion: null });
548
+
549
+ /**
550
+ * Is this run going in circles?
551
+ *
552
+ * @param {Array} rounds the history `runSession` already keeps, oldest first.
553
+ * @param {object} [options]
554
+ * @param {number} [options.window=4] how many recent rounds to examine.
555
+ * @returns {{stuck:boolean, pattern:string|null, evidence:object|null, suggestion:string|null}}
556
+ *
557
+ * ⚠️ A CLEAN RESULT IS FULLY NULL, never partially populated. A caller that
558
+ * reads `.suggestion` without checking `.stuck` must get nothing rather than a
559
+ * stale hint — this repo has already shipped one detector whose "all clear"
560
+ * carried a verdict about a page it had never seen.
561
+ */
562
+
563
+ /**
564
+ * ── ⭐⭐ THE LOOP A FOUR-ROUND WINDOW CANNOT SEE ─────────────────────────────
565
+ *
566
+ * Everything above examines `DEFAULT_WINDOW = 4` rounds. That is the right size
567
+ * for the patterns it names — two identical writes, an A→B→A flip — but it makes
568
+ * one whole family invisible by arithmetic: a cycle of PERIOD 3 (read X, edit X,
569
+ * run tests, read X, edit X, run tests) never fits two repetitions inside four
570
+ * rounds, so it can run until the round cap and never be reported.
571
+ *
572
+ * ⚠️ IT IS REACHABLE WITH OUR OWN BUDGET — 24 rounds by default, 64 by ceiling,
573
+ * 200 under `--until-done`. And MAST (1,600+ traces, κ=0.88) measured **step
574
+ * repetition as the single largest failure mode at 17.14%**, larger than any
575
+ * other category.
576
+ *
577
+ * ⭐ ZERO TOKENS: string comparison over history we already hold.
578
+ *
579
+ * ⚠️ THE SIGNATURE INCLUDES THE ARGUMENTS, and that is what keeps it honest.
580
+ * read → edit → run repeated over DIFFERENT files is exactly what a competent
581
+ * refactor looks like; only a byte-identical cycle counts.
582
+ */
583
+ export const LONG_CYCLE_HISTORY = 24;
584
+ export const LONG_CYCLE_MAX_LEN = 5;
585
+ export const LONG_CYCLE_REPEATS = 3;
586
+
587
+ /** What makes two rounds "the same step" — the verb plus what it was aimed at. */
588
+ function roundSignature(round) {
589
+ const executed = Array.isArray(round?.executed) ? round.executed : [];
590
+ return executed
591
+ .filter((r) => r && typeof r.name === 'string')
592
+ .map((r) => {
593
+ let args = '';
594
+ try { args = JSON.stringify(r.args ?? {}); } catch { args = ''; }
595
+ return `${r.name}(${args.slice(0, 400)})`;
596
+ })
597
+ .join('|');
598
+ }
599
+
600
+ /**
601
+ * ⚠️ EXPORTED FOR THE TESTS, AND THE REASON IS ITSELF A FINDING. The
602
+ * `new Set(cycle).size < 2` guard below could not be reached through
603
+ * `detectStuck`: a run of identical reads trips `no-progress` first, so a
604
+ * mutation deleting the guard left every test green. A guard no test can
605
+ * reach is indistinguishable from dead code — so the function is exported and
606
+ * the guard is exercised directly rather than deleted or left unproven.
607
+ */
608
+ export function findLongCycle(rounds) {
609
+ const recent = rounds.slice(-LONG_CYCLE_HISTORY);
610
+ const sigs = recent.map(roundSignature).filter((x) => x !== '');
611
+ if (sigs.length < LONG_CYCLE_REPEATS * 2) return null;
612
+
613
+ /**
614
+ * ⚠️ LONGEST PERIOD FIRST. A period-4 loop also contains a period-2 one when
615
+ * its halves happen to match; reporting the shorter one would name a smaller
616
+ * loop than the model is actually running.
617
+ */
618
+ for (let len = LONG_CYCLE_MAX_LEN; len >= 2; len -= 1) {
619
+ if (sigs.length < len * LONG_CYCLE_REPEATS) continue;
620
+ const tail = sigs.slice(-len * LONG_CYCLE_REPEATS);
621
+ const cycle = tail.slice(0, len);
622
+ let matches = true;
623
+ for (let i = 0; i < tail.length; i += 1) {
624
+ if (tail[i] !== cycle[i % len]) { matches = false; break; }
625
+ }
626
+ if (!matches) continue;
627
+ /**
628
+ * ⚠️ A CYCLE OF ONE DISTINCT STEP IS NOT A CYCLE — it is the repeated-call
629
+ * case the detectors above already state more precisely.
630
+ */
631
+ if (new Set(cycle).size < 2) continue;
632
+ const verbs = [...new Set(cycle.flatMap((sig) => sig.split('|').map((c) => c.split('(')[0])))];
633
+ return { pattern: 'long-cycle', evidence: { length: len, repeats: LONG_CYCLE_REPEATS, verbs } };
634
+ }
635
+ return null;
636
+ }
637
+
638
+ export function detectStuck(rounds, { window = DEFAULT_WINDOW } = {}) {
639
+ if (!Array.isArray(rounds) || rounds.length === 0) return { ...CLEAN };
640
+
641
+ const windowRounds = Number.isFinite(window) && window >= 1 ? Math.floor(window) : DEFAULT_WINDOW;
642
+ const inertLimit = Math.min(INERT_ROUND_LIMIT, windowRounds);
643
+
644
+ const { events, roundMeta } = flatten(rounds);
645
+ annotate(events);
646
+
647
+ const firstWindowRound = Math.max(0, roundMeta.length - windowRounds);
648
+ const windowEvents = events.filter((ev) => ev.roundIndex >= firstWindowRound);
649
+ const windowMeta = roundMeta.filter((m) => m.roundIndex >= firstWindowRound);
650
+
651
+ const hit = findThrashing(windowEvents)
652
+ ?? findIdenticalWrite(windowEvents)
653
+ ?? findToolErrorLoop(windowEvents)
654
+ ?? findCommandFailureLoop(windowEvents)
655
+ ?? (windowMeta.length >= inertLimit ? findNoProgress(events, roundMeta, windowRounds, inertLimit) : null)
656
+ ?? findLongCycle(rounds);
657
+
658
+ if (!hit) return { ...CLEAN };
659
+
660
+ const suggestion = suggestionFor(hit.pattern, hit.evidence);
661
+ if (!suggestion) return { ...CLEAN };
662
+
663
+ return { stuck: true, pattern: hit.pattern, evidence: hit.evidence, suggestion };
664
+ }
665
+
666
+ /**
667
+ * The exact text to append to the conversation, or null when there is nothing
668
+ * to say.
669
+ *
670
+ * ⚠️ IT ANNOUNCES ITSELF AS MACHINERY, NOT AS THE USER. A bare instruction
671
+ * arriving in the `user` role is indistinguishable from the human changing
672
+ * their mind, and a model that believes the user just spoke will re-plan the
673
+ * whole task around it. Naming the source keeps it a hint about HOW to continue
674
+ * rather than a new instruction about WHAT to do.
675
+ */
676
+ export function nudgeMessage(result) {
677
+ if (!result || result.stuck !== true || typeof result.suggestion !== 'string') return null;
678
+ return `[loop watcher — automatic, not from the user] ${result.suggestion}`;
679
+ }
680
+
681
+ /**
682
+ * ── ⭐ HOW TO WIRE THIS (the whole point of the module) ──────────────────────
683
+ *
684
+ * In `lib/turn.mjs`, at the top:
685
+ *
686
+ * import { detectStuck, nudgeMessage } from './stuck.mjs';
687
+ *
688
+ * and in `runSession`, immediately after the existing `rounds.push({ round, ... })`:
689
+ *
690
+ * const circling = detectStuck(rounds);
691
+ * if (circling.stuck && !nudged.has(circling.evidence.key)) {
692
+ * nudged.add(circling.evidence.key);
693
+ * messages.push({ role: 'user', content: nudgeMessage(circling) });
694
+ * onEvent({ type: 'stuck', round, pattern: circling.pattern, evidence: circling.evidence });
695
+ * }
696
+ *
697
+ * with one declaration beside `const rounds = []`:
698
+ *
699
+ * const nudged = new Set();
700
+ *
701
+ * ⚠️ `nudged` IS NOT OPTIONAL. Without it the same loop re-nudges every round,
702
+ * which changes the prompt prefix every round and destroys the byte-identical
703
+ * cache hit worth 3.05x on DeepSeek — a loop detector that triples the bill is
704
+ * not a saving. `evidence.key` is stable for as long as one loop persists and
705
+ * differs between distinct loops, which is exactly what a dedupe set needs.
706
+ *
707
+ * ⭐ NUDGING IS THE DEFAULT ACTION, NOT STOPPING. The model gets one hint and
708
+ * keeps its budget; a caller that wants a hard stop can compare
709
+ * `circling.evidence.key` across rounds itself and give up on the second sighting.
710
+ * Ending a run automatically is the expensive mistake this module was written to
711
+ * avoid making.
712
+ */