@ouro.bot/cli 0.1.0-alpha.957 → 0.1.0-alpha.959
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 +14 -0
- package/deploy/unraid/README.txt +2 -2
- package/deploy/unraid/sanctuary-replay-gate.mjs +206 -32
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/source-grounding.js +8 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
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.959",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Butler no longer tells you he \"couldn't verify\" ordinary words like Client or Service: a word only counts as a name when the conversation never uses it as a plain word, and anything his own server tools read this turn counts as checked."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.958",
|
|
12
|
+
"changes": [
|
|
13
|
+
"The Butler release gate now judges a new release against the version that is actually running, not an older baseline.",
|
|
14
|
+
"Gate checks that wait on a reply now count only the replies their own case asked for.",
|
|
15
|
+
"Messages sent while a turn is cut short now wait their turn within the replay window instead of failing the gate."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
4
18
|
{
|
|
5
19
|
"version": "0.1.0-alpha.957",
|
|
6
20
|
"changes": [
|
package/deploy/unraid/README.txt
CHANGED
|
@@ -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>]
|
|
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 }) =>
|
|
371
|
+
until: ({ after, before, context }) => caseAwaitsSettled(before, after, context),
|
|
360
372
|
},
|
|
361
|
-
readback: ({ before, after, sink, friends }) => {
|
|
362
|
-
const filed =
|
|
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
|
|
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 }) =>
|
|
581
|
+
until: ({ after, before, context }) => caseAwaitsSettled(before, after, context),
|
|
570
582
|
},
|
|
571
|
-
readback: ({ before, after, sink, friends }) => {
|
|
572
|
-
const filed =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
691
|
-
|
|
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,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.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.959</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -106,6 +106,10 @@ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
106
106
|
function mentions(haystack, name) {
|
|
107
107
|
return new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(name)}(?![\\p{L}\\p{N}])`, "iu").test(haystack);
|
|
108
108
|
}
|
|
109
|
+
/** Case-sensitive whole word: the text uses this word in lowercase ("the download client"), so it is an ordinary word there, not a name. */
|
|
110
|
+
function usesLowercase(text, name) {
|
|
111
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(name.toLowerCase())}(?![\\p{L}\\p{N}])`, "u").test(text);
|
|
112
|
+
}
|
|
109
113
|
/** Ordinary sentence openers that strict mode still does not take for names. */
|
|
110
114
|
exports.STRICT_OPENERS = new Set("too mock very just same such quite rather really kind sort bit less fairly mostly almost simply much little big small high low hard pure plain cheap hmm hm well okay ok yes yeah sure right also great nice cool done sorry thanks thank perfect fine good alright first second third then now so short quick".split(" "));
|
|
111
115
|
const STRICT_SIGN = /^(?:\s*[,:\u2014\u2013]|\s+(?:if|for)\s)/;
|
|
@@ -155,11 +159,12 @@ function candidateNames(text, strict = false) {
|
|
|
155
159
|
* The one name finder: the runtime check below and the replay gate (which runs this file from the shipped release) both call it.
|
|
156
160
|
* Italic actions are blanked first. `strict` is set when the person asked for names. `prose` adds lowercase names in plain prose,
|
|
157
161
|
* for a reply written before any lookup. `reference` is text whose names count in the reply in any case: a name it holds that the
|
|
158
|
-
* reply typed in lowercase ("eithan" for "Eithan") is still a name.
|
|
162
|
+
* reply typed in lowercase ("eithan" for "Eithan") is still a name, unless the reference itself also uses it as a lowercase word.
|
|
159
163
|
*/
|
|
160
164
|
function namesIn(text, options = {}) {
|
|
161
165
|
const body = text.replace(ITALIC_ACTION, (action) => " ".repeat(action.length));
|
|
162
|
-
const
|
|
166
|
+
const reference = options.reference;
|
|
167
|
+
const referenced = reference ? candidateNames(reference).filter((name) => mentions(body, name) && !usesLowercase(reference, name)) : [];
|
|
163
168
|
const found = [...candidateNames(body, options.strict), ...lowercaseListNames(body), ...(options.prose ? lowercaseProseNames(body) : []), ...referenced];
|
|
164
169
|
return found.filter((name, index) => found.findIndex((other) => other.toLowerCase() === name.toLowerCase()) === index);
|
|
165
170
|
}
|
|
@@ -279,7 +284,7 @@ function sourceGroundingFinding(input) {
|
|
|
279
284
|
: `${named} do not appear in anything you looked up this turn, so they may be made up. check them against the source you read, drop the ones it does not support, or look them up, then settle again.`,
|
|
280
285
|
};
|
|
281
286
|
}
|
|
282
|
-
const unsourced = names.filter((name) => mentions(input.userText, name) && !mentions(lookedUp, name) && claimsSomethingAbout(input.answer, name));
|
|
287
|
+
const unsourced = names.filter((name) => mentions(input.userText, name) && !mentions(lookedUp, name) && !mentions(houseRead, name) && !mentions(otherRead, name) && claimsSomethingAbout(input.answer, name));
|
|
283
288
|
if (answerCue && lookups.length === 0 && unsourced.length > 0) {
|
|
284
289
|
return {
|
|
285
290
|
kind: "unsourced_claim", names: unsourced, lookups: 0,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.959",
|
|
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.
|
|
9
|
+
"version": "0.1.0-alpha.959",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|