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,981 @@
1
+ /**
2
+ * ── ⭐⭐ THE PLAN LEDGER — THE WALL THE AGENT CANNOT SEE ─────────────────────
3
+ *
4
+ * FOUND BY RUNNING THE CLI, not by reasoning about it. Four probe suites, and
5
+ * three of them hit the same shape independently:
6
+ *
7
+ * · Probe 4, run 2. The task said port every file "and THEN git_commit". The
8
+ * model wrote one file per round for rounds 2–7, ran the tests on round 8,
9
+ * and the commit never happened — `git log --oneline` still showed only
10
+ * `d4e74e1 callback version` with six files modified. Run 7 of the same task
11
+ * landed the commit on round 8 BY LUCK; at `--max-rounds 7` it loses it.
12
+ * · Probe 1, run 3 ended mid-intention — "Now let me update the README … let
13
+ * me first verify the real output" — and reported ✔ VERIFIED anyway. The
14
+ * README still contained zero occurrences of "prior" while the program
15
+ * printed a PRIORITY column.
16
+ * · Probe 3, tasks D and G each spent all six rounds researching, each FOUND
17
+ * the answer, and neither wrote the NOTES.md / API.md they were asked for.
18
+ *
19
+ * ⚠️ THE COMMON CAUSE IS NOT LAZINESS, IT IS BLINDNESS. Probe 4 captured
20
+ * `body-08.json` — round 8 of 8 — and its round wording was BYTE-IDENTICAL to
21
+ * `body-01.json`. There is no countdown anywhere in the payload. The model is
22
+ * driving at a wall it has never been told exists, so it spends the last round
23
+ * the way it spent the first: on the most interesting remaining thing rather
24
+ * than the last unfinished deliverable. 4 of 4 runs in probe 1 and 4 of 4 in
25
+ * probe 4 consumed the whole budget; none finished early. The wall is hit EVERY
26
+ * TIME, and the LAST-listed deliverable is always the one that dies.
27
+ *
28
+ * ⭐ SO THE BANNER IS THE POINT OF THIS FILE. Every result from all three tools
29
+ * ends with one deterministic line:
30
+ *
31
+ * plan: 5/7 done · 2 remaining: update README.md, git_commit · round 6 of 8
32
+ *
33
+ * That line rides back on a tool result the model already reads, which is how a
34
+ * countdown reaches it WITHOUT editing turn.mjs — the module never reads turn
35
+ * state itself, the caller passes the numbers in as options. One wiring change,
36
+ * no new plumbing through the loop.
37
+ *
38
+ * ── ⭐ AND THE SECOND HALF: RESUME ───────────────────────────────────────────
39
+ * Probe 4 confirmed there was no `.acuvo` directory in the workspace and no
40
+ * `~/.acuvo`, so re-issuing the IDENTICAL command spent 3 of its 4 rounds
41
+ * ($0.000792) re-deriving what the previous process had held in memory seconds
42
+ * earlier. The resume primitive is exactly one rule: **`plan_start` against an
43
+ * incomplete plan REFUSES and hands back the outstanding items.** Nothing else
44
+ * is needed — the model reads "4 of 10 done, here are the six" and continues
45
+ * instead of starting over.
46
+ *
47
+ * ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ───────────────────────────────────
48
+ * · It is NOT mandatory. A run that never calls plan_start behaves exactly as
49
+ * it does today. A planning tool that must be called first is a tax on the
50
+ * 90% of tasks that are one file and one test.
51
+ * · It NEVER invents steps from the task text. A decomposition the model did
52
+ * not author is one it will not follow, and a plan nobody follows is worse
53
+ * than no plan: it makes the banner lie.
54
+ * · It NEVER marks anything done on its own. `done` is ASSERTED by the agent.
55
+ * Inferring completion from "a file with that name now exists" is how a
56
+ * ledger starts reporting success at the exact moment the work stops being
57
+ * real — the failure this module was built to catch.
58
+ * · It changes no exit code. The OUTSTANDING block is evidence for a human,
59
+ * not a new way for the process to fail.
60
+ *
61
+ * ── ⚠️ AND THE LINE BETWEEN "NOT FINISHED" AND "NOT CORRECT" ────────────────
62
+ * `formatLedger` keeps the OUTSTANDING block strictly separate from any
63
+ * verification verdict, because they answer different questions and probe 1 run
64
+ * 3 shows what happens when they blur: that run reported ✔ VERIFIED while a
65
+ * named deliverable was untouched. "What was asked for and not done" is a fact
66
+ * this file owns. "Is the done work right" is the auditor's job, and this module
67
+ * must never appear to have answered it.
68
+ */
69
+
70
+ import { createHash } from 'node:crypto';
71
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
72
+ import { dirname } from 'node:path';
73
+
74
+ import { resolveInWorkspace } from './workspace.mjs';
75
+
76
+ /** The package's scratch directory — the same one `see_page` writes screenshots
77
+ * into and `mcp.json` / `policy.json` live in. A plan is scratch of the same
78
+ * kind: useful to the run, never part of the user's source tree. */
79
+ export const PLAN_DIR = '.acuvo';
80
+ export const PLAN_FILE = `${PLAN_DIR}/plan.json`;
81
+
82
+ /**
83
+ * ── ⚠️⚠️ SEVEN TERMINALS SHARED ONE PLAN, AND ONE MARKED ANOTHER'S WORK DONE ─
84
+ *
85
+ * Measured 2026-08-13 in a scratch workspace, two terminals, one checkout:
86
+ *
87
+ * terminal 1 plan_start "port the auth module" → [port auth, add tests, commit]
88
+ * terminal 2 plan_start "fix the CSS" → REFUSED, "a plan for this
89
+ * workspace already exists…
90
+ * pass replace:true to discard it"
91
+ * terminal 2 plan_step s1 done → ACCEPTED
92
+ * on disk done: port auth ← work terminal 2 never did
93
+ * terminal 2's banner, every round:
94
+ * "plan: 1/3 done · 2 remaining: add tests, commit"
95
+ *
96
+ * Three separate harms from one cause. Terminal 2 cannot plan at all; the
97
+ * refusal it is given INVITES it to destroy terminal 1's plan (`replace:true`);
98
+ * and having failed to plan, its every round is prefixed with a description of
99
+ * somebody else's task. The product direction is seven of these at once.
100
+ *
101
+ * ⭐ THE FIX IS THE KEY, NOT A LOCK. Leases arbitrate a resource two workers
102
+ * both need; a plan is not shared and never was — it is per-worker state that
103
+ * happened to be stored at a per-workspace path. Give it the right name and the
104
+ * conflict stops existing.
105
+ *
106
+ * ⚠️ ONLY AN EXPLICIT `--holder` GETS ITS OWN FILE, and that condition is
107
+ * load-bearing. The default holder is `pid-<pid>`, which changes every run — key
108
+ * on that and `--resume` would look for a plan under a pid that no longer
109
+ * exists, silently losing the ledger the feature is FOR. A single terminal
110
+ * names no holder and keeps exactly today's path and today's behaviour; the
111
+ * seven-terminal case is precisely the case where a user already passes
112
+ * `--holder`, because that is what the leases want too.
113
+ */
114
+ export function planFileFor(holder) {
115
+ const h = String(holder ?? '').trim();
116
+ // No holder, or the pid-shaped default: today's path, unchanged.
117
+ if (!h || /^pid-\d+$/.test(h)) return PLAN_FILE;
118
+ /**
119
+ * ⚠️ Sanitised, because a holder is a user-supplied string and this becomes a
120
+ * PATH. `resolvePlanFile` refuses anything outside `.acuvo/`, so a traversal
121
+ * cannot escape — but it would fail confusingly rather than being impossible,
122
+ * and a name is not the place to rely on a downstream guard.
123
+ */
124
+ const safe = h.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^[.-]+/, '').slice(0, 40) || 'worker';
125
+ return `${PLAN_DIR}/plans/${safe}.json`;
126
+ }
127
+
128
+ /**
129
+ * ── ⚠️⚠️ ONE PLAN, WRITTEN ONCE, INJECTED INTO FOUR UNRELATED RUNS ──────────
130
+ *
131
+ * MEASURED in a dogfood session, 2026-08-14, from the `.acuvo/plan.json` left
132
+ * behind in the workspace:
133
+ *
134
+ * · `createdAt` and `updatedAt` were **6 milliseconds apart**. The file was
135
+ * written by `plan_start` and never touched again — across 20 rounds and
136
+ * FOUR separate `acuvo` invocations.
137
+ * · All six steps still said `"state":"todo"`, including *"Write
138
+ * lib/store.mjs"* for a file that had been written, executed and verified
139
+ * in run 1.
140
+ * · Every round of every later run was prefixed with that banner — including
141
+ * a run whose entire task was **"add a package.json"**.
142
+ * · The model noticed and pushed back: *"Note: The runner is showing a plan
143
+ * with steps about writing library files, but this session's actual task
144
+ * was only to add package.json."*
145
+ *
146
+ * ⭐ THAT IS TWO DEFECTS WEARING ONE BANNER, AND THEY NEED DIFFERENT FIXES.
147
+ *
148
+ * (a) The plan is never UPDATED. `plan_step` exists; the model rarely calls
149
+ * it. Fixed HONESTLY below — see `formatBanner`'s escalated nudge and
150
+ * `formatLedger`'s never-marked notice. NOT fixed by inferring `done`
151
+ * from "a file with that name now exists": this module's whole premise
152
+ * is that `done` is ASSERTED, and inferring it would make the ledger lie
153
+ * in the optimistic direction, which is the exact failure it was built
154
+ * to prevent.
155
+ *
156
+ * (b) A plan from a PREVIOUS TASK is injected into an unrelated later run.
157
+ * This is the worse one, because it is not merely a stale number — it is
158
+ * a per-round instruction to work on somebody else's task, and the model
159
+ * obeys instructions. That is what this section fixes.
160
+ *
161
+ * ── ⭐ THE PLAN NOW RECORDS WHICH TASK IT BELONGS TO ────────────────────────
162
+ * `plan.task` already existed and CANNOT answer this question: it is the
163
+ * MODEL's one-line restatement, written in round N of run 1, so it never
164
+ * equals the user's prompt in run 2 even when the task is genuinely the same.
165
+ * So `plan_start` now also stores `runTaskKey` — a hash of the RUNNER's task
166
+ * string, which the loop passes down. Same prompt ⇒ same key ⇒ resume, exactly
167
+ * as before. Different prompt ⇒ different key ⇒ provably a different task.
168
+ *
169
+ * ⚠️ A HASH, NOT THE TEXT, AND THAT IS DELIBERATE TWICE OVER. The user's prompt
170
+ * is not the model's words, so `looksLikeSecret` cannot be applied to it —
171
+ * refusing `plan_start` because the human pasted a token would break planning
172
+ * over something the model cannot fix. And storing 400 characters of prompt in
173
+ * a scratch file is a disclosure this feature does not need to make. A 16-hex
174
+ * key answers "same task?" and nothing else.
175
+ *
176
+ * ── ⚠️ WHAT HAPPENS ON A MISMATCH: MENTIONED, NOT DELETED, NOT REPEATED ─────
177
+ * Three options were on the table — ignore it, offer it, clear it.
178
+ *
179
+ * · CLEARING IT IS WRONG. An outstanding plan is how a resumed session
180
+ * remembers what is left; silently deleting one is a worse failure than
181
+ * showing a stale one, because the user cannot even see what was lost.
182
+ * · IGNORING IT SILENTLY IS ALSO WRONG. A user who mistypes their prompt and
183
+ * gets a fresh run would never learn that six unfinished steps are sitting
184
+ * on disk.
185
+ * · SO: OFFER IT ONCE. The banner stops (that is the compounding tax and the
186
+ * context pollution), and the runner says once, in round 1, that a plan for
187
+ * a different task exists and how to look at it. One mention, ~40 tokens,
188
+ * not 20 rounds of it.
189
+ *
190
+ * And `plan_start` stops refusing in this case: it ARCHIVES the old plan to a
191
+ * named file and says which one, so the second task in a workspace can plan
192
+ * without being invited to `replace:true` away work it never did.
193
+ */
194
+ export function taskKey(task) {
195
+ const s = String(task ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
196
+ if (!s) return null;
197
+ return createHash('sha256').update(s).digest('hex').slice(0, 16);
198
+ }
199
+
200
+ /**
201
+ * ⚠️ THE FALLBACK EXISTS ONLY FOR PLANS WRITTEN BEFORE THE KEY DID, and it is
202
+ * deliberately blunt. Every plan on disk today has no `runTaskKey`, and the
203
+ * dogfood plan above is exactly one of those — a fix that only works on files
204
+ * written after the fix is not a fix for the bug that was reported.
205
+ *
206
+ * ⭐ THE RULE IS "SHARES NOT ONE SIGNIFICANT WORD", not a similarity score. A
207
+ * threshold would have to be tuned, and the wrong direction of a tuning error
208
+ * is silent: a genuine resume that scores 0.4 loses its countdown for the rest
209
+ * of the run and nobody finds out. Zero overlap is a fact rather than a
210
+ * judgement — "port the store module and commit" vs "add a package.json" share
211
+ * nothing, while a real resume repeats the prompt and shares everything.
212
+ */
213
+ const TASK_STOPWORDS = new Set([
214
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'then', 'than', 'them', 'they',
215
+ 'add', 'use', 'make', 'run', 'get', 'set', 'put', 'new', 'all', 'any', 'one', 'two',
216
+ 'file', 'files', 'code', 'work', 'task', 'also', 'just', 'like', 'need', 'want', 'must',
217
+ 'should', 'would', 'could', 'please', 'you', 'your', 'its', 'have', 'has', 'was', 'are',
218
+ 'not', 'but', 'out', 'off', 'over', 'under', 'each', 'every', 'some', 'more', 'most',
219
+ ]);
220
+
221
+ export function significantWords(text) {
222
+ const out = new Set();
223
+ for (const w of String(text ?? '').toLowerCase().split(/[^a-z0-9_.-]+/)) {
224
+ // ⚠️ Three characters, because "js", "ci" and "db" are noise-prone while
225
+ // "css", "auth" and "api" are the words that actually identify a task.
226
+ if (w.length < 3) continue;
227
+ if (TASK_STOPWORDS.has(w)) continue;
228
+ out.add(w);
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Is this plan the plan for the task now running?
235
+ *
236
+ * @returns {'same'|'different'|'unknown'} — and `unknown` is a real answer, not
237
+ * a placeholder. It means "cannot prove either way", and every caller treats
238
+ * it as today's behaviour, because a feature that guesses wrong about a
239
+ * resume takes the countdown away from the run that needs it most.
240
+ */
241
+ export function planTaskRelation(plan, runTask) {
242
+ if (!plan) return 'unknown';
243
+ const key = taskKey(runTask);
244
+ if (!key) return 'unknown';
245
+ // The exact answer, whenever the plan was written by a build that had one.
246
+ if (typeof plan.runTaskKey === 'string' && plan.runTaskKey) {
247
+ return plan.runTaskKey === key ? 'same' : 'different';
248
+ }
249
+ // Legacy plan: the blunt fallback. `plan.task` is the model's restatement of
250
+ // the task, which is the only description of it the old format carries.
251
+ const planned = significantWords(plan.task);
252
+ const running = significantWords(runTask);
253
+ if (planned.size === 0 || running.size === 0) return 'unknown';
254
+ for (const w of running) if (planned.has(w)) return 'unknown';
255
+ return 'different';
256
+ }
257
+
258
+ /**
259
+ * The ONE line a run gets when the plan on disk belongs to a different task.
260
+ *
261
+ * ⚠️ IT NAMES NO STEPS. The steps are the pollution — the model reading "Write
262
+ * lib/store.mjs" every round is what made it argue with the runner instead of
263
+ * writing the package.json it was asked for. What it needs to know is that a
264
+ * plan exists, that it is NOT this task's, and which verb reaches it.
265
+ */
266
+ export function foreignPlanNotice(plan, { task } = {}) {
267
+ if (planTaskRelation(plan, task) !== 'different') return null;
268
+ const left = outstanding(plan);
269
+ if (left.length === 0) return null;
270
+ return `plan: a plan from an earlier task is still on disk with ${left.length} of ${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'} unfinished — "${truncate(plan.task, 80)}". It is NOT this task, so it is not being shown again; call plan_status to read it, or plan_start (this task's own steps) to set it aside.`;
271
+ }
272
+
273
+ /** A decomposition longer than this is not a plan, it is a transcript — and it
274
+ * costs the banner more tokens every single round for the rest of the run. */
275
+ export const MAX_STEPS = 40;
276
+ /** A step is a deliverable, not a paragraph. Over this and the model is writing
277
+ * the implementation into the plan. */
278
+ export const MAX_STEP_CHARS = 200;
279
+ /** Notes are truncated rather than refused: a note is commentary, and losing its
280
+ * tail costs nothing, whereas refusing the whole call loses the STATE CHANGE. */
281
+ export const MAX_NOTE_CHARS = 300;
282
+ export const MAX_TASK_CHARS = 400;
283
+ /** The banner is printed on every tool result for the whole run, so its width is
284
+ * a per-round token bill, not a formatting preference. */
285
+ export const MAX_BANNER_ITEMS = 3;
286
+ const BANNER_ITEM_CHARS = 48;
287
+
288
+ export const PLAN_STATES = ['todo', 'doing', 'done', 'blocked'];
289
+
290
+ /** `memory-workspace.mjs` names the disk-less executor this. */
291
+ const MEMORY_ROOT = '(memory)';
292
+ const MEMORY_REFUSAL =
293
+ 'this workspace has no disk, so a plan cannot be persisted — track the remaining steps in your reply instead';
294
+
295
+ const err = (e) => (e instanceof Error ? e.message : String(e));
296
+ const nowIso = () => new Date().toISOString();
297
+
298
+ function truncate(text, max) {
299
+ const s = String(text ?? '');
300
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
301
+ }
302
+
303
+ /**
304
+ * ── ⚠️ A PLAN IS WRITTEN TO DISK, SO IT IS A PLACE SECRETS GO TO STAY ───────
305
+ *
306
+ * `audit.mjs` REDACTS, because a log that refuses to be written is a log that
307
+ * does not exist. This file REFUSES, because the input is the model's own words
308
+ * chosen milliseconds ago and it can simply choose different ones — and a plan
309
+ * with `[redacted]` in a step is a step nobody can act on. Same threat, opposite
310
+ * correct answer.
311
+ *
312
+ * ⚠️ THE 40-CHARACTER RUN NEEDS BOTH A DIGIT AND A LETTER, and that condition is
313
+ * load-bearing rather than fussy. Without it, a legitimate step like
314
+ * "implement authenticateUserWithCredentialsAndRefresh" (50 characters, no
315
+ * spaces) is refused as a credential — a false positive on the most ordinary
316
+ * text a coding agent writes. Every base64url secret carries digits; camelCase
317
+ * identifiers almost never do.
318
+ */
319
+ const SECRET_SHAPES = [
320
+ [/sk-[A-Za-z0-9]{16,}/, 'an API key'],
321
+ [/\b(?:sk|rk)-or-v1-[A-Za-z0-9_-]{16,}/, 'an OpenRouter key'],
322
+ [/AKIA[0-9A-Z]{16}/, 'an AWS access key id'],
323
+ [/gh[pousr]_[A-Za-z0-9]{20,}/, 'a GitHub token'],
324
+ ];
325
+
326
+ export function looksLikeSecret(text) {
327
+ if (typeof text !== 'string' || text === '') return null;
328
+ for (const [rx, what] of SECRET_SHAPES) if (rx.test(text)) return what;
329
+ const run = text.match(/[A-Za-z0-9_-]{40,}/);
330
+ if (run && /[0-9]/.test(run[0]) && /[A-Za-z]/.test(run[0])) return 'a long opaque token';
331
+ return null;
332
+ }
333
+
334
+ /** The refusal wording, in one place, because it is an INSTRUCTION: it has to
335
+ * tell the model what to write instead, not merely that it was wrong. */
336
+ function secretRefusal(where, what) {
337
+ return `${where} contains what looks like ${what} — a plan is written to disk; do not put secrets in it. Describe the value ("use the key from the environment") instead, and if it is a commit hash use the 7-character short form.`;
338
+ }
339
+
340
+ /**
341
+ * Resolve a file inside the plan directory and PROVE it is inside it.
342
+ *
343
+ * ⚠️ EXPORTED BECAUSE THE ASSERTION IS THE THING WORTH TESTING. Every caller in
344
+ * this module passes a constant, so the prefix check can never fire in
345
+ * production — which is exactly why it would rot unnoticed if it were private.
346
+ * The test drives it directly with `../outside.json` and `notes/plan.json`.
347
+ *
348
+ * @returns {{ ok: true, absolute: string, relative: string, root: string } | { ok: false, error: string }}
349
+ */
350
+ export function resolvePlanFile(root, name = PLAN_FILE) {
351
+ if (typeof root !== 'string' || root.trim() === '') {
352
+ return { ok: false, error: 'no workspace directory was given, so a plan cannot be stored' };
353
+ }
354
+ if (root === MEMORY_ROOT) return { ok: false, error: MEMORY_REFUSAL };
355
+
356
+ const r = resolveInWorkspace(root, name, 'write');
357
+ if (!r.ok) return { ok: false, error: r.reason };
358
+ if (r.relative !== PLAN_DIR && !r.relative.startsWith(`${PLAN_DIR}/`)) {
359
+ return { ok: false, error: `the plan ledger only ever writes inside ${PLAN_DIR}/ — "${r.relative}" is outside it` };
360
+ }
361
+ return { ok: true, absolute: r.absolute, relative: r.relative, root: r.root };
362
+ }
363
+
364
+ /**
365
+ * ⚠️ VALIDATE THE FILE, DO NOT TRUST IT. `.acuvo/plan.json` is a file on a
366
+ * laptop: a merge, a half-finished write from a killed process, or a human with
367
+ * an editor can all leave something JSON-shaped but wrong. A plan whose `steps`
368
+ * is a string would throw inside the banner — on every round, after the work.
369
+ * Returns null for anything it does not fully recognise, and null means "treat
370
+ * as absent", never "throw".
371
+ */
372
+ function parsePlan(raw) {
373
+ let data;
374
+ try {
375
+ data = JSON.parse(raw);
376
+ } catch {
377
+ return null;
378
+ }
379
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
380
+ if (data.version !== 1) return null;
381
+ if (typeof data.task !== 'string') return null;
382
+ if (!Array.isArray(data.steps) || data.steps.length === 0 || data.steps.length > MAX_STEPS) return null;
383
+
384
+ const steps = [];
385
+ for (const s of data.steps) {
386
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return null;
387
+ if (typeof s.id !== 'string' || !/^s[1-9][0-9]*$/.test(s.id)) return null;
388
+ if (typeof s.text !== 'string' || s.text === '') return null;
389
+ if (!PLAN_STATES.includes(s.state)) return null;
390
+ steps.push({
391
+ id: s.id,
392
+ text: s.text,
393
+ state: s.state,
394
+ note: typeof s.note === 'string' && s.note ? s.note : undefined,
395
+ updatedAt: typeof s.updatedAt === 'string' ? s.updatedAt : undefined,
396
+ });
397
+ }
398
+ return {
399
+ version: 1,
400
+ task: data.task,
401
+ /**
402
+ * ⚠️ VALIDATED LIKE EVERYTHING ELSE HERE, and then carried through. A plan
403
+ * written before this field existed simply has none — `planTaskRelation`
404
+ * says `unknown` for it and the run behaves as it did yesterday. A plan
405
+ * whose key is garbage is treated the same way: an unrecognised value must
406
+ * never become an ASSERTION that the tasks differ, because that direction
407
+ * hides a resume's countdown.
408
+ */
409
+ runTaskKey: typeof data.runTaskKey === 'string' && /^[0-9a-f]{16}$/.test(data.runTaskKey)
410
+ ? data.runTaskKey
411
+ : undefined,
412
+ createdAt: typeof data.createdAt === 'string' ? data.createdAt : nowIso(),
413
+ updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : nowIso(),
414
+ steps,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * A file we cannot parse is MOVED ASIDE rather than deleted or overwritten.
420
+ *
421
+ * ⚠️ The alternative — refusing to run until a human fixes it — makes a
422
+ * corrupted scratch file able to block the whole agent, which is a far worse
423
+ * outcome than losing a plan. And deleting it would destroy the only evidence of
424
+ * whatever wrote it. Renaming costs nothing and keeps both properties.
425
+ */
426
+ function quarantine(root, absolute) {
427
+ return moveAside(root, absolute, 'corrupt');
428
+ }
429
+
430
+ /**
431
+ * ⭐ ARCHIVING IS NOT DELETING, AND THE DIFFERENCE IS THE WHOLE ANSWER TO "what
432
+ * should happen to a plan from another task".
433
+ *
434
+ * A superseded plan keeps every byte, gets a name that says when it was set
435
+ * aside, and that name is RETURNED to the model and printed — so a user who
436
+ * started the wrong task can read the file and a user who meant it never sees
437
+ * it again. Deleting would have been one line shorter and would have destroyed
438
+ * the only record of six unfinished deliverables.
439
+ */
440
+ function moveAside(root, absolute, why) {
441
+ const stamp = nowIso().replace(/[:.]/g, '-');
442
+ const target = resolvePlanFile(root, `${PLAN_DIR}/plan.${why}-${stamp}.json`);
443
+ if (!target.ok) return null;
444
+ try {
445
+ mkdirSync(dirname(target.absolute), { recursive: true });
446
+ renameSync(absolute, target.absolute);
447
+ return target.relative;
448
+ } catch {
449
+ // Locked by another process, or already gone. Either way the caller's next
450
+ // move (write a fresh plan) still works, so this must not become an error.
451
+ return null;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * @returns {{ ok: true, plan: object | null, quarantined?: string } | { ok: false, error: string }}
457
+ */
458
+ export function loadPlan(root, { planFile = PLAN_FILE } = {}) {
459
+ const f = resolvePlanFile(root, planFile);
460
+ if (!f.ok) return f;
461
+ if (!existsSync(f.absolute)) return { ok: true, plan: null };
462
+
463
+ let raw;
464
+ try {
465
+ raw = readFileSync(f.absolute, 'utf8');
466
+ } catch (e) {
467
+ return { ok: false, error: `could not read ${PLAN_FILE}: ${err(e)}` };
468
+ }
469
+ const plan = parsePlan(raw);
470
+ if (plan) return { ok: true, plan };
471
+ const moved = quarantine(root, f.absolute);
472
+ return { ok: true, plan: null, quarantined: moved ?? undefined };
473
+ }
474
+
475
+ /**
476
+ * ⚠️ THE TEMP NAME CARRIES THE PID AND A COUNTER, and that is not decoration.
477
+ * A fixed `plan.json.tmp` shared by two processes is WORSE than no temp file at
478
+ * all: both write into the same path, the second rename publishes a file
479
+ * containing an interleaving of two documents, and the corruption looks like a
480
+ * bug in this module rather than in the concurrency. Unique tmp + rename means
481
+ * the loser of the race simply loses — the published file is always exactly one
482
+ * writer's complete output. (`evaluate.mjs` names its scratch the same way, for
483
+ * the same reason.)
484
+ */
485
+ let tmpCounter = 0;
486
+
487
+ /** @returns {{ ok: true, path: string } | { ok: false, error: string }} */
488
+ export function savePlan(root, plan, { planFile = PLAN_FILE } = {}) {
489
+ const f = resolvePlanFile(root, planFile);
490
+ if (!f.ok) return f;
491
+ const tmp = resolvePlanFile(root, `${PLAN_FILE}.${process.pid}-${tmpCounter++}.tmp`);
492
+ if (!tmp.ok) return tmp;
493
+
494
+ const body = `${JSON.stringify({ ...plan, updatedAt: nowIso() }, null, 2)}\n`;
495
+ try {
496
+ mkdirSync(dirname(f.absolute), { recursive: true });
497
+ writeFileSync(tmp.absolute, body, 'utf8');
498
+ renameSync(tmp.absolute, f.absolute);
499
+ } catch (e) {
500
+ try { unlinkSync(tmp.absolute); } catch { /* nothing to clean up */ }
501
+ return { ok: false, error: `could not write ${PLAN_FILE}: ${err(e)}` };
502
+ }
503
+ return { ok: true, path: f.relative };
504
+ }
505
+
506
+ /** Not-done steps, in the order they were planned. `blocked` counts as
507
+ * outstanding — a step that cannot proceed is still a thing that was asked for
508
+ * and did not happen, and hiding it is how the banner starts flattering. */
509
+ export function outstanding(plan) {
510
+ if (!plan || !Array.isArray(plan.steps)) return [];
511
+ return plan.steps.filter((s) => s.state !== 'done');
512
+ }
513
+
514
+ /**
515
+ * `round 6 of 8`, or `2 rounds left`, or nothing at all.
516
+ *
517
+ * ⚠️ OMITTED RATHER THAN GUESSED when the caller passes nothing. A wrong
518
+ * countdown is worse than none: it either panics the model into skipping work it
519
+ * had time for, or reassures it into losing the last deliverable — the exact
520
+ * failure this file exists to prevent.
521
+ */
522
+ function roundClause({ roundsRemaining, roundIndex, maxRounds } = {}) {
523
+ if (Number.isFinite(roundIndex) && Number.isFinite(maxRounds)) return ` · round ${roundIndex} of ${maxRounds}`;
524
+ if (Number.isFinite(roundsRemaining)) {
525
+ return ` · ${roundsRemaining} round${roundsRemaining === 1 ? '' : 's'} left`;
526
+ }
527
+ return '';
528
+ }
529
+
530
+ /**
531
+ * THE ONE LINE. Deterministic, capped, and the same shape whatever happened —
532
+ * the model has to be able to read it without parsing.
533
+ */
534
+ export function formatBanner(plan, opts = {}) {
535
+ const round = roundClause(opts);
536
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return `plan: none recorded${round}`;
537
+
538
+ const total = plan.steps.length;
539
+ const done = plan.steps.filter((s) => s.state === 'done').length;
540
+ const left = outstanding(plan);
541
+ if (left.length === 0) return `plan: ${done}/${total} done · all steps done${round}`;
542
+
543
+ const shown = left.slice(0, MAX_BANNER_ITEMS).map((s) => {
544
+ const text = truncate(s.text, BANNER_ITEM_CHARS);
545
+ // A blocked step reads differently from an untouched one, and the model
546
+ // needs that difference to decide whether to retry it or route around it.
547
+ return s.state === 'blocked' ? `${text} (blocked)` : text;
548
+ });
549
+ const more = left.length > MAX_BANNER_ITEMS ? `, +${left.length - MAX_BANNER_ITEMS} more` : '';
550
+
551
+ /**
552
+ * ── ⚠️⚠️ A STATUS LINE THAT NAMES NO ACTION PRODUCES NO ACTION ────────────
553
+ *
554
+ * MEASURED on a real 8-round run: the model called `plan_start`, did the work,
555
+ * and printed `plan: 0/3 done · 3 remaining` on EVERY SINGLE ROUND. It never
556
+ * called `plan_step` once. Eight rounds of a countdown that never counted —
557
+ * so the ledger cost tokens on every round and told the model nothing it could
558
+ * act on.
559
+ *
560
+ * ⭐ THE FIX IS THE SAME ONE THAT WORKS FOR REFUSALS: name the verb. A refusal
561
+ * that says only "not allowed" costs a round because the model has nothing to
562
+ * do differently; a status line that says only "0/3 done" is the same mistake
563
+ * wearing a friendlier face. The reminder appears ONLY while nothing has been
564
+ * marked yet, so it disappears the moment it has been understood rather than
565
+ * nagging for the rest of the session.
566
+ *
567
+ * ⚠️ AND THE NUDGE QUOTES THE REAL ARGUMENTS, taken from the plan itself.
568
+ * My first draft wrote `{"step":1,...}` when the tool requires `{"id":"s1"}` —
569
+ * an instruction that would have been REFUSED, which is worse than silence:
570
+ * it spends a round AND teaches the model a shape that cannot work.
571
+ */
572
+ /**
573
+ * ── ⚠️⚠️ AND THE NUDGE ALONE WAS NOT ENOUGH — MEASURED AGAIN, WORSE ────────
574
+ *
575
+ * The nudge above shipped, and the dogfood plan documented at the top of this
576
+ * file was written WITH it in place: 6 steps, 0 marked, `updatedAt` 6ms after
577
+ * `createdAt`, unchanged across 20 rounds and four runs. Naming the verb
578
+ * helped in the 8-round case and did not survive contact with a run whose
579
+ * plan had stopped describing the work.
580
+ *
581
+ * ⭐ SO THE ESCALATION NAMES THE SECOND VERB: the plan may be WRONG, and a
582
+ * model that cannot honestly mark anything done has no move at all if the
583
+ * only offer is "mark something done". `plan_start` with the real steps is
584
+ * the escape hatch, and until it was written here it was never mentioned to
585
+ * a model mid-run.
586
+ *
587
+ * ⚠️ IT MUST NOT INFER. Neither branch marks anything, and neither claims the
588
+ * work is unfinished — it says "nothing has been marked", which is a fact
589
+ * about the LEDGER, not a claim about the disk. That distinction is the
590
+ * whole honesty argument of this module, and the pessimistic-sounding banner
591
+ * is the price of keeping it.
592
+ *
593
+ * Escalates from round 3 because rounds 1–2 are when a plan legitimately has
594
+ * nothing finished yet; nagging there would be noise on every correct run.
595
+ */
596
+ const NOTHING_MARKED_AFTER_ROUND = 3;
597
+ let nudge = '';
598
+ if (done === 0 && left[0]?.id) {
599
+ const late = Number.isFinite(opts.roundIndex) && opts.roundIndex >= NOTHING_MARKED_AFTER_ROUND;
600
+ nudge = late
601
+ ? ` — nothing has been marked yet: plan_step {"id":"${left[0].id}","state":"done"} for anything finished, or plan_start with the real steps if this plan no longer describes what you are doing`
602
+ : ` — mark one finished as you go: plan_step {"id":"${left[0].id}","state":"done"}`;
603
+ }
604
+ return `plan: ${done}/${total} done · ${left.length} remaining: ${shown.join(', ')}${more}${round}${nudge}`;
605
+ }
606
+
607
+ const MARK = { done: '✓', doing: '→', blocked: '✗', todo: '·' };
608
+
609
+ /**
610
+ * The full ledger, for an end-of-run summary. Ends with the same banner, so
611
+ * whatever surfaces it — a tool result or the final report — the last line the
612
+ * model or the human reads is identical.
613
+ */
614
+ export function formatLedger(plan, opts = {}) {
615
+ if (!plan) return formatBanner(null, opts);
616
+ const lines = [`plan for: ${truncate(plan.task, 120)}`];
617
+ for (const s of plan.steps) {
618
+ const note = s.note ? ` — ${truncate(s.note, 80)}` : '';
619
+ lines.push(` ${MARK[s.state] ?? '·'} ${s.id.padEnd(3)} ${truncate(s.text, 100)}${note}`);
620
+ }
621
+
622
+ const left = outstanding(plan);
623
+ lines.push('');
624
+ if (left.length === 0) {
625
+ lines.push('OUTSTANDING — none; every step was marked done.');
626
+ } else {
627
+ /**
628
+ * ── ⚠️⚠️ "NOT FINISHED" AND "NEVER MARKED" ARE DIFFERENT CLAIMS ─────────
629
+ *
630
+ * The dogfood plan had six steps all `todo`, and one of them — "Write
631
+ * lib/store.mjs" — named a file that had been written, run and verified.
632
+ * Printing that under a heading reading "asked for and not finished" is
633
+ * this module lying in the PESSIMISTIC direction: it reports failure it
634
+ * did not observe, from the same evidence that would let it report success
635
+ * it did not observe.
636
+ *
637
+ * ⭐ THE HONEST FIX IS THE HEADING, NOT THE DATA. We do not look at the
638
+ * disk, we do not infer, and we do not quietly drop the steps. We say what
639
+ * is actually true: nothing was ever marked, so this list is what was
640
+ * PLANNED and its states were never updated. A human reading it then knows
641
+ * exactly how much to trust it — which is the only thing an unmarked
642
+ * ledger can honestly offer.
643
+ */
644
+ const neverMarked = plan.steps.every((s) => s.state === 'todo');
645
+ lines.push(neverMarked
646
+ ? `OUTSTANDING — all ${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'} are still "todo" because NO step was ever marked; this is the plan as recorded, not a finding that the work is undone:`
647
+ : `OUTSTANDING — ${left.length} of ${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'} asked for and not finished:`);
648
+ for (const s of left) {
649
+ const note = s.note ? ` — ${truncate(s.note, 80)}` : '';
650
+ lines.push(` [${s.state}] ${s.id} ${truncate(s.text, 100)}${note}`);
651
+ }
652
+ /**
653
+ * ⚠️ Stated out loud because probe 1 run 3 printed ✔ VERIFIED over an
654
+ * untouched deliverable. These are two different questions and the summary
655
+ * must never let one answer the other.
656
+ *
657
+ * ⚠️ AND THE NEVER-MARKED DISCLAIMER IS A DIFFERENT SENTENCE, because the
658
+ * ordinary one contains the very claim that would be false: "what was asked
659
+ * for and not finished". Changing the heading and leaving this line was the
660
+ * first draft, and the test caught it — the untrue phrase survived two lines
661
+ * lower, which is exactly how a struck claim survives a deletion.
662
+ */
663
+ lines.push(neverMarked
664
+ ? '(the ledger was never updated during the run, so this says nothing about which of these were actually done — nobody checked)'
665
+ : '(what was asked for and not finished — this says nothing about whether the finished work is correct)');
666
+ }
667
+
668
+ lines.push(formatBanner(plan, opts));
669
+ return lines.join('\n');
670
+ }
671
+
672
+ /** The projection that goes back to the model: no timestamps, because it pays
673
+ * for them every round and cannot act on them. */
674
+ const compact = (steps) => steps.map((s) => (s.note ? { id: s.id, text: s.text, state: s.state, note: s.note } : { id: s.id, text: s.text, state: s.state }));
675
+
676
+ function validateSteps(raw) {
677
+ if (!Array.isArray(raw)) return { ok: false, error: 'steps must be an array of short strings, one per deliverable' };
678
+ if (raw.length === 0) return { ok: false, error: 'steps was empty — list the deliverables you intend to produce, in the order you will do them' };
679
+ if (raw.length > MAX_STEPS) {
680
+ return { ok: false, error: `${raw.length} steps is more than the ${MAX_STEPS}-step limit — group the small ones, and keep each step a deliverable rather than a keystroke` };
681
+ }
682
+ const steps = [];
683
+ for (let i = 0; i < raw.length; i += 1) {
684
+ const s = raw[i];
685
+ if (typeof s !== 'string') return { ok: false, error: `step ${i + 1} is a ${Array.isArray(s) ? 'array' : typeof s}, not a string — each step is one short sentence` };
686
+ const text = s.trim();
687
+ if (!text) return { ok: false, error: `step ${i + 1} is empty — remove it or say what it produces` };
688
+ if (text.length > MAX_STEP_CHARS) {
689
+ return { ok: false, error: `step ${i + 1} is ${text.length} characters, over the ${MAX_STEP_CHARS} limit — name the deliverable, not the implementation` };
690
+ }
691
+ const secret = looksLikeSecret(text);
692
+ if (secret) return { ok: false, error: secretRefusal(`step ${i + 1}`, secret) };
693
+ // ⭐ IDS ARE ASSIGNED HERE, NEVER BY THE MODEL. A model that renames a step
694
+ // between rounds would otherwise orphan its own progress — and reworded
695
+ // steps are exactly what a model does when it re-reads its plan.
696
+ steps.push({ id: `s${i + 1}`, text, state: 'todo', updatedAt: nowIso() });
697
+ }
698
+ return { ok: true, steps };
699
+ }
700
+
701
+ /**
702
+ * Record the decomposition.
703
+ *
704
+ * ⭐ THE REFUSAL IS THE FEATURE. An incomplete plan already on disk is not an
705
+ * error condition, it is a PREVIOUS SESSION, and the whole resume primitive is
706
+ * that this call hands it back instead of flattening it.
707
+ *
708
+ * @param {string} root
709
+ * @param {{ task?: unknown, steps?: unknown, replace?: unknown }} args
710
+ * @param {{ roundsRemaining?: number, roundIndex?: number, maxRounds?: number }} [opts]
711
+ */
712
+ export function planStart(root, args = {}, opts = {}) {
713
+ const planFile = opts.planFile ?? PLAN_FILE;
714
+ const f = resolvePlanFile(root, planFile);
715
+ if (!f.ok) return { ok: false, error: f.error };
716
+
717
+ if (typeof args.task !== 'string' || args.task.trim() === '') {
718
+ return { ok: false, error: 'task must be a one-line restatement of what you were asked to do' };
719
+ }
720
+ const taskSecret = looksLikeSecret(args.task);
721
+ if (taskSecret) return { ok: false, error: secretRefusal('task', taskSecret) };
722
+ const task = truncate(args.task.trim(), MAX_TASK_CHARS);
723
+
724
+ const parsed = validateSteps(args.steps);
725
+ if (!parsed.ok) return { ok: false, error: parsed.error };
726
+
727
+ /**
728
+ * ⚠️ THE EXISTING PLAN IS LOADED FROM THE SAME FILE THIS CALL WILL WRITE.
729
+ * It used to read `PLAN_FILE` unconditionally while writing `planFile` —
730
+ * harmless with one terminal, wrong with `--holder`.
731
+ */
732
+ const existing = loadPlan(root, { planFile });
733
+ if (!existing.ok) return { ok: false, error: existing.error };
734
+
735
+ /**
736
+ * ⭐ A PROVABLY DIFFERENT TASK IS NOT A CONFLICT, IT IS THE NEXT TASK.
737
+ *
738
+ * The old code refused here, and the refusal carried three harms at once
739
+ * (see the seven-terminals note above): the new run cannot plan at all, the
740
+ * refusal INVITES it to `replace:true` away work it never did, and having
741
+ * failed to plan, every one of its rounds is prefixed with somebody else's
742
+ * task. When we can PROVE the tasks differ — a different `runTaskKey`, or a
743
+ * legacy plan sharing not one significant word — the honest move is to file
744
+ * the old plan away, name the file, and let this run plan its own work.
745
+ *
746
+ * ⚠️ ONLY WHEN PROVEN. `unknown` keeps the refusal, because the refusal is
747
+ * the resume primitive and losing it is worse than an occasional extra flag.
748
+ */
749
+ const relation = planTaskRelation(existing.plan, opts.task);
750
+
751
+ if (existing.plan && args.replace !== true && relation !== 'different') {
752
+ const left = outstanding(existing.plan);
753
+ if (left.length > 0) {
754
+ const done = existing.plan.steps.length - left.length;
755
+ return {
756
+ ok: false,
757
+ error: `a plan for this workspace already exists with ${done} of ${existing.plan.steps.length} steps done — pass replace:true to discard it, or continue with plan_step`,
758
+ existing: { task: existing.plan.task, steps: compact(existing.plan.steps) },
759
+ outstanding: compact(left),
760
+ banner: formatBanner(existing.plan, opts),
761
+ };
762
+ }
763
+ // Every step of the old plan is done, so it is history rather than work in
764
+ // progress. Silently superseding it is right: refusing here would make the
765
+ // second task in a session impossible without a flag nobody would know to
766
+ // pass.
767
+ }
768
+
769
+ const discarded = existing.plan && args.replace === true ? outstanding(existing.plan).length : 0;
770
+
771
+ /**
772
+ * The archive happens BEFORE the write, so the two cannot both hold the
773
+ * same path. If the rename fails (locked, already gone) `savePlan` still
774
+ * publishes atomically over it — losing the archive, never the new plan.
775
+ */
776
+ let superseded;
777
+ if (existing.plan && relation === 'different' && args.replace !== true) {
778
+ const f2 = resolvePlanFile(root, planFile);
779
+ const savedAs = f2.ok ? moveAside(root, f2.absolute, 'superseded') : null;
780
+ superseded = {
781
+ task: existing.plan.task,
782
+ outstanding: outstanding(existing.plan).length,
783
+ savedAs: savedAs ?? undefined,
784
+ };
785
+ }
786
+
787
+ const plan = {
788
+ version: 1,
789
+ task,
790
+ // ⭐ THE FIELD THAT ENDS THE BUG. Absent when the caller passed no task —
791
+ // a direct unit-test call, or a build older than the loop change — and an
792
+ // absent key means `unknown`, which means today's behaviour.
793
+ runTaskKey: taskKey(opts.task) ?? undefined,
794
+ createdAt: nowIso(),
795
+ updatedAt: nowIso(),
796
+ steps: parsed.steps,
797
+ };
798
+ const saved = savePlan(root, plan, { planFile });
799
+ if (!saved.ok) return { ok: false, error: saved.error };
800
+
801
+ return {
802
+ ok: true,
803
+ replaced: Boolean(existing.plan),
804
+ // Honest about what was thrown away — a replace that silently drops six
805
+ // unfinished steps is the one case where "it worked" is misleading.
806
+ discardedOutstanding: discarded,
807
+ // ⚠️ Present ONLY when a plan for a different task was filed away, and it
808
+ // names the file. "Set aside" that nobody can find is "deleted".
809
+ superseded,
810
+ task,
811
+ steps: compact(plan.steps),
812
+ banner: formatBanner(plan, opts),
813
+ };
814
+ }
815
+
816
+ /**
817
+ * Mark exactly one step.
818
+ *
819
+ * @param {string} root
820
+ * @param {{ id?: unknown, state?: unknown, note?: unknown }} args
821
+ * @param {{ roundsRemaining?: number, roundIndex?: number, maxRounds?: number }} [opts]
822
+ */
823
+ export function planStep(root, args = {}, opts = {}) {
824
+ const planFile = opts.planFile ?? PLAN_FILE;
825
+ const loaded = loadPlan(root, { planFile });
826
+ if (!loaded.ok) return { ok: false, error: loaded.error };
827
+ if (!loaded.plan) {
828
+ return { ok: false, error: 'no plan has been recorded for this workspace — call plan_start with the steps you intend to take, then mark them' };
829
+ }
830
+ const plan = loaded.plan;
831
+
832
+ if (typeof args.state !== 'string' || !PLAN_STATES.includes(args.state)) {
833
+ return { ok: false, error: `state must be one of ${PLAN_STATES.join(', ')} — got ${JSON.stringify(args.state ?? null)}` };
834
+ }
835
+ const step = typeof args.id === 'string' ? plan.steps.find((s) => s.id === args.id) : undefined;
836
+ if (!step) {
837
+ const listed = plan.steps.slice(0, 12).map((s) => `${s.id} ${truncate(s.text, 40)}`).join(' · ');
838
+ const more = plan.steps.length > 12 ? ` · +${plan.steps.length - 12} more` : '';
839
+ return { ok: false, error: `no step with id ${JSON.stringify(args.id ?? null)} — the ids in this plan are: ${listed}${more}` };
840
+ }
841
+
842
+ let note;
843
+ if (args.note !== undefined && args.note !== null && args.note !== '') {
844
+ if (typeof args.note !== 'string') return { ok: false, error: 'note must be a string, or omitted' };
845
+ const secret = looksLikeSecret(args.note);
846
+ if (secret) return { ok: false, error: secretRefusal('note', secret) };
847
+ note = truncate(args.note.trim(), MAX_NOTE_CHARS);
848
+ }
849
+
850
+ const previous = step.state;
851
+ step.state = args.state;
852
+ step.updatedAt = nowIso();
853
+ if (note !== undefined) step.note = note;
854
+
855
+ const saved = savePlan(root, plan, { planFile });
856
+ if (!saved.ok) return { ok: false, error: saved.error };
857
+
858
+ return {
859
+ ok: true,
860
+ id: step.id,
861
+ from: previous,
862
+ state: step.state,
863
+ outstanding: compact(outstanding(plan)),
864
+ banner: formatBanner(plan, opts),
865
+ };
866
+ }
867
+
868
+ /**
869
+ * The current ledger.
870
+ *
871
+ * ⚠️ SIGNATURE DIFFERS FROM THE OTHER TWO ON PURPOSE: it takes no model
872
+ * arguments at all, so its second parameter is the CALLER's round options. There
873
+ * is nothing here a model could pass, and giving it a knob it does not need is
874
+ * how a status call turns into a filter nobody asked for.
875
+ */
876
+ export function planStatus(root, opts = {}) {
877
+ const planFile = opts.planFile ?? PLAN_FILE;
878
+ const loaded = loadPlan(root, { planFile });
879
+ if (!loaded.ok) return { ok: false, error: loaded.error };
880
+ if (!loaded.plan) {
881
+ return {
882
+ ok: true,
883
+ exists: false,
884
+ // Not an error — asking is legitimate — but the answer has to say what to
885
+ // do next, or the model spends its next round asking again.
886
+ hint: 'no plan has been recorded for this workspace; call plan_start if this task has more than one deliverable',
887
+ quarantined: loaded.quarantined,
888
+ banner: formatBanner(null, opts),
889
+ };
890
+ }
891
+ const plan = loaded.plan;
892
+ return {
893
+ ok: true,
894
+ exists: true,
895
+ task: plan.task,
896
+ steps: compact(plan.steps),
897
+ outstanding: compact(outstanding(plan)),
898
+ quarantined: loaded.quarantined,
899
+ banner: formatBanner(plan, opts),
900
+ };
901
+ }
902
+
903
+ /**
904
+ * ⚠️ THE DESCRIPTIONS CARRY THE BEHAVIOUR, because a tool description is the
905
+ * only documentation the model ever reads. Two things are said explicitly here
906
+ * and nowhere else: mark a step done only when it is ACTUALLY done, and the last
907
+ * step is the one that dies — do it before the interesting one.
908
+ */
909
+ export function planToolSchemas() {
910
+ return [
911
+ {
912
+ type: 'function',
913
+ function: {
914
+ name: 'plan_start',
915
+ description: [
916
+ 'Record the deliverables for a task that has more than one, BEFORE you start work.',
917
+ 'Every later tool result then carries a line telling you how many are left and how many',
918
+ 'rounds remain — without it you cannot see the round budget running out, and the LAST',
919
+ 'thing you were asked for is the one that gets lost.',
920
+ 'List the deliverables in the order you will produce them, and put a commit or a final',
921
+ 'write EARLY enough that it survives.',
922
+ 'If a plan already exists for this workspace it is returned instead of overwritten, with',
923
+ 'its unfinished steps — continue that work with plan_step rather than starting again.',
924
+ 'If that plan is for a demonstrably different task it is filed aside (the result names the',
925
+ 'file it was saved to) and yours is recorded, so a new task never has to discard old work.',
926
+ ].join(' '),
927
+ parameters: {
928
+ type: 'object',
929
+ properties: {
930
+ task: { type: 'string', description: 'One line restating what you were asked to do.' },
931
+ steps: {
932
+ type: 'array',
933
+ items: { type: 'string' },
934
+ description: `The deliverables, one short sentence each, in order. 1–${MAX_STEPS} of them.`,
935
+ },
936
+ replace: {
937
+ type: 'boolean',
938
+ description: 'Only to discard an existing unfinished plan for a genuinely different task.',
939
+ },
940
+ },
941
+ required: ['task', 'steps'],
942
+ },
943
+ },
944
+ },
945
+ {
946
+ type: 'function',
947
+ function: {
948
+ name: 'plan_step',
949
+ description: [
950
+ 'Mark one planned step done, doing or blocked.',
951
+ 'Mark it done only when the deliverable actually exists and you have checked it —',
952
+ 'nothing else marks steps for you, and a plan that says done over work that is not done',
953
+ 'is worse than no plan.',
954
+ 'Use blocked, with a note saying why, when you cannot finish one; that is how it still',
955
+ 'appears in the outstanding list at the end.',
956
+ ].join(' '),
957
+ parameters: {
958
+ type: 'object',
959
+ properties: {
960
+ id: { type: 'string', description: 'The step id from plan_start, e.g. "s3".' },
961
+ state: { type: 'string', enum: PLAN_STATES, description: 'The new state of that step.' },
962
+ note: { type: 'string', description: 'Optional one-line reason, mainly for blocked.' },
963
+ },
964
+ required: ['id', 'state'],
965
+ },
966
+ },
967
+ },
968
+ {
969
+ type: 'function',
970
+ function: {
971
+ name: 'plan_status',
972
+ description: [
973
+ 'Show the current plan: every step, its state, and what is still outstanding.',
974
+ 'Use it when you have lost track of what is left, or at the start of a session that is',
975
+ 'continuing earlier work.',
976
+ ].join(' '),
977
+ parameters: { type: 'object', properties: {}, required: [] },
978
+ },
979
+ },
980
+ ];
981
+ }