@ouro.bot/cli 0.1.0-alpha.903 → 0.1.0-alpha.905

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,18 @@
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.905",
6
+ "changes": [
7
+ "Ouro keeps itself current without a human. New `ouro self-update` runs one unattended pass. It honors an `ouro rollback` pin for seven days and then rewrites the intent to latest (pins now record pinnedAt, and a pin without one gets it written into version-intent.json when first seen). It asks registry.npmjs.org, the npm registry configured on the machine and GitHub releases for the newest @ouro.bot/cli and installs from whichever serves it, so a network that blocks npmjs or a lagging npm mirror no longer strands a machine; a GitHub release counts only when github-actions[bot] created the release and its asset under this repository's v<version> download URL. It validates and runs the new version's --version before activating it, swaps CurrentVersion atomically, restarts the daemon only if it was already running (a stopped daemon stays stopped, and a dev-mode daemon is left alone), and when the restarted daemon does not answer with the new version it reactivates the previous one, pins it with reason auto-rollback and never retries that release; a newer release replaces the auto-rollback pin. When nothing is newer but a running daemon still reports an older version than CurrentVersion, it restarts the daemon with the same health check and rollback, and the current status names any release source that did not answer. Each pass writes ~/.ouro-cli/update-state.json, and `ouro status` ends with an Updates line that starts with \"needs attention\" when the last pass failed, the updater has not run for three hours, or the launchd agent is missing. On macOS, `ouro up` and `ouro self-update` install bot.ouro.updater, a launchd agent that runs the pass at login and hourly, independent of the daemon, preferring a Homebrew node of the same major version so nvm switches do not rewrite it; `ouro up` does not reload the agent while a live pass holds the update lock, the lock is reclaimed only when its owner process is gone, and the `ouro up` it runs to restart the daemon has stdin closed and OURO_UNATTENDED=1 so its prompts take their default answer. After publishing to npm, CI downloads the exact tarball npm serves, attests its build provenance and attaches it to a GitHub release. Rolling back below this version stops unattended updates until `ouro up --latest`. A package end-to-end test serves the packed build as a GitHub release and runs `ouro self-update` in a clean HOME. See docs/known-issues-and-recovery.md. The Unraid template and the Sanctuary bundle runtime move to this version."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.904",
12
+ "changes": [
13
+ "The Butler no longer throws away its answer when someone is picking a movie, never sends a rejected draft or the runtime's note about it, says a title is on the shelf only when a lookup this turn found it there, and holds house notices while Ari's chat is handed to someone else."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.903",
6
18
  "changes": [
@@ -306,6 +306,9 @@ const WORK_WORDS = "channel more dross from cradle energy. name the Cradle chara
306
306
  const CLICK_ASK = /\bclick(?:ing)?\b|\bopen (?:up )?(?:the )?(?:radarr|sonarr|settings|ui|web ?ui|dashboard)\b|\b(?:settings|config(?:uration)?)[- ](?:screen|page|ui)\b|\bten[- ]second\b|\bmanual(?:ly)? (?:step|change|click)/i
307
307
  const BOLD_LABEL = /(?:^|\n)\s*(?:[-*\u2022]\s+)?\*\*[^*\n]{1,60}\*\*[ \t]*(?::|\u2014|-|\n|$)|\*\*[^*\n]{1,60}:\*\*/
308
308
  const BRIEF_LIMIT = 600
309
+ const HANDOFF_WORDS = "hello pal! I’m going to hand you to my wife, Rachel. I’d love for you to help her pick a movie from our Jellyfin library. Keep your responses brief to the point and have a really fun time interviewing her to find exactly what she wants to watch. She’s the boss, do what she says :) say hi to her!"
310
+ const GREETING = /\b(?:hi|hey|hello|hiya|howdy|welcome)\b/i
311
+ const RUNTIME_NOTE_ECHO = /\b(?:markdown|bold\s+(?:section\s+)?labels?|section\s+labels?|question\s+at\s+the\s+end|closing\s+question|settle\s+again|rewrite\s+the\s+reply|cap\s+it|runtime\s+note|private\s+note)\b/i
309
312
  /** A request filed with Claude Code: a failure report, or a message whose recipient or body names Claude Code. */
310
313
  // The change reaches him through a release, not mid-conversation. Any honest description of that path counts; alpha.883 live: "personality changes live in the files that ship with each release, so they arrive in the next update".
311
314
  export const describesReleasePath = (text) => /\breleases?\b|\bnext update\b|\bengineers?\b|claude code|pull request|\bPRs?\b/i.test(text)
@@ -534,7 +537,22 @@ export const CASES = [
534
537
  check(`the reply is at most ${BRIEF_LIMIT} characters`, text.length > 0 && text.length <= BRIEF_LIMIT, `${text.length} characters`),
535
538
  check("the reply has no markdown headers", !/^#{1,6}\s/m.test(text)),
536
539
  check("the reply has no bold section labels", !BOLD_LABEL.test(text)),
537
- check("the reply does not end with a question", !text.trimEnd().endsWith("?")),
540
+ ]
541
+ },
542
+ },
543
+ {
544
+ // 2026-10-11: Ari handed his chat to Rachel and asked the Butler to say hi and interview her. It sent a title with no
545
+ // greeting, and later its own note about the brevity rules. A handoff gets a greeting and a question.
546
+ id: "rach-handoff",
547
+ words: HANDOFF_WORDS,
548
+ sender: "principal",
549
+ delegated: false,
550
+ readback: ({ reply, said }) => {
551
+ const text = String(said || reply || "")
552
+ return [
553
+ check("the reply greets her", GREETING.test(text), text.slice(0, 120)),
554
+ check("the reply asks her a question", text.includes("?")),
555
+ check("the reply carries no runtime note", !RUNTIME_NOTE_ECHO.test(text), (RUNTIME_NOTE_ECHO.exec(text) ?? [""])[0]),
538
556
  ]
539
557
  },
540
558
  },
@@ -853,6 +871,13 @@ export function selfTestFixtures() {
853
871
  pass: { trace: [], reply: "A movie is one film. A series has seasons of episodes.", before: emptyObservation(), after: emptyObservation() },
854
872
  fail: { trace: [], reply: `## Difference\n**Movies**: ${"long ".repeat(130)}\nWant me to go on?`, before: emptyObservation(), after: emptyObservation() },
855
873
  },
874
+ "rach-handoff": {
875
+ pass: { trace: [], reply: "Hi Rachel! Silly and cosy, or loud and dumb-fun tonight?", before: emptyObservation(), after: emptyObservation() },
876
+ // The live turn-1 reply: a title, no greeting, no question.
877
+ fail: { trace: [], reply: "**The Love Hypothesis** (2026) is on the shelf and ready to play.", before: emptyObservation(), after: emptyObservation() },
878
+ // The live turn-4 reply: the runtime's note, sent to her.
879
+ failNote: { trace: [], reply: "hi! got it. dummy bad, no markdown, no question at the end. just plain answer, cap it. what are you after?", before: emptyObservation(), after: emptyObservation() },
880
+ },
856
881
  }
857
882
  return { friends, fx }
858
883
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.903",
2
+ "runtimeVersion": "0.1.0-alpha.905",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -24,4 +24,6 @@ What I say about a book, show, film or its characters comes from a source I read
24
24
 
25
25
  I never tell Ari to click, open a settings page, or do a ten-second manual step. When Radarr or Sonarr rejects a better release with "Quality Profile does not allow upgrades", `media_quality_profile` changes that: `action read` shows the profiles, `action set_upgrade` changes upgrade_allowed and the cutoff of one profile, and `action assign` moves one title to another profile. Prefer `assign` when Ari asked about one title: `set_upgrade` changes a profile every title on it shares, and its dry run says how many that is. Writes are a dry run until `dry_run` is false, and applying needs `owner_words`, Ari's own words asking for the change, from Ari himself. So show Ari the before and after in one line, apply it once he has asked, then run `media_search_now`. If no tool can do what he asks, I file it with Claude Code and say I did.
26
26
 
27
- When Ari asks me to be brief, my replies are plain sentences with no headers or bold labels, about 600 characters at most, and they do not end with a question he already answered. The runtime holds me to this once he has asked.
27
+ When Ari asks me to be brief, my replies are plain sentences with no headers or bold labels, about 600 characters at most. The runtime holds me to this once he has asked.
28
+
29
+ I say a title is on the shelf only when a library lookup in the same turn found it there; otherwise I say I can check whether we have it.
@@ -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.903</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.905</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -22,6 +22,7 @@ exports.runAgent = runAgent;
22
22
  const config_1 = require("./config");
23
23
  const identity_1 = require("./identity");
24
24
  const source_grounding_1 = require("./source-grounding");
25
+ const shelf_claims_1 = require("./shelf-claims");
25
26
  const reply_style_1 = require("./reply-style");
26
27
  const tools_1 = require("../repertoire/tools");
27
28
  const tool_arguments_1 = require("../repertoire/tool-arguments");
@@ -657,6 +658,19 @@ function historicalFailedEffectsForExactRepeatedRequest(messages, selection) {
657
658
  return [...failedEffects.values()];
658
659
  }
659
660
  const ANSWER_GATE_MAX_REJECTIONS = 2;
661
+ /** Brevity and shelf claims are retried once; after that the reply goes out (an unbacked shelf claim with a disclosure), because a real draft beats another lap. */
662
+ const ANSWER_STYLE_MAX_RETRIES = 1;
663
+ function privateGateNote(reason) {
664
+ return `[private runtime note, not from the person: do not mention, quote or acknowledge it; send the person only the corrected reply] ${reason}`;
665
+ }
666
+ /** A reply that talks about the runtime's correction (its formatting rules, the retry itself) instead of answering the person. */
667
+ const GATE_NOTE_ECHO = /\b(?:markdown|bold\s+(?:section\s+)?labels?|section\s+labels?|question\s+at\s+the\s+end|closing\s+question|settle\s+again|rewrite\s+the\s+reply|cap\s+it|runtime\s+note|private\s+note)\b/i;
668
+ /** The settle call as delivered, so the history holds what the person got rather than a draft the runtime replaced. */
669
+ function withSettleAnswer(msg, settleArgs, answer) {
670
+ const call = msg.tool_calls[0];
671
+ const args = { ...settleArgs, answer };
672
+ return { ...msg, tool_calls: [{ ...call, function: { ...call.function, arguments: JSON.stringify(args) } }, ...msg.tool_calls.slice(1)] };
673
+ }
660
674
  function userTexts(messages) {
661
675
  return messages.filter((message) => message.role === "user").map((message) => messageContentText(message.content));
662
676
  }
@@ -1170,6 +1184,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1170
1184
  const turnStartLength = messages.length;
1171
1185
  let groundingRejections = 0;
1172
1186
  let brevityRejections = 0;
1187
+ let shelfRejections = 0;
1188
+ let lastRealDraft = null;
1173
1189
  const privateReturnHeldTokens = new Set();
1174
1190
  // Once-per-turn flag for the fresh-work rest gate. Without this, an agent
1175
1191
  // that called rest, was told "fresh work arrived", processed the items,
@@ -1212,9 +1228,31 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1212
1228
  // and a plain-text reply with no tool call alike (alpha.886 replay: a plain-text reply skipped them and an invented cast went out).
1213
1229
  // A request-specific contract (options.requiredToolCalls) already validates its own terminal answer. Each gate has its own bounded
1214
1230
  // retry, and none spends the last provider iteration.
1231
+ // A rejected reply never reaches the person, and neither does the runtime's note about it: the retry gets the reason as a private
1232
+ // note, and a retry that only talks about that note is replaced by the last real draft (2026-10-11: "got it. dummy bad, no markdown,
1233
+ // no question at the end. just plain answer, cap it." went to Rachel).
1234
+ // `replacement` is the text to deliver instead of the answer, with any disclosure already applied; null delivers the answer as is.
1215
1235
  const answerGateVerdict = (answer, intent) => {
1236
+ if (lastRealDraft !== null && GATE_NOTE_ECHO.test(answer)) {
1237
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_echo_replaced", message: "a retry talked about the runtime's note instead of answering; delivering the last real draft", meta: { length: answer.length } });
1238
+ return { error: null, replacement: disclosed(lastRealDraft, answerGateCheck(lastRealDraft, intent, true)) };
1239
+ }
1240
+ const verdict = answerGateCheck(answer, intent, false);
1241
+ if (verdict.error) {
1242
+ lastRealDraft = answer;
1243
+ return { error: privateGateNote(verdict.error), replacement: null };
1244
+ }
1245
+ const delivered = disclosed(answer, verdict);
1246
+ return { error: null, replacement: delivered === answer ? null : delivered };
1247
+ };
1248
+ const disclosed = (answer, verdict) => {
1249
+ const shelfChecked = verdict.shelfUnverified ? (0, shelf_claims_1.withShelfDisclosure)(answer, turnToolRecords) : answer;
1250
+ return verdict.unverifiedNames ? (0, source_grounding_1.withUnverifiedDisclosure)(shelfChecked, verdict.unverifiedNames) : shelfChecked;
1251
+ };
1252
+ // `final` grades a draft that is going out regardless: nothing is rejected, and what would have been is disclosed instead.
1253
+ const answerGateCheck = (answer, intent, final) => {
1216
1254
  const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1217
- const retriesLeft = providerIterations < stepBudget - 1;
1255
+ const retriesLeft = !final && providerIterations < stepBudget - 1;
1218
1256
  let groundingError = null;
1219
1257
  let unverifiedNames = null;
1220
1258
  const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
@@ -1229,16 +1267,28 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1229
1267
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1230
1268
  }
1231
1269
  }
