bareloop 0.38.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 (56) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bareloop.context.md +87 -24
  3. package/package.json +3 -3
  4. package/src/authorflow.js +335 -59
  5. package/src/authoring.js +125 -3
  6. package/src/authorjob.js +93 -32
  7. package/src/authorreadout.js +498 -3
  8. package/src/authorrun.js +26 -6
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +85 -11
  12. package/src/cardauthor.js +8 -2
  13. package/src/declaredclose.js +12 -5
  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 +323 -29
  19. package/src/kinds.js +13 -1
  20. package/src/ledger.js +1 -1
  21. package/src/panel/authorroutes.js +17 -11
  22. package/src/panel/authorsession.js +202 -75
  23. package/src/panel/index.html +302 -312
  24. package/src/panel/jobplan.js +107 -0
  25. package/src/panel/server.js +67 -13
  26. package/src/planrun.js +6 -5
  27. package/src/promptregisters.js +1 -1
  28. package/src/providers.js +111 -2
  29. package/src/reuse.js +1 -1
  30. package/src/root.js +1 -1
  31. package/src/run.js +3 -3
  32. package/src/selection.js +1 -1
  33. package/src/source.js +64 -1
  34. package/src/text.js +13 -0
  35. package/src/transport.js +1 -1
  36. package/src/trend.js +1 -0
  37. package/src/userrun.js +3 -3
  38. package/types/authorflow.d.ts +258 -39
  39. package/types/authoring.d.ts +30 -1
  40. package/types/authorjob.d.ts +52 -13
  41. package/types/authorreadout.d.ts +123 -1
  42. package/types/authorscout.d.ts +5 -3
  43. package/types/calibrate.d.ts +4 -1
  44. package/types/declaredclose.d.ts +5 -1
  45. package/types/draftspend.d.ts +8 -2
  46. package/types/job.d.ts +1 -1
  47. package/types/jobblock.d.ts +102 -0
  48. package/types/judged.d.ts +21 -2
  49. package/types/panel/authorsession.d.ts +57 -7
  50. package/types/panel/jobplan.d.ts +58 -0
  51. package/types/panel/server.d.ts +20 -0
  52. package/types/planrun.d.ts +1 -1
  53. package/types/providers.d.ts +52 -1
  54. package/types/run.d.ts +1 -1
  55. package/types/source.d.ts +26 -0
  56. package/types/trend.d.ts +1 -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({
@@ -384,7 +491,8 @@ export const CLASS_STATEMENTS = Object.freeze({
384
491
  + 'the result and deciding whether it is any good — and no command can render it. Compose the mechanical part '
385
492
  + 'first: everything about this repository a command can decide is still a mechanical stage, exactly as it would '
386
493
  + 'be for a green job. Then put ONE judged-floor stage AFTER them, carrying the few things the person said they '
387
- + 'actually look for and the real files the work lands in. A judged stage decides only the lines its card names, '
494
+ + 'actually look for and the real files the work lands in (files the job may EDIT, inside the write fence, '
495
+ + 'because a finding on a file the run is fenced out of can never be fixed). A judged stage decides only the lines its card names, '
388
496
  + 'so name each thing they look for as its own line, and leave nothing to be inferred.',
389
497
  // N4 slice 1. The mechanical-first composition law is stated as an ORDER
390
498
  // because first-red-wins makes the order the mechanism: the cheap stages shield
@@ -456,7 +564,7 @@ export const LANGUAGE_PICK_FIELD = Object.freeze({
456
564
  });
457
565
 
458
566
  /**
459
- * 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
460
568
  * (2026-09-16): "Confirm" and "Type the goal yourself" may not bypass a
461
569
  * `questions` entry the model itself raised on the plan being accepted — the
462
570
  * person answers it inline, one question at a time, with NO extra model
@@ -745,10 +853,11 @@ export function schemaCoverage(catalogue = KIND_CATALOGUE) {
745
853
  * THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
746
854
  * and failing loudly beats emitting a schema that quietly cannot say something
747
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.
748
857
  * @param {Record<string, any>} [catalogue]
749
- * @param {{verdictType?: string|null}} [o]
858
+ * @param {{verdictType?: string|null, questions?: boolean}} [o]
750
859
  */
751
- export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null } = {}) {
860
+ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null, questions = false } = {}) {
752
861
  const cov = schemaCoverage(catalogue);
753
862
  if (!cov.ok) {
754
863
  throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
@@ -767,6 +876,7 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
767
876
  type: 'object',
768
877
  properties: {
769
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)' },
770
880
  kind: { const: kind },
771
881
  params: { type: 'object', properties, required: [...spec.required], additionalProperties: false },
772
882
  },
@@ -794,6 +904,30 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
794
904
  items: { type: 'string', minLength: 1 },
795
905
  description: 'anything you could not express, or any fact you needed and did not have',
796
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
+ } : {}),
797
931
  },
