bareloop 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +625 -0
- package/README.md +101 -110
- package/bareloop.context.md +333 -25
- package/package.json +1 -1
- package/src/authorflow.js +1447 -0
- package/src/authoring.js +1486 -0
- package/src/authorjob.js +801 -0
- package/src/authorscout.js +767 -0
- package/src/bridges.js +16 -3
- package/src/declaredclose.js +519 -0
- package/src/index.js +75 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1445 -0
- package/src/ledger.js +18 -1
- package/src/plan.js +26 -1
- package/src/planrun.js +448 -40
- package/src/ralph.js +24 -4
- package/src/reuse.js +120 -9
- package/src/root.js +8 -1
- package/src/run.js +12 -4
- package/src/text.js +199 -0
- package/src/tools.js +11 -0
- package/src/validate.js +168 -11
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +764 -0
- package/types/authoring.d.ts +559 -0
- package/types/authorjob.d.ts +242 -0
- package/types/authorscout.d.ts +320 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +1 -0
- package/types/kinds.d.ts +395 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +53 -3
- package/types/ralph.d.ts +1 -0
- package/types/reuse.d.ts +1 -1
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +151 -0
- package/types/validate.d.ts +74 -9
- package/types/workbranch.d.ts +102 -0
package/src/authorjob.js
ADDED
|
@@ -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
|
+
}
|