bareloop 0.42.0 → 0.44.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 (79) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/bareloop.context.md +103 -41
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +33 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +6 -3
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/interviewrun.js +0 -1
  14. package/src/job.js +39 -12
  15. package/src/kinds.js +5 -0
  16. package/src/pagedropwords.js +64 -0
  17. package/src/pagemap.js +264 -0
  18. package/src/pagerules.js +319 -0
  19. package/src/panel/authorroutes.js +138 -60
  20. package/src/panel/authorsession.js +88 -32
  21. package/src/panel/index.html +1251 -522
  22. package/src/panel/jobplan.js +4 -2
  23. package/src/panel/runroutes.js +188 -6
  24. package/src/panel/scheduler.js +156 -0
  25. package/src/panel/schedules.js +667 -0
  26. package/src/panel/schedulestart.js +243 -0
  27. package/src/panel/server.js +1234 -68
  28. package/src/panel/status.js +33 -0
  29. package/src/panelmoney.js +29 -0
  30. package/src/planrun.js +92 -11
  31. package/src/promptregisters.js +4 -0
  32. package/src/providerrows.js +3 -3
  33. package/src/replay.js +118 -7
  34. package/src/reviewdoor.js +37 -0
  35. package/src/rubricstep.js +123 -11
  36. package/src/runlist.js +37 -1
  37. package/src/schedulewords.js +299 -0
  38. package/src/source.js +18 -0
  39. package/src/thinking.js +63 -2
  40. package/src/trigger.js +48 -0
  41. package/src/triggerclock.js +127 -0
  42. package/src/triggerfiles.js +215 -0
  43. package/src/triggerledger.js +79 -0
  44. package/src/userrun.js +34 -21
  45. package/types/authorflow.d.ts +42 -6
  46. package/types/authorjob.d.ts +10 -2
  47. package/types/declaredclose.d.ts +3 -1
  48. package/types/draftspend.d.ts +27 -1
  49. package/types/facts.d.ts +15 -0
  50. package/types/factwords.d.ts +26 -3
  51. package/types/inputlook.d.ts +15 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/kinds.d.ts +5 -0
  54. package/types/pagedropwords.d.ts +26 -0
  55. package/types/pagemap.d.ts +154 -0
  56. package/types/pagerules.d.ts +86 -0
  57. package/types/panel/authorroutes.d.ts +50 -14
  58. package/types/panel/authorsession.d.ts +37 -12
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +297 -15
  64. package/types/panel/status.d.ts +51 -0
  65. package/types/panelmoney.d.ts +5 -0
  66. package/types/planrun.d.ts +2 -0
  67. package/types/providerrows.d.ts +2 -2
  68. package/types/replay.d.ts +21 -1
  69. package/types/reviewdoor.d.ts +27 -0
  70. package/types/rubricstep.d.ts +57 -1
  71. package/types/runlist.d.ts +24 -1
  72. package/types/schedulewords.d.ts +137 -0
  73. package/types/source.d.ts +11 -0
  74. package/types/thinking.d.ts +39 -0
  75. package/types/trigger.d.ts +37 -0
  76. package/types/triggerclock.d.ts +52 -0
  77. package/types/triggerfiles.d.ts +93 -0
  78. package/types/triggerledger.d.ts +35 -0
  79. package/types/userrun.d.ts +4 -0
@@ -72,3 +72,36 @@ export function partStatusFor(part) {
72
72
  }
73
73
  return statusFor({ outcome: 'green' });
74
74
  }
