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.
@@ -0,0 +1,793 @@
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,
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
+ seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
379
+ authorFn = authorClose, authorOpts = {},
380
+ }) {
381
+ /** @type {any} */
382
+ const base = {
383
+ ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
384
+ authoring: null, reds: [], stop: null, cost: null, interview: null,
385
+ };
386
+
387
+ const interview = runInterview({ answers, verdictType, repoPath, questions });
388
+ base.interview = interview;
389
+ if (!interview.ok) {
390
+ return { ...base, refusal: interview.refusal, reds: interview.reds, stop: interview.refusal ? 'refused' : 'interview-incomplete' };
391
+ }
392
+ const workdir = /** @type {string} */ (interview.repoPath);
393
+ const picked = /** @type {string} */ (interview.verdictType);
394
+
395
+ if (!GENRE_LANGUAGES.includes(lang)) {
396
+ // THE GENRE REFUSAL, at the composer. This used to be a plain wiring red;
397
+ // under v1.57 §2 the interview no longer asks the user to confirm a genre, so
398
+ // this is where "we can't run this kind of job yet" actually lands — and it
399
+ // is COUNTED demand rather than a silent drop, on the same `request-red`
400
+ // path the confirm slot used. The battery is the reason it must refuse at
401
+ // all: `classGuards` throws rather than hand back an empty one (F59's shape
402
+ // — absent must never read as "no guards needed").
403
+ return {
404
+ ...base,
405
+ refusal: refuse({
406
+ verb: 'genre-other',
407
+ path: 'lang',
408
+ detail: `We can't run this kind of job yet. The catalogue can measure one genre (${GENRE}: a type checker `
409
+ + `stops complaining without breaking the tests) in ${GENRE_LANGUAGES.join(' and ')}, and "${lang}" has no `
410
+ + 'guard battery — a genre with no battery must refuse rather than author a close without the guards it '
411
+ + 'cannot supply.',
412
+ }),
413
+ reds: [{
414
+ code: 'request-red',
415
+ path: 'lang',
416
+ verb: 'genre-other',
417
+ lib: REFUSAL_LIB,
418
+ detail: `no ${GENRE} genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}`,
419
+ }],
420
+ stop: 'refused',
421
+ };
422
+ }
423
+
424
+ // D8 — the seed, READ rather than typed.
425
+ let seed = seedRef;
426
+ if (!isNonEmptyString(seed)) {
427
+ const s = await seedFn(workdir);
428
+ if (s.stop !== null) {
429
+ // same git-stderr channel as `prepareSigning`'s, scrubbed at the same
430
+ // boundary — this red is returned to the caller and kept with the run
431
+ return { ...base, reds: [{ code: 'seed-unreadable', path: 'seedRef', detail: redactSecrets(s.stop) }], stop: 'precheck' };
432
+ }
433
+ seed = s.seedRef;
434
+ }
435
+ base.seedRef = seed;
436
+
437
+ // D11 — the scout LOOKS, the author WRITES, neither acts. An ABSENT survey is
438
+ // never "no special facts are needed" (F59), and `authorClose` refuses on it at
439
+ // $0, so nothing here has to second-guess the classification.
440
+ const survey = scout ?? await scoutFn({ workdir, provider });
441
+ const seeds = listing ?? await listingFn({
442
+ workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
443
+ });
444
+
445
+ const authored = await authorFn({
446
+ workdir, seedRef: seed, lang, verdictType: picked,
447
+ answers: interview.answers, questions: questions ?? questionsFor(picked),
448
+ scout: survey, listing: seeds, generate, ...authorOpts,
449
+ });
450
+
451
+ if (!authored.ok) {
452
+ return {
453
+ ...base,
454
+ authoring: authored, reds: authored.reds, stop: authored.stop, cost: authored.cost,
455
+ // the OTHER composer refusal: the model reached for a kind the catalogue
456
+ // names but does not run. M2 already stamped the counted red at the emit
457
+ // site; this carries it up in the shape the caller's ledger and terminal
458
+ // read, rather than leaving a locked-kind demand buried in a reds array.
459
+ refusal: composerRefusal(authored.reds),
460
+ };
461
+ }
462
+
463
+ // The genre's ENVIRONMENT, recorded from the flow's own applied map — never
464
+ // from the model, and never re-derived here. The flow injected it into every
465
+ // env-capable stage after the model's form passed (M3's `applyGenreEnv`), and
466
+ // from this point on every re-validation reads that key back: signing, the job
467
+ // validator, the runner. Without the record they cannot tell our injection from
468
+ // a model-authored guess, so they must refuse it — which is a python close that
469
+ // can never be signed, validated or run. Recorded ONLY when something was
470
+ // injected: absent is a genre that owns no environment (JS), and an empty
471
+ // object would read as "we looked and found none" for a genre that never looks.
472
+ const applied = isObj(authored.genreEnv?.applied) ? authored.genreEnv.applied : {};
473
+
474
+ // The class the USER picked, carried through unchanged — validated at the
475
+ // interview, honoured by the composition (M2's ceiling rule refuses a
476
+ // declaration above it), and stated here rather than re-derived. The remaining
477
+ // precondition (D9.3 — the close has work to do) is measured by
478
+ // `prepareSigning`, which refuses there.
479
+ return {
480
+ ok: true,
481
+ refusal: null,
482
+ verdictType: picked,
483
+ closeDecl: {
484
+ genre: GENRE,
485
+ lang,
486
+ stages: authored.declaration.stages,
487
+ ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
488
+ // `notes` is the one FREE-TEXT field the model writes straight into the
489
+ // signed spec, and this is that field's persist boundary. Scrubbed here
490
+ // for the same reason `scrubRed` scrubs uniformly a few lines down: every
491
+ // other model-authored string that survives this call rides the ONE
492
+ // redactor, and a signed spec outlives the run that produced it.
493
+ // `redactSecrets` returns a non-matching string byte-identical, so a note
494
+ // carrying nothing hashes exactly as it did — the mask costs the honest
495
+ // case nothing, which is the fail-safe direction.
496
+ ...(authored.declaration.notes?.length
497
+ ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
498
+ : {}),
499
+ },
500
+ seedRef: seed,
501
+ authoring: authored,
502
+ reds: authored.reds,
503
+ stop: authored.stop,
504
+ cost: authored.cost,
505
+ interview,
506
+ };
507
+ }
508
+
509
+ /** The fields `assembleSpec` writes, and therefore the fields a DRAFT must not
510
+ * carry. Named as data so the refusal and the fold can never disagree about
511
+ * which half of the spec is which. */
512
+ export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType']);
513
+
514
+ /**
515
+ * Fold an authored close into the OPERATOR's own half of the spec.
516
+ *
517
+ * The two halves never mix: budgets, the fence, cadence, escalation and the
518
+ * provider are operator input and are not authored by anything here (the
519
+ * permanent hard line). What this adds is the close, the derived verdict class,
520
+ * and nothing else.
521
+ *
522
+ * The GOAL is passed through, not generated. F87 requires the goal to state
523
+ * everything the close judges, and §3 of the design record says that becomes
524
+ * structural once the goal RENDERS from the same answers — that rendering is UI
525
+ * work (PRD v1.51 §5, "lands with the close-authoring interview UI") and is
526
+ * deliberately not invented here.
527
+ *
528
+ * A draft that ALREADY carries the authored half is REFUSED, not merged and not
529
+ * overwritten. `src/job.js` states the rule for the close in its own words — two
530
+ * closes are two arbiters, and "picking one silently is how the signed artefact
531
+ * stops meaning what the signer read" — and the same holds one step earlier and
532
+ * for the class: this fold used to drop a draft's `close` on the floor and stamp
533
+ * its own `verdictType` over the operator's, so a person who typed either would
534
+ * read back a signed spec that never contained what they wrote. Refusing by name
535
+ * costs the legitimate path nothing (a draft is the operator's half BY
536
+ * DEFINITION, and nothing in the pipeline builds one carrying these) and it is
537
+ * the same direction every other gate here takes: a mismatch a human cannot see
538
+ * is worse than a stop a human can read.
539
+ * @param {any} specDraft the operator's half (everything except close/closeDecl/verdictType)
540
+ * @param {{closeDecl: any, verdictType: string}} authored
541
+ * @throws {Error} when the draft already carries close, closeDecl or verdictType
542
+ */
543
+ export function assembleSpec(specDraft, { closeDecl, verdictType }) {
544
+ const draft = isObj(specDraft) ? specDraft : {};
545
+ const carried = AUTHORED_SPEC_FIELDS.filter((f) => draft[f] !== undefined);
546
+ if (carried.length) {
547
+ throw new Error(`the spec draft already carries ${carried.join(', ')} — that is the AUTHORED half of the spec, and `
548
+ + 'this fold writes it. Merging would drop or overwrite what the draft says, and a signed spec that does not '
549
+ + 'contain what its author typed is the one failure nobody can see. Remove the field(s) from the draft');
550
+ }
551
+ return { ...draft, verdictType, closeDecl };
552
+ }
553
+
554
+ // ── 3. D9's THREE GATES, AND THE HASH ────────────────────────────────────────
555
+
556
+ /**
557
+ * A validator red, SCRUBBED for persistence. Both declaration gates in
558
+ * `prepareSigning` hand back reds that quote things this module does not own —
559
+ * the declaration the model wrote (a `cmd`, an env value, a path) and the seed
560
+ * listing the arbiter read (the listing rule names what really sits beside an
561
+ * invented path). Those reds are written into the signing evidence, and a file
562
+ * that captures a key captures it forever.
563
+ *
564
+ * The scrub is at the EMISSION boundary, never inside the validators: they are
565
+ * shared with the model-facing rendering, which already scrubs on its own way
566
+ * out (`renderRejectBlock`), and a second scrub buried in the rule would be the
567
+ * hand-rolled copy the ONE inventory (SECRET_PATTERNS) exists to prevent.
568
+ *
569
+ * UNIFORM over every string-valued field, not a named list of them. `detail` is
570
+ * only the loudest echo: `cmd-denied` quotes the command AGAIN as a structured
571
+ * `cmd`, `path-not-in-listing` carries `declared`/`resolved`, and the next red
572
+ * to learn a field would be missed by an allowlist written today. `redactSecrets`
573
+ * returns a non-matching string byte-identical, so the enumerated and structural
574
+ * fields (`code`, `path`) pass through unchanged and the uniform map costs them
575
+ * nothing — the fail-safe direction, by construction.
576
+ * @param {Red} r @returns {Red}
577
+ */
578
+ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
579
+ Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
580
+ ));
581
+
582
+ /**
583
+ * D9, in code: *nothing JUDGES the close; three mechanical gates plus a
584
+ * signature.* No LLM validates another LLM's close, here or anywhere.
585
+ *
586
+ * 1. THE DECLARATION VALIDATOR — the spec's own gate plus M2's, run GROUNDED
587
+ * against the real seed listing (the job validator defers that half).
588
+ * 2. THE CLOSE PRECHECK — every stage runs against the real patient. A stage
589
+ * that cannot run is `broken-close`, and it is a CASUALTY, never a red.
590
+ * 3. THE SEED-VERDICT READ — every stage, offered or not, run at the seed. Which
591
+ * are RED (the work) and which are GREEN (the guards) is shown to the user.
592
+ * A close with NO work stage red at seed has nothing to do and is refused
593
+ * decision-ready — gate-2 round 2's arm B died exactly there, on a mypy that
594
+ * scanned nothing and therefore read clean.
595
+ *
596
+ * Gates 2 and 3 share ONE execution, and that is stated rather than hidden: at
597
+ * job creation the tree IS the seed, so "does every stage run against the real
598
+ * patient" and "what does every stage say at the seed" are two readings of the
599
+ * same pass. `seedRead` runs EVERY stage rather than first-red-wins, which is
600
+ * D12's own named build item — a stage that never ran mints no baseline, and
601
+ * every shipped close opens with a guard that is red at its own seed.
602
+ *
603
+ * IT NEVER SIGNS. The approvals array and the human's word are unchanged; what
604
+ * comes back is the resolved spec, its hash, and the evidence to sign against.
605
+ *
606
+ * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
607
+ * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function}} o
608
+ * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
609
+ * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
610
+ */
611
+ export async function prepareSigning({
612
+ spec, workdir, seedRef = null, shellCapUsd, timeoutMs,
613
+ seedFn = seedAtHead, listingFn = seedListing, seedReadFn = runSeedRead,
614
+ }) {
615
+ /** @type {any} */
616
+ const base = {
617
+ ok: false, specHash: null, seedRef: null, work: [], guards: [], stops: [], reds: [], refusal: null,
618
+ gates: { declaration: null, precheck: null, seedVerdict: null },
619
+ };
620
+
621
+ // ── gate 1a: the spec itself. A declared close that is not on a plan-shape
622
+ // spec is not a close anyone can run.
623
+ const jv = validateJob(spec, ...(shellCapUsd === undefined ? [] : [{ shellCapUsd }]));
624
+ if (!jv.ok) {
625
+ // scrubbed HERE, at the same boundary as the git channels below: these reds
626
+ // echo the DECLARATION verbatim, and the `secret-literal` red the same sweep
627
+ // raises beside them proves the shape is one the ONE inventory detects — a
628
+ // sibling red that hands it through unmasked is the divergence that rule bans
629
+ const reds = jv.reds.map(scrubRed);
630
+ base.gates.declaration = { ok: false, grounded: false, reds };
631
+ return { ...base, reds };
632
+ }
633
+ if (!isDeclaredClose(spec)) {
634
+ const red = {
635
+ code: 'invalid-value',
636
+ path: 'closeDecl',
637
+ detail: 'prepareSigning runs the authored close\'s gates — a spec carrying an operator-written command close has '
638
+ + 'no declaration to ground, and its stages are signed as written',
639
+ };
640
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
641
+ return { ...base, reds: [red] };
642
+ }
643
+
644
+ let seed = seedRef;
645
+ if (!isNonEmptyString(seed)) {
646
+ const s = await seedFn(workdir);
647
+ if (s.stop !== null) {
648
+ // git's own stderr, scrubbed HERE — the emission boundary, exactly like the
649
+ // gap lines below (M1 deliberately does not scrub; the caller does). This red
650
+ // is persisted with the signing evidence, and a file that captures a key
651
+ // captures it forever.
652
+ const red = { code: 'seed-unreadable', path: 'seedRef', detail: redactSecrets(s.stop) };
653
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
654
+ return { ...base, reds: [red] };
655
+ }
656
+ seed = s.seedRef;
657
+ }
658
+ base.seedRef = seed;
659
+
660
+ // ── gate 1b: the declaration, GROUNDED. The listing is what makes hamr's path
661
+ // rule and the scoped-job derivation real; without it both are deferred, and a
662
+ // signature over a deferred gate signs something nobody checked.
663
+ const listed = await listingFn(workdir, seed);
664
+ if (listed.stop !== null) {
665
+ // the other half of the same channel: `git ls-tree`'s stderr, scrubbed at the
666
+ // same boundary and for the same reason
667
+ const red = { code: 'listing-unreadable', path: 'closeDecl', detail: redactSecrets(listed.stop) };
668
+ base.gates.declaration = { ok: false, grounded: false, reds: [red] };
669
+ return { ...base, reds: [red] };
670
+ }
671
+ // the class rides from the SPEC (PRD v1.57 §2) — gate 1a already refused a spec
672
+ // whose class is unknown or locked, so this is the class D5's battery hangs off
673
+ const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType });
674
+ // the grounded gate's reds quote BOTH untrusted sources — the declaration, and
675
+ // the seed listing itself (the listing rule names what really sits beside an
676
+ // invented path). The listing half is a channel gate 1a structurally cannot
677
+ // cover: `validateJob` sweeps the SPEC, and a token-shaped FILENAME in the
678
+ // patient's tree is not in it. Scrubbed on both exits — the gate record and the
679
+ // returned reds are two copies of the same persisted evidence.
680
+ const dvReds = dv.reds.map(scrubRed);
681
+ base.gates.declaration = { ok: dv.ok, grounded: dv.grounded, reds: dvReds, scoped: dv.scoped };
682
+ if (!dv.ok) return { ...base, reds: dvReds };
683
+
684
+ // ── gates 2 + 3: one execution of EVERY stage at the seed.
685
+ const seedTrees = makeSeedTrees();
686
+ /** @type {any[]} */
687
+ let rows;
688
+ try {
689
+ rows = await seedReadFn(
690
+ { stages: spec.closeDecl.stages },
691
+ { workdir, seedRef: seed, gapKeep: DECLARED_GAP_PREFIX, seedTrees, ...(timeoutMs === undefined ? {} : { timeoutMsDefault: timeoutMs }) },
692
+ );
693
+ } finally {
694
+ await seedTrees.cleanup();
695
+ }
696
+
697
+ // the battery keys off the class the user picked (PRD v1.57 §2), and the spec
698
+ // carries it — gate 1a already refused a spec whose class is unknown or locked
699
+ const guardSet = new Set(guardNames(spec.closeDecl, spec.verdictType));
700
+ const show = (/** @type {any} */ r) => ({
701
+ stage: r.stage,
702
+ kind: r.kind,
703
+ verdict: r.verdict,
704
+ value: r.value ?? null,
705
+ baseline: r.baseline ?? null,
706
+ baselineSource: r.baselineSource ?? null,
707
+ guard: guardSet.has(r.stage),
708
+ // the gap is what the user READS to decide whether the close measures their
709
+ // job — scrubbed here, at the emission boundary, exactly like every other
710
+ // close output (M1 deliberately does not scrub; the caller does)
711
+ gap: (r.gapLines ?? []).map((/** @type {string} */ l) => redactSecrets(l)),
712
+ });
713
+
714
+ const stops = rows.filter((r) => r.verdict === 'instrument-stop').map(show);
715
+ base.gates.precheck = {
716
+ ok: stops.length === 0,
717
+ stops: stops.map((s) => ({ stage: s.stage, gap: s.gap })),
718
+ };
719
+ if (stops.length) {
720
+ const reds = stops.map((s) => ({
721
+ code: 'broken-close',
722
+ path: `closeDecl.stages.${s.stage}`,
723
+ detail: s.gap.join('\n') || `stage "${s.stage}" rendered no judgment`,
724
+ }));
725
+ return {
726
+ ...base,
727
+ reds,
728
+ stops,
729
+ refusal: refuse({
730
+ kind: 'decision-ready',
731
+ verb: null,
732
+ path: 'closeDecl',
733
+ detail: `${stops.length} stage(s) of this close cannot run against the repository — a broken instrument is a `
734
+ + 'casualty, never a verdict, so nothing it reported would be trustworthy.',
735
+ options: [
736
+ 're-author the close against this repository',
737
+ 'fix the repository so the close\'s tools resolve (a missing toolchain reads as a broken instrument)',
738
+ 'abandon the job',
739
+ ],
740
+ }),
741
+ };
742
+ }
743
+
744
+ const work = rows.filter((r) => !guardSet.has(r.stage)).map(show);
745
+ const guards = rows.filter((r) => guardSet.has(r.stage)).map(show);
746
+ const workRed = work.filter((r) => r.verdict === 'red');
747
+ base.gates.seedVerdict = {
748
+ ok: workRed.length > 0,
749
+ redAtSeed: rows.filter((r) => r.verdict === 'red').map((r) => r.stage),
750
+ greenAtSeed: rows.filter((r) => r.verdict === 'green').map((r) => r.stage),
751
+ workRed: workRed.map((r) => r.stage),
752
+ };
753
+
754
+ // D9.3 — nothing red at seed means nothing to do. Round 2's arm B read GREEN
755
+ // with value 0 because mypy died on an unrelated broken fixture and scanned
756
+ // nothing: an instrument scanning nothing reads clean, which is F6's shape at
757
+ // the close-authoring layer, and this gate is what refuses it BEFORE a
758
+ // signature rather than after a paid run.
759
+ if (!workRed.length) {
760
+ return {
761
+ ...base,
762
+ work,
763
+ guards,
764
+ refusal: refuse({
765
+ kind: 'decision-ready',
766
+ verb: null,
767
+ path: 'closeDecl',
768
+ detail: 'No work stage of this close is RED at the seed, so the close already passes on the untouched '
769
+ + 'repository: there is nothing for a run to do, and an instrument that measures nothing reads clean the '
770
+ + 'same way one that measures correctly does.',
771
+ options: [
772
+ 'check the close is measuring the right thing (a tool that scans nothing exits clean)',
773
+ 're-author the close from a sharper description of what is wrong today',
774
+ 'the job may already be done — abandon it',
775
+ ],
776
+ }),
777
+ };
778
+ }
779
+
780
+ return {
781
+ ...base,
782
+ ok: true,
783
+ // The hash is over the RESOLVED spec (MED-1), and the declaration is stored
784
+ // ENUMERATED — every guard spelled out, every short-form parser expanded — so
785
+ // there is no omittable-with-a-default field whose widening could change what
786
+ // runs without changing what was signed (forward-compat point 3). D6 falls
787
+ // out of that: re-authoring produces different bytes, a different hash, and
788
+ // therefore a new signature.
789
+ specHash: jobSpecHash(jv.job),
790
+ work,
791
+ guards,
792
+ };
793
+ }