bareloop 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,801 @@
1
+ // THE COMPOSITION — close-authoring v1, module M4b (design record 2026-08-07,
2
+ // FROZEN; gate-2 POC closed 2026-08-08, addendum 6).
3
+ //
4
+ // M1 runs a stage. M2 says what a declaration may say. M3 looks at the repository
5
+ // and gets a declaration out of a model. M4a bridges the declared close to the
6
+ // arbiter's runtime. This module is the part a PERSON meets: the interview, the
7
+ // refusal when we cannot do their job, and the assembly of everything into a job
8
+ // spec with a hash for them to sign.
9
+ //
10
+ // ── D10: the interview runs INSIDE bareloop ─────────────────────────────────
11
+ //
12
+ // A product feature, not a Claude Code workflow — hamr confirmed it, and it is
13
+ // the answer to the premise this whole rung serves: *"the premise of bareloop is
14
+ // that user might not and mostly isn't swe to write and bareloop solves the whole
15
+ // thing."*
16
+ //
17
+ // It is a PURE FUNCTION over answers, and there is no prompt loop in this file.
18
+ // The UI or the CLI collects the picked verdict class and that class's own frozen
19
+ // questions and hands them over, the same way `runJob` takes a spec rather than
20
+ // asking for one. That is what lets the interview be tested without a terminal
21
+ // and reused by a web panel later.
22
+ //
23
+ // ── THE VERDICT CLASS IS THE USER'S ANSWER (PRD v1.57 §1) ───────────────────
24
+ //
25
+ // D4 (*"verdictType is DERIVED from the answers, never picked"*) is SUPERSEDED
26
+ // in full. The green / soft-green / hitl radio of the 2026-07-21 Layer 2 design
27
+ // is restored: the user picks the class, and that choice DRIVES the authoring
28
+ // rather than falling out of it. Whose knowledge it is settles it — the user is
29
+ // the one who knows whether their *done* is machine-checkable, needs judgment,
30
+ // or needs a person, and a derivation infers that from answers given before the
31
+ // question was asked.
32
+ //
33
+ // The field is what `src/job.js` already calls it: a declared radio the preflight
34
+ // VALIDATES, never infers. It arrives as STRUCTURED input beside the answers, the
35
+ // same way `repoPath` does — a closed set handed over enumerated, never read out
36
+ // of prose.
37
+ //
38
+ // v1 STILL ADMITS ONLY `green`. A soft-green or hitl pick returns the honest
39
+ // counted refusal on the `request-red` admission path (D13's mechanism), now
40
+ // carrying demand for a VERDICT CLASS rather than only for a genre. It refuses
41
+ // at ADMISSION — before that class's questions are ever asked, because those two
42
+ // question sets are named but locked (`QUESTION_SETS`, M3).
43
+ //
44
+ // AND THE INTERVIEW KEYS ON THE CLASS. Three frozen question sets replace one
45
+ // set per genre: genres are a fat tail that cannot be enumerated, classes are
46
+ // exactly three. D13's genre-CONFIRM slot goes with the change — the interview
47
+ // never asks about a genre again. The refusal it carried MOVED to the composer
48
+ // (below): a job the catalogue cannot measure refuses there, on the same counted
49
+ // path. D13's one-genre-at-a-time law survives; only the question is gone.
50
+ //
51
+ // TWO PRECONDITIONS STILL DECIDE WHETHER A GREEN PICK CAN BE HONOURED, and both
52
+ // are mechanical, never a reading of prose:
53
+ //
54
+ // 1. there is a repository with a git seed, because all three of D9's validity
55
+ // gates rest on one (the precheck spawns commands in a workdir and the
56
+ // changed-set primitive is `git diff <seed>` — a doc or a website has
57
+ // neither, which is D13's own reason for putting them out of v1);
58
+ // 2. and, at signing, the close actually has WORK to do (D9.3).
59
+ //
60
+ // A green pick that fails either one REFUSES, counted. It never quietly authors
61
+ // a close for a job it cannot close, which is the failure D13's forward-compat
62
+ // point 1 names: resolving a judgment job to a command close would defeat
63
+ // `CLASS_BY_CLOSE`/`CLASS_BY_VERDICT` from ABOVE, by never letting the illegal
64
+ // pair be formed. The ceiling rule (M2's `closeCeiling`) is the same guard one
65
+ // layer down: the pick is a PROMISE, and a composition may only use kinds at or
66
+ // below it.
67
+ //
68
+ // ── D13: honest refusal, with the lib stamped HERE ──────────────────────────
69
+ //
70
+ // Every refusal rides the `request-red` admission path, so demand for a genre, a
71
+ // verdict class or a locked kind is COUNTED evidence rather than a silent drop.
72
+ // `lib: 'bareloop'` is stamped at THIS emit site, never inferred downstream: the
73
+ // refused catalogue is OURS, and filing it against a bare-suite package is the
74
+ // BA-2 misattribution the typed-lib rule exists to prevent.
75
+ //
76
+ // The design record names this defect as live in `src/ledger.js` and explicitly
77
+ // leaves it unfixed there ("named here, not fixed here"). Measured against the
78
+ // tree rather than the record: it was already closed on `main` at 1b4bb77 —
79
+ // `classifyIncidents` reads `ev.lib ?? 'bare-agent'` and the `request-red` ASK
80
+ // template renders `${o.lib}`, both stamped-first. What M4 owes is therefore not
81
+ // the fix but the PROOF that the new emit site routes through it, which the suite
82
+ // pins end to end (emit → classify → suggestedAsk).
83
+ //
84
+ // ── D6/D7: authored once, signed whole ──────────────────────────────────────
85
+ //
86
+ // `prepareSigning` runs D9's three mechanical gates and hands back the resolved
87
+ // spec plus its hash. It never signs: the approvals array and the human word are
88
+ // the arbiter relocating to the user, not disappearing. Re-authoring produces a
89
+ // different declaration, therefore a different resolved hash, therefore a new
90
+ // signature — that falls out of `jobSpecHash` because the guards are ENUMERATED
91
+ // in the stored declaration (forward-compat point 3), and the suite pins it.
92
+
93
+ import { jobSpecHash, validateJob } from './job.js';
94
+ import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees } from './kinds.js';
95
+ import {
96
+ GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
97
+ } from './authoring.js';
98
+ import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose } from './authorflow.js';
99
+ import { runAuthorScout, buildSeedListing } from './authorscout.js';
100
+ import {
101
+ DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
102
+ } from './declaredclose.js';
103
+ import { isObj, isNonEmptyString, redactSecrets } from './validate.js';
104
+
105
+ /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
106
+ /** @typedef {{kind: 'request-red'|'decision-ready', verb: string|null, path: string,
107
+ * detail: string, options: string[], red: Red|null}} Refusal */
108
+
109
+ /** the one genre v1 admits (D13). The interview no longer ASKS about it — the
110
+ * catalogue is one genre wide and the composer is where that refuses — but the
111
+ * signed `closeDecl` still names the genre it was authored under. */
112
+ export const GENRE = TYPES_GENRE.name;
113
+ /** the three frozen interview sets and their readers, re-exported from where
114
+ * they live (M3) so a caller needs one import and there is still only one copy
115
+ * of the questions */
116
+ export { QUESTION_SETS, questionsFor, requiredAnswersFor };
117
+ /** THE RADIO. A closed set, handed over enumerated rather than as prose with a
118
+ * rule about it: illegal becomes inexpressible instead of rejected after the
119
+ * fact (the `check-passes(name)` model). A UI renders it as three buttons, and
120
+ * two of them are marked locked. */
121
+ export { VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES };
122
+
123
+ /** The territory every refusal here lands against. bareloop's OWN catalogue —
124
+ * the genre menu, the verdict menu, the kind menu — never a bare-suite package.
125
+ * Stamped at the emit site (the typed-lib rule). */
126
+ export const REFUSAL_LIB = 'bareloop';
127
+ /** the escalation category a refusal escalates under. It is DELIBERATELY in the
128
+ * ledger's executable excluded set: the demand is already counted once as a
129
+ * `request-red`, and counting the escalation too would inflate the very number
130
+ * the admission path exists to measure. */
131
+ export const REFUSAL_CATEGORY = 'close-unauthorable';
132
+
133
+ /** @type {string[]} */
134
+ const LEVERS = ([
135
+ 'describe a job bareloop can close today: one whose done is machine-checkable, on a git repository',
136
+ 'wait for the verdict-classes rung — this refusal is the evidence it waits on',
137
+ ]);
138
+
139
+ /**
140
+ * One refusal, in the shape both the caller and the ledger need.
141
+ * @param {{verb: string|null, path: string, detail: string, kind?: 'request-red'|'decision-ready', options?: string[]}} o
142
+ * @returns {Refusal}
143
+ */
144
+ function refuse({ verb, path, detail, kind = 'request-red', options = LEVERS }) {
145
+ return {
146
+ kind,
147
+ verb,
148
+ path,
149
+ detail,
150
+ options,
151
+ // The structured fields the ledger keys admission on — never prose. `lib` is
152
+ // stamped HERE, at the emit site, for the reason src/job.js stamps its own:
153
+ // one code, two territories, and inferring which one downstream is how a
154
+ // bareloop refusal becomes an upstream bug report.
155
+ red: kind === 'request-red'
156
+ ? { code: 'request-red', path, detail, verb: /** @type {string} */ (verb), lib: REFUSAL_LIB }
157
+ : null,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * The spine events a refusal produces — returned rather than emitted, because
163
+ * this module owns no spine (the runner shell does, exactly as with `runJob`).
164
+ *
165
+ * The `job-red` carries the structured `verb`/`lib`, which is the channel
166
+ * `classifyIncidents` already reads; the escalation carries the human's levers
167
+ * and is EXCLUDED from classification so the same demand is not counted twice.
168
+ * @param {Refusal} refusal
169
+ * @returns {{type: string, [k: string]: any}[]}
170
+ */
171
+ export function refusalEvents(refusal) {
172
+ /** @type {{type: string, [k: string]: any}[]} */
173
+ const events = [];
174
+ if (refusal.red) events.push({ type: 'job-red', ...refusal.red });
175
+ events.push({
176
+ type: 'escalation',
177
+ category: REFUSAL_CATEGORY,
178
+ decisionReady: true,
179
+ decision: refusal.detail,
180
+ options: refusal.options,
181
+ });
182
+ return events;
183
+ }
184
+
185
+ // ── 1. THE INTERVIEW ─────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Read the picked verdict class and that class's own frozen answers. Pure: no
189
+ * model, no repository, no clock.
190
+ *
191
+ * Four outcomes, and the middle two are the ones that matter:
192
+ * - `ok` — a class v1 builds, every answer present, a repository named;
193
+ * - a CLASS REFUSAL — the user picked soft-green or hitl. Counted demand for a
194
+ * VERDICT CLASS, refused at ADMISSION, before that class's questions run
195
+ * (they are named but locked, and asking them would be an interview for a
196
+ * job nothing here can close);
197
+ * - a REPO REFUSAL — a green pick with nothing deterministic to measure
198
+ * against. Counted demand too;
199
+ * - plain `reds` — the interview is not finished, or the radio was not set.
200
+ * NOT demand: an incomplete form is not a user asking for a capability, and
201
+ * an unknown class is a typo, never a request (the same split `src/job.js`
202
+ * makes between a locked verdict and an invalid one).
203
+ *
204
+ * @param {{answers: Record<string|number, any>, verdictType?: string|null,
205
+ * repoPath?: string|null, questions?: Record<string|number, string>|null}} o
206
+ * `verdictType` and `repoPath` are STRUCTURED input from the UI, never parsed
207
+ * out of the prose of an answer: a class is picked off a radio and a path is
208
+ * read off the machine, or neither exists.
209
+ * @returns {{ok: boolean, answers: Record<string|number, string>,
210
+ * verdictType: string|null, repoPath: string|null, refusal: Refusal|null, reds: Red[]}}
211
+ */
212
+ export function runInterview({ answers, verdictType = null, repoPath = null, questions = null }) {
213
+ /** @type {Red[]} */
214
+ const reds = [];
215
+ const out = (/** @type {Partial<any>} */ over) => ({
216
+ ok: false, answers: {}, verdictType: null, repoPath: null, refusal: null, reds, ...over,
217
+ });
218
+
219
+ // ── THE RADIO, first. It DRIVES everything below it: which questions get
220
+ // asked, which guard battery attaches, and whether this job is admissible at
221
+ // all. Nothing derives it and nothing defaults it.
222
+ const picked = String(verdictType);
223
+ if (!VERDICT_CLASSES.includes(picked)) {
224
+ reds.push({
225
+ code: verdictType === null || verdictType === undefined ? 'missing-field' : 'invalid-value',
226
+ path: 'verdictType',
227
+ detail: `the verdict class, picked by the person whose job this is: ${VERDICT_CLASSES.join(' | ')} — is your `
228
+ + 'definition of done machine-checkable, does it need judgment, or does it need a person? A closed set, '
229
+ + 'never inferred from the answers',
230
+ });
231
+ return out({});
232
+ }
233
+
234
+ // A LOCKED class refuses HERE, before its questions are asked. Counted demand
235
+ // for the class itself — the verdict-classes rung is what this evidence is for.
236
+ if (LOCKED_CLASSES.includes(picked)) {
237
+ return out({
238
+ refusal: refuse({
239
+ verb: picked,
240
+ path: 'verdictType',
241
+ detail: `You picked "${picked}", and v1 cannot close that yet: it admits ${LIVE_CLASSES.join(', ')} only — a `
242
+ + 'job whose done a command can decide. A judged score and a person\'s sign-off are named in the menu and '
243
+ + 'locked, so picking one is recorded as demand rather than dropped, and this refusal is the evidence the '
244
+ + 'verdict-classes rung waits on.',
245
+ }),
246
+ });
247
+ }
248
+
249
+ const set = questions ?? questionsFor(picked);
250
+ const required = requiredAnswersFor(picked);
251
+
252
+ if (!isObj(answers)) {
253
+ reds.push({ code: 'missing-field', path: 'answers', detail: 'the interview answers, keyed by question number' });
254
+ return out({});
255
+ }
256
+ /** @type {Record<string|number, string>} */
257
+ const given = {};
258
+ for (const n of required) {
259
+ const a = answers[n];
260
+ if (!isNonEmptyString(a)) {
261
+ reds.push({
262
+ code: 'missing-field',
263
+ path: `answers.${n}`,
264
+ detail: `unanswered: ${set[n] ?? `question ${n}`}`,
265
+ });
266
+ continue;
267
+ }
268
+ // scrubbed at INGEST, not at emission: an answer is about to become a prompt
269
+ // ingredient, a spine record and a signed artefact all at once, and a log
270
+ // that captures a key captures it forever
271
+ given[n] = redactSecrets(a);
272
+ }
273
+ if (reds.length) return out({ answers: given });
274
+
275
+ // The green pick's own precondition: D1's "a repo is never a precondition"
276
+ // holds for the INTERVIEW and not for D9's validity gates, all three of which
277
+ // rest on a runnable patient with a git seed. A doc or a website job has no
278
+ // seed and no changed set, so nothing deterministic can be measured against it
279
+ // — that is softgreen/hitl territory, out of v1, refused on the same counted
280
+ // path. The class the user picked cannot be honoured, so it is not returned.
281
+ if (!isNonEmptyString(repoPath)) {
282
+ return out({
283
+ answers: given,
284
+ refusal: refuse({
285
+ verb: 'non-green-verdict',
286
+ path: 'repoPath',
287
+ detail: 'This job has no code repository, so there is no seed to measure against and no changed set to read — '
288
+ + 'nothing deterministic can decide whether it came back done. That needs a judged or a human close, and '
289
+ + 'both are declared-but-locked in v1.',
290
+ }),
291
+ });
292
+ }
293
+
294
+ return {
295
+ ok: true,
296
+ answers: given,
297
+ // ECHOED, never derived: this is the user's own answer, validated against the
298
+ // closed set and carried forward. The remaining precondition (D9.3 — the
299
+ // close actually has work to do) is measured at signing, and refuses there.
300
+ verdictType: picked,
301
+ repoPath,
302
+ refusal: null,
303
+ reds,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * The refusal for a user who asks for a LOCKED KIND by name — a judged score or
309
+ * a person's sign-off. Exposed because the tool-mode declaration schema makes a
310
+ * locked kind INEXPRESSIBLE (M3's header states the cost): the demand cannot
311
+ * arrive through the model, so it has to arrive through the interview layer, and
312
+ * this is that layer.
313
+ * @param {string} kind @returns {Refusal}
314
+ */
315
+ export function refuseLockedKind(kind) {
316
+ const known = LOCKED_KINDS.includes(kind);
317
+ return refuse({
318
+ verb: kind,
319
+ path: 'closeDecl.stages[].kind',
320
+ detail: known
321
+ ? `"${kind}" is a named but LOCKED stage kind — declaring it is recorded demand, never an admission; it does not `
322
+ + 'run in v1, and the verdict class it belongs to is locked with it.'
323
+ : `"${kind}" is not a stage kind bareloop owns — the catalogue is ${DECLARED_GENRES.join('|')}-genre kinds only, `
324
+ + 'and a kind we have not built is a hole in the product, recorded as one.',
325
+ });
326
+ }
327
+
328
+ /**
329
+ * The composer's own refusal, read off the authoring reds rather than re-derived.
330
+ *
331
+ * A `locked-kind` red is M2's counted demand for a catalogue entry v1 names but
332
+ * does not run — the demand is already stamped (`verb`, `lib`) at the emit site,
333
+ * so this reshapes it for the caller and never restates it. Every other authoring
334
+ * red is a defect or a casualty, not a user asking for a capability, and turning
335
+ * one into demand would inflate exactly the number the admission path measures.
336
+ * @param {Red[]} reds @returns {Refusal|null}
337
+ */
338
+ function composerRefusal(reds) {
339
+ const locked = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'locked-kind');
340
+ if (!locked) return null;
341
+ return refuseLockedKind(String(locked.kind ?? locked.verb ?? ''));
342
+ }
343
+
344
+ // ── 2. THE COMPOSITION ───────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Interview answers in, a signable close DECLARATION out.
348
+ *
349
+ * The order is the design's own, and every cheap refusal happens before any
350
+ * token: interview → seed → scout → listing → the grounded authoring loop (M3).
351
+ *
352
+ * `scoutFn`, `listingFn` and `seedFn` are seams so a caller can supply a survey
353
+ * it already paid for (the prereg reuses one across arms), and so the whole
354
+ * pipeline is testable without a provider. All three are REAL by default.
355
+ *
356
+ * THE GENRE REFUSAL LIVES HERE NOW (PRD v1.57 §2). D13's interview slot asked
357
+ * the user to confirm a genre; the interview no longer asks, and the refusal
358
+ * moved to the COMPOSER — a job the catalogue cannot measure refuses at this
359
+ * layer, on the same `request-red` path, still counted as demand. Two mechanical
360
+ * signals reach it and neither is a reading of prose: a language the genre owns
361
+ * no data for (so there is no guard battery to attach), and a LOCKED KIND the
362
+ * model reached for (the catalogue's own counted-demand red, carried up). D13's
363
+ * one-genre-at-a-time law is untouched.
364
+ *
365
+ * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
366
+ * verdictType?: string|null,
367
+ * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
368
+ * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
369
+ * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
370
+ * authorFn?: Function, authorOpts?: object}} o
371
+ * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
372
+ * closeDecl: any, seedRef: string|null, authoring: any, reds: Red[], stop: string|null,
373
+ * cost: any, interview: any}>}
374
+ */
375
+ export async function authorCloseForJob({
376
+ answers, repoPath = null, lang, verdictType = null, questions = null,
377
+ generate, provider = null, seedRef = null, scout = null, listing = null,
378
+ ceilingUsd = null,
379
+ seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
380
+ authorFn = authorClose, authorOpts = {},
381
+ }) {
382
+ /** @type {any} */
383
+ const base = {
384
+ ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
385
+ authoring: null, reds: [], stop: null, cost: null, interview: null,
386
+ };
387
+
388
+ const interview = runInterview({ answers, verdictType, repoPath, questions });
389
+ base.interview = interview;
390
+ if (!interview.ok) {
391
+ return { ...base, refusal: interview.refusal, reds: interview.reds, stop: interview.refusal ? 'refused' : 'interview-incomplete' };
392
+ }
393
+ const workdir = /** @type {string} */ (interview.repoPath);
394
+ const picked = /** @type {string} */ (interview.verdictType);
395
+
396
+ if (!GENRE_LANGUAGES.includes(lang)) {
397
+ // THE GENRE REFUSAL, at the composer. This used to be a plain wiring red;
398
+ // under v1.57 §2 the interview no longer asks the user to confirm a genre, so
399
+ // this is where "we can't run this kind of job yet" actually lands — and it
400
+ // is COUNTED demand rather than a silent drop, on the same `request-red`
401
+ // path the confirm slot used. The battery is the reason it must refuse at
402
+ // all: `classGuards` throws rather than hand back an empty one (F59's shape
403
+ // — absent must never read as "no guards needed").
404
+ return {
405
+ ...base,
406
+ refusal: refuse({
407
+ verb: 'genre-other',
408
+ path: 'lang',
409
+ detail: `We can't run this kind of job yet. The catalogue can measure one genre (${GENRE}: a type checker `
410
+ + `stops complaining without breaking the tests) in ${GENRE_LANGUAGES.join(' and ')}, and "${lang}" has no `
411
+ + 'guard battery — a genre with no battery must refuse rather than author a close without the guards it '
412
+ + 'cannot supply.',
413
+ }),
414
+ reds: [{
415
+ code: 'request-red',
416
+ path: 'lang',
417
+ verb: 'genre-other',
418
+ lib: REFUSAL_LIB,
419
+ detail: `no ${GENRE} genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}`,
420
+ }],
421
+ stop: 'refused',
422
+ };
423
+ }
424
+
425
+ // D8 — the seed, READ rather than typed.
426
+ let seed = seedRef;
427
+ if (!isNonEmptyString(seed)) {
428
+ const s = await seedFn(workdir);
429
+ if (s.stop !== null) {
430
+ // same git-stderr channel as `prepareSigning`'s, scrubbed at the same
431
+ // boundary — this red is returned to the caller and kept with the run
432
+ return { ...base, reds: [{ code: 'seed-unreadable', path: 'seedRef', detail: redactSecrets(s.stop) }], stop: 'precheck' };
433
+ }
434
+ seed = s.seedRef;
435
+ }
436
+ base.seedRef = seed;
437
+
438
+ // D11 — the scout LOOKS, the author WRITES, neither acts. An ABSENT survey is
439
+ // never "no special facts are needed" (F59), and `authorClose` refuses on it at
440
+ // $0, so nothing here has to second-guess the classification.
441
+ // ONE CEILING, BOTH PAID SEAMS. The survey and the declaration loop are two
442
+ // populations of paid call with two accumulators, and the operator set ONE
443
+ // number: handing it to both is what makes the advertised ceiling and the
444
+ // enforced ceiling the same ceiling (the standing hard line, in its money
445
+ // form). `null` travels EXPLICITLY rather than being left off — unbounded is a
446
+ // stated operator choice, and an absent field read as falsy is the same state
447
+ // arrived at by accident.
448
+ const survey = scout ?? await scoutFn({ workdir, provider, ceilingUsd });
449
+ const seeds = listing ?? await listingFn({
450
+ workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
451
+ });
452
+
453
+ const authored = await authorFn({
454
+ workdir, seedRef: seed, lang, verdictType: picked,
455
+ answers: interview.answers, questions: questions ?? questionsFor(picked),
456
+ scout: survey, listing: seeds, generate, ceilingUsd, ...authorOpts,
457
+ });
458
+
459
+ if (!authored.ok) {
460
+ return {
461
+ ...base,
462
+ authoring: authored, reds: authored.reds, stop: authored.stop, cost: authored.cost,
463
+ // the OTHER composer refusal: the model reached for a kind the catalogue
464
+ // names but does not run. M2 already stamped the counted red at the emit
465
+ // site; this carries it up in the shape the caller's ledger and terminal
466
+ // read, rather than leaving a locked-kind demand buried in a reds array.
467
+ refusal: composerRefusal(authored.reds),
468
+ };
469
+ }
470
+
471
+ // The genre's ENVIRONMENT, recorded from the flow's own applied map — never
472
+ // from the model, and never re-derived here. The flow injected it into every
473
+ // env-capable stage after the model's form passed (M3's `applyGenreEnv`), and
474
+ // from this point on every re-validation reads that key back: signing, the job
475
+ // validator, the runner. Without the record they cannot tell our injection from
476
+ // a model-authored guess, so they must refuse it — which is a python close that
477
+ // can never be signed, validated or run. Recorded ONLY when something was
478
+ // injected: absent is a genre that owns no environment (JS), and an empty
479
+ // object would read as "we looked and found none" for a genre that never looks.
480
+ const applied = isObj(authored.genreEnv?.applied) ? authored.genreEnv.applied : {};
481
+
482
+ // The class the USER picked, carried through unchanged — validated at the
483
+ // interview, honoured by the composition (M2's ceiling rule refuses a
484
+ // declaration above it), and stated here rather than re-derived. The remaining
485
+ // precondition (D9.3 — the close has work to do) is measured by
486
+ // `prepareSigning`, which refuses there.
487
+ return {
488
+ ok: true,
489
+ refusal: null,
490
+ verdictType: picked,
491
+ closeDecl: {
492
+ genre: GENRE,
493
+ lang,
494
+ stages: authored.declaration.stages,
495
+ ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
496
+ // `notes` is the one FREE-TEXT field the model writes straight into the
497
+ // signed spec, and this is that field's persist boundary. Scrubbed here
498
+ // for the same reason `scrubRed` scrubs uniformly a few lines down: every
499
+ // other model-authored string that survives this call rides the ONE
500
+ // redactor, and a signed spec outlives the run that produced it.
501
+ // `redactSecrets` returns a non-matching string byte-identical, so a note
502
+ // carrying nothing hashes exactly as it did — the mask costs the honest
503
+ // case nothing, which is the fail-safe direction.
504
+ ...(authored.declaration.notes?.length
505
+ ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
506
+ : {}),
507
+ },
508
+ seedRef: seed,
509
+ authoring: authored,
510
+ reds: authored.reds,
511
+ stop: authored.stop,
512
+ cost: authored.cost,
513
+ interview,
514
+ };
515
+ }
516
+
517
+ /** The fields `assembleSpec` writes, and therefore the fields a DRAFT must not
518
+ * carry. Named as data so the refusal and the fold can never disagree about
519
+ * which half of the spec is which. */
520
+ export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType']);
521
+
522
+ /**
523
+ * Fold an authored close into the OPERATOR's own half of the spec.
524
+ *
525
+ * The two halves never mix: budgets, the fence, cadence, escalation and the
526
+ * provider are operator input and are not authored by anything here (the
527
+ * permanent hard line). What this adds is the close, the derived verdict class,
528
+ * and nothing else.
529
+ *
530
+ * The GOAL is passed through, not generated. F87 requires the goal to state
531
+ * everything the close judges, and §3 of the design record says that becomes
532
+ * structural once the goal RENDERS from the same answers — that rendering is UI
533
+ * work (PRD v1.51 §5, "lands with the close-authoring interview UI") and is
534
+ * deliberately not invented here.
535
+ *
536
+ * A draft that ALREADY carries the authored half is REFUSED, not merged and not
537
+ * overwritten. `src/job.js` states the rule for the close in its own words — two
538
+ * closes are two arbiters, and "picking one silently is how the signed artefact
539
+ * stops meaning what the signer read" — and the same holds one step earlier and
540
+ * for the class: this fold used to drop a draft's `close` on the floor and stamp
541
+ * its own `verdictType` over the operator's, so a person who typed either would
542
+ * read back a signed spec that never contained what they wrote. Refusing by name
543
+ * costs the legitimate path nothing (a draft is the operator's half BY
544
+ * DEFINITION, and nothing in the pipeline builds one carrying these) and it is
545
+ * the same direction every other gate here takes: a mismatch a human cannot see
546
+ * is worse than a stop a human can read.
547
+ * @param {any} specDraft the operator's half (everything except close/closeDecl/verdictType)
548
+ * @param {{closeDecl: any, verdictType: string}} authored
549
+ * @throws {Error} when the draft already carries close, closeDecl or verdictType
550
+ */
551
+ export function assembleSpec(specDraft, { closeDecl, verdictType }) {
552
+ const draft = isObj(specDraft) ? specDraft : {};
553
+ const carried = AUTHORED_SPEC_FIELDS.filter((f) => draft[f] !== undefined);
554
+ if (carried.length) {
555
+ throw new Error(`the spec draft already carries ${carried.join(', ')} — that is the AUTHORED half of the spec, and `
556
+ + 'this fold writes it. Merging would drop or overwrite what the draft says, and a signed spec that does not '
557
+ + 'contain what its author typed is the one failure nobody can see. Remove the field(s) from the draft');
558
+ }
559
+ return { ...draft, verdictType, closeDecl };
560
+ }
561
+
562
+ // ── 3. D9's THREE GATES, AND THE HASH ────────────────────────────────────────
563
+
564
+ /**
565
+ * A validator red, SCRUBBED for persistence. Both declaration gates in
566
+ * `prepareSigning` hand back reds that quote things this module does not own —
567
+ * the declaration the model wrote (a `cmd`, an env value, a path) and the seed
568
+ * listing the arbiter read (the listing rule names what really sits beside an
569
+ * invented path). Those reds are written into the signing evidence, and a file
570
+ * that captures a key captures it forever.
571
+ *
572
+ * The scrub is at the EMISSION boundary, never inside the validators: they are
573
+ * shared with the model-facing rendering, which already scrubs on its own way
574
+ * out (`renderRejectBlock`), and a second scrub buried in the rule would be the
575
+ * hand-rolled copy the ONE inventory (SECRET_PATTERNS) exists to prevent.
576
+ *
577
+ * UNIFORM over every string-valued field, not a named list of them. `detail` is
578
+ * only the loudest echo: `cmd-denied` quotes the command AGAIN as a structured
579
+ * `cmd`, `path-not-in-listing` carries `declared`/`resolved`, and the next red
580
+ * to learn a field would be missed by an allowlist written today. `redactSecrets`
581
+ * returns a non-matching string byte-identical, so the enumerated and structural
582
+ * fields (`code`, `path`) pass through unchanged and the uniform map costs them
583
+ * nothing — the fail-safe direction, by construction.
584
+ * @param {Red} r @returns {Red}
585
+ */
586
+ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
587
+ Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
588
+ ));
589
+
590
+ /**
591
+ * D9, in code: *nothing JUDGES the close; three mechanical gates plus a
592
+ * signature.* No LLM validates another LLM's close, here or anywhere.
593
+ *
594
+ * 1. THE DECLARATION VALIDATOR — the spec's own gate plus M2's, run GROUNDED
595
+ * against the real seed listing (the job validator defers that half).
596
+ * 2. THE CLOSE PRECHECK — every stage runs against the real patient. A stage
597
+ * that cannot run is `broken-close`, and it is a CASUALTY, never a red.
598
+ * 3. THE SEED-VERDICT READ — every stage, offered or not, run at the seed. Which
599
+ * are RED (the work) and which are GREEN (the guards) is shown to the user.
600
+ * A close with NO work stage red at seed has nothing to do and is refused
601
+ * decision-ready — gate-2 round 2's arm B died exactly there, on a mypy that
602
+ * scanned nothing and therefore read clean.
603
+ *
604
+ * Gates 2 and 3 share ONE execution, and that is stated rather than hidden: at
605
+ * job creation the tree IS the seed, so "does every stage run against the real
606
+ * patient" and "what does every stage say at the seed" are two readings of the
607
+ * same pass. `seedRead` runs EVERY stage rather than first-red-wins, which is
608
+ * D12's own named build item — a stage that never ran mints no baseline, and
609
+ * every shipped close opens with a guard that is red at its own seed.
610
+ *
611
+ * IT NEVER SIGNS. The approvals array and the human's word are unchanged; what
612
+ * comes back is the resolved spec, its hash, and the evidence to sign against.
613
+ *
614
+ * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
615
+ * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function}} o
616
+ * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
617
+ * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
618
+ */
619
+ export async function prepareSigning({
620
+ spec, workdir, seedRef = null, shellCapUsd, timeoutMs,
621
+ seedFn = seedAtHead, listingFn = seedListing, seedReadFn = runSeedRead,
622
+ }) {
623
+ /** @type {any} */
624
+ const base = {
625
+ ok: false, specHash: null, seedRef: null, work: [], guards: [], stops: [], reds: [], refusal: null,
626
+ gates: { declaration: null, precheck: null, seedVerdict: null },
627
+ };
628
+
629
+ // ── gate 1a: the spec itself. A declared close that is not on a plan-shape
630
+ // spec is not a close anyone can run.
631
+ const jv = validateJob(spec, ...(shellCapUsd === undefined ? [] : [{ shellCapUsd }]));
632
+ if (!jv.ok) {
633
+ // scrubbed HERE, at the same boundary as the git channels below: these reds
634
+ // echo the DECLARATION verbatim, and the `secret-literal` red the same sweep
635
+ // raises beside them proves the shape is one the ONE inventory detects — a
636
+ // sibling red that hands it through unmasked is the divergence that rule bans
637
+ const reds = jv.reds.map(scrubRed);
638
+ base.gates.declaration = { ok: false, grounded: false, reds };
639
+ return { ...base, reds };
640
+ }
641
+ if (!isDeclaredClose(spec)) {
642
+ const red = {
643
+ code: 'invalid-value',
644
+ path: 'closeDecl',
645
+ detail: 'prepareSigning runs the authored close\'s gates — a spec carrying an operator-written command close has '
646
+ + 'no declaration to ground, and its stages are signed as written',
647
+ };
648
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
649
+ return { ...base, reds: [red] };
650
+ }
651
+
652
+ let seed = seedRef;
653
+ if (!isNonEmptyString(seed)) {
654
+ const s = await seedFn(workdir);
655
+ if (s.stop !== null) {
656
+ // git's own stderr, scrubbed HERE — the emission boundary, exactly like the
657
+ // gap lines below (M1 deliberately does not scrub; the caller does). This red
658
+ // is persisted with the signing evidence, and a file that captures a key
659
+ // captures it forever.
660
+ const red = { code: 'seed-unreadable', path: 'seedRef', detail: redactSecrets(s.stop) };
661
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
662
+ return { ...base, reds: [red] };
663
+ }
664
+ seed = s.seedRef;
665
+ }
666
+ base.seedRef = seed;
667
+
668
+ // ── gate 1b: the declaration, GROUNDED. The listing is what makes hamr's path
669
+ // rule and the scoped-job derivation real; without it both are deferred, and a
670
+ // signature over a deferred gate signs something nobody checked.
671
+ const listed = await listingFn(workdir, seed);
672
+ if (listed.stop !== null) {
673
+ // the other half of the same channel: `git ls-tree`'s stderr, scrubbed at the
674
+ // same boundary and for the same reason
675
+ const red = { code: 'listing-unreadable', path: 'closeDecl', detail: redactSecrets(listed.stop) };
676
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
677
+ return { ...base, reds: [red] };
678
+ }
679
+ // the class rides from the SPEC (PRD v1.57 §2) — gate 1a already refused a spec
680
+ // whose class is unknown or locked, so this is the class D5's battery hangs off
681
+ const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType });
682
+ // the grounded gate's reds quote BOTH untrusted sources — the declaration, and
683
+ // the seed listing itself (the listing rule names what really sits beside an
684
+ // invented path). The listing half is a channel gate 1a structurally cannot
685
+ // cover: `validateJob` sweeps the SPEC, and a token-shaped FILENAME in the
686
+ // patient's tree is not in it. Scrubbed on both exits — the gate record and the
687
+ // returned reds are two copies of the same persisted evidence.
688
+ const dvReds = dv.reds.map(scrubRed);
689
+ base.gates.declaration = { ok: dv.ok, grounded: dv.grounded, reds: dvReds, scoped: dv.scoped };
690
+ if (!dv.ok) return { ...base, reds: dvReds };
691
+
692
+ // ── gates 2 + 3: one execution of EVERY stage at the seed.
693
+ const seedTrees = makeSeedTrees();
694
+ /** @type {any[]} */
695
+ let rows;
696
+ try {
697
+ rows = await seedReadFn(
698
+ { stages: spec.closeDecl.stages },
699
+ { workdir, seedRef: seed, gapKeep: DECLARED_GAP_PREFIX, seedTrees, ...(timeoutMs === undefined ? {} : { timeoutMsDefault: timeoutMs }) },
700
+ );
701
+ } finally {
702
+ await seedTrees.cleanup();
703
+ }
704
+
705
+ // the battery keys off the class the user picked (PRD v1.57 §2), and the spec
706
+ // carries it — gate 1a already refused a spec whose class is unknown or locked
707
+ const guardSet = new Set(guardNames(spec.closeDecl, spec.verdictType));
708
+ const show = (/** @type {any} */ r) => ({
709
+ stage: r.stage,
710
+ kind: r.kind,
711
+ verdict: r.verdict,
712
+ value: r.value ?? null,
713
+ baseline: r.baseline ?? null,
714
+ baselineSource: r.baselineSource ?? null,
715
+ guard: guardSet.has(r.stage),
716
+ // the gap is what the user READS to decide whether the close measures their
717
+ // job — scrubbed here, at the emission boundary, exactly like every other
718
+ // close output (M1 deliberately does not scrub; the caller does)
719
+ gap: (r.gapLines ?? []).map((/** @type {string} */ l) => redactSecrets(l)),
720
+ });
721
+
722
+ const stops = rows.filter((r) => r.verdict === 'instrument-stop').map(show);
723
+ base.gates.precheck = {
724
+ ok: stops.length === 0,
725
+ stops: stops.map((s) => ({ stage: s.stage, gap: s.gap })),
726
+ };
727
+ if (stops.length) {
728
+ const reds = stops.map((s) => ({
729
+ code: 'broken-close',
730
+ path: `closeDecl.stages.${s.stage}`,
731
+ detail: s.gap.join('\n') || `stage "${s.stage}" rendered no judgment`,
732
+ }));
733
+ return {
734
+ ...base,
735
+ reds,
736
+ stops,
737
+ refusal: refuse({
738
+ kind: 'decision-ready',
739
+ verb: null,
740
+ path: 'closeDecl',
741
+ detail: `${stops.length} stage(s) of this close cannot run against the repository — a broken instrument is a `
742
+ + 'casualty, never a verdict, so nothing it reported would be trustworthy.',
743
+ options: [
744
+ 're-author the close against this repository',
745
+ 'fix the repository so the close\'s tools resolve (a missing toolchain reads as a broken instrument)',
746
+ 'abandon the job',
747
+ ],
748
+ }),
749
+ };
750
+ }
751
+
752
+ const work = rows.filter((r) => !guardSet.has(r.stage)).map(show);
753
+ const guards = rows.filter((r) => guardSet.has(r.stage)).map(show);
754
+ const workRed = work.filter((r) => r.verdict === 'red');
755
+ base.gates.seedVerdict = {
756
+ ok: workRed.length > 0,
757
+ redAtSeed: rows.filter((r) => r.verdict === 'red').map((r) => r.stage),
758
+ greenAtSeed: rows.filter((r) => r.verdict === 'green').map((r) => r.stage),
759
+ workRed: workRed.map((r) => r.stage),
760
+ };
761
+
762
+ // D9.3 — nothing red at seed means nothing to do. Round 2's arm B read GREEN
763
+ // with value 0 because mypy died on an unrelated broken fixture and scanned
764
+ // nothing: an instrument scanning nothing reads clean, which is F6's shape at
765
+ // the close-authoring layer, and this gate is what refuses it BEFORE a
766
+ // signature rather than after a paid run.
767
+ if (!workRed.length) {
768
+ return {
769
+ ...base,
770
+ work,
771
+ guards,
772
+ refusal: refuse({
773
+ kind: 'decision-ready',
774
+ verb: null,
775
+ path: 'closeDecl',
776
+ detail: 'No work stage of this close is RED at the seed, so the close already passes on the untouched '
777
+ + 'repository: there is nothing for a run to do, and an instrument that measures nothing reads clean the '
778
+ + 'same way one that measures correctly does.',
779
+ options: [
780
+ 'check the close is measuring the right thing (a tool that scans nothing exits clean)',
781
+ 're-author the close from a sharper description of what is wrong today',
782
+ 'the job may already be done — abandon it',
783
+ ],
784
+ }),
785
+ };
786
+ }
787
+
788
+ return {
789
+ ...base,
790
+ ok: true,
791
+ // The hash is over the RESOLVED spec (MED-1), and the declaration is stored
792
+ // ENUMERATED — every guard spelled out, every short-form parser expanded — so
793
+ // there is no omittable-with-a-default field whose widening could change what
794
+ // runs without changing what was signed (forward-compat point 3). D6 falls
795
+ // out of that: re-authoring produces different bytes, a different hash, and
796
+ // therefore a new signature.
797
+ specHash: jobSpecHash(jv.job),
798
+ work,
799
+ guards,
800
+ };
801
+ }