1270
+ let shelfError = null;
1271
+ let shelfUnverified = false;
1272
+ const shelfFinding = !groundingError && gates?.shelfClaims ? (0, shelf_claims_1.shelfClaimError)(answer, turnToolRecords) : null;
1273
+ if (shelfFinding && shelfRejections < ANSWER_STYLE_MAX_RETRIES && retriesLeft) {
1274
+ shelfRejections += 1;
1275
+ shelfError = shelfFinding;
1276
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer said something is on the shelf that no lookup this turn found there; sending the model back", meta: { gate: "shelf_claims", rejection: shelfRejections, cap: ANSWER_STYLE_MAX_RETRIES } });
1277
+ }
1278
+ else if (shelfFinding) {
1279
+ shelfUnverified = true;
1280
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "an unbacked shelf claim survived the retry; delivering with a disclosure", meta: { gate: "shelf_claims" } });
1281
+ }
1232
1282
  let brevityError = null;
1233
- if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1283
+ if (!groundingError && !shelfError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_STYLE_MAX_RETRIES && retriesLeft
1234
1284
  && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1235
1285
  brevityError = (0, reply_style_1.briefStyleViolation)(answer, intent);
1236
1286
  if (brevityError) {
1237
1287
  brevityRejections += 1;
1238
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
1288
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_STYLE_MAX_RETRIES } });
1239
1289
  }
