@ouro.bot/cli 0.1.0-alpha.984 → 0.1.0-alpha.985

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,12 @@
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.985",
6
+ "changes": [
7
+ "The Butler's release check cleans up after itself, and he knows where his TACIT lives."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.984",
6
12
  "changes": [
@@ -873,7 +873,7 @@ export function withTurnNote(checks, turn) {
873
873
  }
874
874
 
875
875
  // 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"]
876
+ export const NEVER_EXCUSED = ["window-trusted", "no-telegram", "owner-policy-untouched", "tacit-restored", "replay-friend-restored"]
877
877
  export const FAILING_ON_LIVE_TOO = "failing-on-live-too"
878
878
 
879
879
  /**
@@ -940,6 +940,29 @@ function tacitRestoredResult(host, snapshot) {
940
940
  return { id: "tacit-restored", status: detail ? "fail" : "pass", checks: [check("TACIT holds the text it had before the run", !detail, detail)] }
941
941
  }
942
942
 
943
+ /** The three fields of the replay principal's friend record that a case can teach it, as the signature in `principalSig` sees them. */
944
+ const LEARNED_FIELDS = ["notes", "toolPreferences", "relationshipPolicy"]
945
+ const learnedOf = (record) => Object.fromEntries(LEARNED_FIELDS.map((field) => [field, record[field]]))
946
+ const learnedSig = (learned) => sha256(JSON.stringify({ notes: learned.notes ?? {}, toolPreferences: learned.toolPreferences ?? {}, relationshipPolicy: learned.relationshipPolicy ?? null }))
947
+
948
+ /** Puts the replay principal's learned friend-record fields back to their snapshot after the run. Returns the result, or null when there was no snapshot. */
949
+ async function restoreReplayFriend(host, learned, log) {
950
+ if (learned === null || learned === undefined) { log("the replay friend's record was unreadable before the run, so it is left as found"); return null }
951
+ host.restoreReplayFriendLearned(learned)
952
+ return replayFriendRestoredResult(host, learned)
953
+ }
954
+
955
+ /** Reads the principal's record back after the restore. A read that fails is a failed restore, never a skipped one. */
956
+ function replayFriendRestoredResult(host, learned) {
957
+ let detail
958
+ try {
959
+ detail = learnedSig(host.readReplayFriendLearned()) === learnedSig(learned) ? "" : "the replay friend's record differs from the snapshot after the restore"
960
+ } catch (error) {
961
+ detail = `the replay friend's record could not be read after the restore: ${error?.message ?? error}`
962
+ }
963
+ 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
+ }
965
+
943
966
  export async function runSuite(host, { cases = CASES.map((entry) => entry.id), plant, windowMinutes = DEFAULT_WINDOW_MINUTES, liveResults, log = console.error } = {}) {
944
967
  const unknown = [...cases, ...(plant ? [plant] : [])].filter((id) => !CASES.some((entry) => entry.id === id))
945
968
  if (unknown.length) throw new Error(`unknown case(s): ${unknown.join(", ")}. Known: ${CASES.map((entry) => entry.id).join(", ")}`)
@@ -951,6 +974,7 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
951
974
  let aborted = null
952
975
  let failing = false
953
976
  let restored = null
977
+ let friendRestored = null
954
978
  host.openWindow(windowMinutes)
955
979
  try {
956
980
  // The owner notice must reach the sink, never Telegram. Ask the Butler, through its own code, whether it sees the
@@ -971,14 +995,22 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
971
995
  // what it held before the first case, once, after the last, whatever happened in between. A restore that fails while an
972
996
  // earlier error is on its way out is logged, never put in that error's place.
973
997
  try { host.closeWindow() } catch (error) { failing = true; throw error } finally {
998
+ let restoreError = null
974
999
  try { restored = await restoreTacit(host, baseline.tacit, log) } catch (error) {
975
- if (!failing) throw error
976
- log(`could not restore TACIT after the run (an earlier error is reported instead): ${error?.message ?? error}`)
1000
+ if (!failing) restoreError = error
1001
+ else log(`could not restore TACIT after the run (an earlier error is reported instead): ${error?.message ?? error}`)
1002
+ }
1003
+ // The replay principal's friend record: a case that saves a note or a preference leaves it behind for the next release run.
1004
+ // A failed TACIT restore does not stop this one; the first restore error is the one thrown, the other is logged.
1005
+ try { friendRestored = await restoreReplayFriend(host, baseline.principalLearned, log) } catch (error) {
1006
+ if (!failing && !restoreError) restoreError = error
1007
+ else log(`could not restore the replay friend's record after the run (an earlier error is reported instead): ${error?.message ?? error}`)
977
1008
  }
1009
+ if (restoreError) throw restoreError
978
1010
  }
979
1011
  }
980
1012
  if (aborted) {
981
- const entries = [aborted, ...(restored ? [restored] : [])]
1013
+ const entries = [aborted, ...(restored ? [restored] : []), ...(friendRestored ? [friendRestored] : [])]
982
1014
  return { results: entries, summary: { ok: false, passed: entries.filter((entry) => entry.status === "pass").length, skipped: 0, failed: entries.filter((entry) => entry.status === "fail").map((entry) => entry.id) } }
983
1015
  }
984
1016
  const runEnd = host.now()
@@ -990,7 +1022,7 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
990
1022
  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)] }
