bareloop 0.25.1 → 0.27.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 +525 -0
- package/bareloop.context.md +384 -84
- package/package.json +4 -3
- package/src/authorflow.js +947 -104
- package/src/authoring.js +33 -15
- package/src/authorjob.js +161 -5
- package/src/authorscout.js +16 -11
- package/src/cardauthor.js +28 -18
- package/src/cli.js +10 -1
- package/src/detectlang.js +200 -0
- package/src/index.js +26 -30
- package/src/job.js +45 -28
- package/src/planrun.js +81 -2
- package/src/promptregisters.js +14 -0
- package/src/providers.js +51 -3
- package/src/source.js +290 -33
- package/src/text.js +45 -0
- package/src/validate.js +25 -1
- package/types/authorflow.d.ts +476 -105
- 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 +24 -2
- package/types/planrun.d.ts +41 -1
- package/types/providers.d.ts +37 -2
- package/types/source.d.ts +87 -0
- package/types/text.d.ts +35 -0
- package/types/validate.d.ts +22 -0
package/src/authorflow.js
CHANGED
|
@@ -83,7 +83,7 @@ import {
|
|
|
83
83
|
validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES,
|
|
84
84
|
} from './authoring.js';
|
|
85
85
|
import { buildSeedListing, cleanEntry, SURVEY_CAUSES, AUTHOR_CALL_TIMEOUT_MS } from './authorscout.js';
|
|
86
|
-
import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
|
|
86
|
+
import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
|
|
87
87
|
import { redactSecrets } from './validate.js';
|
|
88
88
|
|
|
89
89
|
const require = createRequire(import.meta.url);
|
|
@@ -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,146 @@ 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
|
+
* F175's open half (docs/logs/FINDINGS.md, run mu0voeo4), hamr's ruling
|
|
474
|
+
* (2026-09-16): "Confirm" and "Type the goal yourself" may not bypass a
|
|
475
|
+
* `questions` entry the model itself raised on the plan being accepted — the
|
|
476
|
+
* person answers it inline, one question at a time, with NO extra model
|
|
477
|
+
* call (the round cap and D3 are untouched). This is the `field` handed to
|
|
478
|
+
* `ask({kind: 'answer', ...})` for each forced question — `question`,
|
|
479
|
+
* `index` (1-based) and `total` ride alongside on the step itself, not on
|
|
480
|
+
* this frozen constant, because they differ per question within one turn.
|
|
481
|
+
*/
|
|
482
|
+
export const ANSWER_QUESTION_FIELD = Object.freeze({
|
|
483
|
+
id: 'answerQuestion',
|
|
484
|
+
kind: 'mechanical',
|
|
485
|
+
label: 'Answer',
|
|
486
|
+
prompt: 'The plan raised a question it could not resolve on its own. Answer it before the plan can be signed '
|
|
487
|
+
+ '("Start over" or "Fix" are the ways out if you cannot answer it here).',
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* THE CONFIRM TURN'S MENU — a structured CHOICE the person picks from after
|
|
492
|
+
* each round, never free text matched against a pattern (ruling 6's "no
|
|
493
|
+
* hand-authored check matcher" reasoning applies here too: the person's own
|
|
494
|
+
* reply is read as one of these four keys, never parsed for intent).
|
|
495
|
+
* confirm — sign the plan and the drafted goal sentence as shown.
|
|
496
|
+
* fix — free text describing what to change; feeds round r+1, up
|
|
497
|
+
* to the 2-round cap (ruling 5); after round 2 a "fix" is
|
|
498
|
+
* passed to the composer verbatim and shown at signing as an
|
|
499
|
+
* open question rather than spending a 3rd call (D3).
|
|
500
|
+
* type-goal — the person's own sentence REPLACES the drafted goal,
|
|
501
|
+
* verbatim (redacted); everything else in the plan stands.
|
|
502
|
+
* start-over — abandon this confirm turn; the person reruns the
|
|
503
|
+
* interview from the beginning (`confirm-restart`).
|
|
504
|
+
* @type {Record<'confirm'|'fix'|'type-goal'|'start-over', string>}
|
|
505
|
+
*/
|
|
506
|
+
export const CONFIRM_MENU = Object.freeze({
|
|
507
|
+
confirm: 'Confirm — sign this plan and goal as shown',
|
|
508
|
+
fix: 'Fix — describe what to change',
|
|
509
|
+
'type-goal': 'Type the goal sentence yourself',
|
|
510
|
+
'start-over': 'Start over — rerun the interview from the beginning',
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* PLAIN-ENGLISH lines for the guards `classGuards` can compose, keyed by
|
|
515
|
+
* guard NAME — frozen beside {@link CONFIRM_MENU} because both are constants
|
|
516
|
+
* the confirm turn shows a person verbatim. One entry per guard that exists
|
|
517
|
+
* in `MECHANICAL_GUARDS` (`src/authoring.js`); {@link confirmProtections}
|
|
518
|
+
* throws if a guard `classGuards` hands back has no line here, so a battery
|
|
519
|
+
* that grows a guard fails loudly instead of showing a blank description.
|
|
520
|
+
* @type {Record<string, string>}
|
|
521
|
+
*/
|
|
522
|
+
export const GUARD_DESCRIPTIONS = Object.freeze({
|
|
523
|
+
'changed-from-seed': 'the run must actually change at least one file in scope — a close that reports done '
|
|
524
|
+
+ 'without touching anything is refused',
|
|
525
|
+
'no-suppressions': 'the run may not silence a checker instead of fixing what it flags (things like @ts-ignore, '
|
|
526
|
+
+ 'eslint-disable, or # type: ignore) — none of those may appear in the diff',
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* THE REAL PROTECTIONS a person is shown, and what {@link runConfirmTurn}
|
|
531
|
+
* records as `accepted.protections` — fixes finding #1 from live run
|
|
532
|
+
* mu0voeo4 (2026-09-14): the confirm turn used to show whatever prose the
|
|
533
|
+
* MODEL invented for `protections`, and that run's model invented a
|
|
534
|
+
* "behavior-preservation guard" this build cannot check — no kind in the
|
|
535
|
+
* catalogue can verify runtime behaviour, and the author call's own note
|
|
536
|
+
* elsewhere says outright that behaviour-preservation is not composed here.
|
|
537
|
+
* This reads the SAME guards `classGuards` will actually compose into the
|
|
538
|
+
* close — one source, never a second hand-typed list — rendered through the
|
|
539
|
+
* frozen line in {@link GUARD_DESCRIPTIONS}, plus the write fence (a real,
|
|
540
|
+
* signed fence — {@link writeScopeBlock}'s own subject) when one is set.
|
|
541
|
+
* @param {{verdictType: string, lang: string, writeScope?: string[]|null}} o
|
|
542
|
+
* @returns {string[]}
|
|
543
|
+
*/
|
|
544
|
+
export function confirmProtections({ verdictType, lang, writeScope = null }) {
|
|
545
|
+
const guards = classGuards({ verdictType, lang });
|
|
546
|
+
const lines = guards.map((g) => {
|
|
547
|
+
const desc = GUARD_DESCRIPTIONS[g.name];
|
|
548
|
+
if (!desc) {
|
|
549
|
+
throw new Error(`[authorflow] guard "${g.name}" has no line in GUARD_DESCRIPTIONS — every real guard shown to `
|
|
550
|
+
+ 'a person needs one, frozen beside CONFIRM_MENU');
|
|
551
|
+
}
|
|
552
|
+
return `${g.name} — ${desc}`;
|
|
553
|
+
});
|
|
554
|
+
if (Array.isArray(writeScope) && writeScope.length > 0) {
|
|
555
|
+
lines.push(`write fence — the run may only change files matching: ${writeScope.join(', ')}`);
|
|
556
|
+
}
|
|
557
|
+
return lines;
|
|
558
|
+
}
|
|
345
559
|
|
|
346
560
|
export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
|
|
347
561
|
+ 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
|
|
@@ -737,6 +951,81 @@ export function lawsBlock() {
|
|
|
737
951
|
sounds like it should be called.`;
|
|
738
952
|
}
|
|
739
953
|
|
|
954
|
+
/**
|
|
955
|
+
* WHAT MAY CHANGE, AND WHAT IS ONLY READ — replaces the old Q2 (PRD item 33 M3
|
|
956
|
+
* piece 3). Q2 used to ask the person, in prose, which files change and which
|
|
957
|
+
* are read-only; that decision is now Destination, PROVEN against the
|
|
958
|
+
* repository rather than typed (`docs/product/ITEM33-BUILD.md` "M3", ruling 2),
|
|
959
|
+
* and for a repo source Destination's answer IS the signed `writeScope` fence.
|
|
960
|
+
* This states that fact to the composer instead of letting it vanish along
|
|
961
|
+
* with Q2 — the same "nothing the composer received before may silently
|
|
962
|
+
* disappear" rule `applyGenreEnv` already keeps for a dropped env var.
|
|
963
|
+
*
|
|
964
|
+
* THROWS on an empty `writeScope`: Destination is a MANDATORY fence
|
|
965
|
+
* (`run-interview.mjs` refuses an empty answer at capture), so a caller
|
|
966
|
+
* reaching this with nothing to state is a bareloop wiring bug, not a job with
|
|
967
|
+
* no fence — a composer told it may change everything guards nothing.
|
|
968
|
+
* @param {string[]} writeScope
|
|
969
|
+
*/
|
|
970
|
+
export function writeScopeBlock(writeScope) {
|
|
971
|
+
if (!Array.isArray(writeScope) || writeScope.length === 0) {
|
|
972
|
+
throw new Error('[authorflow] writeScopeBlock needs a non-empty writeScope — Destination is the fence and a '
|
|
973
|
+
+ 'composer call with nothing to state is a bareloop wiring bug, not a job authored with no fence');
|
|
974
|
+
}
|
|
975
|
+
return `WHAT MAY CHANGE, AND WHAT IS ONLY READ\n\n`
|
|
976
|
+
+ 'The person\'s own Destination answer is a write fence, proven against the repository rather than typed as '
|
|
977
|
+
+ 'prose. You may only change files matching one of these patterns:\n\n'
|
|
978
|
+
+ `${writeScope.map((p) => ` - ${p}`).join('\n')}\n\n`
|
|
979
|
+
+ 'Everything else in the facts object and the listing below is READ-ONLY: you may look at it, and any command '
|
|
980
|
+
+ 'you run may read it, but no stage you declare may write, move or delete it.';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* THE CONFIRMED-PLAN BLOCK (PRD item 33 M3 piece 4) — present only when the
|
|
985
|
+
* person already ran a confirm turn (`runConfirmTurn`'s `accepted`). States
|
|
986
|
+
* the composition law as an ORDER over the ACCEPTED checks, exactly the
|
|
987
|
+
* pattern {@link writeScopeBlock} already is for the fence: "compose these
|
|
988
|
+
* checks and no other" rather than a code matcher over the goal prose
|
|
989
|
+
* (ruling 6). Protections are restated as unchanged, never as checks to add.
|
|
990
|
+
* Open questions the person's own fix rounds left unresolved (D3) are named
|
|
991
|
+
* so the composer states them in notes rather than silently deciding them.
|
|
992
|
+
* `notChecked` (fix #1, run mu0voeo4) is anything the person asked for that
|
|
993
|
+
* no listed check can verify — shown so the composer names the gap in its
|
|
994
|
+
* own notes rather than the plan quietly reading as if everything asked for
|
|
995
|
+
* is covered.
|
|
996
|
+
* `answeredQuestions` (F175's open half, run mu0voeo4, hamr's 2026-09-16
|
|
997
|
+
* ruling) is every `Q:/A:` pair the person answered inline when a "Confirm"
|
|
998
|
+
* or "Type the goal yourself" pick forced a raised question before
|
|
999
|
+
* acceptance — BINDING, never re-decided by the composer, and a DIFFERENT
|
|
1000
|
+
* meaning from `openQuestions` (which means "could not resolve"): the two
|
|
1001
|
+
* never merge.
|
|
1002
|
+
* @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[],
|
|
1003
|
+
* answeredQuestions?: string[]}} confirmed
|
|
1004
|
+
*/
|
|
1005
|
+
export function confirmedBlock(confirmed) {
|
|
1006
|
+
const checks = confirmed.checks ?? [];
|
|
1007
|
+
const protections = confirmed.protections ?? [];
|
|
1008
|
+
const openQuestions = confirmed.openQuestions ?? [];
|
|
1009
|
+
const notChecked = confirmed.notChecked ?? [];
|
|
1010
|
+
const answeredQuestions = confirmed.answeredQuestions ?? [];
|
|
1011
|
+
return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
|
|
1012
|
+
+ 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
|
|
1013
|
+
+ 'neither do you:\n\n'
|
|
1014
|
+
+ `${checks.map((c) => ` - ${c}`).join('\n') || ' (none)'}\n\n`
|
|
1015
|
+
+ 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
|
|
1016
|
+
+ `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
|
|
1017
|
+
+ (notChecked.length
|
|
1018
|
+
? 'THE PERSON ASKED FOR THESE, BUT NOTHING CHECKS THEM (state these in your notes — never silently claim they '
|
|
1019
|
+
+ `are covered):\n\n${notChecked.map((n) => ` - ${n}`).join('\n')}\n\n`
|
|
1020
|
+
: '')
|
|
1021
|
+
+ (answeredQuestions.length
|
|
1022
|
+
? `QUESTIONS THE PERSON ANSWERED — these answers are binding; compose to them, never re-decide them:\n\n${answeredQuestions.map((a) => ` - ${a}`).join('\n')}\n\n`
|
|
1023
|
+
: '')
|
|
1024
|
+
+ (openQuestions.length
|
|
1025
|
+
? `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')}`
|
|
1026
|
+
: 'The confirm turn left no open questions.');
|
|
1027
|
+
}
|
|
1028
|
+
|
|
740
1029
|
/**
|
|
741
1030
|
* The whole authoring prompt, as one string.
|
|
742
1031
|
*
|
|
@@ -749,19 +1038,40 @@ export function lawsBlock() {
|
|
|
749
1038
|
* the one thing that tells the composer what DONE is allowed to mean here; a
|
|
750
1039
|
* defaulted class would be the prompt answering a question the user was asked.
|
|
751
1040
|
*
|
|
1041
|
+
* `writeScope` carries what the old Q2 used to (PRD item 33 M3 piece 3): Q2 is
|
|
1042
|
+
* gone from the numbered interview, so what may change and what is read-only
|
|
1043
|
+
* reaches the composer through {@link writeScopeBlock} instead, fed from
|
|
1044
|
+
* Destination's own proven fence — never silently dropped.
|
|
1045
|
+
*
|
|
752
1046
|
* @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
|
|
753
1047
|
* facts: any, listingBlock: string, lang: string, verdictType: string,
|
|
754
1048
|
* guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
|
|
755
|
-
* ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any
|
|
1049
|
+
* ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
|
|
1050
|
+
* writeScope?: string[]|null,
|
|
1051
|
+
* confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
|
|
756
1052
|
*/
|
|
757
|
-
export function authorPrompt({
|
|
1053
|
+
export function authorPrompt({
|
|
1054
|
+
answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
|
|
1055
|
+
ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
|
|
1056
|
+
// PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
|
|
1057
|
+
// turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
|
|
1058
|
+
// only when a confirm turn actually ran and the person accepted a plan.
|
|
1059
|
+
confirmed = null,
|
|
1060
|
+
}) {
|
|
758
1061
|
const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
|
|
759
1062
|
if (statement === null) {
|
|
760
1063
|
throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
|
|
761
1064
|
+ `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
|
|
762
1065
|
}
|
|
1066
|
+
// The signed table's own label for each key (PRD item 33 M3 piece 3's wording
|
|
1067
|
+
// fix), shown beside the question so the composer reads the same Field/Holds
|
|
1068
|
+
// pairing a person sees at the terminal. `verdictType` is already checked
|
|
1069
|
+
// against `CLASS_STATEMENTS` above and that set's keys are exactly
|
|
1070
|
+
// `QUESTION_SETS`'s, so `labelsFor` cannot throw here. A key with no label
|
|
1071
|
+
// (hitl's own fourth question has no signed-table row) is shown with none.
|
|
1072
|
+
const labels = labelsFor(String(verdictType));
|
|
763
1073
|
const interview = Object.entries(questions)
|
|
764
|
-
.map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
|
|
1074
|
+
.map(([n, q]) => `Q${n}${labels[n] ? ` (${labels[n]})` : ''}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
|
|
765
1075
|
.join('\n\n');
|
|
766
1076
|
|
|
767
1077
|
const role = `You are composing the close for one job.
|
|
@@ -819,6 +1129,11 @@ measure anything.`;
|
|
|
819
1129
|
// composer reads that plus the catalogue and works the rest out.
|
|
820
1130
|
`WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
|
|
821
1131
|
`THE INTERVIEW — the person's own words\n\n${interview}`,
|
|
1132
|
+
// Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
|
|
1133
|
+
// change, which are read-only) is proven against the machine now, not typed,
|
|
1134
|
+
// and this is where that proof reaches the composer instead of vanishing.
|
|
1135
|
+
...(writeScope && writeScope.length ? [writeScopeBlock(writeScope)] : []),
|
|
1136
|
+
...(confirmed ? [confirmedBlock(confirmed)] : []),
|
|
822
1137
|
`THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
|
|
823
1138
|
+ (listingBlock ? `\n\n${listingBlock}` : ''),
|
|
824
1139
|
// CLASS-SCOPED (softgreen module 3): the composer for a green job is never
|
|
@@ -1152,6 +1467,10 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
|
|
|
1152
1467
|
// answers hangs the process forever otherwise. `...opts` still spreads AFTER
|
|
1153
1468
|
// it, so a caller that ever needs a tighter number for one call may pass it —
|
|
1154
1469
|
// tighten-only, and nothing does today.
|
|
1470
|
+
// F179 — bare-agent 0.43.0's OpenAIProvider/Ollama generate() no longer
|
|
1471
|
+
// throws on a malformed tool-call arguments string (BA-27): it returns the
|
|
1472
|
+
// round priced with `toolCalls: []` plus its own `malformedToolCall` marker,
|
|
1473
|
+
// which `Loop.run` surfaces unchanged on its return. No local shim needed.
|
|
1155
1474
|
return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...opts });
|
|
1156
1475
|
};
|
|
1157
1476
|
}
|
|
@@ -1207,7 +1526,28 @@ export async function askStructured({ messages, generate, mode, retries, label,
|
|
|
1207
1526
|
// two must offer one set of kinds, or the composer reads about a kind it
|
|
1208
1527
|
// cannot call — or worse, calls one the ceiling will refuse after we paid.
|
|
1209
1528
|
const tools = mode === 'tool' ? [channel.tool(box)] : [];
|
|
1210
|
-
|
|
1529
|
+
// F179/F180 — bare-agent 0.42.0's OpenAIProvider.generate used to THROW
|
|
1530
|
+
// mid-call (a raw SyntaxError off a malformed tool-call arguments string,
|
|
1531
|
+
// parsed with no try/catch) rather than resolve with `{error}` the way a
|
|
1532
|
+
// settled provider-shape casualty normally does; 0.43.0 fixed that class
|
|
1533
|
+
// upstream (BA-27), so `callCasualty` no longer admits a SyntaxError at
|
|
1534
|
+
// all (its branch was dead code and was removed). This try/catch stays
|
|
1535
|
+
// for the class it was ALSO built to cover: `callCasualty` is the SAME
|
|
1536
|
+
// predicate `authorscout.js`'s `settled` uses for its idle-timeout seam
|
|
1537
|
+
// (src/text.js, beside `priceOf`) — a HaltError or an unrecognised throw
|
|
1538
|
+
// re-raises unchanged; only an admitted casualty class lands here as the
|
|
1539
|
+
// same `{error}` shape the resolved-error path already books and returns
|
|
1540
|
+
// below.
|
|
1541
|
+
let r;
|
|
1542
|
+
try {
|
|
1543
|
+
r = await generate(convo, tools, {});
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
const reason = callCasualty(e);
|
|
1546
|
+
if (reason === null) throw e;
|
|
1547
|
+
attempts += 1;
|
|
1548
|
+
book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, { error: reason }, attempts);
|
|
1549
|
+
return { artifact: null, attempts, convo, raw: '', providerError: reason, red: null, budget: null };
|
|
1550
|
+
}
|
|
1211
1551
|
attempts += 1;
|
|
1212
1552
|
book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
|
|
1213
1553
|
const raw = redactSecrets(String(r?.text ?? ''));
|
|
@@ -1217,15 +1557,43 @@ export async function askStructured({ messages, generate, mode, retries, label,
|
|
|
1217
1557
|
}
|
|
1218
1558
|
|
|
1219
1559
|
if (mode === 'tool') {
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1560
|
+
// F179 (2026-09-15 ruling) — retry, never repair. bare-agent 0.43.0
|
|
1561
|
+
// (BA-27) already priced this round on the real usage and returns it
|
|
1562
|
+
// with `toolCalls: []` plus its own `malformedToolCall` marker instead
|
|
1563
|
+
// of throwing; this ladder's EXISTING malformed-emission retry (below)
|
|
1564
|
+
// is the whole mechanism — no new cap, no second pricing path, nothing
|
|
1565
|
+
// here ever re-parses or edits the model's arguments. The raw malformed
|
|
1566
|
+
// string itself is never persisted, only the parser's own error
|
|
1567
|
+
// message, scrubbed like every other model-authored text on this trail.
|
|
1568
|
+
//
|
|
1569
|
+
// Checked BEFORE the `box.calls.length === 1` accept and REGARDLESS of
|
|
1570
|
+
// how many calls survived (0 or more — bare-agent's `parseToolCalls` is
|
|
1571
|
+
// itself all-or-nothing, BA-27, so a real provider always returns 0 on
|
|
1572
|
+
// a malformed round): before this fix, a two-call reply was ALWAYS the
|
|
1573
|
+
// 'multiple-declaration-tool-calls' red, never an accept, so a
|
|
1574
|
+
// malformed call riding alongside a valid one must not widen what this
|
|
1575
|
+
// ladder takes from a malformed reply. Nothing from a malformed reply
|
|
1576
|
+
// is ever accepted.
|
|
1577
|
+
if (r?.malformedToolCall) {
|
|
1578
|
+
red = {
|
|
1579
|
+
code: 'artifact-red',
|
|
1580
|
+
path: channel.name,
|
|
1581
|
+
detail: `the reply's ${channel.name} call arguments were not valid JSON `
|
|
1582
|
+
+ `(${redactSecrets(String(r.malformedToolCall.error))}) — nothing is repaired; the call must be re-sent whole`,
|
|
1583
|
+
axis: 'malformed-tool-call-arguments',
|
|
1584
|
+
};
|
|
1585
|
+
} else if (box.calls.length === 1) {
|
|
1586
|
+
return { artifact: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
|
|
1587
|
+
} else {
|
|
1588
|
+
red = {
|
|
1589
|
+
code: 'artifact-red',
|
|
1590
|
+
path: channel.name,
|
|
1591
|
+
detail: box.calls.length === 0
|
|
1592
|
+
? `the reply delivered no ${channel.name} call — structure is enforced, prose is never parsed`
|
|
1593
|
+
: `the reply delivered ${box.calls.length} ${channel.name} calls; exactly one is expected`,
|
|
1594
|
+
axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1229
1597
|
} else {
|
|
1230
1598
|
// THE FALLBACK PATH — providers without tool mode only. The ONE shipped
|
|
1231
1599
|
// parser, never a second one.
|
|
@@ -1273,6 +1641,398 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1273
1641
|
return { ...r, declaration: r.artifact };
|
|
1274
1642
|
}
|
|
1275
1643
|
|
|
1644
|
+
// ── THE CONFIRM TURN (PRD item 33 M3 piece 4) ───────────────────────────────
|
|
1645
|
+
//
|
|
1646
|
+
// The confirm turn drafts a PLAN — checks it will compose, protections
|
|
1647
|
+
// (always-on guards, never named in the goal), and one signed goal sentence —
|
|
1648
|
+
// from the person's own free-text answers plus the read-only survey/listing
|
|
1649
|
+
// the scout or seed-read already paid for, THEN shows it back and lets the
|
|
1650
|
+
// person confirm, fix (up to the 2-round cap, ruling 5), type the goal
|
|
1651
|
+
// themselves, or start over. No hand-authored matcher ever compares the goal
|
|
1652
|
+
// against the checks (ruling 6) — the model states its own plan, in the
|
|
1653
|
+
// model-facing prompt below, and the person is the one judgement that
|
|
1654
|
+
// accepts it.
|
|
1655
|
+
|
|
1656
|
+
export const CONFIRM_TOOL_NAME = 'confirm_plan';
|
|
1657
|
+
|
|
1658
|
+
/** Acknowledgement text the confirm tool's `execute` returns — mirrors
|
|
1659
|
+
* {@link DECLARATION_ACK}'s role: an output channel that records and
|
|
1660
|
+
* acknowledges, takes no action, and ends the call the moment it fires
|
|
1661
|
+
* (`makeLoopGenerate` stops the loop on any tool execute). */
|
|
1662
|
+
export const CONFIRM_ACK = 'plan received';
|
|
1663
|
+
|
|
1664
|
+
/** the confirm channel's own STRUCTURE_INSTRUCTION_TOOL analogue, shown after
|
|
1665
|
+
* a malformed reply so the retry names the ONE required shape rather than
|
|
1666
|
+
* repeating the whole system prompt */
|
|
1667
|
+
export const CONFIRM_STRUCTURE_INSTRUCTION = `Your reply did not deliver exactly one plan through the ${CONFIRM_TOOL_NAME} tool. `
|
|
1668
|
+
+ 'Call it exactly once, with the whole plan as its arguments: checks, one goal sentence, anything not covered '
|
|
1669
|
+
+ 'by a check, and any genuinely missing questions.';
|
|
1670
|
+
|
|
1671
|
+
/** @type {Record<string, any>} */
|
|
1672
|
+
const CONFIRM_SCHEMA = Object.freeze({
|
|
1673
|
+
type: 'object',
|
|
1674
|
+
properties: {
|
|
1675
|
+
checks: {
|
|
1676
|
+
type: 'array',
|
|
1677
|
+
items: { type: 'string', minLength: 1 },
|
|
1678
|
+
description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
|
|
1679
|
+
+ 'success looks like answers actually asked for — never a check invented beyond them',
|
|
1680
|
+
},
|
|
1681
|
+
goal: {
|
|
1682
|
+
type: 'string',
|
|
1683
|
+
minLength: 1,
|
|
1684
|
+
description: 'ONE sentence naming every listed check (never a protection or guard — those are not yours to '
|
|
1685
|
+
+ "name) — this becomes the signed job spec's goal",
|
|
1686
|
+
},
|
|
1687
|
+
notChecked: {
|
|
1688
|
+
type: 'array',
|
|
1689
|
+
items: { type: 'string', minLength: 1 },
|
|
1690
|
+
description: 'anything the person asked for (in the Goal, What success looks like, Guardrails, or '
|
|
1691
|
+
+ 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
|
|
1692
|
+
+ 'own words; an empty list means every ask is covered by a listed check',
|
|
1693
|
+
},
|
|
1694
|
+
questions: {
|
|
1695
|
+
type: 'array',
|
|
1696
|
+
items: { type: 'string', minLength: 1 },
|
|
1697
|
+
description: 'anything genuinely missing from the person\'s answers that you need to plan honestly — never '
|
|
1698
|
+
+ 'asked to double-check something already answered',
|
|
1699
|
+
},
|
|
1700
|
+
},
|
|
1701
|
+
required: ['checks', 'goal', 'notChecked', 'questions'],
|
|
1702
|
+
additionalProperties: false,
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
/** The output channel. It records and acknowledges; it takes no action.
|
|
1706
|
+
* @param {{calls: any[]}} box */
|
|
1707
|
+
export function confirmTool(box) {
|
|
1708
|
+
return {
|
|
1709
|
+
name: CONFIRM_TOOL_NAME,
|
|
1710
|
+
description: 'Deliver the drafted plan: the checks you will compose, one goal sentence, anything the person '
|
|
1711
|
+
+ 'asked for that no check covers, and any genuinely missing questions. Call this exactly once.',
|
|
1712
|
+
parameters: CONFIRM_SCHEMA,
|
|
1713
|
+
execute: async (/** @type {any} */ args) => { box.calls.push(args); return CONFIRM_ACK; },
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/** THE CONFIRM CHANNEL — the one this turn uses, over the shared
|
|
1718
|
+
* {@link askStructured} ladder, exactly as {@link declarationChannel} is for
|
|
1719
|
+
* the authoring call. */
|
|
1720
|
+
const confirmChannel = () => ({
|
|
1721
|
+
name: CONFIRM_TOOL_NAME,
|
|
1722
|
+
instruction: CONFIRM_STRUCTURE_INSTRUCTION,
|
|
1723
|
+
textPath: 'plan',
|
|
1724
|
+
tool: (/** @type {{calls: any[]}} */ box) => confirmTool(box),
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* The confirm turn's system prompt (model-facing, registered — a commit
|
|
1729
|
+
* touching this needs Failure/Addresses/Corrects labels, `src/promptregisters.js`).
|
|
1730
|
+
* States ruling 6 ("a genre never adds a check the goal did not ask for") as
|
|
1731
|
+
* an ORDER to the model itself, the same register {@link AUTHOR_SYSTEM} and
|
|
1732
|
+
* {@link CLASS_STATEMENTS} already use, rather than a code matcher over prose.
|
|
1733
|
+
*/
|
|
1734
|
+
export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
|
|
1735
|
+
+ 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
|
|
1736
|
+
+ 'signed goal sentence. You never propose a check the Goal or What-success-looks-like answers did not ask for '
|
|
1737
|
+
+ '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
|
|
1738
|
+
+ 'that is exactly the mistake this order exists to prevent). The always-on guards (changed-from-seed, '
|
|
1739
|
+
+ 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
|
|
1740
|
+
+ 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
|
|
1741
|
+
+ 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
|
|
1742
|
+
+ 'listed and nothing more. For anything the person asked for that none of your listed checks can verify, name it '
|
|
1743
|
+
+ 'plainly in `notChecked`, in the person\'s own words — never omit a gap to make the plan look complete; '
|
|
1744
|
+
+ 'over-reporting a gap is always the safe direction. Before you name anything in `notChecked`, check it against '
|
|
1745
|
+
+ 'the ALREADY COVERED list shown to you in the message below (the real guards and write fence this run will '
|
|
1746
|
+
+ 'apply) — anything already covered there never belongs in `notChecked`, even when the person asked for it in '
|
|
1747
|
+
+ 'their own words (runs mu2bjmed and mu2qmept both wrongly claimed the no-suppressions guard and the write fence '
|
|
1748
|
+
+ 'were missing when both were already enforced; check the list before writing the claim). Ask a question only '
|
|
1749
|
+
+ 'where an answer is genuinely missing — never to double-check something already answered. You cannot read '
|
|
1750
|
+
+ 'anything and you cannot run anything: everything you know is in the message you are given.';
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* The confirm turn's per-round prompt (model-facing, registered). Shows the
|
|
1754
|
+
* person's own answers with their labels (the signed table's Field/Holds
|
|
1755
|
+
* pairing, same as {@link authorPrompt}'s interview block), the survey or
|
|
1756
|
+
* listing already paid for, the write fence, the detected language, "worse
|
|
1757
|
+
* than before" when the person gave one (repo only), and — on a fix round —
|
|
1758
|
+
* what the person asked to change.
|
|
1759
|
+
* F183 (2026-09-16, ruling A, run mu2qmept): also carries the REAL,
|
|
1760
|
+
* code-derived `protections` list ({@link confirmProtections} — the same
|
|
1761
|
+
* guards the close will actually compose plus the real write fence, never a
|
|
1762
|
+
* second hand-typed list) as an ALREADY COVERED block, with the instruction
|
|
1763
|
+
* that anything on it never belongs in `notChecked`. Twice live (mu2bjmed,
|
|
1764
|
+
* mu2qmept) the model claimed a guard or the write fence was missing while
|
|
1765
|
+
* blind to what was actually enforced — this is the fix: show it the facts
|
|
1766
|
+
* before it drafts, never hand-write a matcher over its answer.
|
|
1767
|
+
* @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
|
|
1768
|
+
* labels?: Record<string|number, string>, facts?: any, listing?: string|null,
|
|
1769
|
+
* writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
|
|
1770
|
+
* fixText?: string|null, protections?: string[]}} o
|
|
1771
|
+
*/
|
|
1772
|
+
export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null, protections = [] }) {
|
|
1773
|
+
const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
|
|
1774
|
+
for (const k of Object.keys(questions)) {
|
|
1775
|
+
const label = labels[k] ? ` (${labels[k]})` : '';
|
|
1776
|
+
lines.push(`Q${k}${label}. ${questions[k]}`);
|
|
1777
|
+
lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
|
|
1778
|
+
}
|
|
1779
|
+
if (isRepo && worseThanBefore) {
|
|
1780
|
+
lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
|
|
1781
|
+
}
|
|
1782
|
+
lines.push('', `LANGUAGE: ${lang}`);
|
|
1783
|
+
if (writeScope) lines.push(`WRITE SCOPE (the fence — the run may write only here): ${writeScope.join(', ')}`);
|
|
1784
|
+
if (protections.length > 0) {
|
|
1785
|
+
lines.push(
|
|
1786
|
+
'',
|
|
1787
|
+
'ALREADY COVERED — these are real, always-on, and enforced by this run regardless of what you compose. '
|
|
1788
|
+
+ 'Never list anything already covered here in `notChecked`, even if the person asked for it in their own '
|
|
1789
|
+
+ 'words:',
|
|
1790
|
+
...protections.map((p) => ` - ${p}`),
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
if (facts) lines.push('', `READ-ONLY SURVEY:\n${JSON.stringify(facts)}`);
|
|
1794
|
+
if (listing) lines.push('', `SEED LISTING:\n${listing}`);
|
|
1795
|
+
if (fixText) lines.push('', `THE PERSON ASKED FOR A CHANGE: ${fixText}`, 'Revise the plan and resubmit.');
|
|
1796
|
+
return lines.join('\n');
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* ONE confirm-turn round-trip: capStop, then one structured ask over the
|
|
1801
|
+
* confirm channel. Split out of {@link runConfirmTurn} only so the ceiling
|
|
1802
|
+
* check and the ask sit beside each other exactly once.
|
|
1803
|
+
* @param {{convo: any[], generate: Function, mode: 'tool'|'text', book: ReturnType<typeof makeCostBook>, label: string}} o
|
|
1804
|
+
* @returns {Promise<{plan: any, convo: any[], providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
|
|
1805
|
+
*/
|
|
1806
|
+
async function askConfirmPlan({ convo, generate, mode, book, label }) {
|
|
1807
|
+
const halt = book.capStop();
|
|
1808
|
+
if (halt) return { plan: null, convo, providerError: null, red: null, budget: halt };
|
|
1809
|
+
const r = await askStructured({ messages: convo, generate, mode, retries: MAX_STRUCTURE_RETRIES, label, book, channel: confirmChannel() });
|
|
1810
|
+
return { plan: r.artifact, convo: r.convo, providerError: r.providerError, red: r.red, budget: r.budget };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* THE CONFIRM TURN: the $0 half (worse-than-before for a repo, a language
|
|
1815
|
+
* pick when `lang` carries `detectLanguage`'s own `ambiguous` shape), then up
|
|
1816
|
+
* to 2 PAID rounds (ruling 5) of draft → show → the person's menu pick
|
|
1817
|
+
* (ruling 5 addendum). `ask` is the ONE interactive seam — an async function
|
|
1818
|
+
* from a "what's being asked" descriptor to the person's answer, or `null`
|
|
1819
|
+
* meaning input ended (the same signal `scripts/run-interview.mjs`'s own
|
|
1820
|
+
* `nextLine()` already uses) — never a process exit here, because this is a
|
|
1821
|
+
* library function, not a script. A `null` at ANY ask stops the whole turn as
|
|
1822
|
+
* `confirm-abandoned`, spending nothing beyond whatever rounds already ran.
|
|
1823
|
+
*
|
|
1824
|
+
* D3's shape is enforced by the loop bound alone (`round <= 2`), never a
|
|
1825
|
+
* counter checked after the fact: a "fix" on round 2 returns immediately —
|
|
1826
|
+
* there is no 3rd call to make.
|
|
1827
|
+
*
|
|
1828
|
+
* @param {{verdictType: string, answers: Record<string|number, string>,
|
|
1829
|
+
* questions: Record<string|number, string>, labels?: Record<string|number, string>,
|
|
1830
|
+
* facts?: any, listing?: string|null, writeScope?: string[]|null, isRepo: boolean,
|
|
1831
|
+
* lang: string|{kind: 'ambiguous', candidates: string[], dir: string},
|
|
1832
|
+
* generate: Function, book: ReturnType<typeof makeCostBook>,
|
|
1833
|
+
* ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
|
|
1834
|
+
* onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
|
|
1835
|
+
* worseThanBefore?: string}} o
|
|
1836
|
+
* @returns {Promise<{ok: boolean,
|
|
1837
|
+
* stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
|
|
1838
|
+
* rounds: number,
|
|
1839
|
+
* accepted: {goal: string, checks: string[], protections: string[], lang: string,
|
|
1840
|
+
* worseThanBefore: string, openQuestions: string[], notChecked: string[], answeredQuestions?: string[]}|null,
|
|
1841
|
+
* reds: Red[], cost: any}>}
|
|
1842
|
+
*/
|
|
1843
|
+
export async function runConfirmTurn({
|
|
1844
|
+
verdictType, answers, questions, labels = {}, facts = null, listing = null, writeScope = null,
|
|
1845
|
+
isRepo, lang, generate, book, ask, onPhase = () => {}, mode = 'tool',
|
|
1846
|
+
// `undefined` (the default) means "ask me" — every S2 caller (nothing
|
|
1847
|
+
// pre-resolves this). A caller that already asked worseThanBefore itself
|
|
1848
|
+
// (`authorCloseForJob`'s own $0 phase, run BEFORE the scout per D7) passes
|
|
1849
|
+
// the resolved string straight through, and this turn asks nothing a
|
|
1850
|
+
// second time. `''` is a legal resolved answer ("nothing beyond
|
|
1851
|
+
// Guardrails") and is NOT the same as "ask me" — only `undefined` is.
|
|
1852
|
+
worseThanBefore: presetWorseThanBefore = undefined,
|
|
1853
|
+
}) {
|
|
1854
|
+
/** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
|
|
1855
|
+
/** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
|
|
1856
|
+
const base = () => ({ ok: false, stop: null, rounds: 0, accepted: null, reds: [], cost: book.report() });
|
|
1857
|
+
/** @param {number} rounds @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
|
|
1858
|
+
const abandon = (rounds) => ({ ...base(), stop: /** @type {ConfirmStop} */ ('confirm-abandoned'), rounds, cost: book.report() });
|
|
1859
|
+
|
|
1860
|
+
// ── the $0 half, entirely before any token spends (D7) ────────────────────
|
|
1861
|
+
/** @type {string} */
|
|
1862
|
+
let worseThanBefore = '';
|
|
1863
|
+
if (presetWorseThanBefore !== undefined) {
|
|
1864
|
+
worseThanBefore = presetWorseThanBefore;
|
|
1865
|
+
} else if (isRepo) {
|
|
1866
|
+
onPhase('confirm-worse-than-before');
|
|
1867
|
+
const wtb = await ask({ kind: 'worseThanBefore', field: WORSE_THAN_BEFORE_FIELD });
|
|
1868
|
+
if (wtb === null) return abandon(0);
|
|
1869
|
+
worseThanBefore = redactSecrets(String(wtb).trim());
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** @type {string} */
|
|
1873
|
+
let resolvedLang = typeof lang === 'string' ? lang : '';
|
|
1874
|
+
if (lang && typeof lang === 'object' && lang.kind === 'ambiguous') {
|
|
1875
|
+
onPhase('confirm-language-pick', { candidates: lang.candidates });
|
|
1876
|
+
const pick = await ask({ kind: 'language', field: LANGUAGE_PICK_FIELD, candidates: lang.candidates });
|
|
1877
|
+
if (pick === null) return abandon(0);
|
|
1878
|
+
resolvedLang = String(pick);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// F183 (2026-09-16, ruling A): computed ONCE, before the model ever drafts,
|
|
1882
|
+
// from the same source {@link confirmProtections} always used — fed into
|
|
1883
|
+
// the prompt below so the model can check `notChecked` against it, and
|
|
1884
|
+
// reused (never recomputed) after the call so the shown, prompted, and
|
|
1885
|
+
// recorded protections are always the exact same list.
|
|
1886
|
+
const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope });
|
|
1887
|
+
|
|
1888
|
+
// ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
|
|
1889
|
+
/** @type {string|null} */
|
|
1890
|
+
let fixText = null;
|
|
1891
|
+
/** F175: `accepted.openQuestions` carries the model's OWN `questions` from
|
|
1892
|
+
* the plan being ACCEPTED — never a prior, superseded round's — secret-
|
|
1893
|
+
* redacted like every other model/person string on this turn. `fixText`
|
|
1894
|
+
* (round 1's own "fix" text) is a SEPARATE mechanism that only feeds round
|
|
1895
|
+
* 2's redraft; it must never land in `openQuestions` on its own — round 2's
|
|
1896
|
+
* plan is the answer to it, and its own `questions` (if it still has any)
|
|
1897
|
+
* are what actually carries forward.
|
|
1898
|
+
* @param {any} plan @returns {string[]} */
|
|
1899
|
+
const questionsFromPlan = (plan) => (Array.isArray(plan?.questions) ? plan.questions : [])
|
|
1900
|
+
.map((/** @type {any} */ q) => redactSecrets(String(q)));
|
|
1901
|
+
/**
|
|
1902
|
+
* F175's open half (run mu0voeo4, hamr's 2026-09-16 ruling): "Confirm" and
|
|
1903
|
+
* "Type the goal yourself" may not bypass a `questions` entry the plan
|
|
1904
|
+
* being accepted raised — each is answered inline, in order, through the
|
|
1905
|
+
* SAME `ask` seam, with a new `kind: 'answer'` step (NO extra model call:
|
|
1906
|
+
* the round/D3 cap is untouched). A blank/whitespace answer is not an
|
|
1907
|
+
* answer and re-asks the SAME question; a `null` from `ask` abandons,
|
|
1908
|
+
* exactly like every other ask in this function. Returns the `Q:/A:`
|
|
1909
|
+
* pairs in order, or `null` to mean abandon.
|
|
1910
|
+
* @param {any} plan @returns {Promise<string[]|null>} */
|
|
1911
|
+
const forceAnsweredQuestions = async (plan) => {
|
|
1912
|
+
const qs = questionsFromPlan(plan);
|
|
1913
|
+
/** @type {string[]} */
|
|
1914
|
+
const answered = [];
|
|
1915
|
+
for (let i = 0; i < qs.length; i += 1) {
|
|
1916
|
+
const question = qs[i];
|
|
1917
|
+
for (;;) {
|
|
1918
|
+
const raw = await ask({ kind: 'answer', field: ANSWER_QUESTION_FIELD, question, index: i + 1, total: qs.length });
|
|
1919
|
+
if (raw === null) return null;
|
|
1920
|
+
const trimmed = redactSecrets(String(raw).trim());
|
|
1921
|
+
if (trimmed === '') continue; // blank is not an answer — re-ask the same question
|
|
1922
|
+
answered.push(`Q: ${question}\nA: ${trimmed}`);
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
return answered;
|
|
1927
|
+
};
|
|
1928
|
+
let convo = [{
|
|
1929
|
+
role: 'user',
|
|
1930
|
+
content: confirmPrompt({
|
|
1931
|
+
answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections,
|
|
1932
|
+
}),
|
|
1933
|
+
}];
|
|
1934
|
+
|
|
1935
|
+
for (let round = 1; round <= 2; round += 1) {
|
|
1936
|
+
if (fixText) {
|
|
1937
|
+
convo = [...convo, { role: 'user', content: confirmPrompt({
|
|
1938
|
+
answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText, protections,
|
|
1939
|
+
}) }];
|
|
1940
|
+
}
|
|
1941
|
+
onPhase('confirm-round', { round });
|
|
1942
|
+
const label = round === 1 ? 'confirm' : `confirm#${round}`;
|
|
1943
|
+
const r = await askConfirmPlan({ convo, generate, mode, book, label });
|
|
1944
|
+
convo = r.convo;
|
|
1945
|
+
|
|
1946
|
+
if (r.budget) return { ...base(), stop: r.budget, rounds: round - 1, cost: book.report() };
|
|
1947
|
+
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() };
|
|
1948
|
+
if (!r.plan) return { ...base(), stop: /** @type {ConfirmStop} */ ('artifact-red'), rounds: round, reds: [/** @type {Red} */ (r.red)], cost: book.report() };
|
|
1949
|
+
|
|
1950
|
+
// "Unpriced is never free" (F6): a plan can arrive structurally fine on a
|
|
1951
|
+
// call whose OWN cost came back unknown. Nothing downstream ever makes
|
|
1952
|
+
// another call to discover that (a "confirm" pick is the last call this
|
|
1953
|
+
// turn ever makes), so it is checked HERE, once, right after the call
|
|
1954
|
+
// that might have caused it — never silently accepted as a signable plan
|
|
1955
|
+
// whose ceiling can no longer be enforced.
|
|
1956
|
+
const postCallHalt = book.capStop();
|
|
1957
|
+
if (postCallHalt) return { ...base(), stop: postCallHalt, rounds: round, cost: book.report() };
|
|
1958
|
+
|
|
1959
|
+
// THE REAL PROTECTIONS (fix #1, run mu0voeo4, 2026-09-14): never the
|
|
1960
|
+
// model's `protections` prose — the schema no longer even asks the model
|
|
1961
|
+
// for one — but the guards `classGuards` will actually compose, plus the
|
|
1962
|
+
// write fence. Computed once above (F183) and reused here unchanged, so
|
|
1963
|
+
// the list the model was prompted with is byte-identical to the list
|
|
1964
|
+
// shown at confirm-done and recorded on acceptance. `notChecked` is the
|
|
1965
|
+
// model's own honest gap list, carried through unchanged.
|
|
1966
|
+
const notChecked = [...(r.plan.notChecked ?? [])];
|
|
1967
|
+
const plan = { ...r.plan, protections, notChecked };
|
|
1968
|
+
onPhase('confirm-done', { round, plan });
|
|
1969
|
+
const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
|
|
1970
|
+
if (picked === null) return abandon(round);
|
|
1971
|
+
if (picked === 'start-over') return { ...base(), stop: /** @type {ConfirmStop} */ ('confirm-restart'), rounds: round, cost: book.report() };
|
|
1972
|
+
|
|
1973
|
+
if (picked === 'confirm') {
|
|
1974
|
+
const answeredQuestions = await forceAnsweredQuestions(r.plan);
|
|
1975
|
+
if (answeredQuestions === null) return abandon(round);
|
|
1976
|
+
return {
|
|
1977
|
+
ok: true, stop: null, rounds: round,
|
|
1978
|
+
accepted: {
|
|
1979
|
+
goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
1980
|
+
lang: resolvedLang, worseThanBefore,
|
|
1981
|
+
// every question forced above was answered in order, so what's left
|
|
1982
|
+
// unresolved is honestly empty — computed FROM questionsFromPlan
|
|
1983
|
+
// rather than a hardcoded `[]`, so this stays true if the forcing
|
|
1984
|
+
// design ever changes.
|
|
1985
|
+
openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
|
|
1986
|
+
notChecked: [...notChecked], answeredQuestions,
|
|
1987
|
+
},
|
|
1988
|
+
reds: [], cost: book.report(),
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
if (picked === 'type-goal') {
|
|
1993
|
+
const answeredQuestions = await forceAnsweredQuestions(r.plan);
|
|
1994
|
+
if (answeredQuestions === null) return abandon(round);
|
|
1995
|
+
const typed = await ask({ kind: 'goal' });
|
|
1996
|
+
if (typed === null) return abandon(round);
|
|
1997
|
+
return {
|
|
1998
|
+
ok: true, stop: null, rounds: round,
|
|
1999
|
+
accepted: {
|
|
2000
|
+
goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
2001
|
+
lang: resolvedLang, worseThanBefore,
|
|
2002
|
+
openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
|
|
2003
|
+
notChecked: [...notChecked], answeredQuestions,
|
|
2004
|
+
},
|
|
2005
|
+
reds: [], cost: book.report(),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// picked === 'fix'
|
|
2010
|
+
const fix = await ask({ kind: 'fix' });
|
|
2011
|
+
if (fix === null) return abandon(round);
|
|
2012
|
+
const redactedFix = redactSecrets(String(fix).trim());
|
|
2013
|
+
if (round === 2) {
|
|
2014
|
+
// D3: after round 2 a "fix" is passed to the composer verbatim (via
|
|
2015
|
+
// openQuestions, shown at signing) — there is no 3rd call. F175: the
|
|
2016
|
+
// MODEL's own questions from THIS round's plan come first, then the
|
|
2017
|
+
// person's still-pending fix text — order matters (model asked first,
|
|
2018
|
+
// person spoke last) and neither is dropped.
|
|
2019
|
+
return {
|
|
2020
|
+
ok: true, stop: null, rounds: round,
|
|
2021
|
+
accepted: {
|
|
2022
|
+
goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
|
|
2023
|
+
lang: resolvedLang, worseThanBefore, openQuestions: [...questionsFromPlan(r.plan), redactedFix], notChecked: [...notChecked],
|
|
2024
|
+
},
|
|
2025
|
+
reds: [], cost: book.report(),
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
fixText = redactedFix;
|
|
2029
|
+
}
|
|
2030
|
+
// unreachable: the loop above always returns by round 2 — kept only so a
|
|
2031
|
+
// future edit that breaks that invariant fails loudly rather than falling
|
|
2032
|
+
// through to `undefined`.
|
|
2033
|
+
throw new Error('[authorflow] runConfirmTurn fell through its 2-round cap without returning');
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1276
2036
|
/**
|
|
1277
2037
|
* THE AUTHORING FLOW.
|
|
1278
2038
|
*
|
|
@@ -1284,7 +2044,13 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1284
2044
|
* The return is deliberately complete rather than tidy:
|
|
1285
2045
|
* - `ok` is true iff a declaration passed validation and was measured;
|
|
1286
2046
|
* - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
|
|
1287
|
-
* that will run and that the signature will cover;
|
|
2047
|
+
* that will run and that the signature will cover; unless F176's fallback
|
|
2048
|
+
* fired (see `fellBack`), in which case it is the newest SOUND accepted one
|
|
2049
|
+
* instead — a "last accepted" close whose seed read instrument-stopped is
|
|
2050
|
+
* never handed to a signer as though it were the best close this run found;
|
|
2051
|
+
* - `fellBack` is `null` unless the fallback above fired, in which case it names
|
|
2052
|
+
* `{from, to, brokenStages}` — which iteration was dropped, which one now
|
|
2053
|
+
* stands, and which of the dropped one's stages instrument-stopped;
|
|
1288
2054
|
* - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
|
|
1289
2055
|
* `reds` means the final revision was rejected and the previous close stands:
|
|
1290
2056
|
* the rejection is reported, never hidden, and never silently shipped either;
|
|
@@ -1309,12 +2075,32 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
|
|
|
1309
2075
|
* onPhase?: (phase: string, data?: any) => void,
|
|
1310
2076
|
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
1311
2077
|
* maxRevisions?: number, structureRetries?: number,
|
|
1312
|
-
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any
|
|
2078
|
+
* structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
|
|
2079
|
+
* priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
|
|
2080
|
+
* priorRaws?: any[]|null,
|
|
2081
|
+
* confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
|
|
1313
2082
|
*/
|
|
1314
2083
|
export async function authorClose({
|
|
1315
2084
|
workdir, seedRef, lang, verdictType,
|
|
1316
2085
|
answers, questions = GREEN_QUESTIONS,
|
|
2086
|
+
// Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — this is
|
|
2087
|
+
// what carries its information into the prompt instead (`writeScopeBlock`).
|
|
2088
|
+
// Optional and defaulted to `null` so every caller that predates the reshape
|
|
2089
|
+
// still runs byte-identical; a REAL caller (`authorCloseForJob`) always
|
|
2090
|
+
// passes the repo's proven `writeScope`.
|
|
2091
|
+
writeScope = null,
|
|
1317
2092
|
scout, listing = null,
|
|
2093
|
+
// PRD item 33 M3 piece 4: the confirm turn's own paid calls — absorbed
|
|
2094
|
+
// BESIDE the scout's, below, so the ONE ceiling this run advertised is the
|
|
2095
|
+
// one it enforces (the standing hard line's money form): a confirm turn
|
|
2096
|
+
// that already spent half the ceiling must not let the author call spend
|
|
2097
|
+
// as if it never happened. `null`/absent is every caller that ran no
|
|
2098
|
+
// confirm turn — byte-identical to today.
|
|
2099
|
+
priorCalls = null, priorRaws = null,
|
|
2100
|
+
// The confirm turn's ACCEPTED plan (`runConfirmTurn`'s `accepted`), fed
|
|
2101
|
+
// straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
|
|
2102
|
+
// that ran no confirm turn — the prompt is then byte-identical to today.
|
|
2103
|
+
confirmed = null,
|
|
1318
2104
|
generate, seedReadFn = runSeedReadStages, closeCtx = {},
|
|
1319
2105
|
ceilingUsd = null,
|
|
1320
2106
|
// The two REPORTING seams, defaulted to nothing so every existing caller is
|
|
@@ -1351,6 +2137,7 @@ export async function authorClose({
|
|
|
1351
2137
|
// that spends nothing more: run mslhn707 refused at the $0 preflight below,
|
|
1352
2138
|
// and the survey text that would have said WHY died with the process.
|
|
1353
2139
|
book.absorb(scout?.calls ?? [], scout?.raws ?? []);
|
|
2140
|
+
book.absorb(priorCalls ?? [], priorRaws ?? []);
|
|
1354
2141
|
/** @type {any} */
|
|
1355
2142
|
const base = {
|
|
1356
2143
|
ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
|
|
@@ -1487,7 +2274,7 @@ export async function authorClose({
|
|
|
1487
2274
|
// ── the grounded loop ─────────────────────────────────────────────────────
|
|
1488
2275
|
const prompt = authorPrompt({
|
|
1489
2276
|
answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
|
|
1490
|
-
lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
|
|
2277
|
+
lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
|
|
1491
2278
|
});
|
|
1492
2279
|
/** @type {any[]} */
|
|
1493
2280
|
let messages = [{ role: 'user', content: prompt }];
|
|
@@ -1508,6 +2295,19 @@ export async function authorClose({
|
|
|
1508
2295
|
let stop = 'max-revisions';
|
|
1509
2296
|
/** @type {{stage: string, name: string}[]} */
|
|
1510
2297
|
let droppedEnv = [];
|
|
2298
|
+
// F176: every MEASURED iteration (validated + seed-read, never a
|
|
2299
|
+
// validation-rejected one), kept in call order, so a later revision that
|
|
2300
|
+
// broke what an earlier one got right can be detected and reverted rather
|
|
2301
|
+
// than silently kept because it happened to be last. Each entry carries its
|
|
2302
|
+
// OWN droppedEnv — the single `droppedEnv` variable above is overwritten by
|
|
2303
|
+
// every measured iteration and would mismatch a fallen-back iteration.
|
|
2304
|
+
/** @type {{label: string, declaration: any, seedRead: any[], droppedEnv: {stage: string, name: string}[]}[]} */
|
|
2305
|
+
const measuredIterations = [];
|
|
2306
|
+
/** a measured iteration is SOUND when none of its seed-read rows instrument-stopped
|
|
2307
|
+
* (hamr's ruling, 2026-09-14, option A) — a real red/green verdict is sound, a
|
|
2308
|
+
* broken instrument is not.
|
|
2309
|
+
* @param {any[]} rows @returns {boolean} */
|
|
2310
|
+
const isSoundMeasurement = (rows) => Array.isArray(rows) && rows.every((r) => r?.verdict !== 'instrument-stop');
|
|
1511
2311
|
|
|
1512
2312
|
/**
|
|
1513
2313
|
* THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
|
|
@@ -1654,6 +2454,7 @@ export async function authorClose({
|
|
|
1654
2454
|
acceptedSeedRead = rows;
|
|
1655
2455
|
finalFrom = label;
|
|
1656
2456
|
measured = renderSeedReadBlock(rows);
|
|
2457
|
+
measuredIterations.push({ label, declaration: injected.declaration, seedRead: rows, droppedEnv: injected.dropped });
|
|
1657
2458
|
}
|
|
1658
2459
|
|
|
1659
2460
|
if (i === revisionCap) { stop = 'max-revisions'; break; }
|
|
@@ -1674,6 +2475,43 @@ export async function authorClose({
|
|
|
1674
2475
|
await seedTrees.cleanup();
|
|
1675
2476
|
}
|
|
1676
2477
|
|
|
2478
|
+
// F176 (hamr's ruling, 2026-09-14, option A): "kept the last accepted
|
|
2479
|
+
// revision" is not the same claim as "kept the best revision" — a revise
|
|
2480
|
+
// loop that only checks validity per round, never soundness ACROSS rounds,
|
|
2481
|
+
// can silently regress on its own last try. If the last MEASURED iteration
|
|
2482
|
+
// is not sound (an instrument-stop, not a real red/green verdict) and an
|
|
2483
|
+
// earlier measured iteration IS sound, fall back to the NEWEST sound one:
|
|
2484
|
+
// its declaration, its seedRead, its own finalFrom label, and its own
|
|
2485
|
+
// droppedEnv — never the module-level `droppedEnv`, which the unsound last
|
|
2486
|
+
// iteration already overwrote. `stop` itself is never changed: whatever
|
|
2487
|
+
// stopped the ladder still stopped it for the reason it stopped it, this
|
|
2488
|
+
// only changes WHICH close is reported as the one that stands. Said loudly,
|
|
2489
|
+
// never silently: `fellBack` names it and `onPhase` reports it as it
|
|
2490
|
+
// happens, so a caller emitting phases to a spine or a terminal cannot miss
|
|
2491
|
+
// it by reading only `declaration`/`seedRead`/`finalFrom`.
|
|
2492
|
+
/** @type {{from: string, to: string, brokenStages: string[]}|null} */
|
|
2493
|
+
let fellBack = null;
|
|
2494
|
+
if (measuredIterations.length) {
|
|
2495
|
+
const last = measuredIterations[measuredIterations.length - 1];
|
|
2496
|
+
if (!isSoundMeasurement(last.seedRead)) {
|
|
2497
|
+
for (let k = measuredIterations.length - 2; k >= 0; k -= 1) {
|
|
2498
|
+
const candidate = measuredIterations[k];
|
|
2499
|
+
if (isSoundMeasurement(candidate.seedRead)) {
|
|
2500
|
+
const brokenStages = last.seedRead
|
|
2501
|
+
.filter((/** @type {any} */ r) => r?.verdict === 'instrument-stop')
|
|
2502
|
+
.map((/** @type {any} */ r) => r.stage);
|
|
2503
|
+
fellBack = { from: last.label, to: candidate.label, brokenStages };
|
|
2504
|
+
accepted = candidate.declaration;
|
|
2505
|
+
acceptedSeedRead = candidate.seedRead;
|
|
2506
|
+
finalFrom = candidate.label;
|
|
2507
|
+
droppedEnv = candidate.droppedEnv;
|
|
2508
|
+
onPhase('author-fallback', fellBack);
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
1677
2515
|
const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
|
|
1678
2516
|
return {
|
|
1679
2517
|
// `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
|
|
@@ -1697,6 +2535,11 @@ export async function authorClose({
|
|
|
1697
2535
|
listing: seeds,
|
|
1698
2536
|
genreEnv: { ...base.genreEnv, dropped: droppedEnv },
|
|
1699
2537
|
finalFrom,
|
|
2538
|
+
// F176: null on every path where the last measured iteration was itself
|
|
2539
|
+
// sound, or where no measured iteration exists — byte-identical to today.
|
|
2540
|
+
// Non-null names WHICH iteration was dropped, WHICH one now stands, and
|
|
2541
|
+
// WHICH stages broke on the dropped one, so this is never a silent swap.
|
|
2542
|
+
fellBack,
|
|
1700
2543
|
stop,
|
|
1701
2544
|
revisions: Math.max(0, iterations.length - 1),
|
|
1702
2545
|
};
|