@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.991

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 (37) hide show
  1. package/changelog.json +6 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  26. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  27. package/dist/heart/sync-lock.js +431 -0
  28. package/dist/heart/sync.js +1252 -399
  29. package/dist/mind/prompt.js +92 -19
  30. package/dist/repertoire/guardrails.js +285 -3
  31. package/dist/repertoire/tools-bundle.js +107 -185
  32. package/dist/repertoire/tools.js +5 -1
  33. package/dist/senses/pipeline.js +37 -3
  34. package/dist/senses/private-runtime.js +1 -6
  35. package/dist/senses/voice/twilio-phone.js +16 -16
  36. package/npm-shrinkwrap.json +2 -2
  37. package/package.json +1 -1
@@ -9,7 +9,10 @@
9
9
  // run [--cases a,b] [--plant <id>] [--baseline <file>]
10
10
  // open the replay window, run the cases, close the window, print one JSON line per case.
11
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
12
+ // that failed there too is reported as failing-on-live-too and does not block.
13
+ // Once the bundle syncs (bundle-backup/cutover-done), the run pauses sync for its length,
14
+ // syncs once after the restores, checks the remote gained none of its residue, checks git
15
+ // tracks the psyche and nothing excluded, and times sync on one read-only turn
13
16
  // self-test run every case's readback against built-in pass and fail fixtures (used by CI)
14
17
  //
15
18
  // The replay window routes owner notices and A2A await deliveries for the replay peers to
@@ -38,6 +41,26 @@ const [BUTLER_UID, BUTLER_GID] = BUTLER_USER.split(":").map(Number)
38
41
  /** The psyche files that say who the Butler is. TACIT, what he learns, is the one he writes himself. */
39
42
  export const IDENTITY_FILES = ["SOUL.md", "IDENTITY.md", "LORE.md", "ASPIRATIONS.md"]
40
43
  export const CLI_ENTRY = "/opt/ouro/dist/heart/daemon/ouro-entry.js"
44
+ // Bundle sync (M1c). The cutover marker is root's, beside the old backup repository; the pause marker is sync's own (src/heart/sync.ts).
45
+ export const DEFAULT_BACKUP_DIR = "/mnt/user/appdata/ouro-butler/bundle-backup"
46
+ export const SYNC_PAUSE_MS = 2 * 60 * 60 * 1000
47
+ export const SYNC_LOCK_WAIT_MS = 120 * 1000
48
+ export const SYNC_LOCK_POLL_MS = 1000
49
+ // sync-lock.ts's RECLAIM_STALE_MS: a lock that does not parse counts as held for this long.
50
+ export const SYNC_LOCK_MALFORMED_MS = 30 * 1000
51
+ // The sync path inside the container, as src/heart/sync.ts's SYNC_SSH_COMMAND.
52
+ export const SYNC_SSH_COMMAND = "sudo -n -u ouro-sync /usr/local/libexec/ouro-sync-ssh"
53
+ export const SYNC_LATENCY_LIMIT_MS = 2000
54
+ export const SYNC_EVENTS_WAIT_MS = 60 * 1000
55
+ export const SYNC_EVENTS_POLL_MS = 2000
56
+ export const LATENCY_WORDS = "(replay) what time is it?"
57
+ /** The psyche files git must track once the bundle syncs. */
58
+ export const PSYCHE_FILES = ["ASPIRATIONS.md", "IDENTITY.md", "LORE.md", "SOUL.md", "TACIT.md"]
59
+ /** Files each release installs, which sync must never track (SANCTUARY_PACKAGE_MANAGED_FILES, pinned by a test), and the release's bookkeeping. */
60
+ export const NEVER_TRACKED = ["provider-readiness.json", "tool-profiles.json", "mcp/media-mcp.mjs", "habits/sanctuary-health.md", "awaiting/house-care-sweep.md", "product/PRODUCT.md", "bundle-meta.json", "psyche/.release-marker"]
61
+ export const SECRET_PATH = /credential|secret|token|\.key$|\.pem$|(^|\/)\.env($|\.)/i
62
+ /** Text the gate's cases plant (the learns-into-tacit rule, the outbox canary, the replay prefix). None may reach the remote. */
63
+ export const GATE_PLANTED_STRINGS = ["english subtitles", "ISOLATION-CANARY", "(replay)"]
41
64
  // 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),