991
1023
  const policyChecks = ownerPolicyUntouched(baseline, finalObservation)
992
1024
  const policy = { id: "owner-policy-untouched", status: policyChecks.every((entry) => entry.ok) ? "pass" : "fail", checks: policyChecks }
993
- const all = [...results, policy, telegram, ...(restored ? [restored] : [])]
1025
+ const all = [...results, policy, telegram, ...(restored ? [restored] : []), ...(friendRestored ? [friendRestored] : [])]
994
1026
  const judged = liveResults ? applyBaseline(all, liveResults, { plant }) : all
995
1027
  return { results: judged, summary: summarize(judged) }
996
1028
  }
@@ -1006,7 +1038,7 @@ const sessionOf = (calls) => ({
1006
1038
  })
1007
1039
 
1008
1040
  function emptyObservation(overrides = {}) {
1009
- return { effectsReadable: true, stewardSha: "aaa", principalSig: "p1", psycheSha: "s1", tacit: { text: "# TACIT\n", mode: 0o644 }, identitySha: "i1", radarrProfilesSha: "r1", ledgerLines: 3, queue: [], radarrQueue: [], containers: { "calibre-web": true }, awaiting: [], done: [], effects: [], sink: [], outbox: {}, outboxBodies: {}, ...overrides }
1041
+ return { effectsReadable: true, stewardSha: "aaa", principalSig: "p1", principalLearned: { notes: {}, toolPreferences: {}, relationshipPolicy: null }, psycheSha: "s1", tacit: { text: "# TACIT\n", mode: 0o644 }, identitySha: "i1", radarrProfilesSha: "r1", ledgerLines: 3, queue: [], radarrQueue: [], containers: { "calibre-web": true }, awaiting: [], done: [], effects: [], sink: [], outbox: {}, outboxBodies: {}, ...overrides }
1010
1042
  }
1011
1043
 
1012
1044
  const SELF_TEST_CONTEXT = "3d305f1b-0000-4000-8000-000000000000"
@@ -1450,6 +1482,28 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1450
1482
  if (!refuseLinkOrWrongType(dir, "directory")) throw new Error(`refusing to restore TACIT.md: ${dir} does not exist`)
1451
1483
  writePsycheFileAsRoot(dir, name, text, mode, { uid: BUTLER_UID, gid: BUTLER_GID })
1452
1484
  },
1485
+ /**
1486
+ * Puts the replay principal's notes, toolPreferences and relationshipPolicy back to the snapshot, as root, and leaves every other field as found.
1487
+ * A record that already holds the snapshot is left untouched; otherwise it is rewritten owned by its current uid and gid with mode 0600.
1488
+ * Refuses a link, a hard link and any friend but the principal.
1489
+ */
1490
+ restoreReplayFriendLearned(learned, friendId = provisioned.principal.friendId) {
1491
+ if (friendId !== provisioned.principal.friendId) throw new Error(`refusing to restore the record of ${friendId}: only the replay principal is restored`)
1492
+ const file = friendFile(bundle, friendId)
1493
+ const stat = refuseLinkOrWrongType(file, "file")
1494
+ if (!stat) throw new Error(`refusing to restore ${file}: it does not exist`)
1495
+ if (stat.nlink > 1) throw new Error(`refusing: ${file} has another hard link. Nothing was changed.`)
1496
+ const record = readJsonNoFollow(file)
1497
+ // Already what it was before the run: write nothing, so a write the Butler makes meanwhile cannot be lost.
1498
+ if (learnedSig(learnedOf(record)) === learnedSig(learned)) return
1499
+ for (const field of LEARNED_FIELDS) { if (learned[field] === undefined) delete record[field]; else record[field] = learned[field] }
1500
+ // Owner and mode are set on the temp file's descriptor before the rename; nothing touches the path afterwards.
1501
+ writeFileAsRoot(path.dirname(file), path.basename(file), `${JSON.stringify(record, null, 2)}\n`, 0o600, { uid: stat.uid, gid: stat.gid }, "friends")
1502
+ },
1503
+ /** The principal's notes, toolPreferences and relationshipPolicy as the record holds them now, read through no link. Throws when unreadable. */
1504
+ readReplayFriendLearned() {
1505
+ return learnedOf(readJsonNoFollow(friendFile(bundle, provisioned.principal.friendId)))
1506
+ },
1453
1507
  /** TACIT's text and mode ({ null, null } when missing), read through no link, with no docker or network. Throws on a link. */