75
+
76
+ /**
77
+ * PRD v1.106 Part 5 (C, choice 9): the review door's words, ONE table. The door is the only human step bareloop has; there is no "you
78
+ * cancelled" (cancel is deleted). `decision` is the spine's `door-decision.decision`, or null while nothing has been decided.
79
+ */
80
+ export const DOOR_WORDS = Object.freeze({
81
+ waiting: Object.freeze({ head: 'waiting for you', row: 'paused for you', last: 'none yet' }),
82
+ accept: Object.freeze({ head: 'you accepted', row: 'you accepted', last: 'you accepted' }),
83
+ rerun: Object.freeze({ head: 'you said rerun', row: 'you said rerun', last: 'you said rerun' }),
84
+ pause: Object.freeze({ head: 'paused', row: 'paused', last: 'paused' }),
85
+ });
86
+
87
+ /**
88
+ * The words one door wears: header phrase, the `last answer:` line (with the time `HH:MM` once decided) and the table rows.
89
+ * @param {{at: string|null, paths: string[], decision: string|null, text: string|null, decidedAt: string|null}} door
90
+ * @returns {{ head: string, last: string, rows: Array<{path: string|null, text: string, time: string|null}> }}
91
+ */
92
+ export function doorViewOf(door) {
93
+ const key = door.decision === 'accept' || door.decision === 'rerun' || door.decision === 'pause' ? door.decision : 'waiting';
94
+ const w = DOOR_WORDS[key];
95
+ const path = door.paths.length === 0 ? null : door.paths.join(', ');
96
+ const hhmm = (/** @type {string|null} */ iso) => (iso && /T(\d\d:\d\d)/.exec(iso)?.[1]) || null;
97
+ const said = key === 'rerun' && door.text ? `${w.row}: "${door.text}"` : w.row;
98
+ /** @type {Array<{path: string|null, text: string, time: string|null}>} */
99
+ const rows = [{ path, text: DOOR_WORDS.waiting.row, time: door.at }];
100
+ if (key !== 'waiting') rows.push({ path, text: said, time: door.decidedAt });
101
+ const when = hhmm(door.decidedAt);
102
+ const last = key === 'waiting' ? w.last : `${key === 'rerun' && door.text ? said : w.last}${when ? ` · ${when}` : ''}`;
103
+ return { head: w.head, last, rows };
104
+ }
105
+
106
+ /** the marker a part wears for who did its work (PRD v1.106 Part 5 C/G): the model, or a person asked */
107
+ export const ACTOR_MARKER = Object.freeze({ model: '[model]', human: '[human · ask]' });
@@ -0,0 +1,29 @@
1
+ // The panel's ONE money spelling (hamr's ruling 2026-10-19, PRD "Part 6 rulings" (3)):
2
+ // - an amount of $0.01 or more: 2 decimals, rounded half-up ("$0.82", "$3.71");
3
+ // - a REAL positive amount under a cent: 4 decimals ("$0.0054"), never "<$0.01" and never "$0.00"
4
+ // (one too small for 4 decimals reads "<$0.0001"; one that rounds up to a cent reads "$0.01");
5
+ // - exact zero: "$0.00"; null/undefined/non-finite: "unknown" (never a fabricated $0).
6
+ // `src/panel/index.html` carries a browser-script copy of this same function (`panelMoney`, it
7
+ // cannot import a module); tests/panel-page.test.js pins the two to identical output. The CLI's own
8
+ // 4-decimal `money()` (src/replay.js) is a different, unchanged spelling.
9
+
10
+ /**
11
+ * @param {number|null|undefined} n a dollar amount
12
+ * @returns {string}
13
+ */
14
+ export function panelMoney2(n) {
15
+ if (n === null || n === undefined) return 'unknown';
16
+ const v = Number(n);
17
+ if (!Number.isFinite(v)) return 'unknown';
18
+ // clean to 6-decimal precision first (the precision src/text.js sums to) so a float sitting just
19
+ // under an exact boundary is never mis-rounded
20
+ const clean = Math.round(v * 1e6) / 1e6;
21
+ if (clean > 0 && clean < 0.01) {
22
+ const units = Math.round(clean * 1e4 + 1e-6);
23
+ if (units <= 0) return '<$0.0001';
24
+ if (units >= 100) return '$0.01';
25
+ return `$${(units / 1e4).toFixed(4)}`;
26
+ }
27
+ const cents = Math.max(0, Math.round(clean * 100 + 1e-6));
28
+ return `$${(cents / 100).toFixed(2)}`;
29
+ }
package/src/planrun.js CHANGED
@@ -42,10 +42,11 @@ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
42
42
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
43
43
  import { extractArtifact, rateSourceFields } from './text.js';
44
44
  import { defaultJudgeLoop } from './judged.js';
45
+ import { workerThinkingMode, thinkingCallOptions } from './thinking.js';
45
46
  export { rateSourceFields };
46
47
  import { createClock, isWallTimeout } from './clock.js';
47
48
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
48
- import { inputLabelsOf } from './rubricstep.js';
49
+ import { inputLabelsOf, outputJudgedRecords } from './rubricstep.js';
49
50
  import {
50
51
  seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
51
52
  HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
@@ -541,6 +542,10 @@ export const BOUND_REASON_MAX = 200;
541
542
 
542
543
  /** the recorded reason for a step attempt whose round was cut at the output cap (F122 2A b) */
543
544
  export const TRUNCATED_REASON = 'output cut at the token limit (max_tokens)';
545
+ /** the red fed to the redraft when the first replan answer was cut off (plain words, from code) */
546
+ const REPLAN_CUT_RED = 'Your previous answer was cut off at its length limit before the plan was complete. Answer again with a shorter plan that fits.';
547
+ /** the plain sentence for a replan answer cut off at its length limit (hamr 2026-10-16, run mv9gm796) */
548
+ export const REPLAN_CUT_WORDS = 'The replan answer was cut off at its length limit, so no new plan came back.';
544
549
 
545
550
  /**
546
551
  * What the NEXT attempt is told about the bound that cut the previous one.
@@ -1661,6 +1666,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1661
1666
  * `null` for a command close, which has no baseline to measure against. */
1662
1667
  /** @type {{seedRef: string}|null} */
1663
1668
  let declaredCtx = null;
1669
+ /** @type {number|null} the step try whose checks are running right now (the `rubric-evidence` record's `iteration`); null outside a step's judge */
1670
+ let judgingIteration = null;
1664
1671
  /**
1665
1672
  * ONE close-execution seam, chosen once by which field the SIGNED spec carries.
1666
1673
  * Both executors return `runClose`'s verdict shape, so everything downstream —
@@ -1699,6 +1706,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1699
1706
  // handed through as the FUNCTION (not a snapshot) so a retried locate call
1700
1707
  // re-reads the remaining time rather than reusing the first attempt's.
1701
1708
  callBounds,
1709
+ // REPORT-ONLY: what a rubric-step stage's cited checks read (bareguard `full.evidence`), recorded as `rubric-evidence` for the Result
1710
+ // tab's highlights. No verdict, gap, governor or money path reads it. Masked HERE with the run's one `scrub`: the `text` is a quote from
1711
+ // the frozen page record, and the spine is append-only. Masking moves offsets, so an entry whose text changed keeps NO spans.
1712
+ onRubricEvidence: (/** @type {any} */ e) => emit('rubric-evidence', {
1713
+ step: e.step,
1714
+ ...(judgingIteration !== null ? { iteration: judgingIteration } : {}),
1715
+ evidence: e.evidence.map((/** @type {any} */ x) => {
1716
+ if (typeof x?.text !== 'string') return x;
1717
+ const text = scrub(x.text);
1718
+ const spans = text === x.text && Array.isArray(x.spans) ? x.spans.map((/** @type {any} */ sp) => (typeof sp?.canon === 'string' ? { ...sp, canon: scrub(sp.canon) } : sp)) : [];
1719
+ return { ...x, text, spans };
1720
+ }),
1721
+ ...(e.evidenceTotal !== undefined ? { evidenceTotal: e.evidenceTotal } : {}),
1722
+ }),
1702
1723
  onJudgeCost: (/** @type {any} */ c) => emit('judge-round', {
1703
1724
  stage: c.stage, path: c.path, attempt: c.attempt, label: c.label, model: c.model,
1704
1725
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
@@ -2232,7 +2253,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2232
2253
  * denied, the wallet as its budget), granted tools only (the menu IS the
2233
2254
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
2234
2255
  * with a phase label (F12).
2235
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
2256
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any, truncatedBounded?: boolean}} o
2236
2257
  * `root` (Layer R): when present, the worker's write-class actions are teed
2237
2258
  * for the within-run ratchet — staged before the gate decides, discarded on
2238
2259
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
@@ -2242,7 +2263,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2242
2263
  * STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
2243
2264
  * the turn-allowance note below.
2244
2265
  */
2245
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
2266
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null, truncatedBounded = false }) {
2246
2267
  // THE HARD RULE, ENFORCED STRUCTURALLY (PRD v1.57 §3). This is the ONE seam
2247
2268
  // that grants write-class verbs — `writeScope` is empty for every other
2248
2269
  // worker, so a read-only phase cannot reach a file whatever it intends — and
@@ -2769,6 +2790,27 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2769
2790
  self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
2770
2791
  return self;
2771
2792
  };
2793
+ /**
2794
+ * PRD v1.106 Part 1 — what THIS call sends for thinking. The decision is src/thinking.js `workerThinkingMode` (the one owner: phase,
2795
+ * attempt, the job's tick); the per-call option is `thinkingCallOptions` (support-aware: a model bare-agent says cannot switch, a test
2796
+ * double or a clipipe is sent nothing). The decision is written to the spine BEFORE the call. A step/fix iteration that is not a whole
2797
+ * number throws here as a plain operator error, outside any category map. A transport/rate-limit retry inside the call reuses these
2798
+ * very options (the Proxy retries `generate` with the same args), so it carries the same decision.
2799
+ * @returns {{thinkingMode: 'on'|'off'} | {}}
2800
+ */
2801
+ const thinkingForCall = () => {
2802
+ const mode = workerThinkingMode({ phase, iteration: roundIteration, tick: job.thinking === true });
2803
+ emit('thinking-decision', { phase, iteration: roundIteration ?? null, mode });
2804
+ thinkingSent = mode;
2805
+ return thinkingCallOptions(workerProvider ?? provider, mode);
2806
+ };
2807
+ /** bare-agent's own per-call verdict (applied | unsupported | ignored | unknown), recorded as drafting rounds record it; absent when no switch was sent
2808
+ * @param {any} r a Loop.run() result */
2809
+ const noteThinkingStatus = (r) => {
2810
+ if (typeof r?.thinkingStatus === 'string') emit('thinking-status', { phase, iteration: roundIteration ?? null, mode: thinkingSent, thinkingStatus: r.thinkingStatus });
2811
+ };
2812
+ /** @type {'on'|'off'|null} */
2813
+ let thinkingSent = null;
2772
2814
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
2773
2815
  /**
2774
2816
  * F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
@@ -2780,6 +2822,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2780
2822
  * @param {string} prompt
2781
2823
  */
2782
2824
  const askFrom = async (msgs, prompt) => {
2825
+ const thinkOpts = thinkingForCall();
2783
2826
  try {
2784
2827
  // `msgs` is a prior `loop.run()`'s returned transcript, which already has
2785
2828
  // the Loop's `system` PREPENDED (bare-agent Loop.run, loop.js ~482-486).
@@ -2791,8 +2834,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2791
2834
  // bare-agent's own Loop.chat() (loop.js ~1321) strips a leading system
2792
2835
  // message before re-running for exactly this reason; do the same here.
2793
2836
  const rest = msgs[0]?.role === 'system' ? msgs.slice(1) : msgs;
2794
- const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2837
+ const r = await stallWatch.watch((gen) => newLoop(gen).run([...rest, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds(), ...thinkOpts }));
2795
2838
  throwIfStopped();
2839
+ noteThinkingStatus(r);
2796
2840
  return r;
2797
2841
  } catch (e) {
2798
2842
  throw categorize(e).err;
@@ -2800,9 +2844,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2800
2844
  };
2801
2845
  const ask = async (prompt, defs = toolDefs) => {
2802
2846
  let r;
2847
+ const thinkOpts = thinkingForCall();
2803
2848
  try {
2804
- r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
2849
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds(), ...thinkOpts }));
2805
2850
  throwIfStopped();
2851
+ noteThinkingStatus(r);
2806
2852
  } catch (e) {
2807
2853
  throw categorize(e).err;
2808
2854
  }
@@ -2825,8 +2871,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2825
2871
  // cut round's output is discarded (bare-agent already dropped it), its spend is booked
2826
2872
  // by the per-round meter, the tree is judged as it stands, and the step's strike
2827
2873
  // ladder (or the fix governor) applies unchanged (hamr ruling A extended it to `fix`
2828
- // after run muux1x96). Scout, drafter, replan keep the transport route below.
2829
- if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix')) {
2874
+ // after run muux1x96). hamr 2026-10-16 (run mv9gm796) extended it to the REPLAN call: the
2875
+ // replan drafter opts in with `truncatedBounded`. Scout and the first draft keep the
2876
+ // transport route below (provider-red).
2877
+ if (r.error && r.error.startsWith('truncated:') && (phase.startsWith('step:') || phase === 'fix' || truncatedBounded)) {
2830
2878
  attemptBounded = { iteration: roundIteration, cause: 'truncated', reason: TRUNCATED_REASON };
2831
2879
  emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds, reason: TRUNCATED_REASON, stopReason: r.stopReason ?? null });
2832
2880
  return r;
@@ -3008,9 +3056,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3008
3056
  * the hard line) — a drafter built once at full budget would let the replan
3009
3057
  * draft spend against money the steps had already consumed.
3010
3058
  * @param {string} phase @param {string|null} failure
3011
- * @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
3059
+ * @param {string} [progress] the meter's read, replan only (A's adaptation channel)
3060
+ * @returns {Promise<{ok: boolean, reds: any[], plan?: any, truncated?: boolean}>} */
3012
3061
  const obtainPlan = async (phase, failure, progress) => {
3013
- const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
3062
+ const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false, truncatedBounded: phase === 'replan' });
3063
+ /** the LAST draftPlan call was a replan answer cut off at max_tokens: a bounded failed attempt,
3064
+ * never provider-red (hamr 2026-10-16); reset at the start of every call */
3065
+ let replanCut = false;
3014
3066
  // The BALANCE, read live at each draft — never a snapshot and never a rate
3015
3067
  // (addendum 2). At draft it is the whole budget (scale); at replan it is what
3016
3068
  // is actually left, which is the adaptation channel.
@@ -3039,7 +3091,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3039
3091
  };
3040
3092
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
3041
3093
  drafter.setIteration(reds ? 'redraft' : 'draft');
3094
+ replanCut = false;
3042
3095
  const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts, doorWords?.text ?? null, readShim), []);
3096
+ if (phase === 'replan' && typeof r.error === 'string' && r.error.startsWith('truncated:')) {
3097
+ replanCut = true;
3098
+ return '';
3099
+ }
3043
3100
  return extractArtifact(r.text).code ?? '';
3044
3101
  };
3045
3102
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -3056,10 +3113,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3056
3113
  return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
3057
3114
  };
3058
3115
  let text = await draftPlan(null);
3059
- let pv = validate(text);
3116
+ // A cut-off replan answer is treated like a BAD one: its half text is never validated (judging
3117
+ // half an artifact would mint a plan-red the drafter never earned), it becomes one plain-words
3118
+ // red fed to the redraft, and a cut REDRAFT (whatever the first answer was) is the bounded
3119
+ // failed attempt the caller escalates in words.
3120
+ let pv = replanCut
3121
+ ? { ok: false, reds: [{ code: 'parse-error', path: '$', detail: REPLAN_CUT_RED }], plan: null }
3122
+ : validate(text);
3060
3123
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
3061
3124
  if (!pv.ok) {
3062
3125
  text = await draftPlan(pv.reds);
3126
+ if (replanCut) return { ok: false, reds: [], truncated: true };
3063
3127
  pv = validate(text);
3064
3128
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
3065
3129
  }
@@ -3377,7 +3441,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3377
3441
  };
3378
3442
  let lastGap = '';
3379
3443
  const judge = async () => {
3380
- const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
3444
+ judgingIteration = iterationNow;
3445
+ /** @type {any} */
3446
+ let evaled;
3447
+ try { evaled = await evalExits(step.exit, { dir: workdir, snapshot, runCheck }); } finally { judgingIteration = null; }
3448
+ const { pass, results: raw } = evaled;
3381
3449
  // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
3382
3450
  // supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
3383
3451
  // message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
@@ -3392,6 +3460,9 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3392
3460
  // results makes the leak inexpressible; scrubbing each consumer makes it
3393
3461
  // something the next consumer has to remember.
3394
3462
  const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
3463
+ // PRD v1.106 Part 5: what the check just read (path, size, sha256 — never the content), recorded BEFORE the exit-eval because the next try
3464
+ // overwrites the file. The worker is idle between its turn and this judge, so these are the bytes the check read.
3465
+ for (const o of await outputJudgedRecords(job.closeDecl, workdir, step.exit)) emit('output-judged', { step: step.id, iteration: iterationNow, ...o });
3395
3466
  emit('exit-eval', { step: step.id, iteration: iterationNow, results });
3396
3467
  // an instrument fault rides out by its runClose verdict NAME: ralph
3397
3468
  // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
@@ -3659,6 +3730,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
3659
3730
  planExecuted();
3660
3731
  return relay(e, 'replan');
3661
3732
  }
3733
+ if (pv.truncated) {
3734
+ // a bounded failed attempt in plain words, filed as plan-red (a judged no, not a casualty)
3735
+ // with the code-owned sentence on the escalation; the panel reads `cause` to say it
3736
+ emit('escalation', {
3737
+ category: 'plan-red', cause: 'truncated', decisionReady: true, phase: 'replan',
3738
+ decision: REPLAN_CUT_WORDS, options: ['change the job and run it again', 'abandon the run'], detail: TRUNCATED_REASON,
3739
+ });
3740
+ planExecuted();
3741
+ return 'plan-red';
3742
+ }
3662
3743
  if (!pv.ok) {
3663
3744
  for (const r of pv.reds) emit('plan-red', r);
3664
3745
  planExecuted();
@@ -61,6 +61,10 @@ export const PROMPT_REGISTERS = Object.freeze([
61
61
  Object.freeze({ file: 'src/cardauthor.js', name: 'COMPILE_SYSTEM' }),
62
62
  Object.freeze({ file: 'src/cardauthor.js', name: 'cardCasesPrompt() (inline template, not a const)' }),
63
63
 
64
+ // src/pagemap.js - the page-word map call (idea A core): the AI names the page's own words for the person's job
65
+ Object.freeze({ file: 'src/pagemap.js', name: 'MAP_SYSTEM' }),
66
+ Object.freeze({ file: 'src/pagemap.js', name: 'mapPrompt() (inline template, not a const)' }),
67
+
64
68
  // src/readshim.js — the capped/deduplicating read shim's worker-facing strategy
65
69
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_STRATEGY' }),
66
70
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_DIFF_STRATEGY' }),
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
11
11
  // the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
12
- // `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
12
+ // `keyValueOf` / `modelChoiceFor` below — no second lookup exists. Key VALUES
13
13
  // never pass through here — only names.
14
14
  import { readConfig } from './config.js';
15
15
  import { filledKeyNames } from './keysfile.js';
@@ -388,8 +388,8 @@ export function judgeRowFor(rows, judge, workerProvider, workerRow) {
388
388
  * the worker's own provider it calls the worker's endpoint, so a row on that endpoint with a line
389
389
  * NAMED for the judge's model prices it ({@link ratesFor}); a judge model that is absent or names
390
390
  * no line there keeps the worker's price (the worker's row, or none = the guess). A different
391
- * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`applyConfiguredKey`,
392
- * by the worker's model), so the two can come from different rows.
391
+ * provider resolves with no endpoint. The judge's KEY is chosen elsewhere (`judgeRowFor`:
392
+ * the worker's own row when the judge shares its provider), so the two can come from different rows.
393
393
  * @param {{ provider: string, model?: string|null }} judge
394
394
  * @param {string|null|undefined} workerProvider
395
395
  * @param {string|null|undefined} workerBaseUrl
package/src/replay.js CHANGED
@@ -35,6 +35,8 @@
35
35
  // `plan` / `fix` for the other worker phases — src/planrun.js:2735,2515,3359.
36
36
  // `costUsd` is `arg?.costUsd ?? null` — a null is an HONEST unpriced round
37
37
  // (F6), never summed as $0 by this module.
38
+ // rubric-evidence {step, iteration?, evidence, evidenceTotal?} REPORT-ONLY: what a rubric step's cited checks read (bareguard
39
+ // `full.evidence`); read by the Result tab only, by no verdict, governor or money path. src/planrun.js onRubricEvidence
38
40
  // exit-eval {step, iteration, results: [{type, pass, detail?}]}
39
41
  // src/planrun.js:2897
40
42
  // each `results[i].type` is one of plan.js's EXIT_TYPES; `check-passes`
@@ -105,6 +107,7 @@ import { runBehaviour, formatBehaviour } from './behaviour.js';
105
107
  import { shortSha } from './codeversion.js';
106
108
  import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
107
109
  import { legsOf, capKindOf } from './legs.js';
110
+ import { thinkingWordsFrom } from './thinking.js';
108
111
 
109
112
  /**
110
113
  * @param {unknown} e
@@ -586,6 +589,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
586
589
  // re-spelled so the two can never drift apart.
587
590
  const spendRecords = spine.filter((e) => SPEND_RECORD_TYPES.includes(e.type));
588
591
  const exitEvals = spine.filter((e) => e.type === 'exit-eval');
592
+ // PRD v1.106 Part 5 (D1): which check each `check-passes` result was — the `check-run` record just before its exit-eval
593
+ const checkRuns = spine.filter((e) => e.type === 'check-run');
594
+ // the file each try's check read (new runs only; an old spine has none, and then no judged-file line is shown)
595
+ const outputJudgedRecs = spine.filter((e) => e.type === 'output-judged');
596
+ // D2: the iterations that began (a try with an iteration-start and no exit-eval was cut off before its checks ran)
597
+ const iterationStarts = spine.filter((e) => e.type === 'iteration-start');
589
598
 
590
599
  // F119: every `transport-retry` record on this spine, and the (derived,
591
600
  // never guessed) reason(s) behind a `spendComplete:false` job-end — see
@@ -800,7 +809,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
800
809
  // `/api/runs/:runid/rounds` endpoint (`src/panel/server.js`) to slice
801
810
  // this attempt's own rounds/tool-calls out of the full spine+gate-audit,
802
811
  // never recomputed a second way there either.
803
- /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null}>} */
812
+ /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null, checks: Array<{type: string, pass: boolean, detail: string|null, name: string|null}>, outputJudged: Array<{path: string|null, bytes: number|null, sha256: string|null}>}>} */
804
813
  const attemptWindows = [];
805
814
  const stepExitEvals = exitEvals
806
815
  .filter((ee) => ee.step === id && typeof ee.seq === 'number' && ee.seq > startSeq && ee.seq < endSeq)
@@ -827,6 +836,27 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
827
836
  iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
828
837
  outcome: allPass ? 'green' : 'red',
829
838
  });
839
+ // the try's checks AS THEY RAN (D1): each exit-eval result in order; a `check-passes` result is named by the `check-run`
840
+ // record just before this exit-eval (same window), taken in order. `detail` is already scrubbed at capture.
841
+ const lo = prevBoundSeq;
842
+ const namesHere = checkRuns
843
+ .filter((c) => typeof c.seq === 'number' && c.seq > lo && c.seq < eeSeq && typeof c.name === 'string')
844
+ .sort((a, b) => a.seq - b.seq);
845
+ let nameAt = 0;
846
+ const tryChecks = results.map((r) => {
847
+ const type = typeof r.type === 'string' ? r.type : 'unknown';
848
+ const name = type === 'check-passes' ? (namesHere[nameAt++]?.name ?? null) : null;
849
+ return {
850
+ type, pass: r.pass === true, detail: typeof r.detail === 'string' ? r.detail : null, name,
851
+ };
852
+ });
853
+ const judgedHere = outputJudgedRecs
854
+ .filter((o) => o.step === id && (typeof ee.iteration !== 'number' || o.iteration === ee.iteration) && typeof o.seq === 'number' && o.seq > lo && o.seq < eeSeq)
855
+ .map((o) => ({
856
+ path: typeof o.path === 'string' ? o.path : null,
857
+ bytes: typeof o.bytes === 'number' ? o.bytes : null,
858
+ sha256: typeof o.sha256 === 'string' ? o.sha256 : null,
859
+ }));
830
860
  attemptWindows.push({
831
861
  n: attemptWindows.length + 1,
832
862
  iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
@@ -836,11 +866,30 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
836
866
  startTs: prevBoundTs,
837
867
  endTs: eeTs,
838
868
  exitEvalDetail: detailSource && typeof detailSource.detail === 'string' ? detailSource.detail : null,
869
+ checks: tryChecks,
870
+ outputJudged: judgedHere,
839
871
  });
840
872
  prevBoundSeq = eeSeq;
841
873
  prevBoundTs = eeTs ?? prevBoundTs;
842
874
  }
843
875
 
876
+ // D2: a try that BEGAN (an `iteration-start` after the last exit-eval) in a step that has ended, and never reached its checks, was cut off
877
+ // (the wall, a cap, a stop). A step still running has an in-flight try, not a cut-off one. Never a numbered attempt: it ran no check.
878
+ const endedHere = end !== null || stopReason !== null;
879
+ const lastIter = iterationStarts
880
+ .filter((it) => typeof it.seq === 'number' && it.seq > prevBoundSeq && it.seq < endSeq)
881
+ .sort((a, b) => b.seq - a.seq)[0] ?? null;
882
+ const cutOffTry = endedHere && lastIter
883
+ ? {
884
+ n: attemptWindows.length + 1,
885
+ iteration: typeof lastIter.iteration === 'number' ? lastIter.iteration : null,
886
+ startSeq: prevBoundSeq,
887
+ endSeq: end && typeof end.seq === 'number' ? end.seq : (Number.isFinite(endSeq) ? endSeq : prevBoundSeq),
888
+ startTs: prevBoundTs,
889
+ endTs,
890
+ }
891
+ : null;
892
+
844
893
  idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
845
894
  // the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
846
895
  // halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
@@ -859,6 +908,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
859
908
  treeChanged,
860
909
  attempts,
861
910
  attemptWindows,
911
+ cutOffTry,
862
912
  stepStartSeq: startSeq,
863
913
  stepEndSeq: endSeq,
864
914
  // `stepStartTs`/`stepEndTs` (panel item 2 follow-up, F195/F-audit-step):
@@ -877,7 +927,6 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
877
927
  // `phase` filter on the rounds: the seq window between one iteration-start
878
928
  // and the next close-verdict/run-end/escalation already isolates that
879
929
  // iteration's own rounds.
880
- const iterationStarts = spine.filter((e) => e.type === 'iteration-start');
881
930
  const timelineKind = /** @type {'steps'|'iterations'} */ (stepStarts.length > 0 ? 'steps' : (iterationStarts.length > 0 ? 'iterations' : 'steps'));
882
931
  const boundaries = spine.filter((e) => e.type === 'close-verdict' || e.type === 'run-end' || e.type === 'escalation');
883
932
 
@@ -1320,7 +1369,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1320
1369
  // same as a scout/plan/replan/judge/run part already does.
1321
1370
  const stepAttempts = step.attemptWindows.length > 0
1322
1371
  ? step.attemptWindows.map((aw) => ({
1323
- n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs,
1372
+ n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs, iteration: aw.iteration, checks: aw.checks, outputJudged: aw.outputJudged,
1324
1373
  }))
1325
1374
  : wholePartAttempt(step.stepStartSeq, step.stepStartTs, step.stepEndSeq, step.stepEndTs);
1326
1375
  parts.push({
@@ -1340,6 +1389,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1340
1389
  // P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
1341
1390
  // from the leg before, and its retry count not counting continuations
1342
1391
  ...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
1392
+ ...(step.cutOffTry ? { cutOffTry: step.cutOffTry } : {}),
1343
1393
  attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
1344
1394
  });
1345
1395
  }
@@ -1420,6 +1470,63 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
1420
1470
  }
1421
1471
  }
1422
1472
 
1473
+ // PRD v1.106 Part 5 (C, choice 9): a run that reached its review door gets one more part, the only human step bareloop has. `door` carries what
1474
+ // the spine recorded (when it opened, which files, what the person decided and when); the panel server words it (src/panel/status.js).
1475
+ const doorRec = spine.findLast((e) => e.type === 'review-door');
1476
+ if (doorRec) {
1477
+ const decided = spine.findLast((e) => e.type === 'door-decision' && e.honoured === true && typeof e.seq === 'number' && typeof doorRec.seq === 'number' && e.seq > doorRec.seq);
1478
+ const paths = Array.isArray(doorRec.changed?.paths) ? doorRec.changed.paths.filter((/** @type {any} */ x) => typeof x === 'string') : [];
1479
+ parts.push({
1480
+ id: 'door',
1481
+ kind: 'door',
1482
+ label: 'door',
1483
+ number: null,
1484
+ occurrence: null,
1485
+ rounds: 0,
1486
+ toolCalls: 0,
1487
+ byTool: null,
1488
+ blocked: 0,
1489
+ wallMs: null,
1490
+ spentUsd: 0,
1491
+ unpricedRounds: 0,
1492
+ outcome: null,
1493
+ attempts: [],
1494
+ door: {
1495
+ at: typeof doorRec.ts === 'string' ? doorRec.ts : null,
1496
+ paths,
1497
+ decision: decided && typeof decided.decision === 'string' ? decided.decision : null,
1498
+ text: decided && typeof decided.text === 'string' ? decided.text : null,
1499
+ decidedAt: decided && typeof decided.ts === 'string' ? decided.ts : null,
1500
+ },
1501
+ });
1502
+ }
1503
+
1504
+ // PRD v1.106 Part 3: the thinking each call was SENT, from the spine's own `thinking-decision` / `thinking-status` records, worded by
1505
+ // src/thinking.js. A part with one attempt (plan, scout, replan, a single-attempt step) carries it as `part.thinking`; an attempt that made a
1506
+ // worker call carries `attempt.thinking`. A spine with no such record gets no field at all (an older run shows no thinking word).
1507
+ if (spine.some((e) => e.type === 'thinking-decision')) {
1508
+ const firstStepSeq = stepStarts.reduce((m, ss) => (typeof ss.seq === 'number' && ss.seq < m ? ss.seq : m), Infinity);
1509
+ for (const p of parts) {
1510
+ const atts = Array.isArray(p.attempts) ? p.attempts : [];
1511
+ for (const a of atts) {
1512
+ let words = null;
1513
+ if (p.kind === 'step') words = thinkingWordsFrom(spine, { phase: `step:${p.label}`, lo: a.startSeq, hi: a.endSeq });
1514
+ else if (p.kind === 'fix') words = thinkingWordsFrom(spine, { phase: 'fix', lo: a.startSeq, hi: a.endSeq });
1515
+ if (words !== null) a.thinking = words;
1516
+ }
1517
+ if (p.kind === 'step' && p.cutOffTry) {
1518
+ const words = thinkingWordsFrom(spine, { phase: `step:${p.label}`, lo: p.cutOffTry.startSeq, hi: p.cutOffTry.endSeq });
1519
+ if (words !== null) p.cutOffTry.thinking = words;
1520
+ }
1521
+ let part = null;
1522
+ if (p.kind === 'scout') part = thinkingWordsFrom(spine, { phase: 'scout', hi: firstStepSeq });
1523
+ else if (p.kind === 'plan') part = thinkingWordsFrom(spine, { phase: 'plan', hi: firstStepSeq });
1524
+ else if (p.kind === 'replan' && atts[0]) part = thinkingWordsFrom(spine, { phase: 'plan', lo: atts[0].startSeq, hi: atts[0].endSeq });
1525
+ else if ((p.kind === 'step' || p.kind === 'fix') && atts.length === 1) part = atts[0].thinking ?? null;
1526
+ if (part !== null) p.thinking = part;
1527
+ }
1528
+ }
1529
+
1423
1530
  // P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
1424
1531
  // no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
1425
1532
  // marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
@@ -1587,16 +1694,20 @@ function money2(n) {
1587
1694
  * @param {boolean|null} [draftSpendComplete] whether the drafting fold above was EXACT;
1588
1695
  * `null`/`undefined`/`true` all read as exact (never a new enum value elsewhere) —
1589
1696
  * only an explicit `false` triggers the floor wording.
1697
+ * @param {number|null} [runUsd] the run's own spend as a number (excluding drafting), so the sum can be written
1590
1698
  * @returns {string}
1591
1699
  */
1592
- export function moneyWithDraft(spendCore, draftSpentUsd, budgetUsd, draftSpendComplete) {
1700
+ export function moneyWithDraft(spendCore, draftSpentUsd, budgetUsd, draftSpendComplete, runUsd) {
1593
1701
  const hasDraft = draftSpentUsd !== null && draftSpentUsd !== undefined;
1594
1702
  const draftFloor = hasDraft && draftSpendComplete === false;
1595
1703
  const coreAlreadyFloor = /^(at least |≥)/.test(spendCore);
1596
1704
  const core = draftFloor && !coreAlreadyFloor ? `at least ${spendCore}` : spendCore;
1597
- const draftPart = hasDraft ? ` (${draftFloor ? 'at least ' : ''}${money(draftSpentUsd)} drafting)` : '';
1598
1705
  const capPart = budgetUsd === undefined ? '' : ` of ${money2(budgetUsd)}`;
1599
- return `${core}${draftPart}${capPart}`;
1706
+ if (!hasDraft) return `${core}${capPart}`;
1707
+ // PRD v1.106 Part 5 Q4: the run's figure EXCLUDES drafting, so the line says so and adds the sum — "$0.0054 run + $0.0035 drafting =
1708
+ // $0.0089 of $1.00". A floor on either part makes the total a floor too ("at least"). Without the run's number the sum is left out.
1709
+ const total = typeof runUsd === 'number' && Number.isFinite(runUsd) ? ` = ${draftFloor || coreAlreadyFloor ? 'at least ' : ''}${money(runUsd + draftSpentUsd)}` : '';
1710
+ return `${core} run + ${draftFloor ? 'at least ' : ''}${money(draftSpentUsd)} drafting${total}${capPart}`;
1600
1711
  }
1601
1712
 
1602
1713
  /**
@@ -2045,7 +2156,7 @@ export function summarizeForAllLine(summary) {
2045
2156
  // shown on a run with zero retries.
2046
2157
  outcome: `${summary.outcome ?? 'unknown'}${summary.transportRetryCount > 0 ? ` ⟲${summary.transportRetryCount}` : ''}`,
2047
2158
  hadTransportRetries: summary.transportRetryCount > 0,
2048
- spend: moneyWithDraft(money(summary.spentUsd), summary.draftSpentUsd, undefined, summary.draftSpendComplete),
2159
+ spend: moneyWithDraft(money(summary.spentUsd), summary.draftSpentUsd, undefined, summary.draftSpendComplete, summary.spentUsd),
2049
2160
  wall: duration(summary.wallMs),
2050
2161
  steps: stepsCol,
2051
2162
  reason,
package/src/reviewdoor.js CHANGED
@@ -50,6 +50,43 @@ export function doorRecordOf(events) {
50
50
  return events.findLast?.((e) => e?.type === REVIEW_DOOR) ?? null;
51
51
  }
52
52
 
53
+ /**
54
+ * Was this run's door ACCEPTED, honoured? A `door-decision` record with `decision: 'accept'` and `honoured: true`
55
+ * (written by {@link answerReviewDoor}, whichever surface asked: the CLI, or the panel's Accept). Pure, off the
56
+ * run's spine. A refused accept (`honoured: false`) does not count.
57
+ * @param {any[]} events the run's spine, parsed
58
+ * @returns {boolean}
59
+ */
60
+ export function doorAccepted(events) {
61
+ return Array.isArray(events) && events.some((e) => e?.type === 'door-decision' && e.decision === 'accept' && e.honoured === true);
62
+ }
63
+
64
+ /**
65
+ * T D.2 — a SCHEDULED run's green is held until the person accepts it (T10): a run that carries a `trigger.json` (a scheduled
66
+ * start wrote it) and holds no honoured accept. One shared predicate: `getStartFrom` reads its outcome as `'green-held'` (so
67
+ * the Reuse start refuses it unchanged), `listReuseJobs` skips it, and the schedule gate does not count it. A hand run is never held.
68
+ * @param {any[]} events the run's spine, parsed @param {any} triggerRec the run's `trigger.json`, or null
69
+ * @returns {boolean}
70
+ */
71
+ export function heldScheduled(events, triggerRec) {
72
+ return !!triggerRec && typeof triggerRec === 'object' && !doorAccepted(events);
73
+ }
74
+
75
+ /**
76
+ * Is this run WAITING for the person's Accept? Its spine holds a door record, the door has not expired ({@link doorAgeGate}),
77
+ * and no honoured `accept` or `rerun` decision follows it (a `pause` keeps the same three doors open). One owner: the runs
78
+ * list's Needs-you `Accept` tag, the Run tab's Accept button and the scheduled NEXT line all read this.
79
+ * @param {any[]} events the run's spine, parsed
80
+ * @param {{now?: () => number}} [opts]
81
+ * @returns {boolean}
82
+ */
83
+ export function doorWaiting(events, opts = {}) {
84
+ const door = doorRecordOf(events);
85
+ if (!door || !doorAgeGate(door, opts.now ? { now: opts.now } : {}).ok) return false;
86
+ const at = events.lastIndexOf(door);
87
+ return !events.slice(at + 1).some((e) => e?.type === 'door-decision' && e.honoured === true && (e.decision === 'accept' || e.decision === 'rerun'));
88
+ }
89
+
53
90
  /**
54
91
  * IS THE DOOR STILL OPEN? The same 60-day TTL a hitl checkpoint keeps
55
92
  * (`PAUSE_TTL_MS`) and deliberately the same NUMBER — the door replaced `cancel`