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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|