@ouro.bot/cli 0.1.0-alpha.870 → 0.1.0-alpha.873

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,24 @@
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.873",
6
+ "changes": [
7
+ "A new butler:ship command (npm run butler:ship -- <version>) ships a Butler release through the replay gate in one step: it waits for the release's coverage-gate and the image on the host, stages that release's upgrade and gate scripts, runs preflight, starts the detached gated upgrade, follows the case lines until the upgrade process has exited, and verifies, then prints one JSON result (kept, rolled_back, refused or failed); it stops early with a clear message when no push-triggered coverage-gate run exists for the merge commit, because the image would never be published."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.872",
12
+ "changes": [
13
+ "The replay gate's books-up case accepts a live service probe as well as the container list, so a correct live answer no longer fails the upgrade."
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.1.0-alpha.871",
18
+ "changes": [
19
+ "Restating an already-recorded steward desired state is now a no-op (no version bump, audit row or source rewrite) and the tool says unchanged; the replay gate's no-telegram check fails only on owner notices attributable to the replay run."
20
+ ]
21
+ },
4
22
  {
5
23
  "version": "0.1.0-alpha.870",
6
24
  "changes": [
@@ -2981,3 +2981,13 @@ Audit and safety verification:
2981
2981
  key can be adopted after process death. Raw recovery proof is durably deleted
2982
2982
  only after the exact authentication rejection and its redacted replayable
2983
2983
  receipt are fsynced. Never substitute names for IDs when revoking keys.
2984
+
2985
+ Shipping a Butler release (one command)
2986
+
2987
+ From the Mac, after the release PR has merged to main:
2988
+
2989
+ npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--plant <case>] [--dry-run]
2990
+
2991
+ 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.
2992
+
2993
+ 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.
@@ -86,14 +86,40 @@ export function isStalledQueueItem(item, nowMs = Date.now()) {
86
86
  return size > 0 && left > 0 && noEta && Number.isFinite(addedAt) && nowMs - addedAt >= STALL_MIN_AGE_HOURS * 3_600_000
87
87
  }
88
88
 
89
- /** Telegram effect records written during [start, end] that would have reached Ari's chat from a replay. */
90
- export function telegramLeaks(effects, start, end) {
89
+ /** Telegram effect records written during [start, end] that are owner notices, before attribution. */
90
+ export function ownerNoticeEffects(effects, start, end) {
91
91
  return effects.filter((effect) => {
92
92
  const at = Date.parse(effect.createdAt)
93
93
  return Number.isFinite(at) && at >= start && at <= end && String(effect.idempotencyKey ?? "").startsWith("owner-notice:")
94
94
  })
95
95
  }
96
96
 
97
+ const AWAIT_NOTICE_KEY = /^owner-notice:await:(.+):(resolved|expired|asked_owner):(\d{4}-\d{2}-\d{2}T[^:]*:[^:]*:[^:]*Z)$/
98
+ const unquote = (value) => String(value ?? "").replace(/^["']|["']$/g, "")
99
+
100
+ /**
101
+ * Splits the in-window owner notices into leaks (attributable to the replay run, or not attributable at all, which fails
102
+ * closed) and info (an await notice whose await was filed by a friend that is not a replay peer: the Butler's real owner
103
+ * work, which correctly reached Ari during the window). awaits: entries { name, createdAt, filedFor } from awaiting/ and
104
+ * awaiting/.done/. replayFriends: the replay peers' friend ids.
105
+ */
106
+ export function classifyOwnerNotices(effects, start, end, { replayFriends = [], awaits = [] } = {}) {
107
+ const leaks = []
108
+ const info = []
109
+ for (const effect of ownerNoticeEffects(effects, start, end)) {
110
+ const key = String(effect.idempotencyKey)
111
+ if (key.startsWith("owner-notice:delegated:")) { leaks.push({ key, reason: "delegated command notice (replay peers are the only delegating senders)" }); continue }
112
+ const match = AWAIT_NOTICE_KEY.exec(key)
113
+ if (!match) { leaks.push({ key, reason: "unattributable owner notice" }); continue }
114
+ const [, name, , createdAt] = match
115
+ const found = awaits.find((entry) => String(entry.name).replace(/\.md$/, "") === name && unquote(entry.createdAt) === createdAt)
116
+ if (!found || !found.filedFor) { leaks.push({ key, reason: "await not found or has no filing friend" }); continue }
117
+ if (replayFriends.includes(found.filedFor)) leaks.push({ key, reason: `await filed by replay friend ${found.filedFor}` })
118
+ else info.push({ key, reason: `await filed by non-replay friend ${found.filedFor}` })
119
+ }
120
+ return { leaks, info }
121
+ }
122
+
97
123
  // ---- the cases ---------------------------------------------------------------------------------------------------
98
124
  // Each case: id, words, sender, delegated, optional applicable(before) -> reason|null, optional poll, readback(ctx) -> checks.
99
125
  // ctx: { trace, reply, error, before, after, sink (lines inside this case's window), friends: { principal, stranger } }
@@ -125,7 +151,8 @@ export const CASES = [
125
151
  const claim = upDownClaim(reply)
126
152
  const actual = after.containers["calibre-web"] ? "up" : "down"
127
153
  return [
128
- check("the container list tool was used", callsNamed(trace, /^unraid_list_containers$/).length > 0),
154
+ // Either live check counts: the container list, or the service probe of the Books URL. An answer from memory does not.
155
+ check("a live status tool was used", callsNamed(trace, /^unraid_(list_containers|check_services)$/).length > 0),
129
156
  check("the reply's up/down claim matches docker ps", claim === actual, `claimed ${claim}, docker says ${actual}`),
130
157
  ]
131
158
  },
@@ -278,10 +305,11 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
278
305
  }
279
306
  const runEnd = host.now()
280
307
  const finalObservation = await host.observe()
281
- const leaks = telegramLeaks(finalObservation.effects, runStart, runEnd)
308
+ const { leaks, info } = classifyOwnerNotices(finalObservation.effects, runStart, runEnd, { replayFriends: [host.friends.principal, host.friends.stranger], awaits: [...(finalObservation.awaiting ?? []), ...(finalObservation.done ?? [])] })
282
309
  const readable = finalObservation.effectsReadable === true
283
310
  const clean = readable && leaks.length === 0
284
- const telegram = { id: "no-telegram", status: clean ? "pass" : "fail", checks: [check("no owner-notice Telegram effect was recorded during the run", clean, readable ? leaks.map((leak) => leak.idempotencyKey).join(",") : "state/telegram/effects is not readable, so the absence of a leak cannot be shown")] }
311
+ const detail = readable ? [...leaks.map((leak) => `${leak.key} (${leak.reason})`), ...info.map((entry) => `info: ${entry.key} (${entry.reason})`)].join(", ") : "state/telegram/effects is not readable, so the absence of a leak cannot be shown"
312
+ 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)] }
285
313
  const all = [...results, telegram]
286
314
  const failed = all.filter((entry) => entry.status === "fail").map((entry) => entry.id)
287
315
  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 } }