42
65
  // and well inside the 2 hour cap: an await that has not resolved when the window closes is cancelled by the Butler.
43
66
  // Waits for cut turns come out of the same budget and stop five minutes before the window closes (runSuite).
@@ -873,7 +896,7 @@ export function withTurnNote(checks, turn) {
873
896
  }
874
897
 
875
898
  // Safety checks a live failure never excuses: each guards the owner and the house, not the Butler's answers.
876
- export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched", "tacit-restored", "replay-friend-restored"]
899
+ export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched", "tacit-restored", "replay-friend-restored", "no-sync-residue", "psyche-git-tracked"]
877
900
  export const FAILING_ON_LIVE_TOO = "failing-on-live-too"
878
901
 
879
902
  /**
@@ -895,7 +918,8 @@ export function summarize(all) {
895
918
  const ids = (status) => all.filter((entry) => entry.status === status).map((entry) => entry.id)
896
919
  const failed = ids("fail")
897
920
  const failingOnLiveToo = ids(FAILING_ON_LIVE_TOO)
898
- return { ok: failed.length === 0, passed: ids("pass").length, skipped: ids("skipped").length, failed, warned: ids("warn"), ...(failingOnLiveToo.length > 0 ? { failingOnLiveToo } : {}) }
921
+ const pending = ids("pending")
922
+ return { ok: failed.length === 0, passed: ids("pass").length, skipped: ids("skipped").length, failed, warned: ids("warn"), ...(failingOnLiveToo.length > 0 ? { failingOnLiveToo } : {}), ...(pending.length > 0 ? { pending } : {}) }
899
923
  }
900
924
 
901
925
  /** Case lines of an earlier `run` (its stdout, or a log holding it), for --baseline. A missing, stale or empty baseline excuses nothing. */
@@ -963,11 +987,202 @@ function replayFriendRestoredResult(host, learned) {
963
987
  return { id: "replay-friend-restored", status: detail ? "fail" : "pass", checks: [check("the replay friend's notes and preferences hold what they had before the run", !detail, detail)] }
964
988
  }
965
989
 
966
- export async function runSuite(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES, liveResults, log = console.error } = {}) {
990
+ // ---- bundle sync around the run (M1c) -------------------------------------------------------------------------------
991
+
992
+ const pendingSync = (id, name) => ({ id, status: "pending", checks: [check(name, true, "the bundle has not moved to bundle sync yet (bundle-backup/cutover-done is absent)")] })
993
+
994
+ /**
995
+ * Pauses sync for the run: a leftover marker from a crashed run goes first, then a fresh one that expires on its own in two hours,
996
+ * then the gate waits (at most two minutes) for a sync already running to let go of the lock, and records HEAD.
997
+ */
998
+ export async function openSyncBracket(sync, { waitMs = SYNC_LOCK_WAIT_MS } = {}) {
999
+ sync.resume()
1000
+ sync.pause(SYNC_PAUSE_MS)
1001
+ if (!(await sync.waitLockFree(waitMs))) return { ok: false, detail: `sync lock busy: .git/ouro-sync.lock was still held after ${Math.round(waitMs / 1000)} s` }
1002
+ return { ok: true, head: sync.head(), untracked: untrackedSnapshot(sync) }
1003
+ }
1004
+
1005
+ /** The bundle's untracked files and their hashes, or why they could not be read. */
1006
+ export function untrackedSnapshot(sync) {
1007
+ try { return { ok: true, files: sync.untracked() } } catch (error) { return { ok: false, error: String(error?.message ?? error) } }
1008
+ }
1009
+
1010
+ /**
1011
+ * The untracked paths the run created or changed: in `after` and not in `before`, or with another hash. A tracked file the run
1012
+ * changed cannot be told apart from a household turn's edit this way, so those stay on the hand-kept list (TACIT).
1013
+ */
1014
+ export function createdDuringRun(before, after) {
1015
+ if (!before.ok) return { paths: [], error: before.error }
1016
+ if (!after.ok) return { paths: [], error: after.error }
1017
+ return { paths: Object.keys(after.files).filter((file) => before.files[file] !== after.files[file]).sort() }
1018
+ }
1019
+
1020
+ // An entry ending in "/" covers a folder, one ending in "*" covers names that start with it, and any other names one path.
1021
+ const touches = (paths, prefixes) => paths.filter((file) => prefixes.some((prefix) => (prefix.endsWith("/") ? file.startsWith(prefix) : prefix.endsWith("*") ? file.startsWith(prefix.slice(0, -1)) : file === prefix)))
1022
+
1023
+ /**
1024
+ * Ends the pause, runs one sync, and checks what the Butler pushed since the recorded HEAD: none of its commits touches a path the
1025
+ * gate touched or adds a line holding one of the gate's planted strings. Owner pushes, and household turns made during the run,
1026
+ * are allowed: only the Butler's own non-merge commits are read, and a restored file commits as an unchanged tree.
1027
+ */
1028
+ export async function closeSyncBracket(sync, head, { touched, planted, snapshotError }) {
1029
+ sync.resume()
1030
+ const synced = await sync.runSync()
1031
+ const ran = check("ouro sync ran once after the restores", synced.ok, synced.ok ? "" : synced.error)
1032
+ if (!synced.ok) return { id: "no-sync-residue", status: "fail", checks: [ran] }
1033
+ let changes
1034
+ try { changes = sync.agentChanges(head) } catch (error) {
1035
+ return { id: "no-sync-residue", status: "fail", checks: [ran, check("the Butler's commits since the run began could be read", false, String(error?.message ?? error))] }
1036
+ }
1037
+ const paths = touches(changes.paths, touched)
1038
+ const strings = planted.filter((text) => changes.added.some((line) => line.toLowerCase().includes(text.toLowerCase())))
1039
+ const checks = [
1040
+ ran,
1041
+ check("the Butler's commits pushed since the run began touch no path the gate touched", paths.length === 0, paths.length ? `${paths.join(", ")} (${changes.commits} commits up to ${changes.remoteHead})` : ""),
1042
+ check("those commits add none of the gate's planted strings", strings.length === 0, strings.join(", ")),
1043
+ ...(snapshotError === undefined ? [] : [check("the files the run created in the bundle could be read", false, snapshotError)]),
1044
+ ]
1045
+ return { id: "no-sync-residue", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
1046
+ }
1047
+
1048
+ /** The path of each `git status --porcelain` line (the new name of a rename), unquoted. */
1049
+ export function porcelainPaths(status) {
1050
+ return String(status ?? "").split("\n").filter((line) => line.length > 3).map((line) => {
1051
+ const file = line.slice(3).split(" -> ").at(-1)
1052
+ return file.startsWith('"') && file.endsWith('"') ? file.slice(1, -1) : file
1053
+ })
1054
+ }
1055
+
1056
+ /**
1057
+ * The paths in `git status --porcelain --untracked-files=all` output that sync must never commit: a package-managed file or a
1058
+ * secret-named path. The cutover's step 10 runs this same function (`status-scan`), so the two checks cannot drift.
1059
+ */
1060
+ export function statusProblems(status) {
1061
+ return porcelainPaths(status).filter((file) => NEVER_TRACKED.includes(file) || SECRET_PATH.test(file))
1062
+ }
1063
+
1064
+ /** The check `psyche is git-tracked`: pending until the cutover, then git tracks the psyche and nothing the release or a secret owns. */
1065
+ export function trackingResult(sync) {
1066
+ if (!sync.cutoverDone()) return pendingSync("psyche-git-tracked", "git tracks the psyche and nothing excluded")
1067
+ let seen
1068
+ try { seen = sync.tracking() } catch (error) {
1069
+ return { id: "psyche-git-tracked", status: "fail", checks: [check("the bundle's git state could be read", false, String(error?.message ?? error))] }
1070
+ }
1071
+ const missing = PSYCHE_FILES.filter((name) => !seen.psyche.includes(`psyche/${name}`))
1072
+ const dirty = statusProblems(seen.status)
1073
+ const checks = [
1074
+ check("git tracks all five psyche files", missing.length === 0, missing.length ? `not tracked: ${missing.join(", ")}` : ""),
1075
+ check("git tracks nothing the exclude list covers", seen.excludedTracked.length === 0, seen.excludedTracked.join(", ")),
1076
+ check("git status shows no package-managed or secret path", dirty.length === 0, dirty.join(", ")),
1077
+ ]
1078
+ return { id: "psyche-git-tracked", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
1079
+ }
1080
+
1081
+ const PULL_ENDS = ["heart.sync_pull_end", "heart.sync_pull_error", "heart.sync_pull_aborted", "heart.sync_paused", "heart.sync_not_a_repo"]
1082
+ // The synchronous post-turn commit ends at heart.sync_commit_end; the network push after it is not timed. A push_end before it
1083
+ // means the commit was skipped (sync busy), so nothing was timed.
1084
+ const COMMIT_ENDS = ["heart.sync_commit_end", "heart.sync_push_end", "heart.sync_push_error", "heart.sync_paused", "heart.sync_not_a_repo"]
1085
+
1086
+ /** The pre-turn pull (start to end) and the post-turn commit (start to its end) in a turn's sync events, in order. */
1087
+ export function syncIntervals(events) {
1088
+ const sorted = [...events].sort((a, b) => a.at - b.at)
1089
+ const span = (startName, ends, from) => {
1090
+ const start = sorted.findIndex((event, index) => index >= from && event.event === startName)
1091
+ if (start < 0) return { ms: null, problem: `no ${startName} was logged`, next: from }
1092
+ const end = sorted.findIndex((event, index) => index > start && ends.includes(event.event))
1093
+ if (end < 0) return { ms: null, problem: `no end was logged after ${startName}`, next: start + 1 }
1094
+ const ended = sorted[end].event
1095
+ return ended === ends[0] ? { ms: sorted[end].at - sorted[start].at, next: end + 1 } : { ms: null, problem: `it ended with ${ended}`, next: end + 1 }
1096
+ }
1097
+ const pull = span("heart.sync_pull_start", PULL_ENDS, 0)
1098
+ const commit = span("heart.sync_push_start", COMMIT_ENDS, pull.next)
1099
+ return { pull, commit, complete: pull.ms !== null && commit.ms !== null }
1100
+ }
1101
+
1102
+ /**
1103
+ * The check `sync latency`: one read-only turn after the cases, inside the bracket (the residue check reads its commits too). Its
1104
+ * pre-turn pull and its synchronous post-turn commit each stay under two seconds; the network push is not timed.
1105
+ */
1106
+ export async function syncLatencyResult(host) {
1107
+ const sync = host.sync
1108
+ const since = host.now()
1109
+ host.openWindow(10)
1110
+ let sent
1111
+ try { sent = await host.send({ who: "principal", text: LATENCY_WORDS, delegated: false, context: randomUUID() }) } finally { host.closeWindow() }
1112
+ if (sent.error) return { id: "sync-latency", status: "fail", checks: [check("the read-only turn answered", false, sent.error)] }
1113
+ const deadline = host.now() + SYNC_EVENTS_WAIT_MS
1114
+ let intervals = syncIntervals(sync.events(since))
1115
+ while (!intervals.complete && host.now() < deadline) {
1116
+ await host.sleep(SYNC_EVENTS_POLL_MS)
1117
+ intervals = syncIntervals(sync.events(since))
1118
+ }
1119
+ const judge = (name, span) => check(`${name} took under ${SYNC_LATENCY_LIMIT_MS} ms`, span.ms !== null && span.ms < SYNC_LATENCY_LIMIT_MS, span.ms === null ? span.problem : `${span.ms} ms`)
1120
+ const checks = [judge("the pre-turn pull (heart.sync_pull_start to heart.sync_pull_end)", intervals.pull), judge("the post-turn commit (heart.sync_push_start to heart.sync_commit_end)", intervals.commit)]
1121
+ return { id: "sync-latency", status: checks.every((entry) => entry.ok) ? "pass" : "fail", checks }
1122
+ }
1123
+
1124
+ /**
1125
+ * The replay run. On a bundle that syncs, the cases run inside the sync bracket: sync is paused from before the window opens until
1126
+ * the restores are done (the pause is written inside the `try` whose `finally` removes it). Then one read-only turn times sync,
1127
+ * one sync runs, and the gate checks that the remote gained none of the residue of the cases or of that turn. The tracking check
1128
+ * comes last.
1129
+ */
1130
+ export async function runSuite(host, options = {}) {
1131
+ const sync = host.sync
1132
+ if (!sync) return runCases(host, options)
1133
+ validateCases(options)
1134
+ await host.waitReady?.()
1135
+ if (!sync.cutoverDone()) {
1136
+ const suite = await runCases(host, { ...options, ready: true })
1137
+ return withSyncResults(suite, [pendingSync("no-sync-residue", "the remote gains none of the gate's residue"), trackingResult(sync), pendingSync("sync-latency", "sync's pull and commit each stay under two seconds")], options)
1138
+ }
1139
+ let bracket
1140
+ let suite
1141
+ let untrackedAfter
1142
+ try {
1143
+ bracket = await openSyncBracket(sync, options.syncLockWaitMs === undefined ? {} : { waitMs: options.syncLockWaitMs })
1144
+ if (bracket.ok) {
1145
+ suite = await runCases(host, { ...options, ready: true })
1146
+ // Read while sync is still paused, so nothing the run created has been committed yet.
1147
+ untrackedAfter = untrackedSnapshot(sync)
1148
+ }
1149
+ } finally {
1150
+ // The pause never outlives the run. A crash that skips this leaves a marker that expires on its own.
1151
+ sync.resume()
1152
+ }
1153
+ if (!bracket.ok) {
1154
+ const busy = { id: "no-sync-residue", status: "fail", checks: [check("sync let go of the bundle before the run", false, bracket.detail)] }
1155
+ return { results: [busy], summary: summarize([busy]) }
1156
+ }
1157
+ const windowRefused = suite.results.some((entry) => entry.id === "window-trusted")
1158
+ const latency = windowRefused ? { id: "sync-latency", status: "skipped", reason: "the Butler did not trust the replay window", checks: [] } : await syncLatencyResult(host)
1159
+ // A replay friend's record changes on every turn (its token count), so its path is not residue; what a case could teach the
1160
+ // Butler is, and the planted strings and the friend ids catch that in any file. Every other untracked file the run created or
1161
+ // changed is the gate's, and so is the release marker the upgrade plants before the gate runs.
1162
+ const replayFriends = [host.friends.principal, host.friends.stranger, host.friends.escalation].filter(Boolean)
1163
+ const created = createdDuringRun(bracket.untracked, untrackedAfter)
1164
+ const touched = ["psyche/TACIT.md", "state/replay/", "psyche/.release-marker*", ...created.paths.filter((file) => !replayFriends.some((id) => file === `friends/${id}.json`))]
1165
+ const planted = [...GATE_PLANTED_STRINGS, ...replayFriends]
1166
+ const residue = await closeSyncBracket(sync, bracket.head, { touched, planted, snapshotError: created.error })
1167
+ const tracking = trackingResult(sync)
1168
+ return withSyncResults(suite, [residue, tracking, latency], options)
1169
+ }
1170
+
1171
+ function withSyncResults(suite, extra, { liveResults, plant } = {}) {
1172
+ const judged = liveResults ? applyBaseline(extra, liveResults, { plant }) : extra
1173
+ const results = [...suite.results, ...judged]
1174
+ return { results, summary: summarize(results) }
1175
+ }
1176
+
1177
+ function validateCases({ cases = CASES.map((entry) => entry.id), plant } = {}) {
967
1178
  const unknown = [...cases, ...(plant ? [plant] : [])].filter((id) => !CASES.some((entry) => entry.id === id))
968
1179
  if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(", ")}. Known: ${CASES.map((entry) => entry.id).join(", ")}`)
1180
+ }
1181
+
1182
+ async function runCases(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES, liveResults, log = console.error, ready = false } = {}) {
1183
+ validateCases({ cases, plant })
969
1184
  const selected = CASES.filter((entry) => cases.includes(entry.id))
970
- await host.waitReady?.()
1185
+ if (!ready) await host.waitReady?.()
971
1186
  const runStart = host.now()
972
1187
  const baseline = await host.observe()
973
1188
  const results = []
@@ -1338,7 +1553,138 @@ function retireRecordInContainer(friendId, did) {
1338
1553
  sh("docker", ["exec", "-u", BUTLER_USER, CONTAINER, "node", "-e", RETIRE_RECORD_SCRIPT, CONTAINER_BUNDLE, friendId, did ?? ""])
1339
1554
  }
1340
1555
 
1341
- export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR, clientDir = DEFAULT_CLIENT_DIR, chainRoot, cardUrl, log = console.error, exec = sh, stage = dockerStage(exec), rootUid = 0, rootGid = 0 } = {}) {
1556
+ /**
1557
+ * Git on the bundle's own repository, run inside the container as the Butler, never as root on the host: after the cutover the
1558
+ * Butler owns `.git`, so its config, hooks and attributes may name any command (a clean filter, a pushurl through `ext::`), and
1559
+ * that command must run with no more than the Butler's own rights. It runs no hooks and no fsmonitor, and takes no optional lock.
1560
+ * `env` adds `-e NAME=value` pairs (ls-remote's ssh). This is the gate's only git call.
1561
+ */
1562
+ export function bundleGit(args, run = sh, { env = [], timeout = 120_000 } = {}) {
1563
+ const envArgs = ["GIT_OPTIONAL_LOCKS=0", ...env].flatMap((pair) => ["-e", pair])
1564
+ return run("docker", ["exec", "-u", BUTLER_USER, ...envArgs, CONTAINER, "git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", CONTAINER_BUNDLE, ...args], { timeout, maxBuffer: 256 * 1024 * 1024 })
1565
+ }
1566
+
1567
+ /** The untracked, unignored paths in the bundle and a hash of each (git's, with no filter run), read in the container. */
1568
+ export function untrackedFiles(git) {
1569
+ const listed = String(git(["status", "--porcelain=v1", "-z", "--untracked-files=all"])).split("\0").filter((entry) => entry.startsWith("?? ")).map((entry) => entry.slice(3))
1570
+ const files = {}
1571
+ for (let i = 0; i < listed.length; i += 200) {
1572
+ const chunk = listed.slice(i, i + 200)
1573
+ const hashes = String(git(["hash-object", "--no-filters", "--", ...chunk])).split("\n").filter(Boolean)
1574
+ chunk.forEach((file, j) => { files[file] = hashes[j] })
1575
+ }
1576
+ return files
1577
+ }
1578
+
1579
+ /**
1580
+ * The host side of bundle sync for the gate: the cutover marker, sync's own pause marker, the lock, one `ouro sync` in the container,
1581
+ * and read-only git. `sleep` and `now` come from the host, so tests drive the clock.
1582
+ */
1583
+ export function makeSyncHost({ bundle, backupDir = DEFAULT_BACKUP_DIR, exec = sh, git = (args) => bundleGit(args, exec), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now(), pollMs = SYNC_LOCK_POLL_MS }) {
1584
+ const state = path.join(bundle, "state")
1585
+ const marker = path.join(state, "sync-paused")
1586
+ const agentName = path.basename(bundle, ".ouro")
1587
+ const branch = () => {
1588
+ try { return readJsonNoFollow(path.join(bundle, "agent.json"))?.sync?.branch ?? "main" } catch { return "main" }
1589
+ }
1590
+ const lines = (text) => text.split("\n").filter(Boolean)
1591
+ // sync-lock.ts: a lock that does not parse is busy for 30 s (a writer may be between create and write), then stale. A parsed
1592
+ // lock is stale when its pid is gone, or when the pid now runs a process that started at another time. The pid is the
1593
+ // container's, so it is checked there; a container that is not running holds nothing.
1594
+ const lockHeld = (lock) => {
1595
+ const stat = lstatOrNull(lock)
1596
+ if (!stat) return false
1597
+ let content
1598
+ try { content = JSON.parse(readTextNoFollow(lock)) } catch { content = null }
1599
+ if (!content || typeof content.pid !== "number" || typeof content.token !== "string") return now() - stat.mtimeMs <= SYNC_LOCK_MALFORMED_MS
1600
+ let procStat
1601
+ try {
1602
+ procStat = exec("docker", ["exec", CONTAINER, "cat", `/proc/${content.pid}/stat`])
1603
+ } catch (error) {
1604
+ const text = `${error?.stderr ?? ""} ${error?.message ?? ""}`
1605
+ if (error?.status === 1 || /no such container|is not running/iu.test(text)) return false
1606
+ return true
1607
+ }
1608
+ const started = String(procStat).slice(String(procStat).lastIndexOf(")") + 2).split(" ")[22 - 3]
1609
+ if (typeof content.procStart === "string" && /^\d+$/u.test(content.procStart) && started !== undefined && started !== content.procStart) return false
1610
+ return true
1611
+ }
1612
+ return {
1613
+ cutoverDone: () => lstatOrNull(path.join(backupDir, "cutover-done")) !== null,
1614
+ /** Sync's pause marker, written as root for the owner of state/ with no link followed. */
1615
+ pause(ms) {
1616
+ const owner = refuseLinkOrWrongType(state, "directory")
1617
+ if (!owner) throw new Error(`${state} does not exist`)
1618
+ const startedAt = new Date(now())
1619
+ writeFileAsRoot(state, "sync-paused", `${JSON.stringify({ startedAt: startedAt.toISOString(), expiresAt: new Date(startedAt.getTime() + ms).toISOString() })}\n`, 0o644, { uid: owner.uid, gid: owner.gid }, "state")
1620
+ },
1621
+ resume() { rmSync(marker, { force: true }) },
1622
+ /** Waits until no live process holds sync's lock. A lock whose holder is dead is stale, as `src/heart/sync-lock.ts` judges it. */
1623
+ async waitLockFree(waitMs) {
1624
+ const lock = path.join(bundle, ".git", "ouro-sync.lock")
1625
+ const deadline = now() + waitMs
1626
+ while (lockHeld(lock)) {
1627
+ if (now() >= deadline) return false
1628
+ await sleep(pollMs)
1629
+ }
1630
+ return true
1631
+ },
1632
+ head: () => git(["rev-parse", "HEAD"]).trim(),
1633
+ async runSync() {
1634
+ try {
1635
+ exec("docker", ["exec", "-u", BUTLER_USER, CONTAINER, "node", CLI_ENTRY, "sync", "--agent", agentName], { timeout: 120_000 })
1636
+ return { ok: true }
1637
+ } catch (error) {
1638
+ return { ok: false, error: `${error.stderr ?? ""} ${error.stdout ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
1639
+ }
1640
+ },
1641
+ /**
1642
+ * The Butler's own non-merge commits between `head` and the remote branch: their paths and their added lines. The remote head
1643
+ * comes from GitHub itself (`git ls-remote`, inside the container through the sync path), never from a tracking ref a push may
1644
+ * not move.
1645
+ */
1646
+ agentChanges(head) {
1647
+ const listed = bundleGit(["ls-remote", "origin", `refs/heads/${branch()}`], exec, { env: [`GIT_SSH_COMMAND=${SYNC_SSH_COMMAND}`, "GIT_SSH_VARIANT=ssh"], timeout: 60_000 })
1648
+ const remoteHead = /^([0-9a-f]{40})\s/u.exec(listed)?.[1]
1649
+ if (!remoteHead) throw new Error(`git ls-remote origin refs/heads/${branch()} listed no head`)
1650
+ const commits = lines(git(["log", "--no-merges", `--author=<${agentName}@ouro.local>`, "--format=%H", `${head}..${remoteHead}`]))
1651
+ if (commits.length === 0) return { remoteHead, commits: 0, paths: [], added: [] }
1652
+ const paths = [...new Set(lines(git(["show", "--format=", "--name-only", ...commits])))]
1653
+ const added = git(["show", "--format=", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", ...commits]).split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1))
1654
+ return { remoteHead, commits: commits.length, paths, added }
1655
+ },
1656
+ untracked: () => untrackedFiles(git),
1657
+ tracking: () => ({
1658
+ psyche: lines(git(["ls-files", "--", "psyche/"])),
1659
+ excludedTracked: lines(git(["ls-files", "-ci", "--exclude-standard"])),
1660
+ status: git(["status", "--porcelain", "--untracked-files=all"]),
1661
+ }),
1662
+ /** Sync's events since `sinceMs` from the bundle's daemon logs, read through no link. */
1663
+ events(sinceMs) {
1664
+ const dir = path.join(state, "daemon", "logs")
1665
+ const stat = lstatOrNull(dir)
1666
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return []
1667
+ const out = []
1668
+ for (const name of readdirSync(dir).filter((entry) => entry.endsWith(".ndjson")).sort()) {
1669
+ let text
1670
+ try { text = readTextNoFollow(path.join(dir, name)) } catch { continue }
1671
+ for (const line of text.split("\n")) {
1672
+ if (!line.includes('"heart.sync_')) continue
1673
+ try {
1674
+ const entry = JSON.parse(line)
1675
+ const at = Date.parse(entry.ts)
1676
+ if (typeof entry.event !== "string" || !(at >= sinceMs)) continue
1677
+ if (entry.meta?.agentRoot !== undefined && entry.meta.agentRoot !== CONTAINER_BUNDLE) continue
1678
+ out.push({ event: entry.event, at })
1679
+ } catch { /* a torn line */ }
1680
+ }
1681
+ }
1682
+ return out
1683
+ },
1684
+ }
1685
+ }
1686
+
1687
+ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR, clientDir = DEFAULT_CLIENT_DIR, backupDir = DEFAULT_BACKUP_DIR, chainRoot, cardUrl, log = console.error, exec = sh, stage = dockerStage(exec), rootUid = 0, rootGid = 0 } = {}) {
1342
1688
  const state = path.join(bundle, "state")
1343
1689
  const replayDir = path.join(state, "replay")
1344
1690
  const clientIssue = rootOwnedDirectoryIssue(clientDir, rootUid)
@@ -1373,6 +1719,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1373
1719
  }
