bareloop 0.8.0 → 0.10.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.
@@ -0,0 +1,767 @@
1
+ // THE LOOKING HALF — close-authoring v1, module M3a (design record 2026-08-07
2
+ // D11; gate-2 POC prereg addendum 5, lever L1).
3
+ //
4
+ // D11's line, verbatim: *the scout looks, the author writes, neither acts.* This
5
+ // module is the LOOKING. It produces the two inputs the authoring call cannot
6
+ // derive from an interview answer, and it produces them WITHOUT the authoring
7
+ // call ever gaining a tool:
8
+ //
9
+ // 1. THE FACTS OBJECT — a bounded, read-only LLM survey of the patient. Result
10
+ // 5's knowledge (`MYPYPATH`, pytest `-ra`, per-stage timeouts) came from
11
+ // paying for live runs; none of it is in an interview answer and none of it
12
+ // is derivable from prose.
13
+ // 2. THE SEED FILE LISTING — mechanical, $0, deterministic. No model. Round 2's
14
+ // arm A derived `src/alertEmail.js` from the user's words ("the alert email
15
+ // one") and silently reclassified 15 real errors into the wrong population.
16
+ // A path is read off the repository or it does not exist.
17
+ //
18
+ // WHAT TRANSFERS FROM THE PLAN SCOUT, BY NAME (`src/planrun.js:41-62`): a hard
19
+ // round bound (`SCOUT_ROUNDS` 8); write-class and store-class verbs filtered OUT
20
+ // of the menu, so the survey is read-only BY CONSTRUCTION and not by promise; an
21
+ // output cap (`SCOUT_BLOB_MAX`); F59's reserved TOOLLESS final round; and
22
+ // `SCOUT_MIN_BYTES`, below which a survey is ABSENT rather than empty.
23
+ //
24
+ // WHAT DOES NOT TRANSFER, stated rather than assumed (D11): the plan scout
25
+ // derives its menu from a SIGNED `tools` ceiling and runs inside a fenced
26
+ // workdir. This scout runs at JOB CREATION, before any spec exists — there is no
27
+ // signed ceiling and no fence to derive from, so its grant is FIXED HERE and is
28
+ // not spec-authorable. Same arbiter line, one step earlier than usual.
29
+ //
30
+ // F59 IS THE FINDING THIS MODULE EXISTS TO HONOUR, and it bites harder here than
31
+ // it did upstream. An empty `{}` must read as *"the scout did not complete"*,
32
+ // never as *"no special facts are needed"*: the plan scout's version of that
33
+ // mistake cost a survey; this one lands in a SIGNED artefact, and a missing
34
+ // `MYPYPATH` becomes a close that grades the wrong checkout, signed by a user who
35
+ // could not have known. Five routes to ABSENT, and every one of them says which.
36
+ //
37
+ // The listing has TWO consumers and they get DIFFERENT things, deliberately:
38
+ // - the VALIDATOR (`validateDeclaration`'s `listing`) gets the WHOLE tree.
39
+ // Handing it the scoped subset would make `scopeOfJob` read a job scoped to
40
+ // `src/` as whole-tree and silently switch off the F84 one-population law.
41
+ // - the PROMPT gets the block, scoped to the survey's own sourcePaths/testPaths
42
+ // and capped in announced tiers (F28 — nothing is hidden without being
43
+ // counted).
44
+ //
45
+ // Secrets: the survey blob is scrubbed AT CAPTURE through the one
46
+ // `SECRET_PATTERNS` inventory, before it becomes a prompt ingredient or a record
47
+ // — a log that captures a key captures it forever.
48
+
49
+ import { createRequire } from 'node:module';
50
+ import { join } from 'node:path';
51
+ import { Gate } from 'bareguard';
52
+ import { LiteCtx } from 'litectx';
53
+ import { seedListing, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
54
+ import { TOOL_MENU, WRITE_VERBS, STORE_VERBS } from './job.js';
55
+ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from './tools.js';
56
+ import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
+ import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop } from './text.js';
58
+
59
+ const require = createRequire(import.meta.url);
60
+ const { Loop, wireGate } = require('bare-agent');
61
+ const { createShellTools } = require('bare-agent/tools');
62
+
63
+ /** src/planrun.js:42 — the survey's hard round bound. */
64
+ export const AUTHOR_SCOUT_ROUNDS = 8;
65
+ /** src/planrun.js:44 — the survey is a PROMPT INGREDIENT for the authoring call. */
66
+ export const AUTHOR_SCOUT_BLOB_MAX = 8000;
67
+ /**
68
+ * src/planrun.js:62 — below this a survey is ABSENT, not empty. Sized off the
69
+ * archive rather than taste: 15 truncated scouts produced 0/74/86 bytes; every
70
+ * real survey was 5991-8056. It is imported as a NUMBER, not as an import, on
71
+ * purpose — planrun does not export it, and a `SCOUT_MIN_BYTES` that drifts
72
+ * between the two scouts is two instruments, so the suite pins them equal.
73
+ */
74
+ export const AUTHOR_SCOUT_MIN_BYTES = 200;
75
+ /** F30 — the 4096 provider default cannot hold a whole survey. */
76
+ export const AUTHOR_SCOUT_MAX_TOKENS = 32000;
77
+
78
+ /**
79
+ * TOTAL survey attempts, HARDCODED (hamr, 2026-08-09: *"depending on causes, we
80
+ * can hardcode 3 attempts, but if reasons are beyond malformed then we may verge
81
+ * into self-healing but i don't think we should"*).
82
+ *
83
+ * It is a ceiling, not a target: an operator may pass a LOWER cap and never a
84
+ * higher one, exactly the direction the standing budget rule runs in (*the agent
85
+ * may only TIGHTEN*). A widened value CLAMPS here rather than throwing, because
86
+ * the number is not a signed artefact — nothing downstream depends on a caller
87
+ * having asked for the right one, and refusing a run over a too-generous cap
88
+ * costs a paid scout for no safety.
89
+ *
90
+ * Sited beside `MAX_REVISIONS`/`MAX_STRUCTURE_RETRIES` in spirit: the same
91
+ * bounded-retry family, one axis each. This one is the SURVEY's axis.
92
+ */
93
+ export const SCOUT_ATTEMPTS = 3;
94
+
95
+ /**
96
+ * THE TYPED CAUSE, at the detection site.
97
+ *
98
+ * The retry gate reads this enum and nothing else. Typing it here is what stops
99
+ * the gate quietly widening to "any ABSENT" the next time someone edits the
100
+ * classifier: a new route to ABSENT has to be named, and naming it is the moment
101
+ * somebody decides whether it retries.
102
+ */
103
+ export const SURVEY_CAUSES = Object.freeze({
104
+ /** the call itself died — provider error, transport, `truncated:max_tokens` */
105
+ CALL_FAILED: 'call-failed',
106
+ /** the model returned nothing at all, and nothing went wrong to explain it */
107
+ EMPTY: 'empty',
108
+ /** short of the ABSENT floor but not empty — F59's cut-off population */
109
+ SHORT: 'short',
110
+ /** nothing extractable in the reply */
111
+ NO_ARTIFACT: 'no-artifact',
112
+ /** there IS a body and it is not JSON — run mslhn707's own death */
113
+ UNPARSEABLE: 'unparseable',
114
+ /** valid JSON, wrong shape */
115
+ NON_OBJECT: 'non-object',
116
+ /** valid JSON, no content — F59's whole point, and a SEMANTIC failure */
117
+ EMPTY_OBJECT: 'empty-object',
118
+ /**
119
+ * THE SURVEY WAS NEVER ASKED FOR: the operator's money ceiling was already
120
+ * spent when the first attempt came due, so no call was made. Named as its own
121
+ * cause for the reason this enum exists at all — a new route to ABSENT has to
122
+ * be named, and naming it is the moment somebody decides whether it retries.
123
+ * This one does not, and cannot: a retry is precisely what the cap forbade.
124
+ * It is deliberately ABSENT of the model's own doing, and the prose says so —
125
+ * "the scout did not complete" would blame a model that never spoke.
126
+ */
127
+ NOT_FUNDED: 'not-funded',
128
+ });
129
+
130
+ /**
131
+ * THE MALFORMED CLASS — the only causes a retry fires on.
132
+ *
133
+ * What is deliberately NOT here, and why:
134
+ * - `call-failed` covers `truncated:max_tokens` and every transport death.
135
+ * Standing doctrine routes both as provider-red with NO redraft; a retry
136
+ * here would pay for the same failure again under a different name.
137
+ * - `short` is F59's cut-off population, and it already has an instrument: the
138
+ * reserved TOOLLESS recovery round, which fires inside the attempt.
139
+ * - `non-object` and `empty-object` are VALID JSON with wrong or vacuous
140
+ * content. That is a SEMANTIC failure, and F38/F39 measured what re-asking
141
+ * one buys: the same distribution, sampled twice. Reacting to content is a
142
+ * designed loop; this is a retry. The line between them is the self-healing
143
+ * line, and it is not crossed here.
144
+ */
145
+ /** @type {readonly string[]} */
146
+ export const SCOUT_RETRY_CAUSES = Object.freeze([SURVEY_CAUSES.EMPTY, SURVEY_CAUSES.UNPARSEABLE]);
147
+
148
+ /**
149
+ * The re-ask, when the survey came back MALFORMED. It names what failed
150
+ * MECHANICALLY — the parse error and its position — which is the gap genre that
151
+ * converts (F38/F39), and it says explicitly that the FINDINGS are not in
152
+ * question, only their form.
153
+ *
154
+ * There is NO JSON repair anywhere behind this and there never will be: a
155
+ * repairer decides what the model meant to say and writes it down as though the
156
+ * model had said it. That is a dishonest instrument in the one artefact whose
157
+ * entire job is to be honest about what the repository contains.
158
+ * @param {string} reason the classifier's own mechanical reason
159
+ */
160
+ export function scoutReaskTurn(reason) {
161
+ return `MEASURED PARSE — your survey could not be read:\n ${redactSecrets(String(reason ?? ''))}\n\n`
162
+ + 'The facts you found are not in question — only the form was unreadable. Do not go looking again and do not '
163
+ + 'change what you reported. Reply with ONLY the JSON object, in a single fenced block, and nothing else.';
164
+ }
165
+
166
+ /**
167
+ * THE GRANT: the full menu MINUS every write-class and store-class verb.
168
+ * Read-only by MENU CONSTRUCTION — no write-class tool object is ever built, so
169
+ * there is nothing for a fence to have to refuse. Derived from the shipped
170
+ * constants so a verb added to `TOOL_MENU` is covered the day it lands.
171
+ */
172
+ export const AUTHOR_SCOUT_VERBS = Object.freeze(
173
+ TOOL_MENU.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)),
174
+ );
175
+
176
+ export const SCOUT_SYSTEM = 'You are a senior engineer performing a READ-ONLY survey of a repository. '
177
+ + 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
178
+ + 'You cannot write, and you cannot run commands: you have no shell and no test runner, so every statement you make '
179
+ + 'is read off configuration, source, lockfiles and documentation. '
180
+ + 'You have a hard, small number of exploration turns. Read only what you need. '
181
+ + 'Your single deliverable is a facts object about how this repository is BUILT, TYPE-CHECKED and TESTED. '
182
+ + 'You are not deciding what should be done to it and you are not proposing any work.';
183
+
184
+ /**
185
+ * F59's reserved round, as a FIXED constant. The round is toolless BY
186
+ * CONSTRUCTION (`defs` is `[]`, so text is the only output the model can
187
+ * produce) — never by asking politely in prose (F19/F37).
188
+ */
189
+ export const SCOUT_RECOVERY_PROMPT = 'You are out of exploration turns. Write the facts object NOW, from what you '
190
+ + 'have already read. Reply with ONLY the JSON object in a single fenced block.';
191
+
192
+ /**
193
+ * The survey brief. Deliberately NEUTRAL about what anyone will do with the
194
+ * facts: this scout is not allowed to have an opinion about the work, because
195
+ * the authoring call is the only thing that composes a close (D11).
196
+ * @param {string} workdir the ABSOLUTE repository root
197
+ */
198
+ export function scoutPrompt(workdir) {
199
+ return `Repository root (absolute): ${workdir}
200
+
201
+ Survey this repository and report how it is type-checked and tested. You cannot execute
202
+ anything, so mark anything you infer rather than read as "inferred".
203
+
204
+ Report, as a single JSON object, with these keys — use null for anything you genuinely
205
+ could not establish, and never guess a command you did not find written down somewhere:
206
+
207
+ {
208
+ "language": e.g. "javascript" | "typescript" | "python" | ...,
209
+ "runner": the test runner this repository actually uses,
210
+ "typecheck": { "cmd": "<executable>", "args": ["..."], "cwd": "<relative dir or null>",
211
+ "env": {"NAME": "value"} | {}, "source": "<where you read this>",
212
+ "inferred": true|false },
213
+ "test": { "cmd": "<executable>", "args": ["..."], "cwd": "<relative dir or null>",
214
+ "env": {"NAME": "value"} | {}, "source": "<where you read this>",
215
+ "inferred": true|false },
216
+ "envNeeds": [ { "name": "...", "value": "...", "why": "..." } ],
217
+ "sourcePaths": ["..."],
218
+ "testPaths": ["..."],
219
+ "extensions": [".js", ...],
220
+ "outputShapes": { "typecheckErrorLine": "<the literal shape of one diagnostic line>",
221
+ "testSummaryLine": "<the literal shape of the runner's count summary line>",
222
+ "inferred": true|false },
223
+ "layout": "<one or two sentences: packages, nesting, anything unusual>",
224
+ "notes": ["anything else load-bearing for grading work done in this repository"]
225
+ }
226
+
227
+ On "envNeeds": include any environment variable WITHOUT WHICH the type checker or the test
228
+ runner would resolve to the wrong files, cache stale results, or emit output that cannot be
229
+ parsed. Say why, in the "why" field. If there are none, use [].
230
+
231
+ Reply with ONLY the JSON object, in a single fenced block. No commentary before or after.`;
232
+ }
233
+
234
+ /**
235
+ * @typedef {{bytes: number, rounds: number, bounded: boolean, recovered: boolean, error: string|null,
236
+ * attempts?: number, attemptsAllowed?: number}} SurveyMeta
237
+ * @typedef {{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: SurveyMeta}} Survey
238
+ */
239
+
240
+ /**
241
+ * THE F59 CONTRACT, IN CODE. Six routes to ABSENT, one route to PRESENT — and
242
+ * the LAST of them is the whole point of the finding: a parsed `{}` is the
243
+ * scout FAILING, never "no special facts are needed".
244
+ *
245
+ * Every ABSENT route also carries a TYPED `cause` beside its prose reason. The
246
+ * prose is for the operator; the type is what the retry gate reads, so widening
247
+ * that gate requires editing the enum rather than a regular expression over a
248
+ * sentence (the standing rule: hand over the enumerated set, never a rule about
249
+ * prose). `empty` is split OUT of the byte floor for exactly that reason — "the
250
+ * model said nothing" and "the model was cut off mid-sentence" are two findings
251
+ * that used to render as one number.
252
+ * @param {string} blob the scrubbed, capped survey text
253
+ * @param {SurveyMeta} meta
254
+ * @returns {Survey}
255
+ */
256
+ export function classifySurvey(blob, meta) {
257
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause) => ({
258
+ state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta,
259
+ });
260
+ if (meta.error) return absent(`the survey call failed: ${meta.error}`, SURVEY_CAUSES.CALL_FAILED);
261
+ if (String(blob ?? '').trim() === '') {
262
+ return absent('the survey came back EMPTY and nothing failed to explain it — the scout did not complete', SURVEY_CAUSES.EMPTY);
263
+ }
264
+ if (meta.bytes < AUTHOR_SCOUT_MIN_BYTES) {
265
+ return absent(`survey ${meta.bytes} bytes < ${AUTHOR_SCOUT_MIN_BYTES} — the scout did not complete`, SURVEY_CAUSES.SHORT);
266
+ }
267
+ const { code, red } = extractArtifact(blob);
268
+ if (!code) return absent(`no artifact in the survey: ${red}`, SURVEY_CAUSES.NO_ARTIFACT);
269
+ let parsed;
270
+ try { parsed = JSON.parse(code); } catch (e) {
271
+ return absent(`the survey did not parse as JSON: ${String(/** @type {any} */ (e)?.message ?? e)}`, SURVEY_CAUSES.UNPARSEABLE);
272
+ }
273
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
274
+ return absent('the survey parsed to a non-object', SURVEY_CAUSES.NON_OBJECT);
275
+ }
276
+ if (Object.keys(parsed).length === 0) {
277
+ return absent('the survey parsed to an EMPTY object — that is the scout not completing, never "no special facts are needed"', SURVEY_CAUSES.EMPTY_OBJECT);
278
+ }
279
+ return { state: 'PRESENT', facts: parsed, reason: null, cause: null, meta };
280
+ }
281
+
282
+ /**
283
+ * The DEFAULT surveyor: a real bareguard fence plus the granted read-only tools.
284
+ * Read-only TWICE OVER — the menu carries no write verb, and the fence grants no
285
+ * write path — and the arbiter's own books are denied outright, exactly as every
286
+ * worker's are.
287
+ *
288
+ * It is a seam because it is the only part of this module that needs a live
289
+ * process, an index and a gate; the ROUND BOUND and the F59 recovery stay in
290
+ * `runAuthorScout` where they can be proven.
291
+ * @param {{workdir: string, granted: readonly string[], auditPath?: string, ctx?: boolean}} o
292
+ */
293
+ export async function defaultSurveyor({ workdir, granted, auditPath = join(workdir, GATE_AUDIT_FILE), ctx = true }) {
294
+ const gate = new Gate({
295
+ fs: { writeScope: [], readScope: [workdir], deny: [auditPath, ...ARBITER_BOOK_STORES.map((s) => join(workdir, s))] },
296
+ limits: { maxTurns: AUTHOR_SCOUT_ROUNDS + 2 },
297
+ audit: { path: auditPath },
298
+ secrets: { patterns: SECRET_PATTERNS },
299
+ humanChannel: async () => ({ decision: 'terminate' }),
300
+ });
301
+ await gate.init();
302
+ const { policy, onLlmResult } = wireGate(gate, {
303
+ actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir),
304
+ });
305
+
306
+ const names = new Set(granted.map((v) => /** @type {Record<string,string>} */ (TOOL_BY_VERB)[v]));
307
+ const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => names.has(t.name));
308
+ /** @type {any[]} */
309
+ let ctxTools = [];
310
+ if (ctx && [...CTX_TOOLS].some((t) => names.has(t))) {
311
+ const lc = new LiteCtx({ root: workdir });
312
+ await lc.index({ yield: true });
313
+ ctxTools = createCtxTools(lc, workdir, () => {}).filter((/** @type {{name: string}} */ t) => names.has(t.name));
314
+ }
315
+ return { tools: [...shell, ...ctxTools], policy, onLlmResult, cleanup: async () => {} };
316
+ }
317
+
318
+ /** the default loop seam — one spelling of how this repo drives a model */
319
+ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider }) =>
320
+ new Loop({ provider, system, policy, onLlmResult });
321
+
322
+ /**
323
+ * Run the authoring scout.
324
+ *
325
+ * `createLoop` and `createSurveyor` are the two seams, both REAL by default. The
326
+ * round bound, F59's reserved round, the blob cap, the scrub and the
327
+ * classification all live HERE rather than behind a seam, because those are the
328
+ * behaviours the finding is about.
329
+ *
330
+ * THE ATTEMPT LADDER (hamr, 2026-08-09) sits OUTSIDE all of that, and it fires
331
+ * on the typed malformed class only — see `SCOUT_RETRY_CAUSES` for what is
332
+ * deliberately excluded and why. Attempt 1 is the full survey; a re-ask is
333
+ * TOOLLESS over the conversation the survey already produced, because the model
334
+ * has already READ the repository and only its final emission was unreadable —
335
+ * re-paying the exploration would buy the same facts twice. Every attempt is a
336
+ * paid call, metered under its own label, and every attempt's raw text is
337
+ * persisted so the malformation can be READ later rather than remembered.
338
+ *
339
+ * ONE FALLBACK, stated because the code has it: a retry with NO conversation to
340
+ * re-ask over re-runs the FULL toolable survey instead. The toolless form rests
341
+ * entirely on "the model already read the tree", and an empty transcript is that
342
+ * premise gone — re-asking over nothing would be a bare "say it again" to a model
343
+ * that was never shown the repository. Re-surveying costs the exploration twice;
344
+ * the alternative buys a second sample of noise. It is not reachable through the
345
+ * shipped Loop (whose `msgs` starts from the caller's own messages and is never
346
+ * empty) and it is reachable through the `createLoop` seam, so it is a fail-safe
347
+ * with a test rather than a promise with a comment.
348
+ *
349
+ * @param {{workdir: string, provider?: any,
350
+ * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
351
+ * attempts?: number, ceilingUsd?: number|null,
352
+ * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
353
+ * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
354
+ * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
355
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
356
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
357
+ */
358
+ export async function runAuthorScout({
359
+ workdir, provider = null,
360
+ rounds = AUTHOR_SCOUT_ROUNDS,
361
+ minBytes = AUTHOR_SCOUT_MIN_BYTES,
362
+ blobMax = AUTHOR_SCOUT_BLOB_MAX,
363
+ maxTokens = AUTHOR_SCOUT_MAX_TOKENS,
364
+ ctx = true,
365
+ attempts = SCOUT_ATTEMPTS,
366
+ ceilingUsd = null,
367
+ createLoop = defaultLoop,
368
+ createSurveyor = defaultSurveyor,
369
+ }) {
370
+ const granted = AUTHOR_SCOUT_VERBS;
371
+ const system = SCOUT_SYSTEM + strategyFor([...granted]);
372
+ const surveyor = await createSurveyor({ workdir, granted, ctx });
373
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
374
+ const calls = [];
375
+ /** @type {ReturnType<typeof scrubRaw>[]} */
376
+ const raws = [];
377
+ /**
378
+ * ONE recorder for a model call: the cost entry and the persisted raw are
379
+ * written together, so a call can never be metered without leaving its output
380
+ * on the trail (or leave output nobody paid for). The pairing is the audit's
381
+ * own invariant, and the suite pins it.
382
+ * Returns the raw's INDEX, so the caller can attach the verdict to the
383
+ * emission it actually describes — see `verdictAt` below.
384
+ * @param {string} label @param {number} attempt @param {any} r @returns {number}
385
+ */
386
+ const record = (label, attempt, r) => {
387
+ calls.push({ label, ...priceOf(r) });
388
+ return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
389
+ };
390
+ // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
391
+ // value floors at one attempt rather than at zero — a scout that never ran is
392
+ // an ABSENT nobody can act on.
393
+ const allowed = Math.max(1, Math.min(Math.trunc(Number(attempts)) || 1, SCOUT_ATTEMPTS));
394
+
395
+ try {
396
+ /** @type {Survey|null} */
397
+ let survey = null;
398
+ let blob = '';
399
+ /** the conversation the last call left behind — what a re-ask re-asks OVER */
400
+ /** @type {any[]|null} */
401
+ let lastMsgs = null;
402
+ let attempt = 0;
403
+ /** which persisted raw the current verdict actually describes. NOT simply
404
+ * "the last one": a recovery round that came back SMALLER is DISCARDED, and
405
+ * the blob being classified is then the first call's — stamping the verdict
406
+ * on the recovery's raw would attribute a failure to an emission nobody
407
+ * judged. An instrument that misaddresses its own reading is the blind
408
+ * -instrument class, one field wide. */
409
+ let verdictAt = -1;
410
+ /** the operator's money ceiling, when it is what ended the ladder. `null` on
411
+ * every other path INCLUDING the unbounded one, so a reader never has to
412
+ * infer a governance stop from an absence.
413
+ * @type {'cap-halt'|'pricing-red'|null} */
414
+ let budgetStop = null;
415
+
416
+ while (attempt < allowed) {
417
+ // THE CEILING, between attempts. The attempt cap bounds how many times the
418
+ // survey may ask; it says nothing about what those asks cost, and this is
419
+ // the pipeline's FIRST paid stage — a ladder that spends the whole wallet
420
+ // before the declaration is ever authored is the failure this closes.
421
+ // Checked before the counter moves, so `attempt` stays the number of
422
+ // attempts that actually happened.
423
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
424
+ if (halt) { budgetStop = halt; break; }
425
+ attempt += 1;
426
+ const label = attempt === 1 ? 'author-scout' : `author-scout#${attempt}`;
427
+
428
+ if (attempt === 1 || !lastMsgs?.length) {
429
+ // The round bound is the plan scout's own mechanism: `loop.stop()` read
430
+ // at the round boundary. It cannot cut an in-flight call (F61) — it
431
+ // bounds the NEXT round, which is exactly why F59's reserved round
432
+ // exists at all.
433
+ let seen = 0;
434
+ let bounded = false;
435
+ /** @type {any} */
436
+ let loop = null;
437
+ const metered = async (/** @type {any} */ arg) => {
438
+ if ((arg?.kind ?? 'turn') === 'turn') {
439
+ seen += 1;
440
+ if (seen >= rounds) { bounded = true; loop.stop(); }
441
+ }
442
+ return surveyor.onLlmResult ? surveyor.onLlmResult(arg) : undefined;
443
+ };
444
+ loop = createLoop({ system, policy: surveyor.policy, onLlmResult: metered, provider });
445
+
446
+ const r = await loop.run([{ role: 'user', content: scoutPrompt(workdir) }], surveyor.tools,
447
+ { cacheMessages: true, maxTokens });
448
+ verdictAt = record(label, attempt, r);
449
+ blob = redactSecrets(String(r?.text ?? '')).slice(0, blobMax);
450
+ lastMsgs = Array.isArray(r?.msgs) && r.msgs.length ? r.msgs : null;
451
+ let recovered = false;
452
+ /** the recovery call's own result, held in scope so its FAILURE is visible
453
+ * to the classifier — `null` whenever the recovery never fired */
454
+ /** @type {any} */
455
+ let s2 = null;
456
+
457
+ // F59's reserved TOOLLESS final round. It fires on the ONE population it
458
+ // was sized for — a survey that was CUT OFF and came back short — never
459
+ // on a survey that simply finished with little to say.
460
+ // …and the reserved round is a PAID CALL like any other, so it sits behind
461
+ // the same ceiling. Left outside it, the one call the cap most needs to
462
+ // stop is the one that escapes: the recovery fires exactly when the first
463
+ // call was cut off, which is exactly when the wallet is thinnest.
464
+ //
465
+ // THE CEILING IS ASKED ABOUT A CALL THAT WOULD ACTUALLY BE MADE, and the
466
+ // ORDER is what makes that true: the recovery predicate is settled FIRST,
467
+ // and `capStop` is consulted only when a recovery was genuinely pending.
468
+ // Asked unconditionally it answers a question nobody put — the wallet is
469
+ // over, but nothing was refused — and `budgetStop` means the ceiling is
470
+ // WHAT ENDED THE LADDER, which is then a lie on every other attempt-1
471
+ // ending: a dead socket on an unbounded first call, or a survey that came
472
+ // back PRESENT and needed no second call at all. Both then render as
473
+ // `cap-halt` at `path: budgetUsd`, steering the operator to raise a number
474
+ // that cannot move the outcome. Nothing about the REFUSAL moves here — the
475
+ // same predicate, the same tally, the same call not made (the top-of-loop
476
+ // latch still names the ceiling whenever a retry was due). This is which
477
+ // ending gets the label, and nothing else.
478
+ const recoveryWanted = bounded && Buffer.byteLength(blob) < minBytes
479
+ && Array.isArray(r?.msgs) && r.msgs.length > 0;
480
+ const recoveryHalt = recoveryWanted ? capStop({ ceilingUsd, ...tallyCalls(calls) }) : null;
481
+ if (recoveryHalt) budgetStop = recoveryHalt;
482
+ if (recoveryWanted && !recoveryHalt) {
483
+ const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
484
+ s2 = await recovery.run([...r.msgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
485
+ { cacheMessages: true, maxTokens });
486
+ const recoveryAt = record('author-scout-recovery', attempt, s2);
487
+ const t = redactSecrets(String(s2?.text ?? '')).slice(0, blobMax);
488
+ // the verdict follows the blob that WON, never the call that came last
489
+ if (Buffer.byteLength(t) > Buffer.byteLength(blob)) { blob = t; recovered = true; verdictAt = recoveryAt; }
490
+ if (Array.isArray(s2?.msgs) && s2.msgs.length) lastMsgs = s2.msgs;
491
+ }
492
+
493
+ // The RECOVERY's error first, when it fired. It is the call that produced
494
+ // (or failed to produce) the blob being classified, so its death is what
495
+ // describes the survey; the first call's error belongs to the very call
496
+ // the recovery exists to replace, and a recovery dying of
497
+ // `truncated:max_tokens` after a clean-but-bounded first round reported
498
+ // no error at all. Falling back to `r.error` keeps every other
499
+ // classification unchanged.
500
+ survey = classifySurvey(blob, {
501
+ bytes: Buffer.byteLength(blob), rounds: seen, bounded, recovered,
502
+ error: s2?.error ?? r?.error ?? null,
503
+ attempts: attempt, attemptsAllowed: allowed,
504
+ });
505
+ } else {
506
+ // THE RE-ASK. Toolless by construction, over the survey's own
507
+ // conversation, naming only what failed mechanically.
508
+ const reask = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
509
+ const rr = await reask.run(
510
+ [...lastMsgs, { role: 'user', content: scoutReaskTurn(/** @type {Survey} */ (survey).reason ?? '') }], [],
511
+ { cacheMessages: true, maxTokens },
512
+ );
513
+ verdictAt = record(label, attempt, rr);
514
+ blob = redactSecrets(String(rr?.text ?? '')).slice(0, blobMax);
515
+ if (Array.isArray(rr?.msgs) && rr.msgs.length) lastMsgs = rr.msgs;
516
+ survey = classifySurvey(blob, {
517
+ bytes: Buffer.byteLength(blob), rounds: 1, bounded: false, recovered: false,
518
+ error: rr?.error ?? null, attempts: attempt, attemptsAllowed: allowed,
519
+ });
520
+ }
521
+
522
+ // the raw that describes THIS attempt carries the attempt's own verdict —
523
+ // the raw is the autopsy artifact, and an autopsy without a cause of death
524
+ // is a body. STAMPED rather than assigned: `reason` quotes the transport's
525
+ // own error prose (`classifySurvey`'s CALL_FAILED route) and this record is
526
+ // persisted, so it rides the same one boundary the raw's text already did.
527
+ const mine = raws[verdictAt];
528
+ if (mine) stampRaw(mine, { cause: survey.cause, reason: survey.reason });
529
+
530
+ if (survey.state === 'PRESENT') break;
531
+ if (!SCOUT_RETRY_CAUSES.includes(/** @type {string} */ (survey.cause))) break;
532
+ }
533
+
534
+ // THE SURVEY THAT WAS NEVER ASKED FOR. The ceiling was already spent when the
535
+ // first attempt came due, so no call was made and there is no reply to
536
+ // classify. It is spelled out rather than run through `classifySurvey`, which
537
+ // would read the empty blob and report that the model came back EMPTY — a
538
+ // model that never spoke cannot have said nothing, and blaming it would send
539
+ // the operator to the scout when the fact is the wallet.
540
+ const final = /** @type {Survey} */ (survey ?? {
541
+ state: 'ABSENT',
542
+ facts: null,
543
+ cause: SURVEY_CAUSES.NOT_FUNDED,
544
+ reason: budgetStop === 'pricing-red'
545
+ ? 'the survey was never asked for: spend could not be priced, so the ceiling could not be enforced (F6)'
546
+ : `the survey was never asked for: the $${ceilingUsd} ceiling was already spent`,
547
+ meta: { bytes: 0, rounds: 0, bounded: false, recovered: false, error: null, attempts: 0, attemptsAllowed: allowed },
548
+ });
549
+ // Exhaustion is still the same honest ABSENT — it now says how many times we
550
+ // paid to find out. One attempt says nothing about retries, because there
551
+ // were none.
552
+ const reason = final.reason !== null && attempt > 1 ? `${final.reason} — after ${attempt} attempts` : final.reason;
553
+ return { ...final, reason, raw: blob, raws, calls, budgetStop };
554
+ } finally {
555
+ // a leaked surveyor leaks a gate handle and a litectx index — always, even
556
+ // when the loop threw
557
+ await surveyor.cleanup();
558
+ }
559
+ }
560
+
561
+ // ── L1: THE MECHANICAL SEED LISTING ──────────────────────────────────────────
562
+
563
+ /** per-path full-listing cap; over it, names give way to counted directories */
564
+ export const LIST_CAP = 40;
565
+ /** whole-block ceiling — the block is a prompt ingredient, like the survey */
566
+ export const LISTING_BYTE_CAP = 12000;
567
+ /** how many bare file NAMES survive the deepest tier of a flat directory */
568
+ export const COUNTS_ONLY_NAMES = 12;
569
+
570
+ /**
571
+ * A surveyor writes prose into path entries (`"src/aurora_cli (symlink)"`,
572
+ * `"src (also …)"`). Strip the annotation, keep the path.
573
+ * @param {any} e
574
+ */
575
+ export const cleanEntry = (e) => String(e ?? '').split(' (')[0].trim().replace(/\/+$/, '');
576
+
577
+ /**
578
+ * `git ls-tree -r --name-only <seed>` — the files as of the SEED COMMIT.
579
+ * Deliberately NOT `ls-files`, which reads the index and the working tree: this
580
+ * listing is what a path is judged against, and it must describe the tree the
581
+ * close will be measured from, not whatever is lying around today.
582
+ *
583
+ * A fault comes back as a named `stop`, never as an empty list — an unreadable
584
+ * listing that rendered as "no files" would fail every path in the declaration
585
+ * for the wrong reason.
586
+ * `files` is spelled `null` on the stop branch rather than left off: a caller
587
+ * that destructures the failure and finds `undefined` reads it as falsy, which
588
+ * is the "absent renders as empty" shape this whole module exists to refuse.
589
+ *
590
+ * The command itself lives in `src/kinds.js` beside the executor's other git
591
+ * reads (`seedListing`) — the declared close's runtime gate judges paths against
592
+ * the SAME listing this scout hands the author, and two spellings of "what
593
+ * exists at the seed" would be two instruments.
594
+ * @param {string} workdir @param {string} seedRef
595
+ * @returns {Promise<{stop: string, files: null}|{stop: null, files: string[]}>}
596
+ */
597
+ export async function seedFileList(workdir, seedRef) {
598
+ return seedListing(workdir, seedRef);
599
+ }
600
+
601
+ /** `src/*` style entries match by segment; everything else is a path prefix.
602
+ * @param {string} entry @returns {(f: string) => boolean} */
603
+ function matcherFor(entry) {
604
+ if (entry.includes('*')) {
605
+ const rx = new RegExp(`^${entry.split('*').map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}(/|$)`);
606
+ return (f) => rx.test(f);
607
+ }
608
+ return (f) => f === entry || f.startsWith(`${entry}/`);
609
+ }
610
+
611
+ /**
612
+ * Render ONE scoped entry. Three outcomes, and each one is stated in full:
613
+ * nothing matched (say so — an invented source path is the failure this block
614
+ * exists to prevent), under the cap (every name), or over it (direct children in
615
+ * full, deeper files as COUNTED directories, with the trim announced). No file is
616
+ * ever silently dropped: F28's rule is that a bound eats detail, never evidence.
617
+ * @param {string} entry @param {string[]} all @param {number} cap @param {boolean} countsOnly
618
+ */
619
+ function renderEntry(entry, all, cap, countsOnly) {
620
+ const files = all.filter(matcherFor(entry));
621
+ if (files.length === 0) return { lines: [`under ${entry} — NO FILES EXIST at the seed (this path matches nothing)`], count: 0 };
622
+ const depth = entry.split('/').length;
623
+ /** @type {(f: string) => string} */
624
+ const groupKey = (f) => {
625
+ const segs = f.split('/');
626
+ return segs.length > depth + 1 ? `${segs.slice(0, depth + 1).join('/')}/` : f;
627
+ };
628
+ if (countsOnly) {
629
+ /** @type {Map<string, number>} */
630
+ const groups = new Map();
631
+ for (const f of files) groups.set(groupKey(f), (groups.get(groupKey(f)) ?? 0) + 1);
632
+ // A FLAT directory has no subdirectories to collapse into, so grouping alone
633
+ // shrinks nothing — 800 files under `src/` render as 800 names at every tier,
634
+ // and the "deepest tier" would be no smaller than the first. The names are
635
+ // what gets capped here; the TOTAL on the first line is complete either way.
636
+ const dirs = [...groups].filter(([k]) => k.endsWith('/'));
637
+ const named = [...groups].filter(([k]) => !k.endsWith('/')).map(([k]) => k);
638
+ const shown = named.slice(0, COUNTS_ONLY_NAMES);
639
+ return {
640
+ lines: [
641
+ `under ${entry} — ${files.length} files:`,
642
+ ...dirs.map(([k, n]) => ` ${k} — ${n} files`),
643
+ ...shown.map((k) => ` ${k}`),
644
+ ...(named.length > shown.length
645
+ ? [` [${named.length - shown.length} of ${named.length} file names not printed — the counts above are complete]`]
646
+ : []),
647
+ ],
648
+ count: files.length,
649
+ };
650
+ }
651
+ if (files.length <= cap) {
652
+ return { lines: [`under ${entry} — ${files.length} files:`, ...files.map((f) => ` ${f}`)], count: files.length };
653
+ }
654
+ /** @type {string[]} */ const direct = [];
655
+ /** @type {Map<string, number>} */ const subdirs = new Map();
656
+ for (const f of files) {
657
+ if (f.split('/').length === depth + 1) direct.push(f);
658
+ else subdirs.set(groupKey(f), (subdirs.get(groupKey(f)) ?? 0) + 1);
659
+ }
660
+ const withheld = files.length - direct.length;
661
+ return {
662
+ lines: [
663
+ `under ${entry} — ${files.length} files; direct children listed in full, deeper files counted per directory:`,
664
+ ...direct.map((f) => ` ${f}`),
665
+ ...[...subdirs].map(([k, n]) => ` ${k} — ${n} files`),
666
+ ` [listing trimmed: ${withheld} of ${files.length} files are behind the directory counts above — the full-list `
667
+ + `cap is ${cap} per path; every file is counted, none is hidden]`,
668
+ ],
669
+ count: files.length,
670
+ };
671
+ }
672
+
673
+ /**
674
+ * THE SEED LISTING, both halves.
675
+ *
676
+ * `files` is the WHOLE tree and is what the validator judges paths against.
677
+ * `block` is SCOPED to the survey's own sourcePaths/testPaths and is what the
678
+ * prompt carries. Giving the validator the scoped half instead would make a job
679
+ * scoped to `src/` read as whole-tree, and M2's one-population law — which fires
680
+ * only on a SCOPED job — would silently stop firing.
681
+ *
682
+ * @param {{workdir: string, seedRef: string, sourcePaths?: any, testPaths?: any,
683
+ * listCap?: number, byteCap?: number}} o
684
+ * @returns {Promise<{stop: string, files: null, block: null, meta: null}
685
+ * |{stop: null, files: string[], block: string, meta: {seedRef: string, totalSeedFiles: number,
686
+ * entries: {entry: string, raw: string, section: string, matched: number}[], tier: string}}>}
687
+ */
688
+ export async function buildSeedListing({ workdir, seedRef, sourcePaths, testPaths, listCap = LIST_CAP, byteCap = LISTING_BYTE_CAP }) {
689
+ const listed = await seedFileList(workdir, seedRef);
690
+ if (listed.stop !== null) return { stop: listed.stop, files: null, block: null, meta: null };
691
+ const all = listed.files;
692
+
693
+ /** @type {{raw: string, entry: string, section: string}[]} */
694
+ const entries = [];
695
+ const seen = new Set();
696
+ for (const [section, list] of /** @type {[string, any][]} */ ([['sourcePaths', sourcePaths], ['testPaths', testPaths]])) {
697
+ for (const raw of Array.isArray(list) ? list : []) {
698
+ const entry = cleanEntry(raw);
699
+ if (!entry || seen.has(entry)) continue;
700
+ seen.add(entry);
701
+ entries.push({ raw: String(raw), entry, section });
702
+ }
703
+ }
704
+
705
+ const render = (/** @type {number} */ cap, /** @type {boolean} */ countsOnly) => {
706
+ const lines = [
707
+ 'FILES THAT ACTUALLY EXIST AT THE SEED — listed mechanically from the repository tree',
708
+ 'itself (git ls-tree at the seed commit), not by the surveyor. A source or test path not',
709
+ 'in this list DOES NOT EXIST, whatever it sounds like it should be called.',
710
+ '',
711
+ ];
712
+ /** @type {{entry: string, raw: string, section: string, matched: number}[]} */
713
+ const meta = [];
714
+ for (const e of entries) {
715
+ const r = renderEntry(e.entry, all, cap, countsOnly);
716
+ meta.push({ entry: e.entry, raw: e.raw, section: e.section, matched: r.count });
717
+ lines.push(`[${e.section}] ${r.lines[0]}`, ...r.lines.slice(1), '');
718
+ }
719
+ return { text: lines.join('\n').trimEnd(), meta };
720
+ };
721
+
722
+ // Deterministic degradation, each tier ANNOUNCED: full → tight → counts-only.
723
+ // Counts stay complete at every tier; only names give way.
724
+ //
725
+ // "full" is rendered ONCE, here, and TIERS holds only the degradations — the
726
+ // loop's job is to fall back, never to re-render the tier it was handed.
727
+ // (`render` walks the whole seed listing per scoped entry, so rendering full
728
+ // twice was the listing's own cost paid twice on every run that never needed
729
+ // to degrade at all — which is every ordinary run.)
730
+ /** @type {[string, number, boolean][]} */
731
+ const TIERS = [['tight', 12, false], ['counts-only', 0, true]];
732
+ let tier = 'full';
733
+ let out = render(listCap, false);
734
+ for (const [name, cap, countsOnly] of TIERS) {
735
+ if (Buffer.byteLength(out.text) <= byteCap) break;
736
+ tier = name;
737
+ out = render(cap, countsOnly);
738
+ }
739
+ let block = out.text;
740
+ let note = tier === 'full' ? ''
741
+ : `[whole-listing cap: a full listing exceeds ${byteCap} bytes, so this block was reduced to tier "${tier}" — `
742
+ + 'counts are complete even where names are not]';
743
+
744
+ // THE BACKSTOP. Enough scoped paths, each with enough subdirectories, can beat
745
+ // every tier — and this block is a PROMPT INGREDIENT, so "usually small" is not
746
+ // a bound. Truncation is line-wise (a byte cut would split a multi-byte
747
+ // character) and is ANNOUNCED with its count; the totals it printed above the
748
+ // cut stay true.
749
+ if (Buffer.byteLength(block) > byteCap) {
750
+ /** @type {string[]} */
751
+ const kept = [];
752
+ let bytes = 0;
753
+ let cut = 0;
754
+ for (const line of block.split('\n')) {
755
+ const b = Buffer.byteLength(line) + 1;
756
+ if (bytes + b > byteCap) { cut += 1; continue; }
757
+ bytes += b;
758
+ kept.push(line);
759
+ }
760
+ block = kept.join('\n');
761
+ note = `[whole-listing cap: even at tier "${tier}" this block exceeds ${byteCap} bytes, so ${cut} line(s) were cut. `
762
+ + 'Every count printed above is complete — but a path you cannot see here is a path you must not assume exists.]';
763
+ }
764
+ if (note) block = `${block}\n\n${note}`;
765
+
766
+ return { stop: null, files: all, block, meta: { seedRef, totalSeedFiles: all.length, entries: out.meta, tier } };
767
+ }