bareloop 0.45.0 → 0.46.1

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 (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bareloop.context.md +34 -7
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorreadout.js +55 -14
  6. package/src/authorscout.js +1 -1
  7. package/src/bundle.js +90 -6
  8. package/src/close-integrity.js +39 -5
  9. package/src/config.js +5 -2
  10. package/src/inputlook.js +1 -47
  11. package/src/judged.js +3 -3
  12. package/src/labelscan.js +13 -4
  13. package/src/ladder.js +2 -2
  14. package/src/learn.js +943 -0
  15. package/src/learnstore.js +154 -0
  16. package/src/modellist.js +1 -1
  17. package/src/pagedry.js +2 -3
  18. package/src/pagefetch.js +2 -1
  19. package/src/pagerules.js +27 -4
  20. package/src/panel/authorroutes.js +4 -3
  21. package/src/panel/authorsession.js +5 -7
  22. package/src/panel/index.html +108 -0
  23. package/src/panel/schedulestart.js +5 -0
  24. package/src/panel/server.js +16 -134
  25. package/src/panel/status.js +11 -0
  26. package/src/planrun.js +40 -6
  27. package/src/providerrows.js +3 -3
  28. package/src/run.js +4 -2
  29. package/src/schedulewords.js +1 -1
  30. package/src/source.js +1 -1
  31. package/src/stagewords.js +145 -0
  32. package/src/text.js +4 -2
  33. package/src/userrun.js +28 -0
  34. package/src/validate.js +10 -5
  35. package/src/worktree.js +8 -2
  36. package/types/attemptdiff.d.ts +38 -0
  37. package/types/authorreadout.d.ts +7 -0
  38. package/types/config.d.ts +1 -1
  39. package/types/inputlook.d.ts +0 -23
  40. package/types/labelscan.d.ts +6 -0
  41. package/types/learn.d.ts +575 -0
  42. package/types/learnstore.d.ts +107 -0
  43. package/types/pagerules.d.ts +7 -1
  44. package/types/panel/status.d.ts +8 -0
  45. package/types/planrun.d.ts +13 -3
  46. package/types/reuse.d.ts +1 -1
  47. package/types/run.d.ts +8 -2
  48. package/types/stagewords.d.ts +91 -0
@@ -47,6 +47,7 @@ import { chainSpend } from '../ledger.js';
47
47
  import { runBehaviour } from '../behaviour.js';
48
48
  import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
49
49
  import { jobSpecHash, workflowKey } from '../job.js';
50
+ import { parseHintBlock } from '../learn.js';
50
51
  import { confirmProtections } from '../authorflow.js';
51
52
  import { createScheduleDoor } from './schedules.js';
52
53
  import { createStarter } from './schedulestart.js';
@@ -67,13 +68,14 @@ import { SHAPES } from '../providershapes.js';
67
68
  import { resolveProvider } from '../providers.js';
68
69
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
69
70
  import {
70
- statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS,
71
+ statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS, isReusableGreen,
71
72
  } from './status.js';
72
73
  import { thinkingJobText } from '../thinking.js';
73
74
  import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../rubricstep.js';
74
75
  import { factPartOf, factSentence, ruleWords, moneyAs } from '../facts.js';
75
76
  import { createSettingsRoutes } from './settingsroutes.js';
76
77
  import { planFromSpec } from './jobplan.js';
78
+ import { stageQuestionsByName } from '../stagewords.js';
77
79
 
78
80
  const HERE = dirname(fileURLToPath(import.meta.url));
79
81
 
@@ -540,7 +542,7 @@ function endedBase(summary, death, o) {
540
542
  if (o.destinationRefused) {
541
543
  // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); every ending offers Reuse workflow
542
544
  // (withReuseAction adds it) and is told to change the job
543
- const earned = outcome === 'green';
545
+ const earned = isReusableGreen(outcome);
544
546
  return {
545
547
  reason: 'Goal met, but the output could not be delivered.',
546
548
  next: earned ? 'Fix the destination, then Reuse workflow.' : CHANGE,
@@ -548,10 +550,9 @@ function endedBase(summary, death, o) {
548
550
  actions: earned ? REUSE : [],
549
551
  };
550
552
  }
551
- // hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
552
- // earned its green offers Reuse workflow; every ending offers Reuse workflow (withReuseAction adds it). `satisfied` is a
553
- // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
554
- if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
553
+ // a reusable green (isReusableGreen: green or already-green, hamr 2026-10-15) says nothing is left to do; every ending offers
554
+ // Reuse workflow (withReuseAction adds it). `satisfied` is a close VERDICT, never a run outcome, so it is not one.
555
+ if (!isReusableGreen(outcome)) return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
555
556
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
556
557
  }
557
558
  if (outcome === 'cap-halt') {
@@ -1528,7 +1529,8 @@ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
1528
1529
  }
1529
1530
  const got = hit ?? fallback;
1530
1531
  const no = got ? nos.get(got.r.runid) : undefined;
1531
- const word = got ? summarizeRow(got.r).status?.word : undefined;
1532
+ // events: a fire refused at the run-start fetch has no spine, only its settled event; without them it has no status word
1533
+ const word = got ? summarizeRow(got.r, Date.now(), undefined, readRunList(opts).events).status?.word : undefined;
1532
1534
  lastWords = `last ${dateTimeWords(Number(lastRow.at))}${no ? ` · run-${no}` : ''}${word ? ` · ${word}` : ''}${got?.rec.catchUp === true ? ' · catch-up' : ''}`;
1533
1535
  } else {
1534
1536
  lastWords = `last ${dateTimeWords(Number(lastRow.at))} · ${skippedWords(lastRow, true)}`;
@@ -1849,6 +1851,9 @@ function getRunDetailBody(runid, opts) {
1849
1851
  // carries no contamination risk); `null` when the spine carries no
1850
1852
  // `memory-cache` record at all (never armed on this run).
1851
1853
  behaviour: scopedBehaviour(row, rawSpineRecords),
1854
+ // Cross-run learnings, READ-ONLY (ITEM33 rev 2, RULING 8): the exact block the planner was handed, as the spine recorded it at
1855
+ // injection (the draft, and the first replan); `hints` = the same text split into fields by learn.js's parseHintBlock (the one reader). Empty when the setting was off or nothing was readable.
1856
+ learnings: rawSpineRecords.filter((e) => e?.type === 'learnings' && typeof e.text === 'string').map((e) => ({ phase: String(e.phase), text: e.text, chars: e.text.length, ids: Array.isArray(e.ids) ? e.ids : [], hints: parseHintBlock(e.text) })),
1852
1857
  // PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
1853
1858
  callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
1854
1859
  checksLine: checksLineOf(rawSpineRecords, enrichedParts),
@@ -2593,116 +2598,6 @@ function resolveSpecForRow(row) {
2593
2598
  return null;
2594
2599
  }
2595
2600
 
2596
- /**
2597
- * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
2598
- * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
2599
- * when 5 never ran"). ONE pure function mapping a declared stage's own
2600
- * signed `kind`+`params` (plus the close's own `genre`, also a signed field)
2601
- * to a short plain-English question a person can read without knowing the
2602
- * catalogue — never a second spelling on the client, and never anything the
2603
- * model authored (the catalogue's kinds/params are the whole vocabulary this
2604
- * reads).
2605
- *
2606
- * Every branch is traced straight off `src/kinds.js`'s runner semantics and
2607
- * the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
2608
- * `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
2609
- * - `files-changed` is always the `changed-from-seed` guard: "did it
2610
- * change any file?"
2611
- * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
2612
- * casts or silencers added?"
2613
- * - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
2614
- * - `command-exit` is the genre's `suite-green` stage (the only shipped use
2615
- * of this kind): "does the test suite pass?"
2616
- * - `count-not-worse` splits three ways on its own declared
2617
- * `direction`/`baseline` (never the runtime number — see
2618
- * {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
2619
- * travel separately from the measured figure):
2620
- * - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
2621
- * floor: "did all the old tests still exist?"
2622
- * - `lower-is-better` + `baseline: 0` is the genre's in-scope
2623
- * `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
2624
- * command a `command-exit` stage in the same close already runs
2625
- * (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
2626
- * suite exits clean AND reports zero failing tests — TWO assertions"
2627
- * over ONE population (the same `npm test`/`pytest` invocation), so a
2628
- * `count-not-worse` stage sharing that exact command is the failing-
2629
- * test-count half of that pair, never a typecheck stage: "no failing
2630
- * tests?" — derived from the same cmd+args correlation (the stage
2631
- * counts the suite's own failing tests), never from the stage's own
2632
- * name string. The live check that found this (run mul5fofw's own
2633
- * `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
2634
- * `suite-green` command-exit stage) would otherwise have read "test/
2635
- * has 0 type errors?", which is false: its parser counts FAILING
2636
- * TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
2637
- * name this half honestly rather than reusing "type errors" for a
2638
- * population it was never about, or falling back to the stage's raw
2639
- * name. When the command differs from every sibling `command-exit`
2640
- * stage, the declared `scope.includePrefixes` naming exactly ONE path
2641
- * is quoted verbatim (a signed param, never invented): "<path> has 0
2642
- * type errors?" — otherwise the generic "type errors is 0?", never a
2643
- * guessed filename.
2644
- * - `lower-is-better` + `baseline: 'seed'` with a non-empty
2645
- * `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
2646
- * "other files didn't get more type errors?"
2647
- * The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
2648
- * the only genre this catalogue admits today (doc-genre kinds are not yet
2649
- * built) — an unrecognised genre, or a `count-not-worse` shape none of the
2650
- * three above matches, returns `null` rather than invent a fourth shape.
2651
- *
2652
- * `null` means "no honest plain question" — the caller (buildAttemptsList,
2653
- * partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
2654
- * exactly as it already does for a stage with no resolvable kind at all.
2655
- * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
2656
- * @returns {string|null}
2657
- */
2658
- const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
2659
-
2660
- /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
2661
- * itself) that two stages measure the SAME command's output, used to tell a
2662
- * genuine typecheck stage apart from the TYPES genre's other `lower-is-
2663
- * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
2664
- * "two assertions"), which shares that stage's own shape but not its
2665
- * population. `null` for a stage whose `cmd` isn't a non-empty string —
2666
- * never matches anything, so it never falsely suppresses a real typecheck
2667
- * question.
2668
- * @param {any} params @returns {string|null} */
2669
- function cmdKey(params) {
2670
- if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
2671
- const args = Array.isArray(params.args) ? params.args : [];
2672
- return JSON.stringify([params.cmd, args]);
2673
- }
2674
-
2675
- function stageQuestionText({
2676
- kind, params, genre, suiteCmdKeys,
2677
- }) {
2678
- if (kind === 'files-changed') return 'did it change any file?';
2679
- if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
2680
- if (kind === 'command-exit') return 'does the test suite pass?';
2681
- // Part 1a: one rubric-step stage = one job line's output file
2682
- if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
2683
- if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
2684
- const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
2685
- if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
2686
- return 'did all the old tests still exist?';
2687
- }
2688
- if (!what) return null;
2689
- const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
2690
- if (params.direction === 'lower-is-better' && params.baseline === 0) {
2691
- const key = cmdKey(params);
2692
- if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
2693
- const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
2694
- if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
2695
- return `${include[0]} has 0 ${what}?`;
2696
- }
2697
- return `${what} is 0?`;
2698
- }
2699
- if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
2700
- const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
2701
- if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
2702
- return null;
2703
- }
2704
- return null;
2705
- }
2706
2601
 
2707
2602
  /**
2708
2603
  * Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
@@ -2724,18 +2619,7 @@ function stageQuestionText({
2724
2619
  */
2725
2620
  function stageKindMetaFromSpec(spec) {
2726
2621
  if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
2727
- const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
2728
- // every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
2729
- // so stageQuestionText can tell the failing-test-count half of suite-green's
2730
- // "two assertions" apart from a genuine typecheck stage — see its own doc.
2731
- /** @type {Set<string>} */
2732
- const suiteCmdKeys = new Set();
2733
- for (const s of spec.closeDecl.stages) {
2734
- if (s && s.kind === 'command-exit') {
2735
- const key = cmdKey(s.params);
2736
- if (key !== null) suiteCmdKeys.add(key);
2737
- }
2738
- }
2622
+ const questions = stageQuestionsByName(spec);
2739
2623
  /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
2740
2624
  const map = new Map();
2741
2625
  for (const s of spec.closeDecl.stages) {
@@ -2744,9 +2628,7 @@ function stageKindMetaFromSpec(spec) {
2744
2628
  const params = s.params && typeof s.params === 'object' ? s.params : null;
2745
2629
  const direction = params && typeof params.direction === 'string' ? params.direction : null;
2746
2630
  const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
2747
- const question = stageQuestionText({
2748
- kind, params, genre, suiteCmdKeys,
2749
- });
2631
+ const question = questions.get(s.name) ?? null;
2750
2632
  map.set(s.name, {
2751
2633
  kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
2752
2634
  });
@@ -3632,7 +3514,7 @@ export function getStartFrom(runid, opts = {}) {
3632
3514
  // T D.2: a SCHEDULED run's green reads `green-held` until the person accepts it, so that same refusal holds it unchanged
3633
3515
  outcome: (() => {
3634
3516
  const o = legsOf(records).at(-1)?.outcome ?? null;
3635
- return o === 'green' && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
3517
+ return isReusableGreen(o) && heldScheduled(records, triggerRecordOf(row)) ? 'green-held' : o;
3636
3518
  })(),
3637
3519
  card,
3638
3520
  from: 'signed job',
@@ -3697,7 +3579,7 @@ export function listReuseJobs(opts = {}) {
3697
3579
  try { records = parseJsonl(row.spine).records; } catch { continue; }
3698
3580
  const outcome = legsOf(records).at(-1)?.outcome ?? null;
3699
3581
  // a real green or an already-green (hamr 2026-10-15: if it is already fixed and nothing is to be done, it is green and can be reused)
3700
- if (outcome !== 'green' && outcome !== 'already-green') continue;
3582
+ if (!isReusableGreen(outcome)) continue;
3701
3583
  if (heldScheduled(records, triggerRecordOf(row))) continue; // T D.2: a scheduled green waits for the person's Accept
3702
3584
  const signed = signedSpecForRun(row, records);
3703
3585
  if (!signed) continue;
@@ -20,6 +20,17 @@ import { capKindOf } from '../legs.js';
20
20
  /** the sentence a passed run carries as its reason (the Ended block's `line`) */
21
21
  export const GOAL_MET_LINE = 'goal met';
22
22
 
23
+ /**
24
+ * ONE spelling of "this run's outcome is a green that can be reused" (the Reuse picker, the Reuse start/prefill, the Ended block's
25
+ * next line). hamr 2026-10-15: an `already-green` (fixed before any work) counts. `satisfied` is a close verdict, never a run outcome.
26
+ * The SCHEDULE gate (schedules.js greenFor) deliberately stays on a real `green` only (T3).
27
+ * @param {string|null|undefined} outcome
28
+ * @returns {boolean}
29
+ */
30
+ export function isReusableGreen(outcome) {
31
+ return outcome === 'green' || outcome === 'already-green';
32
+ }
33
+
23
34
  /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
24
35
 
25
36
  /** the table: key -> sign + word */
package/src/planrun.js CHANGED
@@ -39,6 +39,7 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, str
39
39
  import { anySuppressionWarning } from './authoring.js';
40
40
  import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
41
41
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
42
+ import { startAttemptDiff } from './attemptdiff.js';
42
43
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
43
44
  import { extractArtifact, rateSourceFields } from './text.js';
44
45
  import { defaultJudgeLoop } from './judged.js';
@@ -338,6 +339,17 @@ function materialsBlock(m) {
338
339
  return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
339
340
  }
340
341
 
342
+ /**
343
+ * Cross-run learnings (ITEM33-BUILD.md rev 2, RULING 7): the block `renderHints` (src/learn.js) made, rendered verbatim into
344
+ * the PLANNER's prompt and nowhere else — a worker (scout, step, close-fix) is never handed ledger text. Absent or empty
345
+ * renders '' so every prompt without hints is byte-identical to what it was.
346
+ * @param {string|null|undefined} text
347
+ */
348
+ function hintsBlock(text) {
349
+ if (typeof text !== 'string' || !text.trim()) return '';
350
+ return `\n${text}\n`;
351
+ }
352
+
341
353
  /**
342
354
  * MODULE 8 — the signer's words, when this leg is a RERUN taken at a previous run's
343
355
  * review door (PRD v1.71 §3.4: *"redo/rerun comes with new authoring"*).
@@ -632,8 +644,10 @@ export function boundedNote(bounded, rounds) {
632
644
  * is STATED as well as enforced (the mailbox precedent again: a law only the validator
633
645
  * knows costs a redraft per draft). The arms without it — `false` and `'diff'` — render
634
646
  * byte-identically to the pre-shim prompt.
647
+ * @param {string|null} [hints] the cross-run learnings block (src/learn.js `renderHints`), verbatim; null/empty
648
+ * renders byte-identically to the prompt without it. Only ever passed for the draft and the first replan.
635
649
  */
636
- export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null, readShim = false) {
650
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}, doorNote = null, readShim = false, hints = null) {
637
651
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
638
652
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
639
653
  // The shape-lottery laws, stated where the check menu is offered (the mailbox
@@ -723,7 +737,7 @@ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
723
737
 
724
738
  Goal:
725
739
  ${job.goal}
726
- ${doorNoteBlock(doorNote)}${materialsBlock(materials)}
740
+ ${doorNoteBlock(doorNote)}${materialsBlock(materials)}${hintsBlock(hints)}
727
741
  Repository survey (from a read-only scout):
728
742
  ${scoutBlob || '(no scout notes)'}`;
729
743
  let p = doc;
@@ -982,12 +996,16 @@ ${scoutBlob || '(no scout notes)'}`;
982
996
  * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
983
997
  * the way a mid-step cap-halt is; resume re-enters that step, or continues the phase). Nothing is in flight at that
984
998
  * point, so the spend is exact. `null` = no stop surface (the default).
999
+ * @param {{text: string, ids: string[]}|null} [opts.learnHints=null] cross-run learnings (src/learn.js `renderHints`, made once at
1000
+ * run start from the ledger as it stood). Handed to the PLANNER only: the first draft and the FIRST replan. No worker ever
1001
+ * receives it, and a later replan gets nothing. Each injection is recorded on the spine as a `learnings` record (the exact
1002
+ * text) so the Audit can show it and the byte check can compare it with a fresh render. `null` = no hints (the default).
985
1003
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
986
1004
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
987
1005
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'provider-red' | 'interpreter-red' |
988
1006
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
989
1007
  */
990
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
1008
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null, learnHints = null }) {
991
1009
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
992
1010
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
993
1011
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -3068,6 +3086,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3068
3086
  // is actually left, which is the adaptation channel.
3069
3087
  const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
3070
3088
  emit('materials', { phase, ...materials });
3089
+ // Cross-run learnings: the planner at the draft and at the FIRST replan, nobody else. The spine keeps the exact text it was
3090
+ // handed (a record, never a verdict), so the Audit can show it and the byte check can compare it with a fresh render.
3091
+ const hints = learnHints && learnHints.text && (phase === 'draft' || (phase === 'replan' && replans === 1)) ? learnHints : null;
3092
+ if (hints) emit('learnings', { phase, text: hints.text, ids: hints.ids, chars: hints.text.length });
3071
3093
  // D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
3072
3094
  // RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
3073
3095
  // answer the replan's question with the material the run just demonstrated does not
@@ -3092,7 +3114,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3092
3114
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
3093
3115
  drafter.setIteration(reds ? 'redraft' : 'draft');
3094
3116
  replanCut = false;
3095
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim), []);
3117
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim, hints?.text ?? null), []);
3096
3118
  if (phase === 'replan' && typeof r.error === 'string' && r.error.startsWith('truncated:')) {
3097
3119
  replanCut = true;
3098
3120
  return '';
@@ -3425,7 +3447,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3425
3447
  // read ONCE: the note and the "was it the attempt just before this one" test
3426
3448
  // are two questions about the same record, and two reads is how they drift
3427
3449
  const bound = w.wasBounded();
3428
- const r = await w.ask([
3450
+ // attempt-diff: what THIS try changed, recorded to the spine only (never a prompt)
3451
+ const tryDiff = await startAttemptDiff({ workdir, fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : fencePrefixes, redact: scrub });
3452
+ let r;
3453
+ try { r = await w.ask([
3429
3454
  step.action,
3430
3455
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
3431
3456
  step.target && `Write your deliverable to: ${resolve(workdir, step.target)}`,
@@ -3437,6 +3462,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3437
3462
  bound?.iteration === iteration - 1 && boundedNote(bound, step.rounds),
3438
3463
  rootInj && rootInj.note,
3439
3464
  ].filter(Boolean).join('\n\n'));
3465
+ } finally {
3466
+ const d = await tryDiff.finish();
3467
+ if (d) emit('attempt-diff', { phase: `step:${step.id}`, step: step.id, iteration, ...d });
3468
+ }
3440
3469
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
3441
3470
  };
3442
3471
  let lastGap = '';
@@ -4172,7 +4201,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4172
4201
  // same iteration numbering as the step loop: `w.setIteration(iteration)` above
4173
4202
  // stamps the bound, so "the attempt just before this one" is `iteration - 1`
4174
4203
  const bound = w.wasBounded();
4175
- await w.ask([
4204
+ const tryDiff = await startAttemptDiff({ workdir, fence: fencePrefixes, redact: scrub });
4205
+ try { await w.ask([
4176
4206
  'The job\'s final verification is failing. Fix the repository so it passes.',
4177
4207
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
4178
4208
  artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
@@ -4182,6 +4212,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4182
4212
  bound?.iteration === iteration - 1 && boundedNote(bound, maxStepRounds),
4183
4213
  rootInj && rootInj.note,
4184
4214
  ].filter(Boolean).join('\n\n'));
4215
+ } finally {
4216
+ const d = await tryDiff.finish();
4217
+ if (d) emit('attempt-diff', { phase: 'fix', step: null, iteration, ...d });
4218
+ }
4185
4219
  };
4186
4220
  fixOutcome = await ralph({
4187
4221
  // the staged close rides in as the JUDGE seam (one verdict, every stage in
@@ -170,8 +170,8 @@ export function endpointOf(provider, baseUrl) {
170
170
  if (raw === '') return '';
171
171
  try {
172
172
  const u = new URL(raw);
173
- return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`;
174
- } catch { return raw.replace(/\/+$/, ''); }
173
+ return `${u.protocol}//${u.host}${u.pathname.replace(/(?<!\/)\/+$/, '')}`;
174
+ } catch { return raw.replace(/(?<!\/)\/+$/, ''); }
175
175
  }
176
176
 
177
177
  /**
@@ -318,7 +318,7 @@ export function keyValueOf(env, envName) {
318
318
  function perMoney(n) {
319
319
  const shown = String(n);
320
320
  if (!shown.includes('e') && (shown.split('.')[1] ?? '').length >= 2) return `$${shown}`;
321
- if (shown.includes('e')) return `$${n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')}`;
321
+ if (shown.includes('e')) return `$${n.toFixed(12).replace(/(?<!0)0+$/, '').replace(/\.$/, '')}`;
322
322
  return `$${n.toFixed(2)}`;
323
323
  }
324
324
 
package/src/run.js CHANGED
@@ -259,6 +259,8 @@ async function primitiveSmoke(workdir) {
259
259
  * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
260
260
  * (`stopFilePath(spine)`, src/legs.js), forwarded verbatim to the plan flow, which reads it at the
261
261
  * round-boundary seam (where the money cap binds) and ends the leg `stopped`. `null` = no stop surface.
262
+ * @param {{text: string, ids: string[]}|null} [opts.learnHints=null] cross-run learnings for the PLANNER only (the draft and the
263
+ * first replan); forwarded verbatim to the plan flow, which records what it handed over as a `learnings` spine record.
262
264
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
263
265
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
264
266
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
@@ -273,7 +275,7 @@ async function primitiveSmoke(workdir) {
273
275
  * deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
274
276
  * unknown in-flight state is not a known-safe resume point.
275
277
  */
276
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
278
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null, learnHints = null }) {
277
279
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
278
280
  // before the ledger, before the approval gate, before a byte of the spec is read.
279
281
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -540,7 +542,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
540
542
  // accounts it natively (F12) and the job-end money contract is unchanged.
541
543
  {
542
544
  const outcome = await runPlan(job, {
543
- workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable, stopFile,
545
+ workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable, stopFile, learnHints,
544
546
  // hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
545
547
  // enforced ceiling is the signed `budgetUsd` (unchanged, still in the
546
548
  // spec hash) MINUS whatever drafting already spent on this job before
@@ -232,7 +232,7 @@ export function startedAuditWords(rec, originNo) {
232
232
 
233
233
  /** a refusal's own words without its trailing "Nothing started." @param {unknown} why */
234
234
  function reasonOf(why) {
235
- return String(why ?? '').replace(/\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
235
+ return String(why ?? '').replace(/(?<!\s)\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
236
236
  }
237
237
 
238
238
  /**
package/src/source.js CHANGED
@@ -1273,7 +1273,7 @@ export async function proveRubricInputs({ inputs, into, destination, fetchUrl =
1273
1273
  labels.add(label.toLowerCase());
1274
1274
  if (isWebUrl(value)) {
1275
1275
  const got = await fetchUrl(value.trim());
1276
- if (!got.ok) return bad('input-url-unreadable', n, `${got.error.replace(/[.\s]+$/, '')}.`);
1276
+ if (!got.ok) return bad('input-url-unreadable', n, `${got.error.replace(/(?<![.\s])[.\s]+$/, '')}.`);
1277
1277
  const pageBuf = Buffer.from(got.text, 'utf8');
1278
1278
  const leak = secretPatternNames(got.text);
1279
1279
  if (leak.length) return bad('input-url-secret', n, `the page at ${got.url} carries a known secret shape (${leak.join(', ')}) - refused, secrets never enter a job.`);
@@ -0,0 +1,145 @@
1
+ // The ONE owner of a stage's plain question ("did it change any file?", "no failing tests?"). Moved out of
2
+ // src/panel/server.js (verbatim) so the Audit/step map AND the learnings cards read the same sentence from the same
3
+ // code — a second spelling is how two screens come to disagree about one check. Pure; no file IO, no model.
4
+
5
+ /** the genre-owned noun for the count a TYPES-genre typecheck stage measures (see {@link stageQuestionText}) */
6
+ export const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
7
+
8
+ /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
9
+ * itself) that two stages measure the SAME command's output, used to tell a
10
+ * genuine typecheck stage apart from the TYPES genre's other `lower-is-
11
+ * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
12
+ * "two assertions"), which shares that stage's own shape but not its
13
+ * population. `null` for a stage whose `cmd` isn't a non-empty string —
14
+ * never matches anything, so it never falsely suppresses a real typecheck
15
+ * question.
16
+ * @param {any} params @returns {string|null} */
17
+ export function cmdKey(params) {
18
+ if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
19
+ const args = Array.isArray(params.args) ? params.args : [];
20
+ return JSON.stringify([params.cmd, args]);
21
+ }
22
+
23
+ /**
24
+ * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
25
+ * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
26
+ * when 5 never ran"). ONE pure function mapping a declared stage's own
27
+ * signed `kind`+`params` (plus the close's own `genre`, also a signed field)
28
+ * to a short plain-English question a person can read without knowing the
29
+ * catalogue — never a second spelling on the client, and never anything the
30
+ * model authored (the catalogue's kinds/params are the whole vocabulary this
31
+ * reads).
32
+ *
33
+ * Every branch is traced straight off `src/kinds.js`'s runner semantics and
34
+ * the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
35
+ * `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
36
+ * - `files-changed` is always the `changed-from-seed` guard: "did it
37
+ * change any file?"
38
+ * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
39
+ * casts or silencers added?"
40
+ * - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
41
+ * - `command-exit` is the genre's `suite-green` stage (the only shipped use
42
+ * of this kind): "does the test suite pass?"
43
+ * - `count-not-worse` splits three ways on its own declared
44
+ * `direction`/`baseline` (never the runtime number — see
45
+ * {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
46
+ * travel separately from the measured figure):
47
+ * - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
48
+ * floor: "did all the old tests still exist?"
49
+ * - `lower-is-better` + `baseline: 0` is the genre's in-scope
50
+ * `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
51
+ * command a `command-exit` stage in the same close already runs
52
+ * (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
53
+ * suite exits clean AND reports zero failing tests — TWO assertions"
54
+ * over ONE population (the same `npm test`/`pytest` invocation), so a
55
+ * `count-not-worse` stage sharing that exact command is the failing-
56
+ * test-count half of that pair, never a typecheck stage: "no failing
57
+ * tests?" — derived from the same cmd+args correlation (the stage
58
+ * counts the suite's own failing tests), never from the stage's own
59
+ * name string. The live check that found this (run mul5fofw's own
60
+ * `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
61
+ * `suite-green` command-exit stage) would otherwise have read "test/
62
+ * has 0 type errors?", which is false: its parser counts FAILING
63
+ * TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
64
+ * name this half honestly rather than reusing "type errors" for a
65
+ * population it was never about, or falling back to the stage's raw
66
+ * name. When the command differs from every sibling `command-exit`
67
+ * stage, the declared `scope.includePrefixes` naming exactly ONE path
68
+ * is quoted verbatim (a signed param, never invented): "<path> has 0
69
+ * type errors?" — otherwise the generic "type errors is 0?", never a
70
+ * guessed filename.
71
+ * - `lower-is-better` + `baseline: 'seed'` with a non-empty
72
+ * `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
73
+ * "other files didn't get more type errors?"
74
+ * The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
75
+ * the only genre this catalogue admits today (doc-genre kinds are not yet
76
+ * built) — an unrecognised genre, or a `count-not-worse` shape none of the
77
+ * three above matches, returns `null` rather than invent a fourth shape.
78
+ *
79
+ * `null` means "no honest plain question" — the caller (buildAttemptsList,
80
+ * partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
81
+ * exactly as it already does for a stage with no resolvable kind at all.
82
+ * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
83
+ * @returns {string|null}
84
+ */
85
+ export function stageQuestionText({
86
+ kind, params, genre, suiteCmdKeys,
87
+ }) {
88
+ if (kind === 'files-changed') return 'did it change any file?';
89
+ if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
90
+ if (kind === 'command-exit') return 'does the test suite pass?';
91
+ // Part 1a: one rubric-step stage = one job line's output file
92
+ if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
93
+ if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
94
+ const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
95
+ if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
96
+ return 'did all the old tests still exist?';
97
+ }
98
+ if (!what) return null;
99
+ const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
100
+ if (params.direction === 'lower-is-better' && params.baseline === 0) {
101
+ const key = cmdKey(params);
102
+ if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
103
+ const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
104
+ if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
105
+ return `${include[0]} has 0 ${what}?`;
106
+ }
107
+ return `${what} is 0?`;
108
+ }
109
+ if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
110
+ const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
111
+ if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
112
+ return null;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Stage name to its plain question, off a signed spec's `closeDecl.stages` — the one pass that gathers every
119
+ * `command-exit` stage's cmd+args FIRST (so the failing-test-count half of `suite-green`'s pair can be told from a
120
+ * genuine typecheck stage) and then asks {@link stageQuestionText} per stage. `null` for a stage with no honest
121
+ * question; an empty map for a spec with no declared stages.
122
+ * @param {any} spec
123
+ * @returns {Map<string, string|null>}
124
+ */
125
+ export function stageQuestionsByName(spec) {
126
+ /** @type {Map<string, string|null>} */
127
+ const out = new Map();
128
+ if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return out;
129
+ const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
130
+ /** @type {Set<string>} */
131
+ const suiteCmdKeys = new Set();
132
+ for (const s of spec.closeDecl.stages) {
133
+ if (s && s.kind === 'command-exit') {
134
+ const key = cmdKey(s.params);
135
+ if (key !== null) suiteCmdKeys.add(key);
136
+ }
137
+ }
138
+ for (const s of spec.closeDecl.stages) {
139
+ if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
140
+ const kind = typeof s.kind === 'string' ? s.kind : null;
141
+ const params = s.params && typeof s.params === 'object' ? s.params : null;
142
+ out.set(s.name, stageQuestionText({ kind, params, genre, suiteCmdKeys }));
143
+ }
144
+ return out;
145
+ }
package/src/text.js CHANGED
@@ -42,10 +42,12 @@ const FENCE_PREAMBLE_LINES = 5;
42
42
  */
43
43
  export function extractArtifact(t) {
44
44
  const text = String(t ?? '');
45
- const m = text.match(/```[^\n`]*\n?([\s\S]*?)```/);
45
+ // (?=([^\n`]*))\1 reads the info string atomically: giving characters back can never find a closing fence the full read missed (a fence starts with a
46
+ // backtick, which the info string excludes), so an unclosed "```aaaa..." fails once instead of once per giveback (quadratic). The body is group 2.
47
+ const m = text.match(/```(?=([^\n`]*))\1\n?([\s\S]*?)```/);
46
48
  if (m) {
47
49
  if (text.slice(0, /** @type {number} */ (m.index)).split('\n').length - 1 < FENCE_PREAMBLE_LINES) {
48
- const code = m[1].trim();
50
+ const code = m[2].trim();
49
51
  return code ? { code, red: null } : { code: null, red: 'empty fenced block' };
50
52
  }
51
53
  // deep fence = the artifact's own content: verbatim, NO fence strips — the