1454
1508
  readPsycheFile(name) {
1455
1509
  if (name !== "TACIT.md") throw new Error(`refusing to read psyche/${name} here: only TACIT.md is read`)
@@ -1481,10 +1535,11 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1481
1535
  // What the replay principal's friend record holds, the psyche folder, and Radarr's quality profiles: a case that must
1482
1536
  // not change them compares these before and after. null means unreadable, which never counts as unchanged.
1483
1537
  let principalSig = null
1538
+ let principalLearned = null
1484
1539
  try {
1485
- const record = readJson(path.join(bundle, "friends", `${provisioned.principal.friendId}.json`))
1486
- principalSig = sha256(JSON.stringify({ notes: record.notes ?? {}, toolPreferences: record.toolPreferences ?? {}, relationshipPolicy: record.relationshipPolicy ?? null }))
1487
- } catch { /* stays null */ }
1540
+ principalLearned = learnedOf(readJson(friendFile(bundle, provisioned.principal.friendId)))
1541
+ principalSig = learnedSig(principalLearned)
1542
+ } catch { /* both stay null */ }
1488
1543
  let psycheSha = null
1489
1544
  try {
1490
1545
  const dir = path.join(bundle, "psyche")
@@ -1527,6 +1582,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1527
1582
  stewardSha: hashOf(policy),
1528
1583
  auditSha: hashOf(audit),
1529
1584
  principalSig,
1585
+ principalLearned,
1530
1586
  psycheSha,
1531
1587
  tacit,
1532
1588
  identitySha,
@@ -1705,6 +1761,11 @@ export function freshRootDirectoryInBundle(dir, mode, { rootUid = 0, rootGid = 0
1705
1761
  * temp file is written, or nothing is renamed; an existing file with another hard link is refused, since replacing it would leave that other name behind.
1706
1762
  */
1707
1763
  function writePsycheFileAsRoot(dir, name, text, mode, owner) {
1764
+ writeFileAsRoot(dir, name, text, mode, owner, "psyche")
1765
+ }
1766
+
1767
+ /** The same write for any folder the Butler's user can write; `label` names the folder in what it refuses (the psyche folder, the friends folder). */
1768
+ function writeFileAsRoot(dir, name, text, mode, owner, label) {
1708
1769
  const folder = refuseLinkOrWrongType(dir, "directory")
1709
1770
  const sameFolder = () => { const now = lstatOrNull(dir); return now !== null && !now.isSymbolicLink() && now.dev === folder.dev && now.ino === folder.ino }
1710
1771
  const file = path.join(dir, name)
@@ -1717,12 +1778,12 @@ function writePsycheFileAsRoot(dir, name, text, mode, owner) {
1717
1778
  // that file has no other name.
1718
1779
  const opened = fstatSync(fd)
1719
1780
  const named = lstatOrNull(tmp)
1720
- if (!sameFolder() || !named || named.dev !== opened.dev || named.ino !== opened.ino || opened.nlink !== 1) throw new Error(`refusing: the psyche folder or the temp file for ${name} changed as it was created. Nothing was written.`)
1781
+ if (!sameFolder() || !named || named.dev !== opened.dev || named.ino !== opened.ino || opened.nlink !== 1) throw new Error(`refusing: the ${label} folder or the temp file for ${name} changed as it was created. Nothing was written.`)
1721
1782
  writeFileSync(fd, text)
1722
1783
  ops.fchown(fd, owner.uid, owner.gid)
1723
1784
  fchmodSync(fd, mode)
1724
1785
  } finally { closeSync(fd) }
1725
- if (!sameFolder()) throw new Error(`refusing: the psyche folder changed while ${name} was written. Nothing was renamed.`)
1786
+ if (!sameFolder()) throw new Error(`refusing: the ${label} folder changed while ${name} was written. Nothing was renamed.`)
1726
1787
  renameSync(tmp, file)
1727
1788
  }
1728
1789
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.984",
2
+ "runtimeVersion": "0.1.0-alpha.985",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -9,4 +9,5 @@
9
9
  - When {{owner}} asks how the whole house is doing, I cover active work, what waits on {{owner}}, snoozed wake times, services quiet on purpose, healthy systems and other current issues, in household words. I leave out daemons, event queues, provider lanes and backend service names.
10
10
  - When {{owner}} hands the chat to someone, that person is reading this chat now. I greet them here and start right away, with a question when {{owner}} asked me to find out what they want. I use the name {{owner}} gives me, and no other.
11
11
  - When I change SOUL, IDENTITY, LORE or ASPIRATIONS, I tell {{owner}} in one line which file changed and why. What I learn goes into TACIT quietly.
12
- - When anyone in the house tells me how the house works, I write it into TACIT this turn and say it's noted.
12
+ - When anyone in the house tells me how the house works, I write it into `/home/ouro/AgentBundles/sanctuary.ouro/psyche/TACIT.md` this turn and say it's noted. Teaching me the house takes no one's say-so.
13
+ - A change to who I am is made in a one-to-one chat with someone in my family. Asked anywhere else, I say that in one line and save nothing.
@@ -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.984</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.985</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.984",
3
+ "version": "0.1.0-alpha.985",
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.984",
9
+ "version": "0.1.0-alpha.985",
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.984",
3
+ "version": "0.1.0-alpha.985",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },