bareloop 0.39.0 → 0.40.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bareloop.context.md +63 -11
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +5 -2
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +1 -1
  12. package/src/cardauthor.js +4 -1
  13. package/src/declaredclose.js +4 -3
  14. package/src/draftspend.js +6 -1
  15. package/src/interviewrun.js +56 -27
  16. package/src/job.js +38 -5
  17. package/src/jobblock.js +140 -0
  18. package/src/judged.js +1 -1
  19. package/src/ledger.js +1 -1
  20. package/src/panel/authorroutes.js +15 -5
  21. package/src/panel/authorsession.js +158 -47
  22. package/src/panel/index.html +179 -70
  23. package/src/panel/jobplan.js +107 -0
  24. package/src/panel/server.js +67 -13
  25. package/src/planrun.js +4 -4
  26. package/src/promptregisters.js +1 -1
  27. package/src/providers.js +101 -19
  28. package/src/reuse.js +1 -1
  29. package/src/root.js +1 -1
  30. package/src/run.js +3 -3
  31. package/src/selection.js +1 -1
  32. package/src/source.js +64 -1
  33. package/src/text.js +13 -0
  34. package/src/transport.js +1 -1
  35. package/src/userrun.js +3 -3
  36. package/types/authorflow.d.ts +258 -39
  37. package/types/authoring.d.ts +7 -1
  38. package/types/authorjob.d.ts +35 -3
  39. package/types/authorreadout.d.ts +17 -1
  40. package/types/authorscout.d.ts +5 -3
  41. package/types/declaredclose.d.ts +4 -1
  42. package/types/draftspend.d.ts +8 -2
  43. package/types/job.d.ts +1 -1
  44. package/types/jobblock.d.ts +102 -0
  45. package/types/panel/authorsession.d.ts +50 -14
  46. package/types/panel/jobplan.d.ts +58 -0
  47. package/types/panel/server.d.ts +20 -0
  48. package/types/planrun.d.ts +1 -1
  49. package/types/providers.d.ts +47 -3
  50. package/types/run.d.ts +1 -1
  51. package/types/source.d.ts +26 -0
package/src/authorflow.js CHANGED
@@ -72,6 +72,8 @@
72
72
  // F6 throughout: an unpriced call makes the TOTAL unknown, and unknown is null.
73
73
  // No spine emission here — M4 wires events.
74
74
 
75
+ import { renderInputs } from './jobblock.js';
76
+ import { draftCallOptions } from './providers.js';
75
77
  import { createRequire } from 'node:module';
76
78
  import { realpathSync } from 'node:fs';
77
79
  import { join, relative, isAbsolute } from 'node:path';
@@ -110,6 +112,96 @@ export const AUTHOR_MAX_TOKENS = 32000;
110
112
  /** the one slot the model fills on an injected guard */
111
113
  export const FILL_MARKER = '<FILL IN>';
112
114
 
115
+ /** P7 item 5 (fwdloop am24/25): the most questions the MACHINE keeps from the try-3 drafter. The drafter is never told this number. */
116
+ export const MAX_QUESTIONS = 2;
117
+
118
+ /**
119
+ * The model-visible words of the optional `questions` property — offered ONLY on the last drafting try and only after the
120
+ * two before it both failed validation (a mechanism, never a wish: the schema carries it on that one call and nowhere
121
+ * else). It lives in the property's description, so the frozen revise turn stays measurement-only. No digit and no number
122
+ * word anywhere in it (a test greps this): the machine, not the prompt, bounds the count.
123
+ */
124
+ export const QUESTIONS_DESCRIPTION = 'Optional. Add a question only if a refusal in the measured results names a job line that cannot be drafted '
125
+ + 'without the person\'s answer (an undefined size, format, destination, or what "done" means). Each question is {line, question}: '
126
+ + '"line" is the number of the job line it is about. If the refusals can be fixed without asking, fix them and leave this out. '
127
+ + 'Never ask about a budget, a cap or a time limit (the person signs those). Still deliver the complete corrected declaration.';
128
+
129
+ /**
130
+ * Which job lines did these validation reds name? A red names a line in its text ("job line N") or through the stage it
131
+ * sits on (path `stages[i]…` → that stage's own `fromLine`; `refused[i]…` → that refusal's `line`). A red naming none (a
132
+ * missing top-level key) names no line. Sorted real line numbers.
133
+ * @param {Red[]} reds @param {any} declaration @param {number[]} lineNums
134
+ * @returns {number[]}
135
+ */
136
+ export function linesNamedByReds(reds, declaration, lineNums) {
137
+ /** @type {Set<number>} */
138
+ const found = new Set();
139
+ const stages = Array.isArray(declaration?.stages) ? declaration.stages : [];
140
+ const refused = Array.isArray(declaration?.refused) ? declaration.refused : [];
141
+ for (const red of reds ?? []) {
142
+ for (const m of String(red?.detail ?? '').matchAll(/\bjob line (\d+)\b/g)) found.add(Number(m[1]));
143
+ const st = /^stages\[(\d+)\]/.exec(String(red?.path ?? ''));
144
+ if (st && Array.isArray(stages[Number(st[1])]?.fromLine)) {
145
+ for (const n of stages[Number(st[1])].fromLine) if (Number.isInteger(n)) found.add(n);
146
+ }
147
+ const rf = /^refused\[(\d+)\]/.exec(String(red?.path ?? ''));
148
+ if (rf && Number.isInteger(refused[Number(rf[1])]?.line)) found.add(refused[Number(rf[1])].line);
149
+ }
150
+ return [...found].filter((n) => lineNums.includes(n)).sort((a, b) => a - b);
151
+ }
152
+
153
+ /**
154
+ * The machine's keep-or-drop of the try-3 questions. Kept only when its line is one the previous try's reds named and its
155
+ * text is non-blank, at most {@link MAX_QUESTIONS}; every drop comes back with its reason, for the draft's log.
156
+ * @param {any} raw @param {number[]} failedLines
157
+ * @returns {{kept: {line: number, question: string}[], dropped: {reason: string, raw: any}[]}}
158
+ */
159
+ export function normalizeQuestions(raw, failedLines) {
160
+ /** @type {{line: number, question: string}[]} */
161
+ const kept = [];
162
+ /** @type {{reason: string, raw: any}[]} */
163
+ const dropped = [];
164
+ if (raw === undefined || raw === null) return { kept, dropped };
165
+ if (!Array.isArray(raw)) return { kept, dropped: [{ reason: 'questions is not a list', raw }] };
166
+ for (const q of raw) {
167
+ if (!q || typeof q !== 'object' || !Number.isInteger(q.line)) { dropped.push({ reason: 'no line', raw: q }); continue; }
168
+ if (!failedLines.includes(q.line)) { dropped.push({ reason: `line ${q.line} is not a line the previous try's checks failed on`, raw: q }); continue; }
169
+ if (typeof q.question !== 'string' || q.question.trim() === '') { dropped.push({ reason: 'blank question', raw: q }); continue; }
170
+ if (kept.length >= MAX_QUESTIONS) { dropped.push({ reason: 'beyond the machine\'s limit', raw: q }); continue; }
171
+ kept.push({ line: q.line, question: redactSecrets(q.question.trim()) });
172
+ }
173
+ return { kept, dropped };
174
+ }
175
+
176
+ /**
177
+ * Did this try FAIL THE SEED READ (hamr's ruling 2026-10-10, differing from fwdloop's validation-only rule)? It did when a work
178
+ * stage instrument-stopped, or when no work stage is red on the repo as it is — the vacuous close ("nothing in the close fails
179
+ * at all": it grades nothing). The mandatory guards (`guardNames`) are green at the seed by design and never count as work.
180
+ * The job lines it names are the `fromLine`s of the stages the seed read named (the instrument-stopped ones, else every work
181
+ * stage); if none of them maps to a real line, EVERY numbered job line counts.
182
+ * @param {any[]} rows @param {any} declaration @param {string[]} guardNames @param {number[]} lineNums
183
+ * @returns {{failed: boolean, lines: number[]}}
184
+ */
185
+ export function seedReadFailure(rows, declaration, guardNames, lineNums) {
186
+ const work = (Array.isArray(rows) ? rows : []).filter((r) => !guardNames.includes(r?.stage));
187
+ const stopped = work.filter((r) => r?.verdict === 'instrument-stop');
188
+ const vacuous = !work.some((r) => r?.verdict === 'red');
189
+ if (stopped.length === 0 && !vacuous) return { failed: false, lines: [] };
190
+ const named = new Set((stopped.length ? stopped : work).map((r) => r?.stage));
191
+ /** @type {Set<number>} */
192
+ const found = new Set();
193
+ for (const st of Array.isArray(declaration?.stages) ? declaration.stages : []) {
194
+ if (named.has(st?.name) && Array.isArray(st.fromLine)) for (const n of st.fromLine) if (Number.isInteger(n)) found.add(n);
195
+ }
196
+ const lines = [...found].filter((n) => lineNums.includes(n)).sort((a, b) => a - b);
197
+ return { failed: true, lines: lines.length ? lines : [...lineNums] };
198
+ }
199
+
200
+ /** The honest truncation signal: bare-agent's Loop tags a round cut off at the output cap `error: 'truncated:max_tokens'`
201
+ * with the neutral `stopReason: 'max_tokens'` (node_modules/bare-agent/src/loop.js, BA-6/BA-13).
202
+ * @param {any} r */
203
+ export const isOutputCutOff = (r) => r?.error === 'truncated:max_tokens' || (Boolean(r?.error) && r?.stopReason === 'max_tokens');
204
+
113
205
  /**
114
206
  * ONE red, scrubbed — the same spelling `prepareSigning` uses (src/authorjob.js),
115
207
  * and for the same reason: a validation red QUOTES what the model declared, and
@@ -205,6 +297,22 @@ export const SOURCE_FIELD = Object.freeze({
205
297
  prompt: 'A local folder, subfolder or file, or one URL the machine can reach (no login, no setup).',
206
298
  });
207
299
 
300
+ /** P7: the Inputs box's wording (replaces Source). Line 1 is the repo; lines 2+ are tracked files inside it. */
301
+ export const INPUTS_FIELD = Object.freeze({
302
+ id: 'inputs',
303
+ kind: 'mechanical',
304
+ label: 'Inputs',
305
+ prompt: 'What the job points at, one per line as `label: value`. Line 1 is the repo, as an absolute path\n'
306
+ + '(e.g. `repo: /home/me/myrepo`). Lines 2 and on are files or folders inside that repo, repo-relative\n'
307
+ + '(e.g. `spec: docs/spec.md`) — git must track them.',
308
+ });
309
+
310
+ /** P7: how to write "The job" (printed under its question by the CLI; the panel's box shows the same in its examples). */
311
+ export const JOB_BLOCK_HELP = Object.freeze([
312
+ 'One line per thing the job must do — each plain line gets a number. A line starting with `~` is a rule for the',
313
+ 'numbered line above it. For a rubric check, a `~ PASS: ...` and a `~ FAIL: ...` line are your examples.',
314
+ ]);
315
+
208
316
  /** Destination's wording when Source resolved to a git repo: the answer IS the
209
317
  * signed `writeScope` fence (ITEM33-BUILD.md M3 ruling 2). */
210
318
  export const DESTINATION_FIELD_REPO = Object.freeze({
@@ -231,8 +339,11 @@ export const DESTINATION_FIELD_PLAIN = Object.freeze({
231
339
  export function destinationFieldFor(isRepo) { return isRepo ? DESTINATION_FIELD_REPO : DESTINATION_FIELD_PLAIN; }
232
340
 
233
341
  /**
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
342
+ * P7 (2026-10-10, docs/product/PANEL-BUILD.md): the old free-text trio (Goal, What success looks like, Guardrails) is ONE
343
+ * answer now — "The job", the person's numbered lines with their `~` rules (src/jobblock.js renders it). What follows
344
+ * is the history of the trio, kept because the numbering rule below still holds.
345
+ *
346
+ * THE FREE-TEXT SLOTS EVERY CLASS SHARES, numbered contiguously from 1. This is what `answers[1..3]` are
236
347
  * filed under for every class; soft-green appends a fourth
237
348
  * (`SOFTGREEN_QUESTIONS`) and hitl appends a different fourth
238
349
  * (`HITL_QUESTIONS`), each its own next number rather than a fixed one, so a
@@ -242,7 +353,7 @@ export function destinationFieldFor(isRepo) { return isRepo ? DESTINATION_FIELD_
242
353
  * 2026-09-10 "The intake form (RULED)" ruling, `docs/product/PRD.md`) — not the
243
354
  * old worded questions ("What do you want done?", "How do you check today
244
355
  * 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
356
+ * (F159, `docs/wiki/FINDINGS.md`): a rulebook this prompt commit did not touch
246
357
  * still had to compile against a card built from a person's actual bar, and the
247
358
  * old wording never matched what got signed — this reshape (commit 1ac4d05)
248
359
  * trimmed the SET to three slots but left their PROSE unreconciled with the
@@ -251,9 +362,7 @@ export function destinationFieldFor(isRepo) { return isRepo ? DESTINATION_FIELD_
251
362
  * `authorPrompt`'s interview block and `scripts/run-interview.mjs`.
252
363
  */
253
364
  export const GREEN_QUESTIONS = Object.freeze({
254
- 1: 'what you want to achieve',
255
- 2: 'checks a machine can count',
256
- 3: 'what must not happen or change',
365
+ 1: 'the job, as numbered lines — each line may carry the rules (~) the person wrote under it',
257
366
  });
258
367
 
259
368
  /**
@@ -268,13 +377,11 @@ export const GREEN_QUESTIONS = Object.freeze({
268
377
  * calls `questionsFor` — additive, nothing existing breaks.
269
378
  */
270
379
  export const FIELD_LABELS = Object.freeze({
271
- 1: 'Goal',
272
- 2: 'What success looks like',
273
- 3: 'Guardrails',
380
+ 1: 'The job',
274
381
  });
275
382
 
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
383
+ /** The key Judge Examples and hitl's own question both prove out at: green holds
384
+ * exactly one free-text slot (P7), so 2 is the next number for both the
278
385
  * soft-green and hitl extensions below — computed, never hardcoded twice. */
279
386
  const NEXT_FREE_TEXT_KEY = Object.keys(GREEN_QUESTIONS).length + 1;
280
387
 
@@ -288,7 +395,7 @@ export const SOFTGREEN_JUDGE_EXAMPLES_KEY = NEXT_FREE_TEXT_KEY;
288
395
  * you'd pass and one you'd fail, and say why."). Exported so `cardauthor.js`'s
289
396
  * prompt and this module's own `SOFTGREEN_QUESTIONS` render the identical
290
397
  * string; its label ("Judge examples") lives in `SOFTGREEN_FIELD_LABELS`. */
291
- export const JUDGE_EXAMPLES_QUESTION = 'one pass, one fail, and why';
398
+ export const JUDGE_EXAMPLES_QUESTION = 'the PASS: and FAIL: examples the person wrote under their job lines, each with its line number';
292
399
 
293
400
  /** {@link FIELD_LABELS} plus Judge Examples' own label, for the soft-green set. */
294
401
  export const SOFTGREEN_FIELD_LABELS = Object.freeze({
@@ -457,7 +564,7 @@ export const LANGUAGE_PICK_FIELD = Object.freeze({
457
564
  });
458
565
 
459
566
  /**
460
- * F175's open half (docs/logs/FINDINGS.md, run mu0voeo4), hamr's ruling
567
+ * F175's open half (docs/wiki/FINDINGS.md, run mu0voeo4), hamr's ruling
461
568
  * (2026-09-16): "Confirm" and "Type the goal yourself" may not bypass a
462
569
  * `questions` entry the model itself raised on the plan being accepted — the
463
570
  * person answers it inline, one question at a time, with NO extra model
@@ -746,10 +853,11 @@ export function schemaCoverage(catalogue = KIND_CATALOGUE) {
746
853
  * THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
747
854
  * and failing loudly beats emitting a schema that quietly cannot say something
748
855
  * the validator will then demand.
856
+ * `questions` (P7 item 5, last drafting try only) adds the optional `questions` property; absent, the schema is byte-identical.
749
857
  * @param {Record<string, any>} [catalogue]
750
- * @param {{verdictType?: string|null}} [o]
858
+ * @param {{verdictType?: string|null, questions?: boolean}} [o]
751
859
  */
752
- export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null } = {}) {
860
+ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null, questions = false } = {}) {
753
861
  const cov = schemaCoverage(catalogue);
754
862
  if (!cov.ok) {
755
863
  throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
@@ -768,6 +876,7 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
768
876
  type: 'object',
769
877
  properties: {
770
878
  name: { type: 'string', description: 'a unique lowercase-hyphenated slug saying what this stage asserts' },
879
+ fromLine: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'integer', minimum: 1 }, description: 'the numbers of the job line(s) this stage checks, e.g. [1] or [1, 3] (absent on a mandatory guard)' },
771
880
  kind: { const: kind },
772
881
  params: { type: 'object', properties, required: [...spec.required], additionalProperties: false },
773
882
  },
@@ -795,6 +904,30 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
795
904
  items: { type: 'string', minLength: 1 },
796
905
  description: 'anything you could not express, or any fact you needed and did not have',
797
906
  },
907
+ refused: {
908
+ type: 'array',
909
+ items: {
910
+ type: 'object',
911
+ properties: {
912
+ line: { type: 'integer', minimum: 1, description: 'the number of a job line no stage checks' },
913
+ reason: { type: 'string', minLength: 1, description: 'why, in plain words the person can read' },
914
+ },
915
+ required: ['line', 'reason'],
916
+ additionalProperties: false,
917
+ },
918
+ description: 'job lines you could not turn into a stage, each with a reason — a note does not cover a line',
919
+ },
920
+ ...(questions ? {
921
+ questions: {
922
+ type: 'array',
923
+ items: {
924
+ type: 'object',
925
+ properties: { line: { type: 'integer', minimum: 1 }, question: { type: 'string', minLength: 1 } },
926
+ required: ['line', 'question'],
927
+ },
928
+ description: QUESTIONS_DESCRIPTION,
929
+ },
930
+ } : {}),
798
931
  },
799
932
  required: ['stages'],
800
933
  additionalProperties: false,
@@ -805,13 +938,13 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
805
938
  * The output channel. It records and acknowledges; it takes no action, which is
806
939
  * why handing it to a toolless authoring call does not reopen D3.
807
940
  * @param {{calls: any[]}} box
808
- * @param {{catalogue?: Record<string, any>, verdictType?: string|null}} [o]
941
+ * @param {{catalogue?: Record<string, any>, verdictType?: string|null, questions?: boolean}} [o]
809
942
  */
810
- export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null } = {}) {
943
+ export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null, questions = false } = {}) {
811
944
  return {
812
945
  name: DECLARATION_TOOL_NAME,
813
946
  description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
814
- parameters: declarationSchema(catalogue, { verdictType }),
947
+ parameters: declarationSchema(catalogue, { verdictType, questions }),
815
948
  execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
816
949
  };
817
950
  }
@@ -986,7 +1119,7 @@ export function writeScopeBlock(writeScope) {
986
1119
  * acceptance — BINDING, never re-decided by the composer, and a DIFFERENT
987
1120
  * meaning from `openQuestions` (which means "could not resolve"): the two
988
1121
  * never merge.
989
- * @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[],
1122
+ * @param {{checks: string[], checkItems?: {text: string, fromLine: number[]|null}[], protections: string[], openQuestions?: string[], notChecked?: string[],
990
1123
  * answeredQuestions?: string[]}} confirmed
991
1124
  */
992
1125
  export function confirmedBlock(confirmed) {
@@ -998,7 +1131,9 @@ export function confirmedBlock(confirmed) {
998
1131
  return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
999
1132
  + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
1000
1133
  + 'neither do you:\n\n'
1001
- + `${checks.map((c) => ` - ${c}`).join('\n') || ' (none)'}\n\n`
1134
+ + `${(confirmed.checkItems?.length
1135
+ ? confirmed.checkItems.map((c) => ` - ${c.fromLine && c.fromLine.length ? `[line ${c.fromLine.join(', ')}] ` : ''}${c.text}`)
1136
+ : checks.map((c) => ` - ${c}`)).join('\n') || ' (none)'}\n\n`
1002
1137
  + 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
1003
1138
  + `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
1004
1139
  + (notChecked.length
@@ -1013,6 +1148,21 @@ export function confirmedBlock(confirmed) {
1013
1148
  : 'The confirm turn left no open questions.');
1014
1149
  }
1015
1150
 
1151
+ /**
1152
+ * P7: the job-line tagging order (model-facing, registered). Rule 1 of the job block: every stage the drafter writes
1153
+ * names the ONE numbered job line it serves, or the line is refused with a reason; the mandatory guards serve no line.
1154
+ * The $0 validator (validateDeclaration, `jobLines`) enforces it and the revise loop feeds a miss back.
1155
+ * @param {number} count how many numbered job lines the person wrote
1156
+ */
1157
+ export function jobLineTagBlock(count) {
1158
+ return 'EVERY STAGE SERVES ONE JOB LINE\n\n'
1159
+ + `The person's job has ${count} numbered line${count === 1 ? '' : 's'} (the interview above). Tag every stage YOU write `
1160
+ + 'with "fromLine": [N, ...] — the number(s) of the job line(s) that stage checks (one stage may serve several lines, e.g. a judged stage that reads lines 1-3). A job line that no kind in the catalogue '
1161
+ + 'can check is not left out silently: list it in "refused" as {"line": N, "reason": "..."}, with a reason in plain '
1162
+ + 'words the person can read. Every numbered line needs at least one stage or one refusal — a note in "notes" does '
1163
+ + 'NOT cover a line. The mandatory guards serve no job line and carry NO "fromLine".';
1164
+ }
1165
+
1016
1166
  /**
1017
1167
  * The whole authoring prompt, as one string.
1018
1168
  *
@@ -1035,11 +1185,15 @@ export function confirmedBlock(confirmed) {
1035
1185
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
1036
1186
  * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1037
1187
  * writeScope?: string[]|null,
1038
- * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
1188
+ * jobLines?: {n: number}[]|null, inputs?: {n: number, label: string, value: string}[]|null,
1189
+ * confirmed?: {checks: string[], checkItems?: {text: string, fromLine: number[]|null}[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
1039
1190
  */
1040
1191
  export function authorPrompt({
1041
1192
  answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
1042
1193
  ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
1194
+ // P7: the person's numbered job lines and the inputs they pointed at. ABSENT = byte-identical prompt (every caller
1195
+ // that predates the job block). Present: the drafter is told to tag every stage it writes with the line it serves.
1196
+ jobLines = null, inputs = null,
1043
1197
  // PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
1044
1198
  // turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
1045
1199
  // only when a confirm turn actually ran and the person accepted a plan.
@@ -1115,7 +1269,9 @@ measure anything.`;
1115
1269
  // asks what KIND of job this is; the person answered what DONE means, and the
1116
1270
  // composer reads that plus the catalogue and works the rest out.
1117
1271
  `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
1118
- `THE INTERVIEW — the person's own words\n\n${interview}`,
1272
+ `THE INTERVIEW — the person's own words\n\n${interview}`
1273
+ + (inputs && inputs.length ? `\n\n${renderInputs(inputs)}` : ''),
1274
+ ...(jobLines && jobLines.length ? [jobLineTagBlock(jobLines.length)] : []),
1119
1275
  // Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
1120
1276
  // change, which are read-only) is proven against the machine now, not typed,
1121
1277
  // and this is where that proof reaches the composer instead of vanishing.
@@ -1380,7 +1536,7 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
1380
1536
  */
1381
1537
  /**
1382
1538
  * @param {{ceilingUsd?: number|null,
1383
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void}} [o]
1539
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void}} [o]
1384
1540
  * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
1385
1541
  * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
1386
1542
  * only chance to know what has been spent while the run is still alive. It is
@@ -1401,7 +1557,9 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1401
1557
  const call = { label, ...priceOf(r) };
1402
1558
  entries.push(call);
1403
1559
  raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
1404
- onCall({ ...call });
1560
+ // bare-agent's per-run `thinkingApplied` (true | false | null) rides the REPORTING seam only — never the stored entry,
1561
+ // so the cost report and every persisted shape stay as they were; absent when thinkingMode was not sent
1562
+ onCall({ ...call, ...(r?.thinkingApplied === undefined ? {} : { thinkingApplied: r.thinkingApplied }) });
1405
1563
  return entries.at(-1);
1406
1564
  },
1407
1565
  /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
@@ -1441,10 +1599,13 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1441
1599
  * `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
1442
1600
  * `rates` is the customer's own price for this provider's key row (USD per 1K, `ratesFor`);
1443
1601
  * absent = bare-agent's built-in guess, exactly as before.
1444
- * @param {any} provider @param {{system?: string, maxTokens?: number, rates?: {in: number, out: number}|null}} [o]
1602
+ * `draftThinking` ('on' | 'off', operator knob; default 'off') adds the drafter's per-call thinking option via `draftCallOptions`.
1603
+ * @param {any} provider @param {{system?: string, maxTokens?: number, rates?: {in: number, out: number}|null, draftThinking?: string}} [o]
1445
1604
  * @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
1446
1605
  */
1447
- export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens = AUTHOR_MAX_TOKENS, rates = null } = {}) {
1606
+ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens = AUTHOR_MAX_TOKENS, rates = null, draftThinking = 'off' } = {}) {
1607
+ // the drafter's per-call options (src/providers.js `draftCallOptions`, one owner); a bad mode throws HERE, at construction
1608
+ const callOptions = draftCallOptions(provider, draftThinking);
1448
1609
  return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
1449
1610
  const loop = new Loop({ provider, system, ...(rates ? { rates } : {}) });
1450
1611
  const wired = tools.map((t) => ({
@@ -1460,7 +1621,7 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
1460
1621
  // throws on a malformed tool-call arguments string (BA-27): it returns the
1461
1622
  // round priced with `toolCalls: []` plus its own `malformedToolCall` marker,
1462
1623
  // which `Loop.run` surfaces unchanged on its return. No local shim needed.
1463
- return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...opts });
1624
+ return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...callOptions, ...opts });
1464
1625
  };
