bareloop 0.41.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 (47) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +44 -4
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +78 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +23 -12
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/job.js +2 -1
  16. package/src/jobblock.js +7 -2
  17. package/src/kinds.js +84 -3
  18. package/src/labelscan.js +170 -0
  19. package/src/pagefetch.js +125 -0
  20. package/src/panel/authorroutes.js +15 -2
  21. package/src/panel/authorsession.js +99 -22
  22. package/src/panel/index.html +33 -7
  23. package/src/panel/jobplan.js +34 -11
  24. package/src/panel/server.js +19 -3
  25. package/src/planrun.js +3 -2
  26. package/src/rubricstep.js +804 -0
  27. package/src/source.js +166 -1
  28. package/src/u-readout.js +1 -1
  29. package/src/userrun.js +14 -2
  30. package/types/authorflow.d.ts +267 -13
  31. package/types/authoring.d.ts +29 -1
  32. package/types/authorjob.d.ts +7 -5
  33. package/types/authorreadout.d.ts +27 -2
  34. package/types/authorscout.d.ts +70 -0
  35. package/types/declaredclose.d.ts +4 -2
  36. package/types/facts.d.ts +71 -0
  37. package/types/factwords.d.ts +42 -0
  38. package/types/inputlook.d.ts +238 -0
  39. package/types/jobblock.d.ts +8 -1
  40. package/types/labelscan.d.ts +38 -0
  41. package/types/pagefetch.d.ts +71 -0
  42. package/types/panel/authorsession.d.ts +18 -2
  43. package/types/panel/jobplan.d.ts +12 -4
  44. package/types/panel/server.d.ts +4 -2
  45. package/types/rubricstep.d.ts +286 -0
  46. package/types/source.d.ts +94 -0
  47. package/types/userrun.d.ts +11 -0
package/src/authoring.js CHANGED
@@ -74,6 +74,7 @@
74
74
 
75
75
  import { normalizeParser, MAX_JUDGED_PATHS, JUDGED_FLOOR_KIND } from './kinds.js';
76
76
  import { JUDGE_RULE_IDS, validateCard } from './judged.js';
77
+ import { rubricStepShapeReds, checkRubricParts, checkRubricRules } from './rubricstep.js';
77
78
  import { isObj, isNonEmptyString, hasNestedQuantifier, globToPrefix } from './validate.js';
78
79
 
79
80
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
@@ -340,6 +341,36 @@ export const KIND_CATALOGUE = Object.freeze({
340
341
  + 'Name one card line per thing you actually look for, in the order you look for them, and name the real files the '
341
342
  + 'work lands in. It is never offered to the agent as an in-run check.',
342
343
  }),
344
+ // PART 1a (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-12"): the rubric job on LOCAL FILES. One stage per numbered job
345
+ // line, reading the one file that line leaves behind (output/steps/N.json). It measures nothing with a command and buys
346
+ // nothing: bareguard's rubric primitive grades the file against the person's own counted limits and quotes. It exists only in
347
+ // the text genre's menu (`catalogueForLang`) — a code job never sees it. `parts` is the person's constraints on the line,
348
+ // each SORTED by the drafter into a class; only `countable` parts become checks, and the validator reds the rest in plain
349
+ // words (`checkRubricParts`), so no signed stage carries a part this build cannot run.
350
+ 'rubric-step': Object.freeze({
351
+ verdictClass: 'green',
352
+ required: Object.freeze(['step', 'parts']),
353
+ optional: Object.freeze([]),
354
+ pathParams: Object.freeze([]),
355
+ shape: 'step: integer (the job line number), parts: [{text: string, class: "countable" | "fact" | "judged" | "guard" | "none", '
356
+ + 'check?: "maxWords" | "minWords" | "maxLines" | "sections" | "sectionWords" | "cited" | "minClaims" | "maxClaims", value?: integer, names?: string[], '
357
+ + 'wordsPerSection?: integer, source?: string (an input label), domains?: string[], covers?: integer[], rules?: integer[], '
358
+ + 'fact parts: facts?: [{name, shape: "money"|"number"|"date"|"time"|"phrase"|"text", unit?|currency?, phrase?, anchor?, until?, '
359
+ + 'rule?: {op, value | lo+hi}, aiProposal?: boolean}], count?: {min?, max?}}]',
360
+ asserts: 'the file output/<step>.json under output/steps/ exists, is a JSON object, and its "text" is present and within the '
361
+ + 'limits the person asked for. Write ONE rubric-step stage per numbered job line (step = that line number, fromLine = [that line]). '
362
+ + 'In "parts" list each constraint the person put on that line, in their own words, and SORT it: "countable" when a machine can '
363
+ + 'count it (check maxWords/minWords/maxLines with value; sections with names; sectionWords with names and wordsPerSection) or '
364
+ + 'when claims must be backed by a quote from a named input (check cited, source = the input label), or when a number of '
365
+ + 'quoted claims is asked for (minClaims / maxClaims with value and source: it counts the claims listed under that input); '
366
+ + 'a line that FINDS, LISTS or LOOKS UP something gets ONE part of class "fact" instead (source = the input label, '
367
+ + 'facts = the required facts, count = how many results): its step file holds only filled facts and no free text, so '
368
+ + 'give such a line no word, section or claim-count parts; list in "rules" the number of every ~ rule a part carries out; '
369
+ + 'on every countable part that carries out a CONFIRMED check, list that check\'s number in covers (a confirmed check no part '
370
+ + 'covers is moved to Not checked before signing); "judged" when only reading '
371
+ + 'it could decide; "guard" for a rule about web sources (domains); "none" when you cannot sort it. Never turn a part you cannot '
372
+ + 'count or quote into a countable one by guessing a number.',
373
+ }),
343
374
  // LIVE at N4 slice 1, and the ONLY kind that does not measure anything: it
344
375
  // does not RUN, it PAUSES the run and hands the tree to a person (ruling 1).
345
376
  // `ask` is the whole parameter surface — no cmd, no timeoutMs, no env — so a
@@ -381,6 +412,21 @@ export const LOCKED_KINDS = Object.freeze(CATALOGUE_KINDS.filter((k) => KIND_CAT
381
412
  * that the catalogue never names is a capability nothing can reach. */
382
413
  export const CATALOGUE_LIVE_KINDS = Object.freeze(CATALOGUE_KINDS.filter((k) => !KIND_CATALOGUE[k].locked));
383
414
 
415
+ /**
416
+ * The catalogue a close of this language may use. A rubric job on local files (`RUBRIC_LANG`) sees only
417
+ * {@link RUBRIC_LANG_KINDS}; every code language sees the whole catalogue MINUS the rubric-only kinds, so a code job's
418
+ * composer, schema and validator can never reach for a stage that reads step files. One filter, both directions.
419
+ * @param {string|null|undefined} lang
420
+ * @param {Record<string, KindSpec>} [catalogue]
421
+ * @returns {Record<string, KindSpec>}
422
+ */
423
+ export function catalogueForLang(lang, catalogue = KIND_CATALOGUE) {
424
+ const keep = lang === RUBRIC_LANG
425
+ ? (/** @type {string} */ k) => RUBRIC_LANG_KINDS.includes(k)
426
+ : (/** @type {string} */ k) => !RUBRIC_ONLY_KINDS.includes(k);
427
+ return Object.freeze(Object.fromEntries(Object.entries(catalogue).filter(([k]) => keep(k))));
428
+ }
429
+
384
430
  export const DIRECTIONS = Object.freeze(['lower-is-better', 'higher-is-better']);
385
431
  /** D12: the close stores the COUNTING RULE, never the number. "seed" is measured
386
432
  * at this run's own seed every run; 0 is literally zero. A frozen constant (the
@@ -516,6 +562,28 @@ export const TYPES_GENRE_TEMPLATE = `1. The STRICT form of the language's type c
516
562
 
517
563
  export const GENRE_LANGUAGES = Object.freeze(['js', 'python']);
518
564
 
565
+ /**
566
+ * PART 1a: the "language" of a RUBRIC job on local text files. It is not a code language and carries no suppression
567
+ * battery, no environment and no instruments — it only lets such a close declare which genre-shaped guards apply to it
568
+ * (`classGuards`) without pretending the file is JavaScript. `GENRE_LANGUAGES` stays the code-language menu (it is what a
569
+ * repo job's language detection and the confirm turn offer); this is the one extra value a close may DECLARE.
570
+ */
571
+ export const RUBRIC_LANG = 'text';
572
+ /** the genre label a close over local text files carries (`closeDecl.genre`); the code languages' is `TYPES_GENRE.name`. Never the other's. */
573
+ export const TEXT_GENRE = 'TEXT';
574
+ /** @param {unknown} lang @returns {string} the genre a close of this language is authored under */
575
+ export function genreForLang(lang) { return lang === RUBRIC_LANG ? TEXT_GENRE : TYPES_GENRE.name; }
576
+ /** every `closeDecl.lang` a close may declare: the code languages plus the rubric-files one */
577
+ export const DECLARABLE_LANGUAGES = Object.freeze([...GENRE_LANGUAGES, RUBRIC_LANG]);
578
+ /** the kinds that exist only for a rubric job on local files — a code job's menu, schema and validator never offer them */
579
+ export const RUBRIC_ONLY_KINDS = Object.freeze(['rubric-step']);
580
+ /** the whole kind menu of a rubric job on local files: the one machine guard that applies to a file tree, plus its own stage */
581
+ export const RUBRIC_LANG_KINDS = Object.freeze(['files-changed', 'rubric-step']);
582
+ /** the rubric-files language's data, all declared-empty (never an absent field that would read as "not looked into", F59) */
583
+ const TEXT_LANGUAGE = Object.freeze({
584
+ suppressions: Object.freeze([]), extensions: Object.freeze([]), env: Object.freeze([]), instruments: Object.freeze([]),
585
+ });
586
+
519
587
  /**
520
588
  * The js `any` suppression pattern's regex, ONE spelling: the TYPES js battery
521
589
  * below composes it into the signed guard, and `anySuppressionWarning` keys on
@@ -733,6 +801,7 @@ export const TYPES_GENRE = Object.freeze({
733
801
 
734
802
  /** @param {string} lang */
735
803
  function language(lang) {
804
+ if (lang === RUBRIC_LANG) return /** @type {any} */ (TEXT_LANGUAGE);
736
805
  const l = TYPES_GENRE.languages[/** @type {'js'|'python'} */ (lang)];
737
806
  // An unknown language THROWS rather than returning an empty battery: a genre
738
807
  // we have not admitted must not read as "this genre needs no guards" (F59's
@@ -858,7 +927,15 @@ export function classGuards({ verdictType, lang }) {
858
927
  + 'admission refuses it before any battery is built; a locked class must never resolve to an empty one');
859
928
  }
860
929
  const l = language(lang);
861
- const guards = battery.guards.map((g) => {
930
+ // PART 1a: a rubric job on local TEXT files keeps every guard a machine can render over a file tree (`changed-from-seed`:
931
+ // something changed, and only inside the fence) and drops only the guards COMPOSED from a code language's tool data — a
932
+ // suppression scan over source code has nothing to scan in a text file, and composing it from no data would be the
933
+ // empty-guard hazard the throw below exists for. Only the soft-green battery has a text form.
934
+ if (lang === RUBRIC_LANG && String(verdictType) !== 'soft-green') {
935
+ throw new Error(`the "${RUBRIC_LANG}" genre is the rubric-files one: it carries a battery for soft-green only, not "${verdictType}"`);
936
+ }
937
+ const batteryGuards = lang === RUBRIC_LANG ? battery.guards.filter((g) => g.compose.length === 0) : battery.guards;
938
+ const guards = batteryGuards.map((g) => {
862
939
  /** @type {Record<string, any>} */
863
940
  const params = structuredClone(g.params);
864
941
  for (const p of g.compose) {
@@ -1199,7 +1276,8 @@ function readPath(p, dotted) {
1199
1276
  * @param {{catalogue?: Record<string, KindSpec>, listing?: string[]|null,
1200
1277
  * guards?: {name: string, kind: string, params: Record<string, any>, fill: string[]}[]|null,
1201
1278
  * envOwned?: string[]|null, envInjected?: Record<string, string>|null, deferListing?: boolean,
1202
- * verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null}} [opts]
1279
+ * verdictType?: string|null, writeScope?: string[]|null, jobLines?: {n: number}[]|null, inputLabels?: string[]|null}} [opts]
1280
+ * `inputLabels` — the labels of the person's inputs (Part 1a). Present, a rubric-step `cited` part must name one of them.
1203
1281
  * `jobLines` — the person's numbered job lines (P7). Present, every stage must name the line it serves and every
1204
1282
  * line needs a stage or a refusal ({@link checkJobLines}); absent (every spec that predates the job block), the
1205
1283
  * rule is not run.
@@ -1221,7 +1299,7 @@ function readPath(p, dotted) {
1221
1299
  export function validateDeclaration(declaration, opts = {}) {
1222
1300
  const {
1223
1301
  catalogue = KIND_CATALOGUE, listing = null, guards = null, envOwned = null, envInjected = null,
1224
- verdictType = null, writeScope = null, jobLines = null,
1302
+ verdictType = null, writeScope = null, jobLines = null, inputLabels = null,
1225
1303
  } = opts;
1226
1304
  const deferListing = opts.deferListing === true;
1227
1305
  /** @type {Red[]} */
@@ -1447,6 +1525,10 @@ export function validateDeclaration(declaration, opts = {}) {
1447
1525
 
1448
1526
  if (haveGuards) checkGuards({ declaration, guards: /** @type {any[]} */ (guards), red });
1449
1527
  checkJobLines({ declaration, stages, jobLines, guards: haveGuards ? /** @type {any[]} */ (guards) : [], red });
1528
+ // PART 1a (R11): the sorting rules for the rubric-step stages — plain-words reds, judged and web parts refused in this build
1529
+ checkRubricParts(stages, { inputLabels, red });
1530
+ // PART 1b (RF16, RF17): every ~ rule is a check or Not checked; the AI's own numbers are marked and a time range is a range
1531
+ checkRubricRules(stages, { jobLines, refused: declaration.refused, red });
1450
1532
 
1451
1533
  const ok = reds.length === 0;
1452
1534
  return { ok, reds, declaration: ok ? normalizeDeclaration(declaration) : null, grounded: haveListing, scoped, ceiling };
@@ -1489,15 +1571,15 @@ function checkJobLines({ declaration, stages, jobLines, guards, red }) {
1489
1571
  const refused = new Set();
1490
1572
  if (declaration.refused !== undefined) {
1491
1573
  if (!Array.isArray(declaration.refused)) {
1492
- red('invalid-value', 'refused', 'an array of {line, reason} — the job lines no stage can check, each with a reason');
1574
+ red('invalid-value', 'refused', 'an array of {line, rule?, reason} — the job lines (or one ~ rule of a line) no stage can check, each with a reason');
1493
1575
  } else {
1494
1576
  declaration.refused.forEach((/** @type {any} */ r, /** @type {number} */ j) => {
1495
1577
  const at = `refused[${j}]`;
1496
- if (!isObj(r) || !Number.isInteger(r.line) || r.line < 1 || !isNonEmptyString(r.reason)) {
1497
- red('invalid-value', at, '{line, reason}: a job line number and a plain-words reason');
1578
+ if (!isObj(r) || !Number.isInteger(r.line) || r.line < 1 || !isNonEmptyString(r.reason) || (Object.hasOwn(r, 'rule') && !(Number.isInteger(r.rule) && r.rule >= 1))) {
1579
+ red('invalid-value', at, '{line, rule?, reason}: a job line number, optionally the number of one of its ~ rules, and a plain-words reason');
1498
1580
  } else if (known !== null && !known.includes(r.line)) {
1499
1581
  red('invalid-value', `${at}.line`, `refused line ${r.line}, but the job has lines ${known.join(', ')} only`);
1500
- } else refused.add(r.line);
1582
+ } else if (!Object.hasOwn(r, 'rule')) refused.add(r.line);
1501
1583
  });
1502
1584
  }
1503
1585
  }
@@ -1689,6 +1771,9 @@ function checkKind({ kind, params: p, at, red, scoped, label, populations, envOw
1689
1771
  if (!strArray(p.allowPrefixes)) red('invalid-value', `${at}.params.allowPrefixes`, 'a non-empty array of path prefixes');
1690
1772
  if (p.requireNonEmpty !== true) red('invalid-value', `${at}.params.requireNonEmpty`, 'literally true');
1691
1773
  break;
1774
+ case 'rubric-step':
1775
+ for (const r of rubricStepShapeReds(p, `${at}.params`)) red(r.code, r.path, r.detail);
1776
+ break;
1692
1777
  case 'human-confirms':
1693
1778
  // the whole surface: the question the signer answers. It is shown to a
1694
1779
  // person verbatim, so an empty one is a bare "approve?" — ruling 2's
package/src/authorjob.js CHANGED
@@ -100,21 +100,25 @@
100
100
 
101
101
  import { jobSpecHash, validateJob } from './job.js';
102
102
  import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, judgedStages, closeJudges } from './kinds.js';
103
+ import { inputLabelsOf, readFrozenInputs } from './rubricstep.js';
104
+ import { proverOf } from './factwords.js';
103
105
  // SOFTGREEN modules 4 and 5 — the compile the interview's Q6/Q7 answers land in,
104
106
  // and the gate that decides whether the ruler they describe is a ruler.
105
107
  import { proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts } from './cardauthor.js';
106
108
  import { runCalibration } from './calibrate.js';
107
109
  import { CALIBRATION_SIZE } from './judged.js';
108
110
  import {
109
- GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
111
+ GENRE_LANGUAGES, RUBRIC_LANG, DECLARABLE_LANGUAGES, genreForLang, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
110
112
  UNLISTED_CLASSES, MENU_CLASSES,
111
113
  } from './authoring.js';
112
114
  import {
113
115
  QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook,
114
- runConfirmTurn, LANGUAGE_PICK_FIELD,
116
+ runConfirmTurn, LANGUAGE_PICK_FIELD, normalizeQuestions,
115
117
  } from './authorflow.js';
118
+ import { ruleQuestions } from './inputlook.js';
116
119
  import { thinkingCallOptions, assertThinkingMode } from './thinking.js';
117
- import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
120
+ import { pageContentChars } from './pagefetch.js';
121
+ import { runAuthorScout, runInputLook, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
118
122
  import {
119
123
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
120
124
  } from './declaredclose.js';
@@ -229,14 +233,14 @@ export function refusalEvents(refusal) {
229
233
  * makes between a locked verdict and an invalid one).
230
234
  *
231
235
  * @param {{answers: Record<string|number, any>, verdictType?: string|null,
232
- * repoPath?: string|null, questions?: Record<string|number, string>|null}} o
233
- * `verdictType` and `repoPath` are STRUCTURED input from the UI, never parsed
236
+ * repoPath?: string|null, questions?: Record<string|number, string>|null, required?: (string|number)[]|null}} o
237
+ * `required` — the answer keys that must be present; absent = the picked class's whole set. `verdictType` and `repoPath` are STRUCTURED input from the UI, never parsed
234
238
  * out of the prose of an answer: a class is picked off a radio and a path is
235
239
  * read off the machine, or neither exists.
236
240
  * @returns {{ok: boolean, answers: Record<string|number, string>,
237
241
  * verdictType: string|null, repoPath: string|null, refusal: Refusal|null, reds: Red[]}}
238
242
  */
239
- export function runInterview({ answers, verdictType = null, repoPath = null, questions = null }) {
243
+ export function runInterview({ answers, verdictType = null, repoPath = null, questions = null, required: requiredOverride = null }) {
240
244
  /** @type {Red[]} */
241
245
  const reds = [];
242
246
  const out = (/** @type {Partial<any>} */ over) => ({
@@ -290,7 +294,8 @@ export function runInterview({ answers, verdictType = null, repoPath = null, que
290
294
  }
291
295
 
292
296
  const set = questions ?? questionsFor(picked);
293
- const required = requiredAnswersFor(picked);
297
+ // PART 1a: a rubric job on local files asks one question (the job lines); the caller names the keys it needs
298
+ const required = Array.isArray(requiredOverride) ? requiredOverride : requiredAnswersFor(picked);
294
299
 
295
300
  if (!isObj(answers)) {
296
301
  reds.push({ code: 'missing-field', path: 'answers', detail: 'the interview answers, keyed by question number' });
@@ -424,7 +429,7 @@ function composerRefusal(reds) {
424
429
  * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
425
430
  * onPhase?: (phase: string, data?: any) => void,
426
431
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
427
- * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
432
+ * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null,
428
433
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
429
434
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
430
435
  * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
@@ -465,6 +470,9 @@ export async function authorCloseForJob({
465
470
  // callback back, and no decision in this file or below it depends on one.
466
471
  onPhase = () => {}, onCall = () => {},
467
472
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
473
+ // PART 1c - the look at a rubric job's inputs (src/authorscout.js `runInputLook`). `null` is an explicit "no look" (a test seam, like
474
+ // a caller-supplied `scout`); the shipped default is the real, paid, read-only look.
475
+ lookFn = runInputLook,
468
476
  authorFn = authorClose, authorOpts = {},
469
477
  // SOFTGREEN modules 4+5. `signerFix` is the D5 seam: a function (or a value)
470
478
  // carrying the SIGNER's corrected artifacts back. Absent means the proposal is
@@ -504,7 +512,10 @@ export async function authorCloseForJob({
504
512
  confirmed: null,
505
513
  };
506
514
 
507
- const interview = runInterview({ answers, verdictType, repoPath, questions });
515
+ // PART 1a: a rubric job on local files has ONE question — the numbered job lines (the judge-example answer is required only
516
+ // of a job that holds a judged part, and this build admits none). The prompt shows just that question.
517
+ if (lang === RUBRIC_LANG) questions = { 1: questionsFor('soft-green')[1] };
518
+ const interview = runInterview({ answers, verdictType, repoPath, questions, ...(lang === RUBRIC_LANG ? { required: [1] } : {}) });
508
519
  base.interview = interview;
509
520
  if (!interview.ok) {
510
521
  return { ...base, refusal: interview.refusal, reds: interview.reds, stop: interview.refusal ? 'refused' : 'interview-incomplete' };
@@ -530,7 +541,7 @@ export async function authorCloseForJob({
530
541
  }
531
542
  }
532
543
 
533
- if (!GENRE_LANGUAGES.includes(lang)) {
544
+ if (!DECLARABLE_LANGUAGES.includes(lang)) {
534
545
  // THE GENRE REFUSAL, at the composer. This used to be a plain wiring red;
535
546
  // under v1.57 §2 the interview no longer asks the user to confirm a genre, so
536
547
  // this is where "we can't run this kind of job yet" actually lands — and it
@@ -588,6 +599,34 @@ export async function authorCloseForJob({
588
599
  // arrived at by accident.
589
600
  /** @type {any} */
590
601
  let survey = scout;
602
+ // PART 1a: a rubric job on local files has no repository to survey. PART 1c: its survey is the LOOK - a read-only, bounded, paid
603
+ // look at the person's frozen input text that reports which facts can be checked and with which label (picked from a code-made
604
+ // list). Without a look (`lookFn` null, or no model boundary) the survey is just the numbered inputs, known to code at $0.
605
+ if (survey == null && lang === RUBRIC_LANG) {
606
+ const inputList = (Array.isArray(inputs) ? inputs : []).map((i) => ({ n: i.n, label: i.label }));
607
+ const frozen = lookFn && provider && Array.isArray(jobLines) && jobLines.length > 0 && inputList.length > 0
608
+ ? await readFrozenInputs(workdir, inputList.map((i) => i.label)) : null;
609
+ if (frozen !== null && !frozen.ok) {
610
+ survey = { state: 'ABSENT', reason: `the look at your input could not start: ${frozen.why}`, cause: 'call-failed', budgetStop: null, calls: [], raws: [], facts: null };
611
+ } else if (frozen !== null && lookFn && !Object.values(frozen.texts).some((t) => pageContentChars(t) > 0)) {
612
+ // L2: an input with nothing in it (empty, or only a site's header and menus) is never looked at and never asked about - there is
613
+ // nothing to check a rule against. Plain words, before any model call.
614
+ survey = { state: 'ABSENT', reason: 'your input holds nothing to read (it is empty, or only the site\'s own header and menus) - nothing was asked and nothing was spent', cause: 'call-failed', budgetStop: null, calls: [], raws: [], facts: null };
615
+ } else if (frozen !== null && lookFn) {
616
+ onPhase('look', {});
617
+ const lookCallOptions = thinkingCallOptions(provider, draftThinking);
618
+ survey = await lookFn({
619
+ texts: frozen.texts, inputs: inputList, jobLines, provider, ceilingUsd, onCall, prove: proverOf(frozen.texts), ...(rates ? { rates } : {}),
620
+ ...(Object.keys(lookCallOptions).length ? { callOptions: lookCallOptions } : {}),
621
+ });
622
+ onPhase('look-done', { state: survey?.state ?? null, facts: survey?.facts?.look?.facts?.length ?? 0 });
623
+ } else {
624
+ survey = {
625
+ state: 'PRESENT', reason: null, cause: null, budgetStop: null, calls: [], raws: [],
626
+ facts: { rubricFiles: true, inputs: inputList },
627
+ };
628
+ }
629
+ }
591
630
  // `== null` and not `!survey`: this is the `??` these two lines replaced, kept
592
631
  // exactly — a caller-supplied survey is used whatever it is, and only an
593
632
  // ABSENT one is paid for.
@@ -606,6 +645,22 @@ export async function authorCloseForJob({
606
645
  onPhase('scout-done', { state: survey?.state ?? null, facts: Object.keys(survey?.facts ?? {}).length });
607
646
  }
608
647
 
648
+ // PART 1c, I1 (hamr 2026-10-13): the look found that the person's intent on a particular `~` rule was NOT captured (its words or
649
+ // meaning are not in the source) - ask about THAT rule at once, before any plan or draft is paid for. The question gate is the
650
+ // existing one (`answerQuestions`: answer required, no skip, the answer becomes a `~` rule on its line; `normalizeQuestions` keeps
651
+ // at most MAX_QUESTIONS, each on a line the look named). Everything else the look could not find asks nothing piecemeal.
652
+ if (answerQuestions && lang === RUBRIC_LANG && survey?.state === 'PRESENT' && Array.isArray(survey.facts?.look?.notFound) && Array.isArray(jobLines)) {
653
+ const candidates = ruleQuestions(survey.facts.look.notFound, jobLines);
654
+ const kept = normalizeQuestions(candidates, [...new Set(candidates.map((q) => q.line))]);
655
+ if (kept.kept.length > 0) {
656
+ onPhase('look-ask', { questions: kept.kept.length });
657
+ const updated = await answerQuestions({ questions: kept.kept, dropped: kept.dropped, confirmed: null });
658
+ if (updated === null) return { ...base, interview, verdictType: picked, seedRef: seed, reds: [], stop: 'confirm-abandoned', cost: null };
659
+ jobLines = updated.jobLines;
660
+ interview.answers = updated.answers;
661
+ }
662
+ }
663
+
609
664
  // ── THE CONFIRM TURN'S PAID HALF (PRD item 33 M3 piece 4) ──────────────────
610
665
  // Slotted between the scout and the listing: it reuses the scout's own
611
666
  // survey (ruling 4 — never a second read of the source) and needs nothing
@@ -622,7 +677,9 @@ export async function authorCloseForJob({
622
677
  let confirmPriorCalls = [];
623
678
  /** @type {any[]} */
624
679
  let confirmPriorRaws = [];
625
- if (ask && isRepo && survey?.state === 'PRESENT') {
680
+ // PART 1a (hamr's ruling 1A): a rubric job on local files runs the SAME confirm turn a repo job does — the survey is the
681
+ // numbered inputs (synthetic, above), and the protections it shows come from code, never the model
682
+ if (ask && (isRepo || lang === RUBRIC_LANG) && survey?.state === 'PRESENT') {
626
683
  if (typeof confirmGenerate !== 'function') {
627
684
  throw new Error('[authorjob] authorCloseForJob was given `ask` for a repo job but no `confirmGenerate` — the '
628
685
  + 'confirm turn needs its OWN model boundary (bound to CONFIRM_SYSTEM), never the authoring `generate` '
@@ -645,7 +702,7 @@ export async function authorCloseForJob({
645
702
  onPhase('confirm', {});
646
703
  const confirm = await runConfirmTurn({
647
704
  verdictType: picked, answers: interview.answers, questions: questions ?? questionsFor(picked),
648
- facts: survey.facts, listing: null, writeScope, isRepo, lang, worseThanBefore, inputs,
705
+ facts: survey.facts, listing: null, writeScope, isRepo, lang, worseThanBefore, inputs, jobLines,
649
706
  generate: confirmGenerate, book: confirmBook, ask, onPhase,
650
707
  });
651
708
  // DISTINCT from `runConfirmTurn`'s own per-round 'confirm-done' (fired once
@@ -696,7 +753,9 @@ export async function authorCloseForJob({
696
753
  answers: stageAnswers, questions: questions ?? questionsFor(picked),
697
754
  scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, writeScope,
698
755
  priorCalls: confirmPriorCalls, priorRaws: confirmPriorRaws, confirmed, jobLines, inputs,
699
- ...(answerQuestions ? { offerQuestions: true } : {}),
756
+ // PART 1c: a rubric job asks nothing piecemeal on a failed try - it stops, and the person reviews the whole job (the one early
757
+ // ask is about a `~` rule the look could not find, above)
758
+ ...(answerQuestions && lang !== RUBRIC_LANG ? { offerQuestions: true } : {}),
700
759
  ...authorOpts,
701
760
  });
702
761
  // P7 item 5: the drafter, on the last try after two failed ones, asked. The person answers (required, no skip); each
@@ -752,7 +811,7 @@ export async function authorCloseForJob({
752
811
  // `prepareSigning`, which refuses there.
753
812
  /** @type {any} */
754
813
  let closeDecl = {
755
- genre: GENRE,
814
+ genre: genreForLang(lang),
756
815
  lang,
757
816
  stages: authored.declaration.stages,
758
817
  ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
@@ -770,7 +829,7 @@ export async function authorCloseForJob({
770
829
  // P7: the job lines the drafter could not turn into a stage, each with its reason — the Job tab shows them. Free
771
830
  // text the model wrote, so it rides the same redactor `notes` does.
772
831
  ...(authored.declaration.refused?.length
773
- ? { refused: authored.declaration.refused.map((/** @type {any} */ r) => ({ line: r.line, reason: redactSecrets(String(r.reason)) })) }
832
+ ? { refused: authored.declaration.refused.map((/** @type {any} */ r) => ({ line: r.line, ...(Number.isInteger(r.rule) ? { rule: r.rule } : {}), reason: redactSecrets(String(r.reason)) })) }
774
833
  : {}),
775
834
  };
776
835
 
@@ -1301,7 +1360,7 @@ export async function prepareSigning({
1301
1360
  }
1302
1361
  // the class rides from the SPEC (PRD v1.57 §2) — gate 1a already refused a spec
1303
1362
  // whose class is unknown or locked, so this is the class D5's battery hangs off
1304
- const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
1363
+ const dv = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null, inputLabels: inputLabelsOf(spec.inputs) });
1305
1364
  // the grounded gate's reds quote BOTH untrusted sources — the declaration, and
1306
1365
  // the seed listing itself (the listing rule names what really sits beside an
1307
1366
  // invented path). The listing half is a channel gate 1a structurally cannot
@@ -1348,8 +1407,11 @@ export async function prepareSigning({
1348
1407
  stops: stops.map((s) => ({ stage: s.stage, gap: s.gap })),
1349
1408
  };
1350
1409
  if (stops.length) {
1410
+ // M2: a rubric job's readout never uses the code-job wording ("install, build and test commands")
1411
+ const rubricJob = (Array.isArray(spec.closeDecl?.stages) ? spec.closeDecl.stages : []).some((/** @type {any} */ st) => st && (st.kind === 'rubric-step' || st.kind === 'judged-floor'));
1351
1412
  const reds = stops.map((s) => ({
1352
1413
  code: 'broken-close',
1414
+ ...(rubricJob ? { rubricJob: true } : {}),
1353
1415
  path: `closeDecl.stages.${s.stage}`,
1354
1416
  detail: s.gap.join('\n') || `stage "${s.stage}" rendered no judgment`,
1355
1417
  }));
@@ -313,9 +313,12 @@ export function phaseLine(phase, data = {}) {
313
313
  case 'seed': return '· reading the seed commit…';
314
314
  case 'scout': return `· scout running (up to ${data.attempts ?? '?'} attempt(s)) — a real survey over the repository…`;
315
315
  case 'scout-done': return `· scout ${data.state ?? 'unknown'} — ${data.facts ?? 0} fact(s)`;
316
+ case 'look': return '· looking at your input — a read-only search for the facts the job asks for…';
317
+ case 'look-done': return `· look ${data.state ?? 'unknown'} — ${data.facts ?? 0} fact(s) can be checked`;
316
318
  case 'listing': return '· listing the files the declaration may name…';
317
319
  case 'listing-done': return `· listing ${data.stop ? `STOPPED (${data.stop})` : `${data.files ?? 'unknown'} file(s)`}`;
318
320
  case 'author': return '· authoring the close declaration…';
321
+ case 'author-try-failed': return `· ${data.call} refused — ${tryRefusedText(data)}`;
319
322
  case 'author-call': return `· ${data.call} (call ${(data.i ?? 0) + 1} of up to ${(data.of ?? 0) + 1})…`;
320
323
  case 'seed-read': return `· measuring ${data.stages ?? '?'} stage(s) at the seed — each one runs a real toolchain…`;
321
324
  case 'stage': return `· ${data.stage} [${data.kind}] ${data.verdict} — ${ms(data.durationMs)}`;
@@ -405,6 +408,14 @@ const PLAIN_MAX_BULLETS = 8;
405
408
  const RECORD_MAX_REDS = 40;
406
409
  const RECORD_STR_MAX = 600;
407
410
 
411
+ /** a code-written sentence that carries the person's own words (a rubric-step sorting red): scrubbed and one line, bounded wide enough to stay whole
412
+ * @param {unknown} s @returns {string} */
413
+ function fullText(s) {
414
+ // every caller renders a drafting refusal AFTER a model call was paid; a red text written for the $0 front doors ends "Nothing spent." — false here
415
+ const one = redactSecrets(String(s ?? '')).replace(/\s+/g, ' ').trim().replace(/\s*Nothing spent\.$/, '');
416
+ return one.length > 400 ? `${one.slice(0, 400)}…` : one;
417
+ }
418
+
408
419
  /** model-derived text in a person-facing line: scrubbed, one line, bounded
409
420
  * @param {unknown} s @returns {string} */
410
421
  function safeText(s) {
@@ -417,6 +428,25 @@ function safeText(s) {
417
428
  * @typedef {{kind: string, sentence: string, red: PlainRed}} PlainReason
418
429
  */
419
430
 
431
+ /** a shape red on a rubric-step stage's own parameters (live fix B3): invalid-value / missing-field at `stages[n].params.step|parts…` */
432
+ const isRubricShape = (/** @type {PlainRed} */ r) => (r.code === 'invalid-value' || r.code === 'missing-field') && /^stages\[\d+\]\.params\.(step|parts)\b/.test(r.path);
433
+
434
+ /**
435
+ * The plain sentence for a shape red on a rubric-step: WHERE (stage, part, fact, field — counted from 1, the way the plan card shows
436
+ * them) and WHAT is wrong, in the red's own detail. Code-worded, secret-scrubbed.
437
+ * @param {PlainRed} r @returns {string}
438
+ */
439
+ function rubricShapeSentence(r) {
440
+ const m = /^stages\[(\d+)\]\.params\.(.*)$/.exec(r.path);
441
+ const where = [`stage ${Number(m?.[1] ?? 0) + 1}`];
442
+ for (const seg of (m?.[2] ?? '').split('.').filter(Boolean)) {
443
+ const idx = /^(parts|facts)\[(\d+)\]$/.exec(seg);
444
+ if (idx) where.push(`${idx[1] === 'parts' ? 'part' : 'fact'} ${Number(idx[2]) + 1}`);
445
+ else if (seg !== 'parts' && seg !== 'facts') where.push(`field "${safeText(seg)}"`);
446
+ }
447
+ return `The model's check for a job line is not shaped right (${where.join(', ')}): ${fullText(r.detail).replace(/\.$/, '')}.`;
448
+ }
449
+
420
450
  /**
421
451
  * The ONE table of plain kinds → sentence builders. A builder gets the red, the
422
452
  * name of the case it lands on (when it lands on one) and the match of the
@@ -440,6 +470,9 @@ const PLAIN_TABLE = [
440
470
  say: (r) => `Rubric line ${cardLineNo(r)} uses a rule that an earlier line already uses.` },
441
471
  { kind: 'card-line-no-text', when: (r) => r.code === 'invalid-value' && /^card item \d+: needs `text`/.test(r.detail),
442
472
  say: (r) => `Rubric line ${cardLineNo(r)} has no wording.` },
473
+ // Part 1b live fix B3: the rubric-step reds are whole sentences already (src/rubricstep.js); a shape red on its parameters is worded here
474
+ { kind: 'rubric-red', when: (r) => r.code.startsWith('rubric-'), say: (r) => fullText(r.detail) },
475
+ { kind: 'rubric-shape', when: isRubricShape, say: (r) => rubricShapeSentence(r) },
443
476
  { kind: 'proposal-not-object', when: (r) => r.code === 'invalid-value' && r.path === 'proposal',
444
477
  say: () => 'The model\'s answer was not a rubric plus practice cases.' },
445
478
  // ── the practice-case set as a whole ──
@@ -544,6 +577,47 @@ function spendText(spend) {
544
577
  return `${spend.spendComplete === false ? 'at least ' : ''}${moneyText(spend.knownUsd)}`;
545
578
  }
546
579
 
580
+ /**
581
+ * WHY one drafting try was refused, in the code's own words: the unique `detail` of each red it raised (the same sentences
582
+ * `draftStopText` quotes at a stop), else the plain `reason` the loop gave (a seed-read failure raises no red). Scrubbed,
583
+ * one line, bounded; never empty.
584
+ * @param {{reds?: PlainRed[]|null, reason?: string|null}} o
585
+ * @returns {string}
586
+ */
587
+ export function tryRefusedText({ reds = null, reason = null }) {
588
+ const said = [...new Set((Array.isArray(reds) ? reds : []).map((r) => fullText(r?.detail)).filter(Boolean))];
589
+ if (said.length === 0 && reason) said.push(fullText(reason));
590
+ if (said.length === 0) return 'no reason was recorded';
591
+ const text = said.slice(0, 3).join('; ') + (said.length > 3 ? `; …and ${said.length - 3} more` : '');
592
+ return text.length > 600 ? `${text.slice(0, 600)}…` : text;
593
+ }
594
+
595
+ /** the most characters of a refused declaration the drafting log keeps (a bigger one is kept as cut text, said so) */
596
+ const DECLARATION_RECORD_MAX = 60000;
597
+
598
+ /**
599
+ * A refused declaration as the drafting log keeps it (live fix A3), so a failed draft can be read and replayed at $0: the declaration
600
+ * itself, every secret masked as everywhere else (the JSON text goes through `redactSecrets`, then parses back), and bounded.
601
+ * @param {unknown} declaration
602
+ * @returns {{declaration: unknown}|{declarationText: string, declarationCut: true}|{}} nothing when there is no declaration
603
+ */
604
+ export function declarationRecord(declaration) {
605
+ if (declaration === undefined || declaration === null) return {};
606
+ let text;
607
+ try { text = redactSecrets(JSON.stringify(declaration)); } catch { return {}; }
608
+ if (typeof text !== 'string') return {};
609
+ if (text.length > DECLARATION_RECORD_MAX) return { declarationText: text.slice(0, DECLARATION_RECORD_MAX), declarationCut: true };
610
+ try { return { declaration: JSON.parse(text) }; } catch { return { declarationText: text, declarationCut: true }; }
611
+ }
612
+
613
+ /** one red as a line of a stop message: a rubric red is a whole sentence already, a rubric shape red is worded by code, any other is bounded
614
+ * @param {any} r @returns {string} */
615
+ function said1(r) {
616
+ const red = /** @type {PlainRed} */ ({ code: String(r?.code ?? ''), path: String(r?.path ?? ''), detail: String(r?.detail ?? '') });
617
+ if (red.code.startsWith('rubric-')) return fullText(red.detail);
618
+ return isRubricShape(red) ? rubricShapeSentence(red) : safeText(red.detail);
619
+ }
620
+
547
621
  /** the stops this module explains in plain words */
548
622
  export const PLAIN_PROPOSAL_STOPS = Object.freeze(['proposal-invalid', 'rubric-invalid']);
549
623
 
@@ -582,10 +656,11 @@ const SCOUT_REASON_RE = /^the survey is [^(]*\((.*)\) — an absent facts/s;
582
656
 
583
657
  /**
584
658
  * @param {{stop: string, reds?: PlainRed[]|null, refusal?: {detail?: string}|null,
585
- * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null}} o
659
+ * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null, wholeJob?: boolean}} o `wholeJob` - a rubric job on the person's
660
+ * own inputs (PART 1c, I1): a failed draft asks nothing piecemeal; the person reviews the whole job and submits it again
586
661
  * @returns {string}
587
662
  */
588
- export function draftStopText({ stop, reds = null, refusal = null, spend = null }) {
663
+ export function draftStopText({ stop, reds = null, refusal = null, spend = null, wholeJob = false }) {
589
664
  const spent = spendText(spend);
590
665
  /** @type {Record<string, string>} */
591
666
  const heads = {
@@ -596,7 +671,7 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null
596
671
  'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
597
672
  precheck: 'Drafting stopped before any call: something it needs was missing.',
598
673
  unchanged: 'Drafting stopped: the AI sent the same plan twice.',
599
- 'scout-absent': 'Drafting stopped: the first look at your repo did not complete.',
674
+ 'scout-absent': 'Drafting stopped: the first look at your repo or input did not complete.',
600
675
  };
601
676
  const redList = Array.isArray(reds) ? reds : [];
602
677
  // F217: the first look at the repo failing (a dropped connection, a timeout) arrives as a `precheck` stop with a
@@ -608,7 +683,7 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null
608
683
  const said = scoutRed
609
684
  ? [SCOUT_REASON_RE.exec(String(scoutRed.detail ?? ''))?.[1]
610
685
  ?.replace(/\s+/g, ' ').trim().slice(0, 240) ?? safeText(scoutRed.detail)].map((x) => redactSecrets(x)).filter(Boolean)
611
- : [...new Set(redList.map((r) => safeText(r?.detail)).filter(Boolean))];
686
+ : [...new Set(redList.map((r) => said1(r)).filter(Boolean))];
612
687
  if (said.length === 0 && refusal?.detail) said.push(safeText(refusal.detail));
613
688
  const lines = [`${head} Spent so far: ${spent ?? 'unknown'}.`];
614
689
  if (said.length) {
@@ -616,7 +691,9 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null
616
691
  for (const x of said.slice(0, PLAIN_MAX_BULLETS)) lines.push(` • ${x}`);
617
692
  if (said.length > PLAIN_MAX_BULLETS) lines.push(` • …and ${said.length - PLAIN_MAX_BULLETS} more.`);
618
693
  }
619
- lines.push(stop === 'cap-halt' || stop === 'pricing-red' ? 'What to do: raise the cap or fix the price in Settings, then draft again.' : 'What to do: draft again.');
694
+ lines.push(stop === 'cap-halt' || stop === 'pricing-red'
695
+ ? 'What to do: raise the cap or fix the price in Settings, then draft again.'
696
+ : (wholeJob ? 'What to do: review the whole job - every line and its ~ rules, and the inputs - and submit it again.' : 'What to do: draft again.'));
620
697
  return lines.join('\n');
621
698
  }
622
699
 
@@ -716,6 +793,10 @@ const SIGNING_TABLE = [
716
793
  say: () => ['The judge model could not be reached or was cut off while the practice cases were graded. Nothing says the rubric is wrong.'], todo: 'try again in a few minutes' },
717
794
  { kind: 'artifact-red', when: (r) => r.code === 'artifact-red',
718
795
  say: () => ['The judge model answered with something bareloop could not read while the practice cases were graded. Nothing says the rubric is wrong.'], todo: 'try again' },
796
+ // M2: a rubric job's check has no install/build/test commands to blame — its own wording, never the code-job one
797
+ { kind: 'broken-close-rubric', when: (r) => r.code === 'broken-close' && r.rubricJob === true,
798
+ say: (r) => [`A check of the close (${safeText(r.path.split('.').pop() ?? '')}) could not run, so nothing it reported could be trusted.`],
799
+ todo: 'draft again; if it fails the same way, report the check named above' },
719
800
  { kind: 'broken-close', when: (r) => r.code === 'broken-close',
720
801
  say: (r) => [`A check of the close (${safeText(r.path.split('.').pop() ?? '')}) cannot run on this repository, so nothing it reported could be trusted.`],
721
802
  todo: 'check that the project\'s tools (its install, build and test commands) run, then try again' },
@@ -726,6 +807,9 @@ const SIGNING_TABLE = [
726
807
  { kind: 'judged-outside-fence', when: (r) => r.code === 'judged-outside-write-scope',
727
808
  say: (r) => [`The rubric check looks at ${listText(r.outside)}, but the job may only edit ${listText(r.fence)} — so the run could never fix what it finds.`],
728
809
  todo: 'draft again' },
810
+ // Part 1a: the rubric-step sorting reds are already whole sentences in the person's words (src/rubricstep.js `checkRubricParts`)
811
+ { kind: 'rubric-part', when: (r) => r.code.startsWith('rubric-'), say: (r) => [fullText(r.detail)], todo: 'draft again, or reword that line' },
812
+ { kind: 'rubric-shape', when: isRubricShape, say: (r) => [rubricShapeSentence(r)], todo: 'draft again, or reword that line' },
729
813
  { kind: 'declaration', when: (r) => DECLARATION_CODES.includes(r.code) && !(r.code === 'invalid-value' && r.path === 'card'),
730
814
  say: (r) => [`The close the model wrote was refused before anything ran ("${safeText(r.code)}" at ${safeText(r.path)}).`], todo: 'draft again' },
731
815
  ];