798
932
  required: ['stages'],
799
933
  additionalProperties: false,
@@ -804,13 +938,13 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
804
938
  * The output channel. It records and acknowledges; it takes no action, which is
805
939
  * why handing it to a toolless authoring call does not reopen D3.
806
940
  * @param {{calls: any[]}} box
807
- * @param {{catalogue?: Record<string, any>, verdictType?: string|null}} [o]
941
+ * @param {{catalogue?: Record<string, any>, verdictType?: string|null, questions?: boolean}} [o]
808
942
  */
809
- export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null } = {}) {
943
+ export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null, questions = false } = {}) {
810
944
  return {
811
945
  name: DECLARATION_TOOL_NAME,
812
946
  description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
813
- parameters: declarationSchema(catalogue, { verdictType }),
947
+ parameters: declarationSchema(catalogue, { verdictType, questions }),
814
948
  execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
815
949
  };
816
950
  }
@@ -985,7 +1119,7 @@ export function writeScopeBlock(writeScope) {
985
1119
  * acceptance — BINDING, never re-decided by the composer, and a DIFFERENT
986
1120
  * meaning from `openQuestions` (which means "could not resolve"): the two
987
1121
  * never merge.
988
- * @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[],
989
1123
  * answeredQuestions?: string[]}} confirmed
990
1124
  */
991
1125
  export function confirmedBlock(confirmed) {
@@ -997,7 +1131,9 @@ export function confirmedBlock(confirmed) {
997
1131
  return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
998
1132
  + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
999
1133
  + 'neither do you:\n\n'
1000
- + `${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`
1001
1137
  + 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
1002
1138
  + `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
1003
1139
  + (notChecked.length
@@ -1012,6 +1148,21 @@ export function confirmedBlock(confirmed) {
1012
1148
  : 'The confirm turn left no open questions.');
1013
1149
  }
1014
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
+
1015
1166
  /**
1016
1167
  * The whole authoring prompt, as one string.
1017
1168
  *
@@ -1034,11 +1185,15 @@ export function confirmedBlock(confirmed) {
1034
1185
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
1035
1186
  * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1036
1187
  * writeScope?: string[]|null,
1037
- * 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
1038
1190
  */
1039
1191
  export function authorPrompt({
1040
1192
  answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
1041
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,
1042
1197
  // PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
1043
1198
  // turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
1044
1199
  // only when a confirm turn actually ran and the person accepted a plan.
@@ -1114,7 +1269,9 @@ measure anything.`;
1114
1269
  // asks what KIND of job this is; the person answered what DONE means, and the
1115
1270
  // composer reads that plus the catalogue and works the rest out.
1116
1271
  `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
1117
- `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)] : []),
1118
1275
  // Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
1119
1276
  // change, which are read-only) is proven against the machine now, not typed,
1120
1277
  // and this is where that proof reaches the composer instead of vanishing.
@@ -1379,7 +1536,7 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
1379
1536
  */
1380
1537
  /**
1381
1538
  * @param {{ceilingUsd?: number|null,
1382
- * 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]
1383
1540
  * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
1384
1541
  * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
1385
1542
  * only chance to know what has been spent while the run is still alive. It is
@@ -1400,7 +1557,9 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1400
1557
  const call = { label, ...priceOf(r) };
1401
1558
  entries.push(call);
1402
1559
  raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
1403
- 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 }) });
1404
1563
  return entries.at(-1);
1405
1564
  },
1406
1565
  /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
@@ -1440,10 +1599,13 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1440
1599
  * `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
1441
1600
  * `rates` is the customer's own price for this provider's key row (USD per 1K, `ratesFor`);
1442
1601
  * absent = bare-agent's built-in guess, exactly as before.
1443
- * @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]
1444
1604
  * @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
1445
1605
  */
1446
- 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);
1447
1609
  return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
1448
1610
  const loop = new Loop({ provider, system, ...(rates ? { rates } : {}) });
1449
1611
  const wired = tools.map((t) => ({
@@ -1459,7 +1621,7 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
1459
1621
  // throws on a malformed tool-call arguments string (BA-27): it returns the
1460
1622
  // round priced with `toolCalls: []` plus its own `malformedToolCall` marker,
1461
1623
  // which `Loop.run` surfaces unchanged on its return. No local shim needed.
1462
- 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 });
1463
1625
  };
1464
1626
  }
1465
1627
 
@@ -1488,7 +1650,7 @@ function canonical(x) {
1488
1650
  * label: string, book: ReturnType<typeof makeCostBook>,
1489
1651
  * channel: {name: string, instruction: string, textPath?: string, tool: (box: {calls: any[]}) => any}}} o
1490
1652
  * @returns {Promise<{artifact: any, attempts: number, convo: any[], raw: string,
1491
- * 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}>}
1492
1654
  */
1493
1655
  export async function askStructured({ messages, generate, mode, retries, label, book, channel }) {
1494
1656
  /** @type {any[]} */
@@ -1496,6 +1658,8 @@ export async function askStructured({ messages, generate, mode, retries, label,
1496
1658
  let attempts = 0;
1497
1659
  /** @type {Red|null} */
1498
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;
1499
1663
 
1500
1664
  for (let attempt = 0; attempt <= retries; attempt += 1) {
1501
1665
  // THE CEILING, immediately before the paid call and nowhere else. Sited HERE
@@ -1540,11 +1704,23 @@ export async function askStructured({ messages, generate, mode, retries, label,
1540
1704
  book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1541
1705
  const raw = redactSecrets(String(r?.text ?? ''));
1542
1706
 
1543
- 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) {
1544
1713
  return { artifact: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
1545
1714
  }
1546
1715
 
1547
- 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') {
1548
1724
  // F179 (2026-09-15 ruling) — retry, never repair. bare-agent 0.43.0
1549
1725
  // (BA-27) already priced this round on the real usage and returns it
1550
1726
  // with `toolCalls: []` plus its own `malformedToolCall` marker instead
@@ -1597,22 +1773,23 @@ export async function askStructured({ messages, generate, mode, retries, label,
1597
1773
 
1598
1774
  if (attempt < retries) {
1599
1775
  const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
1600
- 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)' });
1601
1778
  convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? channel.instruction : STRUCTURE_INSTRUCTION_TEXT}` });
1602
1779
  }
1603
1780
  }
1604
- 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 };
1605
1782
  }
1606
1783
 
1607
1784
  /** THE DECLARATION CHANNEL — the one this loop has always used. Its schema is
1608
1785
  * CLASS-SCOPED, like the prompt's catalogue block: the two must offer one set of
1609
1786
  * kinds, or the composer reads about a kind it cannot call.
1610
- * @param {{catalogue: Record<string, any>, verdictType: string}} o */
1611
- const declarationChannel = ({ catalogue, verdictType }) => ({
1787
+ * @param {{catalogue: Record<string, any>, verdictType: string, questions?: boolean}} o */
1788
+ const declarationChannel = ({ catalogue, verdictType, questions = false }) => ({
1612
1789
  name: DECLARATION_TOOL_NAME,
1613
1790
  instruction: STRUCTURE_INSTRUCTION_TOOL,
1614
1791
  textPath: 'declaration',
1615
- tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType }),
1792
+ tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType, questions }),
1616
1793
  });
1617
1794
 
1618
1795
  /** The declaration ask, over the shared ladder. `declaration` is `artifact`
@@ -1620,11 +1797,11 @@ const declarationChannel = ({ catalogue, verdictType }) => ({
1620
1797
  * anonymous artifact, and one word for two things is how a reader loses which.
1621
1798
  * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
1622
1799
  * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>,
1623
- * verdictType: string}} o
1800
+ * verdictType: string, questions?: boolean}} o
1624
1801
  */
1625
- 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 }) {
1626
1803
  const r = await askStructured({
1627
- messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType }),
1804
+ messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType, questions }),
1628
1805
  });
1629
1806
  return { ...r, declaration: r.artifact };
1630
1807
  }
@@ -1662,9 +1839,17 @@ const CONFIRM_SCHEMA = Object.freeze({
1662
1839
  properties: {
1663
1840
  checks: {
1664
1841
  type: 'array',
1665
- items: { type: 'string', minLength: 1 },
1666
- description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
1667
- + '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',
1668
1853
  },
1669
1854
  goal: {
1670
1855
  type: 'string',
@@ -1675,7 +1860,7 @@ const CONFIRM_SCHEMA = Object.freeze({
1675
1860
  notChecked: {
1676
1861
  type: 'array',
1677
1862
  items: { type: 'string', minLength: 1 },
1678
- 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 '
1679
1864
  + 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
1680
1865
  + 'own words; an empty list means every ask is covered by a listed check',
1681
1866
  },
@@ -1690,6 +1875,25 @@ const CONFIRM_SCHEMA = Object.freeze({
1690
1875
  additionalProperties: false,
1691
1876
  });
1692
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
+
1693
1897
  /** The output channel. It records and acknowledges; it takes no action.
1694
1898
  * @param {{calls: any[]}} box */
1695
1899
  export function confirmTool(box) {
@@ -1721,9 +1925,10 @@ const confirmChannel = () => ({
1721
1925
  */
1722
1926
  export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
1723
1927
  + 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
1724
- + '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 '
1725
1929
  + '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
1726
- + '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, '
1727
1932
  + 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
1728
1933
  + 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
1729
1934
  + 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
@@ -1755,15 +1960,16 @@ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a rea
1755
1960
  * @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
1756
1961
  * labels?: Record<string|number, string>, facts?: any, listing?: string|null,
1757
1962
  * writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
1758
- * fixText?: string|null, protections?: string[]}} o
1963
+ * fixText?: string|null, protections?: string[], inputs?: {n: number, label: string, value: string}[]|null}} o
1759
1964
  */
1760
- 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 }) {
1761
1966
  const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
1762
1967
  for (const k of Object.keys(questions)) {
1763
1968
  const label = labels[k] ? ` (${labels[k]})` : '';
1764
1969
  lines.push(`Q${k}${label}. ${questions[k]}`);
1765
1970
  lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
1766
1971
  }
1972
+ if (inputs && inputs.length) lines.push('', renderInputs(inputs));
1767
1973
  if (isRepo && worseThanBefore) {
1768
1974
  lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
1769
1975
  }
@@ -1820,14 +2026,14 @@ async function askConfirmPlan({ convo, generate, mode, book, label }) {
1820
2026
  * generate: Function, book: ReturnType<typeof makeCostBook>,
1821
2027
  * ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
1822
2028
  * onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
1823
- * 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):
1824
2030
  * `worseThanBefore` is never asked any more and defaults to `''` — a
1825
2031
  * caller may still pass a string through (kept for a future, non-ask
1826
2032
  * source of the same constraint), it just never comes from an ask() step.
1827
2033
  * @returns {Promise<{ok: boolean,
1828
2034
  * stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
1829
2035
  * rounds: number,
1830
- * 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,
1831
2037
  * worseThanBefore: string, openQuestions: string[], notChecked: string[], answeredQuestions?: string[]}|null,
1832
2038
  * reds: Red[], cost: any}>}
1833
2039
  */
@@ -1840,6 +2046,8 @@ export async function runConfirmTurn({
1840
2046
  // still pass a resolved string through (kept as a plain pass-through, not
1841
2047
  // an ask trigger).
1842
2048
  worseThanBefore: presetWorseThanBefore = '',
2049
+ // P7: the inputs the person pointed at; absent = the prompt is byte-identical
2050
+ inputs = null,
1843
2051
  }) {
1844
2052
  /** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
1845
2053
  /** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
@@ -1910,14 +2118,14 @@ export async function runConfirmTurn({
1910
2118
  let convo = [{
1911
2119
  role: 'user',
1912
2120
  content: confirmPrompt({
1913
- answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections,
2121
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections, inputs,
1914
2122
  }),
1915
2123
  }];
1916
2124
 
1917
2125
  for (let round = 1; round <= 2; round += 1) {
1918
2126
  if (fixText) {
1919
2127
  convo = [...convo, { role: 'user', content: confirmPrompt({
1920
- 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,
1921
2129
  }) }];
1922
2130
  }
1923
2131
  onPhase('confirm-round', { round });
@@ -1946,6 +2154,8 @@ export async function runConfirmTurn({
1946
2154
  // shown at confirm-done and recorded on acceptance. `notChecked` is the
1947
2155
  // model's own honest gap list, carried through unchanged.
1948
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 };
1949
2159
  const plan = { ...r.plan, protections, notChecked };
1950
2160
  onPhase('confirm-done', { round, plan });
1951
2161
  const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
@@ -1958,7 +2168,7 @@ export async function runConfirmTurn({
1958
2168
  return {
1959
2169
  ok: true, stop: null, rounds: round,
1960
2170
  accepted: {
1961
- 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],
1962
2172
  lang: resolvedLang, worseThanBefore,
1963
2173
  // every question forced above was answered in order, so what's left
1964
2174
  // unresolved is honestly empty — computed FROM questionsFromPlan
@@ -1979,7 +2189,7 @@ export async function runConfirmTurn({
1979
2189
  return {
1980
2190
  ok: true, stop: null, rounds: round,
1981
2191
  accepted: {
1982
- 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],
1983
2193
  lang: resolvedLang, worseThanBefore,
1984
2194
  openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
1985
2195
  notChecked: [...notChecked], answeredQuestions,
@@ -2001,7 +2211,7 @@ export async function runConfirmTurn({
2001
2211
  return {
2002
2212
  ok: true, stop: null, rounds: round,
2003
2213
  accepted: {
2004
- 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],
2005
2215
  lang: resolvedLang, worseThanBefore, openQuestions: [...questionsFromPlan(r.plan), redactedFix], notChecked: [...notChecked],
2006
2216
  },
2007
2217
  reds: [], cost: book.report(),
@@ -2055,12 +2265,14 @@ export async function runConfirmTurn({
2055
2265
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
2056
2266
  * ceilingUsd?: number|null,
2057
2267
  * onPhase?: (phase: string, data?: any) => void,
2058
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
2268
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
2059
2269
  * maxRevisions?: number, structureRetries?: number,
2060
2270
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
2061
2271
  * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
2062
2272
  * priorRaws?: any[]|null,
2063
- * 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
2064
2276
  */
2065
2277
  export async function authorClose({
2066
2278
  workdir, seedRef, lang, verdictType,
@@ -2083,6 +2295,9 @@ export async function authorClose({
2083
2295
  // straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
2084
2296
  // that ran no confirm turn — the prompt is then byte-identical to today.
2085
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,
2086
2301
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
2087
2302
  ceilingUsd = null,
2088
2303
  // The two REPORTING seams, defaulted to nothing so every existing caller is
@@ -2094,6 +2309,10 @@ export async function authorClose({
2094
2309
  structureRetries = MAX_STRUCTURE_RETRIES,
2095
2310
  structuredMode = 'tool',
2096
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,
2097
2316
  }) {
2098
2317
  // TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
2099
2318
  // revising launders thrash as adaptation (addendum 5). The floor is zero: one
@@ -2256,7 +2475,7 @@ export async function authorClose({
2256
2475
  // ── the grounded loop ─────────────────────────────────────────────────────
2257
2476
  const prompt = authorPrompt({
2258
2477
  answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
2259
- lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
2478
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed, jobLines, inputs,
2260
2479
  });
2261
2480
  /** @type {any[]} */
2262
2481
  let messages = [{ role: 'user', content: prompt }];
@@ -2322,6 +2541,15 @@ export async function authorClose({
2322
2541
  };
2323
2542
  };
2324
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]);
2325
2553
  const seedTrees = makeSeedTrees();
2326
2554
  try {
2327
2555
  for (let i = 0; i <= revisionCap; i += 1) {
@@ -2331,8 +2559,21 @@ export async function authorClose({
2331
2559
  // a readout quoting a number the loop does not obey is a second
2332
2560
  // instrument, and this file's own history is what that costs.
2333
2561
  onPhase('author-call', { call: label, i, of: revisionCap });
2334
- 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 });
2335
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
+ }
2336
2577
 
2337
2578
  // The cap tripped BEFORE this ask made any call at all, so there is no
2338
2579
  // iteration to record: an iteration row for a call that never happened is
@@ -2350,6 +2591,7 @@ export async function authorClose({
2350
2591
  call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
2351
2592
  artifactRed: ask.red ?? null, providerError: ask.providerError ?? null,
2352
2593
  validation: null, seedRead: null, reviseTurn: null,
2594
+ ...(askOpen ? { questionsOffered: true, questions: askedQuestions, droppedQuestions } : {}),
2353
2595
  };
2354
2596
  iterations.push(iter);
2355
2597
 
@@ -2373,10 +2615,31 @@ export async function authorClose({
2373
2615
  break;
2374
2616
  }
2375
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
+ }
2376
2633
  fatal = [/** @type {Red} */ (ask.red)];
2377
2634
  stop = 'artifact-red';
2378
2635
  break;
2379
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
+ }
2380
2643
 
2381
2644
  // early stop: an unchanged declaration has the previous iteration's
2382
2645
  // measurements by definition — re-executing it buys nothing and costs a
@@ -2389,7 +2652,7 @@ export async function authorClose({
2389
2652
  }
2390
2653
  previous = ask.declaration;
2391
2654
 
2392
- const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType });
2655
+ const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType, writeScope, jobLines });
2393
2656
  // SCRUBBED HERE, once, where the reds enter this module's records — the same
2394
2657
  // boundary rule `renderRejectBlock` already states, applied to the OTHER
2395
2658
  // channel they travel down. A validation red quotes what the model declared
@@ -2409,6 +2672,7 @@ export async function authorClose({
2409
2672
  // a validation red IS a measurement of the declaration, so it rides the
2410
2673
  // same channel and counts as a revision (addendum 5)
2411
2674
  measured = renderRejectBlock({ kind: 'validation', reds: vReds });
2675
+ tryFails[i] = { lines: linesNamedByReds(vReds, ask.declaration, (jobLines ?? []).map((l) => l.n)) };
2412
2676
  } else {
2413
2677
  // what gets MEASURED is what will RUN: the resolved declaration with the
2414
2678
  // genre's environment already injected
@@ -2437,10 +2701,20 @@ export async function authorClose({
2437
2701
  finalFrom = label;
2438
2702
  measured = renderSeedReadBlock(rows);
2439
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;
2440
2706
  }
2441
2707
 
2442
2708
  if (i === revisionCap) { stop = 'max-revisions'; break; }
2443
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
+
2444
2718
  const turn = buildReviseTurn(measured);
2445
2719
  // A TRIPWIRE, not behaviour: it can only fire once something else is
2446
2720
  // already broken, so it is the one guard in this module the mutation
@@ -2523,6 +2797,8 @@ export async function authorClose({
2523
2797
  // WHICH stages broke on the dropped one, so this is never a silent swap.
2524
2798
  fellBack,
2525
2799
  stop,
2800
+ // P7 item 5: what the person is asked (empty on every other stop)
2801
+ questions: stop === 'questions-open' ? askedQuestions : [],
2526
2802
  revisions: Math.max(0, iterations.length - 1),
2527
2803
  };
2528
2804
  }