1240
1290
  }
1241
- return { error: groundingError ?? brevityError, unverifiedNames };
1291
+ return { error: groundingError ?? shelfError ?? brevityError, unverifiedNames, shelfUnverified };
1242
1292
  };
1243
1293
  const rejectToolBatch = (assistant, reasons) => {
1244
1294
  rejectAttempt(assistant, assistant.tool_calls.map((call, index) => ({
@@ -1815,12 +1865,11 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1815
1865
  rejectAttempt(msg, [{ role: "user", content: textVerdict.error }]);
1816
1866
  continue;
1817
1867
  }
1818
- if (textVerdict?.unverifiedNames) {
1819
- const disclosed = (0, source_grounding_1.withUnverifiedDisclosure)(textAnswer, textVerdict.unverifiedNames);
1868
+ if (textVerdict?.replacement) {
1820
1869
  streamCallbackBuffer?.discard();
1821
1870
  callbacks.onClearText?.();
1822
- callbacks.onTextChunk(disclosed);
1823
- pushGenerated({ ...msg, content: disclosed });
1871
+ callbacks.onTextChunk(textVerdict.replacement);
1872
+ pushGenerated({ ...msg, content: textVerdict.replacement });
1824
1873
  done = true;
1825
1874
  continue;
1826
1875
  }
@@ -2026,7 +2075,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2026
2075
  sawSettleContentMismatch = true;
2027
2076
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
2028
2077
  }
2029
- const { error: answerGateError, unverifiedNames } = answerGateVerdict(deliveredAnswer, intent);
2078
+ const { error: answerGateError, replacement } = answerGateVerdict(deliveredAnswer, intent);
2030
2079
  const retryError = contentMismatch
2031
2080
  ?? answerGateError
2032
2081
  ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
@@ -2038,11 +2087,12 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2038
2087
  ?? getSettleRetryError(mustResolveBeforeHandoffActive, intent, sawSteeringFollowUp, options?.delegationDecision, sawSendMessageSelf, sawPonder, sawQuerySession, options?.currentObligation ?? null, options?.activeWorkFrame?.inner?.job, sawExternalStateQuery);
2039
2088
  const validDirectReply = mustResolveBeforeHandoffActive && intent === "direct_reply" && sawSteeringFollowUp;
2040
2089
  if (retryError === null) {
2041
- const finalAnswer = unverifiedNames ? (0, source_grounding_1.withUnverifiedDisclosure)(deliveredAnswer, unverifiedNames) : deliveredAnswer;
2090
+ const finalAnswer = replacement ?? deliveredAnswer;
2091
+ const rewritten = replacement !== null;
2042
2092
  try {
2043
- if (unverifiedNames && result.settleStreamed)
2093
+ if (rewritten && result.settleStreamed)
2044
2094
  callbacks.onClearText?.();
2045
- if (unverifiedNames || !result.settleStreamed) {
2095
+ if (rewritten || !result.settleStreamed) {
2046
2096
  const acceptedOutputCallbacks = streamCallbackBuffer?.callbacks ?? callbacks;
2047
2097
  acceptedOutputCallbacks.onTextChunk(finalAnswer);
2048
2098
  }
@@ -2061,7 +2111,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2061
2111
  };
2062
2112
  // Retractable owners already hold the validated answer. Final-only
2063
2113
  // owners receive it here, after every semantic continuation gate.
2064
- pushGenerated(msg);
2114
+ pushGenerated(rewritten ? withSettleAnswer(msg, settleArgs, finalAnswer) : msg);
2065
2115
  if (validDirectReply) {
2066
2116
  const resumeWork = "direct reply delivered. resume the unresolved obligation now and keep working until you can finish or clearly report that you are blocked.";
2067
2117
  pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: resumeWork });
@@ -68,6 +68,8 @@ const agent_discovery_1 = require("./agent-discovery");
68
68
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
69
69
  const ouro_bot_global_installer_1 = require("../versioning/ouro-bot-global-installer");
70
70
  const version_intent_1 = require("../versioning/version-intent");
71
+ const self_update_defaults_1 = require("../versioning/self-update-defaults");
72
+ const update_state_1 = require("../versioning/update-state");
71
73
  const logs_prune_1 = require("./logs-prune");
72
74
  const daemon_health_1 = require("./daemon-health");
73
75
  const log_tailer_1 = require("./log-tailer");
@@ -445,6 +447,11 @@ function defaultPrepareDaemonRuntimeReplacement() {
445
447
  (0, child_process_1.execSync)("/bin/sleep 1", { stdio: "ignore" });
446
448
  }
447
449
  async function defaultPromptInput(question) {
450
+ // The unattended updater runs `ouro up` with no one to answer; take the
451
+ // default answer instead of waiting on a stdin that never ends. Piped
452
+ // stdin (scripts, tests) still answers prompts.
453
+ if (process.env.OURO_UNATTENDED === "1")
454
+ return "";
448
455
  const readline = await Promise.resolve().then(() => __importStar(require("readline/promises")));
449
456
  const rl = readline.createInterface({
450
457
  input: process.stdin,
@@ -813,6 +820,16 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
813
820
  getCurrentCliVersion: () => (0, ouro_version_manager_1.getCurrentVersion)({}),
814
821
  getPreviousCliVersion: () => (0, ouro_version_manager_1.getPreviousVersion)({}),
815
822
  listCliVersions: () => (0, ouro_version_manager_1.listInstalledVersions)({}),
823
+ runSelfUpdate: async () => {
824
+ const { runSelfUpdate } = await Promise.resolve().then(() => __importStar(require("../versioning/self-update")));
825
+ const { createDefaultSelfUpdateDeps } = await Promise.resolve().then(() => __importStar(require("../versioning/self-update-defaults")));
826
+ return runSelfUpdate(createDefaultSelfUpdateDeps({ socketPath }));
827
+ },
828
+ ensureUpdaterAgent: () => (0, self_update_defaults_1.ensureDefaultUpdaterAgent)(),
829
+ readUpdateStatusLine: () => (0, update_state_1.formatUpdateStatusLine)((0, update_state_1.readUpdateState)(), {
830
+ now: Date.now(),
831
+ updaterInstalled: (0, self_update_defaults_1.isDefaultUpdaterAgentInstalled)(),
832
+ }),
816
833
  reExecFromNewVersion: (reArgs) => {
817
834
  const entry = path.join((0, ouro_version_manager_1.getOuroCliHome)(), "CurrentVersion", "node_modules", "@ouro.bot", "cli", "dist", "heart", "daemon", "ouro-entry.js");
818
835
  require("child_process").execFileSync("node", [entry, ...reArgs], { stdio: "inherit" });
@@ -5956,6 +5956,46 @@ function writeVersionIntentWithRecoveryLauncher(deps, intent) {
5956
5956
  }
5957
5957
  deps.writeVersionIntent(intent);
5958
5958
  }
5959
+ const SELF_UPDATE_FAILURE_OUTCOMES = new Set(["unreachable", "install-failed", "rejected", "rolled-back"]);
5960
+ async function executeSelfUpdate(command, deps) {
5961
+ if (!deps.runSelfUpdate) {
5962
+ return returnCliFailure(deps, "self-update is not available in this runtime");
5963
+ }
5964
+ const result = await deps.runSelfUpdate();
5965
+ const lines = [`self-update: ${result.outcome}: ${result.summary}`];
5966
+ // Install or refresh the hourly updater after the pass so its first run
5967
+ // (RunAtLoad) does not race this one for the update lock.
5968
+ if (deps.ensureUpdaterAgent) {
5969
+ try {
5970
+ const agent = deps.ensureUpdaterAgent();
5971
+ if (agent === "installed")
5972
+ lines.push("unattended updater: installed (runs hourly and at login)");
5973
+ else if (agent === "written-reload-pending")
5974
+ lines.push("unattended updater: refreshed; reloads on the next interactive run");
5975
+ else if (agent === null && !command.unattended)
5976
+ lines.push("unattended updater: not available on this platform");
5977
+ }
5978
+ catch (error) {
5979
+ lines.push(`unattended updater: could not install: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error)}`);
5980
+ }
5981
+ }
5982
+ const message = lines.join("\n");
5983
+ if (SELF_UPDATE_FAILURE_OUTCOMES.has(result.outcome)) {
5984
+ return returnCliFailure(deps, message);
5985
+ }
5986
+ deps.writeStdout(message);
5987
+ return message;
5988
+ }
5989
+ function withUpdateStatusLine(message, deps) {
5990
+ let line = null;
5991
+ try {
5992
+ line = deps.readUpdateStatusLine?.() ?? null;
5993
+ }
5994
+ catch {
5995
+ line = null;
5996
+ }
5997
+ return line ? `${message}\n\n${line}` : message;
5998
+ }
5959
5999
  async function performSystemSetup(deps) {
5960
6000
  // Install ouro command to PATH (non-blocking)
5961
6001
  if (deps.installOuroCommand) {
@@ -6031,6 +6071,21 @@ async function performSystemSetup(deps) {
6031
6071
  }
6032
6072
  /* v8 ignore stop */
6033
6073
  }
6074
+ // Keep the unattended updater installed so updates do not depend on the daemon.
6075
+ if (deps.ensureUpdaterAgent) {
6076
+ try {
6077
+ deps.ensureUpdaterAgent();
6078
+ }
6079
+ catch (error) {
6080
+ (0, runtime_1.emitNervesEvent)({
6081
+ level: "warn",
6082
+ component: "daemon",
6083
+ event: "daemon.system_setup_updater_agent_error",
6084
+ message: "failed to install the unattended updater launch agent",
6085
+ meta: { error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error) },
6086
+ });
6087
+ }
6088
+ }
6034
6089
  // Register .ouro bundle type (UTI on macOS)
6035
6090
  await registerOuroBundleTypeNonBlocking(deps);
6036
6091
  }
@@ -7274,7 +7329,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7274
7329
  }
7275
7330
  targetVersion = previousVersion;
7276
7331
  }
7277
- writeVersionIntentWithRecoveryLauncher(deps, { schemaVersion: 1, mode: "pinned", targetVersion });
7332
+ writeVersionIntentWithRecoveryLauncher(deps, {
7333
+ schemaVersion: 1,
7334
+ mode: "pinned",
7335
+ targetVersion,
7336
+ pinnedAt: new Date((deps.now ?? Date.now)()).toISOString(),
7337
+ reason: "rollback",
7338
+ });
7278
7339
  deps.activateCliVersion(targetVersion);
7279
7340
  // Stop daemon (non-fatal if not running)
7280
7341
  try {
@@ -7291,6 +7352,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7291
7352
  if (command.kind === "a2a.identity" || command.kind === "a2a.message" || command.kind === "a2a.outbox") {
7292
7353
  return executeA2AClientCommand(command, deps);
7293
7354
  }
7355
+ // ── self-update (unattended-safe update pass; never starts a stopped daemon) ──
7356
+ if (command.kind === "self-update") {
7357
+ return executeSelfUpdate(command, deps);
7358
+ }
7294
7359
  // ── versions command (local install list + published update truth, no daemon socket needed) ──
7295
7360
  if (command.kind === "versions") {
7296
7361
  const versions = deps.listCliVersions?.() ?? [];
@@ -8979,7 +9044,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8979
9044
  if (command.kind === "daemon.status" && (0, cli_render_1.isDaemonUnavailableError)(error)) {
8980
9045
  const message = command.json
8981
9046
  ? daemonUnavailableStatusJsonOutput(deps.socketPath, deps.healthFilePath)
8982
- : (0, cli_render_1.daemonUnavailableStatusOutput)(deps.socketPath, deps.healthFilePath);
9047
+ : withUpdateStatusLine((0, cli_render_1.daemonUnavailableStatusOutput)(deps.socketPath, deps.healthFilePath), deps);
8983
9048
  deps.writeStdout(message);
8984
9049
  return message;
8985
9050
  }
@@ -8994,7 +9059,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8994
9059
  const message = command.kind === "daemon.status"
8995
9060
  ? command.json
8996
9061
  ? formatDaemonStatusJsonOutput(response)
8997
- : (0, cli_render_1.formatDaemonStatusOutput)(response, fallbackMessage)
9062
+ : withUpdateStatusLine((0, cli_render_1.formatDaemonStatusOutput)(response, fallbackMessage), deps)
8998
9063
  : command.kind === "habit.probe" && command.json
8999
9064
  ? habitProbeJsonResponse(response)
9000
9065
  : fallbackMessage;
@@ -62,6 +62,12 @@ exports.COMMAND_REGISTRY = {
62
62
  usage: "ouro rollback [<version>]",
63
63
  example: "ouro rollback 0.1.0-alpha.250",
64
64
  },
65
+ "self-update": {
66
+ category: "Lifecycle",
67
+ description: "Update the installed CLI to the newest release from npm or GitHub, restart a running daemon on it, roll back if it fails its checks, and install the hourly launchd updater on macOS",
68
+ usage: "ouro self-update [--unattended]",
69
+ example: "ouro self-update",
70
+ },
65
71
  versions: {
66
72
  category: "Lifecycle",
67
73
  description: "List installed CLI versions and show the active version intent mode and target",
@@ -2988,6 +2988,13 @@ function parseOuroCommand(args) {
2988
2988
  throw new Error("Usage: ouro move <agent> here");
2989
2989
  return { kind: "agent.move", agent: second };
2990
2990
  }
2991
+ if (head === "self-update") {
2992
+ const rest = args.slice(1);
2993
+ const unknown = rest.filter((token) => token !== "--unattended");
2994
+ if (unknown.length > 0)
2995
+ throw new Error("Usage: ouro self-update [--unattended]");
2996
+ return { kind: "self-update", ...(rest.includes("--unattended") ? { unattended: true } : {}) };
2997
+ }
2991
2998
  if (head === "stop" || head === "down")
2992
2999
  return { kind: "daemon.stop" };
2993
3000
  if (head === "status") {
@@ -13,7 +13,7 @@ exports.savedCommunicationPreference = savedCommunicationPreference;
13
13
  */
14
14
  const runtime_1 = require("../nerves/runtime");
15
15
  exports.BRIEF_MAX_CHARS = 600;
16
- const ASK_BRIEF = /\b(?:be|keep(?:\s+(?:it|things|replies|answers|your\s+(?:replies|answers|messages)))?|stay)\s+(?:more\s+)?(?:brief|briefer|concise|terse|short|shorter|tight|compact)\b|\bbrevity\b|\bno\s+sections\b|\bshort\s+(?:answers|replies|messages)\b/i;
16
+ const ASK_BRIEF = /\b(?:be|keep(?:\s+(?:it|things|replies|responses|answers|your\s+(?:replies|responses|answers|messages)))?|stay)\s+(?:more\s+)?(?:brief|briefer|concise|terse|short|shorter|tight|compact)\b|\bbrevity\b|\bno\s+sections\b|\bshort\s+(?:answers|replies|messages)\b/i;
17
17
  const ASK_LONGER = /\b(?:be\s+(?:more\s+)?(?:detailed|thorough|verbose|longer)|(?:more|full)\s+detail|elaborate|longer\s+(?:answers|replies)|you\s+can\s+be\s+longer|in\s+depth)\b/i;
18
18
  const NEGATED_BRIEF = /\b(?:don['’]?t|do\s+not|stop\s+being|no\s+more|not\s+so|less|never)\s+(?:be\s+|being\s+|so\s+|that\s+)*(?:brief|concise|terse|short|brevity|tight)\b/i;
19
19
  /** A reply that asks for a confirmation or a choice the person must make: the question is the point, so brevity does not apply. */
@@ -31,7 +31,6 @@ function briefStyleRequested(userTexts, savedPreference) {
31
31
  }
32
32
  const HEADER = /^#{1,6}\s/m;
33
33
  const BOLD_LABEL = /(?:^|\n)\s*(?:[-*•]\s+)?\*\*[^*\n]{1,60}\*\*[ \t]*(?::|—|-|\n|$)|\*\*[^*\n]{1,60}:\*\*/;
34
- const SENTENCE_END = /[.!?]+(?:\s+|$)/;
35
34
  /** What is wrong with an answer that should be brief, or null when it is fine. */
36
35
  function briefStyleViolation(answer, intent) {
37
36
  // A blocked reply must say what is blocking, and a reply that asks for a confirmation or a choice must ask it.
@@ -42,9 +41,6 @@ function briefStyleViolation(answer, intent) {
42
41
  problems.push("no markdown headers or bold section labels, just plain sentences");
43
42
  if (answer.length > exports.BRIEF_MAX_CHARS)
44
43
  problems.push(`about ${exports.BRIEF_MAX_CHARS} characters at most (this one is ${answer.length})`);
45
- const sentences = answer.split(SENTENCE_END).filter((part) => part.trim());
46
- if (sentences.length > 1 && answer.trimEnd().endsWith("?"))
47
- problems.push("no closing question tacked onto an answer; answer and stop");
48
44
  if (problems.length > 0)
49
45
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.brief_style_violation", message: "a reply broke the person's brevity request", meta: { problems: problems.length, length: answer.length } });
50
46
  return problems.length === 0 ? null : `the person asked you to be brief. rewrite the reply: ${problems.join("; ")}. say the answer once, in your own voice, keep every warning, uncertainty and required confirmation, cut everything else, and settle again.`;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shelfClaimError = shelfClaimError;
4
+ exports.withShelfDisclosure = withShelfDisclosure;
5
+ /**
6
+ * "On the shelf" is a promise that a title is in the house library and ready to play. A reply may make it only for a
7
+ * title a library lookup in this turn found there (2026-10-11: the Butler told Rachel lists were "all on the shelf"
8
+ * with no lookup, and named titles its own search had just shown were missing). Pure functions; the runtime calls
9
+ * them at settle for agents that opt in (see ToolContext.answerGates).
10
+ */
11
+ const runtime_1 = require("../nerves/runtime");
12
+ const SHELF_CLAIM = /\b(?:on\s+(?:the|our)\s+shelf|shelf[- ]ready|ready\s+to\s+(?:play|watch)|in\s+(?:the|our)\s+(?:library|jellyfin))\b/i;
13
+ /** "isn't on the shelf", "check if it's in our library": the same words, but not a claim that something is there. */
14
+ const NOT_A_CLAIM = /\bnot\b|n['’]t\b|\bno\s+longer\b|\bnever\b|\bif\b|\bwhether\b/i;
15
+ /** How far before a claim a denial word still governs it ("Dune isn't on the shelf"; not "No worries, Dune is on the shelf"). */
16
+ const DENIAL_REACH_WORDS = 4;
17
+ const LIBRARY_LOOKUP_TOOLS = new Set(["media_search", "sanctuary_search_media_catalog"]);
18
+ const MIN_TITLE_LENGTH = 3;
19
+ function normalize(text) {
20
+ return ` ${text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/&/g, " and ").replace(/[^\p{L}\p{N}]+/gu, " ").trim()} `;
21
+ }
22
+ function titlesFrom(records) {
23
+ const onShelf = [];
24
+ const offShelf = [];
25
+ const add = (list, title) => { if (typeof title === "string" && normalize(title).trim().length >= MIN_TITLE_LENGTH)
26
+ list.push({ name: title.trim(), key: normalize(title) }); };
27
+ for (const record of records) {
28
+ if (!LIBRARY_LOOKUP_TOOLS.has(record.name))
29
+ continue;
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(record.result);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ const list = (value) => (Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : []);
38
+ for (const item of list(parsed.items))
39
+ add(item.on_shelf === true ? onShelf : item.on_shelf === false ? offShelf : [], item.title);
40
+ for (const item of list(parsed.library_matches))
41
+ add(item.in_jellyfin === true ? onShelf : [], item.title);
42
+ for (const item of list(parsed.lookup_candidates))
43
+ add(offShelf, item.title);
44
+ // The legacy catalog read lists only what is already in the library.
45
+ for (const item of list(parsed.data?.items))
46
+ add(onShelf, item.untrustedTitle);
47
+ }
48
+ return { onShelf, offShelf: offShelf.filter((title) => !onShelf.some((found) => found.key === title.key)) };
49
+ }
50
+ /** Null when the reply's shelf claims are backed; otherwise the titles this turn's lookup showed missing (empty: nothing found). */
51
+ function unbackedClaim(answer, tools) {
52
+ const clauses = answer.split(/[.!?,;\n]+/);
53
+ const denied = (clause) => NOT_A_CLAIM.test(clause.slice(0, clause.search(SHELF_CLAIM)).trim().split(/\s+/).slice(-DENIAL_REACH_WORDS).join(" "));
54
+ if (!clauses.some((clause) => SHELF_CLAIM.test(clause) && !denied(clause)))
55
+ return null;
56
+ // Titles count from everything but the denials, so "Dune isn't on the shelf" never reads as a claim about Dune.
57
+ const said = normalize(clauses.filter((clause) => !SHELF_CLAIM.test(clause) || !denied(clause)).join(" "));
58
+ const { onShelf, offShelf } = titlesFrom(tools);
59
+ const missing = offShelf.filter((title) => said.includes(title.key)).map((title) => title.name);
60
+ return onShelf.some((title) => said.includes(title.key)) && missing.length === 0 ? null : { missing };
61
+ }
62
+ /** The correction for a reply that says something is on the shelf without this turn's lookup finding it there, or null. */
63
+ function shelfClaimError(answer, tools) {
64
+ const claim = unbackedClaim(answer, tools);
65
+ if (!claim)
66
+ return null;
67
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.shelf_claim_unbacked", message: "a reply said something is on the shelf that no lookup this turn found there", meta: { missing: claim.missing.length } });
68
+ const why = claim.missing.length > 0
69
+ ? `your library lookup this turn shows ${claim.missing.join(", ")} not on the shelf`
70
+ : "no library lookup this turn found what you named";
71
+ return `you said something is on the shelf, but ${why}. say "on the shelf" only for a title a lookup in this turn found there. otherwise say it plainly, like "I can check if we have it", or look it up first.`;
72
+ }
73
+ /** An unbacked shelf claim that survived its retry goes out with the truth in front: what the lookup showed missing, or that nothing was checked. */
74
+ function withShelfDisclosure(answer, tools) {
75
+ const { missing } = unbackedClaim(answer, tools);
76
+ const note = missing.length > 0 ? `My lookup shows ${missing.join(", ")} isn't on the shelf.` : "I haven't checked the library for that yet, so I can't promise it's on the shelf.";
77
+ return `${note}\n\n${answer}`;
78
+ }
@@ -240,6 +240,7 @@ function activateVersion(version, deps) {
240
240
  const readlinkSync = deps.readlinkSync ?? fs.readlinkSync;
241
241
  const unlinkSync = deps.unlinkSync ?? fs.unlinkSync;
242
242
  const symlinkSync = deps.symlinkSync ?? fs.symlinkSync;
243
+ const renameSync = deps.renameSync ?? fs.renameSync;
243
244
  const existsSync = deps.existsSync ?? fs.existsSync;
244
245
  /* v8 ignore stop */
245
246
  const currentVersionPath = path.join(cliHome, "CurrentVersion");
@@ -269,11 +270,33 @@ function activateVersion(version, deps) {
269
270
  }
270
271
  symlinkSync(oldTarget, previousPath);
271
272
  }
272
- // Update CurrentVersion symlink
273
- if (existsSync(currentVersionPath)) {
274
- unlinkSync(currentVersionPath);
273
+ // Swap CurrentVersion atomically: build the new link beside it and rename
274
+ // it over the old one, so a process killed mid-swap never leaves the
275
+ // machine without a CurrentVersion.
276
+ const tempPath = `${currentVersionPath}.next-${process.pid}`;
277
+ try {
278
+ unlinkSync(tempPath);
279
+ }
280
+ catch {
281
+ // No leftover from an earlier interrupted swap.
282
+ }
283
+ symlinkSync(newTarget, tempPath);
284
+ try {
285
+ renameSync(tempPath, currentVersionPath);
286
+ }
287
+ catch {
288
+ // rename cannot replace a real directory; fall back to unlink + link.
289
+ try {
290
+ unlinkSync(tempPath);
291
+ }
292
+ catch {
293
+ // Already gone.
294
+ }
295
+ if (existsSync(currentVersionPath)) {
296
+ unlinkSync(currentVersionPath);
297
+ }
298
+ symlinkSync(newTarget, currentVersionPath);
275
299
  }
276
- symlinkSync(newTarget, currentVersionPath);
277
300
  }
278
301
  /**
279
302
  * Compare two version strings of the form `0.1.0-alpha.{n}`. Returns