bareloop 0.9.0 → 0.11.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 +555 -0
- package/README.md +111 -117
- package/bareloop.context.md +314 -24
- package/package.json +3 -3
- package/src/authorflow.js +300 -35
- package/src/authoring.js +290 -33
- package/src/authorjob.js +59 -7
- package/src/authorscout.js +83 -10
- package/src/bridges.js +15 -2
- package/src/clock.js +21 -3
- package/src/declaredclose.js +88 -11
- package/src/index.js +11 -1
- package/src/job.js +7 -2
- package/src/kinds.js +292 -20
- package/src/ledger.js +29 -2
- package/src/planrun.js +404 -29
- package/src/ralph.js +14 -0
- package/src/reuse.js +247 -9
- package/src/root.js +1 -1
- package/src/run.js +12 -5
- package/src/selection.js +1 -1
- package/src/text.js +79 -0
- package/src/tools.js +11 -0
- package/src/validate.js +168 -11
- package/types/authorflow.d.ts +135 -17
- package/types/authoring.d.ts +161 -35
- package/types/authorjob.d.ts +11 -2
- package/types/authorscout.d.ts +22 -3
- package/types/clock.d.ts +5 -2
- package/types/declaredclose.d.ts +64 -6
- package/types/index.d.ts +4 -4
- package/types/job.d.ts +5 -0
- package/types/kinds.d.ts +134 -10
- package/types/planrun.d.ts +61 -2
- package/types/reuse.d.ts +75 -1
- package/types/run.d.ts +12 -2
- package/types/text.d.ts +67 -0
- package/types/validate.d.ts +74 -9
package/src/authorflow.js
CHANGED
|
@@ -78,11 +78,11 @@ import { join, relative, isAbsolute } from 'node:path';
|
|
|
78
78
|
import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS } from './kinds.js';
|
|
79
79
|
import {
|
|
80
80
|
KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
|
|
81
|
-
GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames,
|
|
81
|
+
GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
|
|
82
82
|
validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
|
|
83
83
|
} from './authoring.js';
|
|
84
|
-
import { buildSeedListing, cleanEntry } from './authorscout.js';
|
|
85
|
-
import { extractArtifact, priceOf, scrubRaw } from './text.js';
|
|
84
|
+
import { buildSeedListing, cleanEntry, SURVEY_CAUSES } from './authorscout.js';
|
|
85
|
+
import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
|
|
86
86
|
import { redactSecrets } from './validate.js';
|
|
87
87
|
|
|
88
88
|
const require = createRequire(import.meta.url);
|
|
@@ -163,21 +163,63 @@ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one J
|
|
|
163
163
|
* cannot emit anything else (D2/D3), so a genre it has never seen is a
|
|
164
164
|
* composition problem, not a catalogue problem.
|
|
165
165
|
*
|
|
166
|
-
* The GREEN set
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
166
|
+
* The GREEN set descends from questions 1–6 of the frozen TYPES set (prereg §5).
|
|
167
|
+
* TWO slots have been deleted since, both because the interview was ASKING FOR
|
|
168
|
+
* SOMETHING IT ALREADY HAD or already knew:
|
|
169
|
+
*
|
|
170
|
+
* - D13's genre CONFIRM ("this looks like a type-fixing job, correct?") —
|
|
171
|
+
* superseded in full: the interview never asks about a genre again, and the
|
|
172
|
+
* refusal it carried MOVED to the composer, on the same counted
|
|
173
|
+
* `request-red` path (see `authorCloseForJob`);
|
|
174
|
+
* - "Is there a code repo I can look at? Where?" — DROPPED on hamr's ruling
|
|
175
|
+
* after he drove the terminal interview himself (2026-08-15, verbatim: *"drop
|
|
176
|
+
* Q6"*). The repository is a MANDATORY, STRUCTURED input already —
|
|
177
|
+
* `runInterview`'s own `repoPath`, which the runner takes from `--patient` and
|
|
178
|
+
* refuses to start without. Asking a person to re-type in prose a path the
|
|
179
|
+
* machine is holding in a variable is the SWE tax this product refuses, and
|
|
180
|
+
* it invites a second, drifting answer for the same fact (hamr's own answer
|
|
181
|
+
* on the live run was *"Yes — the patient itself."*). Nothing downstream lost
|
|
182
|
+
* anything: the scout reads `repoPath`, never the answer.
|
|
183
|
+
*
|
|
184
|
+
* Question 2 was WIDENED in the same ruling: it now asks for the read-or-draw-from
|
|
185
|
+
* files as well as the ones that change. The two halves belong in one answer
|
|
186
|
+
* because they are one decision for the person answering — what the work touches,
|
|
187
|
+
* and what it touches only to look at.
|
|
188
|
+
*
|
|
189
|
+
* The five that remain are genre-neutral, which is why every generalisation so far
|
|
190
|
+
* has deleted a slot rather than reworded one. They are NUMBERED CONTIGUOUSLY from
|
|
191
|
+
* 1: the number a person sees is the key their answer is filed under, and a gap
|
|
192
|
+
* would be a hole in a form nobody can explain.
|
|
173
193
|
*/
|
|
174
194
|
export const GREEN_QUESTIONS = Object.freeze({
|
|
175
195
|
1: 'What do you want done?',
|
|
176
|
-
2: 'Which files or folders should change?',
|
|
196
|
+
2: 'Which files or folders should change? And which files should the work read or draw from (they stay untouched)?',
|
|
177
197
|
3: 'Is there anything that must not change?',
|
|
178
198
|
4: 'How do you check today whether it\'s working?',
|
|
179
199
|
5: 'What would make you say this came back worse than before?',
|
|
180
|
-
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* THE HITL SET (N4 slice 1) — the green questions BYTE FOR BYTE, plus one.
|
|
204
|
+
*
|
|
205
|
+
* The five are unchanged deliberately rather than economically: the
|
|
206
|
+
* mechanical-first composition law (2026-08-07) says every hitl close is
|
|
207
|
+
* *deterministic stages first, judge minimal, human last*, so a hitl job needs
|
|
208
|
+
* exactly the same mechanical facts a green one does — what changes, what must
|
|
209
|
+
* not, how it is checked today, what "worse" looks like. Rewording them would
|
|
210
|
+
* fork one interview into two that drift.
|
|
211
|
+
*
|
|
212
|
+
* The last one is the only thing the green set cannot supply, and it is not a
|
|
213
|
+
* flourish: `human-confirms` requires an `ask` — the question the signer answers
|
|
214
|
+
* at the end of the run — and nothing else in the interview names it. Without it
|
|
215
|
+
* the composer would have to invent what a person is deciding, which is the one
|
|
216
|
+
* thing a human stage must never have done for it. It is the SPREAD's next number
|
|
217
|
+
* rather than a fixed one, so a green-side deletion renumbers it instead of
|
|
218
|
+
* leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
|
|
219
|
+
*/
|
|
220
|
+
export const HITL_QUESTIONS = Object.freeze({
|
|
221
|
+
...GREEN_QUESTIONS,
|
|
222
|
+
6: 'When you look at the finished result yourself, what are you deciding?',
|
|
181
223
|
});
|
|
182
224
|
|
|
183
225
|
/**
|
|
@@ -195,7 +237,11 @@ export const QUESTION_SETS = Object.freeze({
|
|
|
195
237
|
required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
196
238
|
}),
|
|
197
239
|
'soft-green': Object.freeze({ locked: true, questions: null, required: null }),
|
|
198
|
-
hitl: Object.freeze({
|
|
240
|
+
hitl: Object.freeze({
|
|
241
|
+
locked: false,
|
|
242
|
+
questions: HITL_QUESTIONS,
|
|
243
|
+
required: Object.freeze(Object.keys(HITL_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
244
|
+
}),
|
|
199
245
|
});
|
|
200
246
|
|
|
201
247
|
/**
|
|
@@ -214,7 +260,15 @@ export const CLASS_STATEMENTS = Object.freeze({
|
|
|
214
260
|
+ 'measurement of this repository. If part of what they asked for cannot be measured that way, leave it out and '
|
|
215
261
|
+ 'say so in your notes rather than approximating it with a stage that means something else.',
|
|
216
262
|
'soft-green': null,
|
|
217
|
-
|
|
263
|
+
// N4 slice 1. The mechanical-first composition law is stated as an ORDER
|
|
264
|
+
// because first-red-wins makes the order the mechanism: the cheap stages shield
|
|
265
|
+
// the expensive one, and the person is never the first filter.
|
|
266
|
+
hitl: 'The person declared this job HITL: a PERSON renders the final verdict, because their judgement is what '
|
|
267
|
+
+ '"done" means here. Compose the mechanical part first — everything about this repository a command can decide '
|
|
268
|
+
+ 'is still a mechanical stage, exactly as it would be for a green job — and put ONE human-confirms stage LAST, '
|
|
269
|
+
+ 'carrying the question they answer when they look at the result. Never make the person the first filter: a '
|
|
270
|
+
+ 'stage a command can render is never handed to them, and they are asked only once, at the end, about the part '
|
|
271
|
+
+ 'no command can hold.',
|
|
218
272
|
});
|
|
219
273
|
|
|
220
274
|
/** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
|
|
@@ -354,6 +408,12 @@ export const PARAM_SCHEMAS = Object.freeze({
|
|
|
354
408
|
extensions: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
355
409
|
allowPrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
356
410
|
requireNonEmpty: { const: true },
|
|
411
|
+
ask: {
|
|
412
|
+
type: 'string',
|
|
413
|
+
minLength: 1,
|
|
414
|
+
description: 'the plain question the person answers while looking at the finished result. They see it verbatim, '
|
|
415
|
+
+ 'so write what they are DECIDING — never a bare "approve?".',
|
|
416
|
+
},
|
|
357
417
|
});
|
|
358
418
|
|
|
359
419
|
/**
|
|
@@ -496,6 +556,60 @@ THE PARSER reads ONE number out of a command's output, as signed arithmetic over
|
|
|
496
556
|
not actually happen.`;
|
|
497
557
|
}
|
|
498
558
|
|
|
559
|
+
/**
|
|
560
|
+
* HOW THE GENRE'S OWN TOOLS PRINT — rendered FROM `genreInstruments`, so the
|
|
561
|
+
* pattern the author is handed and the pattern the tree already grades with are
|
|
562
|
+
* one string.
|
|
563
|
+
*
|
|
564
|
+
* This block is the fix for run msmbpjk6: a parser for a tool WE OWN THE FACTS
|
|
565
|
+
* ABOUT was left to the model to compose, and it composed tsc's terminal-only
|
|
566
|
+
* shape against piped output — 0 matches on 67 real error lines. The delivery is
|
|
567
|
+
* deliberately the MANDATORY-GUARD delivery, not advice: the pattern arrives
|
|
568
|
+
* spelled out, marked genre property, with the real captured line beside it, in
|
|
569
|
+
* the same register as the guard battery that arrives already parameterised.
|
|
570
|
+
*
|
|
571
|
+
* It states the MECHANISM (pipe, not a terminal) rather than only the answer,
|
|
572
|
+
* because the same trap is waiting behind every tool that pretty-prints for a
|
|
573
|
+
* human, and only some of them are in the table yet. That prediction has since
|
|
574
|
+
* been paid off by measurement: `node --test` prints TAP down a pipe and the
|
|
575
|
+
* spec reporter on a terminal, the identical flip, on a second tool.
|
|
576
|
+
*
|
|
577
|
+
* Each row also carries its `capture`, because a count term and a tally term are
|
|
578
|
+
* read by DIFFERENT aggregates and the wrong pairing is a silent misreading, not
|
|
579
|
+
* an error — `first` over a line the tool omits on a good run reports a broken
|
|
580
|
+
* instrument. The block states that mapping once rather than per row.
|
|
581
|
+
* @param {string} lang
|
|
582
|
+
*/
|
|
583
|
+
export function instrumentsBlock(lang) {
|
|
584
|
+
const rows = genreInstruments(lang).map((i) => `- ${i.id} — ${i.what}\n`
|
|
585
|
+
+ ` lineMatch: ${i.lineMatch}\n`
|
|
586
|
+
+ ` capture: ${i.capture === null ? 'null' : i.capture}\n`
|
|
587
|
+
+ ` a REAL captured line: ${i.example}\n`
|
|
588
|
+
+ ` why this spelling: ${i.why}`);
|
|
589
|
+
return `KNOWN INSTRUMENTS — how this genre's own tools PRINT, fixed by the genre, not yours to decide
|
|
590
|
+
|
|
591
|
+
A close captures output through a PIPE, never a terminal. Tools that pretty-print for a human
|
|
592
|
+
print something else entirely down a pipe, and the piped shape is the only one a close ever reads.
|
|
593
|
+
The patterns below are MEASURED against real captured output of this genre's tools.
|
|
594
|
+
|
|
595
|
+
Where a stage of yours counts one of these, use its lineMatch EXACTLY as written — a pattern you
|
|
596
|
+
compose yourself for one of these tools is a pattern for output that never arrives, and a term that
|
|
597
|
+
matches nothing does not report a small number, it reports a broken instrument.
|
|
598
|
+
|
|
599
|
+
Each row states its own capture, and that choice decides the aggregate:
|
|
600
|
+
capture: null the term TALLIES one per matching line. Use aggregate "sum" — over no matching
|
|
601
|
+
lines that is a legitimate counted zero.
|
|
602
|
+
capture: <n> the term reads the FIGURE the tool printed, out of that group. Use aggregate
|
|
603
|
+
"first" — and note that no match at all then means the tool never reported the
|
|
604
|
+
number, which is a broken instrument, never a zero. A count is listed this way
|
|
605
|
+
only where the tool prints the line even when the number is zero.
|
|
606
|
+
|
|
607
|
+
${rows.join('\n\n')}
|
|
608
|
+
|
|
609
|
+
This covers only the tools listed. For anything else, you still compose the pattern yourself from
|
|
610
|
+
the facts object — and say in your notes when you had to.`;
|
|
611
|
+
}
|
|
612
|
+
|
|
499
613
|
/** The three laws. Stated to the author; enforced, where checkable, by M2. */
|
|
500
614
|
export function lawsBlock() {
|
|
501
615
|
return `THE LAWS YOUR DECLARATION MUST OBEY
|
|
@@ -611,6 +725,11 @@ measure anything.`;
|
|
|
611
725
|
+ 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
|
|
612
726
|
+ 'no command, no path, no environment value and no pattern for this repository — finding those is your work,\n'
|
|
613
727
|
+ `from the facts object and the interview:\n\n${TYPES_GENRE_TEMPLATE}`,
|
|
728
|
+
// The template deliberately names no regex for this repository — but the
|
|
729
|
+
// shape a KNOWN tool prints is not "for this repository", it is a fact about
|
|
730
|
+
// an instrument the genre already owns and already grades with. It sits here,
|
|
731
|
+
// immediately after the policy that mandates the typecheck stage.
|
|
732
|
+
instrumentsBlock(lang),
|
|
614
733
|
'MANDATORY GUARDS — already placed and already parameterised\n\n'
|
|
615
734
|
+ 'This kind of job carries stages the person did not ask for. They are fixed by the job\'s genre and they are\n'
|
|
616
735
|
+ 'already in your declaration, with their pattern battery and extensions already filled in — those are genre\n'
|
|
@@ -853,7 +972,19 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
|
|
|
853
972
|
* run could not be read afterwards. A cost entry with no raw beside it is that
|
|
854
973
|
* run again.
|
|
855
974
|
*/
|
|
856
|
-
|
|
975
|
+
/**
|
|
976
|
+
* @param {{ceilingUsd?: number|null,
|
|
977
|
+
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void}} [o]
|
|
978
|
+
* `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
|
|
979
|
+
* `onCall` is a REPORTING seam fired as each metered call lands — the shell's
|
|
980
|
+
* only chance to know what has been spent while the run is still alive. It is
|
|
981
|
+
* never consulted and never governs: `capStop` below is still the one
|
|
982
|
+
* predicate, still asked between calls, and still reads the same `entries`.
|
|
983
|
+
* `absorb` deliberately does NOT fire it: those calls were metered (and
|
|
984
|
+
* reported) by whoever made them, and reporting them twice would double the
|
|
985
|
+
* scout's spend in every reader downstream.
|
|
986
|
+
*/
|
|
987
|
+
export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
|
|
857
988
|
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
858
989
|
const entries = [];
|
|
859
990
|
/** @type {ReturnType<typeof scrubRaw>[]} */
|
|
@@ -861,8 +992,10 @@ export function makeCostBook() {
|
|
|
861
992
|
return {
|
|
862
993
|
/** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
|
|
863
994
|
add(label, r, attempt = 1) {
|
|
864
|
-
|
|
995
|
+
const call = { label, ...priceOf(r) };
|
|
996
|
+
entries.push(call);
|
|
865
997
|
raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
|
|
998
|
+
onCall({ ...call });
|
|
866
999
|
return entries.at(-1);
|
|
867
1000
|
},
|
|
868
1001
|
/** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
|
|
@@ -872,19 +1005,19 @@ export function makeCostBook() {
|
|
|
872
1005
|
* `report().calls`, because both are readings of one list */
|
|
873
1006
|
raws() { return raws.map((w) => ({ ...w })); },
|
|
874
1007
|
report() {
|
|
875
|
-
|
|
876
|
-
const nullCostCalls = entries.filter((e) => e.costUsd === null).length;
|
|
877
|
-
const unpricedRounds = entries.reduce((a, e) => a + (e.unpricedRounds || 0), 0);
|
|
878
|
-
const spendComplete = nullCostCalls === 0 && unpricedRounds === 0;
|
|
879
|
-
return {
|
|
880
|
-
costUsd: spendComplete ? Number(known.toFixed(6)) : null,
|
|
881
|
-
knownUsd: Number(known.toFixed(6)),
|
|
882
|
-
spendComplete,
|
|
883
|
-
nullCostCalls,
|
|
884
|
-
unpricedRounds,
|
|
885
|
-
calls: entries.map((e) => ({ ...e })),
|
|
886
|
-
};
|
|
1008
|
+
return { ...tallyCalls(entries), calls: entries.map((e) => ({ ...e })) };
|
|
887
1009
|
},
|
|
1010
|
+
/**
|
|
1011
|
+
* THE CEILING'S OWN READING, asked between metered calls. The book is the one
|
|
1012
|
+
* place this pipeline's spend accumulates, so it is the one place that can
|
|
1013
|
+
* answer honestly — and it answers through the shared predicate rather than a
|
|
1014
|
+
* second hand-spelled comparison (`src/text.js`, beside `priceOf`).
|
|
1015
|
+
* @returns {'cap-halt'|'pricing-red'|null}
|
|
1016
|
+
*/
|
|
1017
|
+
capStop() { return capStop({ ceilingUsd, ...tallyCalls(entries) }); },
|
|
1018
|
+
/** what was set, stated — `null` is "nobody set one", which is UNBOUNDED and
|
|
1019
|
+
* a visible operator choice, never a defaulted number */
|
|
1020
|
+
ceilingUsd,
|
|
888
1021
|
};
|
|
889
1022
|
}
|
|
890
1023
|
|
|
@@ -939,6 +1072,16 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
939
1072
|
let red = null;
|
|
940
1073
|
|
|
941
1074
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
1075
|
+
// THE CEILING, immediately before the paid call and nowhere else. Sited HERE
|
|
1076
|
+
// rather than in the revise loop because this is where every declaration call
|
|
1077
|
+
// is actually made: the author call, each revise, AND each malformed-emission
|
|
1078
|
+
// retry. The retry axis is the one that matters most — it fires exactly when
|
|
1079
|
+
// the model is malforming, which is the worst moment for a paid loop to be
|
|
1080
|
+
// unbounded. `attempts` is returned as-is so the caller can tell a cap that
|
|
1081
|
+
// tripped before this ask began (0) from one that cut a retry ladder short.
|
|
1082
|
+
const halt = book.capStop();
|
|
1083
|
+
if (halt) return { declaration: null, attempts, convo, raw: '', providerError: null, red: null, budget: halt };
|
|
1084
|
+
|
|
942
1085
|
/** @type {{calls: any[]}} */
|
|
943
1086
|
const box = { calls: [] };
|
|
944
1087
|
const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
|
|
@@ -948,11 +1091,11 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
948
1091
|
const raw = redactSecrets(String(r?.text ?? ''));
|
|
949
1092
|
|
|
950
1093
|
if (r?.error) {
|
|
951
|
-
return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null };
|
|
1094
|
+
return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
|
|
952
1095
|
}
|
|
953
1096
|
|
|
954
1097
|
if (mode === 'tool') {
|
|
955
|
-
if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null };
|
|
1098
|
+
if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
|
|
956
1099
|
red = {
|
|
957
1100
|
code: 'artifact-red',
|
|
958
1101
|
path: DECLARATION_TOOL_NAME,
|
|
@@ -966,7 +1109,7 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
966
1109
|
// parser, never a second one.
|
|
967
1110
|
const { code, red: extractRed } = extractArtifact(raw);
|
|
968
1111
|
if (code) {
|
|
969
|
-
try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null }; }
|
|
1112
|
+
try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null, budget: null }; }
|
|
970
1113
|
catch (e) { red = { code: 'artifact-red', path: 'declaration', detail: `the declaration did not parse as JSON: ${String(/** @type {any} */ (e)?.message ?? e)}`, axis: 'unparseable-json' }; }
|
|
971
1114
|
} else {
|
|
972
1115
|
red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
|
|
@@ -979,7 +1122,7 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
979
1122
|
convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` });
|
|
980
1123
|
}
|
|
981
1124
|
}
|
|
982
|
-
return { declaration: null, attempts, convo, raw: '', providerError: null, red };
|
|
1125
|
+
return { declaration: null, attempts, convo, raw: '', providerError: null, red, budget: null };
|
|
983
1126
|
}
|
|
984
1127
|
|
|
985
1128
|
/**
|
|
@@ -1010,9 +1153,13 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1010
1153
|
*
|
|
1011
1154
|
* @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
|
|
1012
1155
|
* answers: Record<string|number, string>, questions?: Record<string|number, string>,
|
|
1013
|
-
* scout: {state: string, facts: any, reason?: string|null, calls?: any[], raws?: any[]
|
|
1156
|
+
* scout: {state: string, facts: any, reason?: string|null, cause?: string|null, calls?: any[], raws?: any[],
|
|
1157
|
+
* budgetStop?: 'cap-halt'|'pricing-red'|null},
|
|
1014
1158
|
* listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
|
|
1015
1159
|
* generate: Function, seedReadFn?: Function, closeCtx?: any,
|
|
1160
|
+
* ceilingUsd?: number|null,
|
|
1161
|
+
* onPhase?: (phase: string, data?: any) => void,
|
|
1162
|
+
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
1016
1163
|
* maxRevisions?: number, structureRetries?: number,
|
|
1017
1164
|
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
|
|
1018
1165
|
*/
|
|
@@ -1021,6 +1168,12 @@ export async function authorClose({
|
|
|
1021
1168
|
answers, questions = GREEN_QUESTIONS,
|
|
1022
1169
|
scout, listing = null,
|
|
1023
1170
|
generate, seedReadFn = runSeedReadStages, closeCtx = {},
|
|
1171
|
+
ceilingUsd = null,
|
|
1172
|
+
// The two REPORTING seams, defaulted to nothing so every existing caller is
|
|
1173
|
+
// byte-identical. Neither is consulted: the revise ladder, the ceiling and the
|
|
1174
|
+
// seed read all run exactly as they did — the shell is merely told, while the
|
|
1175
|
+
// run is still alive, which of the up-to-15 silent minutes it is inside.
|
|
1176
|
+
onPhase = () => {}, onCall = () => {},
|
|
1024
1177
|
maxRevisions = MAX_REVISIONS,
|
|
1025
1178
|
structureRetries = MAX_STRUCTURE_RETRIES,
|
|
1026
1179
|
structuredMode = 'tool',
|
|
@@ -1039,7 +1192,13 @@ export async function authorClose({
|
|
|
1039
1192
|
// ceiling. Floor 0, exactly as above: `askDeclaration` loops `attempt <= retries`,
|
|
1040
1193
|
// so 0 is still ONE attempt with no retry — a legal tighter ask, not an absent one.
|
|
1041
1194
|
const retryCap = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_STRUCTURE_RETRIES));
|
|
1042
|
-
|
|
1195
|
+
// THE OPERATOR'S CEILING — NO DEFAULT, and none is invented here. `null` means
|
|
1196
|
+
// nobody set one, which is UNBOUNDED: a defaulted cap is a silent second
|
|
1197
|
+
// ceiling, and the run path already paid for that lesson twice (`maxWallMs` has
|
|
1198
|
+
// no default for exactly this reason, and `shellCapUsd` left off judged against
|
|
1199
|
+
// a number nobody chose). An unbounded authoring run is legal; it is just never
|
|
1200
|
+
// an accident, which is why the driver PRINTS it.
|
|
1201
|
+
const book = makeCostBook({ ceilingUsd, onCall });
|
|
1043
1202
|
// The scout's calls AND its raws, together. The raws matter most on the path
|
|
1044
1203
|
// that spends nothing more: run mslhn707 refused at the $0 preflight below,
|
|
1045
1204
|
// and the survey text that would have said WHY died with the process.
|
|
@@ -1057,6 +1216,40 @@ export async function authorClose({
|
|
|
1057
1216
|
// special facts are needed". This one lands in a SIGNED artefact.
|
|
1058
1217
|
if (scout?.state !== 'PRESENT' || !scout.facts || typeof scout.facts !== 'object'
|
|
1059
1218
|
|| Object.keys(scout.facts).length === 0) {
|
|
1219
|
+
// A SURVEY THE CEILING STOPPED IS NOT A SURVEY THAT FAILED. The scout carries
|
|
1220
|
+
// its own governance stop, and when it is set that is the honest name for why
|
|
1221
|
+
// the facts are missing — the same rule W-2 settled for the clock (a time-stop
|
|
1222
|
+
// is NAMED a time-stop). `scout-absent` sends the operator to the scout; a
|
|
1223
|
+
// cap-halt sends them to the number they set, which is the one that moves.
|
|
1224
|
+
if (scout?.budgetStop) {
|
|
1225
|
+
// A CONCURRENT CAUSE IS CONCEDED, NEVER DENIED. The ceiling can refuse the
|
|
1226
|
+
// reserved recovery round for a call that ALSO died on transport, and both
|
|
1227
|
+
// are then true at once. "not on anything it read" would deny the very
|
|
1228
|
+
// error the same sentence goes on to quote, and this red carries
|
|
1229
|
+
// `path: budgetUsd` — so the operator is steered to raise a ceiling into a
|
|
1230
|
+
// dead socket, which is the one lever that cannot repair it. The stop
|
|
1231
|
+
// itself does not move: the ceiling did bind, and naming the second cause
|
|
1232
|
+
// is attribution, not routing.
|
|
1233
|
+
//
|
|
1234
|
+
// The transport death is not the only second cause. The ceiling also
|
|
1235
|
+
// refuses the recovery round behind a SHORT/EMPTY reply, and the retry
|
|
1236
|
+
// behind an EMPTY/UNPARSEABLE one — every cause except NOT_FUNDED means a
|
|
1237
|
+
// real call produced the reason being quoted, so "not on anything it
|
|
1238
|
+
// read" is honest ONLY when no call was ever made.
|
|
1239
|
+
const alsoDied = scout?.cause === SURVEY_CAUSES.CALL_FAILED;
|
|
1240
|
+
const alsoIncomplete = !alsoDied && scout?.cause != null && scout.cause !== SURVEY_CAUSES.NOT_FUNDED;
|
|
1241
|
+
return refuse([{
|
|
1242
|
+
code: scout.budgetStop,
|
|
1243
|
+
path: 'budgetUsd',
|
|
1244
|
+
detail: alsoDied
|
|
1245
|
+
? 'the authoring ceiling bound here AND the survey call itself failed — both are true, and raising the '
|
|
1246
|
+
+ `ceiling will not repair the second: ${redactSecrets(String(scout.reason ?? ''))}`
|
|
1247
|
+
: alsoIncomplete
|
|
1248
|
+
? 'the authoring ceiling bound here AND the survey came back incomplete — both are true: the ceiling '
|
|
1249
|
+
+ `refused the round that could have repaired it: ${redactSecrets(String(scout.reason ?? ''))}`
|
|
1250
|
+
: `the survey stopped on the authoring ceiling, not on anything it read: ${redactSecrets(String(scout.reason ?? ''))}`,
|
|
1251
|
+
}], scout.budgetStop);
|
|
1252
|
+
}
|
|
1060
1253
|
return refuse([{
|
|
1061
1254
|
code: 'scout-absent',
|
|
1062
1255
|
path: 'scout',
|
|
@@ -1158,12 +1351,60 @@ export async function authorClose({
|
|
|
1158
1351
|
/** @type {{stage: string, name: string}[]} */
|
|
1159
1352
|
let droppedEnv = [];
|
|
1160
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
|
|
1356
|
+
* operator's next move is a NUMBER — resume-to-cap is the shipped lever (the
|
|
1357
|
+
* stop IS the checkpoint), and a stop that reports neither side of the
|
|
1358
|
+
* comparison cannot be acted on. It is a governance stop, never an error: the
|
|
1359
|
+
* partial artifacts stay exactly where they are, nothing retries, and nothing
|
|
1360
|
+
* about the close itself is being judged here.
|
|
1361
|
+
* @param {'cap-halt'|'pricing-red'} code
|
|
1362
|
+
* @returns {Red}
|
|
1363
|
+
*/
|
|
1364
|
+
const capRed = (code) => {
|
|
1365
|
+
const c = book.report();
|
|
1366
|
+
const spent = `$${c.knownUsd.toFixed(6)} across ${c.calls.length} metered call(s)`;
|
|
1367
|
+
return code === 'pricing-red'
|
|
1368
|
+
? {
|
|
1369
|
+
code,
|
|
1370
|
+
path: 'budgetUsd',
|
|
1371
|
+
detail: `the authoring ceiling cannot be enforced: ${c.nullCostCalls} unpriced call(s) and ${c.unpricedRounds} `
|
|
1372
|
+
+ `unpriced round(s) make the total unknown, against a $${ceilingUsd} ceiling (${spent} is the KNOWN half). `
|
|
1373
|
+
+ 'Unpriced is never free (F6) — a cap that cannot see the spend cannot govern it, so the run stops rather '
|
|
1374
|
+
+ 'than spending against a floor that reads as exact.',
|
|
1375
|
+
}
|
|
1376
|
+
: {
|
|
1377
|
+
code,
|
|
1378
|
+
path: 'budgetUsd',
|
|
1379
|
+
detail: `the authoring ceiling is spent: ${spent} against a $${ceilingUsd} ceiling. The next call was never `
|
|
1380
|
+
+ 'made — the cap binds BETWEEN calls, so nothing was cut off mid-flight and everything paid for is on '
|
|
1381
|
+
+ 'disk. The stop IS the checkpoint: raise the ceiling and re-run, or read what is here and stop.',
|
|
1382
|
+
};
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1161
1385
|
const seedTrees = makeSeedTrees();
|
|
1162
1386
|
try {
|
|
1163
1387
|
for (let i = 0; i <= revisionCap; i += 1) {
|
|
1164
1388
|
const label = i === 0 ? 'author' : `revise-${i}`;
|
|
1389
|
+
// WHICH call, and how many the ladder may make. `of` is the ENFORCED cap
|
|
1390
|
+
// (`revisionCap`, already clamped tighten-only) rather than the constant —
|
|
1391
|
+
// a readout quoting a number the loop does not obey is a second
|
|
1392
|
+
// instrument, and this file's own history is what that costs.
|
|
1393
|
+
onPhase('author-call', { call: label, i, of: revisionCap });
|
|
1165
1394
|
const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
|
|
1166
1395
|
messages = ask.convo;
|
|
1396
|
+
|
|
1397
|
+
// The cap tripped BEFORE this ask made any call at all, so there is no
|
|
1398
|
+
// iteration to record: an iteration row for a call that never happened is
|
|
1399
|
+
// the blind-instrument class one field wide (`ralph` already pays for the
|
|
1400
|
+
// sibling of this — a governance stop narrated for a loop it never
|
|
1401
|
+
// entered). A ladder cut SHORT is the other case and falls through: those
|
|
1402
|
+
// calls did happen, so the row stays and the stop is read after it.
|
|
1403
|
+
if (ask.budget && ask.attempts === 0) {
|
|
1404
|
+
fatal = [capRed(ask.budget)];
|
|
1405
|
+
stop = ask.budget;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1167
1408
|
/** @type {any} */
|
|
1168
1409
|
const iter = {
|
|
1169
1410
|
call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
|
|
@@ -1172,6 +1413,17 @@ export async function authorClose({
|
|
|
1172
1413
|
};
|
|
1173
1414
|
iterations.push(iter);
|
|
1174
1415
|
|
|
1416
|
+
// A retry ladder cut short by the cap. The money stop OUTRANKS the artifact
|
|
1417
|
+
// red that was in flight: the artifact red says the model malformed, the cap
|
|
1418
|
+
// says we stopped paying to find out — and only the second one is why the
|
|
1419
|
+
// loop ended. Reporting the first would send the operator to the model when
|
|
1420
|
+
// the fact is the wallet.
|
|
1421
|
+
if (ask.budget) {
|
|
1422
|
+
fatal = [capRed(ask.budget)];
|
|
1423
|
+
stop = ask.budget;
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1175
1427
|
if (ask.providerError) {
|
|
1176
1428
|
// a transport failure is a casualty, never evidence about the model — and
|
|
1177
1429
|
// the transport's own prose (a proxy URL, an auth echo) is scrubbed on the
|
|
@@ -1225,7 +1477,20 @@ export async function authorClose({
|
|
|
1225
1477
|
// `gapKeep` defaults to EMPTY rather than being left undefined: M1
|
|
1226
1478
|
// prefixes every gap line with it verbatim, so an unset one prints the
|
|
1227
1479
|
// word "undefined" on every failure line the model is asked to read
|
|
1228
|
-
|
|
1480
|
+
// THE LONG ONE. Every stage of the declaration spawns a real toolchain
|
|
1481
|
+
// against the patient, so this single await is where a run spends
|
|
1482
|
+
// minutes saying nothing — the per-stage line below is the whole point,
|
|
1483
|
+
// and the block boundary is what makes it readable.
|
|
1484
|
+
const stageCount = injected.declaration?.stages?.length ?? null;
|
|
1485
|
+
onPhase('seed-read', { call: label, stages: stageCount });
|
|
1486
|
+
// `onStage` sits AFTER the `closeCtx` spread beside `workdir`/`seedRef`:
|
|
1487
|
+
// it is the runner's own reporting channel, not a knob a caller's close
|
|
1488
|
+
// context may quietly redirect.
|
|
1489
|
+
const rows = await seedReadFn(injected.declaration, {
|
|
1490
|
+
gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees,
|
|
1491
|
+
onStage: (/** @type {any} */ row) => onPhase('stage', { call: label, ...row }),
|
|
1492
|
+
});
|
|
1493
|
+
onPhase('seed-read-done', { call: label, stages: stageCount });
|
|
1229
1494
|
iter.seedRead = rows;
|
|
1230
1495
|
accepted = injected.declaration;
|
|
1231
1496
|
acceptedSeedRead = rows;
|