@@ -354,9 +382,17 @@ export function selfTest() {
354
382
  const stalled = { trackedDownloadStatus: "warning" }
355
383
  if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [{ id: 1 }] })) === null) problems.push("stall-kept ran with no stalled item")
356
384
  if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [stalled] })) !== null) problems.push("stall-kept skipped with a stalled item")
357
- const leaked = telegramLeaks([{ idempotencyKey: "owner-notice:delegated:x", createdAt: new Date(500).toISOString() }, { idempotencyKey: "other", createdAt: new Date(500).toISOString() }], 0, 1000)
358
- if (leaked.length !== 1) problems.push("the telegram leak check missed an in-window owner notice")
359
- if (telegramLeaks([{ idempotencyKey: "owner-notice:x", createdAt: new Date(5000).toISOString() }], 0, 1000).length !== 0) problems.push("the telegram leak check counted an out-of-window record")
385
+ const at = new Date(500).toISOString()
386
+ const created = "2026-10-08T03:03:00.876Z"
387
+ const ctx = { replayFriends: ["p", "s"], awaits: [{ name: "replay-wait.md", createdAt: created, filedFor: "p" }, { name: "chef_show_s2_landed.md", createdAt: created, filedFor: "real-friend" }] }
388
+ const classify = (key) => classifyOwnerNotices([{ idempotencyKey: key, createdAt: at }, { idempotencyKey: "other", createdAt: at }], 0, 1000, ctx)
389
+ if (classify("owner-notice:delegated:x").leaks.length !== 1) problems.push("no-telegram missed a replay-delegated owner notice")
390
+ if (classify(`owner-notice:await:replay-wait:asked_owner:${created}`).leaks.length !== 1) problems.push("no-telegram missed an await notice filed by a replay friend")
391
+ const real = classify(`owner-notice:await:chef_show_s2_landed:asked_owner:${created}`)
392
+ if (real.leaks.length !== 0 || real.info.length !== 1) problems.push("no-telegram did not pass a real owner await notice with an info detail")
393
+ if (classify("owner-notice:mystery").leaks.length !== 1) problems.push("no-telegram did not fail closed on an unknown owner-notice key")
394
+ 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")
395
+ 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")
360
396
  return problems
361
397
  }
362
398
 
@@ -378,7 +414,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
378
414
  const list = (dir) => (existsSync(dir) ? readdirSync(dir) : [])
379
415
  const awaitEntries = (dir) => list(dir).filter((name) => name.endsWith(".md")).map((name) => {
380
416
  const text = readFileSync(path.join(dir, name), "utf8")
381
- return { name, status: /^status:\s*(\S+)/m.exec(text)?.[1] ?? "pending", createdAt: /^created_at:\s*(\S+)/m.exec(text)?.[1] ?? "", reason: /^cancel_reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "" }
417
+ 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() ?? "" }
382
418
  })
383
419
  return {
384
420
  friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId },
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.870",
2
+ "runtimeVersion": "0.1.0-alpha.873",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.870</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.873</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -381,6 +381,13 @@ function updateStewardPolicy(agentRoot, input) {
381
381
  }
382
382
  if (current.version !== input.expectedVersion)
383
383
  throw new Error(`steward policy version changed: expected ${input.expectedVersion}, got ${current.version}`);
384
+ if (input.mutation.kind === "set_desired_state") {
385
+ const existing = current.desiredStates[key];
386
+ const sameValue = existing !== undefined && existing.provenance === input.mutation.provenance && existing.value === affectedKeyResult.value && existing.expiresAt === expiresAt;
387
+ // Restating what is already recorded is a no-op: no version bump, no audit row, no provenance rewrite.
388
+ if (sameValue)
389
+ return current;
390
+ }
384
391
  optionalExpiry(expiresAt, now);
385
392
  const postimage = JSON.stringify(next, null, 2);
386
393
  if (Buffer.byteLength(postimage) > MAX_POLICY_BYTES)
@@ -117,6 +117,7 @@ exports.stewardPolicyToolDefinition = {
117
117
  || ctx.currentSession?.key !== sessionKey || !sessionMatches() || ctx.currentExternalEvent) {
118
118
  throw new Error("steward policy owner authorization or session changed before mutation");
119
119
  }
120
+ const versionBefore = (0, steward_policy_1.readStewardPolicy)(agentRoot).version;
120
121
  const result = (0, steward_policy_1.updateStewardPolicy)(agentRoot, {
121
122
  expectedVersion: expectedVersion ?? (0, steward_policy_1.readStewardPolicy)(agentRoot).version,
122
123
  actor: {
@@ -127,6 +128,11 @@ exports.stewardPolicyToolDefinition = {
127
128
  mutation,
128
129
  });
129
130
  (0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.steward_policy_tool_call", message: "updated steward policy", meta: { action: args.action } });
131
+ if (mutation.kind === "set_desired_state" && result.version === versionBefore) {
132
+ // A same-value restatement writes nothing; the entry is necessarily present.
133
+ const existing = result.desiredStates[mutation.key];
134
+ return JSON.stringify({ ...result, unchanged: true, note: `already recorded as ${existing.value} (source: ${existing.source})` });
135
+ }
130
136
  return JSON.stringify(result);
131
137
  });
132
138
  },
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.870",
3
+ "version": "0.1.0-alpha.873",
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.870",
9
+ "version": "0.1.0-alpha.873",
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.870",
3
+ "version": "0.1.0-alpha.873",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -46,6 +46,7 @@
46
46
  "lint": "eslint src/",
47
47
  "package:verify-assets": "node scripts/package-assets.cjs",
48
48
  "prepack": "npm run build && npm run package:verify-assets",
49
+ "butler:ship": "node scripts/butler-ship.mjs",
49
50
  "release:bump": "node scripts/release-bump.cjs",
50
51
  "release:preflight": "node scripts/release-preflight.cjs",
51
52
  "release:trust:check": "node scripts/npm-trusted-publishers.cjs check",