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.
- package/CHANGELOG.md +625 -0
- package/README.md +101 -110
- package/bareloop.context.md +333 -25
- package/package.json +1 -1
- package/src/authorflow.js +1447 -0
- package/src/authoring.js +1486 -0
- package/src/authorjob.js +801 -0
- package/src/authorscout.js +767 -0
- package/src/bridges.js +16 -3
- package/src/declaredclose.js +519 -0
- package/src/index.js +75 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1445 -0
- package/src/ledger.js +18 -1
- package/src/plan.js +26 -1
- package/src/planrun.js +448 -40
- package/src/ralph.js +24 -4
- package/src/reuse.js +120 -9
- package/src/root.js +8 -1
- package/src/run.js +12 -4
- package/src/text.js +199 -0
- package/src/tools.js +11 -0
- package/src/validate.js +168 -11
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +764 -0
- package/types/authoring.d.ts +559 -0
- package/types/authorjob.d.ts +242 -0
- package/types/authorscout.d.ts +320 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +1 -0
- package/types/kinds.d.ts +395 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +53 -3
- package/types/ralph.d.ts +1 -0
- package/types/reuse.d.ts +1 -1
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +151 -0
- package/types/validate.d.ts +74 -9
- package/types/workbranch.d.ts +102 -0
|
@@ -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
|
+
}
|