1374
1720
  return {
1375
1721
  friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId, escalation: provisioned.escalation.friendId },
1722
+ sync: makeSyncHost({ bundle, backupDir, exec }),
1376
1723
  now: () => Date.now(),
1377
1724
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1378
1725
  /** The Butler's A2A card must answer before the window opens: a fresh container needs a moment after it turns healthy. */
@@ -2064,6 +2411,7 @@ export function parseArgs(argv) {
2064
2411
  else if (flag === "--trust-dir" && rest[i + 1]) options.trustDir = rest[++i]
2065
2412
  else if (flag === "--card-url" && rest[i + 1]) options.cardUrl = rest[++i]
2066
2413
  else if (flag === "--client-dir" && rest[i + 1]) options.clientDir = rest[++i]
2414
+ else if (flag === "--backup-dir" && rest[i + 1]) options.backupDir = rest[++i]
2067
2415
  else if (flag === "--retire-friend" && rest[i + 1]) options.retire = [...(options.retire ?? []), rest[++i]]
2068
2416
  else if (flag === "--rotate-replay-identities") options.rotate = true
2069
2417
  else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
@@ -2082,12 +2430,17 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
2082
2430
  io.out(problems.length === 0 ? `self-test ok (${CASES.length} cases)` : `self-test FAILED (${problems.length})`)
2083
2431
  return problems.length === 0 ? 0 : 1
2084
2432
  }
2433
+ if (options.command === "status-scan") {
2434
+ // The cutover's step 10: `git status --porcelain --untracked-files=all` on stdin; prints each path sync must never commit.
2435
+ for (const file of statusProblems((deps.readStdin ?? (() => readFileSync(0, "utf8")))())) io.out(file)
2436
+ return 0
2437
+ }
2085
2438
  if (options.command === "provision") {
2086
2439
  try { deps.provision({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.retire ? { retire: options.retire } : {}), ...(options.rotate ? { rotate: true } : {}), log: io.out }); return 0 } catch (error) { io.err(`provision failed: ${error.message}`); return 1 }
2087
2440
  }
2088
2441
  if (options.command === "run") {
2089
2442
  let host
2090
- 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 }
2443
+ try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.backupDir ? { backupDir: options.backupDir } : {}), ...(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 }
2091
2444
  // butler-ship runs this gate against the live version first and hands the upgrade that log through the environment.
2092
2445
  const baselineFile = options.baseline ?? env.OURO_REPLAY_BASELINE
2093
2446
  let baseline
@@ -2105,7 +2458,7 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
2105
2458
  io.out(JSON.stringify({ summary: suite.summary }))
2106
2459
  return suite.summary.ok ? 0 : 1
2107
2460
  }
2108
- 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>]")
2461
+ io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n] [--baseline <file>]|self-test|status-scan> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--backup-dir <dir>] [--card-url <url>]")
2109
2462
  return 2
2110
2463
  }
2111
2464