@ouro.bot/cli 0.1.0-alpha.755 → 0.1.0-alpha.757

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,19 @@
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.757",
6
+ "changes": [
7
+ "Allow authenticated steward policy writes to use the current version when the agent omits an explicit concurrency token.",
8
+ "Keep private external-event turns bound to their exact leased batch, with prompt-safe lease context and deterministic disposition completeness."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.756",
13
+ "changes": [
14
+ "Recover private-runtime turns safely across container restarts and restore Sanctuary steward-policy context."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.755",
6
19
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.755",
2
+ "runtimeVersion": "0.1.0-alpha.757",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-08-30T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>Mendelow Cloud Butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.755</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.757</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -51,6 +51,7 @@ const fs = __importStar(require("node:fs"));
51
51
  const path = __importStar(require("node:path"));
52
52
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
53
53
  const runtime_1 = require("../nerves/runtime");
54
+ const habit_lifecycle_1 = require("../heart/habits/habit-lifecycle");
54
55
  class SessionTurnBusyError extends Error {
55
56
  retryable = true;
56
57
  constructor(message = "session turn is busy") {
@@ -101,34 +102,71 @@ function openLeaseDatabase(lockPath, busyTimeoutMs = 0) {
101
102
  owner_token TEXT NOT NULL
102
103
  )
103
104
  `);
105
+ const columns = new Set(database.prepare(`PRAGMA table_info(session_turn_lease)`).all().map((column) => column.name));
106
+ if (!columns.has("boot_identity"))
107
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN boot_identity TEXT`);
108
+ if (!columns.has("process_started_at"))
109
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN process_started_at TEXT`);
104
110
  return database;
105
111
  }
106
- function claimLeaseRecord(lockPath, record, isProcessAlive) {
112
+ function acquisitionRecord(options) {
113
+ const pid = options.pid ?? process.pid;
114
+ const bootIdentity = (options.getBootIdentity ?? habit_lifecycle_1.probeHabitBootIdentity)();
115
+ const processStartedAt = (options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt)(pid);
116
+ if (!bootIdentity || !processStartedAt)
117
+ throw new SessionTransactionError("current process identity is unavailable");
118
+ return {
119
+ pid,
120
+ ownerId: options.ownerId ?? (0, node_crypto_1.randomUUID)(),
121
+ ownerToken: options.ownerToken ?? (0, node_crypto_1.randomUUID)(),
122
+ bootIdentity,
123
+ processStartedAt,
124
+ };
125
+ }
126
+ function claimLeaseRecord(lockPath, record, isProcessAlive, getProcessStartedAt) {
107
127
  const database = openLeaseDatabase(lockPath);
108
128
  try {
109
129
  return database.transaction(() => {
110
130
  const current = database.prepare(`
111
- SELECT pid, owner_id, owner_token FROM session_turn_lease WHERE singleton = 1
131
+ SELECT pid, owner_id, owner_token, boot_identity, process_started_at FROM session_turn_lease WHERE singleton = 1
112
132
  `).get();
113
133
  if (!current) {
114
134
  database.prepare(`
115
- INSERT INTO session_turn_lease (singleton, pid, owner_id, owner_token) VALUES (1, ?, ?, ?)
116
- `).run(record.pid, record.ownerId, record.ownerToken);
135
+ INSERT INTO session_turn_lease (singleton, pid, owner_id, owner_token, boot_identity, process_started_at) VALUES (1, ?, ?, ?, ?, ?)
136
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt);
117
137
  return { acquired: true, stale: null };
118
138
  }
119
139
  if (!Number.isSafeInteger(current.pid) || current.pid <= 0
120
140
  || typeof current.owner_id !== "string" || current.owner_id.length === 0
121
- || typeof current.owner_token !== "string" || current.owner_token.length === 0) {
141
+ || typeof current.owner_token !== "string" || current.owner_token.length === 0
142
+ || !((current.boot_identity === null && current.process_started_at === null)
143
+ || (typeof current.boot_identity === "string" && current.boot_identity.length > 0
144
+ && typeof current.process_started_at === "string" && current.process_started_at.length > 0))) {
122
145
  throw new SessionTransactionError("invalid session lease record");
123
146
  }
124
- const observed = { pid: current.pid, ownerId: current.owner_id, ownerToken: current.owner_token };
125
- if (isProcessAlive(observed.pid))
147
+ const observed = {
148
+ pid: current.pid,
149
+ ownerId: current.owner_id,
150
+ ownerToken: current.owner_token,
151
+ bootIdentity: current.boot_identity,
152
+ processStartedAt: current.process_started_at,
153
+ };
154
+ const differentBoot = observed.bootIdentity !== null && observed.bootIdentity !== record.bootIdentity;
155
+ let differentProcess = false;
156
+ if (observed.bootIdentity !== null && !differentBoot) {
157
+ const liveStartedAt = getProcessStartedAt(observed.pid);
158
+ differentProcess = liveStartedAt !== null && liveStartedAt !== observed.processStartedAt;
159
+ if (liveStartedAt === observed.processStartedAt)
160
+ return { acquired: false, stale: null };
161
+ }
162
+ if (!differentBoot && !differentProcess && isProcessAlive(observed.pid))
126
163
  return { acquired: false, stale: null };
127
- database.prepare(`
128
- UPDATE session_turn_lease SET pid = ?, owner_id = ?, owner_token = ?
164
+ const changed = database.prepare(`
165
+ UPDATE session_turn_lease SET pid = ?, owner_id = ?, owner_token = ?, boot_identity = ?, process_started_at = ?
129
166
  WHERE singleton = 1 AND pid = ? AND owner_id = ? AND owner_token = ?
130
- `).run(record.pid, record.ownerId, record.ownerToken, observed.pid, observed.ownerId, observed.ownerToken);
131
- return { acquired: true, stale: observed };
167
+ AND boot_identity IS ? AND process_started_at IS ?
168
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt, observed.pid, observed.ownerId, observed.ownerToken, observed.bootIdentity, observed.processStartedAt).changes;
169
+ return changed === 1 ? { acquired: true, stale: observed } : { acquired: false, stale: null };
132
170
  })();
133
171
  }
134
172
  finally {
@@ -140,7 +178,8 @@ function releaseLeaseRecord(lockPath, record) {
140
178
  try {
141
179
  return database.prepare(`
142
180
  DELETE FROM session_turn_lease WHERE singleton = 1 AND pid = ? AND owner_id = ? AND owner_token = ?
143
- `).run(record.pid, record.ownerId, record.ownerToken).changes === 1;
181
+ AND boot_identity IS ? AND process_started_at IS ?
182
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt).changes === 1;
144
183
  }
145
184
  finally {
146
185
  database.close();
@@ -180,9 +219,8 @@ async function acquireSessionTurnLease(sessionPath, options = {}) {
180
219
  return makeLease(existing);
181
220
  }
182
221
  }
183
- const ownerId = options.ownerId ?? (0, node_crypto_1.randomUUID)();
184
- const ownerToken = options.ownerToken ?? (0, node_crypto_1.randomUUID)();
185
- const pid = options.pid ?? process.pid;
222
+ const record = acquisitionRecord(options);
223
+ const { ownerId } = record;
186
224
  const timeoutMs = options.timeoutMs ?? 5_000;
187
225
  const pollIntervalMs = options.pollIntervalMs ?? 10;
188
226
  const isProcessAlive = options.isProcessAlive ?? processAlive;
@@ -192,14 +230,14 @@ async function acquireSessionTurnLease(sessionPath, options = {}) {
192
230
  for (;;) {
193
231
  let claim = null;
194
232
  try {
195
- claim = claimLeaseRecord(lockPath, { pid, ownerId, ownerToken }, isProcessAlive);
233
+ claim = claimLeaseRecord(lockPath, record, isProcessAlive, options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt);
196
234
  }
197
235
  catch (error) {
198
236
  if (!isSqliteBusy(error))
199
237
  throw error;
200
238
  }
201
239
  if (claim?.acquired) {
202
- const held = { sessionPath: canonical, lockPath, pid, ownerId, ownerToken, depth: 1, released: false };
240
+ const held = { sessionPath: canonical, lockPath, ...record, depth: 1, released: false };
203
241
  heldLeases.set(canonical, held);
204
242
  (0, runtime_1.emitNervesEvent)({
205
243
  component: "mind",
@@ -246,15 +284,13 @@ function withImmediateSessionTurnLease(sessionPath, work, options = {}) {
246
284
  const contextual = currentSessionTurnLease(canonical);
247
285
  if (contextual)
248
286
  return work(contextual);
249
- const ownerId = options.ownerId ?? (0, node_crypto_1.randomUUID)();
250
- const ownerToken = options.ownerToken ?? (0, node_crypto_1.randomUUID)();
251
- const pid = options.pid ?? process.pid;
287
+ const record = acquisitionRecord(options);
252
288
  const lockPath = `${canonical}.turn.lock`;
253
289
  fs.mkdirSync(path.dirname(canonical), { recursive: true });
254
290
  const tryAcquire = () => {
255
291
  let claim;
256
292
  try {
257
- claim = claimLeaseRecord(lockPath, { pid, ownerId, ownerToken }, options.isProcessAlive ?? processAlive);
293
+ claim = claimLeaseRecord(lockPath, record, options.isProcessAlive ?? processAlive, options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt);
258
294
  }
259
295
  catch (error) {
260
296
  if (isSqliteBusy(error))
@@ -262,7 +298,7 @@ function withImmediateSessionTurnLease(sessionPath, work, options = {}) {
262
298
  throw error;
263
299
  }
264
300
  if (claim.acquired) {
265
- const held = { sessionPath: canonical, lockPath, pid, ownerId, ownerToken, depth: 1, released: false };
301
+ const held = { sessionPath: canonical, lockPath, ...record, depth: 1, released: false };
266
302
  heldLeases.set(canonical, held);
267
303
  if (claim.stale) {
268
304
  options.onStaleLease?.(claim.stale);
@@ -204,6 +204,7 @@ exports.continuityToolDefinitions = [
204
204
  verificationRefs,
205
205
  },
206
206
  });
207
+ ctx?.externalEventAuthority?.recordCommittedDisposition?.(turnEvent);
207
208
  (0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.external_event_disposition", message: "external event disposition recorded", meta: { agentName, eventId: record.eventId, generation: record.generation, classification, decision } });
208
209
  return JSON.stringify(handled, null, 2);
209
210
  };
@@ -16,12 +16,12 @@ exports.stewardPolicyToolDefinition = {
16
16
  type: "function",
17
17
  function: {
18
18
  name: "steward_policy_manage",
19
- description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated family request and exact policy version.",
19
+ description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated family request. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
20
20
  parameters: {
21
21
  type: "object",
22
22
  properties: {
23
23
  action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action"] },
24
- expectedVersion: { type: "integer", minimum: 0 },
24
+ expectedVersion: { type: "integer", minimum: 0, description: "Optional version returned by read. When omitted, the runtime uses the current policy version." },
25
25
  key: { type: "string" },
26
26
  value: { type: "string" },
27
27
  provenance: { type: "string", enum: ["stated", "observed", "default", "installed_explicit_policy"] },
@@ -51,7 +51,9 @@ exports.stewardPolicyToolDefinition = {
51
51
  const actor = ctx.relationshipAuthorization?.actor;
52
52
  if (!actor)
53
53
  throw new Error("steward policy mutation requires authenticated relationship authority");
54
- const expectedVersion = Number(args.expectedVersion);
54
+ const expectedVersion = args.expectedVersion === undefined
55
+ ? (0, steward_policy_1.readStewardPolicy)(ctx.agentRoot).version
56
+ : Number(args.expectedVersion);
55
57
  if (!Number.isInteger(expectedVersion) || expectedVersion < 0)
56
58
  throw new Error("expectedVersion must be a nonnegative integer");
57
59
  let mutation;
@@ -515,6 +515,7 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
515
515
  const currentHabitName = nextHabitName;
516
516
  const currentTrigger = nextTrigger ?? "overdue";
517
517
  const currentNoSend = nextNoSend;
518
+ const currentExternalEvent = nextExternalEvent;
518
519
  const currentExternalEventHeartbeat = nextExternalEventHeartbeat;
519
520
  nextExternalEventHeartbeat = undefined;
520
521
  const currentHabitRun = currentReason === "habit" && currentHabitName
@@ -655,6 +656,11 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
655
656
  // would cause hasPendingWork() to be true here, producing a
656
657
  // self-sustaining "instinct" loop with no external input. Cap it.
657
658
  if (hasPendingWork(currentHabitRun?.paths.pendingDir)) {
659
+ if (currentExternalEvent && (turnErrors.length > 0
660
+ || (turnResult && typeof turnResult === "object" && "turnOutcome" in turnResult && turnResult.turnOutcome === "errored"))) {
661
+ finalizeCurrentHabitRun();
662
+ break;
663
+ }
658
664
  clearHeartbeatRestShield();
659
665
  if (consecutiveInstinctTurns >= exports.MAX_CONSECUTIVE_INSTINCT_TURNS) {
660
666
  (0, runtime_1.emitNervesEvent)({
@@ -835,6 +835,24 @@ function buildHeldReturnWakeMessage() {
835
835
  "Return each listed item with surface(delegationId=...) before resting or settling.",
836
836
  ].join("\n");
837
837
  }
838
+ function buildExternalEventLeaseMessage(event) {
839
+ const members = [event, ...(event.relatedEvents ?? [])];
840
+ return [
841
+ "[current external-event disposition contract]",
842
+ "This exact lease frame is authoritative for this turn. Ignore older receipts, failed tool arguments, held-work summaries, and checkpoints when choosing disposition arguments.",
843
+ "Investigate the current observation, then call external_event_disposition once for every listed lease before settling.",
844
+ ...members.flatMap((member, index) => [
845
+ "",
846
+ `lease ${index + 1}:`,
847
+ `recordPath: ${JSON.stringify(member.recordPath)}`,
848
+ `expectedGeneration: ${member.generation}`,
849
+ `classifiedRevision: ${JSON.stringify(member.observationRevision)}`,
850
+ ]),
851
+ ].join("\n");
852
+ }
853
+ function externalEventLeaseKey(event) {
854
+ return JSON.stringify([event.recordPath, event.generation, event.observationRevision, event.claimOwner]);
855
+ }
838
856
  function buildAlsoDueLine(agentRoot, currentHabitName, now) {
839
857
  const habitsDir = path.join(agentRoot, "habits");
840
858
  let files;
@@ -1126,7 +1144,10 @@ async function runPrivateRuntimeTurn(options) {
1126
1144
  userContent = buildInstinctUserMessage(instincts, reason, state);
1127
1145
  }
1128
1146
  }
1129
- if (shouldUseHeldReturnWake) {
1147
+ if (options?.externalEvent) {
1148
+ userContent = buildExternalEventLeaseMessage(options.externalEvent);
1149
+ }
1150
+ else if (shouldUseHeldReturnWake) {
1130
1151
  userContent = buildHeldReturnWakeMessage();
1131
1152
  }
1132
1153
  // ── Session loader: wraps existing session logic ──────────────────
@@ -1197,6 +1218,7 @@ async function runPrivateRuntimeTurn(options) {
1197
1218
  };
1198
1219
  })()
1199
1220
  : undefined;
1221
+ const committedExternalEventLeases = new Set();
1200
1222
  const externalEventRelationship = options?.externalEvent
1201
1223
  ? await (async () => {
1202
1224
  const agentRoot = (0, identity_1.getAgentRoot)(agentName);
@@ -1231,6 +1253,9 @@ async function runPrivateRuntimeTurn(options) {
1231
1253
  const decision = initial.authorizeTool("external_event_disposition");
1232
1254
  return decision.allowed ? { allowed: true, reason: "relationship-authorized" } : { allowed: false, reason: decision.reason };
1233
1255
  },
1256
+ recordCommittedDisposition: (event) => {
1257
+ committedExternalEventLeases.add(externalEventLeaseKey(event));
1258
+ },
1234
1259
  },
1235
1260
  externalEventEffects: {
1236
1261
  deliverOwnerDecision: async (input) => {
@@ -1403,6 +1428,13 @@ async function runPrivateRuntimeTurn(options) {
1403
1428
  ...(effectiveHabitSession ? { habitSession: effectiveHabitSession } : {}),
1404
1429
  },
1405
1430
  });
1431
+ if (options?.externalEvent && ["settled", "observed", "rested"].includes(String(result.turnOutcome))) {
1432
+ const incomplete = [options.externalEvent, ...(options.externalEvent.relatedEvents ?? [])]
1433
+ .filter((event) => !committedExternalEventLeases.has(externalEventLeaseKey(event)));
1434
+ if (incomplete.length > 0) {
1435
+ throw new Error(`External-event turn did not commit dispositions for every exact lease (${incomplete.length} incomplete)`);
1436
+ }
1437
+ }
1406
1438
  if (externalRunBase) {
1407
1439
  const lifecycle = result.turnOutcome === "settled" || result.turnOutcome === "observed" || result.turnOutcome === "rested"
1408
1440
  ? "completed"
@@ -120,10 +120,10 @@ function persistAttempt(filePath, attempt) {
120
120
  fs.writeFileSync(temporary, `${JSON.stringify(attempt)}\n`, { mode: 0o600 });
121
121
  fs.renameSync(temporary, filePath);
122
122
  }
123
- async function appendAcceptanceAttempt(agentName, attempt) {
123
+ async function appendAcceptanceAttempt(agentRoot, attempt) {
124
124
  if (!attempt.scenarioHandleDigest)
125
125
  return;
126
- const filePath = path.join((0, identity_1.getAgentRoot)(agentName), "state", "acceptance", "restart-attempts.ndjson");
126
+ const filePath = path.join(agentRoot, "state", "acceptance", "restart-attempts.ndjson");
127
127
  const previous = acceptanceLedgerTails.get(filePath) ?? Promise.resolve();
128
128
  const current = previous.catch(() => undefined).then(() => {
129
129
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -187,6 +187,7 @@ function createSanctuaryToolContext(agentName) {
187
187
  message: "creating typed Sanctuary tool context",
188
188
  meta: { agentName },
189
189
  });
190
+ const agentRoot = (0, identity_1.getAgentRoot)(agentName);
190
191
  const initial = machineConfig(agentName);
191
192
  const endpoint = required(initial, "unraidGraphqlUrl");
192
193
  const readClient = new unraid_client_1.UnraidClient({ endpoint, apiKey: required(initial, "unraidReadApiKey") });
@@ -216,15 +217,16 @@ function createSanctuaryToolContext(agentName) {
216
217
  listContainers: reads.listContainers,
217
218
  loadWriteApiKey: async () => required(machineConfig(agentName), "unraidWriteApiKey"),
218
219
  persistAttempt: async (attempt) => {
219
- persistAttempt(path.join((0, identity_1.getAgentRoot)(agentName), "state", "approvals", "unraid-restart-attempt.json"), attempt);
220
- await appendAcceptanceAttempt(agentName, attempt);
220
+ persistAttempt(path.join(agentRoot, "state", "approvals", "unraid-restart-attempt.json"), attempt);
221
+ await appendAcceptanceAttempt(agentRoot, attempt);
221
222
  },
222
223
  acceptanceScenarioHandleDigest: () => (0, sanctuary_acceptance_marker_1.readSanctuaryAcceptanceMarker)(agentName)?.scenarioHandleDigest,
223
224
  acceptanceApproval: sanctuary_acceptance_marker_1.readSanctuaryAcceptanceApproval,
224
- reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)((0, identity_1.getAgentRoot)(agentName), input),
225
- transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)((0, identity_1.getAgentRoot)(agentName), input),
225
+ reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)(agentRoot, input),
226
+ transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)(agentRoot, input),
226
227
  });
227
228
  return {
229
+ agentRoot,
228
230
  sanctuary: {
229
231
  listContainers: acceptanceRead("unraid_list_containers", reads.listContainers),
230
232
  getContainerLogs: acceptanceRead("unraid_get_container_logs", reads.getContainerLogs),
@@ -255,7 +257,7 @@ function createSanctuaryToolContext(agentName) {
255
257
  collectToolResult(result);
256
258
  return result;
257
259
  },
258
- recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)((0, identity_1.getAgentRoot)(agentName), {
260
+ recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)(agentRoot, {
259
261
  observeTarget: async (target) => {
260
262
  const observed = await reads.listContainers();
261
263
  if (!observed.ok)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.755",
3
+ "version": "0.1.0-alpha.757",
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.755",
9
+ "version": "0.1.0-alpha.757",
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.755",
3
+ "version": "0.1.0-alpha.757",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },