bareloop 0.40.0 → 0.42.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 (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
package/src/authorflow.js CHANGED
@@ -73,7 +73,7 @@
73
73
  // No spine emission here — M4 wires events.
74
74
 
75
75
  import { renderInputs } from './jobblock.js';
76
- import { draftCallOptions } from './providers.js';
76
+ import { thinkingCallOptions } from './thinking.js';
77
77
  import { createRequire } from 'node:module';
78
78
  import { realpathSync } from 'node:fs';
79
79
  import { join, relative, isAbsolute } from 'node:path';
@@ -81,12 +81,16 @@ import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TE
81
81
  import { JUDGE_RULE_IDS } from './judged.js';
82
82
  import {
83
83
  KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
84
- GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
85
- validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES,
84
+ GENRE_LANGUAGES, RUBRIC_LANG, DECLARABLE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
85
+ validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES, catalogueForLang,
86
86
  } from './authoring.js';
87
87
  import { buildSeedListing, cleanEntry, SURVEY_CAUSES, AUTHOR_CALL_TIMEOUT_MS } from './authorscout.js';
88
88
  import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
89
89
  import { redactSecrets } from './validate.js';
90
+ import { stepFileContract, inputLabelsOf, stepFileRel, readFrozenInputs, withFactBindings, settlePersonsWords } from './rubricstep.js';
91
+ import { factShapeGuide } from './facts.js';
92
+ import { renderLookReport, labelListsOf } from './inputlook.js';
93
+ import { checkDeclarationWords } from './factwords.js';
90
94
 
91
95
  const require = createRequire(import.meta.url);
92
96
  const { Loop } = require('bare-agent');
@@ -632,10 +636,10 @@ export const GUARD_DESCRIPTIONS = Object.freeze({
632
636
  * close — one source, never a second hand-typed list — rendered through the
633
637
  * frozen line in {@link GUARD_DESCRIPTIONS}, plus the write fence (a real,
634
638
  * signed fence — {@link writeScopeBlock}'s own subject) when one is set.
635
- * @param {{verdictType: string, lang: string, writeScope?: string[]|null}} o
639
+ * @param {{verdictType: string, lang: string, writeScope?: string[]|null, jobLines?: {n: number}[]|null}} o
636
640
  * @returns {string[]}
637
641
  */
638
- export function confirmProtections({ verdictType, lang, writeScope = null }) {
642
+ export function confirmProtections({ verdictType, lang, writeScope = null, jobLines = null }) {
639
643
  const guards = classGuards({ verdictType, lang });
640
644
  const lines = guards.map((g) => {
641
645
  const desc = GUARD_DESCRIPTIONS[g.name];
@@ -648,6 +652,11 @@ export function confirmProtections({ verdictType, lang, writeScope = null }) {
648
652
  if (Array.isArray(writeScope) && writeScope.length > 0) {
649
653
  lines.push(`write fence — the run may only change files matching: ${writeScope.join(', ')}`);
650
654
  }
655
+ // PART 1a: a rubric job on local files always checks, for EVERY numbered line, that the line's file exists, is complete
656
+ // JSON and has text — a fact of the close's shape (`rubric-step`), not a plan of the model's, so it is listed from code
657
+ if (lang === RUBRIC_LANG && Array.isArray(jobLines)) {
658
+ for (const l of jobLines) lines.push(`line ${l.n} file — ${stepFileRel(l.n)} must exist, be complete JSON, and carry text`);
659
+ }
651
660
  return lines;
652
661
  }
653
662
 
@@ -801,6 +810,68 @@ export const PARAM_SCHEMAS = Object.freeze({
801
810
  items: { type: 'string' },
802
811
  description: 'the AUTHORITATIVE artifacts to judge, repository-relative, read off the listing. One paid call each.',
803
812
  },
813
+ step: { type: 'integer', minimum: 1, description: 'the number of the job line this stage serves; its file is output/steps/<step>.json' },
814
+ parts: {
815
+ type: 'array',
816
+ description: 'every constraint the person put on that job line, in their own words, each SORTED: countable (a machine counts it, or a '
817
+ + 'quote from a named input must back it), judged (only reading it could decide), guard (a rule about web sources), none (you cannot sort it)',
818
+ items: {
819
+ type: 'object',
820
+ properties: {
821
+ text: { type: 'string', minLength: 1, description: 'the constraint, in the person\'s own words' },
822
+ class: { enum: ['countable', 'fact', 'judged', 'guard', 'none'] },
823
+ check: { enum: ['maxWords', 'minWords', 'maxLines', 'sections', 'sectionWords', 'cited', 'minClaims', 'maxClaims'], description: 'countable only; minClaims / maxClaims count the claims listed under the input named in source' },
824
+ value: { type: 'integer', minimum: 1, description: 'maxWords / minWords / maxLines: the limit the person asked for' },
825
+ names: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 }, description: 'sections / sectionWords: the section names' },
826
+ wordsPerSection: { type: 'integer', minimum: 1 },
827
+ source: { type: 'string', minLength: 1, description: 'cited: the label of the input a quote must come from' },
828
+ domains: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 }, description: 'guard only: the web domains the rule is about' },
829
+ covers: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'integer', minimum: 1 }, description: 'the number(s) of the CONFIRMED check(s) this part carries out' },
830
+ rules: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'integer', minimum: 1 }, description: 'the number(s) of the job line\'s ~ rules this part carries out (1 = the first ~ rule under the line)' },
831
+ facts: {
832
+ type: 'array',
833
+ minItems: 1,
834
+ maxItems: 10,
835
+ description: 'fact only: the REQUIRED FACTS every result must hold, one per thing the person asked for (from the job line AND its ~ rules)',
836
+ items: {
837
+ type: 'object',
838
+ properties: {
839
+ name: { type: 'string', minLength: 1, description: 'what the fact is called: letters and spaces only, no digits' },
840
+ shape: { enum: ['money', 'number', 'date', 'time', 'phrase', 'text'], description: 'money / number / date / time may carry a rule; phrase = fixed words the quote must contain; text = words the quote holds, as it writes them' },
841
+ unit: { type: 'string', minLength: 1, description: 'money: the words the input writes after the amount, copied verbatim (or give currency instead)' },
842
+ currency: { enum: ['$', '€', '£'], description: 'money: the symbol the input writes before the amount (or give unit instead)' },
843
+ phrase: { type: 'string', minLength: 1, description: 'phrase only: the fixed words, copied verbatim from the input' },
844
+ anchor: { type: 'string', minLength: 1, description: 'a label copied VERBATIM from the input that sits right before the value (picks the right one when a result holds several)' },
845
+ until: { type: 'string', minLength: 1, description: 'text with an anchor: the label copied VERBATIM that ends the value' },
846
+ rule: {
847
+ type: 'object',
848
+ description: 'only when the person asked for a limit',
849
+ properties: {
850
+ op: { enum: ['lt', 'lte', 'gt', 'gte', 'between'] },
851
+ value: { type: ['number', 'string'], description: 'a number for number/money; a string for date (YYYY-MM-DD) / time (HH:MM)' },
852
+ lo: { type: ['number', 'string'] },
853
+ hi: { type: ['number', 'string'] },
854
+ },
855
+ required: ['op'],
856
+ additionalProperties: false,
857
+ },
858
+ aiProposal: { type: 'boolean', description: 'true when the rule\'s number is YOURS (made out of the person\'s words), not one the person wrote' },
859
+ },
860
+ required: ['name', 'shape'],
861
+ additionalProperties: false,
862
+ },
863
+ },
864
+ count: {
865
+ type: 'object',
866
+ description: 'fact only: how many results the person asked for',
867
+ properties: { min: { type: 'integer', minimum: 1 }, max: { type: 'integer', minimum: 1 } },
868
+ additionalProperties: false,
869
+ },
870
+ },
871
+ required: ['text', 'class'],
872
+ additionalProperties: false,
873
+ },
874
+ },
804
875
  ask: {
805
876
  type: 'string',
806
877
  minLength: 1,
@@ -854,10 +925,12 @@ export function schemaCoverage(catalogue = KIND_CATALOGUE) {
854
925
  * and failing loudly beats emitting a schema that quietly cannot say something
855
926
  * the validator will then demand.
856
927
  * `questions` (P7 item 5, last drafting try only) adds the optional `questions` property; absent, the schema is byte-identical.
928
+ * `kinds` (Part 1a) narrows the branches to the kinds a language may use (`catalogueForLang`) WITHOUT narrowing the coverage check,
929
+ * which stays over the whole catalogue; null (the default) = no narrowing.
857
930
  * @param {Record<string, any>} [catalogue]
858
- * @param {{verdictType?: string|null, questions?: boolean}} [o]
931
+ * @param {{verdictType?: string|null, questions?: boolean, kinds?: string[]|null}} [o]
859
932
  */
860
- export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null, questions = false } = {}) {
933
+ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null, questions = false, kinds = null } = {}) {
861
934
  const cov = schemaCoverage(catalogue);
862
935
  if (!cov.ok) {
863
936
  throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
@@ -865,7 +938,7 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
865
938
  }
866
939
  const menu = classMenu(verdictType, catalogue);
867
940
  const branches = Object.entries(catalogue)
868
- .filter(([kind, spec]) => !spec.locked && menu.includes(kind))
941
+ .filter(([kind, spec]) => !spec.locked && menu.includes(kind) && (kinds === null || kinds.includes(kind)))
869
942
  .map(([kind, spec]) => {
870
943
  /** @type {Record<string, any>} */
871
944
  const properties = {};
@@ -910,12 +983,13 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
910
983
  type: 'object',
911
984
  properties: {
912
985
  line: { type: 'integer', minimum: 1, description: 'the number of a job line no stage checks' },
986
+ rule: { type: 'integer', minimum: 1, description: 'optional: the number of ONE ~ rule of that line that nothing checks (the line itself still has a stage)' },
913
987
  reason: { type: 'string', minLength: 1, description: 'why, in plain words the person can read' },
914
988
  },
915
989
  required: ['line', 'reason'],
916
990
  additionalProperties: false,
917
991
  },
918
- description: 'job lines you could not turn into a stage, each with a reason — a note does not cover a line',
992
+ description: 'job lines (or single ~ rules of a line) you could not turn into a check, each with a reason in the person\'s words — a note does not cover a line or a rule',
919
993
  },
920
994
  ...(questions ? {
921
995
  questions: {
@@ -938,13 +1012,13 @@ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = nu
938
1012
  * The output channel. It records and acknowledges; it takes no action, which is
939
1013
  * why handing it to a toolless authoring call does not reopen D3.
940
1014
  * @param {{calls: any[]}} box
941
- * @param {{catalogue?: Record<string, any>, verdictType?: string|null, questions?: boolean}} [o]
1015
+ * @param {{catalogue?: Record<string, any>, verdictType?: string|null, questions?: boolean, kinds?: string[]|null}} [o]
942
1016
  */
943
- export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null, questions = false } = {}) {
1017
+ export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null, questions = false, kinds = null } = {}) {
944
1018
  return {
945
1019
  name: DECLARATION_TOOL_NAME,
946
1020
  description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
947
- parameters: declarationSchema(catalogue, { verdictType, questions }),
1021
+ parameters: declarationSchema(catalogue, { verdictType, questions, kinds }),
948
1022
  execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
949
1023
  };
950
1024
  }
@@ -1121,8 +1195,9 @@ export function writeScopeBlock(writeScope) {
1121
1195
  * never merge.
1122
1196
  * @param {{checks: string[], checkItems?: {text: string, fromLine: number[]|null}[], protections: string[], openQuestions?: string[], notChecked?: string[],
1123
1197
  * answeredQuestions?: string[]}} confirmed
1198
+ * @param {{numbered?: boolean}} [o] `numbered` (a rubric job on files): each confirmed check carries its `#n`, which a part cites in `covers`
1124
1199
  */
1125
- export function confirmedBlock(confirmed) {
1200
+ export function confirmedBlock(confirmed, { numbered = false } = {}) {
1126
1201
  const checks = confirmed.checks ?? [];
1127
1202
  const protections = confirmed.protections ?? [];
1128
1203
  const openQuestions = confirmed.openQuestions ?? [];
@@ -1132,8 +1207,8 @@ export function confirmedBlock(confirmed) {
1132
1207
  + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
1133
1208
  + 'neither do you:\n\n'
1134
1209
  + `${(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`
1210
+ ? confirmed.checkItems.map((c, i) => ` - ${numbered ? `#${i + 1} ` : ''}${c.fromLine && c.fromLine.length ? `[line ${c.fromLine.join(', ')}] ` : ''}${c.text}`)
1211
+ : checks.map((c, i) => ` - ${numbered ? `#${i + 1} ` : ''}${c}`)).join('\n') || ' (none)'}\n\n`
1137
1212
  + 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
1138
1213
  + `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
1139
1214
  + (notChecked.length
@@ -1183,14 +1258,16 @@ export function jobLineTagBlock(count) {
1183
1258
  * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
1184
1259
  * facts: any, listingBlock: string, lang: string, verdictType: string,
1185
1260
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
1186
- * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1261
+ * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>|null,
1187
1262
  * writeScope?: string[]|null,
1188
1263
  * 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
1264
+ * confirmed?: {checks: string[], checkItems?: {text: string, fromLine: number[]|null}[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null,
1265
+ * labelLists?: string[]|null,
1266
+ * }} o
1190
1267
  */
1191
1268
  export function authorPrompt({
1192
1269
  answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
1193
- ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
1270
+ ownedEnvNames = [], mode = 'tool', catalogue: catalogueIn = null, writeScope = null,
1194
1271
  // P7: the person's numbered job lines and the inputs they pointed at. ABSENT = byte-identical prompt (every caller
1195
1272
  // that predates the job block). Present: the drafter is told to tag every stage it writes with the line it serves.
1196
1273
  jobLines = null, inputs = null,
@@ -1198,7 +1275,16 @@ export function authorPrompt({
1198
1275
  // turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
1199
1276
  // only when a confirm turn actually ran and the person accepted a plan.
1200
1277
  confirmed = null,
1278
+ // PART 1c: the label lists code made from the frozen inputs (src/labelscan.js `renderLabelList`); a label is picked from them
1279
+ labelLists = null,
1201
1280
  }) {
1281
+ // PART 1a: a rubric job on the person's own local files has no repository, no genre template and no tool to measure with —
1282
+ // it gets its own brief (`rubricAuthorPrompt`), which still ends in the same declaration tool and the same guards
1283
+ // an absent catalogue is the language's own menu (`catalogueForLang`), so a code job's prompt never lists a rubric-only kind
1284
+ const catalogue = catalogueIn ?? catalogueForLang(lang);
1285
+ if (lang === RUBRIC_LANG) {
1286
+ return rubricAuthorPrompt({ answers, questions, facts, listingBlock, verdictType, guards, mode, catalogue, writeScope, jobLines, inputs, confirmed, labelLists });
1287
+ }
1202
1288
  const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
1203
1289
  if (statement === null) {
1204
1290
  throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
@@ -1303,6 +1389,170 @@ measure anything.`;
1303
1389
  ].join('\n\n---\n\n');
1304
1390
  }
1305
1391
 
1392
+ /**
1393
+ * What a rubric job on local files means by DONE — the class statement of the text genre (Part 1a). It is stated as an order
1394
+ * about KINDS: every part of a job line is counted or checked against a quote, because nothing in this build judges.
1395
+ */
1396
+ export const RUBRIC_FILES_STATEMENT = 'The person declared this job SOFT-GREEN and pointed it at their own text files: the work is writing '
1397
+ + 'about those files. This build has no judge, so every part of a job line must be one of three things. COUNTED: a limit on words, '
1398
+ + 'sections or lines of what is written. Or a REQUIRED FACT: something the line finds, lists or looks up, each answer quoted word for '
1399
+ + 'word from a named input and checked by its shape (money, number, date, time, phrase, text). Or BACKED BY A QUOTE: a claim that '
1400
+ + 'must carry words copied from a named input. '
1401
+ + 'Compose ONE rubric-step stage per numbered job line, after the mandatory guard, and name only limits the person actually '
1402
+ + 'asked for. A part you cannot express that way is not approximated by a stage that means something else.';
1403
+
1404
+ /**
1405
+ * THE RUBRIC RULES, in one place (live fix B1): what the confirm step and the drafter are both told about turning the person's words
1406
+ * into checks. The confirm note ({@link RUBRIC_CONFIRM_NOTE}) and the composer brief ({@link REQUIRED_FACTS_BRIEF}) interpolate THIS
1407
+ * constant, so the two cannot drift (in smv6k6l8dxkzs the confirm step planned a limit the drafter then could not carry, and the
1408
+ * drafter added an airport code the person never wrote). Field-neutral: neither turn needs the other's field names.
1409
+ */
1410
+ export const RUBRIC_RULES = 'THE RULES FOR TURNING THE PERSON\'S WORDS INTO CHECKS (the same for every turn of this job):\n - A limit the person WROTE ("under $450") is a rule that can be checked: plan it as a limit with its number.\n'
1411
+ + ' - A number YOU make out of their words ("not a red-eye" becomes a departure time between 06:00 and 21:00) is your proposal, marked '
1412
+ + 'as the AI\'s proposal for the person to check; a limit on a time that you propose is a RANGE (between), never one-sided. The input does '
1413
+ + 'not have to hold the person\'s word (it holds no "red-eye"): the limit goes on the time the input DOES hold (e.g. the one after "Departure time:"), as a '
1414
+ + 'range marked as the AI\'s proposal.\n'
1415
+ + ' - Names, labels and units are the INPUT\'s own words, copied exactly as the input writes them. When the input does not use the '
1416
+ + 'person\'s word (they wrote SFO, the input says San Francisco International Airport), use the words the input uses; never invent a '
1417
+ + 'word the input does not hold, and never add a code (like SFO) that the person did not write and the input does not hold.\n'
1418
+ + ' - A label for a line that asks for N results (a count of N) must sit in each result, so the label list must count it N or more times '
1419
+ + '(the x-number beside it). A label counted fewer times is on the page once or twice (a sort tab, a switch) and is refused at $0 with its count.\n'
1420
+ + ' - The same holds for the words a "must say" (phrase) fact makes each quote say: they must be in the input at least N times for N results. Words counted fewer times (page-header words like "from San Francisco" when each result says "Leaves San Francisco International Airport") are refused at $0 with their count; pick words every result says, or drop the check.';
1421
+
1422
+ /**
1423
+ * REQUIRED FACTS, as the composer is told them (PART 1b, PRD item 33 RF1–RF17). The shape of the fact part is in the catalogue and the
1424
+ * schema; this is the judgement the schema cannot carry: what becomes a fact, where its words come from, and what is Not checked.
1425
+ */
1426
+ export const REQUIRED_FACTS_BRIEF = 'REQUIRED FACTS — what a line that finds, lists or looks something up leaves behind\n\n'
1427
+ + 'For such a line write ONE part of class "fact". The worker\'s file for that line holds only results, each with ONE quote copied '
1428
+ + 'from the input and the value of every fact as the quote writes it — no free text. Code checks every value against its quote.\n'
1429
+ + ' - The facts come from the job line itself AND from its ~ rules: "SFO to LAX" is a from fact and a to fact even with no ~ line. '
1430
+ + 'One fact per thing the person asked for. Give every fact a shape. A rule (op, value, or between lo and hi) only where the person '
1431
+ + 'asked for a limit.\n'
1432
+ + ' - THE LOOK above is what a read of the inputs found: write the facts from it. Copy every label (anchor), unit and phrase EXACTLY '
1433
+ + 'as the look gives it (the look\'s phrase facts become phrase facts with the same words), and say in "notes" when you used the input\'s '
1434
+ + 'words instead of the person\'s. A label (anchor) is PICKED from the labels the look found in the input, never typed; one that is not '
1435
+ + 'among them is refused at $0. What the look could not find is not checked: list it under "refused" in the person\'s words.\n'
1436
+ + ' - When one result holds several values of one kind (leaves / arrives, subtotal / total) give the fact an anchor, the label that sits right before the value you mean.\n'
1437
+ + ' - Set aiProposal true on a fact whose number you made out of their words.\n'
1438
+ + RUBRIC_RULES + '\n'
1439
+ + ' - How MANY results were asked for is a count ("3 releases": count {min: 3, max: 3}). WHICH ones ("the last", "the newest") and '
1440
+ + 'anything else you cannot express as a shape and a rule ("the cheapest", "the best", "a good pick") is NOT CHECKED: list it in "refused" as {"line": N, "rule": the number of that ~ rule, "reason": the '
1441
+ + 'person\'s own words}. EVERY ~ rule of a line must be carried out by a part (list its number in the part\'s "rules", 1 = the first ~ '
1442
+ + 'rule under the line) or be listed in "refused"; a rule that is neither is refused at $0.\n'
1443
+ + ' - A line that WRITES (a summary, a report) keeps word, section and line counts. Code ties its numbers to the facts of the lines '
1444
+ + 'before it; you do not add that.\n'
1445
+ + ' - A look fact that says "carries ~ rule N" is how the look carried that rule: put N in the fact part\'s "rules" and do not also list rule N in "refused".\n'
1446
+ + ' - A ~ rule that you carry out as a fact (a limit on a time, a price, a date: "not a red-eye" is a time fact with a between rule) is carried by the '
1447
+ + 'fact part: list its number in that part\'s "rules". It is NOT also a part of its own: on a line that lists facts, a part of class judged or none '
1448
+ + 'is refused. A rule you cannot express as a shape and a rule goes in "refused", never into a part.\n'
1449
+ + ' - "covers" and "rules" are fields of the PART, written once on it as a list of numbers. A fact never has a "covers" or a "rules" field.\n'
1450
+ + ' - ONE stage per job line, holding ALL of that line\'s facts (and its other parts). Two stages for one line are refused: put a '
1451
+ + '~ rule\'s fact (a "not a red-eye" time) on the same stage as the line\'s other facts.\n'
1452
+ + ' - WHICH SHAPE TAKES WHICH FIELDS (a field a shape does not take is refused):\n'
1453
+ + factShapeGuide();
1454
+
1455
+ /**
1456
+ * The composer prompt for a rubric job on local files (Part 1a, docs/product/ITEM33-BUILD.md). There is no repository
1457
+ * and no survey: the facts are the numbered inputs and the listing is the frozen tree (input/ files, output/). It states
1458
+ * the step-file contract the worker is also told ({@link stepFileContract}), so the stage and the file cannot drift.
1459
+ * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>, facts: any, listingBlock: string,
1460
+ * verdictType: string, guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
1461
+ * mode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
1462
+ * jobLines?: {n: number}[]|null, inputs?: {n: number, label: string, value: string}[]|null, confirmed?: any,
1463
+ * labelLists?: string[]|null,
1464
+ * }} o
1465
+ * @returns {string}
1466
+ */
1467
+ export function rubricAuthorPrompt({
1468
+ answers, questions = GREEN_QUESTIONS, facts, listingBlock, verdictType, guards,
1469
+ mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null, jobLines = null, inputs = null, confirmed = null, labelLists = null,
1470
+ }) {
1471
+ const interview = Object.entries(questions)
1472
+ .map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
1473
+ .join('\n\n');
1474
+ const role = `You are composing the close for one job.
1475
+
1476
+ The close is what decides whether a run of this job came back DONE. It runs after the work is
1477
+ finished, it is the only truth, and nothing else can overrule it. It is a list of STAGES. Each
1478
+ stage names a kind from the catalogue below and fills that kind's parameters. Stages run in the
1479
+ order you declare them.
1480
+
1481
+ Two sources fed you this brief and neither of them is you:
1482
+ - the person who wants the job done answered an interview, below, in their own words. They are
1483
+ not a software engineer and did not name commands, thresholds or patterns;
1484
+ - the person's own text files, frozen into the job. There is no code repository: the files sit
1485
+ under input/ and the run writes only under output/.`;
1486
+ const guardJson = guards.map((g) => ({
1487
+ name: g.name,
1488
+ kind: g.kind,
1489
+ params: { ...g.params, ...Object.fromEntries(g.fill.map((f) => [f, FILL_MARKER])) },
1490
+ }));
1491
+ const output = mode === 'tool'
1492
+ ? `HOW TO ANSWER
1493
+
1494
+ Call the ${DECLARATION_TOOL_NAME} tool exactly once. Its arguments ARE the declaration: an ordered
1495
+ "stages" array, plus "notes" for anything you could not express, and "refused" for a job line no stage can check.
1496
+ Do not write the declaration as text.
1497
+
1498
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
1499
+ left as a placeholder. You never write a number the person did not ask for.`
1500
+ : `HOW TO ANSWER
1501
+
1502
+ A single JSON object and nothing else: an ordered "stages" array, plus "notes" for anything you
1503
+ could not express, and "refused" for a job line no stage can check.
1504
+
1505
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
1506
+ left as a placeholder. You never write a number the person did not ask for.`;
1507
+ return [
1508
+ role,
1509
+ `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${RUBRIC_FILES_STATEMENT}`,
1510
+ `THE INTERVIEW — the person's own words\n\n${interview}`
1511
+ + (inputs && inputs.length ? `\n\n${renderInputs(inputs)}` : ''),
1512
+ ...(jobLines && jobLines.length ? [jobLineTagBlock(jobLines.length)] : []),
1513
+ ...(writeScope && writeScope.length ? [writeScopeBlock(writeScope)] : []),
1514
+ ...(confirmed ? [confirmedBlock(confirmed, { numbered: true }),
1515
+ 'EVERY CONFIRMED CHECK IS COMPOSED OR MOVED, NEVER DROPPED\n\nThe person saw the numbered checks above. On each countable or fact part that carries one of them out, list its number in the PART\'s "covers" (once on the part, never on a fact inside it). '
1516
+ + 'A confirmed check that no part covers is not silently left out: it is moved to Not checked before signing, and the person is told. '
1517
+ + 'To count "N things listed", use minClaims / maxClaims (it counts the claims listed under an input); anything else you cannot count is not composed.'] : []),
1518
+ REQUIRED_FACTS_BRIEF,
1519
+ `THE STEP FILES — what the worker leaves behind for each job line\n\n${stepFileContract()}\n\n`
1520
+ + 'The rubric-step stage for job line N reads exactly that file, output/steps/N.json. It names the inputs (by label) the '
1521
+ + 'step must cite from, and the limits on its "text".',
1522
+ `THE FILES — the inputs the person pointed at, frozen into the job\n\n${JSON.stringify(withoutLook(facts), null, 2)}`
1523
+ + (listingBlock ? `\n\n${listingBlock}` : ''),
1524
+ ...(Array.isArray(labelLists) && labelLists.length ? [labelLists.join('\n\n')] : []),
1525
+ ...(facts && facts.look ? [renderLookReport(facts.look)] : []),
1526
+ rubricCatalogueBlock(catalogue, { verdictType }),
1527
+ 'MANDATORY GUARDS — already placed and already parameterised\n\n'
1528
+ + 'This kind of job carries a stage the person did not ask for. It is fixed and you may not change, drop, rename, or '
1529
+ + `re-kind it. The ONLY thing you fill in is each "${FILL_MARKER}" slot, from the interview:\n\n${JSON.stringify(guardJson, null, 2)}\n\n`
1530
+ + 'Place it first: it is cheap, and the first red ends the judgement.',
1531
+ output,
1532
+ ].join('\n\n---\n\n');
1533
+ }
1534
+
1535
+ /** the facts object without the look's report (that is its own block, written by code) @param {any} facts */
1536
+ function withoutLook(facts) {
1537
+ if (!facts || typeof facts !== 'object' || !('look' in facts)) return facts;
1538
+ const { look: _look, ...rest } = facts;
1539
+ return rest;
1540
+ }
1541
+
1542
+ /**
1543
+ * The kind rows of the catalogue, without the command-output parser text a rubric job never needs.
1544
+ * @param {Record<string, any>} catalogue @param {{verdictType?: string|null}} [o]
1545
+ */
1546
+ function rubricCatalogueBlock(catalogue, { verdictType = null } = {}) {
1547
+ const menu = classMenu(verdictType, catalogue);
1548
+ const rows = Object.entries(catalogue).filter(([name, spec]) => menu.includes(name) && !spec.locked).map(([name, spec]) => `- ${name}\n ${spec.shape}\n required: ${spec.required.join(', ')}`
1549
+ + `${spec.optional.length ? ` optional: ${spec.optional.join(', ')}` : ''}\n asserts: ${spec.asserts}`);
1550
+ return `THE KIND CATALOGUE — these are the only kinds that exist.
1551
+ You parameterise kinds. You never write a script, a shell fragment, or a new kind.
1552
+
1553
+ ${rows.join('\n\n')}`;
1554
+ }
1555
+
1306
1556
  // ── 3. THE MEASURED BLOCKS ───────────────────────────────────────────────────
1307
1557
 
1308
1558
  /** F6, at the rendering boundary: an absent number is unknown, never 0. */
@@ -1536,7 +1786,7 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
1536
1786
  */
1537
1787
  /**
1538
1788
  * @param {{ceilingUsd?: number|null,
1539
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void}} [o]
1789
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void}} [o]
1540
1790
  * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
1541
1791
  * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
1542
1792
  * only chance to know what has been spent while the run is still alive. It is
@@ -1557,9 +1807,9 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1557
1807
  const call = { label, ...priceOf(r) };
1558
1808
  entries.push(call);
1559
1809
  raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
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 }) });
1810
+ // bare-agent's per-run `thinkingStatus` ('applied' | 'unsupported' | 'ignored' | 'unknown') rides the REPORTING seam only —
1811
+ // never the stored entry, so the cost report and every persisted shape stay as they were; absent when thinkingMode was not sent
1812
+ onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
1563
1813
  return entries.at(-1);
1564
1814
  },
1565
1815
  /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
@@ -1599,13 +1849,13 @@ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
1599
1849
  * `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
1600
1850
  * `rates` is the customer's own price for this provider's key row (USD per 1K, `ratesFor`);
1601
1851
  * absent = bare-agent's built-in guess, exactly as before.
1602
- * `draftThinking` ('on' | 'off', operator knob; default 'off') adds the drafter's per-call thinking option via `draftCallOptions`.
1852
+ * `draftThinking` ('on' | 'off', the job's "Thinking on" tick spelled as a word; default 'off') adds the drafter's per-call thinking option via `thinkingCallOptions` (src/thinking.js, the one owner).
1603
1853
  * @param {any} provider @param {{system?: string, maxTokens?: number, rates?: {in: number, out: number}|null, draftThinking?: string}} [o]
1604
1854
  * @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
1605
1855
  */
1606
1856
  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);
1857
+ // the drafter's per-call options (src/thinking.js `thinkingCallOptions`, one owner); a bad mode throws HERE, at construction
1858
+ const callOptions = thinkingCallOptions(provider, draftThinking);
1609
1859
  return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
1610
1860
  const loop = new Loop({ provider, system, ...(rates ? { rates } : {}) });
1611
1861
  const wired = tools.map((t) => ({
@@ -1784,12 +2034,12 @@ export async function askStructured({ messages, generate, mode, retries, label,
1784
2034
  /** THE DECLARATION CHANNEL — the one this loop has always used. Its schema is
1785
2035
  * CLASS-SCOPED, like the prompt's catalogue block: the two must offer one set of
1786
2036
  * kinds, or the composer reads about a kind it cannot call.
1787
- * @param {{catalogue: Record<string, any>, verdictType: string, questions?: boolean}} o */
1788
- const declarationChannel = ({ catalogue, verdictType, questions = false }) => ({
2037
+ * @param {{catalogue: Record<string, any>, verdictType: string, questions?: boolean, kinds?: string[]|null}} o */
2038
+ const declarationChannel = ({ catalogue, verdictType, questions = false, kinds = null }) => ({
1789
2039
  name: DECLARATION_TOOL_NAME,
1790
2040
  instruction: STRUCTURE_INSTRUCTION_TOOL,
1791
2041
  textPath: 'declaration',
1792
- tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType, questions }),
2042
+ tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType, questions, kinds }),
1793
2043
  });
1794
2044
 
1795
2045
  /** The declaration ask, over the shared ladder. `declaration` is `artifact`
@@ -1797,11 +2047,11 @@ const declarationChannel = ({ catalogue, verdictType, questions = false }) => ({
1797
2047
  * anonymous artifact, and one word for two things is how a reader loses which.
1798
2048
  * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
1799
2049
  * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>,
1800
- * verdictType: string, questions?: boolean}} o
2050
+ * verdictType: string, questions?: boolean, kinds?: string[]|null}} o
1801
2051
  */
1802
- async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue, verdictType, questions = false }) {
2052
+ async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue, verdictType, questions = false, kinds = null }) {
1803
2053
  const r = await askStructured({
1804
- messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType, questions }),
2054
+ messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType, questions, kinds }),
1805
2055
  });
1806
2056
  return { ...r, declaration: r.artifact };
1807
2057
  }
@@ -1923,6 +2173,23 @@ const confirmChannel = () => ({
1923
2173
  * an ORDER to the model itself, the same register {@link AUTHOR_SYSTEM} and
1924
2174
  * {@link CLASS_STATEMENTS} already use, rather than a code matcher over prose.
1925
2175
  */
2176
+ /**
2177
+ * What the confirm turn tells the model about a rubric job on local text files (model-facing, registered). There is no
2178
+ * repository and no judge: a check is a limit a machine counts (words, lines, sections), or a claim a quote from a named
2179
+ * input must back. Everything else the person asked for is named in `notChecked`, in their words.
2180
+ */
2181
+ export const RUBRIC_CONFIRM_NOTE = 'THIS JOB HAS NO REPOSITORY. Its inputs are the person\'s own text files, and each numbered job line leaves ONE '
2182
+ + 'file the run writes (output/steps/<line>.json: a "text" and, per input, quoted claims — or, for a line that finds, lists or '
2183
+ + 'looks something up, only filled facts, each quoted from the input). In this build only three kinds of check exist: a COUNTED '
2184
+ + 'limit on that text (words, lines, sections, words per section); a claim that must be backed by a quote copied from a named input; '
2185
+ + 'and a REQUIRED FACT, something a line finds (a price, a date, a time, a name, a phrase) with a limit only if the person gave one '
2186
+ + '— plan one fact check for each thing the line asks to find, including those named in the line itself ("SFO to LAX"). Plan a check '
2187
+ + 'only for what the person asked for in those terms; '
2188
+ + 'anything that needs reading the text to judge it (tone, quality, accuracy), any superlative ("the cheapest", "the best") and any '
2189
+ + 'choice of WHICH ones ("the last", "the newest") goes in `notChecked`, in the person\'s own words; how MANY results were asked for is '
2190
+ + 'a count and can be checked.\n'
2191
+ + RUBRIC_RULES;
2192
+
1926
2193
  export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
1927
2194
  + 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
1928
2195
  + 'signed goal sentence. You never propose a check the numbered job lines (and the ~ rules written under them) did not ask for '
@@ -1974,6 +2241,9 @@ export function confirmPrompt({ answers, questions, labels = {}, facts = null, l
1974
2241
  lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
1975
2242
  }
1976
2243
  lines.push('', `LANGUAGE: ${lang}`);
2244
+ // PART 1a: a rubric job on the person's own text files. The model is told what a check CAN be here, so that anything
2245
+ // needing judgement lands in `notChecked` instead of being promised as a check
2246
+ if (lang === RUBRIC_LANG) lines.push('', RUBRIC_CONFIRM_NOTE);
1977
2247
  if (writeScope) lines.push(`WRITE SCOPE (the fence — the run may write only here): ${writeScope.join(', ')}`);
1978
2248
  if (protections.length > 0) {
1979
2249
  lines.push(
@@ -2026,7 +2296,8 @@ async function askConfirmPlan({ convo, generate, mode, book, label }) {
2026
2296
  * generate: Function, book: ReturnType<typeof makeCostBook>,
2027
2297
  * ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
2028
2298
  * onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
2029
- * worseThanBefore?: string, inputs?: {n: number, label: string, value: string}[]|null}} o retired field (hamr's ruling 2026-09-28):
2299
+ * worseThanBefore?: string, inputs?: {n: number, label: string, value: string}[]|null,
2300
+ * jobLines?: {n: number}[]|null}} o retired field (hamr's ruling 2026-09-28):
2030
2301
  * `worseThanBefore` is never asked any more and defaults to `''` — a
2031
2302
  * caller may still pass a string through (kept for a future, non-ask
2032
2303
  * source of the same constraint), it just never comes from an ask() step.
@@ -2048,6 +2319,8 @@ export async function runConfirmTurn({
2048
2319
  worseThanBefore: presetWorseThanBefore = '',
2049
2320
  // P7: the inputs the person pointed at; absent = the prompt is byte-identical
2050
2321
  inputs = null,
2322
+ // PART 1a: the numbered job lines, so a rubric job's per-line file protections are listed from code
2323
+ jobLines = null,
2051
2324
  }) {
2052
2325
  /** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
2053
2326
  /** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
@@ -2073,7 +2346,7 @@ export async function runConfirmTurn({
2073
2346
  // the prompt below so the model can check `notChecked` against it, and
2074
2347
  // reused (never recomputed) after the call so the shown, prompted, and
2075
2348
  // recorded protections are always the exact same list.
2076
- const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope });
2349
+ const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope, jobLines });
2077
2350
 
2078
2351
  // ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
2079
2352
  /** @type {string|null} */
@@ -2265,7 +2538,7 @@ export async function runConfirmTurn({
2265
2538
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
2266
2539
  * ceilingUsd?: number|null,
2267
2540
  * onPhase?: (phase: string, data?: any) => void,
2268
- * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingApplied?: boolean|null}) => void,
2541
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
2269
2542
  * maxRevisions?: number, structureRetries?: number,
2270
2543
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
2271
2544
  * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
@@ -2308,12 +2581,15 @@ export async function authorClose({
2308
2581
  maxRevisions = MAX_REVISIONS,
2309
2582
  structureRetries = MAX_STRUCTURE_RETRIES,
2310
2583
  structuredMode = 'tool',
2311
- catalogue = KIND_CATALOGUE,
2584
+ catalogue: catalogueIn = KIND_CATALOGUE,
2312
2585
  // P7 item 5 (fwdloop am25): OFF unless the caller can really ask the person (the panel). When on, ONLY the last of the
2313
2586
  // full drafting ladder (the author call plus both revisions), and only after the two before it both failed validation,
2314
2587
  // is offered the optional `questions` property — see `askOpen` below. Absent = byte-identical to today.
2315
2588
  offerQuestions = false,
2316
2589
  }) {
2590
+ // PART 1a: the menu this close may draw from — a rubric job on local files sees its own two kinds, a code job never sees
2591
+ // the rubric-only ones (`catalogueForLang`). Everything below (prompt, schema, validator, env) reads THIS one catalogue.
2592
+ const catalogue = catalogueForLang(lang, catalogueIn);
2317
2593
  // TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
2318
2594
  // revising launders thrash as adaptation (addendum 5). The floor is zero: one
2319
2595
  // authoring call with no revise round is a legal ask, and a garbage value is
@@ -2431,7 +2707,7 @@ export async function authorClose({
2431
2707
  }], 'precheck');
2432
2708
  }
2433
2709
 
2434
- if (!GENRE_LANGUAGES.includes(lang)) {
2710
+ if (!DECLARABLE_LANGUAGES.includes(lang)) {
2435
2711
  return refuse([{
2436
2712
  code: 'genre-unsupported',
2437
2713
  path: 'lang',
@@ -2473,9 +2749,20 @@ export async function authorClose({
2473
2749
  if (envProbe.reds.length) return refuse(envProbe.reds, 'precheck');
2474
2750
 
2475
2751
  // ── the grounded loop ─────────────────────────────────────────────────────
2752
+ // PART 1c: a rubric job on files - the frozen input text the found-once check proves every word against, and the label lists code
2753
+ // made from it (a label is PICKED from them, never typed). null = not a files job, or the freeze could not be read here.
2754
+ /** @type {Record<string, string>|null} */
2755
+ let inputTexts = null;
2756
+ /** @type {string[]|null} */
2757
+ let labelLists = null;
2758
+ if (lang === RUBRIC_LANG && Array.isArray(inputs) && inputs.length > 0) {
2759
+ const frozen = await readFrozenInputs(workdir, inputs.map((i) => i.label));
2760
+ if (frozen.ok) { inputTexts = frozen.texts; labelLists = Object.values(labelListsOf(frozen.texts).lists); }
2761
+ }
2476
2762
  const prompt = authorPrompt({
2477
2763
  answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
2478
2764
  lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed, jobLines, inputs,
2765
+ labelLists,
2479
2766
  });
2480
2767
  /** @type {any[]} */
2481
2768
  let messages = [{ role: 'user', content: prompt }];
@@ -2562,7 +2849,7 @@ export async function authorClose({
2562
2849
  // P7 item 5: a mechanism, never a wish. Only the last try of the full ladder carries the property, and only when the
2563
2850
  // two before it both failed (validation or the seed read; `questionLines` is set at the end of the second one, below).
2564
2851
  const askOpen = questionLines !== null && i === revisionCap;
2565
- const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue, verdictType, questions: askOpen });
2852
+ const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue: catalogueIn, kinds: Object.keys(catalogue), verdictType, questions: askOpen });
2566
2853
  messages = ask.convo;
2567
2854
  /** @type {{reason: string, raw: any}[]} */
2568
2855
  let droppedQuestions = [];
@@ -2624,6 +2911,7 @@ export async function authorClose({
2624
2911
  iter.cutOff = true;
2625
2912
  if (nextTryMayAsk(i)) {
2626
2913
  questionLines = allLines.length ? allLines : null;
2914
+ onPhase('author-try-failed', { call: label, i, of: revisionCap, reds: [scrubRed(/** @type {Red} */ (ask.red))] });
2627
2915
  const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (ask.red).detail] });
2628
2916
  messages = [...ask.convo, { role: 'assistant', content: '(the reply carried no text)' },
2629
2917
  { role: 'user', content: `${block}\n\n${structuredMode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` }];
@@ -2652,7 +2940,15 @@ export async function authorClose({
2652
2940
  }
2653
2941
  previous = ask.declaration;
2654
2942
 
2655
- const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType, writeScope, jobLines });
2943
+ // PART 1b: code puts the number tie on every prose step that follows a fact step, before anything is validated or signed
2944
+ let v = validateDeclaration(withFactBindings(settlePersonsWords(ask.declaration, jobLines)), { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType, writeScope, jobLines, inputLabels: inputLabelsOf(inputs) });
2945
+ // PART 1c: code proves, at $0, that every label is PICKED from the input's own list and every word a fact depends on is really
2946
+ // in the source (white-space blind, once is enough). A word that is not found is a red naming the word and the input; the
2947
+ // drafter revises like it does for any validation red.
2948
+ if (v.ok && lang === RUBRIC_LANG) {
2949
+ const words = checkDeclarationWords(v.declaration, inputTexts);
2950
+ if (words.length > 0) v = { ...v, ok: false, reds: [...v.reds, ...words], declaration: null };
2951
+ }
2656
2952
  // SCRUBBED HERE, once, where the reds enter this module's records — the same
2657
2953
  // boundary rule `renderRejectBlock` already states, applied to the OTHER
2658
2954
  // channel they travel down. A validation red quotes what the model declared
@@ -2673,6 +2969,8 @@ export async function authorClose({
2673
2969
  // same channel and counts as a revision (addendum 5)
2674
2970
  measured = renderRejectBlock({ kind: 'validation', reds: vReds });
2675
2971
  tryFails[i] = { lines: linesNamedByReds(vReds, ask.declaration, (jobLines ?? []).map((l) => l.n)) };
2972
+ // WHY this try was refused, for the drafting log and the progress line (the reds are already scrubbed)
2973
+ onPhase('author-try-failed', { call: label, i, of: revisionCap, reds: vReds, declaration: ask.declaration });
2676
2974
  } else {
2677
2975
  // what gets MEASURED is what will RUN: the resolved declaration with the
2678
2976
  // genre's environment already injected
@@ -2703,6 +3001,16 @@ export async function authorClose({
2703
3001
  measuredIterations.push({ label, declaration: injected.declaration, seedRead: rows, droppedEnv: injected.dropped });
2704
3002
  const sf = seedReadFailure(rows, injected.declaration, guards.map((/** @type {any} */ g) => g.name), (jobLines ?? []).map((l) => l.n));
2705
3003
  tryFails[i] = sf.failed ? { lines: sf.lines } : null;
3004
+ if (sf.failed) {
3005
+ const guardNames = guards.map((/** @type {any} */ g) => g.name);
3006
+ const stoppedNames = [...new Set(rows.filter((/** @type {any} */ r) => !guardNames.includes(r?.stage) && r?.verdict === 'instrument-stop').map((/** @type {any} */ r) => String(r.stage)))];
3007
+ onPhase('author-try-failed', {
3008
+ call: label, i, of: revisionCap, declaration: ask.declaration,
3009
+ reason: stoppedNames.length
3010
+ ? `a check could not run at the starting point (${stoppedNames.join(', ')})`
3011
+ : 'no check was failing at the starting point, so none of them could tell a finished job from an unfinished one',
3012
+ });
3013
+ }
2706
3014
  }
2707
3015
 
2708
3016
  if (i === revisionCap) { stop = 'max-revisions'; break; }