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