1465
1626
  }
1466
1627
 
@@ -1489,7 +1650,7 @@ function canonical(x) {
1489
1650
  * label: string, book: ReturnType<typeof makeCostBook>,
1490
1651
  * channel: {name: string, instruction: string, textPath?: string, tool: (box: {calls: any[]}) => any}}} o
1491
1652
  * @returns {Promise<{artifact: any, attempts: number, convo: any[], raw: string,
1492
- * providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
1653
+ * providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null, allCutOff?: boolean}>}
1493
1654
  */
1494
1655
  export async function askStructured({ messages, generate, mode, retries, label, book, channel }) {
1495
1656
  /** @type {any[]} */
@@ -1497,6 +1658,8 @@ export async function askStructured({ messages, generate, mode, retries, label,
1497
1658
  let attempts = 0;
1498
1659
  /** @type {Red|null} */
1499
1660
  let red = null;
1661
+ /** how many of this ask's attempts were cut off at the output cap (P7: all of them = a failed try for the question gate) */
1662
+ let cutOffs = 0;
1500
1663
 
1501
1664
  for (let attempt = 0; attempt <= retries; attempt += 1) {
1502
1665
  // THE CEILING, immediately before the paid call and nowhere else. Sited HERE
@@ -1541,11 +1704,23 @@ export async function askStructured({ messages, generate, mode, retries, label,
1541
1704
  book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1542
1705
  const raw = redactSecrets(String(r?.text ?? ''));
1543
1706
 
1544
- if (r?.error) {
1707
+ // ITEM 6 (P7, live session smv2bzdqmp0f8, 2026-10-10): a round cut off at the output cap is the MODEL's own overlong
1708
+ // answer, not a transport fault — it is a failed try on the same ladder as a malformed artifact (the spend is already
1709
+ // booked above). Only a real error (a throw, a provider/halt tag) stays provider-red.
1710
+ const cutOff = isOutputCutOff(r);
1711
+ if (cutOff) cutOffs += 1;
1712
+ if (r?.error && !cutOff) {
1545
1713
  return { artifact: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
1546
1714
  }
1547
1715
 
1548
- if (mode === 'tool') {
1716
+ if (cutOff) {
1717
+ red = {
1718
+ code: 'artifact-red',
1719
+ path: channel.name,
1720
+ detail: "the AI's answer was cut off at the output limit",
1721
+ axis: 'output-truncated',
1722
+ };
1723
+ } else if (mode === 'tool') {
1549
1724
  // F179 (2026-09-15 ruling) — retry, never repair. bare-agent 0.43.0
1550
1725
  // (BA-27) already priced this round on the real usage and returns it
1551
1726
  // with `toolCalls: []` plus its own `malformedToolCall` marker instead
@@ -1598,22 +1773,23 @@ export async function askStructured({ messages, generate, mode, retries, label,
1598
1773
 
1599
1774
  if (attempt < retries) {
1600
1775
  const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
1601
- convo.push({ role: 'assistant', content: raw.trim() || '(the reply carried no text)' });
1776
+ // a cut-off reply is NOT replayed (it would re-bill the whole cap as input and teach nothing)
1777
+ convo.push({ role: 'assistant', content: (cutOff ? '' : raw.trim()) || '(the reply carried no text)' });
1602
1778
  convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? channel.instruction : STRUCTURE_INSTRUCTION_TEXT}` });
1603
1779
  }
1604
1780
  }
1605
- return { artifact: null, attempts, convo, raw: '', providerError: null, red, budget: null };
1781
+ return { artifact: null, attempts, convo, raw: '', providerError: null, red, budget: null, allCutOff: attempts > 0 && cutOffs === attempts };
1606
1782
  }
1607
1783
 
1608
1784
  /** THE DECLARATION CHANNEL — the one this loop has always used. Its schema is
1609
1785
  * CLASS-SCOPED, like the prompt's catalogue block: the two must offer one set of
1610
1786
  * kinds, or the composer reads about a kind it cannot call.
1611
- * @param {{catalogue: Record<string, any>, verdictType: string}} o */
1612
- const declarationChannel = ({ catalogue, verdictType }) => ({
1787
+ * @param {{catalogue: Record<string, any>, verdictType: string, questions?: boolean}} o */
1788
+ const declarationChannel = ({ catalogue, verdictType, questions = false }) => ({
1613
1789
  name: DECLARATION_TOOL_NAME,
1614
1790
  instruction: STRUCTURE_INSTRUCTION_TOOL,
1615
1791
  textPath: 'declaration',
1616
- tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType }),
1792
+ tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType, questions }),
1617
1793
  });
1618
1794
 
1619
1795
  /** The declaration ask, over the shared ladder. `declaration` is `artifact`
@@ -1621,11 +1797,11 @@ const declarationChannel = ({ catalogue, verdictType }) => ({
1621
1797
  * anonymous artifact, and one word for two things is how a reader loses which.
1622
1798
  * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
1623
1799
  * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>,
1624
- * verdictType: string}} o
1800
+ * verdictType: string, questions?: boolean}} o
1625
1801
  */
1626
- async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue, verdictType }) {
1802
+ async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue, verdictType, questions = false }) {
1627
1803
  const r = await askStructured({
1628
- messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType }),
1804
+ messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType, questions }),
1629
1805
  });
1630
1806
  return { ...r, declaration: r.artifact };
1631
1807
  }
@@ -1663,9 +1839,17 @@ const CONFIRM_SCHEMA = Object.freeze({
1663
1839
  properties: {
1664
1840
  checks: {
1665
1841
  type: 'array',
1666
- items: { type: 'string', minLength: 1 },
1667
- description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
1668
- + 'success looks like answers actually asked for — never a check invented beyond them',
1842
+ items: {
1843
+ type: 'object',
1844
+ properties: {
1845
+ text: { type: 'string', minLength: 1 },
1846
+ fromLine: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'integer', minimum: 1 }, description: 'the number(s) of the numbered job line(s) this check is for, e.g. [1] or [1, 3]' },
1847
+ },
1848
+ required: ['text', 'fromLine'],
1849
+ additionalProperties: false,
1850
+ },
1851
+ description: 'the mechanical checks you plan to compose, each one traceable to the numbered job line it checks '
1852
+ + '(fromLine, an array: one check may serve several lines) — never a check invented beyond what the job lines and their rules asked for',
1669
1853
  },
1670
1854
  goal: {
1671
1855
  type: 'string',
@@ -1676,7 +1860,7 @@ const CONFIRM_SCHEMA = Object.freeze({
1676
1860
  notChecked: {
1677
1861
  type: 'array',
1678
1862
  items: { type: 'string', minLength: 1 },
1679
- description: 'anything the person asked for (in the Goal, What success looks like, Guardrails, or '
1863
+ description: 'anything the person asked for (in the job lines, the rules under them, or '
1680
1864
  + 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
1681
1865
  + 'own words; an empty list means every ask is covered by a listed check',
1682
1866
  },
@@ -1691,6 +1875,25 @@ const CONFIRM_SCHEMA = Object.freeze({
1691
1875
  additionalProperties: false,
1692
1876
  });
1693
1877
 
1878
+ /**
1879
+ * The confirm plan's checks as `{text, fromLine}` items. The schema asks for objects (P7: each check names the job line
1880
+ * it is for); a bare string (a text-mode reply, an older caller) reads as a check with no line.
1881
+ * @param {any} raw the plan's `checks`
1882
+ * @returns {{text: string, fromLine: number[]|null}[]}
1883
+ */
1884
+ export function normalizeChecks(raw) {
1885
+ /** a fromLine as a non-empty, duplicate-free array of line numbers, else null (never a guess)
1886
+ * @param {any} v @returns {number[]|null} */
1887
+ const normLines = (v) => (Array.isArray(v) && v.length > 0 && v.every((x) => Number.isInteger(x) && x >= 1) ? [...new Set(v)] : null);
1888
+ return (Array.isArray(raw) ? raw : []).flatMap((/** @type {any} */ c) => {
1889
+ if (typeof c === 'string') return c.trim() ? [{ text: c, fromLine: null }] : [];
1890
+ if (c && typeof c === 'object' && typeof c.text === 'string' && c.text.trim()) {
1891
+ return [{ text: c.text, fromLine: normLines(c.fromLine) }];
1892
+ }
1893
+ return [];
1894
+ });
1895
+ }
1896
+
1694
1897
  /** The output channel. It records and acknowledges; it takes no action.
1695
1898
  * @param {{calls: any[]}} box */
1696
1899
  export function confirmTool(box) {
@@ -1722,9 +1925,10 @@ const confirmChannel = () => ({
1722
1925
  */
1723
1926
  export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
1724
1927
  + 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
1725
- + 'signed goal sentence. You never propose a check the Goal or What-success-looks-like answers did not ask for '
1928
+ + 'signed goal sentence. You never propose a check the numbered job lines (and the ~ rules written under them) did not ask for '
1726
1929
  + '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
1727
- + 'that is exactly the mistake this order exists to prevent). The always-on guards (changed-from-seed, '
1930
+ + 'that is exactly the mistake this order exists to prevent). Tag each check with "fromLine", an array of the number(s) of the '
1931
+ + 'numbered job line(s) it is for. The always-on guards (changed-from-seed, '
1728
1932
  + 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
1729
1933
  + 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
1730
1934
  + 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
@@ -1756,15 +1960,16 @@ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a rea
1756
1960
  * @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
1757
1961
  * labels?: Record<string|number, string>, facts?: any, listing?: string|null,
1758
1962
  * writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
1759
- * fixText?: string|null, protections?: string[]}} o
1963
+ * fixText?: string|null, protections?: string[], inputs?: {n: number, label: string, value: string}[]|null}} o
1760
1964
  */
1761
- export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null, protections = [] }) {
1965
+ export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null, protections = [], inputs = null }) {
1762
1966
  const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
1763
1967
  for (const k of Object.keys(questions)) {
1764
1968
  const label = labels[k] ? ` (${labels[k]})` : '';
1765
1969
  lines.push(`Q${k}${label}. ${questions[k]}`);
1766
1970
  lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
1767
1971
  }
1972
+ if (inputs && inputs.length) lines.push('', renderInputs(inputs));
1768
1973
  if (isRepo && worseThanBefore) {
1769
1974
  lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
1770
1975
  }
@@ -1821,14 +2026,14 @@ async function askConfirmPlan({ convo, generate, mode, book, label }) {
1821
2026
  * generate: Function, book: ReturnType<typeof makeCostBook>,
1822
2027
  * ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
1823
2028
  * onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
1824
- * worseThanBefore?: string}} o retired field (hamr's ruling 2026-09-28):
2029
+ * worseThanBefore?: string, inputs?: {n: number, label: string, value: string}[]|null}} o retired field (hamr's ruling 2026-09-28):
1825
2030
  * `worseThanBefore` is never asked any more and defaults to `''` — a
1826
2031
  * caller may still pass a string through (kept for a future, non-ask
1827
2032
  * source of the same constraint), it just never comes from an ask() step.
1828
2033
  * @returns {Promise<{ok: boolean,
1829
2034
  * stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
1830
2035
  * rounds: number,
1831
- * accepted: {goal: string, checks: string[], protections: string[], lang: string,
2036
+ * accepted: {goal: string, checks: string[], checkItems: {text: string, fromLine: number[]|null}[], protections: string[], lang: string,
1832
2037
  * worseThanBefore: string, openQuestions: string[], notChecked: string[], answeredQuestions?: string[]}|null,
1833
2038
  * reds: Red[], cost: any}>}
1834
2039
  */
@@ -1841,6 +2046,8 @@ export async function runConfirmTurn({
1841
2046
  // still pass a resolved string through (kept as a plain pass-through, not
1842
2047
  // an ask trigger).
1843
2048
  worseThanBefore: presetWorseThanBefore = '',
2049
+ // P7: the inputs the person pointed at; absent = the prompt is byte-identical
2050
+ inputs = null,
1844
2051
  }) {
1845
2052
  /** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
1846
2053
  /** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
@@ -1911,14 +2118,14 @@ export async function runConfirmTurn({
1911
2118
  let convo = [{
1912
2119
  role: 'user',
1913
2120
  content: confirmPrompt({
1914
- answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections,
2121
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections, inputs,
1915
2122
  }),
1916
2123
  }];
1917
2124
 
1918
2125
  for (let round = 1; round <= 2; round += 1) {
1919
2126
  if (fixText) {
1920
2127
  convo = [...convo, { role: 'user', content: confirmPrompt({
1921
- answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText, protections,
2128
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText, protections, inputs,
1922
2129
  }) }];
1923
2130
  }
1924
2131
  onPhase('confirm-round', { round });
@@ -1947,6 +2154,8 @@ export async function runConfirmTurn({
1947
2154
  // shown at confirm-done and recorded on acceptance. `notChecked` is the
1948
2155
  // model's own honest gap list, carried through unchanged.
1949
2156
  const notChecked = [...(r.plan.notChecked ?? [])];
2157
+ const checkItems = normalizeChecks(r.plan.checks);
2158
+ r.plan = { ...r.plan, checks: checkItems.map((c) => c.text), checkItems };
1950
2159
  const plan = { ...r.plan, protections, notChecked };
1951
2160
  onPhase('confirm-done', { round, plan });
1952
2161
  const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
@@ -1959,7 +2168,7 @@ export async function runConfirmTurn({
1959
2168
  return {
1960
2169
  ok: true, stop: null, rounds: round,
1961
2170
  accepted: {
1962
- goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
2171
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], checkItems: [...(r.plan.checkItems ?? [])], protections: [...protections],
1963
2172
  lang: resolvedLang, worseThanBefore,
1964
2173
  // every question forced above was answered in order, so what's left
1965
2174
  // unresolved is honestly empty — computed FROM questionsFromPlan
@@ -1980,7 +2189,7 @@ export async function runConfirmTurn({
1980
2189
  return {
1981
2190
  ok: true, stop: null, rounds: round,
1982
2191
  accepted: {
1983
- goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
2192
+ goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], checkItems: [...(r.plan.checkItems ?? [])], protections: [...protections],
1984
2193
  lang: resolvedLang, worseThanBefore,
1985
2194
  openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
1986
2195
  notChecked: [...notChecked], answeredQuestions,
@@ -2002,7 +2211,7 @@ export async function runConfirmTurn({
2002
2211
  return {
2003
2212
  ok: true, stop: null, rounds: round,
2004
2213
  accepted: {
2005
- goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
2214
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], checkItems: [...(r.plan.checkItems ?? [])], protections: [...protections],
2006
2215
  lang: resolvedLang, worseThanBefore, openQuestions: [...questionsFromPlan(r.plan), redactedFix], notChecked: [...notChecked],
2007
2216
  },
2008
2217
  reds: [], cost: book.report(),
@@ -2056,12 +2265,14 @@ export async function runConfirmTurn({
2056
2265
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
2057
2266
  * ceilingUsd?: number|null,
2058
2267
  * onPhase?: (phase: string, data?: any) => void,
2059
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
2268
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
2060
2269
  * maxRevisions?: number, structureRetries?: number,
2061
2270
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
2062
2271
  * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
2063
2272
  * priorRaws?: any[]|null,
2064
- * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
2273
+ * jobLines?: {n: number, text: string}[]|null, inputs?: {n: number, label: string, value: string}[]|null,
2274
+ * offerQuestions?: boolean,
2275
+ * confirmed?: {checks: string[], checkItems?: {text: string, fromLine: number[]|null}[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
2065
2276
  */
2066
2277
  export async function authorClose({
2067
2278
  workdir, seedRef, lang, verdictType,
@@ -2084,6 +2295,9 @@ export async function authorClose({
2084
2295
  // straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
2085
2296
  // that ran no confirm turn — the prompt is then byte-identical to today.
2086
2297
  confirmed = null,
2298
+ // P7: the person's numbered job lines and inputs (src/jobblock.js). ABSENT = every caller that predates the job block:
2299
+ // no tagging order in the prompt, and no coverage rule in the validator.
2300
+ jobLines = null, inputs = null,
2087
2301
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
2088
2302
  ceilingUsd = null,
2089
2303
  // The two REPORTING seams, defaulted to nothing so every existing caller is
@@ -2095,6 +2309,10 @@ export async function authorClose({
2095
2309
  structureRetries = MAX_STRUCTURE_RETRIES,
2096
2310
  structuredMode = 'tool',
2097
2311
  catalogue = KIND_CATALOGUE,
2312
+ // P7 item 5 (fwdloop am25): OFF unless the caller can really ask the person (the panel). When on, ONLY the last of the
2313
+ // full drafting ladder (the author call plus both revisions), and only after the two before it both failed validation,
2314
+ // is offered the optional `questions` property — see `askOpen` below. Absent = byte-identical to today.
2315
+ offerQuestions = false,
2098
2316
  }) {
2099
2317
  // TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
2100
2318
  // revising launders thrash as adaptation (addendum 5). The floor is zero: one
@@ -2257,7 +2475,7 @@ export async function authorClose({
2257
2475
  // ── the grounded loop ─────────────────────────────────────────────────────
2258
2476
  const prompt = authorPrompt({
2259
2477
  answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
2260
- lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
2478
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed, jobLines, inputs,
2261
2479
  });
2262
2480
  /** @type {any[]} */
2263
2481
  let messages = [{ role: 'user', content: prompt }];
@@ -2323,6 +2541,15 @@ export async function authorClose({
2323
2541
  };
2324
2542
  };
2325
2543
 
2544
+ /** the job lines the PREVIOUS try's validation reds named — set only when the next try may ask (null = it may not) @type {number[]|null} */
2545
+ let questionLines = null;
2546
+ /** per try: null = it passed (validated AND was measured with a work stage red); else the job lines its failure named @type {({lines: number[]}|null)[]} */
2547
+ const tryFails = [];
2548
+ /** @type {{line: number, question: string}[]} */
2549
+ let askedQuestions = [];
2550
+ /** P7 item 5: may the try AFTER try `i` ask? Only the last try of a full ladder, and only when try 1 and try `i` both failed. */
2551
+ const nextTryMayAsk = (/** @type {number} */ i) => offerQuestions && revisionCap === MAX_REVISIONS && i === revisionCap - 1
2552
+ && Array.isArray(jobLines) && jobLines.length > 0 && Boolean(tryFails[0]) && Boolean(tryFails[1]);
2326
2553
  const seedTrees = makeSeedTrees();
2327
2554
  try {
2328
2555
  for (let i = 0; i <= revisionCap; i += 1) {
@@ -2332,8 +2559,21 @@ export async function authorClose({
2332
2559
  // a readout quoting a number the loop does not obey is a second
2333
2560
  // instrument, and this file's own history is what that costs.
2334
2561
  onPhase('author-call', { call: label, i, of: revisionCap });
2335
- const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue, verdictType });
2562
+ // P7 item 5: a mechanism, never a wish. Only the last try of the full ladder carries the property, and only when the
2563
+ // two before it both failed (validation or the seed read; `questionLines` is set at the end of the second one, below).
2564
+ const askOpen = questionLines !== null && i === revisionCap;
2565
+ const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue, verdictType, questions: askOpen });
2336
2566
  messages = ask.convo;
2567
+ /** @type {{reason: string, raw: any}[]} */
2568
+ let droppedQuestions = [];
2569
+ if (askOpen && ask.declaration && typeof ask.declaration === 'object' && Object.hasOwn(ask.declaration, 'questions')) {
2570
+ // `questions` is never part of the declaration: stripped before validation, kept only through the machine's filter
2571
+ const { questions: rawQuestions, ...rest } = ask.declaration;
2572
+ ask.declaration = rest;
2573
+ const nq = normalizeQuestions(rawQuestions, /** @type {number[]} */ (questionLines));
2574
+ askedQuestions = nq.kept;
2575
+ droppedQuestions = nq.dropped;
2576
+ }
2337
2577
 
2338
2578
  // The cap tripped BEFORE this ask made any call at all, so there is no
2339
2579
  // iteration to record: an iteration row for a call that never happened is
@@ -2351,6 +2591,7 @@ export async function authorClose({
2351
2591
  call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
2352
2592
  artifactRed: ask.red ?? null, providerError: ask.providerError ?? null,
2353
2593
  validation: null, seedRead: null, reviseTurn: null,
2594
+ ...(askOpen ? { questionsOffered: true, questions: askedQuestions, droppedQuestions } : {}),
2354
2595
  };
2355
2596
  iterations.push(iter);
2356
2597
 
@@ -2374,10 +2615,31 @@ export async function authorClose({
2374
2615
  break;
2375
2616
  }
2376
2617
  if (!ask.declaration) {
2618
+ // hamr's ruling 2026-10-10 (live session smv2k6bczlvk3): a try whose EVERY structure attempt was cut off at the output cap
2619
+ // is a FAILED try for the question gate. A cut-off names no line, so every numbered job line counts. The ladder goes on to
2620
+ // try 3 only when that gate can open (try 1 failed too); anywhere else it stops artifact-red as it always did.
2621
+ if (ask.allCutOff && i === revisionCap - 1) {
2622
+ const allLines = (jobLines ?? []).map((l) => l.n);
2623
+ tryFails[i] = { lines: allLines };
2624
+ iter.cutOff = true;
2625
+ if (nextTryMayAsk(i)) {
2626
+ questionLines = allLines.length ? allLines : null;
2627
+ const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (ask.red).detail] });
2628
+ messages = [...ask.convo, { role: 'assistant', content: '(the reply carried no text)' },
2629
+ { role: 'user', content: `${block}\n\n${structuredMode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` }];
2630
+ continue;
2631
+ }
2632
+ }
2377
2633
  fatal = [/** @type {Red} */ (ask.red)];
2378
2634
  stop = 'artifact-red';
2379
2635
  break;
2380
2636
  }
2637
+ // P7 item 5: the person is asked; nothing from this try is validated, measured or signable. The reds that opened the
2638
+ // asking stay as the record of why (`lastValidation`).
2639
+ if (askedQuestions.length) {
2640
+ stop = 'questions-open';
2641
+ break;
2642
+ }
2381
2643
 
2382
2644
  // early stop: an unchanged declaration has the previous iteration's
2383
2645
  // measurements by definition — re-executing it buys nothing and costs a
@@ -2390,7 +2652,7 @@ export async function authorClose({
2390
2652
  }
2391
2653
  previous = ask.declaration;
2392
2654
 
2393
- const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType, writeScope });
2655
+ const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType, writeScope, jobLines });
2394
2656
  // SCRUBBED HERE, once, where the reds enter this module's records — the same
2395
2657
  // boundary rule `renderRejectBlock` already states, applied to the OTHER
2396
2658
  // channel they travel down. A validation red quotes what the model declared
@@ -2410,6 +2672,7 @@ export async function authorClose({
2410
2672
  // a validation red IS a measurement of the declaration, so it rides the
2411
2673
  // same channel and counts as a revision (addendum 5)
2412
2674
  measured = renderRejectBlock({ kind: 'validation', reds: vReds });
2675
+ tryFails[i] = { lines: linesNamedByReds(vReds, ask.declaration, (jobLines ?? []).map((l) => l.n)) };
2413
2676
  } else {
2414
2677
  // what gets MEASURED is what will RUN: the resolved declaration with the
2415
2678
  // genre's environment already injected
@@ -2438,10 +2701,20 @@ export async function authorClose({
2438
2701
  finalFrom = label;
2439
2702
  measured = renderSeedReadBlock(rows);
2440
2703
  measuredIterations.push({ label, declaration: injected.declaration, seedRead: rows, droppedEnv: injected.dropped });
2704
+ const sf = seedReadFailure(rows, injected.declaration, guards.map((/** @type {any} */ g) => g.name), (jobLines ?? []).map((l) => l.n));
2705
+ tryFails[i] = sf.failed ? { lines: sf.lines } : null;
2441
2706
  }
2442
2707
 
2443
2708
  if (i === revisionCap) { stop = 'max-revisions'; break; }
2444
2709
 
2710
+ // P7 item 5: the NEXT try is the last of a full ladder, and this one AND the first both FAILED — either failed validation
2711
+ // or failed the seed read (a vacuous close; hamr's ruling 2026-10-10, differing from fwdloop's validation-only rule) — so
2712
+ // it may ask, about the lines THIS try's failure named. A try that passed (a confident guess) never opens asking.
2713
+ if (nextTryMayAsk(i)) {
2714
+ const second = tryFails[1];
2715
+ questionLines = second && second.lines.length ? second.lines : null;
2716
+ }
2717
+
2445
2718
  const turn = buildReviseTurn(measured);
2446
2719
  // A TRIPWIRE, not behaviour: it can only fire once something else is
2447
2720
  // already broken, so it is the one guard in this module the mutation
@@ -2524,6 +2797,8 @@ export async function authorClose({
2524
2797
  // WHICH stages broke on the dropped one, so this is never a silent swap.
2525
2798
  fellBack,
2526
2799
  stop,
2800
+ // P7 item 5: what the person is asked (empty on every other stop)
2801
+ questions: stop === 'questions-open' ? askedQuestions : [],
2527
2802
  revisions: Math.max(0, iterations.length - 1),
2528
2803
  };
2529
2804
  }