bareloop 0.25.0 → 0.26.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 +317 -0
- package/bareloop.context.md +268 -83
- package/package.json +1 -1
- package/src/authorflow.js +713 -92
- package/src/authoring.js +33 -15
- package/src/authorjob.js +158 -5
- package/src/cardauthor.js +28 -18
- package/src/detectlang.js +200 -0
- package/src/index.js +29 -30
- package/src/job.js +3 -2
- package/src/kinds.js +5 -3
- package/src/promptregisters.js +6 -0
- package/src/providers.js +15 -3
- package/src/source.js +153 -33
- package/types/authorflow.d.ts +433 -104
- package/types/authoring.d.ts +23 -8
- package/types/authorjob.d.ts +22 -4
- package/types/cardauthor.d.ts +7 -3
- package/types/detectlang.d.ts +38 -0
- package/types/index.d.ts +16 -16
- package/types/job.d.ts +3 -2
- package/types/kinds.d.ts +5 -3
- package/types/providers.d.ts +9 -2
- package/types/source.d.ts +42 -0
package/src/authorflow.js
CHANGED
|
@@ -153,108 +153,174 @@ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one J
|
|
|
153
153
|
+ 'Reply with ONLY the JSON object, in a single fenced block, and nothing else.';
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* THE
|
|
156
|
+
* THE UNIFIED INTAKE FORM (PRD item 33 M3 piece 3, `docs/product/ITEM33-BUILD.md`
|
|
157
|
+
* "M3", and the 2026-09-10 signed ruling "The intake form (RULED)" in
|
|
158
|
+
* `docs/product/PRD.md` item 33). Motivated by run `mtv8jihy` (F159): the OLD
|
|
159
|
+
* five-question green set produced a rubric compiled onto doc-comment rules —
|
|
160
|
+
* the wrong bar for what the person actually asked — because Q2 asked in prose
|
|
161
|
+
* for a decision the machine can prove instead, and Q3/Q5 asked the same "what
|
|
162
|
+
* must not go wrong" question twice from two directions.
|
|
157
163
|
*
|
|
158
|
-
*
|
|
159
|
-
* tail that cannot be enumerated, and TYPES was the first specimen rather than
|
|
160
|
-
* the pattern. One set per verdict CLASS has exactly three. Genre understanding
|
|
161
|
-
* does not disappear: it moves into the COMPOSITION over the kind catalogue
|
|
162
|
-
* (`CLASS_STATEMENTS` below, rendered into the authoring prompt), which is the
|
|
163
|
-
* layer already built to hold it — the authoring call composes owned kinds and
|
|
164
|
-
* cannot emit anything else (D2/D3), so a genre it has never seen is a
|
|
165
|
-
* composition problem, not a catalogue problem.
|
|
164
|
+
* THE SAME SIX FIELDS FOR GREEN AND SOFT-GREEN, in the order a person sees them:
|
|
166
165
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
166
|
+
* Source replaces the read half of the old Q2 — MECHANICAL, proven
|
|
167
|
+
* against the machine ($0), never typed as free prose.
|
|
168
|
+
* Destination replaces the change half of the old Q2 (and, for a repo
|
|
169
|
+
* source, IS the signed `writeScope` fence) — MECHANICAL,
|
|
170
|
+
* same reason.
|
|
171
|
+
* Goal the old Q1, unchanged: "What do you want done?"
|
|
172
|
+
* What success looks like the old Q4, unchanged: a machine-countable check.
|
|
173
|
+
* Guardrails the old Q3 ("must not change") and Q5 ("worse than
|
|
174
|
+
* before") JOINED into one field — they were always one
|
|
175
|
+
* decision answered twice, and Q5 already only mattered for
|
|
176
|
+
* a repo source (the M3 confirm turn, not built in this
|
|
177
|
+
* piece, is where the repo-only half of it gets asked again
|
|
178
|
+
* if needed).
|
|
179
|
+
* Judge examples soft-green only — the old Q7 verbatim ("one you'd pass and
|
|
180
|
+
* one you'd fail, and say why"). The old Q6 ("what separates
|
|
181
|
+
* a pass from a fail") is retired as its own question: the
|
|
182
|
+
* "why" lines of a real pass/fail pair are what the rubric
|
|
183
|
+
* card compiles from (`cardauthor.js`), and asking the
|
|
184
|
+
* differentiator twice — once in the abstract, once against
|
|
185
|
+
* a real example — is the same double-ask Q3/Q5 already were.
|
|
170
186
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* refuses to start without. Asking a person to re-type in prose a path the
|
|
180
|
-
* machine is holding in a variable is the SWE tax this product refuses, and
|
|
181
|
-
* it invites a second, drifting answer for the same fact (hamr's own answer
|
|
182
|
-
* on the live run was *"Yes — the patient itself."*). Nothing downstream lost
|
|
183
|
-
* anything: the scout reads `repoPath`, never the answer.
|
|
187
|
+
* SOURCE and DESTINATION ARE MECHANICAL FIELDS, not entries in the numbered
|
|
188
|
+
* question record below: `runInterview`'s `answers` dict is what a close's
|
|
189
|
+
* composer prompt quotes verbatim (Q<n>/A<n>), and quoting a machine-proven fact
|
|
190
|
+
* back at the model as if a person had typed it would be the model reading a
|
|
191
|
+
* fabricated conversation. Their WORDING still lives here (`SOURCE_FIELD`,
|
|
192
|
+
* `DESTINATION_FIELD_REPO`, `DESTINATION_FIELD_PLAIN`, immediately below) so
|
|
193
|
+
* `scripts/run-interview.mjs` prints library text and nothing of its own, exactly
|
|
194
|
+
* as ruling 1 requires for the free-text questions.
|
|
184
195
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
196
|
+
* The free-text trio (soon quartet, soft-green only) IS numbered contiguously
|
|
197
|
+
* from 1 — the number a person sees is the key their answer is filed under, and
|
|
198
|
+
* that is what `questionsFor`/`requiredAnswersFor`/`QUESTION_SETS` still expose,
|
|
199
|
+
* unchanged in shape, to every existing caller.
|
|
200
|
+
*/
|
|
201
|
+
export const SOURCE_FIELD = Object.freeze({
|
|
202
|
+
id: 'source',
|
|
203
|
+
kind: 'mechanical',
|
|
204
|
+
label: 'Source',
|
|
205
|
+
prompt: 'A local folder, subfolder or file, or one URL the machine can reach (no login, no setup).',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** Destination's wording when Source resolved to a git repo: the answer IS the
|
|
209
|
+
* signed `writeScope` fence (ITEM33-BUILD.md M3 ruling 2). */
|
|
210
|
+
export const DESTINATION_FIELD_REPO = Object.freeze({
|
|
211
|
+
id: 'destination',
|
|
212
|
+
kind: 'mechanical',
|
|
213
|
+
label: 'Destination',
|
|
214
|
+
prompt: 'Which files the worker is allowed to WRITE — everything else is read-only.\n'
|
|
215
|
+
+ 'Patterns are relative to the repo root, comma-separated (e.g. `src/**`). The run works on a copy of the\n'
|
|
216
|
+
+ 'repo, so absolute paths are refused. The agent may narrow this and may never widen it.',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** Destination's wording for every other kind of Source (a plain folder, a
|
|
220
|
+
* file, a URL): a directory the run may write into, never a filename
|
|
221
|
+
* (2026-09-12 Destination correction, PRD item 33). */
|
|
222
|
+
export const DESTINATION_FIELD_PLAIN = Object.freeze({
|
|
223
|
+
id: 'destination',
|
|
224
|
+
kind: 'mechanical',
|
|
225
|
+
label: 'Destination',
|
|
226
|
+
prompt: 'A LOCAL DIRECTORY the run may write into — never a filename. It may already exist. If it is the same\n'
|
|
227
|
+
+ 'directory as Source, changes are permitted there and nowhere else. Type an absolute path.',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/** @param {boolean} isRepo @returns {typeof DESTINATION_FIELD_REPO} */
|
|
231
|
+
export function destinationFieldFor(isRepo) { return isRepo ? DESTINATION_FIELD_REPO : DESTINATION_FIELD_PLAIN; }
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* THE FREE-TEXT TRIO EVERY CLASS SHARES: Goal, What success looks like,
|
|
235
|
+
* Guardrails — numbered contiguously from 1. This is what `answers[1..3]` are
|
|
236
|
+
* filed under for every class; soft-green appends a fourth
|
|
237
|
+
* (`SOFTGREEN_QUESTIONS`) and hitl appends a different fourth
|
|
238
|
+
* (`HITL_QUESTIONS`), each its own next number rather than a fixed one, so a
|
|
239
|
+
* future trim of this trio renumbers instead of leaving a gap.
|
|
189
240
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
241
|
+
* THE TEXT IS THE SIGNED TABLE'S "Holds" COLUMN, VERBATIM (PRD item 33's
|
|
242
|
+
* 2026-09-10 "The intake form (RULED)" ruling, `docs/product/PRD.md`) — not the
|
|
243
|
+
* old worded questions ("What do you want done?", "How do you check today
|
|
244
|
+
* whether it's working?", "...worse than before?"). Motivated by run `mtv8jihy`
|
|
245
|
+
* (F159, `docs/logs/FINDINGS.md`): a rulebook this prompt commit did not touch
|
|
246
|
+
* still had to compile against a card built from a person's actual bar, and the
|
|
247
|
+
* old wording never matched what got signed — this reshape (commit 1ac4d05)
|
|
248
|
+
* trimmed the SET to three slots but left their PROSE unreconciled with the
|
|
249
|
+
* table it was meant to realize. `FIELD_LABELS` (below) carries the matching
|
|
250
|
+
* label for each key, so a caller wanting BOTH shows them together — see
|
|
251
|
+
* `authorPrompt`'s interview block and `scripts/run-interview.mjs`.
|
|
194
252
|
*/
|
|
195
253
|
export const GREEN_QUESTIONS = Object.freeze({
|
|
196
|
-
1: '
|
|
197
|
-
2: '
|
|
198
|
-
3: '
|
|
199
|
-
4: 'How do you check today whether it\'s working?',
|
|
200
|
-
5: 'What would make you say this came back worse than before?',
|
|
254
|
+
1: 'what you want to achieve',
|
|
255
|
+
2: 'checks a machine can count',
|
|
256
|
+
3: 'what must not happen or change',
|
|
201
257
|
});
|
|
202
258
|
|
|
203
259
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* The last one is the only thing the green set cannot supply, and it is not a
|
|
214
|
-
* flourish: `human-confirms` requires an `ask` — the question the signer answers
|
|
215
|
-
* at the end of the run — and nothing else in the interview names it. Without it
|
|
216
|
-
* the composer would have to invent what a person is deciding, which is the one
|
|
217
|
-
* thing a human stage must never have done for it. It is the SPREAD's next number
|
|
218
|
-
* rather than a fixed one, so a green-side deletion renumbers it instead of
|
|
219
|
-
* leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
|
|
260
|
+
* The signed table's own label for each of {@link GREEN_QUESTIONS}' keys — "the
|
|
261
|
+
* Field column", where {@link GREEN_QUESTIONS} is "the Holds column". Kept as a
|
|
262
|
+
* SEPARATE map rather than folding the label into the question string itself:
|
|
263
|
+
* every existing caller of `questionsFor`/`requiredAnswersFor`/`QUESTION_SETS`
|
|
264
|
+
* (`src/authorjob.js`, `src/cardauthor.js`, `scripts/run-interview.mjs`) treats
|
|
265
|
+
* a question as a plain string handed verbatim into a prompt or a `Q<n>. `
|
|
266
|
+
* line, and folding a label in would change that shape for every one of them.
|
|
267
|
+
* A caller that wants the label calls {@link labelsFor} exactly the way it
|
|
268
|
+
* calls `questionsFor` — additive, nothing existing breaks.
|
|
220
269
|
*/
|
|
221
|
-
export const
|
|
222
|
-
|
|
223
|
-
|
|
270
|
+
export const FIELD_LABELS = Object.freeze({
|
|
271
|
+
1: 'Goal',
|
|
272
|
+
2: 'What success looks like',
|
|
273
|
+
3: 'Guardrails',
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/** The key Guardrails' merged question and Judge Examples both prove out at:
|
|
277
|
+
* green holds exactly 3 free-text slots, so 4 is the next number for both the
|
|
278
|
+
* soft-green and hitl extensions below — computed, never hardcoded twice. */
|
|
279
|
+
const NEXT_FREE_TEXT_KEY = Object.keys(GREEN_QUESTIONS).length + 1;
|
|
280
|
+
|
|
281
|
+
/** The soft-green quartet's own key for Judge Examples — exported so
|
|
282
|
+
* `cardauthor.js` reads the SAME number this module files the answer under,
|
|
283
|
+
* rather than a second, hand-typed "6"/"7". */
|
|
284
|
+
export const SOFTGREEN_JUDGE_EXAMPLES_KEY = NEXT_FREE_TEXT_KEY;
|
|
285
|
+
|
|
286
|
+
/** The Judge Examples question — the signed table's own Holds text, verbatim
|
|
287
|
+
* ("one pass, one fail, and why"), not the old worded Q7 ("Give one example
|
|
288
|
+
* you'd pass and one you'd fail, and say why."). Exported so `cardauthor.js`'s
|
|
289
|
+
* prompt and this module's own `SOFTGREEN_QUESTIONS` render the identical
|
|
290
|
+
* string; its label ("Judge examples") lives in `SOFTGREEN_FIELD_LABELS`. */
|
|
291
|
+
export const JUDGE_EXAMPLES_QUESTION = 'one pass, one fail, and why';
|
|
292
|
+
|
|
293
|
+
/** {@link FIELD_LABELS} plus Judge Examples' own label, for the soft-green set. */
|
|
294
|
+
export const SOFTGREEN_FIELD_LABELS = Object.freeze({
|
|
295
|
+
...FIELD_LABELS,
|
|
296
|
+
[SOFTGREEN_JUDGE_EXAMPLES_KEY]: 'Judge examples',
|
|
224
297
|
});
|
|
225
298
|
|
|
226
299
|
/**
|
|
227
|
-
* THE SOFTGREEN SET
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* The two additions are the only things the green set cannot supply, and neither
|
|
236
|
-
* is a flourish — they are the judged floor's two REQUIRED inputs:
|
|
237
|
-
* - Q6 becomes the RUBRIC CARD (§4.3): the person's own pass/fail lines,
|
|
238
|
-
* compiled into enumerated items, signed, and enumerated in the spec hash.
|
|
239
|
-
* The documented ceiling rides with it — the judge catches violations of
|
|
240
|
-
* STATED card items only, and the fix for a miss is a new card line and a
|
|
241
|
-
* re-sign, never a smarter judge.
|
|
242
|
-
* - Q7 becomes the frozen CALIBRATION SET (§4.4): the whole pipe must grade it
|
|
243
|
-
* correctly before the close is signable.
|
|
244
|
-
* Both are asked in the person's own terms — an example they would pass and one
|
|
245
|
-
* they would fail, not a rubric they are asked to write — because compiling a lay
|
|
246
|
-
* answer into extractable items is the LLM's job (D5, proposed-and-signed) and
|
|
247
|
-
* asking the person to do it is the SWE tax this product refuses.
|
|
248
|
-
*
|
|
249
|
-
* What the two answers COMPILE INTO is module 4. This module carries them
|
|
250
|
-
* exactly as the hitl set carries its own last answer: they ride in `answers`,
|
|
251
|
-
* they reach the composer verbatim through the prompt's interview block, and
|
|
252
|
-
* nothing here derives anything from them.
|
|
300
|
+
* THE SOFTGREEN SET — the green trio plus Judge Examples. A judged close needs
|
|
301
|
+
* exactly this one more thing the green set cannot supply: a real pass/fail
|
|
302
|
+
* pair in the person's own words, which module 4 (`cardauthor.js`) compiles
|
|
303
|
+
* into the signed rubric card and the frozen calibration set. It rides in
|
|
304
|
+
* `answers` and reaches the composer verbatim through the prompt's interview
|
|
305
|
+
* block, exactly as every other free-text answer does — this module derives
|
|
306
|
+
* nothing from it.
|
|
253
307
|
*/
|
|
254
308
|
export const SOFTGREEN_QUESTIONS = Object.freeze({
|
|
255
309
|
...GREEN_QUESTIONS,
|
|
256
|
-
|
|
257
|
-
|
|
310
|
+
[SOFTGREEN_JUDGE_EXAMPLES_KEY]: JUDGE_EXAMPLES_QUESTION,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* THE HITL SET (N4 slice 1, kept in code only — never offered in the form or
|
|
315
|
+
* anywhere customer-facing, PRD item 33 M3 ruling 8) — the green trio plus one.
|
|
316
|
+
* `human-confirms` requires an `ask`, the question the signer answers at the
|
|
317
|
+
* end of the run, and nothing else in the interview names it; without it the
|
|
318
|
+
* composer would have to invent what a person is deciding. It is the green
|
|
319
|
+
* set's next number, not a fixed one, so a green-side trim renumbers it too.
|
|
320
|
+
*/
|
|
321
|
+
export const HITL_QUESTIONS = Object.freeze({
|
|
322
|
+
...GREEN_QUESTIONS,
|
|
323
|
+
[NEXT_FREE_TEXT_KEY]: 'When you look at the finished result yourself, what are you deciding?',
|
|
258
324
|
});
|
|
259
325
|
|
|
260
326
|
/**
|
|
@@ -265,22 +331,30 @@ export const SOFTGREEN_QUESTIONS = Object.freeze({
|
|
|
265
331
|
* distinction, at the interview layer) — and a locked class refuses at ADMISSION,
|
|
266
332
|
* before its questions run, so the two readers throw rather than hand back
|
|
267
333
|
* nothing. A fourth class arrives locked, and the machinery is here for it.
|
|
268
|
-
*
|
|
334
|
+
* `labels` travels ALONGSIDE `questions`, one label per key that the signed
|
|
335
|
+
* table names one for (PRD item 33 M3 piece 3's wording fix) — hitl's own
|
|
336
|
+
* fourth question has no table row (it is never customer-facing, ruling 8) and
|
|
337
|
+
* so carries none; a reader finding no label for a key falls back to showing
|
|
338
|
+
* none, never a fabricated one.
|
|
339
|
+
* @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, labels: Record<string|number, string>, required: readonly number[]|null}>}
|
|
269
340
|
*/
|
|
270
341
|
export const QUESTION_SETS = Object.freeze({
|
|
271
342
|
green: Object.freeze({
|
|
272
343
|
locked: false,
|
|
273
344
|
questions: GREEN_QUESTIONS,
|
|
345
|
+
labels: FIELD_LABELS,
|
|
274
346
|
required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
275
347
|
}),
|
|
276
348
|
'soft-green': Object.freeze({
|
|
277
349
|
locked: false,
|
|
278
350
|
questions: SOFTGREEN_QUESTIONS,
|
|
351
|
+
labels: SOFTGREEN_FIELD_LABELS,
|
|
279
352
|
required: Object.freeze(Object.keys(SOFTGREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
280
353
|
}),
|
|
281
354
|
hitl: Object.freeze({
|
|
282
355
|
locked: false,
|
|
283
356
|
questions: HITL_QUESTIONS,
|
|
357
|
+
labels: FIELD_LABELS,
|
|
284
358
|
required: Object.freeze(Object.keys(HITL_QUESTIONS).map(Number).sort((a, b) => a - b)),
|
|
285
359
|
}),
|
|
286
360
|
});
|
|
@@ -323,7 +397,7 @@ export const CLASS_STATEMENTS = Object.freeze({
|
|
|
323
397
|
+ 'no command can hold.',
|
|
324
398
|
});
|
|
325
399
|
|
|
326
|
-
/** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
|
|
400
|
+
/** @param {string} verdictType @returns {{locked: boolean, questions: any, labels: any, required: any}} */
|
|
327
401
|
function questionSet(verdictType) {
|
|
328
402
|
const set = Object.hasOwn(QUESTION_SETS, String(verdictType)) ? QUESTION_SETS[String(verdictType)] : null;
|
|
329
403
|
if (!set) throw new Error(`no interview set for verdict class "${verdictType}" — one of ${VERDICT_CLASSES.join(', ')}`);
|
|
@@ -342,6 +416,128 @@ export function questionsFor(verdictType) { return questionSet(verdictType).ques
|
|
|
342
416
|
/** The question numbers whose answer must be present for that class's interview
|
|
343
417
|
* to be finished. @param {string} verdictType @returns {number[]} */
|
|
344
418
|
export function requiredAnswersFor(verdictType) { return [...questionSet(verdictType).required]; }
|
|
419
|
+
/** The signed table's label for each of that class's question keys (PRD item
|
|
420
|
+
* 33's "The intake form (RULED)" ruling) — a key with no table row (hitl's own
|
|
421
|
+
* fourth) is simply absent, never a fabricated label. Throws on the same locked
|
|
422
|
+
* or unknown class {@link questionsFor} does, for the same reason.
|
|
423
|
+
* @param {string} verdictType @returns {Record<string|number, string>} */
|
|
424
|
+
export function labelsFor(verdictType) { return questionSet(verdictType).labels; }
|
|
425
|
+
|
|
426
|
+
// ── CONFIRM TURN — person-facing wording (PRD item 33 M3 piece 4) ──────────
|
|
427
|
+
//
|
|
428
|
+
// These three are the $0, no-provider half of the confirm turn (D7): asked
|
|
429
|
+
// before the scout ever runs, so a missing person at end of input stops at
|
|
430
|
+
// $0 rather than after a paid call. FROZEN wording, exactly like SOURCE_FIELD
|
|
431
|
+
// and DESTINATION_FIELD_* above — a caller prints these verbatim, never
|
|
432
|
+
// rewords or reorders them.
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Asked ONLY for a repo source (ruling 2 addendum's D7) — a plain folder or a
|
|
436
|
+
* URL has no "before" a repo's own history gives it to compare against. This
|
|
437
|
+
* is the OLD Q5 wording, verbatim ("What would make you say this came back
|
|
438
|
+
* worse than before?"), which M3 piece 3's reshape folded into Guardrails —
|
|
439
|
+
* it survives here as its OWN field because the confirm turn (ruling 5's
|
|
440
|
+
* 2026-09-13 addendum) includes it as a constraint only when the person
|
|
441
|
+
* actually gave one, and folding it silently into the Guardrails free-text
|
|
442
|
+
* slot would lose that "present or absent" distinction. An empty answer is
|
|
443
|
+
* "nothing beyond what Guardrails already said" and is not required — see
|
|
444
|
+
* the frozen-wording test below: this phrase appears NOWHERE in
|
|
445
|
+
* {@link questionsFor}'s own sets, so a caller cannot double-ask it by
|
|
446
|
+
* reading the wrong table.
|
|
447
|
+
*/
|
|
448
|
+
export const WORSE_THAN_BEFORE_FIELD = Object.freeze({
|
|
449
|
+
id: 'worseThanBefore',
|
|
450
|
+
kind: 'mechanical',
|
|
451
|
+
label: 'Worse than before',
|
|
452
|
+
prompt: 'What would make you say this came back worse than before?',
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Asked ONLY when `src/detectlang.js`'s `detectLanguage` reports its
|
|
457
|
+
* `ambiguous` outcome — two supported languages' manifests at the same
|
|
458
|
+
* (nearest) directory level (M3 ruling 3, point 3). The candidate list is
|
|
459
|
+
* NOT embedded here: it travels with the caller's own `ambiguous` result
|
|
460
|
+
* (`langResult.candidates`), so this module never hand-types a second copy
|
|
461
|
+
* of the supported-language set. An unsupported pick still goes through the
|
|
462
|
+
* existing `language-unsupported` refusal — this field only offers a choice
|
|
463
|
+
* among the candidates `detectLanguage` actually found.
|
|
464
|
+
*/
|
|
465
|
+
export const LANGUAGE_PICK_FIELD = Object.freeze({
|
|
466
|
+
id: 'language',
|
|
467
|
+
kind: 'mechanical',
|
|
468
|
+
label: 'Language',
|
|
469
|
+
prompt: 'This repo has more than one supported language\'s manifest at the same level. Which one is this job about?',
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* THE CONFIRM TURN'S MENU — a structured CHOICE the person picks from after
|
|
474
|
+
* each round, never free text matched against a pattern (ruling 6's "no
|
|
475
|
+
* hand-authored check matcher" reasoning applies here too: the person's own
|
|
476
|
+
* reply is read as one of these four keys, never parsed for intent).
|
|
477
|
+
* confirm — sign the plan and the drafted goal sentence as shown.
|
|
478
|
+
* fix — free text describing what to change; feeds round r+1, up
|
|
479
|
+
* to the 2-round cap (ruling 5); after round 2 a "fix" is
|
|
480
|
+
* passed to the composer verbatim and shown at signing as an
|
|
481
|
+
* open question rather than spending a 3rd call (D3).
|
|
482
|
+
* type-goal — the person's own sentence REPLACES the drafted goal,
|
|
483
|
+
* verbatim (redacted); everything else in the plan stands.
|
|
484
|
+
* start-over — abandon this confirm turn; the person reruns the
|
|
485
|
+
* interview from the beginning (`confirm-restart`).
|
|
486
|
+
* @type {Record<'confirm'|'fix'|'type-goal'|'start-over', string>}
|
|
487
|
+
*/
|
|
488
|
+
export const CONFIRM_MENU = Object.freeze({
|
|
489
|
+
confirm: 'Confirm — sign this plan and goal as shown',
|
|
490
|
+
fix: 'Fix — describe what to change',
|
|
491
|
+
'type-goal': 'Type the goal sentence yourself',
|
|
492
|
+
'start-over': 'Start over — rerun the interview from the beginning',
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* PLAIN-ENGLISH lines for the guards `classGuards` can compose, keyed by
|
|
497
|
+
* guard NAME — frozen beside {@link CONFIRM_MENU} because both are constants
|
|
498
|
+
* the confirm turn shows a person verbatim. One entry per guard that exists
|
|
499
|
+
* in `MECHANICAL_GUARDS` (`src/authoring.js`); {@link confirmProtections}
|
|
500
|
+
* throws if a guard `classGuards` hands back has no line here, so a battery
|
|
501
|
+
* that grows a guard fails loudly instead of showing a blank description.
|
|
502
|
+
* @type {Record<string, string>}
|
|
503
|
+
*/
|
|
504
|
+
export const GUARD_DESCRIPTIONS = Object.freeze({
|
|
505
|
+
'changed-from-seed': 'the run must actually change at least one file in scope — a close that reports done '
|
|
506
|
+
+ 'without touching anything is refused',
|
|
507
|
+
'no-suppressions': 'the run may not silence a checker instead of fixing what it flags (things like @ts-ignore, '
|
|
508
|
+
+ 'eslint-disable, or # type: ignore) — none of those may appear in the diff',
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* THE REAL PROTECTIONS a person is shown, and what {@link runConfirmTurn}
|
|
513
|
+
* records as `accepted.protections` — fixes finding #1 from live run
|
|
514
|
+
* mu0voeo4 (2026-09-14): the confirm turn used to show whatever prose the
|
|
515
|
+
* MODEL invented for `protections`, and that run's model invented a
|
|
516
|
+
* "behavior-preservation guard" this build cannot check — no kind in the
|
|
517
|
+
* catalogue can verify runtime behaviour, and the author call's own note
|
|
518
|
+
* elsewhere says outright that behaviour-preservation is not composed here.
|
|
519
|
+
* This reads the SAME guards `classGuards` will actually compose into the
|
|
520
|
+
* close — one source, never a second hand-typed list — rendered through the
|
|
521
|
+
* frozen line in {@link GUARD_DESCRIPTIONS}, plus the write fence (a real,
|
|
522
|
+
* signed fence — {@link writeScopeBlock}'s own subject) when one is set.
|
|
523
|
+
* @param {{verdictType: string, lang: string, writeScope?: string[]|null}} o
|
|
524
|
+
* @returns {string[]}
|
|
525
|
+
*/
|
|
526
|
+
export function confirmProtections({ verdictType, lang, writeScope = null }) {
|
|
527
|
+
const guards = classGuards({ verdictType, lang });
|
|
528
|
+
const lines = guards.map((g) => {
|
|
529
|
+
const desc = GUARD_DESCRIPTIONS[g.name];
|
|
530
|
+
if (!desc) {
|
|
531
|
+
throw new Error(`[authorflow] guard "${g.name}" has no line in GUARD_DESCRIPTIONS — every real guard shown to `
|
|
532
|
+
+ 'a person needs one, frozen beside CONFIRM_MENU');
|
|
533
|
+
}
|
|
534
|
+
return `${g.name} — ${desc}`;
|
|
535
|
+
});
|
|
536
|
+
if (Array.isArray(writeScope) && writeScope.length > 0) {
|
|
537
|
+
lines.push(`write fence — the run may only change files matching: ${writeScope.join(', ')}`);
|
|
538
|
+
}
|
|
539
|
+
return lines;
|
|
540
|
+
}
|
|
345
541
|
|
|
346
542
|
export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
|
|
347
543
|
+ 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
|
|
@@ -737,6 +933,70 @@ export function lawsBlock() {
|
|
|
737
933
|
sounds like it should be called.`;
|
|
738
934
|
}
|
|
739
935
|
|
|
936
|
+
/**
|
|
937
|
+
* WHAT MAY CHANGE, AND WHAT IS ONLY READ — replaces the old Q2 (PRD item 33 M3
|
|
938
|
+
* piece 3). Q2 used to ask the person, in prose, which files change and which
|
|
939
|
+
* are read-only; that decision is now Destination, PROVEN against the
|
|
940
|
+
* repository rather than typed (`docs/product/ITEM33-BUILD.md` "M3", ruling 2),
|
|
941
|
+
* and for a repo source Destination's answer IS the signed `writeScope` fence.
|
|
942
|
+
* This states that fact to the composer instead of letting it vanish along
|
|
943
|
+
* with Q2 — the same "nothing the composer received before may silently
|
|
944
|
+
* disappear" rule `applyGenreEnv` already keeps for a dropped env var.
|
|
945
|
+
*
|
|
946
|
+
* THROWS on an empty `writeScope`: Destination is a MANDATORY fence
|
|
947
|
+
* (`run-interview.mjs` refuses an empty answer at capture), so a caller
|
|
948
|
+
* reaching this with nothing to state is a bareloop wiring bug, not a job with
|
|
949
|
+
* no fence — a composer told it may change everything guards nothing.
|
|
950
|
+
* @param {string[]} writeScope
|
|
951
|
+
*/
|
|
952
|
+
export function writeScopeBlock(writeScope) {
|
|
953
|
+
if (!Array.isArray(writeScope) || writeScope.length === 0) {
|
|
954
|
+
throw new Error('[authorflow] writeScopeBlock needs a non-empty writeScope — Destination is the fence and a '
|
|
955
|
+
+ 'composer call with nothing to state is a bareloop wiring bug, not a job authored with no fence');
|
|
956
|
+
}
|
|
957
|
+
return `WHAT MAY CHANGE, AND WHAT IS ONLY READ\n\n`
|
|
958
|
+
+ 'The person\'s own Destination answer is a write fence, proven against the repository rather than typed as '
|
|
959
|
+
+ 'prose. You may only change files matching one of these patterns:\n\n'
|
|
960
|
+
+ `${writeScope.map((p) => ` - ${p}`).join('\n')}\n\n`
|
|
961
|
+
+ 'Everything else in the facts object and the listing below is READ-ONLY: you may look at it, and any command '
|
|
962
|
+
+ 'you run may read it, but no stage you declare may write, move or delete it.';
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* THE CONFIRMED-PLAN BLOCK (PRD item 33 M3 piece 4) — present only when the
|
|
967
|
+
* person already ran a confirm turn (`runConfirmTurn`'s `accepted`). States
|
|
968
|
+
* the composition law as an ORDER over the ACCEPTED checks, exactly the
|
|
969
|
+
* pattern {@link writeScopeBlock} already is for the fence: "compose these
|
|
970
|
+
* checks and no other" rather than a code matcher over the goal prose
|
|
971
|
+
* (ruling 6). Protections are restated as unchanged, never as checks to add.
|
|
972
|
+
* Open questions the person's own fix rounds left unresolved (D3) are named
|
|
973
|
+
* so the composer states them in notes rather than silently deciding them.
|
|
974
|
+
* `notChecked` (fix #1, run mu0voeo4) is anything the person asked for that
|
|
975
|
+
* no listed check can verify — shown so the composer names the gap in its
|
|
976
|
+
* own notes rather than the plan quietly reading as if everything asked for
|
|
977
|
+
* is covered.
|
|
978
|
+
* @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}} confirmed
|
|
979
|
+
*/
|
|
980
|
+
export function confirmedBlock(confirmed) {
|
|
981
|
+
const checks = confirmed.checks ?? [];
|
|
982
|
+
const protections = confirmed.protections ?? [];
|
|
983
|
+
const openQuestions = confirmed.openQuestions ?? [];
|
|
984
|
+
const notChecked = confirmed.notChecked ?? [];
|
|
985
|
+
return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
|
|
986
|
+
+ 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
|
|
987
|
+
+ 'neither do you:\n\n'
|
|
988
|
+
+ `${checks.map((c) => ` - ${c}`).join('\n') || ' (none)'}\n\n`
|
|
989
|
+
+ 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
|
|
990
|
+
+ `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
|
|
991
|
+
+ (notChecked.length
|
|
992
|
+
? 'THE PERSON ASKED FOR THESE, BUT NOTHING CHECKS THEM (state these in your notes — never silently claim they '
|
|
993
|
+
+ `are covered):\n\n${notChecked.map((n) => ` - ${n}`).join('\n')}\n\n`
|
|
994
|
+
: '')
|
|
995
|
+
+ (openQuestions.length
|
|
996
|
+
? `OPEN QUESTIONS the confirm turn could not resolve (state these in your notes, never decide them silently):\n\n${openQuestions.map((q) => ` - ${q}`).join('\n')}`
|
|
997
|
+
: 'The confirm turn left no open questions.');
|
|
998
|
+
}
|
|
999
|
+
|
|
740
1000
|
/**
|
|
741
1001
|
* The whole authoring prompt, as one string.
|
|
742
1002
|
*
|
|
@@ -749,19 +1009,40 @@ export function lawsBlock() {
|
|
|
749
1009
|
* the one thing that tells the composer what DONE is allowed to mean here; a
|
|
750
1010
|
* defaulted class would be the prompt answering a question the user was asked.
|
|
751
1011
|
*
|
|
1012
|
+
* `writeScope` carries what the old Q2 used to (PRD item 33 M3 piece 3): Q2 is
|
|
1013
|
+
* gone from the numbered interview, so what may change and what is read-only
|
|
1014
|
+
* reaches the composer through {@link writeScopeBlock} instead, fed from
|
|
1015
|
+
* Destination's own proven fence — never silently dropped.
|
|
1016
|
+
*
|
|
752
1017
|
* @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
|
|
753
1018
|
* facts: any, listingBlock: string, lang: string, verdictType: string,
|
|
754
1019
|
* guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
|
|
755
|
-
* ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any
|
|
1020
|
+
* ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
|
|
1021
|
+
* writeScope?: string[]|null,
|
|
1022
|
+
* confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
|
|
756
1023
|
*/
|
|
757
|
-
export function authorPrompt({
|
|
1024
|
+
export function authorPrompt({
|
|
1025
|
+
answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
|
|
1026
|
+
ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
|
|
1027
|
+
// PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
|
|
1028
|
+
// turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
|
|
1029
|
+
// only when a confirm turn actually ran and the person accepted a plan.
|
|
1030
|
+
confirmed = null,
|
|
1031
|
+
}) {
|
|
758
1032
|
const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
|
|
759
1033
|
if (statement === null) {
|
|
760
1034
|
throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
|
|
761
1035
|
+ `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
|
|
762
1036
|
}
|
|
1037
|
+
// The signed table's own label for each key (PRD item 33 M3 piece 3's wording
|
|
1038
|
+
// fix), shown beside the question so the composer reads the same Field/Holds
|
|
1039
|
+
// pairing a person sees at the terminal. `verdictType` is already checked
|
|
1040
|
+
// against `CLASS_STATEMENTS` above and that set's keys are exactly
|
|
1041
|
+
// `QUESTION_SETS`'s, so `labelsFor` cannot throw here. A key with no label
|
|
1042
|
+
// (hitl's own fourth question has no signed-table row) is shown with none.
|
|
1043
|
+
const labels = labelsFor(String(verdictType));
|
|
763
1044
|
const interview = Object.entries(questions)
|
|
764
|
-
.map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
|
|
1045
|
+
.map(([n, q]) => `Q${n}${labels[n] ? ` (${labels[n]})` : ''}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
|
|
765
1046
|
.join('\n\n');
|
|
766
1047
|
|
|
767
1048
|
const role = `You are composing the close for one job.
|
|
@@ -819,6 +1100,11 @@ measure anything.`;
|
|
|
819
1100
|
// composer reads that plus the catalogue and works the rest out.
|
|
820
1101
|
`WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
|
|
821
1102
|
`THE INTERVIEW — the person's own words\n\n${interview}`,
|
|
1103
|
+
// Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
|
|
1104
|
+
// change, which are read-only) is proven against the machine now, not typed,
|
|
1105
|
+
// and this is where that proof reaches the composer instead of vanishing.
|
|
1106
|
+
...(writeScope && writeScope.length ? [writeScopeBlock(writeScope)] : []),
|
|
1107
|
+
...(confirmed ? [confirmedBlock(confirmed)] : []),
|
|
822
1108
|
`THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
|
|
823
1109
|
+ (listingBlock ? `\n\n${listingBlock}` : ''),
|
|
824
1110
|
// CLASS-SCOPED (softgreen module 3): the composer for a green job is never
|
|
@@ -1273,6 +1559,320 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1273
1559
|
return { ...r, declaration: r.artifact };
|
|
1274
1560
|
}
|
|
1275
1561
|
|
|
1562
|
+
// ── THE CONFIRM TURN (PRD item 33 M3 piece 4) ───────────────────────────────
|
|
1563
|
+
//
|
|
1564
|
+
// The confirm turn drafts a PLAN — checks it will compose, protections
|
|
1565
|
+
// (always-on guards, never named in the goal), and one signed goal sentence —
|
|
1566
|
+
// from the person's own free-text answers plus the read-only survey/listing
|
|
1567
|
+
// the scout or seed-read already paid for, THEN shows it back and lets the
|
|
1568
|
+
// person confirm, fix (up to the 2-round cap, ruling 5), type the goal
|
|
1569
|
+
// themselves, or start over. No hand-authored matcher ever compares the goal
|
|
1570
|
+
// against the checks (ruling 6) — the model states its own plan, in the
|
|
1571
|
+
// model-facing prompt below, and the person is the one judgement that
|
|
1572
|
+
// accepts it.
|
|
1573
|
+
|
|
1574
|
+
export const CONFIRM_TOOL_NAME = 'confirm_plan';
|
|
1575
|
+
|
|
1576
|
+
/** Acknowledgement text the confirm tool's `execute` returns — mirrors
|
|
1577
|
+
* {@link DECLARATION_ACK}'s role: an output channel that records and
|
|
1578
|
+
* acknowledges, takes no action, and ends the call the moment it fires
|
|
1579
|
+
* (`makeLoopGenerate` stops the loop on any tool execute). */
|
|
1580
|
+
export const CONFIRM_ACK = 'plan received';
|
|
1581
|
+
|
|
1582
|
+
/** the confirm channel's own STRUCTURE_INSTRUCTION_TOOL analogue, shown after
|
|
1583
|
+
* a malformed reply so the retry names the ONE required shape rather than
|
|
1584
|
+
* repeating the whole system prompt */
|
|
1585
|
+
export const CONFIRM_STRUCTURE_INSTRUCTION = `Your reply did not deliver exactly one plan through the ${CONFIRM_TOOL_NAME} tool. `
|
|
1586
|
+
+ 'Call it exactly once, with the whole plan as its arguments: checks, one goal sentence, anything not covered '
|
|
1587
|
+
+ 'by a check, and any genuinely missing questions.';
|
|
1588
|
+
|
|
1589
|
+
/** @type {Record<string, any>} */
|
|
1590
|
+
const CONFIRM_SCHEMA = Object.freeze({
|
|
1591
|
+
type: 'object',
|
|
1592
|
+
properties: {
|
|
1593
|
+
checks: {
|
|
1594
|
+
type: 'array',
|
|
1595
|
+
items: { type: 'string', minLength: 1 },
|
|
1596
|
+
description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
|
|
1597
|
+
+ 'success looks like answers actually asked for — never a check invented beyond them',
|
|
1598
|
+
},
|
|
1599
|
+
goal: {
|
|
1600
|
+
type: 'string',
|
|
1601
|
+
minLength: 1,
|
|
1602
|
+
description: 'ONE sentence naming every listed check (never a protection or guard — those are not yours to '
|
|
1603
|
+
+ "name) — this becomes the signed job spec's goal",
|
|
1604
|
+
},
|
|
1605
|
+
notChecked: {
|
|
1606
|
+
type: 'array',
|
|
1607
|
+
items: { type: 'string', minLength: 1 },
|
|
1608
|
+
description: 'anything the person asked for (in the Goal, What success looks like, Guardrails, or '
|
|
1609
|
+
+ 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
|
|
1610
|
+
+ 'own words; an empty list means every ask is covered by a listed check',
|
|
1611
|
+
},
|
|
1612
|
+
questions: {
|
|
1613
|
+
type: 'array',
|
|
1614
|
+
items: { type: 'string', minLength: 1 },
|
|
1615
|
+
description: 'anything genuinely missing from the person\'s answers that you need to plan honestly — never '
|
|
1616
|
+
+ 'asked to double-check something already answered',
|
|
1617
|
+
},
|
|
1618
|
+
},
|
|
1619
|
+
required: ['checks', 'goal', 'notChecked', 'questions'],
|
|
1620
|
+
additionalProperties: false,
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
/** The output channel. It records and acknowledges; it takes no action.
|
|
1624
|
+
* @param {{calls: any[]}} box */
|
|
1625
|
+
export function confirmTool(box) {
|
|
1626
|
+
return {
|
|
1627
|
+
name: CONFIRM_TOOL_NAME,
|
|
1628
|
+
description: 'Deliver the drafted plan: the checks you will compose, one goal sentence, anything the person '
|
|
1629
|
+
+ 'asked for that no check covers, and any genuinely missing questions. Call this exactly once.',
|
|
1630
|
+
parameters: CONFIRM_SCHEMA,
|
|
1631
|
+
execute: async (/** @type {any} */ args) => { box.calls.push(args); return CONFIRM_ACK; },
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** THE CONFIRM CHANNEL — the one this turn uses, over the shared
|
|
1636
|
+
* {@link askStructured} ladder, exactly as {@link declarationChannel} is for
|
|
1637
|
+
* the authoring call. */
|
|
1638
|
+
const confirmChannel = () => ({
|
|
1639
|
+
name: CONFIRM_TOOL_NAME,
|
|
1640
|
+
instruction: CONFIRM_STRUCTURE_INSTRUCTION,
|
|
1641
|
+
textPath: 'plan',
|
|
1642
|
+
tool: (/** @type {{calls: any[]}} */ box) => confirmTool(box),
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* The confirm turn's system prompt (model-facing, registered — a commit
|
|
1647
|
+
* touching this needs Failure/Addresses/Corrects labels, `src/promptregisters.js`).
|
|
1648
|
+
* States ruling 6 ("a genre never adds a check the goal did not ask for") as
|
|
1649
|
+
* an ORDER to the model itself, the same register {@link AUTHOR_SYSTEM} and
|
|
1650
|
+
* {@link CLASS_STATEMENTS} already use, rather than a code matcher over prose.
|
|
1651
|
+
*/
|
|
1652
|
+
export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
|
|
1653
|
+
+ 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
|
|
1654
|
+
+ 'signed goal sentence. You never propose a check the Goal or What-success-looks-like answers did not ask for '
|
|
1655
|
+
+ '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
|
|
1656
|
+
+ 'that is exactly the mistake this order exists to prevent). The always-on guards (changed-from-seed, '
|
|
1657
|
+
+ 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
|
|
1658
|
+
+ 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
|
|
1659
|
+
+ 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
|
|
1660
|
+
+ 'listed and nothing more. For anything the person asked for that none of your listed checks can verify, name it '
|
|
1661
|
+
+ 'plainly in `notChecked`, in the person\'s own words — never omit a gap to make the plan look complete; '
|
|
1662
|
+
+ 'over-reporting a gap is always the safe direction. Ask a question only where an answer is genuinely missing — '
|
|
1663
|
+
+ 'never to double-check something already answered. You cannot read anything and you cannot run anything: '
|
|
1664
|
+
+ 'everything you know is in the message you are given.';
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* The confirm turn's per-round prompt (model-facing, registered). Shows the
|
|
1668
|
+
* person's own answers with their labels (the signed table's Field/Holds
|
|
1669
|
+
* pairing, same as {@link authorPrompt}'s interview block), the survey or
|
|
1670
|
+
* listing already paid for, the write fence, the detected language, "worse
|
|
1671
|
+
* than before" when the person gave one (repo only), and — on a fix round —
|
|
1672
|
+
* what the person asked to change.
|
|
1673
|
+
* @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
|
|
1674
|
+
* labels?: Record<string|number, string>, facts?: any, listing?: string|null,
|
|
1675
|
+
* writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
|
|
1676
|
+
* fixText?: string|null}} o
|
|
1677
|
+
*/
|
|
1678
|
+
export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null }) {
|
|
1679
|
+
const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
|
|
1680
|
+
for (const k of Object.keys(questions)) {
|
|
1681
|
+
const label = labels[k] ? ` (${labels[k]})` : '';
|
|
1682
|
+
lines.push(`Q${k}${label}. ${questions[k]}`);
|
|
1683
|
+
lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
|
|
1684
|
+
}
|
|
1685
|
+
if (isRepo && worseThanBefore) {
|
|
1686
|
+
lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
|
|
1687
|
+
}
|
|
1688
|
+
lines.push('', `LANGUAGE: ${lang}`);
|
|
1689
|
+
if (writeScope) lines.push(`WRITE SCOPE (the fence — the run may write only here): ${writeScope.join(', ')}`);
|
|
1690
|
+
if (facts) lines.push('', `READ-ONLY SURVEY:\n${JSON.stringify(facts)}`);
|
|
1691
|
+
if (listing) lines.push('', `SEED LISTING:\n${listing}`);
|
|
1692
|
+
if (fixText) lines.push('', `THE PERSON ASKED FOR A CHANGE: ${fixText}`, 'Revise the plan and resubmit.');
|
|
1693
|
+
return lines.join('\n');
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* ONE confirm-turn round-trip: capStop, then one structured ask over the
|
|
1698
|
+
* confirm channel. Split out of {@link runConfirmTurn} only so the ceiling
|
|
1699
|
+
* check and the ask sit beside each other exactly once.
|
|
1700
|
+
* @param {{convo: any[], generate: Function, mode: 'tool'|'text', book: ReturnType<typeof makeCostBook>, label: string}} o
|
|
1701
|
+
* @returns {Promise<{plan: any, convo: any[], providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
|
|
1702
|
+
*/
|
|
1703
|
+
async function askConfirmPlan({ convo, generate, mode, book, label }) {
|
|
1704
|
+
const halt = book.capStop();
|
|
1705
|
+
if (halt) return { plan: null, convo, providerError: null, red: null, budget: halt };
|
|
1706
|
+
const r = await askStructured({ messages: convo, generate, mode, retries: MAX_STRUCTURE_RETRIES, label, book, channel: confirmChannel() });
|
|
1707
|
+
return { plan: r.artifact, convo: r.convo, providerError: r.providerError, red: r.red, budget: r.budget };
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* THE CONFIRM TURN: the $0 half (worse-than-before for a repo, a language
|
|
1712
|
+
* pick when `lang` carries `detectLanguage`'s own `ambiguous` shape), then up
|
|
1713
|
+
* to 2 PAID rounds (ruling 5) of draft → show → the person's menu pick
|
|
1714
|
+
* (ruling 5 addendum). `ask` is the ONE interactive seam — an async function
|
|
1715
|
+
* from a "what's being asked" descriptor to the person's answer, or `null`
|
|
1716
|
+
* meaning input ended (the same signal `scripts/run-interview.mjs`'s own
|
|
1717
|
+
* `nextLine()` already uses) — never a process exit here, because this is a
|
|
1718
|
+
* library function, not a script. A `null` at ANY ask stops the whole turn as
|
|
1719
|
+
* `confirm-abandoned`, spending nothing beyond whatever rounds already ran.
|
|
1720
|
+
*
|
|
1721
|
+
* D3's shape is enforced by the loop bound alone (`round <= 2`), never a
|
|
1722
|
+
* counter checked after the fact: a "fix" on round 2 returns immediately —
|
|
1723
|
+
* there is no 3rd call to make.
|
|
1724
|
+
*
|
|
1725
|
+
* @param {{verdictType: string, answers: Record<string|number, string>,
|
|
1726
|
+
* questions: Record<string|number, string>, labels?: Record<string|number, string>,
|
|
1727
|
+
* facts?: any, listing?: string|null, writeScope?: string[]|null, isRepo: boolean,
|
|
1728
|
+
* lang: string|{kind: 'ambiguous', candidates: string[], dir: string},
|
|
1729
|
+
* generate: Function, book: ReturnType<typeof makeCostBook>,
|
|
1730
|
+
* ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
|
|
1731
|
+
* onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
|
|
1732
|
+
* worseThanBefore?: string}} o
|
|
1733
|
+
* @returns {Promise<{ok: boolean,
|
|
1734
|
+
* stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
|
|
1735
|
+
* rounds: number,
|
|
1736
|
+
* accepted: {goal: string, checks: string[], protections: string[], lang: string,
|
|
1737
|
+
* worseThanBefore: string, openQuestions: string[], notChecked: string[]}|null,
|
|
1738
|
+
* reds: Red[], cost: any}>}
|
|
1739
|
+
*/
|
|
1740
|
+
export async function runConfirmTurn({
|
|
1741
|
+
verdictType, answers, questions, labels = {}, facts = null, listing = null, writeScope = null,
|
|
1742
|
+
isRepo, lang, generate, book, ask, onPhase = () => {}, mode = 'tool',
|
|
1743
|
+
// `undefined` (the default) means "ask me" — every S2 caller (nothing
|
|
1744
|
+
// pre-resolves this). A caller that already asked worseThanBefore itself
|
|
1745
|
+
// (`authorCloseForJob`'s own $0 phase, run BEFORE the scout per D7) passes
|
|
1746
|
+
// the resolved string straight through, and this turn asks nothing a
|
|
1747
|
+
// second time. `''` is a legal resolved answer ("nothing beyond
|
|
1748
|
+
// Guardrails") and is NOT the same as "ask me" — only `undefined` is.
|
|
1749
|
+
worseThanBefore: presetWorseThanBefore = undefined,
|
|
1750
|
+
}) {
|
|
1751
|
+
/** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
|
|
1752
|
+
/** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
|
|
1753
|
+
const base = () => ({ ok: false, stop: null, rounds: 0, accepted: null, reds: [], cost: book.report() });
|
|
1754
|
+
/** @param {number} rounds @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
|
|
1755
|
+
const abandon = (rounds) => ({ ...base(), stop: /** @type {ConfirmStop} */ ('confirm-abandoned'), rounds, cost: book.report() });
|
|
1756
|
+
|
|
1757
|
+
// ── the $0 half, entirely before any token spends (D7) ────────────────────
|
|
1758
|
+
/** @type {string} */
|
|
1759
|
+
let worseThanBefore = '';
|
|
1760
|
+
if (presetWorseThanBefore !== undefined) {
|
|
1761
|
+
worseThanBefore = presetWorseThanBefore;
|
|
1762
|
+
} else if (isRepo) {
|
|
1763
|
+
onPhase('confirm-worse-than-before');
|
|
1764
|
+
const wtb = await ask({ kind: 'worseThanBefore', field: WORSE_THAN_BEFORE_FIELD });
|
|
1765
|
+
if (wtb === null) return abandon(0);
|
|
1766
|
+
worseThanBefore = redactSecrets(String(wtb).trim());
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/** @type {string} */
|
|
1770
|
+
let resolvedLang = typeof lang === 'string' ? lang : '';
|
|
1771
|
+
if (lang && typeof lang === 'object' && lang.kind === 'ambiguous') {
|
|
1772
|
+
onPhase('confirm-language-pick', { candidates: lang.candidates });
|
|
1773
|
+
const pick = await ask({ kind: 'language', field: LANGUAGE_PICK_FIELD, candidates: lang.candidates });
|
|
1774
|
+
if (pick === null) return abandon(0);
|
|
1775
|
+
resolvedLang = String(pick);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
|
|
1779
|
+
/** @type {string[]} */
|
|
1780
|
+
const openQuestions = [];
|
|
1781
|
+
/** @type {string|null} */
|
|
1782
|
+
let fixText = null;
|
|
1783
|
+
let convo = [{
|
|
1784
|
+
role: 'user',
|
|
1785
|
+
content: confirmPrompt({
|
|
1786
|
+
answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore,
|
|
1787
|
+
}),
|
|
1788
|
+
}];
|
|
1789
|
+
|
|
1790
|
+
for (let round = 1; round <= 2; round += 1) {
|
|
1791
|
+
if (fixText) {
|
|
1792
|
+
convo = [...convo, { role: 'user', content: confirmPrompt({
|
|
1793
|
+
answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText,
|
|
1794
|
+
}) }];
|
|
1795
|
+
}
|
|
1796
|
+
onPhase('confirm-round', { round });
|
|
1797
|
+
const label = round === 1 ? 'confirm' : `confirm#${round}`;
|
|
1798
|
+
const r = await askConfirmPlan({ convo, generate, mode, book, label });
|
|
1799
|
+
convo = r.convo;
|
|
1800
|
+
|
|
1801
|
+
if (r.budget) return { ...base(), stop: r.budget, rounds: round - 1, cost: book.report() };
|
|
1802
|
+
if (r.providerError) return { ...base(), stop: /** @type {ConfirmStop} */ ('provider-red'), rounds: round, reds: [{ code: 'provider-red', path: label, detail: redactSecrets(r.providerError) }], cost: book.report() };
|
|
1803
|
+
if (!r.plan) return { ...base(), stop: /** @type {ConfirmStop} */ ('artifact-red'), rounds: round, reds: [/** @type {Red} */ (r.red)], cost: book.report() };
|
|
1804
|
+
|
|
1805
|
+
// "Unpriced is never free" (F6): a plan can arrive structurally fine on a
|
|
1806
|
+
// call whose OWN cost came back unknown. Nothing downstream ever makes
|
|
1807
|
+
// another call to discover that (a "confirm" pick is the last call this
|
|
1808
|
+
// turn ever makes), so it is checked HERE, once, right after the call
|
|
1809
|
+
// that might have caused it — never silently accepted as a signable plan
|
|
1810
|
+
// whose ceiling can no longer be enforced.
|
|
1811
|
+
const postCallHalt = book.capStop();
|
|
1812
|
+
if (postCallHalt) return { ...base(), stop: postCallHalt, rounds: round, cost: book.report() };
|
|
1813
|
+
|
|
1814
|
+
// THE REAL PROTECTIONS (fix #1, run mu0voeo4, 2026-09-14): never the
|
|
1815
|
+
// model's `protections` prose — the schema no longer even asks the model
|
|
1816
|
+
// for one — but the guards `classGuards` will actually compose, plus the
|
|
1817
|
+
// write fence. `notChecked` is the model's own honest gap list, carried
|
|
1818
|
+
// through unchanged.
|
|
1819
|
+
const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope });
|
|
1820
|
+
const notChecked = [...(r.plan.notChecked ?? [])];
|
|
1821
|
+
const plan = { ...r.plan, protections, notChecked };
|
|
1822
|
+
onPhase('confirm-done', { round, plan });
|
|
1823
|
+
const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
|
|
1824
|
+
if (picked === null) return abandon(round);
|
|
1825
|
+
if (picked === 'start-over') return { ...base(), stop: /** @type {ConfirmStop} */ ('confirm-restart'), rounds: round, cost: book.report() };
|
|
1826
|
+
|
|
1827
|
+
if (picked === 'confirm') {
|
|
1828
|
+
return {
|
|
1829
|
+
ok: true, stop: null, rounds: round,
|
|
1830
|
+
accepted: {
|
|
1831
|
+
goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
1832
|
+
lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
|
|
1833
|
+
},
|
|
1834
|
+
reds: [], cost: book.report(),
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (picked === 'type-goal') {
|
|
1839
|
+
const typed = await ask({ kind: 'goal' });
|
|
1840
|
+
if (typed === null) return abandon(round);
|
|
1841
|
+
return {
|
|
1842
|
+
ok: true, stop: null, rounds: round,
|
|
1843
|
+
accepted: {
|
|
1844
|
+
goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
1845
|
+
lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
|
|
1846
|
+
},
|
|
1847
|
+
reds: [], cost: book.report(),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// picked === 'fix'
|
|
1852
|
+
const fix = await ask({ kind: 'fix' });
|
|
1853
|
+
if (fix === null) return abandon(round);
|
|
1854
|
+
const redactedFix = redactSecrets(String(fix).trim());
|
|
1855
|
+
openQuestions.push(redactedFix);
|
|
1856
|
+
if (round === 2) {
|
|
1857
|
+
// D3: after round 2 a "fix" is passed to the composer verbatim (via
|
|
1858
|
+
// openQuestions, shown at signing) — there is no 3rd call.
|
|
1859
|
+
return {
|
|
1860
|
+
ok: true, stop: null, rounds: round,
|
|
1861
|
+
accepted: {
|
|
1862
|
+
goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
1863
|
+
lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
|
|
1864
|
+
},
|
|
1865
|
+
reds: [], cost: book.report(),
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
fixText = redactedFix;
|
|
1869
|
+
}
|
|
1870
|
+
// unreachable: the loop above always returns by round 2 — kept only so a
|
|
1871
|
+
// future edit that breaks that invariant fails loudly rather than falling
|
|
1872
|
+
// through to `undefined`.
|
|
1873
|
+
throw new Error('[authorflow] runConfirmTurn fell through its 2-round cap without returning');
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1276
1876
|
/**
|
|
1277
1877
|
* THE AUTHORING FLOW.
|
|
1278
1878
|
*
|
|
@@ -1309,12 +1909,32 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1309
1909
|
* onPhase?: (phase: string, data?: any) => void,
|
|
1310
1910
|
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
1311
1911
|
* maxRevisions?: number, structureRetries?: number,
|
|
1312
|
-
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any
|
|
1912
|
+
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
|
|
1913
|
+
* priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
|
|
1914
|
+
* priorRaws?: any[]|null,
|
|
1915
|
+
* confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
|
|
1313
1916
|
*/
|
|
1314
1917
|
export async function authorClose({
|
|
1315
1918
|
workdir, seedRef, lang, verdictType,
|
|
1316
1919
|
answers, questions = GREEN_QUESTIONS,
|
|
1920
|
+
// Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — this is
|
|
1921
|
+
// what carries its information into the prompt instead (`writeScopeBlock`).
|
|
1922
|
+
// Optional and defaulted to `null` so every caller that predates the reshape
|
|
1923
|
+
// still runs byte-identical; a REAL caller (`authorCloseForJob`) always
|
|
1924
|
+
// passes the repo's proven `writeScope`.
|
|
1925
|
+
writeScope = null,
|
|
1317
1926
|
scout, listing = null,
|
|
1927
|
+
// PRD item 33 M3 piece 4: the confirm turn's own paid calls — absorbed
|
|
1928
|
+
// BESIDE the scout's, below, so the ONE ceiling this run advertised is the
|
|
1929
|
+
// one it enforces (the standing hard line's money form): a confirm turn
|
|
1930
|
+
// that already spent half the ceiling must not let the author call spend
|
|
1931
|
+
// as if it never happened. `null`/absent is every caller that ran no
|
|
1932
|
+
// confirm turn — byte-identical to today.
|
|
1933
|
+
priorCalls = null, priorRaws = null,
|
|
1934
|
+
// The confirm turn's ACCEPTED plan (`runConfirmTurn`'s `accepted`), fed
|
|
1935
|
+
// straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
|
|
1936
|
+
// that ran no confirm turn — the prompt is then byte-identical to today.
|
|
1937
|
+
confirmed = null,
|
|
1318
1938
|
generate, seedReadFn = runSeedReadStages, closeCtx = {},
|
|
1319
1939
|
ceilingUsd = null,
|
|
1320
1940
|
// The two REPORTING seams, defaulted to nothing so every existing caller is
|
|
@@ -1351,6 +1971,7 @@ export async function authorClose({
|
|
|
1351
1971
|
// that spends nothing more: run mslhn707 refused at the $0 preflight below,
|
|
1352
1972
|
// and the survey text that would have said WHY died with the process.
|
|
1353
1973
|
book.absorb(scout?.calls ?? [], scout?.raws ?? []);
|
|
1974
|
+
book.absorb(priorCalls ?? [], priorRaws ?? []);
|
|
1354
1975
|
/** @type {any} */
|
|
1355
1976
|
const base = {
|
|
1356
1977
|
ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
|
|
@@ -1487,7 +2108,7 @@ export async function authorClose({
|
|
|
1487
2108
|
// ── the grounded loop ─────────────────────────────────────────────────────
|
|
1488
2109
|
const prompt = authorPrompt({
|
|
1489
2110
|
answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
|
|
1490
|
-
lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
|
|
2111
|
+
lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
|
|
1491
2112
|
});
|
|
1492
2113
|
/** @type {any[]} */
|
|
1493
2114
|
let messages = [{ role: 'user', content: prompt }];
|