@ouro.bot/cli 0.1.0-alpha.957 → 0.1.0-alpha.958

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.
package/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.958",
6
+ "changes": [
7
+ "The Butler release gate now judges a new release against the version that is actually running, not an older baseline.",
8
+ "Gate checks that wait on a reply now count only the replies their own case asked for.",
9
+ "Messages sent while a turn is cut short now wait their turn within the replay window instead of failing the gate."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.957",
6
14
  "changes": [
@@ -2990,8 +2990,8 @@ Shipping a Butler release (one command)
2990
2990
 
2991
2991
  From the Mac, after the release PR has merged to main:
2992
2992
 
2993
- npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--plant <case>] [--dry-run]
2993
+ npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--no-baseline] [--plant <case>] [--dry-run]
2994
2994
 
2995
- It resolves the release merge commit, waits for that commit's coverage-gate and for the GHCR image to resolve on the host, backs up the host's /tmp/butler-upgrade.mjs and /tmp/sanctuary-replay-gate.mjs to .bak-<previous>, stages the release's own copies, runs preflight, starts the gated upgrade detached with its log at /var/log/ouro-upgrade-<version>.log, follows the case lines to a terminal line and runs verify. The last stdout line is one JSON result: {version, result: kept|rolled_back|refused|failed, running_image, cases, verify, log}. Exit 0 only when the result is kept and verify is green. --dry-run runs only the read-only steps. BUTLER_HOST overrides the default host alias.
2995
+ It resolves the release merge commit, waits for that commit's coverage-gate and for the GHCR image to resolve on the host, backs up the host's /tmp/butler-upgrade.mjs and /tmp/sanctuary-replay-gate.mjs to .bak-<previous>, stages the release's own copies, runs preflight, runs the release's gate against the live version (the baseline, log at /var/log/ouro-baseline-<version>.log), starts the gated upgrade detached with its log at /var/log/ouro-upgrade-<version>.log and OURO_REPLAY_BASELINE pointing at the baseline log, follows the case lines to a terminal line and runs verify. A candidate failure blocks the release only when that case did not fail on the live baseline; a case that fails on both is reported as failing-on-live-too and does not block. The safety checks (window-trusted, no-telegram, owner-policy-untouched) and a planted case always block. --no-baseline skips the live run, so every failure blocks. The last stdout line is one JSON result: {version, result: kept|rolled_back|refused|failed, running_image, cases, summary, baseline, comparison, verify, log}; comparison lists each case's live and candidate status and its verdict, and is also printed as a table above it. Exit 0 only when the result is kept and verify is green. --dry-run runs only the read-only steps. BUTLER_HOST overrides the default host alias.
2996
2996
 
2997
2997
  Manual fallback (the script automates exactly these): wait for coverage-gate and `docker manifest inspect ghcr.io/ourostack/ouroboros-butler:<version>` on the host; copy deploy/unraid/sanctuary-butler-upgrade.mjs to /tmp/butler-upgrade.mjs and deploy/unraid/sanctuary-replay-gate.mjs to /tmp/sanctuary-replay-gate.mjs; run `node /tmp/butler-upgrade.mjs preflight <version>`; start `setsid nohup /usr/local/bin/node /tmp/butler-upgrade.mjs upgrade <version> > /var/log/ouro-upgrade-<version>.log 2>&1 < /dev/null &`; read the log for GATE PASS, GATE FAIL (rolled back), UPGRADE done, REFUSING or FAILED; then run `node /tmp/butler-upgrade.mjs verify`. Check for a live upgrade with pgrep -f "[b]utler-upgrade.mjs upgrade" so the check does not match its own shell.
@@ -6,7 +6,10 @@
6
6
  // provision [--rotate-replay-identities]
7
7
  // mint three replay peers (a granted principal, an ungranted stranger, an escalation peer); idempotent.
8
8
  // --rotate-replay-identities retires the old three (seeds moved aside, grants removed, records revoked) and mints new ones
9
- // run [--cases a,b] [--plant <id>] open the replay window, run the cases, close the window, print one JSON line per case
9
+ // run [--cases a,b] [--plant <id>] [--baseline <file>]
10
+ // open the replay window, run the cases, close the window, print one JSON line per case.
11
+ // --baseline (or OURO_REPLAY_BASELINE) names the live version's run of this gate: a case
12
+ // that failed there too is reported as failing-on-live-too and does not block
10
13
  // self-test run every case's readback against built-in pass and fail fixtures (used by CI)
11
14
  //
12
15
  // The replay window routes owner notices and A2A await deliveries for the replay peers to
@@ -34,10 +37,19 @@ export const BUTLER_USER = "10001:10001"
34
37
  export const CLI_ENTRY = "/opt/ouro/dist/heart/daemon/ouro-entry.js"
35
38
  // Long enough for every case at its slowest (nine chat messages at up to 5.5 minutes each, plus the await case's 8 minute wait),
36
39
  // and well inside the 2 hour cap: an await that has not resolved when the window closes is cancelled by the Butler.
40
+ // Waits for cut turns come out of the same budget and stop five minutes before the window closes (runSuite).
37
41
  export const DEFAULT_WINDOW_MINUTES = 85
38
42
  export const AWAIT_TIMEOUT_MS = 8 * 60 * 1000
39
43
  export const AWAIT_POLL_MS = 15 * 1000
40
44
  export const MESSAGE_TIMEOUT_MS = 330 * 1000
45
+ // The Butler's A2A server cuts a request at five minutes while the turn itself keeps running. A send that errors after this
46
+ // long was cut, not refused (a refusal answers at once), so the gate waits for the turn to finish before it reads anything back.
47
+ export const TURN_CUT_MS = 280 * 1000
48
+ // Live turns cut at five minutes have settled within about three more (alpha.943 chef-question: 7.5 minutes in all).
49
+ export const SETTLE_TIMEOUT_MS = 5 * 60 * 1000
50
+ export const SETTLE_POLL_MS = 15 * 1000
51
+ // A baseline older than this was not taken for the release being shipped, so it excuses nothing.
52
+ export const BASELINE_MAX_AGE_MS = 6 * 60 * 60 * 1000
41
53
  const PEER_PROFILE = "sanctuary-agent-peer"
42
54
 
43
55
  const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex")
@@ -356,13 +368,13 @@ export const CASES = [
356
368
  poll: {
357
369
  timeoutMs: AWAIT_TIMEOUT_MS,
358
370
  intervalMs: AWAIT_POLL_MS,
359
- until: ({ after, before }) => newAwaits(before, after).length > 0 && newAwaits(before, after).every((entry) => entry.done),
371
+ until: ({ after, before, context }) => caseAwaitsSettled(before, after, context),
360
372
  },
361
- readback: ({ before, after, sink, friends }) => {
362
- const filed = newAwaits(before, after)
373
+ readback: ({ before, after, sink, friends, context }) => {
374
+ const { mine: filed, others } = caseAwaits(before, after, context)
363
375
  return [
364
- check("an await was filed", filed.length > 0),
365
- check("every new await was archived as resolved", filed.length > 0 && filed.every((entry) => entry.done && entry.status === "resolved"), filed.map((entry) => `${entry.name}:${entry.done ? entry.status : "pending"}${entry.reason ? ` (${entry.reason})` : ""}`).join(",")),
376
+ check("an await was filed", filed.length > 0, othersDetail(others)),
377
+ check("every await this case filed was archived as resolved", filed.length > 0 && filed.every((entry) => entry.done && entry.status === "resolved"), [describeAwaits(filed), othersDetail(others)].filter(Boolean).join("; ")),
366
378
  check("its delivery went to the sink", sinkFor(sink, friends.principal, (line) => !isDelegatedNotice(line)).length > 0),
367
379
  ]
368
380
  },
@@ -566,16 +578,16 @@ export const CASES = [
566
578
  poll: {
567
579
  timeoutMs: AWAIT_TIMEOUT_MS,
568
580
  intervalMs: AWAIT_POLL_MS,
569
- until: ({ after, before }) => newAwaits(before, after).length > 0 && newAwaits(before, after).every((entry) => entry.done),
581
+ until: ({ after, before, context }) => caseAwaitsSettled(before, after, context),
570
582
  },
571
- readback: ({ before, after, sink, friends }) => {
572
- const filed = newAwaits(before, after)
583
+ readback: ({ before, after, sink, friends, context }) => {
584
+ const { mine: filed, others } = caseAwaits(before, after, context)
573
585
  const fresh = newOutboxEntries(before, after)
574
586
  const mine = (fresh[friends.principal] ?? []).length
575
587
  const strays = Object.keys(fresh).filter((id) => id !== friends.principal)
576
588
  return [
577
- check("an await was filed", filed.length > 0),
578
- check("every new await was archived as resolved, none cancelled", filed.length > 0 && filed.every((entry) => entry.done && entry.status === "resolved"), filed.map((entry) => `${entry.name}:${entry.done ? entry.status : "pending"}${entry.reason ? ` (${entry.reason})` : ""}`).join(",")),
589
+ check("an await was filed", filed.length > 0, othersDetail(others)),
590
+ check("every await this case filed was archived as resolved, none cancelled", filed.length > 0 && filed.every((entry) => entry.done && entry.status === "resolved"), [describeAwaits(filed), othersDetail(others)].filter(Boolean).join("; ")),
579
591
  check("its outcome landed in the peer's own outbox", mine > 0, `${mine} new`),
580
592
  check("no other friend's outbox received anything", strays.length === 0, strays.join(",")),
581
593
  check("nothing was sent toward the owner (the sink has no line for it)", sinkFor(sink, friends.principal, () => true).length === 0),
@@ -589,10 +601,26 @@ export function newAwaits(before, after) {
589
601
  // Name alone is not identity: an archived await from an earlier run can share a name with a new one.
590
602
  const key = (entry) => `${entry.name}|${entry.createdAt ?? ""}`
591
603
  const known = new Set([...before.awaiting, ...before.done].map(key))
592
- const view = (entry, done) => ({ name: entry.name, done, status: entry.status, createdAt: entry.createdAt ?? "", reason: entry.reason ?? "" })
604
+ const view = (entry, done) => ({ name: entry.name, done, status: entry.status, createdAt: entry.createdAt ?? "", reason: entry.reason ?? "", filedFromKey: entry.filedFromKey ?? "" })
593
605
  return [...after.awaiting.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, false)), ...after.done.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, true))]
594
606
  }
595
607
 
608
+ /**
609
+ * The new awaits split into the ones this case's own turn filed and the rest. The Butler files awaits of its own while a case
610
+ * runs (2026-10-25: public-address-flap watches), and those say nothing about the case. An await belongs to the case when the
611
+ * tool recorded the case's A2A context as the session that filed it (filed_from_key), or, for a record without that field,
612
+ * when its name carries the run-unique tag the case asked for (the first eight characters of the context).
613
+ */
614
+ export function caseAwaits(before, after, context) {
615
+ const tag = String(context ?? "").slice(0, 8)
616
+ const ours = (entry) => Boolean(context) && (entry.filedFromKey === context || (tag.length === 8 && String(entry.name).includes(tag)))
617
+ const fresh = newAwaits(before, after)
618
+ return { mine: fresh.filter(ours), others: fresh.filter((entry) => !ours(entry)) }
619
+ }
620
+ const caseAwaitsSettled = (before, after, context) => { const { mine } = caseAwaits(before, after, context); return mine.length > 0 && mine.every((entry) => entry.done) }
621
+ const describeAwaits = (entries) => entries.map((entry) => `${entry.name}:${entry.done ? entry.status : "pending"}${entry.reason ? ` (${entry.reason})` : ""}`).join(",")
622
+ const othersDetail = (others) => (others.length > 0 ? `ignored ${others.length} await(s) this case did not file: ${describeAwaits(others)}` : "")
623
+
596
624
  /**
597
625
  * Cross-cutting proof that the replay run never wrote the real owner policy: the sha256 of the steward.json bytes and of
598
626
  * the audit file, taken before the first case and after the last, must be identical. No case may change them, whatever
@@ -607,7 +635,35 @@ export function ownerPolicyUntouched(before, after) {
607
635
 
608
636
  // ---- orchestration (all I/O is behind `host`) --------------------------------------------------------------------
609
637
 
610
- export async function runCase(host, testCase, { plant } = {}) {
638
+ const sessionEvents = (session) => (Array.isArray(session?.events) ? session.events : [])
639
+ /** The last thing the assistant said in a session, or "" when it has said nothing. */
640
+ export function lastReply(session) {
641
+ const replies = sessionEvents(session).filter((event) => event.role === "assistant" && typeof event.content === "string" && event.content.length > 0)
642
+ return replies.at(-1)?.content ?? ""
643
+ }
644
+
645
+ /** A turn has finished when the session has grown past what it held before the message and ends on a plain reply (no tool calls pending). */
646
+ export function turnSettled(session, eventsBefore) {
647
+ const events = sessionEvents(session)
648
+ const last = events.at(-1)
649
+ return events.length > eventsBefore && last?.role === "assistant" && typeof last.content === "string" && last.content.length > 0 && !(Array.isArray(last.toolCalls) && last.toolCalls.length > 0)
650
+ }
651
+
652
+ /** After a cut send, wait (bounded) until the turn has settled and the session has stopped growing between two reads. */
653
+ export async function waitForTurnToSettle(host, context, eventsBefore, { timeoutMs = SETTLE_TIMEOUT_MS, intervalMs = SETTLE_POLL_MS } = {}) {
654
+ const start = host.now()
655
+ let previous = -1
656
+ for (;;) {
657
+ const session = await host.readSession(context)
658
+ const count = sessionEvents(session).length
659
+ if (turnSettled(session, eventsBefore) && count === previous) return { settled: true, waitedMs: host.now() - start }
660
+ previous = count
661
+ if (host.now() - start >= timeoutMs) return { settled: false, waitedMs: host.now() - start }
662
+ await host.sleep(intervalMs)
663
+ }
664
+ }
665
+
666
+ export async function runCase(host, testCase, { plant, deadline = Infinity } = {}) {
611
667
  const startedAt = host.now()
612
668
  const before = await host.observe()
613
669
  const skipReason = testCase.applicable ? testCase.applicable(before) : null
@@ -619,20 +675,35 @@ export async function runCase(host, testCase, { plant } = {}) {
619
675
  const context = randomUUID()
620
676
  let acted = null
621
677
  let sent = {}
678
+ let turn = null
622
679
  if (testCase.act) {
623
680
  try { acted = await testCase.act(host, { friends: host.friends }) } catch (error) { acted = null; sent = { error: String(error?.message ?? error) } }
624
681
  } else {
625
682
  const words = typeof testCase.words === "function" ? testCase.words({ context }) : testCase.words
626
683
  // A case may be a short conversation: the messages go one after another in the same context, and the reply is the last one.
627
684
  for (const text of Array.isArray(words) ? words : [words]) {
685
+ const eventsBefore = sessionEvents(await host.readSession(context)).length
686
+ const sendStart = host.now()
628
687
  sent = await host.send({ who: testCase.sender, text, delegated: testCase.delegated, context })
688
+ const cutAfterMs = host.now() - sendStart
689
+ if (sent.error && (sent.timedOut === true || cutAfterMs >= TURN_CUT_MS)) {
690
+ // The turn is still running on the Butler: reading state now would judge a half-finished turn (2026-10-25: stall-kept read
691
+ // Sonarr's queue while the turn was still searching it; chef-question saw "0 tool calls" from a turn that made 25).
692
+ // Never wait past the run's budget: the window, and the upgrade's own cap on the whole gate, must hold.
693
+ const settle = await waitForTurnToSettle(host, context, eventsBefore, { timeoutMs: Math.max(0, Math.min(SETTLE_TIMEOUT_MS, deadline - host.now())) })
694
+ turn = { timedOut: true, cutAfterSeconds: Math.round(cutAfterMs / 1000), settled: settle.settled, settleSeconds: Math.round(settle.waitedMs / 1000) }
695
+ if (!settle.settled) break
696
+ // The send was cut, not refused: the turn ran to its end, so judge what it did.
697
+ sent = { text: lastReply(await host.readSession(context)) }
698
+ continue
699
+ }
629
700
  if (sent.error) break
630
701
  }
631
702
  }
632
703
  let after = await host.observe()
633
704
  if (testCase.poll) {
634
705
  const deadline = host.now() + testCase.poll.timeoutMs
635
- while (!testCase.poll.until({ before, after }) && host.now() < deadline) {
706
+ while (!testCase.poll.until({ before, after, context }) && host.now() < deadline) {
636
707
  await host.sleep(testCase.poll.intervalMs)
637
708
  after = await host.observe()
638
709
  }
@@ -644,20 +715,70 @@ export async function runCase(host, testCase, { plant } = {}) {
644
715
  const sink = after.sink.filter((line) => Date.parse(line.at) >= startedAt - 1000 && Date.parse(line.at) <= endedAt + 1000)
645
716
  let checks
646
717
  try {
647
- checks = testCase.readback({ trace, timeline: extractTimeline(session), names: host.names, said, reply: sent.text ?? "", error: sent.error ?? null, before, after, sink, friends: host.friends, acted })
718
+ checks = testCase.readback({ trace, timeline: extractTimeline(session), names: host.names, said, reply: sent.text ?? "", error: sent.error ?? null, before, after, sink, friends: host.friends, acted, context })
648
719
  } catch (error) {
649
720
  checks = [check("readback ran", false, String(error?.message ?? error))]
650
721
  }
722
+ if (turn) checks = withTurnNote(checks, turn)
651
723
  // The replay escalation peer's outbox holds only replay reports: once a case is checked, acknowledge them so it stays small.
652
724
  const reports = after.outbox?.[host.friends.escalation] ?? []
653
725
  if (reports.length > 0) await host.outbox("escalation", ["ack", "--ids", reports.join(",")])
654
726
  if (plant === testCase.id) checks = [...checks, check("planted failure (--plant)", false, "this case was told to fail on purpose")]
655
727
  const pass = checks.every((entry) => entry.ok)
656
728
  const warn = !pass && testCase.warnOnly && plant !== testCase.id
657
- return { id: testCase.id, status: pass ? "pass" : warn ? "warn" : "fail", ...(testCase.warnOnly ? { label: testCase.warnOnly } : {}), context, checks, startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString() }
729
+ return { id: testCase.id, status: pass ? "pass" : warn ? "warn" : "fail", ...(testCase.warnOnly ? { label: testCase.warnOnly } : {}), context, ...(turn ? { turn } : {}), checks, startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString() }
730
+ }
731
+
732
+ /** A cut turn is named in every failing check, so "0 tool calls" never stands in for "the gate stopped reading too early". An unsettled turn fails the case. */
733
+ export function withTurnNote(checks, turn) {
734
+ const note = turn.settled
735
+ ? `the send was cut at ${turn.cutAfterSeconds}s and the turn settled ${turn.settleSeconds}s later`
736
+ : `the send was cut at ${turn.cutAfterSeconds}s and the turn had not settled ${turn.settleSeconds}s later, so what it did is unknown`
737
+ const noted = checks.map((entry) => (entry.ok ? entry : { ...entry, detail: entry.detail ? `${note}; ${entry.detail}` : note }))
738
+ return turn.settled ? noted : [...noted, check("the turn finished before the gate read it back", false, note)]
739
+ }
740
+
741
+ // Safety checks a live failure never excuses: each guards the owner and the house, not the Butler's answers.
742
+ export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched"]
743
+ export const FAILING_ON_LIVE_TOO = "failing-on-live-too"
744
+
745
+ /**
746
+ * Judges the candidate against the live version's run of the same gate (Ari's ruling, 2026-10-25: a check that fails on live
747
+ * cannot show that the candidate is worse). A case that failed on both becomes "failing-on-live-too" and does not block; a case
748
+ * that passed, was skipped or did not run on live still blocks when it fails, as does a planted case and every safety check.
749
+ * Every result gets `live`: the live run's status, or "not run".
750
+ */
751
+ export function applyBaseline(results, baselineCases, { plant } = {}) {
752
+ const live = new Map((Array.isArray(baselineCases) ? baselineCases : []).filter((entry) => typeof entry?.id === "string").map((entry) => [entry.id, String(entry.status)]))
753
+ return results.map((entry) => {
754
+ const liveStatus = live.get(entry.id) ?? "not run"
755
+ const excused = entry.status === "fail" && liveStatus === "fail" && entry.id !== plant && !NEVER_EXCUSED.includes(entry.id)
756
+ return { ...entry, live: liveStatus, ...(excused ? { status: FAILING_ON_LIVE_TOO } : {}) }
757
+ })
658
758
  }
659
759
 
660
- export async function runSuite(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES } = {}) {
760
+ export function summarize(all) {
761
+ const ids = (status) => all.filter((entry) => entry.status === status).map((entry) => entry.id)
762
+ const failed = ids("fail")
763
+ const failingOnLiveToo = ids(FAILING_ON_LIVE_TOO)
764
+ return { ok: failed.length === 0, passed: ids("pass").length, skipped: ids("skipped").length, failed, warned: ids("warn"), ...(failingOnLiveToo.length > 0 ? { failingOnLiveToo } : {}) }
765
+ }
766
+
767
+ /** Case lines of an earlier `run` (its stdout, or a log holding it), for --baseline. A missing, stale or empty baseline excuses nothing. */
768
+ export function loadBaseline(file, { now = Date.now(), stat = statSync, read = (target) => readFileSync(target, "utf8") } = {}) {
769
+ let text
770
+ try {
771
+ const age = now - stat(file).mtimeMs
772
+ if (age > BASELINE_MAX_AGE_MS) return { cases: [], problem: `the baseline ${file} is ${Math.round(age / 3_600_000)} hours old, so it was not taken for this release` }
773
+ text = read(file)
774
+ } catch (error) {
775
+ return { cases: [], problem: `the baseline ${file} could not be read: ${error.message}` }
776
+ }
777
+ const cases = text.split("\n").filter((line) => line.startsWith('{"id":')).flatMap((line) => { try { return [JSON.parse(line)] } catch { return [] } })
778
+ return cases.length > 0 ? { cases } : { cases, problem: `the baseline ${file} holds no case results` }
779
+ }
780
+
781
+ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES, liveResults } = {}) {
661
782
  const unknown = [...cases, ...(plant ? [plant] : [])].filter((id) => !CASES.some((entry) => entry.id === id))
662
783
  if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(", ")}. Known: ${CASES.map((entry) => entry.id).join(", ")}`)
663
784
  const selected = CASES.filter((entry) => cases.includes(entry.id))
@@ -674,7 +795,9 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
674
795
  const entry = { id: "window-trusted", status: "fail", checks: [check("the Butler sees the replay window as trusted", false, trusted.detail)] }
675
796
  return { results: [entry], summary: { ok: false, passed: 0, skipped: 0, failed: ["window-trusted"] } }
676
797
  }
677
- for (const testCase of selected) results.push(await runCase(host, testCase, { plant }))
798
+ // Waits for cut turns stop five minutes before the window closes, so a slow night cannot run the gate past its window.
799
+ const deadline = runStart + (windowMinutes - 5) * 60_000
800
+ for (const testCase of selected) results.push(await runCase(host, testCase, { plant, deadline }))
678
801
  } finally {
679
802
  host.closeWindow()
680
803
  }
@@ -687,10 +810,8 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
687
810
  const telegram = { id: "no-telegram", status: clean ? "pass" : "fail", checks: [check("no owner-notice Telegram effect attributable to the replay run was recorded", clean, detail)] }
688
811
  const policyChecks = ownerPolicyUntouched(baseline, finalObservation)
689
812
  const policy = { id: "owner-policy-untouched", status: policyChecks.every((entry) => entry.ok) ? "pass" : "fail", checks: policyChecks }
690
- const all = [...results, policy, telegram]
691
- const failed = all.filter((entry) => entry.status === "fail").map((entry) => entry.id)
692
- const warned = all.filter((entry) => entry.status === "warn").map((entry) => entry.id)
693
- return { results: all, summary: { ok: failed.length === 0, passed: all.filter((entry) => entry.status === "pass").length, skipped: all.filter((entry) => entry.status === "skipped").length, failed, warned } }
813
+ const judged = liveResults ? applyBaseline([...results, policy, telegram], liveResults, { plant }) : [...results, policy, telegram]
814
+ return { results: judged, summary: summarize(judged) }
694
815
  }
695
816
 
696
817
  // ---- self-test ---------------------------------------------------------------------------------------------------
@@ -707,6 +828,8 @@ function emptyObservation(overrides = {}) {
707
828
  return { effectsReadable: true, stewardSha: "aaa", principalSig: "p1", psycheSha: "s1", radarrProfilesSha: "r1", ledgerLines: 3, queue: [], radarrQueue: [], containers: { "calibre-web": true }, awaiting: [], done: [], effects: [], sink: [], outbox: {}, outboxBodies: {}, ...overrides }
708
829
  }
709
830
 
831
+ const SELF_TEST_CONTEXT = "3d305f1b-0000-4000-8000-000000000000"
832
+
710
833
  /** Fixtures: for each case a passing and a failing outcome. Returns the list of problems (empty means the readbacks behave). */
711
834
  export function selfTestFixtures() {
712
835
  const friends = { principal: "p", stranger: "s", escalation: "e" }
@@ -718,6 +841,7 @@ export function selfTestFixtures() {
718
841
  const stalledNamed = { id: 7, trackedDownloadStatus: "warning", series: { title: "The Chef Show" }, title: "Chef.S02" }
719
842
  const sweepRun = { queue: { sonarr: { total: 1, stalled: 1, importProblems: 0 }, radarr: { total: 0, stalled: 0, importProblems: 0 } }, findings: [{ id: "downloads:sonarr:7", refs: { service: "sonarr", queueId: 7 } }], fresh: ["downloads:sonarr:7"], digest_due: true }
720
843
  const dueRun = { queue: { sonarr: { total: 0, stalled: 0, importProblems: 0 }, radarr: { total: 0, stalled: 0, importProblems: 0 } }, findings: [{ id: "x", next: "owner" }], fresh: ["x"], digest_due: true }
844
+ const flapWatch = { name: "public-address-flap-recurrence-20261025.md", status: "pending", filedFromKey: "house-watch" }
721
845
  const cleanRun = { queue: { sonarr: { total: 0, stalled: 0, importProblems: 0 }, radarr: { total: 0, stalled: 0, importProblems: 0 } }, findings: [], fresh: [], digest_due: false }
722
846
  const fx = {
723
847
  "chef-question": {
@@ -772,12 +896,18 @@ export function selfTestFixtures() {
772
896
  fail: { acted: { ...isolated, stranger: ok({ entries: [{ id: "c1", body: "ISOLATION-CANARY not for anyone else" }] }) } },
773
897
  },
774
898
  "peer-await-delivers": {
775
- pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved" }], outbox: { p: ["a"] } }) },
776
- fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "canceled", reason: "Relationship await provenance is incomplete or legacy" }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
899
+ pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved", filedFromKey: SELF_TEST_CONTEXT }], outbox: { p: ["a"] } }) },
900
+ fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "canceled", reason: "Relationship await provenance is incomplete or legacy", filedFromKey: SELF_TEST_CONTEXT }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
901
+ // 2026-10-25: the Butler's own public-address watch, filed during the case from another session, is not the case's await.
902
+ passBackgroundAwait: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [flapWatch], done: [{ name: `replay_plain_${SELF_TEST_CONTEXT.slice(0, 8)}.md`, status: "resolved" }], outbox: { p: ["a"] } }) },
903
+ failOnlyBackgroundAwait: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ ...flapWatch, status: "resolved" }], outbox: { p: ["a"] } }) },
777
904
  },
778
905
  "await-self-resolve": {
779
- pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved" }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
780
- fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [{ name: "w", status: "pending" }] }) },
906
+ pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved", filedFromKey: SELF_TEST_CONTEXT }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
907
+ fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [{ name: "w", status: "pending", filedFromKey: SELF_TEST_CONTEXT }] }) },
908
+ // The 2026-10-25 failure: the case's await resolved, the Butler's own flap watch was still pending.
909
+ passBackgroundAwait: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [flapWatch], done: [{ name: "w", status: "resolved", filedFromKey: SELF_TEST_CONTEXT }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
910
+ failOwnStillPending: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [{ name: "w", status: "pending", filedFromKey: SELF_TEST_CONTEXT }], done: [{ ...flapWatch, status: "resolved" }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
781
911
  },
782
912
  // The names come from the release's own finder; these fixtures check what the gate does with them.
783
913
  "work-sourced": {
@@ -833,7 +963,7 @@ export function selfTest() {
833
963
  const fixture = fx[testCase.id]
834
964
  if (!fixture) { problems.push(`${testCase.id}: no fixtures`); continue }
835
965
  for (const [kind, ctx] of Object.entries(fixture)) {
836
- const checks = testCase.readback({ reply: "", error: null, sink: [], timeline: [], ...ctx, friends })
966
+ const checks = testCase.readback({ reply: "", error: null, sink: [], timeline: [], context: SELF_TEST_CONTEXT, ...ctx, friends })
837
967
  const pass = checks.every((entry) => entry.ok)
838
968
  if (pass !== kind.startsWith("pass")) problems.push(`${testCase.id}: the ${kind} fixture ${pass ? "passed" : "failed"} (${checks.filter((entry) => !entry.ok).map((entry) => entry.name).join("; ")})`)
839
969
  }
@@ -868,6 +998,40 @@ export function selfTest() {
868
998
  if (classify("owner-notice:mystery").leaks.length !== 1) problems.push("no-telegram did not fail closed on an unknown owner-notice key")
869
999
  if (classify(`owner-notice:await:missing:asked_owner:${created}`).leaks.length !== 1) problems.push("no-telegram did not fail closed on an await it could not find")
870
1000
  if (classifyOwnerNotices([{ idempotencyKey: "owner-notice:x", createdAt: new Date(5000).toISOString() }], 0, 1000, ctx).leaks.length !== 0) problems.push("no-telegram counted an out-of-window record")
1001
+ problems.push(...selfTestTurnAndBaseline())
1002
+ return problems
1003
+ }
1004
+
1005
+ /** The cut-turn and live-baseline rules, on fixtures from the 2026-10-25 runs. */
1006
+ function selfTestTurnAndBaseline() {
1007
+ const problems = []
1008
+ const reply = { role: "assistant", content: "We have season 1 of the Chef Show." }
1009
+ const calling = { role: "assistant", content: null, toolCalls: [{ id: "1", function: { name: "media_episodes", arguments: "{\"service_id\":191}" } }] }
1010
+ const result = { role: "tool", toolCallId: "1", content: "{}" }
1011
+ const user = { role: "user", content: "why don't we have all eps of chef?" }
1012
+ if (!turnSettled({ events: [user, calling, result, reply] }, 1)) problems.push("a turn that ended on a plain reply was not seen as settled")
1013
+ if (turnSettled({ events: [user, calling, result] }, 1)) problems.push("a turn still waiting on a tool was seen as settled")
1014
+ if (turnSettled({ events: [user, reply] }, 2)) problems.push("the previous message's reply was taken for this turn's end")
1015
+ if (turnSettled(null, 0)) problems.push("a missing session was seen as settled")
1016
+ if (lastReply({ events: [user, reply, calling] }) !== reply.content || lastReply(null) !== "") problems.push("lastReply did not return the last thing the assistant said")
1017
+ const failing = [check("a media tool call names Sonarr series 191 or TMDB 89557", false, "25 tool calls"), check("the reply names the Chef Show", true)]
1018
+ const settledNote = withTurnNote(failing, { timedOut: true, cutAfterSeconds: 300, settled: true, settleSeconds: 150 })
1019
+ if (settledNote.length !== 2 || !/cut at 300s and the turn settled 150s later; 25 tool calls/.test(settledNote[0].detail) || settledNote[1].detail) problems.push("a settled cut turn was not named in the failing check alone")
1020
+ const unsettled = withTurnNote([check("x", true)], { timedOut: true, cutAfterSeconds: 300, settled: false, settleSeconds: 300 })
1021
+ if (unsettled.every((entry) => entry.ok) || !/had not settled/.test(unsettled.at(-1).detail)) problems.push("an unsettled cut turn did not fail the case")
1022
+ const live = [{ id: "failure-report", status: "fail" }, { id: "chef-question", status: "pass" }, { id: "stall-kept", status: "skipped" }, { id: "no-telegram", status: "fail" }, { id: "books-up", status: "fail" }]
1023
+ const candidate = [{ id: "failure-report", status: "fail" }, { id: "chef-question", status: "fail" }, { id: "stall-kept", status: "fail" }, { id: "no-telegram", status: "fail" }, { id: "books-up", status: "fail" }, { id: "request-status", status: "fail" }, { id: "psyche-via-pr", status: "pass" }]
1024
+ const judged = Object.fromEntries(applyBaseline(candidate, live, { plant: "books-up" }).map((entry) => [entry.id, `${entry.live}>${entry.status}`]))
1025
+ const want = { "failure-report": `fail>${FAILING_ON_LIVE_TOO}`, "chef-question": "pass>fail", "stall-kept": "skipped>fail", "no-telegram": "fail>fail", "books-up": "fail>fail", "request-status": "not run>fail", "psyche-via-pr": "not run>pass" }
1026
+ for (const [id, verdict] of Object.entries(want)) if (judged[id] !== verdict) problems.push(`baseline: ${id} judged ${judged[id]}, expected ${verdict}`)
1027
+ const summary = summarize(applyBaseline([{ id: "failure-report", status: "fail" }, { id: "books-up", status: "pass" }], live))
1028
+ if (!summary.ok || summary.failingOnLiveToo?.[0] !== "failure-report") problems.push("a case failing on live too still blocked the run, or was not reported")
1029
+ const stat = (mtimeMs) => () => ({ mtimeMs })
1030
+ const lines = '{"id":"failure-report","status":"fail"}\nreplay window open\n{"id":"broken\n{"summary":{}}'
1031
+ if (loadBaseline("b", { now: 10, stat: stat(0), read: () => lines }).cases.length !== 1) problems.push("loadBaseline did not read the case lines of a fresh baseline")
1032
+ if (!loadBaseline("b", { now: BASELINE_MAX_AGE_MS + 1, stat: stat(0), read: () => lines }).problem) problems.push("a stale baseline was accepted")
1033
+ if (!loadBaseline("b", { stat: () => { throw new Error("ENOENT") } }).problem) problems.push("a missing baseline was accepted")
1034
+ if (!loadBaseline("b", { now: 10, stat: stat(0), read: () => "nothing" }).problem) problems.push("a baseline with no case lines was accepted")
871
1035
  return problems
872
1036
  }
873
1037
 
@@ -953,7 +1117,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
953
1117
  const hashOf = (file) => (lstatOrNull(file) ? sha256(readTextNoFollow(file)) : null)
954
1118
  const awaitEntries = (dir) => list(dir).filter((name) => name.endsWith(".md")).map((name) => {
955
1119
  const text = readTextNoFollow(path.join(dir, name))
956
- return { name, status: /^status:\s*(\S+)/m.exec(text)?.[1] ?? "pending", createdAt: /^created_at:\s*(\S+)/m.exec(text)?.[1] ?? "", filedFor: unquote(/^filed_for_friend_id:\s*(\S+)/m.exec(text)?.[1] ?? ""), reason: /^cancel_reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "" }
1120
+ return { name, status: /^status:\s*(\S+)/m.exec(text)?.[1] ?? "pending", createdAt: /^created_at:\s*(\S+)/m.exec(text)?.[1] ?? "", filedFor: unquote(/^filed_for_friend_id:\s*(\S+)/m.exec(text)?.[1] ?? ""), filedFromKey: unquote(/^filed_from_key:\s*(\S+)/m.exec(text)?.[1] ?? ""), reason: /^cancel_reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "" }
957
1121
  })
958
1122
  let stageDir = null
959
1123
  const identityFor = (who) => {
@@ -1024,7 +1188,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1024
1188
  const parsed = JSON.parse(out.trim().split("\n").at(-1))
1025
1189
  return { text: parsed.text ?? "" }
1026
1190
  } catch (error) {
1027
- return { error: `${error.stderr ?? ""} ${error.stdout ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
1191
+ return { error: `${error.stderr ?? ""} ${error.stdout ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim(), ...(error.code === "ETIMEDOUT" ? { timedOut: true } : {}) }
1028
1192
  }
1029
1193
  },
1030
1194
  /** Plants one entry in a replay peer's outbox, as the Butler's user, to prove another peer cannot read it. Refuses non-replay peers. */
@@ -1549,12 +1713,13 @@ export function parseArgs(argv) {
1549
1713
  else if (flag === "--retire-friend" && rest[i + 1]) options.retire = [...(options.retire ?? []), rest[++i]]
1550
1714
  else if (flag === "--rotate-replay-identities") options.rotate = true
1551
1715
  else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
1716
+ else if (flag === "--baseline" && rest[i + 1]) options.baseline = rest[++i]
1552
1717
  else throw new Error(`unknown argument: ${flag}`)
1553
1718
  }
1554
1719
  return options
1555
1720
  }
1556
1721
 
1557
- export async function main(argv, io = { out: (text) => console.log(text), err: (text) => console.error(text) }, deps = { makeHost, provision }) {
1722
+ export async function main(argv, io = { out: (text) => console.log(text), err: (text) => console.error(text) }, deps = { makeHost, provision }, env = process.env) {
1558
1723
  let options
1559
1724
  try { options = parseArgs(argv) } catch (error) { io.err(String(error.message)); return 2 }
1560
1725
  if (options.command === "self-test") {
@@ -1569,15 +1734,24 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
1569
1734
  if (options.command === "run") {
1570
1735
  let host
1571
1736
  try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
1737
+ // butler-ship runs this gate against the live version first and hands the upgrade that log through the environment.
1738
+ const baselineFile = options.baseline ?? env.OURO_REPLAY_BASELINE
1739
+ let baseline
1740
+ if (baselineFile) {
1741
+ const loaded = (deps.loadBaseline ?? loadBaseline)(baselineFile)
1742
+ if (loaded.problem) io.out(`BASELINE IGNORED (every failure blocks): ${loaded.problem}`)
1743
+ else { baseline = loaded.cases; io.err(`judging against the live baseline in ${baselineFile} (${baseline.length} cases)`) }
1744
+ }
1572
1745
  let suite
1573
1746
  try {
1574
- suite = await runSuite(host, { ...(options.cases ? { cases: options.cases } : {}), ...(options.plant ? { plant: options.plant } : {}), ...(options.windowMinutes ? { windowMinutes: options.windowMinutes } : {}) })
1747
+ suite = await runSuite(host, { ...(options.cases ? { cases: options.cases } : {}), ...(options.plant ? { plant: options.plant } : {}), ...(options.windowMinutes ? { windowMinutes: options.windowMinutes } : {}), ...(baseline ? { liveResults: baseline } : {}) })
1575
1748
  } catch (error) { io.err(`replay gate could not run: ${error.message}`); return 1 }
1576
1749
  for (const result of suite.results) io.out(JSON.stringify(result))
1750
+ if (suite.summary.failingOnLiveToo) io.out(`FAILING ON LIVE TOO (does not block; the live version fails these as well): ${suite.summary.failingOnLiveToo.join(", ")}`)
1577
1751
  io.out(JSON.stringify({ summary: suite.summary }))
1578
1752
  return suite.summary.ok ? 0 : 1
1579
1753
  }
1580
- io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n]|self-test> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--card-url <url>]")
1754
+ io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n] [--baseline <file>]|self-test> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--card-url <url>]")
1581
1755
  return 2
1582
1756
  }
1583
1757
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.957",
2
+ "runtimeVersion": "0.1.0-alpha.958",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.957</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.958</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.957",
3
+ "version": "0.1.0-alpha.958",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.957",
9
+ "version": "0.1.0-alpha.958",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.957",
3
+ "version": "0.1.0-alpha.958",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },