bareloop 0.8.0 → 0.9.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 +421 -0
- package/README.md +10 -3
- package/bareloop.context.md +198 -17
- package/package.json +1 -1
- package/src/authorflow.js +1280 -0
- package/src/authoring.js +1296 -0
- package/src/authorjob.js +793 -0
- package/src/authorscout.js +706 -0
- package/src/bridges.js +2 -2
- package/src/declaredclose.js +519 -0
- package/src/index.js +72 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1354 -0
- package/src/ledger.js +13 -0
- package/src/plan.js +26 -1
- package/src/planrun.js +239 -16
- package/src/ralph.js +19 -4
- package/src/reuse.js +68 -7
- package/src/root.js +8 -1
- package/src/run.js +10 -3
- package/src/text.js +120 -0
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +720 -0
- package/types/authoring.d.ts +447 -0
- package/types/authorjob.d.ts +241 -0
- package/types/authorscout.d.ts +307 -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 +343 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +10 -3
- package/types/ralph.d.ts +1 -0
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +84 -0
- package/types/workbranch.d.ts +102 -0
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
// THE WRITING HALF — close-authoring v1, module M3b (design record 2026-08-07
|
|
2
|
+
// D3/D11/D12; gate-2 POC prereg addenda 5 and 6).
|
|
3
|
+
//
|
|
4
|
+
// M1 owns what a stage DOES. M2 owns what a declaration may SAY and whether one
|
|
5
|
+
// said it legally. This module is the part that gets a declaration out of a model
|
|
6
|
+
// and refuses to accept a bad one — the authoring call, the schema that bounds
|
|
7
|
+
// it, and the grounded revise loop that measures it against the real repository
|
|
8
|
+
// before anyone signs anything.
|
|
9
|
+
//
|
|
10
|
+
// ── THE ONE STRUCTURAL DECISION (hamr, 2026-08-08) ──────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// The declaration is emitted through a SCHEMA-FORCED TOOL CALL, never parsed out
|
|
13
|
+
// of prose. Standing doctrine: *"text mode will NOT force JSON output; the
|
|
14
|
+
// structure-enforced alternative is tool mode."* The model answers by calling
|
|
15
|
+
// `declare_close`, whose input schema IS the declaration shape, and that schema
|
|
16
|
+
// is DERIVED from M2's catalogue — one source, never a second hand-written copy.
|
|
17
|
+
//
|
|
18
|
+
// This does NOT reopen D3 (*"the authoring call gets no tools at all"*). D3's
|
|
19
|
+
// subject is hamr's law — *"the danger is in the ACTIONS, not the SYNTAX"* — and
|
|
20
|
+
// `declare_close` takes no action: it has no fence to cross because it touches
|
|
21
|
+
// nothing, spawns nothing and reads nothing. It is the OUTPUT CHANNEL, in the
|
|
22
|
+
// place a fenced JSON block used to sit. The authoring call still has no repo
|
|
23
|
+
// tool, no shell, and no way to reach the tree; the scout (M3a) is where looking
|
|
24
|
+
// happens, and it is a different call with a different grant.
|
|
25
|
+
//
|
|
26
|
+
// TWO CONSEQUENCES, NAMED RATHER THAN SMOOTHED:
|
|
27
|
+
//
|
|
28
|
+
// 1. A LOCKED KIND IS INEXPRESSIBLE IN TOOL MODE. The schema carries one branch
|
|
29
|
+
// per LIVE kind, so there is no way to spell `judged-floor` or
|
|
30
|
+
// `human-confirms` — which is the `check-passes(name)` move applied one
|
|
31
|
+
// level up (illegal becomes unsayable, not rejected late). The price is
|
|
32
|
+
// real: M2's `locked-kind` red is bareloop's DEMAND COUNTER for its own
|
|
33
|
+
// catalogue (D13 forward-compat point 2), and a demand that cannot be
|
|
34
|
+
// spoken cannot be counted. On the tool path, locked-kind demand must come
|
|
35
|
+
// from the INTERVIEW layer (D13's genre/verdict refusal, which is where a
|
|
36
|
+
// user's "I want a person to sign this off" actually lands) or from the text
|
|
37
|
+
// fallback below. This module counts what it can see and says so; it does
|
|
38
|
+
// not pretend the channel is intact.
|
|
39
|
+
//
|
|
40
|
+
// 2. THE TEXT PATH SURVIVES ONLY AS A FALLBACK for a provider with no tool
|
|
41
|
+
// mode. It is `structuredMode: 'text'`, it is marked everywhere it appears,
|
|
42
|
+
// and it uses the ONE shipped parser (`extractArtifact`). It is not a
|
|
43
|
+
// recovery path for a model that ignored the tool: that is an ARTIFACT-RED —
|
|
44
|
+
// name the axis, retry under a bound, write nothing.
|
|
45
|
+
//
|
|
46
|
+
// ── THE GROUNDED LOOP (addendum 5, lever L2) ────────────────────────────────
|
|
47
|
+
//
|
|
48
|
+
// author → validate → run EVERY stage at the seed → feed the MEASURED results
|
|
49
|
+
// back → revise. Bounded at two revisions, early-stop when the model returns its
|
|
50
|
+
// declaration unchanged. The feedback is EXECUTION OUTPUT and nothing else: no
|
|
51
|
+
// model ever reviews another model's close (D9), because a model grading its own
|
|
52
|
+
// homework is the self-consistency trap the design already bans. The revise turn
|
|
53
|
+
// is exactly `measured block + one frozen instruction`, and `assertReviseTurn`
|
|
54
|
+
// is what makes that a fact instead of a promise.
|
|
55
|
+
//
|
|
56
|
+
// A validation red is fed back the same way and COUNTS as a revision — it is a
|
|
57
|
+
// measurement of the declaration, made by the validator. A malformed EMISSION is
|
|
58
|
+
// not: it is a transport fault, retried under its own bound, and it never eats
|
|
59
|
+
// the model's revision budget.
|
|
60
|
+
//
|
|
61
|
+
// ── WHAT THIS MODULE INJECTS, AND WHY IT IS NOT THE MODEL'S JOB ─────────────
|
|
62
|
+
//
|
|
63
|
+
// The genre's environment (addendum 6's second residue). `MYPYPATH` on a Python
|
|
64
|
+
// patient is the Result-5 fact no user can supply and no model found: round 2's
|
|
65
|
+
// arm B authored it itself and authored it WRONG. It is applied MECHANICALLY to
|
|
66
|
+
// every stage that spawns something, a model-authored copy is DROPPED and
|
|
67
|
+
// ANNOUNCED, and a genre variable this patient cannot supply is a RED — because
|
|
68
|
+
// it moves no seed number, so no seed-verdict read can ever catch its absence.
|
|
69
|
+
// The model is never handed its own injected copy back, or M2 would red it as
|
|
70
|
+
// `genre-owned-env` on the next turn for a variable the model did not write.
|
|
71
|
+
//
|
|
72
|
+
// F6 throughout: an unpriced call makes the TOTAL unknown, and unknown is null.
|
|
73
|
+
// No spine emission here — M4 wires events.
|
|
74
|
+
|
|
75
|
+
import { createRequire } from 'node:module';
|
|
76
|
+
import { realpathSync } from 'node:fs';
|
|
77
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
78
|
+
import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS } from './kinds.js';
|
|
79
|
+
import {
|
|
80
|
+
KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
|
|
81
|
+
GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames,
|
|
82
|
+
validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
|
|
83
|
+
} from './authoring.js';
|
|
84
|
+
import { buildSeedListing, cleanEntry } from './authorscout.js';
|
|
85
|
+
import { extractArtifact, priceOf, scrubRaw } from './text.js';
|
|
86
|
+
import { redactSecrets } from './validate.js';
|
|
87
|
+
|
|
88
|
+
const require = createRequire(import.meta.url);
|
|
89
|
+
const { Loop } = require('bare-agent');
|
|
90
|
+
|
|
91
|
+
/** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
|
|
92
|
+
|
|
93
|
+
// ── constants ────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** the output channel's name. It takes no action; it carries the answer. */
|
|
96
|
+
export const DECLARATION_TOOL_NAME = 'declare_close';
|
|
97
|
+
/** what the tool returns. The loop stops on delivery, so the model never reads it. */
|
|
98
|
+
export const DECLARATION_ACK = 'declaration received';
|
|
99
|
+
/** addendum 5: unlimited revising launders thrash as adaptation (v1.12's rule). */
|
|
100
|
+
export const MAX_REVISIONS = 2;
|
|
101
|
+
/** a malformed EMISSION is a transport fault, bounded on its own axis */
|
|
102
|
+
export const MAX_STRUCTURE_RETRIES = 2;
|
|
103
|
+
/** per-stage gap lines carried into a revise turn, trim announced (F28) */
|
|
104
|
+
export const REVISE_GAP_CAP = 15;
|
|
105
|
+
/** validator reds carried into a revise turn, trim announced (F28) */
|
|
106
|
+
export const REVISE_RED_CAP = 20;
|
|
107
|
+
/** F18 / F30 — caching on, and the 4096 provider default cannot hold a declaration */
|
|
108
|
+
export const AUTHOR_MAX_TOKENS = 32000;
|
|
109
|
+
/** the one slot the model fills on an injected guard */
|
|
110
|
+
export const FILL_MARKER = '<FILL IN>';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ONE red, scrubbed — the same spelling `prepareSigning` uses (src/authorjob.js),
|
|
114
|
+
* and for the same reason: a validation red QUOTES what the model declared, and
|
|
115
|
+
* these copies are persisted (`iterations[].validation`, this module's returned
|
|
116
|
+
* `reds`) rather than only rendered.
|
|
117
|
+
*
|
|
118
|
+
* UNIFORM over every string-valued field, never a named list of them. `detail` is
|
|
119
|
+
* only the loudest echo — `cmd-denied` quotes the command again as a structured
|
|
120
|
+
* `cmd`, `path-not-in-listing` carries `declared`/`resolved`, and the next red to
|
|
121
|
+
* learn a field would be missed by an allowlist written today. `redactSecrets`
|
|
122
|
+
* returns a non-matching string byte-identical, so `code`/`path` pass through
|
|
123
|
+
* unchanged and the uniform map costs them nothing.
|
|
124
|
+
* @param {Red} r @returns {Red}
|
|
125
|
+
*/
|
|
126
|
+
const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
|
|
127
|
+
Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
|
|
128
|
+
));
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* THE FROZEN REVISE INSTRUCTION — gate-2 round 3's own text, byte for byte
|
|
132
|
+
* (`scratch-gate2/author.mjs:63`, the turn that PASSED both arms). It is the only
|
|
133
|
+
* prose a revise turn may carry, this constant is its only source, and the suite
|
|
134
|
+
* asserts its bytes: an edit reds the suite and forces a decision rather than
|
|
135
|
+
* quietly changing what every future close is corrected against.
|
|
136
|
+
*/
|
|
137
|
+
export const REVISE_INSTRUCTION = 'These are the measured results of running your close against the UNCHANGED repository (the seed). '
|
|
138
|
+
+ 'The work has not been done yet. Judge your own declaration against them: a work stage that is GREEN at '
|
|
139
|
+
+ 'seed is grading nothing; an instrument stop means the stage cannot run at all; a count far from what the '
|
|
140
|
+
+ 'interview describes means the instrument is measuring the wrong thing. Return the full corrected '
|
|
141
|
+
+ 'declaration, or return it unchanged if you believe it is correct.';
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The structure re-ask, per mode. DELIBERATELY NOT `REVISE_INSTRUCTION`: nothing
|
|
145
|
+
* has been measured, there is no declaration to judge, and reusing the revise
|
|
146
|
+
* text would tell a model to "judge its own declaration against them" when there
|
|
147
|
+
* is no them.
|
|
148
|
+
*/
|
|
149
|
+
export const STRUCTURE_INSTRUCTION_TOOL = `Your reply did not deliver exactly one declaration through the ${DECLARATION_TOOL_NAME} tool. `
|
|
150
|
+
+ `Call ${DECLARATION_TOOL_NAME} exactly once, with the whole declaration as its arguments. Do not write the declaration as text.`;
|
|
151
|
+
export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one JSON declaration. '
|
|
152
|
+
+ 'Reply with ONLY the JSON object, in a single fenced block, and nothing else.';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* THE INTERVIEW, KEYED BY VERDICT CLASS (hamr's ruling, PRD v1.57 §2).
|
|
156
|
+
*
|
|
157
|
+
* One question set per GENRE has no terminating shape — genres are a fat long
|
|
158
|
+
* tail that cannot be enumerated, and TYPES was the first specimen rather than
|
|
159
|
+
* the pattern. One set per verdict CLASS has exactly three. Genre understanding
|
|
160
|
+
* does not disappear: it moves into the COMPOSITION over the kind catalogue
|
|
161
|
+
* (`CLASS_STATEMENTS` below, rendered into the authoring prompt), which is the
|
|
162
|
+
* layer already built to hold it — the authoring call composes owned kinds and
|
|
163
|
+
* cannot emit anything else (D2/D3), so a genre it has never seen is a
|
|
164
|
+
* composition problem, not a catalogue problem.
|
|
165
|
+
*
|
|
166
|
+
* The GREEN set is questions 1–6 of the frozen TYPES set (prereg §5) BYTE FOR
|
|
167
|
+
* BYTE. What is gone is the seventh slot — D13's genre CONFIRM ("this looks like
|
|
168
|
+
* a type-fixing job, correct?") — superseded in full: the interview never asks
|
|
169
|
+
* about a genre again. The refusal it carried did not disappear; it MOVED to the
|
|
170
|
+
* composer, on the same counted `request-red` path (see `authorCloseForJob`).
|
|
171
|
+
* The remaining six are already genre-neutral, which is why generalising them
|
|
172
|
+
* required deleting a slot rather than rewording one.
|
|
173
|
+
*/
|
|
174
|
+
export const GREEN_QUESTIONS = Object.freeze({
|
|
175
|
+
1: 'What do you want done?',
|
|
176
|
+
2: 'Which files or folders should change?',
|
|
177
|
+
3: 'Is there anything that must not change?',
|
|
178
|
+
4: 'How do you check today whether it\'s working?',
|
|
179
|
+
5: 'What would make you say this came back worse than before?',
|
|
180
|
+
6: 'Is there a code repo I can look at? Where?',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The three sets. `questions: null` on a locked class is ABSENT, never `{}`:
|
|
185
|
+
* an empty set would read as "this class needs no questions", which is a
|
|
186
|
+
* different and false statement (F59's distinction, at the interview layer).
|
|
187
|
+
* Nothing ever reads them — a locked class refuses at ADMISSION, before its
|
|
188
|
+
* questions run — and the two readers below throw rather than hand back nothing.
|
|
189
|
+
* @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, required: readonly number[]|null}>}
|
|
190
|
+
*/
|
|
191
|
+
export const QUESTION_SETS = Object.freeze({
|
|
192
|
+
green: Object.freeze({
|
|
193
|
+
locked: false,
|
|
194
|
+
questions: GREEN_QUESTIONS,
|
|
195
|
+
required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
196
|
+
}),
|
|
197
|
+
'soft-green': Object.freeze({ locked: true, questions: null, required: null }),
|
|
198
|
+
hitl: Object.freeze({ locked: true, questions: null, required: null }),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* WHERE GENRE UNDERSTANDING NOW LIVES: the plain statement the composer is fed —
|
|
203
|
+
* *the user has this problem and declared it green/deterministic*. It is the
|
|
204
|
+
* user's own answer travelling into the composition call, and the composer is
|
|
205
|
+
* what turns it into stages over owned kinds.
|
|
206
|
+
*
|
|
207
|
+
* A locked class states NOTHING rather than something bland: a sentence here
|
|
208
|
+
* would be a class this build can compose for, and it cannot.
|
|
209
|
+
* @type {Record<string, string|null>}
|
|
210
|
+
*/
|
|
211
|
+
export const CLASS_STATEMENTS = Object.freeze({
|
|
212
|
+
green: 'The person declared this job GREEN: their definition of done is MACHINE-CHECKABLE. A command has to be able '
|
|
213
|
+
+ 'to decide it — no judgement, no score, and no person in the loop. Every stage you compose is a mechanical '
|
|
214
|
+
+ 'measurement of this repository. If part of what they asked for cannot be measured that way, leave it out and '
|
|
215
|
+
+ 'say so in your notes rather than approximating it with a stage that means something else.',
|
|
216
|
+
'soft-green': null,
|
|
217
|
+
hitl: null,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
|
|
221
|
+
function questionSet(verdictType) {
|
|
222
|
+
const set = Object.hasOwn(QUESTION_SETS, String(verdictType)) ? QUESTION_SETS[String(verdictType)] : null;
|
|
223
|
+
if (!set) throw new Error(`no interview set for verdict class "${verdictType}" — one of ${VERDICT_CLASSES.join(', ')}`);
|
|
224
|
+
if (set.locked || set.questions === null) {
|
|
225
|
+
throw new Error(`the "${verdictType}" interview set is LOCKED — that class is declared-but-locked in v1 and `
|
|
226
|
+
+ 'admission refuses it before its questions are ever asked');
|
|
227
|
+
}
|
|
228
|
+
return set;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The frozen question set for a picked class. Throws on a locked or unknown
|
|
232
|
+
* one: reaching here with either means admission did not refuse, which is a
|
|
233
|
+
* bareloop bug, and failing loudly beats interviewing for a class we cannot run.
|
|
234
|
+
* @param {string} verdictType */
|
|
235
|
+
export function questionsFor(verdictType) { return questionSet(verdictType).questions; }
|
|
236
|
+
/** The question numbers whose answer must be present for that class's interview
|
|
237
|
+
* to be finished. @param {string} verdictType @returns {number[]} */
|
|
238
|
+
export function requiredAnswersFor(verdictType) { return [...questionSet(verdictType).required]; }
|
|
239
|
+
|
|
240
|
+
export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
|
|
241
|
+
+ 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
|
|
242
|
+
+ 'a new kind, or a new parameter name. You cannot read anything and you cannot run anything: everything you know '
|
|
243
|
+
+ 'about this repository is in the message you are given.';
|
|
244
|
+
|
|
245
|
+
// ── 1. THE SCHEMA, DERIVED FROM THE CATALOGUE ───────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** `{includePrefixes?, excludePrefixes?}` — one definition, both kinds that take it */
|
|
248
|
+
const SCOPE_SCHEMA = Object.freeze({
|
|
249
|
+
type: 'object',
|
|
250
|
+
description: 'which population this stage counts. A path prefix, read off the seed listing.',
|
|
251
|
+
properties: {
|
|
252
|
+
includePrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
253
|
+
excludePrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
254
|
+
},
|
|
255
|
+
additionalProperties: false,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** the parser, in both legal forms. Enums come from M1's own exported constants. */
|
|
259
|
+
const PARSER_SCHEMA = Object.freeze({
|
|
260
|
+
description: 'how to read ONE number out of the command output: the signed sum over terms of '
|
|
261
|
+
+ '(first | sum of that term\'s captures, inside that term\'s region).',
|
|
262
|
+
oneOf: [
|
|
263
|
+
{
|
|
264
|
+
title: 'terms',
|
|
265
|
+
type: 'object',
|
|
266
|
+
properties: {
|
|
267
|
+
terms: {
|
|
268
|
+
type: 'array',
|
|
269
|
+
minItems: 1,
|
|
270
|
+
maxItems: MAX_TERMS,
|
|
271
|
+
items: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
lineMatch: { type: 'string', description: 'a regular expression, applied line by line' },
|
|
275
|
+
capture: { type: 'integer', minimum: 0, description: 'which capture group holds the number (default 1). A pattern with NO group tallies 1 per match.' },
|
|
276
|
+
sign: { enum: [...SIGNS], description: '+1 adds this term, -1 subtracts it' },
|
|
277
|
+
aggregate: { enum: [...AGGREGATES], description: '"sum" over nothing is a counted zero; "first" over nothing is UNKNOWN, and unknown is an instrument stop' },
|
|
278
|
+
region: {
|
|
279
|
+
oneOf: [
|
|
280
|
+
{ const: 'whole-output' },
|
|
281
|
+
{
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties: {
|
|
284
|
+
anchor: { type: 'string' },
|
|
285
|
+
capture: { type: 'integer', minimum: 0, description: '0 = the whole anchor match' },
|
|
286
|
+
},
|
|
287
|
+
required: ['anchor', 'capture'],
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
required: ['lineMatch', 'sign', 'aggregate', 'region'],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
required: ['terms'],
|
|
299
|
+
additionalProperties: false,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
title: 'short form',
|
|
303
|
+
type: 'object',
|
|
304
|
+
description: 'exactly one term with sign +1, aggregate "first", region "whole-output"',
|
|
305
|
+
properties: {
|
|
306
|
+
lineMatch: { type: 'string' },
|
|
307
|
+
capture: { type: 'integer', minimum: 0 },
|
|
308
|
+
},
|
|
309
|
+
required: ['lineMatch'],
|
|
310
|
+
additionalProperties: false,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* ONE schema per PARAMETER NAME, composed into kinds by the catalogue's own
|
|
317
|
+
* `required`/`optional` lists. Keyed by name rather than by kind on purpose: the
|
|
318
|
+
* catalogue already decides which kind takes which parameter, and a per-kind
|
|
319
|
+
* table would be a second place for that fact to live.
|
|
320
|
+
*
|
|
321
|
+
* What is genuinely NEW here is only the JSON TYPE of each leaf — the catalogue
|
|
322
|
+
* states it as prose (`shape`) for the prompt, and nothing in the tree states it
|
|
323
|
+
* as a schema. Everything a schema could DRIFT on — which kinds exist, which are
|
|
324
|
+
* locked, which parameters each takes, which values an enum admits — is read
|
|
325
|
+
* from M2 and M1, and {@link schemaCoverage} makes the remaining seam mechanical:
|
|
326
|
+
* a catalogue that grows a parameter this table does not know about fails LOUDLY
|
|
327
|
+
* at schema-build time rather than silently omitting it from what the model can
|
|
328
|
+
* say.
|
|
329
|
+
*/
|
|
330
|
+
export const PARAM_SCHEMAS = Object.freeze({
|
|
331
|
+
cmd: { type: 'string', description: 'the executable, invoked so its binary actually resolves' },
|
|
332
|
+
args: { type: 'array', items: { type: 'string' } },
|
|
333
|
+
expectExit: { type: 'integer' },
|
|
334
|
+
timeoutMs: { type: 'integer', minimum: 1 },
|
|
335
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
336
|
+
parser: PARSER_SCHEMA,
|
|
337
|
+
scope: SCOPE_SCHEMA,
|
|
338
|
+
direction: { enum: [...DIRECTIONS] },
|
|
339
|
+
baseline: {
|
|
340
|
+
enum: [...BASELINES],
|
|
341
|
+
description: '"seed" is the same number taken from this run\'s own starting point, measured every run; '
|
|
342
|
+
+ '0 is literally zero. You never write a number you measured yourself.',
|
|
343
|
+
},
|
|
344
|
+
patterns: {
|
|
345
|
+
type: 'array',
|
|
346
|
+
minItems: 1,
|
|
347
|
+
items: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
properties: { id: { type: 'string' }, regex: { type: 'string' } },
|
|
350
|
+
required: ['id', 'regex'],
|
|
351
|
+
additionalProperties: false,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
extensions: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
355
|
+
allowPrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
356
|
+
requireNonEmpty: { const: true },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The mechanical seam between the catalogue and the schema, read BOTH ways: a
|
|
361
|
+
* catalogue parameter with no schema would be silently undeclarable, and a schema
|
|
362
|
+
* entry the catalogue does not have is a second catalogue.
|
|
363
|
+
* @param {Record<string, any>} [catalogue]
|
|
364
|
+
* @returns {{ok: boolean, missing: string[], orphan: string[]}}
|
|
365
|
+
*/
|
|
366
|
+
export function schemaCoverage(catalogue = KIND_CATALOGUE) {
|
|
367
|
+
/** @type {string[]} */ const missing = [];
|
|
368
|
+
/** @type {Set<string>} */ const used = new Set();
|
|
369
|
+
for (const [kind, spec] of Object.entries(catalogue)) {
|
|
370
|
+
if (spec.locked) continue;
|
|
371
|
+
for (const p of [...spec.required, ...spec.optional]) {
|
|
372
|
+
used.add(p);
|
|
373
|
+
if (!Object.hasOwn(PARAM_SCHEMAS, p)) missing.push(`${kind}.${p}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const orphan = Object.keys(PARAM_SCHEMAS).filter((p) => !used.has(p));
|
|
377
|
+
return { ok: missing.length === 0 && orphan.length === 0, missing, orphan };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* THE DECLARATION SCHEMA — the tool's input schema, and therefore the whole
|
|
382
|
+
* shape a model can express.
|
|
383
|
+
*
|
|
384
|
+
* One `oneOf` branch per LIVE kind, each pinning its own `kind` as a const so a
|
|
385
|
+
* parameter set cannot drift onto the wrong kind. A LOCKED kind gets no branch at
|
|
386
|
+
* all: declaring one is inexpressible here rather than rejected afterwards (see
|
|
387
|
+
* the header for what that costs).
|
|
388
|
+
*
|
|
389
|
+
* It enforces STRUCTURE, not LAW. The stage-name slug rule, the one-population
|
|
390
|
+
* law, the listing rule, F49's regex reject and the D5 guard equality are M2's,
|
|
391
|
+
* and they arrive as validation reds through the revise loop — a schema that
|
|
392
|
+
* tried to restate them would be a second validator.
|
|
393
|
+
*
|
|
394
|
+
* THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
|
|
395
|
+
* and failing loudly beats emitting a schema that quietly cannot say something
|
|
396
|
+
* the validator will then demand.
|
|
397
|
+
* @param {Record<string, any>} [catalogue]
|
|
398
|
+
*/
|
|
399
|
+
export function declarationSchema(catalogue = KIND_CATALOGUE) {
|
|
400
|
+
const cov = schemaCoverage(catalogue);
|
|
401
|
+
if (!cov.ok) {
|
|
402
|
+
throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
|
|
403
|
+
+ `missing: ${cov.missing.join(', ') || 'none'}; orphaned: ${cov.orphan.join(', ') || 'none'}`);
|
|
404
|
+
}
|
|
405
|
+
const branches = Object.entries(catalogue)
|
|
406
|
+
.filter(([, spec]) => !spec.locked)
|
|
407
|
+
.map(([kind, spec]) => {
|
|
408
|
+
/** @type {Record<string, any>} */
|
|
409
|
+
const properties = {};
|
|
410
|
+
for (const p of [...spec.required, ...spec.optional]) properties[p] = PARAM_SCHEMAS[/** @type {keyof typeof PARAM_SCHEMAS} */ (p)];
|
|
411
|
+
return {
|
|
412
|
+
title: kind,
|
|
413
|
+
description: spec.asserts,
|
|
414
|
+
type: 'object',
|
|
415
|
+
properties: {
|
|
416
|
+
name: { type: 'string', description: 'a unique lowercase-hyphenated slug saying what this stage asserts' },
|
|
417
|
+
kind: { const: kind },
|
|
418
|
+
params: { type: 'object', properties, required: [...spec.required], additionalProperties: false },
|
|
419
|
+
},
|
|
420
|
+
required: ['name', 'kind', 'params'],
|
|
421
|
+
additionalProperties: false,
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
type: 'object',
|
|
427
|
+
properties: {
|
|
428
|
+
stages: {
|
|
429
|
+
type: 'array',
|
|
430
|
+
minItems: 1,
|
|
431
|
+
maxItems: MAX_STAGES,
|
|
432
|
+
description: 'the close, in FIRST-RED-WINS order — the first red ends the judgement',
|
|
433
|
+
items: { oneOf: branches },
|
|
434
|
+
},
|
|
435
|
+
notes: {
|
|
436
|
+
type: 'array',
|
|
437
|
+
// minLength 1 for the standing reason: where only a closed set (here,
|
|
438
|
+
// "a string that says something") can satisfy a field, the illegal value
|
|
439
|
+
// is made INEXPRESSIBLE rather than rejected after the fact. The
|
|
440
|
+
// validator reds an empty note either way — this stops it being written.
|
|
441
|
+
items: { type: 'string', minLength: 1 },
|
|
442
|
+
description: 'anything you could not express, or any fact you needed and did not have',
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
required: ['stages'],
|
|
446
|
+
additionalProperties: false,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The output channel. It records and acknowledges; it takes no action, which is
|
|
452
|
+
* why handing it to a toolless authoring call does not reopen D3.
|
|
453
|
+
* @param {{calls: any[]}} box
|
|
454
|
+
* @param {{catalogue?: Record<string, any>}} [o]
|
|
455
|
+
*/
|
|
456
|
+
export function declarationTool(box, { catalogue = KIND_CATALOGUE } = {}) {
|
|
457
|
+
return {
|
|
458
|
+
name: DECLARATION_TOOL_NAME,
|
|
459
|
+
description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
|
|
460
|
+
parameters: declarationSchema(catalogue),
|
|
461
|
+
execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── 2. THE PROMPT ────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The catalogue, as the authoring call sees it — rendered FROM the catalogue, so
|
|
469
|
+
* the prompt and the validator judge one set of facts. `shape` and `asserts` are
|
|
470
|
+
* M2's own strings.
|
|
471
|
+
* @param {Record<string, any>} [catalogue]
|
|
472
|
+
*/
|
|
473
|
+
export function catalogueBlock(catalogue = KIND_CATALOGUE) {
|
|
474
|
+
const rows = Object.entries(catalogue).map(([name, spec]) => (spec.locked
|
|
475
|
+
? `- ${name} — LOCKED. Not available in v1; it will not run.`
|
|
476
|
+
: `- ${name}\n ${spec.shape}\n required: ${spec.required.join(', ')}`
|
|
477
|
+
+ `${spec.optional.length ? ` optional: ${spec.optional.join(', ')}` : ''}\n asserts: ${spec.asserts}`));
|
|
478
|
+
return `THE KIND CATALOGUE — these are the only kinds that exist.
|
|
479
|
+
You parameterise kinds. You never write a script, a shell fragment, or a new kind.
|
|
480
|
+
|
|
481
|
+
${rows.join('\n')}
|
|
482
|
+
|
|
483
|
+
THE PARSER reads ONE number out of a command's output, as signed arithmetic over terms:
|
|
484
|
+
|
|
485
|
+
value = the sum over terms of sign x (first | sum of that term's matches, inside its region)
|
|
486
|
+
|
|
487
|
+
lineMatch a regular expression, applied to the output line by line.
|
|
488
|
+
capture which capture group holds the number (default 1). A pattern with NO capture group
|
|
489
|
+
contributes 1 per match — that is how you TALLY occurrences instead of reading a
|
|
490
|
+
figure the tool printed.
|
|
491
|
+
aggregate ${AGGREGATES.join(' | ')}. "sum" over nothing is a counted zero. "first" over nothing
|
|
492
|
+
means the number was never reported, which is a broken instrument, not a zero.
|
|
493
|
+
region "whole-output", or {anchor, capture} to search only inside the part of the output the
|
|
494
|
+
anchor locates. A region that cannot be located is a broken instrument.
|
|
495
|
+
sign ${SIGNS.join(' | ')}. Subtraction is how you exclude things that were reported but did
|
|
496
|
+
not actually happen.`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The three laws. Stated to the author; enforced, where checkable, by M2. */
|
|
500
|
+
export function lawsBlock() {
|
|
501
|
+
return `THE LAWS YOUR DECLARATION MUST OBEY
|
|
502
|
+
|
|
503
|
+
1. ONE POPULATION PER STAGE.
|
|
504
|
+
A stage name owns exactly one population of things being counted or checked. Two structurally
|
|
505
|
+
different counts never share a stage name, because their results land in one trend series and a
|
|
506
|
+
run that merely swapped one wall for another then reads as progress. Every counting stage over a
|
|
507
|
+
job that touches only part of the tree must say WHICH population it counts: its scope carries
|
|
508
|
+
includePrefixes or excludePrefixes. A scope object with neither names nothing.
|
|
509
|
+
|
|
510
|
+
2. FIRST RED WINS — order cheap before expensive.
|
|
511
|
+
Stages run in the order you declare them and the first red ends the judgement. Put the cheap,
|
|
512
|
+
fast, structural stages first so they shield the slow ones.
|
|
513
|
+
|
|
514
|
+
3. MECHANICAL FIRST.
|
|
515
|
+
Every assertion that CAN be a command IS a command. Nothing is left to judgement, to prose, or to
|
|
516
|
+
a person. If you cannot express something with the kinds above, leave it out and say so in your
|
|
517
|
+
notes — do not approximate it with a stage that means something else.
|
|
518
|
+
|
|
519
|
+
4. EVERY PATH IS SELECTED, NEVER INVENTED.
|
|
520
|
+
Any path you write down must appear in the seed file listing above. A file is not called what it
|
|
521
|
+
sounds like it should be called.`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The whole authoring prompt, as one string.
|
|
526
|
+
*
|
|
527
|
+
* Two people fed this brief and neither of them is the model: a person who is not
|
|
528
|
+
* a software engineer answered an interview, and a read-only survey produced the
|
|
529
|
+
* facts. The prompt says so, because round 1 measured what happens when a model
|
|
530
|
+
* treats a lay answer as a specification.
|
|
531
|
+
*
|
|
532
|
+
* `verdictType` is REQUIRED and has no default. It is the user's own answer and
|
|
533
|
+
* the one thing that tells the composer what DONE is allowed to mean here; a
|
|
534
|
+
* defaulted class would be the prompt answering a question the user was asked.
|
|
535
|
+
*
|
|
536
|
+
* @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
|
|
537
|
+
* facts: any, listingBlock: string, lang: string, verdictType: string,
|
|
538
|
+
* guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
|
|
539
|
+
* ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>}} o
|
|
540
|
+
*/
|
|
541
|
+
export function authorPrompt({ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards, ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE }) {
|
|
542
|
+
const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
|
|
543
|
+
if (statement === null) {
|
|
544
|
+
throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
|
|
545
|
+
+ `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
|
|
546
|
+
}
|
|
547
|
+
const interview = Object.entries(questions)
|
|
548
|
+
.map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
|
|
549
|
+
.join('\n\n');
|
|
550
|
+
|
|
551
|
+
const role = `You are composing the close for one job.
|
|
552
|
+
|
|
553
|
+
The close is what decides whether a run of this job came back DONE. It runs after the work is
|
|
554
|
+
finished, it is the only truth, and nothing else can overrule it. It is a list of STAGES. Each
|
|
555
|
+
stage names a kind from the catalogue below and fills that kind's parameters. Stages run in the
|
|
556
|
+
order you declare them.
|
|
557
|
+
|
|
558
|
+
Two people fed you this brief and neither of them is you:
|
|
559
|
+
- the person who wants the job done answered an interview, below, in their own words. They are
|
|
560
|
+
not a software engineer. They did not name commands, thresholds or patterns, and you must not
|
|
561
|
+
assume they know what any of those would be;
|
|
562
|
+
- a read-only survey of their repository produced the facts object, below. It is the only thing
|
|
563
|
+
you know about the repository. If a fact you need is not in it, say so in your notes rather
|
|
564
|
+
than inventing it.`;
|
|
565
|
+
|
|
566
|
+
// The guards arrive FULLY PARAMETERISED — the battery and its scope are genre
|
|
567
|
+
// property (addendum 6). Only a declared `fill` slot is the model's, and it is
|
|
568
|
+
// marked. Round 3's arm A narrowed a guard's scan and called it stricter.
|
|
569
|
+
const guardJson = guards.map((g) => ({
|
|
570
|
+
name: g.name,
|
|
571
|
+
kind: g.kind,
|
|
572
|
+
params: { ...g.params, ...Object.fromEntries(g.fill.map((f) => [f, FILL_MARKER])) },
|
|
573
|
+
}));
|
|
574
|
+
|
|
575
|
+
const envLine = ownedEnvNames.length
|
|
576
|
+
? `\n\nThis job's genre sets ${ownedEnvNames.join(', ')} for you, from facts you were not given. `
|
|
577
|
+
+ 'Do not declare any of those variables yourself — a declaration that sets one is rejected.'
|
|
578
|
+
: '';
|
|
579
|
+
|
|
580
|
+
const output = mode === 'tool'
|
|
581
|
+
? `HOW TO ANSWER
|
|
582
|
+
|
|
583
|
+
Call the ${DECLARATION_TOOL_NAME} tool exactly once. Its arguments ARE the declaration: an ordered
|
|
584
|
+
"stages" array, plus "notes" for anything you could not express or any fact you needed and did not
|
|
585
|
+
have. Do not write the declaration as text.
|
|
586
|
+
|
|
587
|
+
Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
|
|
588
|
+
left as a placeholder. You never write a threshold number you measured yourself, because you cannot
|
|
589
|
+
measure anything.`
|
|
590
|
+
: `HOW TO ANSWER
|
|
591
|
+
|
|
592
|
+
A single JSON object and nothing else: an ordered "stages" array, plus "notes" for anything you
|
|
593
|
+
could not express or any fact you needed and did not have.
|
|
594
|
+
|
|
595
|
+
Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
|
|
596
|
+
left as a placeholder. You never write a threshold number you measured yourself, because you cannot
|
|
597
|
+
measure anything.`;
|
|
598
|
+
|
|
599
|
+
return [
|
|
600
|
+
role,
|
|
601
|
+
// WHERE GENRE UNDERSTANDING LIVES NOW (PRD v1.57 §2). The interview no longer
|
|
602
|
+
// asks what KIND of job this is; the person answered what DONE means, and the
|
|
603
|
+
// composer reads that plus the catalogue and works the rest out.
|
|
604
|
+
`WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
|
|
605
|
+
`THE INTERVIEW — the person's own words\n\n${interview}`,
|
|
606
|
+
`THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
|
|
607
|
+
+ (listingBlock ? `\n\n${listingBlock}` : ''),
|
|
608
|
+
catalogueBlock(catalogue),
|
|
609
|
+
lawsBlock(),
|
|
610
|
+
`THE GENRE TEMPLATE — fixed by the job's genre (${lang}), not yours to decide\n\n`
|
|
611
|
+
+ 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
|
|
612
|
+
+ 'no command, no path, no environment value and no pattern for this repository — finding those is your work,\n'
|
|
613
|
+
+ `from the facts object and the interview:\n\n${TYPES_GENRE_TEMPLATE}`,
|
|
614
|
+
'MANDATORY GUARDS — already placed and already parameterised\n\n'
|
|
615
|
+
+ '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
|
+
+ 'already in your declaration, with their pattern battery and extensions already filled in — those are genre\n'
|
|
617
|
+
+ `property and you may not change, drop, rename, or re-kind them. The ONLY thing you fill in is each\n`
|
|
618
|
+
+ `"${FILL_MARKER}" slot, from the facts object and the interview:\n\n${JSON.stringify(guardJson, null, 2)}\n\n`
|
|
619
|
+
+ 'Place them where the ordering law says they belong, and author the remaining stages around them.'
|
|
620
|
+
+ envLine,
|
|
621
|
+
output,
|
|
622
|
+
].join('\n\n---\n\n');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── 3. THE MEASURED BLOCKS ───────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
/** F6, at the rendering boundary: an absent number is unknown, never 0. */
|
|
628
|
+
const show = (/** @type {any} */ v) => (v === null || v === undefined ? 'unknown' : String(v));
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The seed read, as the model sees it: mechanical labels, no editorial. Per-stage
|
|
632
|
+
* gap capped with the trim ANNOUNCED (F28 — a bound that eats the failure names
|
|
633
|
+
* deletes exactly the mechanical detail that converts).
|
|
634
|
+
*
|
|
635
|
+
* SCRUBBED at the emission boundary. What this block renders is raw subprocess
|
|
636
|
+
* output (gap lines, an instrument stop carrying a command's stderr) and it
|
|
637
|
+
* becomes the next USER TURN sent to the provider — the surface most likely to
|
|
638
|
+
* carry a real credential, crossing the network. It goes through the ONE shared
|
|
639
|
+
* redactor, the same one `askDeclaration` puts the model's own text through and
|
|
640
|
+
* the same one `declaredclose`/`authorjob` put these very fields through; never
|
|
641
|
+
* a second inventory. Applied to the whole rendered block rather than one field,
|
|
642
|
+
* so a line added here is scrubbed by construction.
|
|
643
|
+
* @param {any[]} rows M1 `StageResult[]`
|
|
644
|
+
* @param {{gapCap?: number}} [o]
|
|
645
|
+
*/
|
|
646
|
+
export function renderSeedReadBlock(rows, { gapCap = REVISE_GAP_CAP } = {}) {
|
|
647
|
+
const lines = ['MEASURED SEED-READ — every stage of your declaration was executed against the repository at the seed:'];
|
|
648
|
+
for (const r of rows ?? []) {
|
|
649
|
+
lines.push('', `stage "${r.stage}" [${r.kind}]`);
|
|
650
|
+
lines.push(` verdict: ${r.verdict} · exit code: ${show(r.exitCode)}`);
|
|
651
|
+
if (r.value !== null && r.value !== undefined) {
|
|
652
|
+
lines.push(` value: ${show(r.value)} · baseline: ${show(r.baseline)}`
|
|
653
|
+
+ `${r.baselineSource ? ` (baseline ${r.baselineSource})` : ''}`);
|
|
654
|
+
}
|
|
655
|
+
if (r.detail?.stop) lines.push(` instrument-stop: ${r.detail.stop}`);
|
|
656
|
+
const gap = r.gapLines ?? [];
|
|
657
|
+
if (gap.length) {
|
|
658
|
+
lines.push(' gap:');
|
|
659
|
+
for (const g of gap.slice(0, gapCap)) lines.push(` ${g}`);
|
|
660
|
+
if (gap.length > gapCap) {
|
|
661
|
+
lines.push(` [gap trimmed: ${gap.length - gapCap} of ${gap.length} lines withheld — the cap is ${gapCap} per stage]`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return redactSecrets(lines.join('\n'));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The measured block when the declaration never reached execution. Both cases are
|
|
670
|
+
* machine outputs of the pipeline, never editorial prose about the model's work.
|
|
671
|
+
*
|
|
672
|
+
* SCRUBBED at the emission boundary, for the SAME reason `renderSeedReadBlock`
|
|
673
|
+
* is and through the SAME one redactor. The channel here is different and just
|
|
674
|
+
* as real: a validation red QUOTES WHAT THE MODEL DECLARED, verbatim — the
|
|
675
|
+
* genre-env branch echoes the expected and the declared value, the deny floor
|
|
676
|
+
* echoes the whole `cmd`, the listing rule echoes the path — and this block
|
|
677
|
+
* becomes the next USER TURN sent to the provider. A declared value shaped like
|
|
678
|
+
* a credential crossed unmasked before this line existed. Applied to the whole
|
|
679
|
+
* rendered block rather than one field, so a red added to the validator is
|
|
680
|
+
* scrubbed by construction rather than by remembering.
|
|
681
|
+
* @param {{kind: 'validation'|'artifact', reds: (Red|string)[], cap?: number}} o
|
|
682
|
+
*/
|
|
683
|
+
export function renderRejectBlock({ kind, reds, cap = REVISE_RED_CAP }) {
|
|
684
|
+
const head = kind === 'validation'
|
|
685
|
+
? 'MEASURED VALIDATION — your declaration was rejected by the validator before any stage could run:'
|
|
686
|
+
: 'MEASURED PARSE — your reply could not be read as one declaration:';
|
|
687
|
+
const rendered = (reds ?? []).map((r) => (typeof r === 'string' ? r : `${r.code} at ${r.path || '(root)'}: ${r.detail}`));
|
|
688
|
+
const shown = rendered.slice(0, cap);
|
|
689
|
+
const tail = rendered.length > shown.length
|
|
690
|
+
? [` [${rendered.length - shown.length} of ${rendered.length} reds withheld — the cap is ${cap}]`]
|
|
691
|
+
: [];
|
|
692
|
+
return redactSecrets([head, ...shown.map((r) => ` ${r}`), ...tail].join('\n'));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** A revise turn is EXACTLY the measured block, a blank line, and the instruction. */
|
|
696
|
+
export function buildReviseTurn(/** @type {string} */ measuredBlock) {
|
|
697
|
+
return `${measuredBlock}\n\n${REVISE_INSTRUCTION}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The structural guarantee that no other prose can enter a revise turn. Without
|
|
702
|
+
* it "the feedback is measurement only" is a promise; with it, it is a fact that
|
|
703
|
+
* fails the build the day someone appends a helpful sentence.
|
|
704
|
+
* @param {string} turn @param {string} measuredBlock
|
|
705
|
+
*/
|
|
706
|
+
export function assertReviseTurn(turn, measuredBlock) {
|
|
707
|
+
if (!/^MEASURED (SEED-READ|VALIDATION|PARSE) —/.test(measuredBlock)) {
|
|
708
|
+
throw new Error('REVISE DRIFT: the measured block does not open with a mechanical header');
|
|
709
|
+
}
|
|
710
|
+
if (turn !== `${measuredBlock}\n\n${REVISE_INSTRUCTION}`) {
|
|
711
|
+
throw new Error('REVISE DRIFT: the turn is not measured-block + the frozen instruction, exactly');
|
|
712
|
+
}
|
|
713
|
+
return { ok: true, bytes: Buffer.byteLength(turn) };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── 4. THE GENRE'S ENVIRONMENT ───────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
/** Which kinds can carry an environment at all — read off the catalogue, never a
|
|
719
|
+
* list, and read through M2's OWN predicate rather than a second copy of it.
|
|
720
|
+
* The validator's completeness rule demands the variable on exactly the stages
|
|
721
|
+
* this injector puts it on, so the two must be one function: a drift either way
|
|
722
|
+
* leaves stages injected-but-unchecked or checked-but-never-injected. */
|
|
723
|
+
const envCapable = envCapableKind;
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Apply the genre's environment to a declaration, mechanically.
|
|
727
|
+
*
|
|
728
|
+
* Three rules, and each one is a finding:
|
|
729
|
+
* - it lands on every stage that SPAWNS something (env-capable by the
|
|
730
|
+
* catalogue) and nowhere else — a stage that runs no process has no
|
|
731
|
+
* environment to set;
|
|
732
|
+
* - a model-authored copy of a genre-owned name is DROPPED and ANNOUNCED. M2's
|
|
733
|
+
* validator already reds that, so this is defence in depth — but the failure
|
|
734
|
+
* direction matters: silently keeping the model's guess is how round 2's arm
|
|
735
|
+
* B graded an unedited checkout;
|
|
736
|
+
* - a genre variable this patient cannot supply is a RED, not an omission. It
|
|
737
|
+
* moves no seed number, so no seed-verdict read can ever see it missing, and
|
|
738
|
+
* the artefact it would be missing from is SIGNED.
|
|
739
|
+
*
|
|
740
|
+
* The input declaration is never mutated: the model's own copy has to stay what
|
|
741
|
+
* the model wrote, or the next turn's validator reds it for our injection.
|
|
742
|
+
* @param {any} declaration
|
|
743
|
+
* @param {string} lang
|
|
744
|
+
* @param {{sourcePrefixes?: string[], catalogue?: Record<string, any>}} [o]
|
|
745
|
+
* @returns {{declaration: any, applied: Record<string, string>, dropped: {stage: string, name: string}[],
|
|
746
|
+
* missing: string[], reds: Red[]}}
|
|
747
|
+
*/
|
|
748
|
+
export function applyGenreEnv(declaration, lang, { sourcePrefixes = [], catalogue = KIND_CATALOGUE } = {}) {
|
|
749
|
+
const applied = genreEnv(lang, { sourcePrefixes });
|
|
750
|
+
const owned = genreOwnedEnvNames(lang);
|
|
751
|
+
const missing = owned.filter((n) => !Object.hasOwn(applied, n));
|
|
752
|
+
/** @type {{stage: string, name: string}[]} */
|
|
753
|
+
const dropped = [];
|
|
754
|
+
/** @type {Red[]} */
|
|
755
|
+
const reds = missing.map((name) => ({
|
|
756
|
+
code: 'genre-env-unavailable',
|
|
757
|
+
path: `genre.env.${name}`,
|
|
758
|
+
detail: `the ${lang} genre owns ${name} and this patient supplies nothing to build it from — it is a fact `
|
|
759
|
+
+ 'the interview cannot give and the repository does not state, it moves no seed number, and a close signed '
|
|
760
|
+
+ 'without it grades a different checkout',
|
|
761
|
+
name,
|
|
762
|
+
}));
|
|
763
|
+
|
|
764
|
+
const out = structuredClone(declaration);
|
|
765
|
+
for (const s of out?.stages ?? []) {
|
|
766
|
+
if (!s || typeof s !== 'object' || !envCapable(s.kind, catalogue)) continue;
|
|
767
|
+
if (!s.params || typeof s.params !== 'object') continue;
|
|
768
|
+
/** @type {Record<string, string>} */
|
|
769
|
+
const env = { ...(s.params.env ?? {}) };
|
|
770
|
+
const conflicts = owned.filter((n) => Object.hasOwn(env, n));
|
|
771
|
+
// touch nothing when there is nothing to do: a declaration is a SIGNED
|
|
772
|
+
// artefact, and rewriting a stage we had no reason to change makes the
|
|
773
|
+
// signed form differ from what the model wrote for no stated reason
|
|
774
|
+
if (conflicts.length === 0 && Object.keys(applied).length === 0) continue;
|
|
775
|
+
for (const name of conflicts) { dropped.push({ stage: s.name, name }); delete env[name]; }
|
|
776
|
+
Object.assign(env, applied);
|
|
777
|
+
if (Object.keys(env).length) s.params.env = env;
|
|
778
|
+
else delete s.params.env;
|
|
779
|
+
}
|
|
780
|
+
return { declaration: out, applied, dropped, missing, reds };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The prefixes `genreEnv` JOINS into the genre's variable, resolved against the
|
|
785
|
+
* real tree. Two filters, and the second one was paid for on a live patient.
|
|
786
|
+
*
|
|
787
|
+
* EXISTENCE (unchanged): a prefix that names nothing at the seed would build a
|
|
788
|
+
* variable pointing at nothing, and the grounded validator refuses it anyway
|
|
789
|
+
* (`ungroundedGenreEnv` checks every ELEMENT against the seed listing).
|
|
790
|
+
*
|
|
791
|
+
* IDENTITY (new): existence alone reads a SYMLINK and the directory behind it as
|
|
792
|
+
* two prefixes. On aurora, 11 entries under `src` resolve into the per-package
|
|
793
|
+
* `packages/<pkg>/src` trees, so the joined `MYPYPATH` named one tree twice
|
|
794
|
+
* under two spellings and mypy died FATALLY — exit 2, "shadows library module",
|
|
795
|
+
* zero output — which the close reads as a broken instrument on a perfectly
|
|
796
|
+
* healthy patient. The realpath-deduped control produced the identical clean
|
|
797
|
+
* reading as the known-good invocation.
|
|
798
|
+
*
|
|
799
|
+
* WHICH SPELLING SURVIVES is not cosmetic. The REAL path wins, because that is
|
|
800
|
+
* the one the checker itself resolves to; the symlink spelling is kept only when
|
|
801
|
+
* the real one cannot be used. And a spelling is only ever emitted when it
|
|
802
|
+
* SELECTS from the seed listing, because the arbiter records this value in a
|
|
803
|
+
* SIGNED envelope and re-validates it GROUNDED — a value the grounded validator
|
|
804
|
+
* cannot select is one the arbiter must never build. So a link out of the
|
|
805
|
+
* repository, or into a path git does not track, keeps its declared spelling
|
|
806
|
+
* rather than becoming an absolute path nothing can ground.
|
|
807
|
+
*
|
|
808
|
+
* An unreadable candidate (a dangling link, a race) is NOT dropped: it passed the
|
|
809
|
+
* existence filter against the listing, and silently losing a real prefix is the
|
|
810
|
+
* Result-5 hazard in the other direction.
|
|
811
|
+
* @param {{workdir: string, sourcePaths: any, seedFiles: string[]}} o
|
|
812
|
+
* @returns {string[]}
|
|
813
|
+
*/
|
|
814
|
+
export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
|
|
815
|
+
const files = Array.isArray(seedFiles) ? seedFiles : [];
|
|
816
|
+
const listed = (/** @type {string} */ p) => p !== '' && files.some((f) => f === p || f.startsWith(`${p}/`));
|
|
817
|
+
const candidates = (Array.isArray(sourcePaths) ? sourcePaths : []).map(cleanEntry).filter(listed);
|
|
818
|
+
|
|
819
|
+
// the root's OWN realpath: a workdir reached through a link would otherwise
|
|
820
|
+
// make every candidate look like it points outside the repository
|
|
821
|
+
let root;
|
|
822
|
+
try { root = realpathSync(String(workdir ?? '')); } catch { root = String(workdir ?? ''); }
|
|
823
|
+
|
|
824
|
+
/** @type {Map<string, string>} realpath → the spelling that owns it */
|
|
825
|
+
const byReal = new Map();
|
|
826
|
+
for (const declared of candidates) {
|
|
827
|
+
let real = null;
|
|
828
|
+
try { real = realpathSync(join(root, declared)); } catch { real = null; }
|
|
829
|
+
let spelling = declared;
|
|
830
|
+
if (real !== null) {
|
|
831
|
+
const rel = relative(root, real);
|
|
832
|
+
// inside the repository, and something git tracks under it
|
|
833
|
+
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) && listed(rel)) spelling = rel;
|
|
834
|
+
}
|
|
835
|
+
const key = real ?? join(root, declared);
|
|
836
|
+
if (!byReal.has(key)) byReal.set(key, spelling);
|
|
837
|
+
}
|
|
838
|
+
return [...byReal.values()];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── 5. COST ──────────────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* F6's shape: an unpriced call or round makes the TOTAL unknown, and unknown is
|
|
845
|
+
* reported as null — never as the sum of the priced half wearing a complete coat.
|
|
846
|
+
* `knownUsd` carries the priced half explicitly so nothing is hidden either way.
|
|
847
|
+
*
|
|
848
|
+
* IT IS ALSO THE RUN'S AUDIT OF WHAT WAS SAID, not only of what was spent. Every
|
|
849
|
+
* `add` records the call's RAW output beside its price, through the one
|
|
850
|
+
* scrubbed-persist helper — one list of calls, read two ways. That pairing is
|
|
851
|
+
* structural rather than remembered: run mslhn707 metered a $0.053 scout call
|
|
852
|
+
* and kept not one byte of what it returned, so the malformation that killed the
|
|
853
|
+
* run could not be read afterwards. A cost entry with no raw beside it is that
|
|
854
|
+
* run again.
|
|
855
|
+
*/
|
|
856
|
+
export function makeCostBook() {
|
|
857
|
+
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
858
|
+
const entries = [];
|
|
859
|
+
/** @type {ReturnType<typeof scrubRaw>[]} */
|
|
860
|
+
const raws = [];
|
|
861
|
+
return {
|
|
862
|
+
/** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
|
|
863
|
+
add(label, r, attempt = 1) {
|
|
864
|
+
entries.push({ label, ...priceOf(r) });
|
|
865
|
+
raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
|
|
866
|
+
return entries.at(-1);
|
|
867
|
+
},
|
|
868
|
+
/** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
|
|
869
|
+
* @param {ReturnType<typeof scrubRaw>[]} [callRaws] */
|
|
870
|
+
absorb(calls, callRaws) { for (const c of calls ?? []) entries.push({ ...c }); for (const w of callRaws ?? []) raws.push({ ...w }); },
|
|
871
|
+
/** the persisted raws, in call order — the same order and the same labels as
|
|
872
|
+
* `report().calls`, because both are readings of one list */
|
|
873
|
+
raws() { return raws.map((w) => ({ ...w })); },
|
|
874
|
+
report() {
|
|
875
|
+
const known = entries.reduce((a, e) => a + (typeof e.costUsd === 'number' ? e.costUsd : 0), 0);
|
|
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
|
+
};
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The default model boundary: one bare-agent Loop per call, with the declaration
|
|
893
|
+
* tool wired to END the call the moment it is used. The tool is an OUTPUT
|
|
894
|
+
* CHANNEL, not a step in a conversation, so the round that would acknowledge its
|
|
895
|
+
* result has nothing left to say.
|
|
896
|
+
*
|
|
897
|
+
* MEASURED against the real Loop with a counting provider, both arms: stop-wired
|
|
898
|
+
* = 1 provider round, $0.001; unwired = 2 rounds, $0.002. So the wiring halves
|
|
899
|
+
* the round count of every authoring and revise call — that is the number, not
|
|
900
|
+
* an estimate, and it is why tool mode does not cost double what text mode did.
|
|
901
|
+
*
|
|
902
|
+
* `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
|
|
903
|
+
* @param {any} provider @param {{system?: string, maxTokens?: number}} [o]
|
|
904
|
+
* @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
|
|
905
|
+
*/
|
|
906
|
+
export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens = AUTHOR_MAX_TOKENS } = {}) {
|
|
907
|
+
return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
|
|
908
|
+
const loop = new Loop({ provider, system });
|
|
909
|
+
const wired = tools.map((t) => ({
|
|
910
|
+
...t,
|
|
911
|
+
execute: async (/** @type {any} */ a) => { const r = await t.execute(a); loop.stop(); return r; },
|
|
912
|
+
}));
|
|
913
|
+
return loop.run(messages, wired, { cacheMessages: true, maxTokens, ...opts });
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ── 6. THE FLOW ──────────────────────────────────────────────────────────────
|
|
918
|
+
|
|
919
|
+
/** key-order-insensitive structural identity, for the early stop @param {any} x */
|
|
920
|
+
function canonical(x) {
|
|
921
|
+
if (x === null || typeof x !== 'object') return JSON.stringify(x) ?? 'null';
|
|
922
|
+
if (Array.isArray(x)) return `[${x.map(canonical).join(',')}]`;
|
|
923
|
+
return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${canonical(x[k])}`).join(',')}}`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* ONE structured ask, with the malformed-emission retry bounded on its OWN axis.
|
|
928
|
+
* A reply that carries no declaration is an ARTIFACT-RED — name the axis, retry
|
|
929
|
+
* under a cap, write nothing — and it never consumes a revision, because a
|
|
930
|
+
* transport fault is not a defect in a close.
|
|
931
|
+
* @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
|
|
932
|
+
* label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>}} o
|
|
933
|
+
*/
|
|
934
|
+
async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue }) {
|
|
935
|
+
/** @type {any[]} */
|
|
936
|
+
const convo = [...messages];
|
|
937
|
+
let attempts = 0;
|
|
938
|
+
/** @type {Red|null} */
|
|
939
|
+
let red = null;
|
|
940
|
+
|
|
941
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
942
|
+
/** @type {{calls: any[]}} */
|
|
943
|
+
const box = { calls: [] };
|
|
944
|
+
const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
|
|
945
|
+
const r = await generate(convo, tools, {});
|
|
946
|
+
attempts += 1;
|
|
947
|
+
book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
|
|
948
|
+
const raw = redactSecrets(String(r?.text ?? ''));
|
|
949
|
+
|
|
950
|
+
if (r?.error) {
|
|
951
|
+
return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (mode === 'tool') {
|
|
955
|
+
if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null };
|
|
956
|
+
red = {
|
|
957
|
+
code: 'artifact-red',
|
|
958
|
+
path: DECLARATION_TOOL_NAME,
|
|
959
|
+
detail: box.calls.length === 0
|
|
960
|
+
? `the reply delivered no ${DECLARATION_TOOL_NAME} call — structure is enforced, prose is never parsed`
|
|
961
|
+
: `the reply delivered ${box.calls.length} ${DECLARATION_TOOL_NAME} calls; exactly one declaration is expected`,
|
|
962
|
+
axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
|
|
963
|
+
};
|
|
964
|
+
} else {
|
|
965
|
+
// THE FALLBACK PATH — providers without tool mode only. The ONE shipped
|
|
966
|
+
// parser, never a second one.
|
|
967
|
+
const { code, red: extractRed } = extractArtifact(raw);
|
|
968
|
+
if (code) {
|
|
969
|
+
try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null }; }
|
|
970
|
+
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
|
+
} else {
|
|
972
|
+
red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (attempt < retries) {
|
|
977
|
+
const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
|
|
978
|
+
convo.push({ role: 'assistant', content: raw.trim() || '(the reply carried no text)' });
|
|
979
|
+
convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return { declaration: null, attempts, convo, raw: '', providerError: null, red };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* THE AUTHORING FLOW.
|
|
987
|
+
*
|
|
988
|
+
* Everything cheap happens BEFORE any token is spent: an ABSENT survey, an
|
|
989
|
+
* unreadable listing, an unsupported genre and a genre variable that cannot be
|
|
990
|
+
* built are all $0 refusals, because a paid call that was always going to be
|
|
991
|
+
* thrown away is a paid call nobody needed.
|
|
992
|
+
*
|
|
993
|
+
* The return is deliberately complete rather than tidy:
|
|
994
|
+
* - `ok` is true iff a declaration passed validation and was measured;
|
|
995
|
+
* - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
|
|
996
|
+
* that will run and that the signature will cover;
|
|
997
|
+
* - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
|
|
998
|
+
* `reds` means the final revision was rejected and the previous close stands:
|
|
999
|
+
* the rejection is reported, never hidden, and never silently shipped either;
|
|
1000
|
+
* - `iterations` carries every intermediate declaration, validation, seed read
|
|
1001
|
+
* and attempt count, because M4 emits them and a human signs against them;
|
|
1002
|
+
* - `raws` carries what every model call actually SAID — the scout's attempts
|
|
1003
|
+
* and the declaration's, scrubbed and bounded through the one persist
|
|
1004
|
+
* helper, in the cost book's own order and under its own labels. It is
|
|
1005
|
+
* present on EVERY path including the $0 preflight refusals, because the
|
|
1006
|
+
* path that spends nothing more is exactly the one whose evidence used to
|
|
1007
|
+
* vanish with the process.
|
|
1008
|
+
*
|
|
1009
|
+
* No spine emission here (M4 wires events).
|
|
1010
|
+
*
|
|
1011
|
+
* @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
|
|
1012
|
+
* answers: Record<string|number, string>, questions?: Record<string|number, string>,
|
|
1013
|
+
* scout: {state: string, facts: any, reason?: string|null, calls?: any[], raws?: any[]},
|
|
1014
|
+
* listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
|
|
1015
|
+
* generate: Function, seedReadFn?: Function, closeCtx?: any,
|
|
1016
|
+
* maxRevisions?: number, structureRetries?: number,
|
|
1017
|
+
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
|
|
1018
|
+
*/
|
|
1019
|
+
export async function authorClose({
|
|
1020
|
+
workdir, seedRef, lang, verdictType,
|
|
1021
|
+
answers, questions = GREEN_QUESTIONS,
|
|
1022
|
+
scout, listing = null,
|
|
1023
|
+
generate, seedReadFn = runSeedReadStages, closeCtx = {},
|
|
1024
|
+
maxRevisions = MAX_REVISIONS,
|
|
1025
|
+
structureRetries = MAX_STRUCTURE_RETRIES,
|
|
1026
|
+
structuredMode = 'tool',
|
|
1027
|
+
catalogue = KIND_CATALOGUE,
|
|
1028
|
+
}) {
|
|
1029
|
+
// TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
|
|
1030
|
+
// revising launders thrash as adaptation (addendum 5). The floor is zero: one
|
|
1031
|
+
// authoring call with no revise round is a legal ask, and a garbage value is
|
|
1032
|
+
// the floor rather than a negative bound the loop never enters.
|
|
1033
|
+
const revisionCap = Math.max(0, Math.min(Math.trunc(Number(maxRevisions)) || 0, MAX_REVISIONS));
|
|
1034
|
+
// TIGHTEN-ONLY on the OTHER axis too, for the identical reason. `structureRetries`
|
|
1035
|
+
// buys provider calls exactly as `maxRevisions` does, and it rode straight through
|
|
1036
|
+
// to `askDeclaration` unclamped — a caller handing 50 got 50 paid malformed-emission
|
|
1037
|
+
// retries above a ceiling this module declares and the suite pins. No new number:
|
|
1038
|
+
// the bound is `MAX_STRUCTURE_RETRIES`, the constant already documented as the
|
|
1039
|
+
// ceiling. Floor 0, exactly as above: `askDeclaration` loops `attempt <= retries`,
|
|
1040
|
+
// so 0 is still ONE attempt with no retry — a legal tighter ask, not an absent one.
|
|
1041
|
+
const retryCap = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_STRUCTURE_RETRIES));
|
|
1042
|
+
const book = makeCostBook();
|
|
1043
|
+
// The scout's calls AND its raws, together. The raws matter most on the path
|
|
1044
|
+
// that spends nothing more: run mslhn707 refused at the $0 preflight below,
|
|
1045
|
+
// and the survey text that would have said WHY died with the process.
|
|
1046
|
+
book.absorb(scout?.calls ?? [], scout?.raws ?? []);
|
|
1047
|
+
/** @type {any} */
|
|
1048
|
+
const base = {
|
|
1049
|
+
ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
|
|
1050
|
+
raws: book.raws(),
|
|
1051
|
+
facts: null, listing: null, genreEnv: null, finalFrom: null, stop: null, revisions: 0,
|
|
1052
|
+
};
|
|
1053
|
+
const refuse = (/** @type {Red[]} */ reds, /** @type {string} */ stop) => ({ ...base, reds, stop, cost: book.report(), raws: book.raws() });
|
|
1054
|
+
|
|
1055
|
+
// ── $0 preflight ──────────────────────────────────────────────────────────
|
|
1056
|
+
// F59, in the shape that bites: `{}` is the scout not completing, never "no
|
|
1057
|
+
// special facts are needed". This one lands in a SIGNED artefact.
|
|
1058
|
+
if (scout?.state !== 'PRESENT' || !scout.facts || typeof scout.facts !== 'object'
|
|
1059
|
+
|| Object.keys(scout.facts).length === 0) {
|
|
1060
|
+
return refuse([{
|
|
1061
|
+
code: 'scout-absent',
|
|
1062
|
+
path: 'scout',
|
|
1063
|
+
// `scout.reason` is half survey bookkeeping and half the scout's own error
|
|
1064
|
+
// string (its `meta.error`), so provider prose reaches a KEPT red here —
|
|
1065
|
+
// scrubbed at the emission boundary like every other channel that quotes
|
|
1066
|
+
// something this module does not own
|
|
1067
|
+
detail: `the survey is ${scout?.state ?? 'missing'}${scout?.reason ? ` (${redactSecrets(scout.reason)})` : ''} — an absent facts `
|
|
1068
|
+
+ 'object is never "no special facts are needed", and there is nothing to author from',
|
|
1069
|
+
}], 'precheck');
|
|
1070
|
+
}
|
|
1071
|
+
const facts = scout.facts;
|
|
1072
|
+
base.facts = facts;
|
|
1073
|
+
|
|
1074
|
+
// THE PICKED CLASS, checked at $0 before the language: the battery attaches to
|
|
1075
|
+
// the CLASS (PRD v1.57 §2), so a class v1 does not build has no battery to
|
|
1076
|
+
// attach — and a close authored without the guards it cannot supply is exactly
|
|
1077
|
+
// what D5 exists to make impossible. `verb` rides as a structured field so the
|
|
1078
|
+
// caller can carry this to the counted admission path rather than re-deriving
|
|
1079
|
+
// the demand from prose.
|
|
1080
|
+
if (!LIVE_CLASSES.includes(String(verdictType))) {
|
|
1081
|
+
const known = VERDICT_CLASSES.includes(String(verdictType));
|
|
1082
|
+
return refuse([{
|
|
1083
|
+
code: 'class-unsupported',
|
|
1084
|
+
path: 'verdictType',
|
|
1085
|
+
verb: String(verdictType),
|
|
1086
|
+
lib: 'bareloop',
|
|
1087
|
+
detail: known
|
|
1088
|
+
? `"${verdictType}" is a named but LOCKED verdict class — v1 admits ${LIVE_CLASSES.join(', ')} only, and a `
|
|
1089
|
+
+ 'class with no guard battery must refuse rather than author a close without the guards it cannot supply'
|
|
1090
|
+
: `"${verdictType}" is not a verdict class — the menu is ${VERDICT_CLASSES.join(' | ')}, and nothing derives `
|
|
1091
|
+
+ 'a class from the answers',
|
|
1092
|
+
}], 'precheck');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (!GENRE_LANGUAGES.includes(lang)) {
|
|
1096
|
+
return refuse([{
|
|
1097
|
+
code: 'genre-unsupported',
|
|
1098
|
+
path: 'lang',
|
|
1099
|
+
verb: 'genre-other',
|
|
1100
|
+
lib: 'bareloop',
|
|
1101
|
+
detail: `no TYPES genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}. A genre with no `
|
|
1102
|
+
+ 'guard battery must refuse, never author a close without the guards it cannot supply',
|
|
1103
|
+
}], 'precheck');
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const seeds = listing ?? await buildSeedListing({
|
|
1107
|
+
workdir, seedRef, sourcePaths: facts.sourcePaths, testPaths: facts.testPaths,
|
|
1108
|
+
});
|
|
1109
|
+
const seedFiles = Array.isArray(seeds.files) ? seeds.files : null;
|
|
1110
|
+
if (seeds.stop !== null || seedFiles === null || seedFiles.length === 0) {
|
|
1111
|
+
return refuse([{
|
|
1112
|
+
code: seeds.stop ? 'listing-unreadable' : 'listing-absent',
|
|
1113
|
+
path: 'listing',
|
|
1114
|
+
// `stop` is git's own stderr (kinds.js builds it from the subprocess);
|
|
1115
|
+
// scrubbed HERE, at the emission boundary, because this red LEAVES the flow
|
|
1116
|
+
// and is kept — M1 never scrubs, the caller does
|
|
1117
|
+
detail: seeds.stop != null
|
|
1118
|
+
? redactSecrets(seeds.stop)
|
|
1119
|
+
: 'the seed listing is empty — without it nothing can tell a real path from an invented one',
|
|
1120
|
+
}], 'precheck');
|
|
1121
|
+
}
|
|
1122
|
+
base.facts = facts;
|
|
1123
|
+
base.listing = seeds;
|
|
1124
|
+
|
|
1125
|
+
const guards = classGuards({ verdictType, lang });
|
|
1126
|
+
const ownedEnvNames = genreOwnedEnvNames(lang);
|
|
1127
|
+
// the source prefixes the genre's environment is built from, filtered against
|
|
1128
|
+
// the REAL tree — a prefix that names nothing would build a variable pointing
|
|
1129
|
+
// at nothing, and a symlink plus the directory behind it would name ONE tree
|
|
1130
|
+
// twice (see resolveSourcePrefixes: that is a FATAL mypy exit, not a warning)
|
|
1131
|
+
const sourcePrefixes = resolveSourcePrefixes({ workdir, sourcePaths: facts.sourcePaths, seedFiles });
|
|
1132
|
+
const envProbe = applyGenreEnv({ stages: [] }, lang, { sourcePrefixes, catalogue });
|
|
1133
|
+
base.genreEnv = { applied: envProbe.applied, owned: ownedEnvNames, missing: envProbe.missing, dropped: [] };
|
|
1134
|
+
if (envProbe.reds.length) return refuse(envProbe.reds, 'precheck');
|
|
1135
|
+
|
|
1136
|
+
// ── the grounded loop ─────────────────────────────────────────────────────
|
|
1137
|
+
const prompt = authorPrompt({
|
|
1138
|
+
answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
|
|
1139
|
+
lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
|
|
1140
|
+
});
|
|
1141
|
+
/** @type {any[]} */
|
|
1142
|
+
let messages = [{ role: 'user', content: prompt }];
|
|
1143
|
+
/** @type {any[]} */
|
|
1144
|
+
const iterations = [];
|
|
1145
|
+
/** @type {any} */
|
|
1146
|
+
let previous = null;
|
|
1147
|
+
/** @type {any} */
|
|
1148
|
+
let accepted = null;
|
|
1149
|
+
/** @type {any[]|null} */
|
|
1150
|
+
let acceptedSeedRead = null;
|
|
1151
|
+
/** @type {string|null} */
|
|
1152
|
+
let finalFrom = null;
|
|
1153
|
+
/** @type {{ok: boolean, reds: Red[]}|null} */
|
|
1154
|
+
let lastValidation = null;
|
|
1155
|
+
/** @type {Red[]} */
|
|
1156
|
+
let fatal = [];
|
|
1157
|
+
let stop = 'max-revisions';
|
|
1158
|
+
/** @type {{stage: string, name: string}[]} */
|
|
1159
|
+
let droppedEnv = [];
|
|
1160
|
+
|
|
1161
|
+
const seedTrees = makeSeedTrees();
|
|
1162
|
+
try {
|
|
1163
|
+
for (let i = 0; i <= revisionCap; i += 1) {
|
|
1164
|
+
const label = i === 0 ? 'author' : `revise-${i}`;
|
|
1165
|
+
const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
|
|
1166
|
+
messages = ask.convo;
|
|
1167
|
+
/** @type {any} */
|
|
1168
|
+
const iter = {
|
|
1169
|
+
call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
|
|
1170
|
+
artifactRed: ask.red ?? null, providerError: ask.providerError ?? null,
|
|
1171
|
+
validation: null, seedRead: null, reviseTurn: null,
|
|
1172
|
+
};
|
|
1173
|
+
iterations.push(iter);
|
|
1174
|
+
|
|
1175
|
+
if (ask.providerError) {
|
|
1176
|
+
// a transport failure is a casualty, never evidence about the model — and
|
|
1177
|
+
// the transport's own prose (a proxy URL, an auth echo) is scrubbed on the
|
|
1178
|
+
// way into a red the caller keeps
|
|
1179
|
+
fatal = [{ code: 'provider-red', path: label, detail: redactSecrets(ask.providerError) }];
|
|
1180
|
+
stop = 'provider-red';
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
if (!ask.declaration) {
|
|
1184
|
+
fatal = [/** @type {Red} */ (ask.red)];
|
|
1185
|
+
stop = 'artifact-red';
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// early stop: an unchanged declaration has the previous iteration's
|
|
1190
|
+
// measurements by definition — re-executing it buys nothing and costs a
|
|
1191
|
+
// full close
|
|
1192
|
+
if (previous && canonical(ask.declaration) === canonical(previous)) {
|
|
1193
|
+
iter.validation = '(identical declaration — see the previous iteration)';
|
|
1194
|
+
iter.seedRead = '(identical declaration — see the previous iteration)';
|
|
1195
|
+
stop = 'unchanged';
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
previous = ask.declaration;
|
|
1199
|
+
|
|
1200
|
+
const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType });
|
|
1201
|
+
// SCRUBBED HERE, once, where the reds enter this module's records — the same
|
|
1202
|
+
// boundary rule `renderRejectBlock` already states, applied to the OTHER
|
|
1203
|
+
// channel they travel down. A validation red quotes what the model declared
|
|
1204
|
+
// verbatim (`cmd-denied` carries the whole `cmd`, the genre-env branch carries
|
|
1205
|
+
// the declared value, the listing rule carries the path), and these copies are
|
|
1206
|
+
// PERSISTED: they ride `iterations[].validation` and this function's returned
|
|
1207
|
+
// `reds` into `authored.json` and into the spine (`emit('job-red', …)`), which
|
|
1208
|
+
// is append-only — a record that captures a key captures it forever. The
|
|
1209
|
+
// model-facing render was masked and the persisted twin was not.
|
|
1210
|
+
const vReds = v.reds.map(scrubRed);
|
|
1211
|
+
iter.validation = { ok: v.ok, reds: vReds, scoped: v.scoped };
|
|
1212
|
+
lastValidation = { ok: v.ok, reds: vReds };
|
|
1213
|
+
|
|
1214
|
+
/** @type {string} */
|
|
1215
|
+
let measured;
|
|
1216
|
+
if (!v.ok) {
|
|
1217
|
+
// a validation red IS a measurement of the declaration, so it rides the
|
|
1218
|
+
// same channel and counts as a revision (addendum 5)
|
|
1219
|
+
measured = renderRejectBlock({ kind: 'validation', reds: vReds });
|
|
1220
|
+
} else {
|
|
1221
|
+
// what gets MEASURED is what will RUN: the resolved declaration with the
|
|
1222
|
+
// genre's environment already injected
|
|
1223
|
+
const injected = applyGenreEnv(v.declaration, lang, { sourcePrefixes, catalogue });
|
|
1224
|
+
droppedEnv = injected.dropped;
|
|
1225
|
+
// `gapKeep` defaults to EMPTY rather than being left undefined: M1
|
|
1226
|
+
// prefixes every gap line with it verbatim, so an unset one prints the
|
|
1227
|
+
// word "undefined" on every failure line the model is asked to read
|
|
1228
|
+
const rows = await seedReadFn(injected.declaration, { gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees });
|
|
1229
|
+
iter.seedRead = rows;
|
|
1230
|
+
accepted = injected.declaration;
|
|
1231
|
+
acceptedSeedRead = rows;
|
|
1232
|
+
finalFrom = label;
|
|
1233
|
+
measured = renderSeedReadBlock(rows);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (i === revisionCap) { stop = 'max-revisions'; break; }
|
|
1237
|
+
|
|
1238
|
+
const turn = buildReviseTurn(measured);
|
|
1239
|
+
// A TRIPWIRE, not behaviour: it can only fire once something else is
|
|
1240
|
+
// already broken, so it is the one guard in this module the mutation
|
|
1241
|
+
// battery cannot kill — removing it changes no output today. It is kept
|
|
1242
|
+
// deliberately, and what the suite pins instead is the invariant it
|
|
1243
|
+
// protects: the bytes leaving this loop are exactly
|
|
1244
|
+
// `buildReviseTurn(renderSeedReadBlock(rows))`, so a future edit that
|
|
1245
|
+
// appends one helpful sentence here reds the suite either way.
|
|
1246
|
+
assertReviseTurn(turn, measured);
|
|
1247
|
+
iter.reviseTurn = { bytes: Buffer.byteLength(turn) };
|
|
1248
|
+
messages = [...messages, { role: 'assistant', content: ask.raw.trim() || '(the reply carried no text)' }, { role: 'user', content: turn }];
|
|
1249
|
+
}
|
|
1250
|
+
} finally {
|
|
1251
|
+
await seedTrees.cleanup();
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
|
|
1255
|
+
return {
|
|
1256
|
+
// `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
|
|
1257
|
+
// LATER revise call does not invalidate a close that was already validated
|
|
1258
|
+
// and already measured — a casualty is never evidence (F45), and throwing a
|
|
1259
|
+
// paid, measured close away over a transport fault is the wrong direction.
|
|
1260
|
+
// Why the loop ended is never hidden: `stop` names it and `reds` carries it.
|
|
1261
|
+
ok: accepted !== null,
|
|
1262
|
+
declaration: accepted,
|
|
1263
|
+
seedRead: acceptedSeedRead,
|
|
1264
|
+
iterations,
|
|
1265
|
+
reds,
|
|
1266
|
+
cost: book.report(),
|
|
1267
|
+
// WHAT THE MODEL ACTUALLY SAID, every call of it, on the run's own audit
|
|
1268
|
+
// trail — the scout's attempts and the declaration's, in call order, under
|
|
1269
|
+
// the same labels the cost book meters. `iterations` records what each turn
|
|
1270
|
+
// MEANT (declaration, validation, seed read); this records what it SAID, and
|
|
1271
|
+
// a malformation is only ever visible in the second.
|
|
1272
|
+
raws: book.raws(),
|
|
1273
|
+
facts,
|
|
1274
|
+
listing: seeds,
|
|
1275
|
+
genreEnv: { ...base.genreEnv, dropped: droppedEnv },
|
|
1276
|
+
finalFrom,
|
|
1277
|
+
stop,
|
|
1278
|
+
revisions: Math.max(0, iterations.length - 1),
|
|
1279
|
+
};
|
|
1280
|
+
}
|