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
@@ -0,0 +1,1461 @@
1
+ /**
2
+ * ── ⭐⭐ MAKING A PLAN BIND — WHAT THE LEDGER HONESTLY IS TODAY ──────────────
3
+ *
4
+ * Read `plan-ledger.mjs` before this file. Its header is unusually honest and
5
+ * it already contains the verdict, measured rather than argued:
6
+ *
7
+ * · A dogfood plan on disk, 2026-08-14: six steps, `createdAt` and
8
+ * `updatedAt` **6 milliseconds apart**, every step still `"todo"` — across
9
+ * 20 rounds and FOUR separate `acuvo` invocations. One of those untouched
10
+ * steps named a file that had been written, executed and verified.
11
+ * · An 8-round run: `plan_start` called, work done, `plan: 0/3 done` printed
12
+ * on every single round, `plan_step` never called once.
13
+ *
14
+ * ⚠️⚠️ SO THE HONEST ANSWER TO "WHAT DOES THE PLAN DO TODAY" IS: **it is a
15
+ * to-do list the model can ignore, and mostly does.** Precisely:
16
+ *
17
+ * 1. `plan_start` writes a decomposition to `.acuvo/plan.json`. It refuses if
18
+ * an unfinished plan for the same task exists — that refusal IS the resume
19
+ * primitive and it is genuinely good.
20
+ * 2. One line is appended to the conversation each round (`formatBanner` via
21
+ * `planBannerFor`): counts, up to three outstanding items, a round
22
+ * countdown, and a nudge naming `plan_step`.
23
+ * 3. `plan_step` mutates one step's state. `done` is **ASSERTED** — nothing
24
+ * checks it, by explicit design.
25
+ * 4. The final report prints an OUTSTANDING block.
26
+ *
27
+ * And that is the whole of it. Nothing reads the tool stream. Nothing notices
28
+ * that round 14 is editing files no step mentions. Nothing distinguishes a step
29
+ * marked done over a written file from a step marked done over nothing at all.
30
+ * `stuck.mjs` watches for CIRCLES and is entirely plan-blind: a run that quietly
31
+ * abandons its plan and productively builds the wrong thing is "not stuck", and
32
+ * no part of this package can currently say otherwise.
33
+ *
34
+ * ── ⭐ WHAT THIS MODULE ADDS, AND WHAT IT REFUSES TO ADD ────────────────────
35
+ *
36
+ * Four pure decisions, no state, no I/O, no clock:
37
+ *
38
+ * · `detectDrift` — is the run still doing what it said?
39
+ * · `acceptCompletion` — is a `done` a completion or a claim?
40
+ * · `reanchorDecision` — what to re-inject, when, and where it may go.
41
+ * · `reconcile` — what changed vs what was promised.
42
+ *
43
+ * ⚠️ IT NEVER MARKS, NEVER UNMARKS, NEVER STOPS A RUN AND NEVER GATES A TOOL.
44
+ * `plan-ledger.mjs` is built on `done` being asserted; a coherence layer that
45
+ * quietly started inferring completion from the disk would break the one
46
+ * property that makes the ledger trustworthy, and it would break it in the
47
+ * OPTIMISTIC direction. Everything here returns a verdict plus its evidence and
48
+ * hands the decision to the caller.
49
+ *
50
+ * ⚠️ AND EVERY THRESHOLD LEANS THE SAME WAY `stuck.mjs` LEANS, for the same
51
+ * asymmetry: missing drift costs a few rounds; telling a working run it has
52
+ * drifted costs the user the work, because the model believes the runner and
53
+ * re-plans around it. Every ambiguity here resolves towards "on plan".
54
+ *
55
+ * ⚠️ PURE, WITH NOTHING TO INJECT. No `fetchImpl`, no `spawnImpl`, no `now` —
56
+ * not because they were omitted but because this module reads only the round
57
+ * history the loop already keeps and the plan object `loadPlan` already
58
+ * returned. That is why every branch below is tested with no network, no disk
59
+ * and no API key.
60
+ *
61
+ * ── ⭐ TWO SECTIONS WERE ADDED 2026-08-20, AND THE HEADER ABOVE NOW UNDERSTATES
62
+ * WHAT IS HERE ─────────────────────────────────────────────────────────
63
+ *
64
+ * · `driftBannerLine` — the one-line, PERSON-facing version of `driftNudge`.
65
+ * Added because the wiring in `turn.mjs` reached the model and stopped
66
+ * there: `renderEvent` has no case for the `plan-drift` event, and
67
+ * `formatReconciliation` was imported by `turn.mjs` and called nowhere. The
68
+ * verdicts were correct, bound the model, and were invisible to the user.
69
+ * · **PLAN MODE** (section 5) — `--plan`: propose read-only, get a human's
70
+ * yes, then unlock writes. It is in this file rather than a new one because
71
+ * an approved plan is worthless unless it BINDS, and everything that makes
72
+ * a plan bind is already here.
73
+ *
74
+ * ⚠️ SECTION 5 IS STILL DEPENDENCY-FREE, but `runPlanGate` takes `propose`,
75
+ * `ask` and `print` as parameters — the model call, the terminal and the output
76
+ * stream, injected rather than reached for. Same property, stated differently:
77
+ * nothing in this file touches the world on its own.
78
+ */
79
+
80
+ import { changedPaths } from './changed-paths.mjs';
81
+ import { outstanding, significantWords } from './plan-ledger.mjs';
82
+
83
+ /* ══════════════════════════════════════════════════════════════════════════
84
+ * tool vocabulary
85
+ * ══════════════════════════════════════════════════════════════════════════ */
86
+
87
+ /**
88
+ * ⚠️ KEYED ON WHAT HAPPENED, NOT ON THE FAMOUS NAME — `stuck.mjs` says this and
89
+ * says it has been bitten three times (`evaluate`, `check_acceptance`,
90
+ * `run_program` each missing in turn). Anything that spawns a process belongs
91
+ * here, and the list is exported so a test can compare it against the
92
+ * dispatcher rather than trusting that two files stayed in sync.
93
+ *
94
+ * ⚠️ OPEN ITEM, RECORDED RATHER THAN QUIETLY DUPLICATED: `stuck.mjs` keeps its
95
+ * own PRIVATE copy of this set. Two spellings of "a process ran" is exactly the
96
+ * three-way disagreement `changed-paths.mjs` was written to end. The right fix
97
+ * is to hoist one set into a leaf both import — a lead wiring change, not mine
98
+ * to make in a shared checkout, since `stuck.mjs` is not one of my files.
99
+ */
100
+ export const RUN_TOOLS = new Set([
101
+ 'run_command', 'run_program', 'evaluate', 'check_acceptance', 'repl', 'check_types',
102
+ ]);
103
+
104
+ /** Tools whose success means work left this machine, or became a commit. */
105
+ export const DELIVER_TOOLS = new Set([
106
+ 'git_commit', 'git_push', 'git_branch', 'gh_pr', 'gh_issue',
107
+ ]);
108
+
109
+ /** Read-only orientation. ⭐ Named so the drift rule can say out loud that
110
+ * none of these can ever count as drift, however many of them there are. */
111
+ export const ORIENT_TOOLS = new Set([
112
+ 'read_file', 'read_lines', 'read_around', 'read_document', 'read_table', 'read_image',
113
+ 'search_text', 'find_files', 'find_definition', 'find_references', 'list_symbols',
114
+ 'list_dir', 'git_diff', 'git_log', 'git_status', 'docs', 'fetch_url', 'web_search',
115
+ 'see_page', 'read_skill', 'plan_status',
116
+ ]);
117
+
118
+ /** A shell string that IS a delivery, for runs that shell out instead. */
119
+ const DELIVER_COMMAND = /\bgit\s+(?:commit|push)\b|\bgh\s+pr\s+create\b/;
120
+
121
+ /* ══════════════════════════════════════════════════════════════════════════
122
+ * normalisation
123
+ * ══════════════════════════════════════════════════════════════════════════ */
124
+
125
+ /**
126
+ * `src\x.js`, `./src/x.js` and `src//x.js` are one file.
127
+ *
128
+ * ⚠️ CASE IS LEFT ALONE, copying `stuck.mjs`'s reasoning: folding case MERGES
129
+ * two paths, and a merge is the direction that manufactures a false positive.
130
+ */
131
+ function normPath(value) {
132
+ if (typeof value !== 'string') return null;
133
+ let s = value.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
134
+ while (s.startsWith('./')) s = s.slice(2);
135
+ s = s.replace(/\/+$/, '');
136
+ return s.length ? s : null;
137
+ }
138
+
139
+ const basename = (p) => p.slice(p.lastIndexOf('/') + 1);
140
+
141
+ /**
142
+ * The name before the FIRST dot, not the last.
143
+ *
144
+ * ⚠️ AND THAT CHOICE IS A FALSE-POSITIVE FIX, NOT A STYLE ONE. Splitting on the
145
+ * last dot makes `store.test.mjs` stem to `store.test`, which matches nothing —
146
+ * so a run that wrote `lib/store.mjs` for step 1 and then `test/store.test.mjs`
147
+ * beside it had the SECOND file counted as unattributed, and three such pairs
148
+ * would have manufactured a drift verdict out of a model doing exactly what it
149
+ * promised plus tests. Splitting on the first dot gives `store` for both.
150
+ */
151
+ const primaryStem = (p) => {
152
+ const b = basename(p);
153
+ const dot = b.indexOf('.');
154
+ return dot > 0 ? b.slice(0, dot) : b;
155
+ };
156
+
157
+ /**
158
+ * Path-shaped tokens inside a step's prose.
159
+ *
160
+ * ⚠️ DELIBERATELY LOOSE. This feeds ATTRIBUTION, and attribution failing open
161
+ * (a file counted as promised when it was only nearly promised) costs nothing,
162
+ * while attribution failing shut manufactures drift out of ordinary work. So
163
+ * `node.js` in a sentence matches as a "path". That is the cheap direction.
164
+ */
165
+ const PATH_TOKEN = /[A-Za-z0-9_.-]+(?:[/\\][A-Za-z0-9_.-]+)+|[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9]{0,7}/g;
166
+
167
+ /**
168
+ * ⚠️ SEGMENT MATCHING NEEDS A STOPLIST OR IT ATTRIBUTES EVERYTHING. A step
169
+ * mentioning "lib" would otherwise claim every file under `lib/`, which is the
170
+ * whole tree — attribution so generous it can never report anything, i.e. a
171
+ * check that cannot fail.
172
+ */
173
+ const GENERIC_SEGMENTS = new Set([
174
+ 'src', 'lib', 'test', 'tests', 'spec', 'app', 'apps', 'dist', 'build', 'out',
175
+ 'bin', 'pkg', 'pkgs', 'packages', 'node_modules', 'index', 'main', 'utils',
176
+ 'util', 'common', 'shared', 'components', 'scripts', 'tmp', 'temp', 'docs',
177
+ ]);
178
+
179
+ /**
180
+ * Everything a step names that could be matched against a real path.
181
+ * @returns {{ paths: string[], basenames: string[], words: Set<string> }}
182
+ */
183
+ export function stepTargets(text) {
184
+ const s = String(text ?? '');
185
+ const paths = [];
186
+ const basenames = [];
187
+ const stems = new Set();
188
+ for (const raw of s.match(PATH_TOKEN) ?? []) {
189
+ const p = normPath(raw.replace(/[.,;:)"'`]+$/, ''));
190
+ if (!p) continue;
191
+ if (!paths.includes(p)) paths.push(p);
192
+ const b = basename(p);
193
+ if (b && !basenames.includes(b)) basenames.push(b);
194
+ const st = primaryStem(p).toLowerCase();
195
+ if (st.length >= 3) stems.add(st);
196
+ }
197
+ return { paths, basenames, stems, words: significantWords(s) };
198
+ }
199
+
200
+ /* ══════════════════════════════════════════════════════════════════════════
201
+ * flattening — one ordered event stream, in the shape turn.mjs already keeps
202
+ * ══════════════════════════════════════════════════════════════════════════ */
203
+
204
+ /**
205
+ * @param {Array} rounds `{ round, executed: [{ name, args, result, mutated }] }`
206
+ *
207
+ * ⚠️ EVERY FIELD ABSENT UNTIL PROVEN PRESENT. A provider can emit a tool call
208
+ * whose arguments do not parse; a resumed session can carry an older shape. A
209
+ * coherence check that throws inside the loop it protects is worse than none.
210
+ *
211
+ * ⭐ PATHS COME FROM `changedPaths`, NOT FROM `args.path`. That module exists
212
+ * because three callers each invented their own spelling and one of them
213
+ * silently dropped 44 of 45 files from a bulk write. A fourth spelling here
214
+ * would re-open exactly that bug — and it would do it inside the function whose
215
+ * job is to notice which files changed.
216
+ */
217
+ export function flattenRounds(rounds) {
218
+ const events = [];
219
+ if (!Array.isArray(rounds)) return events;
220
+ rounds.forEach((round, roundIndex) => {
221
+ const label = Number.isFinite(round?.round) ? round.round : roundIndex + 1;
222
+ const executed = Array.isArray(round?.executed) ? round.executed : [];
223
+ for (const rec of executed) {
224
+ if (!rec || typeof rec !== 'object') continue;
225
+ if (typeof rec.name !== 'string' || !rec.name) continue;
226
+ const args = (rec.args && typeof rec.args === 'object') ? rec.args : {};
227
+ const result = (rec.result && typeof rec.result === 'object') ? rec.result : {};
228
+ events.push({
229
+ roundIndex,
230
+ label,
231
+ name: rec.name,
232
+ args,
233
+ result,
234
+ ok: result.ok === true,
235
+ mutated: rec.mutated === true,
236
+ paths: changedPaths(rec).map(normPath).filter(Boolean),
237
+ isRun: RUN_TOOLS.has(rec.name),
238
+ isOrient: ORIENT_TOOLS.has(rec.name),
239
+ });
240
+ }
241
+ });
242
+ return events;
243
+ }
244
+
245
+ /** The command string behind a run record, whatever tool produced it. */
246
+ function commandOf(ev) {
247
+ if (typeof ev.result.command === 'string') return ev.result.command;
248
+ if (typeof ev.args.command === 'string') return ev.args.command;
249
+ if (Array.isArray(ev.result.argv)) return ev.result.argv.join(' ');
250
+ if (Array.isArray(ev.args.argv)) return ev.args.argv.join(' ');
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * ⚠️ `ok: true` MEANS THE COMMAND RAN, NOT THAT IT PASSED — `command.mjs` says
256
+ * so itself, and `stuck.mjs` records what reading it the other way costs.
257
+ */
258
+ function runFailed(result) {
259
+ if (result?.timedOut === true) return true;
260
+ if (result?.passed === false) return true;
261
+ return Number.isFinite(result?.exitCode) && result.exitCode !== 0;
262
+ }
263
+
264
+ /* ══════════════════════════════════════════════════════════════════════════
265
+ * attribution — which step, if any, does this changed file belong to?
266
+ * ══════════════════════════════════════════════════════════════════════════ */
267
+
268
+ /** Strongest first. Exported so a test can pin the ORDER, not just the outcome. */
269
+ export const ATTRIBUTION_STRENGTHS = ['exact', 'basename', 'stem', 'segment'];
270
+
271
+ function matchesAt(target, path, strength) {
272
+ switch (strength) {
273
+ case 'exact':
274
+ return target.paths.some((p) => p === path || path.endsWith(`/${p}`) || p.endsWith(`/${path}`));
275
+ case 'basename':
276
+ return target.basenames.includes(basename(path));
277
+ case 'stem': {
278
+ const s = primaryStem(path).toLowerCase();
279
+ // ⚠️ Two characters is noise ("db", "js"); three is where a name starts
280
+ // identifying something — the same threshold `plan-ledger.mjs` picked.
281
+ if (s.length < 3) return false;
282
+ return target.words.has(s) || target.stems.has(s);
283
+ }
284
+ case 'segment': {
285
+ const segs = path.split('/').slice(0, -1);
286
+ return segs.some((s) => s.length >= 3 && !GENERIC_SEGMENTS.has(s.toLowerCase()) && target.words.has(s.toLowerCase()));
287
+ }
288
+ default:
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Which planned step does a changed path belong to?
295
+ *
296
+ * ⭐ STRENGTH-ORDERED, NOT PLAN-ORDERED, and the difference is the diagnosis.
297
+ * A run writing `lib/store.mjs` while step 1 says "write lib/store.mjs" and
298
+ * step 4 says "add store tests" must attribute to step 1; scanning steps first
299
+ * would hand it to whichever came earlier and make the reconciliation read
300
+ * wrong even when the verdict is right.
301
+ *
302
+ * @returns {{ id: string, strength: string } | null}
303
+ */
304
+ export function attributePath(path, steps) {
305
+ const p = normPath(path);
306
+ if (!p || !Array.isArray(steps)) return null;
307
+ const targets = steps.map((s) => ({ id: s?.id, target: stepTargets(s?.text) }));
308
+ for (const strength of ATTRIBUTION_STRENGTHS) {
309
+ for (const { id, target } of targets) {
310
+ if (id && matchesAt(target, p, strength)) return { id, strength };
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /* ══════════════════════════════════════════════════════════════════════════
317
+ * 1. DRIFT
318
+ * ══════════════════════════════════════════════════════════════════════════ */
319
+
320
+ /**
321
+ * ── ⚠️⚠️ WHERE THE LINE BETWEEN EXPLORATION AND DRIFT ACTUALLY IS ───────────
322
+ *
323
+ * The brief says drift must not fire on legitimate exploration, so here is the
324
+ * line, argued rather than tuned:
325
+ *
326
+ * ⭐ **READING IS NEVER DRIFT. WRITING IS THE ONLY EVIDENCE THAT COUNTS.**
327
+ *
328
+ * A model that reads forty files no step mentions is doing the thing a good
329
+ * agent does first: it cannot know which files matter until it has looked. The
330
+ * files it reads are chosen by the CODE's structure, not by the plan's wording,
331
+ * so "this read touched a file no step named" is not weak evidence of drift —
332
+ * it is no evidence at all, and a detector built on it would fire hardest on
333
+ * the most careful runs. `stuck.mjs` reached the same conclusion from the other
334
+ * side: "a long research phase — many reads, no writes" is pinned there as a
335
+ * NEGATIVE that must never be flagged.
336
+ *
337
+ * A WRITE is different in kind. It is a commitment: rounds spent, a file on
338
+ * disk, a thing the user will have to review. When a run commits repeatedly to
339
+ * files that no step in its own declared plan can be stretched to cover, and it
340
+ * has not touched its ledger while doing so, the run and the plan have stopped
341
+ * describing each other. That is a fact about two documents, not a judgement
342
+ * about intent.
343
+ *
344
+ * ⚠️ AND EVEN THAT NEEDS FOUR CONDITIONS AT ONCE, because each alone has an
345
+ * innocent explanation:
346
+ *
347
+ * (a) the window contains mutations at all — otherwise this is exploration,
348
+ * and the verdict is `exploring`, which is a SEPARATE verdict precisely
349
+ * so nobody later collapses it into `drifting`;
350
+ * (b) NOT ONE of those mutations attributes to any step — one stray
351
+ * `package.json` beside three planned files is ordinary;
352
+ * (c) at least MIN_UNATTRIBUTED distinct unplanned paths — a single unplanned
353
+ * file is a helper, three are a different piece of work;
354
+ * (d) no `plan_step` call in the window — a run maintaining its ledger is
355
+ * tracking its plan BY DEFINITION, whatever it is writing. This condition
356
+ * is the one that makes the check safe to ship: the well-behaved run can
357
+ * never trip it.
358
+ *
359
+ * ⚠️ ONE THING THIS DELIBERATELY CANNOT SEE: a plan whose steps are so vague
360
+ * ("improve the module") that every path attributes to them. Attribution is
361
+ * generous on purpose, so a vague plan produces `on-plan` forever. That is the
362
+ * correct failure direction — a vague plan is a planning problem, and inventing
363
+ * drift out of it would punish the user for the model's wording.
364
+ */
365
+ export const DRIFT_WINDOW = 6;
366
+ export const DRIFT_MIN_ROUNDS = 4;
367
+ export const DRIFT_MIN_UNATTRIBUTED = 3;
368
+
369
+ export const DRIFT_VERDICTS = ['no-plan', 'insufficient-history', 'plan-complete', 'exploring', 'on-plan', 'drifting'];
370
+
371
+ const NO_DRIFT = Object.freeze({
372
+ verdict: 'no-plan', drifting: false, evidence: null, suggestion: null,
373
+ });
374
+
375
+ /**
376
+ * Is the run still doing what it said it would?
377
+ *
378
+ * @param {object} args
379
+ * @param {object|null} args.plan a plan as `loadPlan` returns it
380
+ * @param {Array} args.rounds the history `runSession` keeps, oldest first
381
+ * @param {number} [args.window] how many recent rounds to examine
382
+ * @returns {{ verdict: string, drifting: boolean, evidence: object|null, suggestion: string|null }}
383
+ *
384
+ * ⚠️ A CLEAN RESULT CARRIES NO STALE HINT. `suggestion` is null unless
385
+ * `drifting` is true — this repo has already shipped one detector whose
386
+ * all-clear carried a verdict about a page it had never seen.
387
+ */
388
+ export function detectDrift({ plan, rounds, window = DRIFT_WINDOW } = {}) {
389
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return { ...NO_DRIFT };
390
+
391
+ const roundCount = Array.isArray(rounds) ? rounds.length : 0;
392
+ const windowRounds = Number.isFinite(window) && window >= 1 ? Math.floor(window) : DRIFT_WINDOW;
393
+ const events = flattenRounds(rounds);
394
+ const left = outstanding(plan);
395
+
396
+ // Always computed, always returned: "which steps have had no activity" is
397
+ // evidence a caller wants even when the verdict is on-plan.
398
+ const stale = staleSteps(plan, rounds);
399
+
400
+ const base = (verdict, extra = {}) => ({
401
+ verdict,
402
+ drifting: false,
403
+ evidence: { window: windowRounds, roundsSeen: roundCount, staleSteps: stale, ...extra },
404
+ suggestion: null,
405
+ });
406
+
407
+ if (left.length === 0) return base('plan-complete');
408
+ if (roundCount < DRIFT_MIN_ROUNDS) return base('insufficient-history');
409
+
410
+ const first = Math.max(0, roundCount - windowRounds);
411
+ const inWindow = events.filter((ev) => ev.roundIndex >= first);
412
+
413
+ const markedInWindow = inWindow
414
+ .filter((ev) => ev.name === 'plan_step' && ev.ok)
415
+ .map((ev) => ev.label);
416
+
417
+ const attributed = [];
418
+ const unattributed = [];
419
+ const mutatingRounds = new Set();
420
+ for (const ev of inWindow) {
421
+ if (!ev.mutated || ev.paths.length === 0) continue;
422
+ mutatingRounds.add(ev.label);
423
+ for (const p of ev.paths) {
424
+ const hit = attributePath(p, plan.steps);
425
+ if (hit) {
426
+ if (!attributed.some((a) => a.path === p)) attributed.push({ path: p, ...hit, round: ev.label });
427
+ } else if (!unattributed.some((u) => u.path === p)) {
428
+ unattributed.push({ path: p, round: ev.label });
429
+ }
430
+ }
431
+ }
432
+
433
+ const extra = {
434
+ markedInWindow,
435
+ attributed,
436
+ unattributed,
437
+ mutatingRounds: [...mutatingRounds],
438
+ };
439
+
440
+ // (a) nothing was committed to disk in the window — this is orientation.
441
+ if (mutatingRounds.size === 0) return base('exploring', extra);
442
+ // (d) the ledger is being maintained; whatever it is writing, it is tracking.
443
+ if (markedInWindow.length > 0) return base('on-plan', extra);
444
+ // (b) at least one mutation belongs to a step.
445
+ if (attributed.length > 0) return base('on-plan', extra);
446
+ // (c) one stray file is a helper; three are a different piece of work.
447
+ if (unattributed.length < DRIFT_MIN_UNATTRIBUTED) return base('on-plan', extra);
448
+
449
+ const evidence = {
450
+ window: windowRounds,
451
+ roundsSeen: roundCount,
452
+ staleSteps: stale,
453
+ ...extra,
454
+ key: `drift:${unattributed.map((u) => u.path).sort().join(',')}`,
455
+ };
456
+ return {
457
+ verdict: 'drifting',
458
+ drifting: true,
459
+ evidence,
460
+ suggestion: driftSuggestion(plan, evidence),
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Steps that nothing in the whole run can be attributed to.
466
+ *
467
+ * ⚠️ THE WORDING IS THE HONESTY. "No activity observed" is a fact about the
468
+ * tool stream; "not done" would be a claim about the disk that this module has
469
+ * no right to make — `plan-ledger.mjs` had to rewrite its own OUTSTANDING
470
+ * heading for exactly this reason after printing failure it had not observed.
471
+ */
472
+ export function staleSteps(plan, rounds) {
473
+ /**
474
+ * ⚠️ IT TAKES `rounds`, NOT the already-flattened events its only caller has
475
+ * to hand. A parameter that accepts either shape needs a sniff test to tell
476
+ * them apart, and a sniff test on an empty array guesses — which is how a
477
+ * function starts returning "nothing is stale" for a run it never looked at.
478
+ * One shape, one meaning, one flatten.
479
+ */
480
+ const events = flattenRounds(rounds);
481
+ const touched = new Set();
482
+ for (const ev of events) {
483
+ if (!ev.mutated) continue;
484
+ for (const p of ev.paths) {
485
+ const hit = attributePath(p, plan?.steps ?? []);
486
+ if (hit) touched.add(hit.id);
487
+ }
488
+ }
489
+ return (plan?.steps ?? [])
490
+ .filter((s) => s.state !== 'done' && !touched.has(s.id))
491
+ .map((s) => ({ id: s.id, text: s.text, state: s.state }));
492
+ }
493
+
494
+ /**
495
+ * ⭐ WRITTEN FOR A MODEL TO ACT ON, and it offers BOTH exits.
496
+ *
497
+ * A drift nudge that only says "get back on plan" is wrong half the time: the
498
+ * commonest cause of drift is a plan that stopped describing the work, not a
499
+ * model that stopped caring. `plan-ledger.mjs` learned this the expensive way —
500
+ * its first nudge named only `plan_step`, and a model that cannot honestly mark
501
+ * anything has no move at all when that is the only offer. So both verbs, and
502
+ * no scolding: an error string is an instruction, and "you have failed" reads
503
+ * as permission to hand back half a job.
504
+ */
505
+ function driftSuggestion(plan, evidence) {
506
+ const files = evidence.unattributed.slice(0, 4).map((u) => `\`${u.path}\``).join(', ');
507
+ const more = evidence.unattributed.length > 4 ? `, +${evidence.unattributed.length - 4} more` : '';
508
+ const left = outstanding(plan).slice(0, 3).map((s) => `${s.id} ${s.text}`).join(' · ');
509
+ return `The last ${evidence.window} rounds changed ${files}${more} — none of which any step of the recorded plan names — `
510
+ + `and no step was marked in that time. Still outstanding: ${left}. `
511
+ + `Two honest ways forward: if this work IS the task, call plan_start with the steps you are actually doing so the `
512
+ + `remaining budget is spent against a plan that is true; if the outstanding steps are still what was asked for, do `
513
+ + `the next one now, because the last one on the list is the one that gets lost.`;
514
+ }
515
+
516
+ /**
517
+ * The exact text to append, or null.
518
+ *
519
+ * ⚠️ IT ANNOUNCES ITSELF AS MACHINERY. A bare instruction arriving in the
520
+ * `user` role is indistinguishable from the human changing their mind, and a
521
+ * model that believes the user just spoke re-plans the whole task around it.
522
+ */
523
+ export function driftNudge(result) {
524
+ if (!result || result.drifting !== true || typeof result.suggestion !== 'string') return null;
525
+ return `[plan coherence — automatic, not from the user] ${result.suggestion}`;
526
+ }
527
+
528
+ /* ══════════════════════════════════════════════════════════════════════════
529
+ * 2. STEP COMPLETION EVIDENCE
530
+ * ══════════════════════════════════════════════════════════════════════════ */
531
+
532
+ /**
533
+ * ── ⭐⭐ A `done` WITH NO ARTIFACT IS A CLAIM, NOT A COMPLETION ──────────────
534
+ *
535
+ * Probe 1 run 3 in `plan-ledger.mjs`: the run reported ✔ VERIFIED while a named
536
+ * deliverable was untouched. Probe 3 tasks D and G each spent every round
537
+ * researching, each FOUND the answer, and neither wrote the file they were
538
+ * asked for. In both cases a `plan_step done` would have been accepted without
539
+ * a murmur, because nothing has ever looked.
540
+ *
541
+ * ⭐ SO EVIDENCE IS PER KIND, because the kinds are not comparable:
542
+ *
543
+ * kind what counts refuses a bare assertion?
544
+ * ─────── ───────────────────────────────── ────────────────────────
545
+ * write a mutating tool call whose changed YES
546
+ * path attributes to this step
547
+ * command a run tool that actually ran YES
548
+ * deliver git_commit / git_push / gh_pr, or a YES
549
+ * shell command that is one
550
+ * verify a run, OR a read of an attributed YES
551
+ * path (you must have LOOKED)
552
+ * research nothing observable NO — and deliberately
553
+ * unknown nothing observable NO — and deliberately
554
+ *
555
+ * ⚠️⚠️ THE LAST TWO ROWS ARE THE POINT, NOT A GAP. "Decide which library to
556
+ * use", "understand why the test fails" — the deliverable is knowledge and it
557
+ * lives in the model's reply, where this module cannot see it. Refusing those
558
+ * would be a guard that fails correct work, which this package has already
559
+ * shipped four times in one day and written a memory about. So they are
560
+ * accepted, and the result SAYS SO (`evidence: 'assertion'`), which is the
561
+ * difference between a hole and a documented boundary.
562
+ *
563
+ * ⚠️ AND A `write` STEP THAT NAMES NO FILE IS STILL CHECKED, JUST WEAKLY.
564
+ * "implement the retry logic" names nothing to attribute, so we fall back to
565
+ * the weakest true assertion available: a write step in a run that mutated
566
+ * NOTHING AT ALL is provably unfinished. That is the probe-3 shape exactly, and
567
+ * it costs no false positives because any mutation anywhere satisfies it.
568
+ */
569
+ export const STEP_KINDS = ['deliver', 'write', 'command', 'verify', 'research', 'unknown'];
570
+
571
+ /**
572
+ * ⚠️ `git_commit` DOES NOT MATCH `\bcommit\b`. An underscore is a word
573
+ * character, so there is no boundary between `git_` and `commit` — and the
574
+ * single most-cited example in `plan-ledger.mjs`'s header is a step literally
575
+ * named "git_commit". The first draft of this table classified that step as
576
+ * `unknown` and accepted it without evidence: the one step the whole module
577
+ * exists to catch, waved through by a regex detail. Hence the bare
578
+ * alternatives.
579
+ *
580
+ * ⚠️ AND BARE `make` / `build` ARE DELIBERATELY ABSENT. "make it easier to
581
+ * read" and "build the parser" are writing, not running, and classifying them
582
+ * as `command` would refuse them in a session that happened to run nothing —
583
+ * a guard failing correct work. `npm run build` still classifies, via `npm`
584
+ * and `run`.
585
+ */
586
+ const KIND_PATTERNS = [
587
+ ['deliver', /\b(?:commit|push|pull\s+request|open\s+a\s+pr|merge|tag|publish|release|deploy)\b|git_commit|git_push|gh_pr/i],
588
+ ['write', /\b(?:writ\w*|creat\w*|add|adds|adding|implement\w*|port\w*|refactor\w*|renam\w*|delet\w*|remov\w*|updat\w*|edit\w*|generat\w*|scaffold\w*|fix\w*|extend\w*|wire\w*|replac\w*|migrat\w*)\b/i],
589
+ ['command', /\b(?:run|runs|running|execut\w*|npm|npx|node|pytest|makefile|lint\w*|typecheck\w*|compil\w*|benchmark\w*|install|test|tests|suite)\b/i],
590
+ ['verify', /\b(?:verif\w*|check\w*|confirm\w*|validat\w*|ensur\w*|prov\w*|measur\w*|inspect\w*)\b/i],
591
+ ['research', /\b(?:research\w*|investigat\w*|read|reads|reading|review\w*|understand\w*|explor\w*|decid\w*|choos\w*|chose|design\w*|identif\w*|figure|compar\w*|assess\w*|audit\w*|plan|survey)\b/i],
592
+ ];
593
+
594
+ /**
595
+ * @returns {{ kind: string, kinds: string[] }}
596
+ *
597
+ * ⚠️ `kind` IS THE PRIMARY AND EVIDENCE IS REQUIRED FOR IT ALONE, even though
598
+ * `kinds` often has two entries ("port every file and THEN git_commit"). The
599
+ * conservative alternative — require evidence for every kind detected — refuses
600
+ * a step the moment any half of it is unobservable, and this module's whole
601
+ * safety argument is that it refuses rarely and specifically. `kinds` is
602
+ * exported so a lead with real data can tighten this deliberately rather than
603
+ * by accident.
604
+ *
605
+ * ⚠️ AND `deliver` OUTRANKS `write` ON PURPOSE. The measured failure is that
606
+ * the LAST-listed deliverable dies, and the last-listed deliverable is almost
607
+ * always the commit. A step containing both verbs is the exact shape of probe
608
+ * 4 run 2, where six files were ported and the commit never happened.
609
+ */
610
+ export function classifyStep(text) {
611
+ const s = String(text ?? '');
612
+ const kinds = KIND_PATTERNS.filter(([, rx]) => rx.test(s)).map(([k]) => k);
613
+ return { kind: kinds[0] ?? 'unknown', kinds };
614
+ }
615
+
616
+ /**
617
+ * Does the tool stream contain evidence that this step happened?
618
+ *
619
+ * @param {object} args
620
+ * @param {object} args.step `{ id, text, state }`
621
+ * @param {object} args.plan needed so attribution can see the OTHER steps
622
+ * @param {Array} args.rounds
623
+ * @returns {{
624
+ * id: string, kind: string, kinds: string[], accepted: boolean,
625
+ * evidence: string, paths: string[], rounds: number[],
626
+ * contradicted: boolean, why: string, remedy: string|null
627
+ * }}
628
+ */
629
+ export function acceptCompletion({ step, plan, rounds } = {}) {
630
+ const id = step?.id ?? null;
631
+ const { kind, kinds } = classifyStep(step?.text);
632
+ const events = flattenRounds(rounds);
633
+ const steps = plan?.steps ?? (step ? [step] : []);
634
+
635
+ const mine = (p) => attributePath(p, steps)?.id === id;
636
+
637
+ const out = (accepted, evidence, why, remedy = null, extra = {}) => ({
638
+ id, kind, kinds, accepted, evidence, why, remedy,
639
+ paths: extra.paths ?? [], rounds: extra.rounds ?? [], contradicted: extra.contradicted === true,
640
+ });
641
+
642
+ if (kind === 'research' || kind === 'unknown') {
643
+ return out(true, 'assertion',
644
+ `this step's deliverable is not observable in the tool stream (kind: ${kind}), so the assertion is accepted as-is — `
645
+ + 'refusing it would fail correct work');
646
+ }
647
+
648
+ const mutations = events.filter((ev) => ev.mutated && ev.paths.length > 0);
649
+ const myWrites = mutations.filter((ev) => ev.paths.some(mine));
650
+
651
+ if (kind === 'write') {
652
+ if (myWrites.length > 0) {
653
+ return out(true, 'file-written', 'a file this step names was written or edited', null, {
654
+ paths: [...new Set(myWrites.flatMap((ev) => ev.paths.filter(mine)))],
655
+ rounds: [...new Set(myWrites.map((ev) => ev.label))],
656
+ });
657
+ }
658
+ const named = stepTargets(step?.text).paths.length > 0;
659
+ if (!named && mutations.length > 0) {
660
+ // The weakest true statement available, and it is still worth making.
661
+ return out(true, 'run-wide-mutation',
662
+ 'this step names no file, so it cannot be attributed — but files were written in this run, which is the only '
663
+ + 'check available and it passes');
664
+ }
665
+ return out(false, 'none',
666
+ named
667
+ ? `this step names ${stepTargets(step.text).paths.map((p) => `\`${p}\``).join(', ')} and no tool call in this session wrote or edited it`
668
+ : 'this step asks for something to be written and no file was written or edited in this session at all',
669
+ 'write the file, then mark it done — or mark it blocked with a note saying what stopped you, which keeps it in the '
670
+ + 'outstanding list instead of hiding it', { contradicted: false });
671
+ }
672
+
673
+ if (kind === 'deliver') {
674
+ const delivered = events.filter((ev) => ev.ok
675
+ && (DELIVER_TOOLS.has(ev.name) || (ev.isRun && DELIVER_COMMAND.test(commandOf(ev) ?? ''))));
676
+ if (delivered.length > 0) {
677
+ return out(true, 'delivered', `${delivered[0].name} succeeded in this session`, null, {
678
+ rounds: [...new Set(delivered.map((ev) => ev.label))],
679
+ });
680
+ }
681
+ return out(false, 'none',
682
+ 'this step is a commit, push or PR and no such call succeeded in this session',
683
+ 'run it now — this is the step that gets lost when the rounds run out, so do it before anything more interesting');
684
+ }
685
+
686
+ if (kind === 'command') {
687
+ const ran = events.filter((ev) => ev.isRun && ev.ok);
688
+ if (ran.length === 0) {
689
+ return out(false, 'none',
690
+ 'this step asks for something to be run and no command, program or evaluation ran in this session',
691
+ 'run it and read the output — a step like this cannot be finished by describing what the command would print');
692
+ }
693
+ const last = ran[ran.length - 1];
694
+ if (runFailed(last.result)) {
695
+ return out(false, 'contradicted',
696
+ `the last command in this session (\`${commandOf(last) ?? last.name}\`) exited ${last.result.exitCode ?? 'non-zero'}`,
697
+ 'fix what it reported and run it again, or mark this step blocked with a note carrying that error — a done over a '
698
+ + 'red command is the exact claim this check exists to catch',
699
+ { contradicted: true, rounds: [last.label] });
700
+ }
701
+ return out(true, 'command-ran', `${last.name} ran and did not fail`, null, { rounds: [last.label] });
702
+ }
703
+
704
+ // verify
705
+ const ran = events.filter((ev) => ev.isRun && ev.ok);
706
+ if (ran.length > 0) {
707
+ return out(true, 'command-ran', 'a command ran in this session, which is how a check gets made', null,
708
+ { rounds: [ran[ran.length - 1].label] });
709
+ }
710
+ const looked = events.filter((ev) => ev.isOrient && ev.ok
711
+ && typeof ev.args?.path === 'string' && mine(normPath(ev.args.path) ?? ''));
712
+ if (looked.length > 0) {
713
+ return out(true, 'read-back', 'a file this step names was read back after the work', null, {
714
+ paths: [...new Set(looked.map((ev) => normPath(ev.args.path)))],
715
+ rounds: [...new Set(looked.map((ev) => ev.label))],
716
+ });
717
+ }
718
+ return out(false, 'none',
719
+ 'this step asks for something to be checked and nothing was run, and nothing it names was read back',
720
+ 'run the check, or read the artifact back and say what you saw — "it should work" is the claim, not the check');
721
+ }
722
+
723
+ /* ══════════════════════════════════════════════════════════════════════════
724
+ * 3. RE-ANCHORING
725
+ * ══════════════════════════════════════════════════════════════════════════ */
726
+
727
+ /**
728
+ * ── ⚠️⚠️ THE CONSTRAINT THAT MAKES THIS HARD, WITH THE NUMBER ───────────────
729
+ *
730
+ * A provider's prompt cache is keyed on an EXACT BYTE PREFIX: everything up to
731
+ * the first differing byte is reused at a fraction of the price, and everything
732
+ * from that byte on is paid in full. `lib/plan.mjs` states the consequence
733
+ * flatly — **"the cache rate IS the margin"** — and prices it: blended flash
734
+ * cost is $0.0686/M at 0% cache and $0.0228/M at 85%, so the same product doing
735
+ * the same work costs **2.4x** more with a cold prefix. `plan.mjs` puts a 95M
736
+ * plan at $3.19 at 65% cache and $1.66 at 95%: an 83% margin against a 91% one.
737
+ *
738
+ * ⭐ SO THE OBVIOUS DESIGN IS THE EXPENSIVE ONE, AND IT IS NAMED HERE SO NOBODY
739
+ * RE-PROPOSES IT FROM FIRST PRINCIPLES:
740
+ *
741
+ * ✗ "keep the goal fresh by rewriting the system prompt each round"
742
+ * ✗ "edit the original task message to add what is outstanding"
743
+ * ✗ "insert a reminder just before the last few messages"
744
+ *
745
+ * All three CHANGE A BYTE THAT IS ALREADY IN THE CACHED PREFIX, so every round
746
+ * pays full price for the entire conversation to say one sentence. A design that
747
+ * silently costs 2.4x is a bad design however smart it reads — and the failure
748
+ * is invisible: nothing goes red, the run works, the bill arrives later.
749
+ *
750
+ * ⭐ THE ONE SAFE PLACEMENT IS APPEND-AT-THE-END, and it is safe for a
751
+ * structural reason rather than a lucky one: messages `[0..k]` are untouched, so
752
+ * the cached prefix covering them still matches byte for byte. `turn.mjs`
753
+ * already relies on exactly this for the plan banner and says so: "APPENDED,
754
+ * never inserted… the 97.2% cache hit this loop was built around survives."
755
+ *
756
+ * ⚠️ WHICH LEAVES TOKENS, NOT CACHE, AS THE REAL COST — and it is a compounding
757
+ * one. Every appended anchor is re-sent on every subsequent round for the rest
758
+ * of the run. `turn.mjs` measured the equivalent: a 433-character per-round
759
+ * injection was ~109 tokens A ROUND, ~2,200–2,700 tokens over a 20-round
760
+ * session. So the anchor is RARE and BUDGETED rather than periodic-and-cheap:
761
+ * at most `REANCHOR_MAX_PER_RUN`, never inside `REANCHOR_MIN_GAP` rounds of the
762
+ * last one, never before `REANCHOR_FIRST_ROUND` (the goal is still in immediate
763
+ * context early on, so an anchor there buys nothing).
764
+ *
765
+ * ⭐ AND THE BUDGET IS SPENT WHERE THE MEASURED FAILURE IS. Three triggers earn
766
+ * an anchor, in strength order:
767
+ *
768
+ * · `compaction` — the strongest by far. Compaction DROPS transcript, which
769
+ * is the only event that can actually remove the goal from context. (It is
770
+ * also the one place appending is not merely cheap but necessary: the memory
771
+ * `project_acuvo_compaction_voids_the_cache` records that compaction frees
772
+ * ~8% of a transcript and destroys a 50x discount on ~87% of it.)
773
+ * · `drift` — `detectDrift` said so, with evidence.
774
+ * · `interval` — nothing is wrong; it has simply been a long time.
775
+ *
776
+ * · plus `final` — one anchor in the last rounds, EXEMPT from the cap. This is
777
+ * the probe-4 bug bought directly: 4 of 4 runs consumed the whole budget,
778
+ * none finished early, and the last-listed deliverable died every time. If
779
+ * there is one place to spend tokens on a reminder, it is the round before
780
+ * the wall.
781
+ */
782
+ export const REANCHOR_PLACEMENT = 'append-end';
783
+ export const REANCHOR_FIRST_ROUND = 6;
784
+ export const REANCHOR_MIN_GAP = 8;
785
+ export const REANCHOR_MAX_PER_RUN = 3;
786
+ /** How close to the wall the one exempt "finish it" anchor fires. */
787
+ export const REANCHOR_FINAL_WITHIN = 2;
788
+
789
+ export const REANCHOR_REASONS = ['compaction', 'drift', 'final', 'interval'];
790
+
791
+ /**
792
+ * The text of an anchor. Deterministic given its inputs — no clock, no
793
+ * randomness — so a test can assert the bytes and a cache can be reasoned about.
794
+ *
795
+ * ⚠️ IT RESTATES THE GOAL AND THE OUTSTANDING STEPS AND NOTHING ELSE. It is
796
+ * NOT a second banner: `formatBanner` already runs every round with the counts
797
+ * and the countdown. What survives 30 rounds of tool output badly is the TASK
798
+ * ITSELF — the one string the model has not seen since message 1.
799
+ */
800
+ export function anchorText(plan, { reason = 'interval', roundIndex, maxRounds } = {}) {
801
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return null;
802
+ const left = outstanding(plan);
803
+ if (left.length === 0) return null;
804
+
805
+ const where = Number.isFinite(roundIndex) && Number.isFinite(maxRounds)
806
+ ? ` (round ${roundIndex} of ${maxRounds})`
807
+ : '';
808
+ const lead = {
809
+ compaction: 'the conversation above was compacted, so the original task may no longer be in context',
810
+ drift: 'the recent rounds changed files that no planned step names',
811
+ final: 'the round budget is nearly spent',
812
+ interval: 'it has been a while since the task was stated',
813
+ }[reason] ?? 'restating the task';
814
+
815
+ const items = left.map((s) => ` ${s.id} [${s.state}] ${s.text}`).join('\n');
816
+ const tail = reason === 'final'
817
+ ? 'Finish the outstanding items that can still be finished, smallest-risk first, and if one cannot be finished mark it '
818
+ + 'blocked with a note rather than leaving it silent.'
819
+ : 'If these are still the deliverables, do the next one. If they are not, call plan_start with the steps you are '
820
+ + 'actually doing so the rest of the budget is spent against a plan that is true.';
821
+
822
+ return `[plan anchor — automatic, not from the user] Re-stating the goal because ${lead}${where}.\n`
823
+ + `TASK: ${plan.task}\n`
824
+ + `STILL OUTSTANDING (${left.length} of ${plan.steps.length}):\n${items}\n`
825
+ + tail;
826
+ }
827
+
828
+ /**
829
+ * Should an anchor be appended this round, and what should it say?
830
+ *
831
+ * ⚠️ THE CALLER OWNS THE STATE. No module-level counter: several workers share
832
+ * this process image in a fleet, and a counter that leaked between runs would
833
+ * silence the anchor for whichever run started second. State in, state out.
834
+ *
835
+ * @param {object} args
836
+ * @param {object|null} args.plan
837
+ * @param {number} args.roundIndex
838
+ * @param {number} [args.maxRounds]
839
+ * @param {{ lastAnchorRound?: number, count?: number, finalDone?: boolean }} [args.state]
840
+ * @param {object|null} [args.drift] a `detectDrift` result
841
+ * @param {boolean} [args.compactedSinceAnchor] the transcript was compacted
842
+ * @returns {{
843
+ * reanchor: boolean, reason: string|null, text: string|null,
844
+ * placement: string, approxTokens: number,
845
+ * state: { lastAnchorRound: number|null, count: number, finalDone: boolean },
846
+ * why: string
847
+ * }}
848
+ */
849
+ export function reanchorDecision({
850
+ plan, roundIndex, maxRounds, state = {}, drift = null, compactedSinceAnchor = false,
851
+ firstRound = REANCHOR_FIRST_ROUND, minGap = REANCHOR_MIN_GAP, maxPerRun = REANCHOR_MAX_PER_RUN,
852
+ finalWithin = REANCHOR_FINAL_WITHIN,
853
+ } = {}) {
854
+ const prev = {
855
+ lastAnchorRound: Number.isFinite(state.lastAnchorRound) ? state.lastAnchorRound : null,
856
+ count: Number.isFinite(state.count) ? state.count : 0,
857
+ finalDone: state.finalDone === true,
858
+ };
859
+ const no = (why) => ({
860
+ reanchor: false, reason: null, text: null,
861
+ placement: REANCHOR_PLACEMENT, approxTokens: 0, state: prev, why,
862
+ });
863
+
864
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return no('no plan is recorded');
865
+ if (outstanding(plan).length === 0) return no('every step is marked done, so there is nothing to re-anchor to');
866
+
867
+ const round = Number.isFinite(roundIndex) ? roundIndex : null;
868
+ if (round === null) return no('no round index was given, so neither the interval nor the wall can be judged');
869
+
870
+ const remaining = Number.isFinite(maxRounds) ? maxRounds - round : null;
871
+
872
+ /**
873
+ * ⭐ THE FINAL ANCHOR IS CHECKED FIRST AND IGNORES THE CAP. It is the one
874
+ * this whole feature is bought for; spending its tokens is the point.
875
+ */
876
+ if (!prev.finalDone && remaining !== null && remaining <= finalWithin && remaining >= 0) {
877
+ return yes('final', 'the round budget is nearly spent and the last-listed deliverable is the one that dies');
878
+ }
879
+
880
+ if (round < firstRound && !compactedSinceAnchor) {
881
+ return no(`round ${round} is inside the first ${firstRound}, where the task is still in immediate context`);
882
+ }
883
+ if (prev.count >= maxPerRun && !compactedSinceAnchor) {
884
+ return no(`${prev.count} anchors already appended, at the ${maxPerRun}-per-run cap; each one is re-sent every later round`);
885
+ }
886
+ if (prev.lastAnchorRound !== null && round - prev.lastAnchorRound < minGap && !compactedSinceAnchor) {
887
+ return no(`the last anchor was round ${prev.lastAnchorRound}, inside the ${minGap}-round minimum gap`);
888
+ }
889
+
890
+ if (compactedSinceAnchor) {
891
+ return yes('compaction', 'the transcript was compacted, which is the only event that can remove the goal from context');
892
+ }
893
+ if (drift?.drifting === true) {
894
+ return yes('drift', 'drift was detected with evidence, so restating the goal is the cheapest correction available');
895
+ }
896
+ if (prev.lastAnchorRound === null || round - prev.lastAnchorRound >= minGap) {
897
+ return yes('interval', `nothing is wrong; the task has not been restated for ${prev.lastAnchorRound === null ? round : round - prev.lastAnchorRound} rounds`);
898
+ }
899
+ return no('no trigger fired this round');
900
+
901
+ function yes(reason, why) {
902
+ const text = anchorText(plan, { reason, roundIndex: round, maxRounds });
903
+ if (!text) return no('there is nothing outstanding to restate');
904
+ return {
905
+ reanchor: true,
906
+ reason,
907
+ text,
908
+ placement: REANCHOR_PLACEMENT,
909
+ // ⚠️ A ROUGH DIVISOR, LABELLED ROUGH. ~4 chars/token is the usual English
910
+ // approximation and it is here so a caller can BUDGET, not so anyone can
911
+ // quote it as a measurement.
912
+ approxTokens: Math.ceil(text.length / 4),
913
+ state: {
914
+ lastAnchorRound: round,
915
+ count: prev.count + 1,
916
+ finalDone: prev.finalDone || reason === 'final',
917
+ },
918
+ why,
919
+ };
920
+ }
921
+ }
922
+
923
+ /**
924
+ * ── ⭐ THE GUARD THAT MAKES THE CACHE ARGUMENT TESTABLE ─────────────────────
925
+ *
926
+ * The whole re-anchoring design rests on one claim — "we only ever append" —
927
+ * and a claim nothing checks is a comment. This proves it about a real message
928
+ * array: every message that existed before must still be there, in the same
929
+ * order, byte-identical.
930
+ *
931
+ * ⚠️ IT COMPARES SERIALISED CONTENT, NOT OBJECT IDENTITY. A caller that
932
+ * rebuilds an equal message object has not broken the cache; a caller that
933
+ * changes one character of an earlier one has, and identity comparison would
934
+ * report those two the wrong way round.
935
+ */
936
+ export function wasAppendOnly(before, after) {
937
+ const a = Array.isArray(before) ? before : [];
938
+ const b = Array.isArray(after) ? after : [];
939
+ /**
940
+ * ⚠️ THERE IS NO `b.length < a.length` EARLY RETURN HERE, AND THERE USED TO
941
+ * BE. Mutation testing could not kill it: disabling the line left all 39
942
+ * tests green, because a truncated array makes the loop below compare a real
943
+ * message against `undefined`, whose key is `[null,null,null,null]` and never
944
+ * matches. A line no test can kill is a line whose absence changes nothing,
945
+ * and keeping it would have been a guard that only LOOKED load-bearing.
946
+ * Truncation is still refused — `wasAppendOnly proves the claim…` pins it.
947
+ */
948
+ const key = (m) => JSON.stringify([m?.role ?? null, m?.content ?? null, m?.tool_calls ?? null, m?.tool_call_id ?? null]);
949
+ for (let i = 0; i < a.length; i += 1) {
950
+ if (key(a[i]) !== key(b[i])) return false;
951
+ }
952
+ return true;
953
+ }
954
+
955
+ /* ══════════════════════════════════════════════════════════════════════════
956
+ * 4. RECONCILIATION
957
+ * ══════════════════════════════════════════════════════════════════════════ */
958
+
959
+ /**
960
+ * ── ⭐⭐ WHAT CHANGED vs WHAT I PROMISED ────────────────────────────────────
961
+ *
962
+ * The final report can already print what was PLANNED (`formatLedger`). It has
963
+ * never been able to print what was DONE, because nothing joined the ledger to
964
+ * the tool stream. This is that join, and its headline is the number nobody has
965
+ * ever been shown: **how many of the steps marked done have any evidence.**
966
+ *
967
+ * ⚠️ IT ANSWERS "FINISHED?", NEVER "CORRECT?" — the same wall `formatLedger`
968
+ * keeps, for the same reason. A file being written is not the same as the file
969
+ * being right, and the moment this block appears to have judged correctness it
970
+ * becomes the ✔ VERIFIED over an untouched deliverable that started all of this.
971
+ */
972
+ export function reconcile({ plan, rounds } = {}) {
973
+ if (!plan || !Array.isArray(plan.steps)) {
974
+ return {
975
+ ok: false, promised: 0, markedDone: 0, evidenced: 0, claimed: [],
976
+ steps: [], unpromised: [], note: 'no plan was recorded for this run, so there is nothing to reconcile against',
977
+ };
978
+ }
979
+ const events = flattenRounds(rounds);
980
+
981
+ const steps = plan.steps.map((step) => {
982
+ const verdict = acceptCompletion({ step, plan, rounds });
983
+ return {
984
+ id: step.id,
985
+ text: step.text,
986
+ state: step.state,
987
+ kind: verdict.kind,
988
+ accepted: verdict.accepted,
989
+ evidence: verdict.evidence,
990
+ paths: verdict.paths,
991
+ rounds: verdict.rounds,
992
+ contradicted: verdict.contradicted,
993
+ why: verdict.why,
994
+ remedy: verdict.remedy,
995
+ };
996
+ });
997
+
998
+ const changed = [];
999
+ for (const ev of events) {
1000
+ if (!ev.mutated) continue;
1001
+ for (const p of ev.paths) {
1002
+ if (changed.some((c) => c.path === p)) continue;
1003
+ const hit = attributePath(p, plan.steps);
1004
+ changed.push({ path: p, round: ev.label, step: hit?.id ?? null, strength: hit?.strength ?? null });
1005
+ }
1006
+ }
1007
+
1008
+ const markedDone = steps.filter((s) => s.state === 'done');
1009
+ const claimed = markedDone.filter((s) => !s.accepted);
1010
+
1011
+ return {
1012
+ ok: true,
1013
+ promised: steps.length,
1014
+ markedDone: markedDone.length,
1015
+ /** Marked done AND the tool stream backs it. The only honest "done" count. */
1016
+ evidenced: markedDone.length - claimed.length,
1017
+ /** ⭐ THE MONEY LIST: asserted, unevidenced. */
1018
+ claimed,
1019
+ outstandingSteps: steps.filter((s) => s.state !== 'done'),
1020
+ steps,
1021
+ changed,
1022
+ /** Files this run wrote that no step promised. Not an accusation — often a
1023
+ * test, a lockfile or a fixture — but the user is entitled to the list. */
1024
+ unpromised: changed.filter((c) => c.step === null).map((c) => c.path),
1025
+ note: null,
1026
+ };
1027
+ }
1028
+
1029
+ const MARK = { done: '✓', doing: '→', blocked: '✗', todo: '·' };
1030
+
1031
+ /** The printable block. Lines, so the caller decides about colour and width. */
1032
+ export function formatReconciliation(rec) {
1033
+ if (!rec?.ok) return [`RECONCILIATION — ${rec?.note ?? 'nothing to reconcile'}`];
1034
+
1035
+ const lines = ['RECONCILIATION — what changed vs what was promised'];
1036
+ lines.push(` promised ${rec.promised} · marked done ${rec.markedDone} · evidenced ${rec.evidenced} · `
1037
+ + `asserted without evidence ${rec.claimed.length} · unpromised files ${rec.unpromised.length}`);
1038
+
1039
+ for (const s of rec.steps) {
1040
+ const mark = s.state === 'done' && !s.accepted ? '!' : (MARK[s.state] ?? '·');
1041
+ const where = s.paths.length ? ` — ${s.paths.slice(0, 3).join(', ')}` : '';
1042
+ const note = s.state === 'done' && !s.accepted ? ` — MARKED DONE, but ${s.why}` : where;
1043
+ lines.push(` ${mark} ${String(s.id).padEnd(3)} [${s.kind}] ${s.text}${note}`);
1044
+ }
1045
+
1046
+ if (rec.unpromised.length) {
1047
+ lines.push('');
1048
+ lines.push(` changed but promised by no step: ${rec.unpromised.slice(0, 10).join(', ')}`
1049
+ + (rec.unpromised.length > 10 ? `, +${rec.unpromised.length - 10} more` : ''));
1050
+ }
1051
+
1052
+ lines.push('');
1053
+ lines.push('(this compares the ledger against the tool calls that actually ran — it says nothing about whether the '
1054
+ + 'finished work is correct)');
1055
+ return lines;
1056
+ }
1057
+
1058
+ /**
1059
+ * ── ⭐⭐ THE ONE-LINE VERSION, FOR THE PERSON RATHER THAN THE MODEL ──────────
1060
+ *
1061
+ * `driftNudge` above is written for a model: it offers both exits, names the
1062
+ * verbs, and refuses to scold. It is also 400-odd characters and it goes into
1063
+ * the CONVERSATION, where the user never sees it.
1064
+ *
1065
+ * ⚠️⚠️ AND THAT WAS THE WHOLE OF THE VISIBILITY STORY UNTIL NOW. Measured
1066
+ * 2026-08-20 against the wired loop: `turn.mjs` emits `{ type: 'plan-drift' }`
1067
+ * every time a distinct drift is detected, `renderEvent` has NO case for that
1068
+ * type and returns `[]`, and `formatReconciliation` is imported on line 64 and
1069
+ * called nowhere. So both verdicts bound the model, both were computed
1070
+ * correctly, and the person paying for the run saw neither of them.
1071
+ *
1072
+ * ⚠️ ONE LINE, AND ONLY ON `drifting`. Every other verdict returns null. This
1073
+ * file's own rule about clean results carrying no stale hint applies double to
1074
+ * a terminal: a banner that also fires on `on-plan` and `exploring` is a line
1075
+ * per round saying nothing is wrong, which is how people learn to skim the one
1076
+ * round where something was.
1077
+ *
1078
+ * ⚠️ AND IT NAMES FILES, NOT COUNTS. "3 unattributed writes" is a number a user
1079
+ * cannot act on; `notes/scratch1.md` is the fact that makes them say either
1080
+ * "yes, that is the task" or "stop".
1081
+ */
1082
+ export function driftBannerLine(result) {
1083
+ if (!result || result.drifting !== true) return null;
1084
+ const ev = result.evidence;
1085
+ if (!ev || !Array.isArray(ev.unattributed) || ev.unattributed.length === 0) return null;
1086
+
1087
+ const files = ev.unattributed.slice(0, 3).map((u) => u.path).join(', ');
1088
+ const more = ev.unattributed.length > 3 ? ` +${ev.unattributed.length - 3} more` : '';
1089
+ const stale = Array.isArray(ev.staleSteps) ? ev.staleSteps.length : 0;
1090
+ const untouched = stale > 0 ? `; ${stale} planned step${stale === 1 ? '' : 's'} still untouched` : '';
1091
+ return `⚠ plan drift — the last ${ev.window} rounds wrote ${files}${more}, which no planned step names, `
1092
+ + `and marked no step${untouched}. The model has been told; nothing has been stopped.`;
1093
+ }
1094
+
1095
+ /* ══════════════════════════════════════════════════════════════════════════
1096
+ * 5. PLAN MODE — READ, PROPOSE, GET APPROVAL, THEN UNLOCK WRITES
1097
+ * ══════════════════════════════════════════════════════════════════════════ */
1098
+
1099
+ /**
1100
+ * ── ⚠️⚠️ TWO FLAGS LOOKED LIKE THIS GATE AND NEITHER IS IT ──────────────────
1101
+ *
1102
+ * `--dry-run` prints what WOULD be written; `--no-run` withholds the process
1103
+ * spawners. Both describe the ACT. Neither describes the INTENT, and intent is
1104
+ * the thing a person wants to see before an agent touches a repository they
1105
+ * care about: *tell me what you are going to do, and let me say no.*
1106
+ *
1107
+ * Measured before this landed: `grep -c "arg === '--plan'" lib/cli-args.mjs`
1108
+ * returned **0**, and the word "plan" appeared in `--help` only inside the
1109
+ * `plan_start` ledger story. The capability did not exist under any spelling.
1110
+ *
1111
+ * ── ⭐ EVERY PART ALREADY EXISTED, WHICH IS WHY THIS SECTION IS SMALL ───────
1112
+ *
1113
+ * · the read-only subset → `ORIENT_TOOLS`, twenty lines above
1114
+ * · the question → `createAsker` in prompt.mjs
1115
+ * · an offer that varies by budget → `toolNamesForRounds` in tools.mjs
1116
+ *
1117
+ * Nothing joined them. What is added here is the JOIN, kept pure so it can be
1118
+ * tested with no network, no disk, no terminal and no key — the same rule the
1119
+ * rest of this module obeys.
1120
+ *
1121
+ * ⚠️⚠️ AND THE PHASE IS READ-ONLY BY ABSENCE, NOT BY REFUSAL. That is this
1122
+ * package's standing rule (`tools.mjs`: "a control that presents itself and
1123
+ * does nothing is worse than one that is absent") and it matters more here than
1124
+ * anywhere: a plan phase that OFFERS `write_file` and refuses it teaches the
1125
+ * model to spend the proposal budget discovering the button is dead, and the
1126
+ * proposal budget is the whole phase.
1127
+ */
1128
+
1129
+ /**
1130
+ * ⚠️ TWO, BECAUSE ONE IS THE SINGLE-SHOT COLLAPSE. `toolNamesForRounds(1)`
1131
+ * returns `['write_file','write_files']` — intersect that with the read-only
1132
+ * set and the model is handed ZERO tools and asked to plan, in the one phase
1133
+ * whose entire value is that it can look before it commits.
1134
+ */
1135
+ export const PLAN_MODE_MIN_ROUNDS = 2;
1136
+
1137
+ /**
1138
+ * ⚠️ THE PROPOSAL MUST NOT BE ALLOWED TO EAT THE BUDGET IT IS PLANNING. A
1139
+ * twelve-round proposal against `--max-rounds 12` leaves nothing to execute
1140
+ * with, and the user typed that number for the WORK. Five rounds is enough to
1141
+ * read a handful of files and a diff; beyond that the model is not planning,
1142
+ * it is doing the task without being allowed to write it down.
1143
+ */
1144
+ export const PLAN_MODE_MAX_ROUNDS = 5;
1145
+
1146
+ /**
1147
+ * ⚠️ `plan_status` READS, AND IS STILL EXCLUDED. `.acuvo/plan.json` outlives the
1148
+ * run that wrote it — `plan-ledger.mjs` documents the two-terminal case where
1149
+ * one workspace's plan described a completely different task. During a PROPOSAL
1150
+ * there is by construction no plan for this task yet, so the only thing
1151
+ * `plan_status` can return is somebody else's, and a model that reads it starts
1152
+ * planning around work it is not doing.
1153
+ */
1154
+ export const PLAN_MODE_EXCLUDED = new Set(['plan_status']);
1155
+
1156
+ export const PLAN_MODE_DECISIONS = ['approve', 'amend', 'reject'];
1157
+
1158
+ /**
1159
+ * The read-only offer for the proposal phase.
1160
+ *
1161
+ * ⭐ AN INTERSECTION, NEVER A FIXED LIST, and that is the load-bearing choice.
1162
+ * `ORIENT_TOOLS` names tools this machine may not have — `read_skill` is gated
1163
+ * on the workspace containing skills, the LSP reads are gated on a language
1164
+ * server existing. Handing the model a hard-coded read list would ship exactly
1165
+ * the dead buttons `tools.mjs` spends four hundred lines refusing to ship.
1166
+ *
1167
+ * @param {string[]} offered what `toolNamesForRounds` returned for this run
1168
+ * @returns {{ ok: true, names: string[] } | { ok: false, names: [], error: string }}
1169
+ */
1170
+ export function planModeToolNames(offered) {
1171
+ const list = Array.isArray(offered) ? offered : [];
1172
+ const names = list.filter((n) => typeof n === 'string' && ORIENT_TOOLS.has(n) && !PLAN_MODE_EXCLUDED.has(n));
1173
+ if (names.length === 0) {
1174
+ return {
1175
+ ok: false,
1176
+ names: [],
1177
+ error: 'this run offers no read-only tools at all, so a plan phase would ask the model to propose work it '
1178
+ + 'cannot look at first. Raise --max-rounds to at least 2 (a single-round run is offered write verbs only).',
1179
+ };
1180
+ }
1181
+ return { ok: true, names };
1182
+ }
1183
+
1184
+ /** How many rounds the proposal gets. Clamped at both ends; see the constants. */
1185
+ export function planModeRounds(maxRounds) {
1186
+ const n = Number.isFinite(maxRounds) ? Math.floor(maxRounds) : PLAN_MODE_MIN_ROUNDS;
1187
+ if (n < PLAN_MODE_MIN_ROUNDS) return PLAN_MODE_MIN_ROUNDS;
1188
+ return Math.min(n, PLAN_MODE_MAX_ROUNDS);
1189
+ }
1190
+
1191
+ /**
1192
+ * ⚠️ THE THIRD PART OF REACHABILITY. A schema the model is offered and a
1193
+ * dispatcher that answers are two thirds; the sentence telling it what mode it
1194
+ * is in is the third, and it is the one this repo forgets. A model handed only
1195
+ * read tools and no explanation concludes the write tools failed to load and
1196
+ * spends the phase apologising.
1197
+ *
1198
+ * ⭐ IT ALSO NAMES THE SHAPE OF THE ANSWER. The proposal's whole job is to be
1199
+ * read by a human in five seconds and then re-read by a model as a ledger, so
1200
+ * "numbered, one deliverable a line, files named" is not style — it is what
1201
+ * makes `plan_start` able to record it and `attributePath` able to bind it.
1202
+ */
1203
+ export function planPhaseTask(task) {
1204
+ return [
1205
+ 'PLAN MODE — you are proposing, not doing. This phase is READ-ONLY: you have been offered reading, searching',
1206
+ 'and history tools only, and you cannot write a file, run a command or commit anything until a human has read',
1207
+ 'your plan and approved it. That is by design, not a fault — do not try to work around it and do not apologise',
1208
+ 'for it.',
1209
+ '',
1210
+ 'Look at whatever you need to (read the files, search, check the diff), then answer with THE PLAN and nothing',
1211
+ 'else. Number the steps, one deliverable per line, in the order you will do them. NAME THE FILES you intend to',
1212
+ 'create or change, because a step that names no file cannot be checked against what you actually did. Put any',
1213
+ 'commit or final write early enough in the list that it survives a short budget. If something is genuinely',
1214
+ 'ambiguous, say which assumption you are taking — the person approving this is about to read it.',
1215
+ '',
1216
+ 'Your final message is the plan. It will be shown to a person for approval before anything is executed.',
1217
+ '',
1218
+ `THE TASK: ${task}`,
1219
+ ].join('\n');
1220
+ }
1221
+
1222
+ /**
1223
+ * ⚠️ A PLAN LONGER THAN THIS IS NOT A PLAN. It is re-sent on every round of the
1224
+ * executing run, so it is a per-round tax for the life of that run — the same
1225
+ * compounding cost `reanchorDecision` above budgets against. 6,000 characters
1226
+ * is roughly 1,500 tokens, which is a generous twenty-step plan.
1227
+ */
1228
+ export const MAX_APPROVED_PLAN_CHARS = 6_000;
1229
+ /** Same reasoning, smaller: a correction is a sentence, not an essay. */
1230
+ export const MAX_AMENDMENT_CHARS = 1_000;
1231
+
1232
+ /**
1233
+ * ⚠️ THE CUT SAYS SO. A silently truncated plan reads as a whole one, and the
1234
+ * model then confidently does the first two thirds of a job and reports it
1235
+ * finished. `clampOutput` in command.mjs makes the same argument; this module
1236
+ * is deliberately dependency-free (see the header), so it makes it locally.
1237
+ */
1238
+ function clamp(text, max, what) {
1239
+ const s = String(text ?? '');
1240
+ if (s.length <= max) return s;
1241
+ return `${s.slice(0, max)}\n… [${what} truncated here: ${s.length} characters, ${max} kept]`;
1242
+ }
1243
+
1244
+ const APPROVE = /^(?:y|yes|ok|okay|go|approve|approved|do it)$/i;
1245
+ /**
1246
+ * ⚠️ A LEADING-WORD TEST, NOT AN EXACT ONE, AND THE ASYMMETRY IS DELIBERATE.
1247
+ * "no, do it differently" begins with a refusal and ends with an instruction;
1248
+ * reading it as an amendment would unlock writes on a sentence that started
1249
+ * with the word no. It refuses, and the user retypes. Approval matches EXACTLY
1250
+ * because the opposite mistake — "yes but not the tests" read as a bare yes —
1251
+ * would silently drop the only constraint they gave.
1252
+ */
1253
+ const REJECT = /^(?:n|no|nope|q|quit|abort|cancel|stop)\b/i;
1254
+
1255
+ /**
1256
+ * What the person typed, as a decision.
1257
+ *
1258
+ * ⚠️⚠️ SILENCE IS NOT CONSENT, AND THIS IS WHERE THIS FILE PARTS COMPANY WITH
1259
+ * `ask-user.mjs`. That module treats a bare Enter as "you decide", which is
1260
+ * right for "which of these two designs" — the model has to pick something
1261
+ * either way. It is wrong here. This keystroke is the only thing standing
1262
+ * between a proposal and a file-writing agent, and the two errors are not
1263
+ * symmetric: approving unintended writes costs the user their work, refusing
1264
+ * costs them one retype. So Enter refuses, and the prompt says `[y/N]` so the
1265
+ * default is visible rather than discovered.
1266
+ *
1267
+ * @returns {{ decision: 'approve'|'amend'|'reject', amendment: string|null, why: string }}
1268
+ */
1269
+ export function planApproval(answer) {
1270
+ if (answer === null || answer === undefined) {
1271
+ return { decision: 'reject', amendment: null, why: 'the terminal closed before an answer arrived' };
1272
+ }
1273
+ const s = String(answer).trim();
1274
+ if (s === '') return { decision: 'reject', amendment: null, why: 'nothing was typed, and silence is not approval here' };
1275
+ if (APPROVE.test(s)) return { decision: 'approve', amendment: null, why: 'approved as proposed' };
1276
+ if (REJECT.test(s)) return { decision: 'reject', amendment: null, why: `declined: "${s}"` };
1277
+ return {
1278
+ decision: 'amend',
1279
+ amendment: clamp(s, MAX_AMENDMENT_CHARS, 'amendment'),
1280
+ why: 'approved with a correction, which the executing run is told outranks the plan',
1281
+ };
1282
+ }
1283
+
1284
+ /**
1285
+ * The task the EXECUTING run receives once a human has said yes.
1286
+ *
1287
+ * ⭐⭐ THE LAST PARAGRAPH IS WHERE THE TWO HALVES OF THIS FILE MEET. Getting a
1288
+ * plan approved buys nothing on its own — `plan-ledger.mjs`'s measured failure
1289
+ * is a six-step plan across 20 rounds with `plan_step` never called once. So
1290
+ * the approved task does not merely CARRY the plan, it instructs the model to
1291
+ * record it with `plan_start` and mark it with `plan_step`, which is what puts
1292
+ * it in front of `detectDrift` and `reconcile`. An approved plan nobody records
1293
+ * is the same to-do list nobody reads, with a ceremony in front of it.
1294
+ *
1295
+ * ⚠️ NO CACHE ARGUMENT APPLIES HERE, unlike `anchorText` above. This string is
1296
+ * the FIRST user message of a session that has not started, so there is no
1297
+ * cached prefix to void — the rule that appending is the only safe placement is
1298
+ * about a conversation in flight.
1299
+ */
1300
+ export function approvedTask({ task, plan, amendment = null } = {}) {
1301
+ const lines = [
1302
+ String(task ?? ''),
1303
+ '',
1304
+ '--- APPROVED PLAN ---',
1305
+ 'A person has read the following plan and approved it. It is not a suggestion and it is not your own draft;',
1306
+ 'it is the agreed scope of this run.',
1307
+ '',
1308
+ clamp(plan, MAX_APPROVED_PLAN_CHARS, 'plan'),
1309
+ ];
1310
+ if (amendment) {
1311
+ lines.push(
1312
+ '',
1313
+ '--- AMENDMENT FROM THE PERSON WHO APPROVED IT ---',
1314
+ 'This was typed after reading the plan. Where it disagrees with the plan, IT OVERRIDES THE PLAN.',
1315
+ '',
1316
+ String(amendment),
1317
+ );
1318
+ }
1319
+ lines.push(
1320
+ '',
1321
+ '--- HOW TO RUN IT ---',
1322
+ 'Record this plan with plan_start BEFORE your first write, using these steps in this order, and mark each one',
1323
+ 'with plan_step as you finish it. That is not bookkeeping: it is what lets the run notice if it wanders off the',
1324
+ 'plan the person agreed to, and what lets the final report say which steps have evidence behind them.',
1325
+ 'If you find the plan is wrong once you start, say so and call plan_start with the steps you are actually doing',
1326
+ 'rather than quietly doing something else.',
1327
+ );
1328
+ return lines.join('\n');
1329
+ }
1330
+
1331
+ /**
1332
+ * The block shown to the person, as lines so the caller owns colour and width.
1333
+ *
1334
+ * ⚠️ THE HEADING HAS TO SAY THE WORKSPACE IS STILL UNTOUCHED. Without that
1335
+ * sentence the block reads like a REPORT of work already done — which is the
1336
+ * one misreading that makes a person approve something they would have refused.
1337
+ */
1338
+ export function formatPlanForApproval(planText) {
1339
+ return [
1340
+ '',
1341
+ ' ── PROPOSED PLAN ─────────────────────────────────────────────',
1342
+ ' Nothing has been written and no command has been run. This is what it INTENDS to do.',
1343
+ '',
1344
+ ...String(planText ?? '').split('\n').map((l) => ` ${l}`),
1345
+ '',
1346
+ ];
1347
+ }
1348
+
1349
+ /** The question, so the prompt and the parser cannot drift apart. */
1350
+ export const PLAN_APPROVAL_QUESTION = '\n Approve this plan and let it start writing? [y/N, or type a correction]\n > ';
1351
+
1352
+ /**
1353
+ * The whole gate: propose read-only, show it, ask, and hand back either a
1354
+ * refusal or the task the executing run should receive.
1355
+ *
1356
+ * ⚠️ EVERY DEPENDENCY IS INJECTED — `propose` does the model call, `ask` is
1357
+ * `createAsker`'s result, `print` writes to whichever stream the caller decided
1358
+ * on (stderr under `--json`). That keeps this function testable with no
1359
+ * network, no terminal and no key, which is the only reason its refusal paths
1360
+ * have coverage at all.
1361
+ *
1362
+ * ⚠️⚠️ THE NO-TERMINAL CHECK IS FIRST, ABOVE `propose`. `createAsker` returns
1363
+ * null in CI, in a pipe and under a task runner, and a plan phase there would
1364
+ * buy a model call for a plan that structurally cannot be approved — money
1365
+ * spent on a question nobody can answer. Refusing costs nothing and says why.
1366
+ *
1367
+ * @param {object} args
1368
+ * @param {string} args.task
1369
+ * @param {() => Promise<{ok?: boolean, note?: string|null}>} args.propose
1370
+ * @param {null | ((q: string) => Promise<string|null>)} args.ask
1371
+ * @param {(text: string) => void} [args.print]
1372
+ * @returns {Promise<{
1373
+ * proceed: boolean, task: string, decision: string|null,
1374
+ * reason: string|null, why: string, planText: string|null, outcome: object|null
1375
+ * }>}
1376
+ */
1377
+ export async function runPlanGate({ task, propose, ask, print = () => {} } = {}) {
1378
+ const original = String(task ?? '');
1379
+ const no = (reason, why) => ({
1380
+ proceed: false, task: original, decision: null, reason, why, planText: null, outcome: null,
1381
+ });
1382
+
1383
+ if (typeof ask !== 'function') {
1384
+ return no('no-terminal',
1385
+ '--plan needs somebody to approve the plan, and stdin/stdout are not both terminals here. Nothing was sent '
1386
+ + 'to the model. Run it from a terminal, or drop --plan (and consider --dry-run, which touches nothing).');
1387
+ }
1388
+ if (typeof propose !== 'function') return no('no-proposer', 'no proposal phase was supplied — this is a wiring bug');
1389
+
1390
+ const outcome = await propose();
1391
+ const planText = typeof outcome?.note === 'string' ? outcome.note.trim() : '';
1392
+ if (outcome?.ok === false || planText === '') {
1393
+ return {
1394
+ ...no('no-plan',
1395
+ 'the planning phase produced no plan, so there is nothing to approve and nothing has been executed'),
1396
+ outcome: outcome ?? null,
1397
+ };
1398
+ }
1399
+
1400
+ print(`${formatPlanForApproval(planText).join('\n')}\n`);
1401
+ const verdict = planApproval(await ask(PLAN_APPROVAL_QUESTION));
1402
+
1403
+ if (verdict.decision === 'reject') {
1404
+ return { ...no('declined', verdict.why), decision: 'reject', planText, outcome };
1405
+ }
1406
+ return {
1407
+ proceed: true,
1408
+ task: approvedTask({ task: original, plan: planText, amendment: verdict.amendment }),
1409
+ decision: verdict.decision,
1410
+ reason: null,
1411
+ why: verdict.why,
1412
+ planText,
1413
+ outcome,
1414
+ };
1415
+ }
1416
+
1417
+ /**
1418
+ * ── ⭐ HOW TO WIRE THIS (the part that is not mine to do) ────────────────────
1419
+ *
1420
+ * All four pieces hang off state `runSession` already has: `rounds`, the plan
1421
+ * `planBannerFor` already loads, and the round counter.
1422
+ *
1423
+ * In `lib/turn.mjs`, beside `const nudged = new Set()`:
1424
+ *
1425
+ * let anchorState = {};
1426
+ *
1427
+ * and immediately after the existing `rounds.push({ round, ... })`:
1428
+ *
1429
+ * const plan = loadPlanQuietly(executor);
1430
+ * const drift = detectDrift({ plan, rounds });
1431
+ * if (drift.drifting && !nudged.has(drift.evidence.key)) {
1432
+ * nudged.add(drift.evidence.key);
1433
+ * messages.push({ role: 'user', content: driftNudge(drift) }); // APPEND ONLY
1434
+ * }
1435
+ * const anchor = reanchorDecision({
1436
+ * plan, roundIndex: round, maxRounds, state: anchorState, drift,
1437
+ * compactedSinceAnchor: compactedThisRound,
1438
+ * });
1439
+ * if (anchor.reanchor) {
1440
+ * messages.push({ role: 'user', content: anchor.text }); // APPEND ONLY
1441
+ * anchorState = anchor.state;
1442
+ * }
1443
+ *
1444
+ * and in the final report, beside `formatLedger`:
1445
+ *
1446
+ * for (const line of formatReconciliation(reconcile({ plan, rounds }))) print(line);
1447
+ *
1448
+ * ⚠️ `nudged` IS NOT OPTIONAL, for the reason `stuck.mjs` gives: re-nudging
1449
+ * every round changes the tail every round, which is token cost forever.
1450
+ * `drift.evidence.key` is stable while one drift persists and differs between
1451
+ * distinct ones.
1452
+ *
1453
+ * ⚠️⚠️ AND BOTH INJECTIONS MUST BE `messages.push`, NEVER A SPLICE OR AN EDIT
1454
+ * OF AN EARLIER MESSAGE. `wasAppendOnly(before, after)` exists so that rule can
1455
+ * be asserted in a test rather than remembered. Getting it wrong costs 2.4x and
1456
+ * nothing goes red.
1457
+ *
1458
+ * ⭐ NUDGING IS THE ACTION. Nothing here stops a run, changes an exit code, or
1459
+ * marks a step. `plan-ledger.mjs`'s premise is that `done` is asserted; this
1460
+ * module reports how much that assertion is worth and lets the human decide.
1461
+ */