@ouro.bot/cli 0.1.0-alpha.44 → 0.1.0-alpha.45

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,14 @@
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.45",
6
+ "changes": [
7
+ "`ouro up` now persists a boot-time launch agent with `RunAtLoad`, so the daemon comes back reliably after reboot instead of only starting for the current session.",
8
+ "BlueBubbles sense status is now truthful about upstream health: it probes the real BlueBubbles API, records runtime state, and surfaces `error` when the webhook listener is alive but the upstream server is unreachable.",
9
+ "BlueBubbles intake now replays recoverable read/delivery mutation backlogs through the normal inbound agent path, with a small dedupe ledger so missed messages can be recovered without silent drops or duplicate delivery."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.44",
6
14
  "changes": [
@@ -63,6 +63,7 @@ const bundle_meta_1 = require("./hooks/bundle-meta");
63
63
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
64
64
  const tasks_1 = require("../../repertoire/tasks");
65
65
  const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
66
+ const launchd_1 = require("./launchd");
66
67
  function stringField(value) {
67
68
  return typeof value === "string" ? value : null;
68
69
  }
@@ -761,6 +762,25 @@ function defaultFallbackPendingMessage(command) {
761
762
  });
762
763
  return pendingPath;
763
764
  }
765
+ function defaultEnsureDaemonBootPersistence(socketPath) {
766
+ if (process.platform !== "darwin") {
767
+ return;
768
+ }
769
+ const homeDir = os.homedir();
770
+ const launchdDeps = {
771
+ writeFile: (filePath, content) => fs.writeFileSync(filePath, content, "utf-8"),
772
+ mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
773
+ homeDir,
774
+ };
775
+ const entryPath = path.join((0, identity_1.getRepoRoot)(), "dist", "heart", "daemon", "daemon-entry.js");
776
+ const logDir = path.join(homeDir, ".agentstate", "daemon", "logs");
777
+ (0, launchd_1.writeLaunchAgentPlist)(launchdDeps, {
778
+ nodePath: process.execPath,
779
+ entryPath,
780
+ socketPath,
781
+ logDir,
782
+ });
783
+ }
764
784
  async function defaultInstallSubagents() {
765
785
  return (0, subagent_installer_1.installSubagentsForAvailableCli)({
766
786
  repoRoot: (0, identity_1.getRepoRoot)(),
@@ -1045,6 +1065,7 @@ function createDefaultOuroCliDeps(socketPath = "/tmp/ouroboros-daemon.sock") {
1045
1065
  registerOuroBundleType: ouro_uti_1.registerOuroBundleUti,
1046
1066
  installOuroCommand: ouro_path_installer_1.installOuroCommand,
1047
1067
  syncGlobalOuroBotWrapper: ouro_bot_global_installer_1.syncGlobalOuroBotWrapper,
1068
+ ensureDaemonBootPersistence: defaultEnsureDaemonBootPersistence,
1048
1069
  /* v8 ignore next 3 -- integration: launches interactive CLI session @preserve */
1049
1070
  startChat: async (agentName) => {
1050
1071
  const { main } = await Promise.resolve().then(() => __importStar(require("../../senses/cli")));
@@ -1406,6 +1427,20 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
1406
1427
  });
1407
1428
  if (command.kind === "daemon.up") {
1408
1429
  await performSystemSetup(deps);
1430
+ if (deps.ensureDaemonBootPersistence) {
1431
+ try {
1432
+ await Promise.resolve(deps.ensureDaemonBootPersistence(deps.socketPath));
1433
+ }
1434
+ catch (error) {
1435
+ (0, runtime_1.emitNervesEvent)({
1436
+ level: "warn",
1437
+ component: "daemon",
1438
+ event: "daemon.system_setup_launchd_error",
1439
+ message: "failed to persist daemon boot startup",
1440
+ meta: { error: error instanceof Error ? error.message : String(error), socketPath: deps.socketPath },
1441
+ });
1442
+ }
1443
+ }
1409
1444
  // Run update hooks before starting daemon so user sees the output
1410
1445
  (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
1411
1446
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DAEMON_PLIST_LABEL = void 0;
37
37
  exports.generateDaemonPlist = generateDaemonPlist;
38
+ exports.writeLaunchAgentPlist = writeLaunchAgentPlist;
38
39
  exports.installLaunchAgent = installLaunchAgent;
39
40
  exports.uninstallLaunchAgent = uninstallLaunchAgent;
40
41
  exports.isDaemonInstalled = isDaemonInstalled;
@@ -65,6 +66,8 @@ function generateDaemonPlist(options) {
65
66
  ` <string>--socket</string>`,
66
67
  ` <string>${options.socketPath}</string>`,
67
68
  ` </array>`,
69
+ ` <key>RunAtLoad</key>`,
70
+ ` <true/>`,
68
71
  ` <key>KeepAlive</key>`,
69
72
  ` <true/>`,
70
73
  ];
@@ -74,6 +77,23 @@ function generateDaemonPlist(options) {
74
77
  lines.push(`</dict>`, `</plist>`, ``);
75
78
  return lines.join("\n");
76
79
  }
80
+ function writeLaunchAgentPlist(deps, options) {
81
+ const launchAgentsDir = path.join(deps.homeDir, "Library", "LaunchAgents");
82
+ deps.mkdirp(launchAgentsDir);
83
+ if (options.logDir) {
84
+ deps.mkdirp(options.logDir);
85
+ }
86
+ const fullPath = plistFilePath(deps.homeDir);
87
+ const xml = generateDaemonPlist(options);
88
+ deps.writeFile(fullPath, xml);
89
+ (0, runtime_1.emitNervesEvent)({
90
+ component: "daemon",
91
+ event: "daemon.launchd_plist_written",
92
+ message: "daemon launch agent plist written",
93
+ meta: { plistPath: fullPath, entryPath: options.entryPath, socketPath: options.socketPath },
94
+ });
95
+ return fullPath;
96
+ }
77
97
  function installLaunchAgent(deps, options) {
78
98
  (0, runtime_1.emitNervesEvent)({
79
99
  component: "daemon",
@@ -81,8 +101,6 @@ function installLaunchAgent(deps, options) {
81
101
  message: "installing launch agent",
82
102
  meta: { entryPath: options.entryPath, socketPath: options.socketPath },
83
103
  });
84
- const launchAgentsDir = path.join(deps.homeDir, "Library", "LaunchAgents");
85
- deps.mkdirp(launchAgentsDir);
86
104
  const fullPath = plistFilePath(deps.homeDir);
87
105
  // Unload existing (best effort) for idempotent re-install
88
106
  if (deps.existsFile(fullPath)) {
@@ -91,8 +109,7 @@ function installLaunchAgent(deps, options) {
91
109
  }
92
110
  catch { /* best effort */ }
93
111
  }
94
- const xml = generateDaemonPlist(options);
95
- deps.writeFile(fullPath, xml);
112
+ writeLaunchAgentPlist(deps, options);
96
113
  deps.exec(`launchctl load "${fullPath}"`);
97
114
  (0, runtime_1.emitNervesEvent)({
98
115
  component: "daemon",
@@ -38,6 +38,7 @@ const fs = __importStar(require("fs"));
38
38
  const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
40
40
  const runtime_1 = require("../../nerves/runtime");
41
+ const bluebubbles_runtime_state_1 = require("../../senses/bluebubbles-runtime-state");
41
42
  const identity_1 = require("../identity");
42
43
  const sense_truth_1 = require("../sense-truth");
43
44
  const process_manager_1 = require("./process-manager");
@@ -175,12 +176,29 @@ function runtimeInfoFor(status) {
175
176
  return { runtime: "running" };
176
177
  return { runtime: "error" };
177
178
  }
179
+ function readBlueBubblesRuntimeFacts(agent, bundlesRoot, snapshot) {
180
+ const agentRoot = path.join(bundlesRoot, `${agent}.ouro`);
181
+ const runtimePath = path.join(agentRoot, "state", "senses", "bluebubbles", "runtime.json");
182
+ if (snapshot?.runtime !== "running" || !fs.existsSync(runtimePath)) {
183
+ return { runtime: snapshot?.runtime };
184
+ }
185
+ const state = (0, bluebubbles_runtime_state_1.readBlueBubblesRuntimeState)(agent, agentRoot);
186
+ if (state.upstreamStatus === "error") {
187
+ return {
188
+ runtime: "error",
189
+ detail: state.detail,
190
+ };
191
+ }
192
+ return { runtime: snapshot.runtime };
193
+ }
178
194
  class DaemonSenseManager {
179
195
  processManager;
180
196
  contexts;
197
+ bundlesRoot;
181
198
  constructor(options) {
182
199
  const bundlesRoot = options.bundlesRoot ?? path.join(os.homedir(), "AgentBundles");
183
200
  const secretsRoot = options.secretsRoot ?? path.join(os.homedir(), ".agentsecrets");
201
+ this.bundlesRoot = bundlesRoot;
184
202
  this.contexts = new Map(options.agents.map((agent) => {
185
203
  const senses = readAgentSenses(path.join(bundlesRoot, `${agent}.ouro`, "agent.json"));
186
204
  const facts = senseFactsFromSecrets(agent, senses, path.join(secretsRoot, agent, "secrets.json"));
@@ -227,6 +245,7 @@ class DaemonSenseManager {
227
245
  runtime.set(parsed.agent, current);
228
246
  }
229
247
  const rows = [...this.contexts.entries()].flatMap(([agent, context]) => {
248
+ const blueBubblesRuntimeFacts = readBlueBubblesRuntimeFacts(agent, this.bundlesRoot, runtime.get(agent)?.bluebubbles);
230
249
  const runtimeInfo = {
231
250
  cli: { configured: true },
232
251
  teams: {
@@ -235,7 +254,7 @@ class DaemonSenseManager {
235
254
  },
236
255
  bluebubbles: {
237
256
  configured: context.facts.bluebubbles.configured,
238
- ...(runtime.get(agent)?.bluebubbles ?? {}),
257
+ ...blueBubblesRuntimeFacts,
239
258
  },
240
259
  };
241
260
  const inventory = (0, sense_truth_1.getSenseInventory)({ senses: context.senses }, runtimeInfo);
@@ -245,7 +264,12 @@ class DaemonSenseManager {
245
264
  label: entry.label,
246
265
  enabled: entry.enabled,
247
266
  status: entry.status,
248
- detail: entry.enabled ? context.facts[entry.sense].detail : "not enabled in agent.json",
267
+ detail: entry.enabled
268
+ ? entry.sense === "bluebubbles"
269
+ ? blueBubblesRuntimeFacts.detail
270
+ ?? context.facts[entry.sense].detail
271
+ : context.facts[entry.sense].detail
272
+ : "not enabled in agent.json",
249
273
  }));
250
274
  });
251
275
  (0, runtime_1.emitNervesEvent)({
@@ -170,6 +170,12 @@ function applyRepairNotice(event, notice) {
170
170
  repairNotice: notice,
171
171
  };
172
172
  }
173
+ function hasRecoverableMessageContent(event) {
174
+ return event.kind === "message"
175
+ && (event.textForAgent.trim().length > 0
176
+ || event.attachments.length > 0
177
+ || event.hasPayloadData);
178
+ }
173
179
  function hydrateTextForAgent(event, rawData) {
174
180
  if (event.kind !== "message") {
175
181
  return { ...event, requiresRepair: false };
@@ -323,6 +329,40 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
323
329
  throw new Error(`BlueBubbles read failed (${response.status}): ${errorText || "unknown"}`);
324
330
  }
325
331
  },
332
+ async checkHealth() {
333
+ const url = buildBlueBubblesApiUrl(config.serverUrl, "/api/v1/message/count", config.password);
334
+ (0, runtime_1.emitNervesEvent)({
335
+ component: "senses",
336
+ event: "senses.bluebubbles_healthcheck_start",
337
+ message: "probing bluebubbles upstream health",
338
+ meta: { serverUrl: config.serverUrl },
339
+ });
340
+ const response = await fetch(url, {
341
+ method: "GET",
342
+ signal: AbortSignal.timeout(channelConfig.requestTimeoutMs),
343
+ });
344
+ if (!response.ok) {
345
+ const errorText = await response.text().catch(() => "");
346
+ (0, runtime_1.emitNervesEvent)({
347
+ level: "warn",
348
+ component: "senses",
349
+ event: "senses.bluebubbles_healthcheck_error",
350
+ message: "bluebubbles upstream health probe failed",
351
+ meta: {
352
+ serverUrl: config.serverUrl,
353
+ status: response.status,
354
+ reason: errorText || "unknown",
355
+ },
356
+ });
357
+ throw new Error(`BlueBubbles upstream health check failed (${response.status}): ${errorText || "unknown"}`);
358
+ }
359
+ (0, runtime_1.emitNervesEvent)({
360
+ component: "senses",
361
+ event: "senses.bluebubbles_healthcheck_end",
362
+ message: "bluebubbles upstream health probe succeeded",
363
+ meta: { serverUrl: config.serverUrl },
364
+ });
365
+ },
326
366
  async repairEvent(event) {
327
367
  if (!event.requiresRepair) {
328
368
  (0, runtime_1.emitNervesEvent)({
@@ -387,7 +427,16 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
387
427
  type: event.eventType,
388
428
  data,
389
429
  });
390
- let hydrated = hydrateTextForAgent(normalized, data);
430
+ const recoveredMessage = event.kind === "mutation"
431
+ && !event.shouldNotifyAgent
432
+ ? (0, bluebubbles_model_1.normalizeBlueBubblesEvent)({
433
+ type: "new-message",
434
+ data,
435
+ })
436
+ : null;
437
+ let hydrated = recoveredMessage && hasRecoverableMessageContent(recoveredMessage)
438
+ ? hydrateTextForAgent(recoveredMessage, data)
439
+ : hydrateTextForAgent(normalized, data);
391
440
  if (hydrated.kind === "message" &&
392
441
  hydrated.balloonBundleId !== "com.apple.messages.URLBalloonProvider" &&
393
442
  hydrated.attachments.length > 0) {
@@ -411,6 +460,7 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
411
460
  kind: hydrated.kind,
412
461
  messageGuid: hydrated.messageGuid,
413
462
  repairedFrom: event.kind,
463
+ promotedFromMutation: event.kind === "mutation" && hydrated.kind === "message",
414
464
  },
415
465
  });
416
466
  return hydrated;
@@ -8,4 +8,6 @@ if (!process.argv.includes("--agent")) {
8
8
  process.exit(1);
9
9
  }
10
10
  const bluebubbles_1 = require("./bluebubbles");
11
+ const runtime_logging_1 = require("../heart/daemon/runtime-logging");
12
+ (0, runtime_logging_1.configureDaemonRuntimeLogger)("bluebubbles");
11
13
  (0, bluebubbles_1.startBlueBubblesApp)();
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getBlueBubblesInboundLogPath = getBlueBubblesInboundLogPath;
37
+ exports.hasRecordedBlueBubblesInbound = hasRecordedBlueBubblesInbound;
38
+ exports.recordBlueBubblesInbound = recordBlueBubblesInbound;
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const config_1 = require("../heart/config");
42
+ const identity_1 = require("../heart/identity");
43
+ const runtime_1 = require("../nerves/runtime");
44
+ function getBlueBubblesInboundLogPath(agentName, sessionKey) {
45
+ return path.join((0, identity_1.getAgentRoot)(agentName), "state", "senses", "bluebubbles", "inbound", `${(0, config_1.sanitizeKey)(sessionKey)}.ndjson`);
46
+ }
47
+ function readEntries(filePath) {
48
+ try {
49
+ const raw = fs.readFileSync(filePath, "utf-8");
50
+ return raw
51
+ .split("\n")
52
+ .map((line) => line.trim())
53
+ .filter(Boolean)
54
+ .map((line) => JSON.parse(line))
55
+ .filter((entry) => typeof entry.messageGuid === "string" && typeof entry.sessionKey === "string");
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ function hasRecordedBlueBubblesInbound(agentName, sessionKey, messageGuid) {
62
+ if (!messageGuid.trim())
63
+ return false;
64
+ const filePath = getBlueBubblesInboundLogPath(agentName, sessionKey);
65
+ return readEntries(filePath).some((entry) => entry.messageGuid === messageGuid);
66
+ }
67
+ function recordBlueBubblesInbound(agentName, event, source) {
68
+ const filePath = getBlueBubblesInboundLogPath(agentName, event.chat.sessionKey);
69
+ try {
70
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ fs.appendFileSync(filePath, JSON.stringify({
72
+ recordedAt: new Date(event.timestamp).toISOString(),
73
+ messageGuid: event.messageGuid,
74
+ chatGuid: event.chat.chatGuid ?? null,
75
+ chatIdentifier: event.chat.chatIdentifier ?? null,
76
+ sessionKey: event.chat.sessionKey,
77
+ textForAgent: event.textForAgent,
78
+ source,
79
+ }) + "\n", "utf-8");
80
+ }
81
+ catch (error) {
82
+ (0, runtime_1.emitNervesEvent)({
83
+ level: "warn",
84
+ component: "senses",
85
+ event: "senses.bluebubbles_inbound_log_error",
86
+ message: "failed to record bluebubbles inbound sidecar log",
87
+ meta: {
88
+ agentName,
89
+ messageGuid: event.messageGuid,
90
+ sessionKey: event.chat.sessionKey,
91
+ reason: error instanceof Error ? error.message : String(error),
92
+ },
93
+ });
94
+ return filePath;
95
+ }
96
+ (0, runtime_1.emitNervesEvent)({
97
+ component: "senses",
98
+ event: "senses.bluebubbles_inbound_logged",
99
+ message: "recorded bluebubbles inbound message to sidecar log",
100
+ meta: {
101
+ agentName,
102
+ messageGuid: event.messageGuid,
103
+ sessionKey: event.chat.sessionKey,
104
+ source,
105
+ path: filePath,
106
+ },
107
+ });
108
+ return filePath;
109
+ }
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getBlueBubblesMutationLogPath = getBlueBubblesMutationLogPath;
37
37
  exports.recordBlueBubblesMutation = recordBlueBubblesMutation;
38
+ exports.listBlueBubblesRecoveryCandidates = listBlueBubblesRecoveryCandidates;
38
39
  const fs = __importStar(require("node:fs"));
39
40
  const path = __importStar(require("node:path"));
40
41
  const runtime_1 = require("../nerves/runtime");
@@ -72,3 +73,44 @@ function recordBlueBubblesMutation(agentName, event) {
72
73
  });
73
74
  return filePath;
74
75
  }
76
+ function listBlueBubblesRecoveryCandidates(agentName) {
77
+ const rootDir = path.join((0, identity_1.getAgentRoot)(agentName), "state", "senses", "bluebubbles", "mutations");
78
+ let files;
79
+ try {
80
+ files = fs.readdirSync(rootDir);
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ const deduped = new Map();
86
+ for (const file of files.filter((entry) => entry.endsWith(".ndjson")).sort()) {
87
+ const filePath = path.join(rootDir, file);
88
+ let raw = "";
89
+ try {
90
+ raw = fs.readFileSync(filePath, "utf-8");
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ for (const line of raw.split("\n")) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed)
98
+ continue;
99
+ try {
100
+ const entry = JSON.parse(trimmed);
101
+ if (typeof entry.messageGuid !== "string"
102
+ || !entry.messageGuid.trim()
103
+ || entry.fromMe
104
+ || entry.shouldNotifyAgent
105
+ || (entry.mutationType !== "read" && entry.mutationType !== "delivery")) {
106
+ continue;
107
+ }
108
+ deduped.set(entry.messageGuid, entry);
109
+ }
110
+ catch {
111
+ // ignore malformed recovery candidates
112
+ }
113
+ }
114
+ }
115
+ return [...deduped.values()].sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
116
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getBlueBubblesRuntimeStatePath = getBlueBubblesRuntimeStatePath;
37
+ exports.readBlueBubblesRuntimeState = readBlueBubblesRuntimeState;
38
+ exports.writeBlueBubblesRuntimeState = writeBlueBubblesRuntimeState;
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const identity_1 = require("../heart/identity");
42
+ const runtime_1 = require("../nerves/runtime");
43
+ const DEFAULT_RUNTIME_STATE = {
44
+ upstreamStatus: "unknown",
45
+ detail: "startup health probe pending",
46
+ pendingRecoveryCount: 0,
47
+ };
48
+ function getBlueBubblesRuntimeStatePath(agentName, agentRoot = (0, identity_1.getAgentRoot)(agentName)) {
49
+ return path.join(agentRoot, "state", "senses", "bluebubbles", "runtime.json");
50
+ }
51
+ function readBlueBubblesRuntimeState(agentName, agentRoot) {
52
+ const filePath = getBlueBubblesRuntimeStatePath(agentName, agentRoot);
53
+ try {
54
+ const raw = fs.readFileSync(filePath, "utf-8");
55
+ const parsed = JSON.parse(raw);
56
+ return {
57
+ upstreamStatus: parsed.upstreamStatus === "ok" || parsed.upstreamStatus === "error"
58
+ ? parsed.upstreamStatus
59
+ : "unknown",
60
+ detail: typeof parsed.detail === "string" && parsed.detail.trim()
61
+ ? parsed.detail
62
+ : DEFAULT_RUNTIME_STATE.detail,
63
+ lastCheckedAt: typeof parsed.lastCheckedAt === "string" ? parsed.lastCheckedAt : undefined,
64
+ pendingRecoveryCount: typeof parsed.pendingRecoveryCount === "number" && Number.isFinite(parsed.pendingRecoveryCount)
65
+ ? parsed.pendingRecoveryCount
66
+ : 0,
67
+ lastRecoveredAt: typeof parsed.lastRecoveredAt === "string" ? parsed.lastRecoveredAt : undefined,
68
+ lastRecoveredMessageGuid: typeof parsed.lastRecoveredMessageGuid === "string"
69
+ ? parsed.lastRecoveredMessageGuid
70
+ : undefined,
71
+ };
72
+ }
73
+ catch {
74
+ return { ...DEFAULT_RUNTIME_STATE };
75
+ }
76
+ }
77
+ function writeBlueBubblesRuntimeState(agentName, state, agentRoot) {
78
+ const filePath = getBlueBubblesRuntimeStatePath(agentName, agentRoot);
79
+ try {
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
82
+ }
83
+ catch (error) {
84
+ (0, runtime_1.emitNervesEvent)({
85
+ level: "warn",
86
+ component: "senses",
87
+ event: "senses.bluebubbles_runtime_state_error",
88
+ message: "failed to write bluebubbles runtime state",
89
+ meta: {
90
+ agentName,
91
+ upstreamStatus: state.upstreamStatus,
92
+ reason: error instanceof Error ? error.message : String(error),
93
+ },
94
+ });
95
+ return filePath;
96
+ }
97
+ (0, runtime_1.emitNervesEvent)({
98
+ component: "senses",
99
+ event: "senses.bluebubbles_runtime_state_written",
100
+ message: "wrote bluebubbles runtime state",
101
+ meta: {
102
+ agentName,
103
+ upstreamStatus: state.upstreamStatus,
104
+ pendingRecoveryCount: state.pendingRecoveryCount,
105
+ path: filePath,
106
+ },
107
+ });
108
+ return filePath;
109
+ }
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
37
+ exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
37
38
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
38
39
  exports.drainAndSendPendingBlueBubbles = drainAndSendPendingBlueBubbles;
39
40
  exports.startBlueBubblesApp = startBlueBubblesApp;
@@ -55,7 +56,9 @@ const phrases_1 = require("../mind/phrases");
55
56
  const runtime_1 = require("../nerves/runtime");
56
57
  const bluebubbles_model_1 = require("./bluebubbles-model");
57
58
  const bluebubbles_client_1 = require("./bluebubbles-client");
59
+ const bluebubbles_inbound_log_1 = require("./bluebubbles-inbound-log");
58
60
  const bluebubbles_mutation_log_1 = require("./bluebubbles-mutation-log");
61
+ const bluebubbles_runtime_state_1 = require("./bluebubbles-runtime-state");
59
62
  const bluebubbles_session_cleanup_1 = require("./bluebubbles-session-cleanup");
60
63
  const debug_activity_1 = require("./debug-activity");
61
64
  const trust_gate_1 = require("./trust-gate");
@@ -74,6 +77,7 @@ const defaultDeps = {
74
77
  createFriendResolver: (store, params) => new resolver_1.FriendResolver(store, params),
75
78
  createServer: http.createServer,
76
79
  };
80
+ const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
77
81
  function resolveFriendParams(event) {
78
82
  if (event.chat.isGroup) {
79
83
  const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
@@ -234,6 +238,47 @@ function buildInboundContent(event, existingMessages) {
234
238
  ...event.inputPartsForAgent,
235
239
  ];
236
240
  }
241
+ function sessionLikelyContainsMessage(event, existingMessages) {
242
+ const fragment = event.textForAgent.trim();
243
+ if (!fragment)
244
+ return false;
245
+ return existingMessages.some((message) => {
246
+ if (message.role !== "user")
247
+ return false;
248
+ return extractMessageText(message.content).includes(fragment);
249
+ });
250
+ }
251
+ function mutationEntryToEvent(entry) {
252
+ return {
253
+ kind: "mutation",
254
+ eventType: entry.eventType,
255
+ mutationType: entry.mutationType,
256
+ messageGuid: entry.messageGuid,
257
+ targetMessageGuid: entry.targetMessageGuid ?? undefined,
258
+ timestamp: Date.parse(entry.recordedAt) || Date.now(),
259
+ fromMe: entry.fromMe,
260
+ sender: {
261
+ provider: "imessage-handle",
262
+ externalId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
263
+ rawId: entry.chatIdentifier ?? entry.chatGuid ?? "unknown",
264
+ displayName: entry.chatIdentifier ?? entry.chatGuid ?? "Unknown",
265
+ },
266
+ chat: {
267
+ chatGuid: entry.chatGuid ?? undefined,
268
+ chatIdentifier: entry.chatIdentifier ?? undefined,
269
+ displayName: undefined,
270
+ isGroup: Boolean(entry.chatGuid?.includes(";+;")),
271
+ sessionKey: entry.sessionKey,
272
+ sendTarget: entry.chatGuid
273
+ ? { kind: "chat_guid", value: entry.chatGuid }
274
+ : { kind: "chat_identifier", value: entry.chatIdentifier ?? "unknown" },
275
+ participantHandles: [],
276
+ },
277
+ shouldNotifyAgent: entry.shouldNotifyAgent,
278
+ textForAgent: entry.textForAgent,
279
+ requiresRepair: true,
280
+ };
281
+ }
237
282
  function getBlueBubblesContinuityIngressTexts(event) {
238
283
  if (event.kind !== "message")
239
284
  return [];
@@ -435,10 +480,8 @@ function isWebhookPasswordValid(url, expectedPassword) {
435
480
  const provided = url.searchParams.get("password");
436
481
  return !provided || provided === expectedPassword;
437
482
  }
438
- async function handleBlueBubblesEvent(payload, deps = {}) {
439
- const resolvedDeps = { ...defaultDeps, ...deps };
483
+ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
440
484
  const client = resolvedDeps.createClient();
441
- const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
442
485
  if (event.fromMe) {
443
486
  (0, runtime_1.emitNervesEvent)({
444
487
  component: "senses",
@@ -509,6 +552,36 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
509
552
  const sessionMessages = existing?.messages && existing.messages.length > 0
510
553
  ? existing.messages
511
554
  : [{ role: "system", content: await resolvedDeps.buildSystem("bluebubbles", undefined, context) }];
555
+ if (event.kind === "message") {
556
+ const agentName = resolvedDeps.getAgentName();
557
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, event.chat.sessionKey, event.messageGuid)) {
558
+ (0, runtime_1.emitNervesEvent)({
559
+ component: "senses",
560
+ event: "senses.bluebubbles_recovery_skip",
561
+ message: "skipped bluebubbles message already recorded as handled",
562
+ meta: {
563
+ messageGuid: event.messageGuid,
564
+ sessionKey: event.chat.sessionKey,
565
+ source,
566
+ },
567
+ });
568
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
569
+ }
570
+ if (source !== "webhook" && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)) {
571
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(agentName, event, "recovery-bootstrap");
572
+ (0, runtime_1.emitNervesEvent)({
573
+ component: "senses",
574
+ event: "senses.bluebubbles_recovery_skip",
575
+ message: "skipped bluebubbles recovery because the session already contains the message text",
576
+ meta: {
577
+ messageGuid: event.messageGuid,
578
+ sessionKey: event.chat.sessionKey,
579
+ source,
580
+ },
581
+ });
582
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
583
+ }
584
+ }
512
585
  // Build inbound user message (adapter concern: BB-specific content formatting)
513
586
  const userMessage = {
514
587
  role: "user",
@@ -578,6 +651,9 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
578
651
  text: result.gateResult.autoReply,
579
652
  });
580
653
  }
654
+ if (event.kind === "message") {
655
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
656
+ }
581
657
  return {
582
658
  handled: true,
583
659
  notifiedAgent: false,
@@ -586,6 +662,9 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
586
662
  }
587
663
  // Gate allowed — flush the agent's reply
588
664
  await callbacks.flush();
665
+ if (event.kind === "message") {
666
+ (0, bluebubbles_inbound_log_1.recordBlueBubblesInbound)(resolvedDeps.getAgentName(), event, source);
667
+ }
589
668
  (0, runtime_1.emitNervesEvent)({
590
669
  component: "senses",
591
670
  event: "senses.bluebubbles_turn_end",
@@ -606,6 +685,94 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
606
685
  await callbacks.finish();
607
686
  }
608
687
  }
688
+ async function handleBlueBubblesEvent(payload, deps = {}) {
689
+ const resolvedDeps = { ...defaultDeps, ...deps };
690
+ const client = resolvedDeps.createClient();
691
+ const event = await client.repairEvent((0, bluebubbles_model_1.normalizeBlueBubblesEvent)(payload));
692
+ return handleBlueBubblesNormalizedEvent(event, resolvedDeps, "webhook");
693
+ }
694
+ function countPendingRecoveryCandidates(agentName) {
695
+ return (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)
696
+ .filter((entry) => !(0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, entry.sessionKey, entry.messageGuid))
697
+ .length;
698
+ }
699
+ async function syncBlueBubblesRuntime(deps = {}) {
700
+ const resolvedDeps = { ...defaultDeps, ...deps };
701
+ const agentName = resolvedDeps.getAgentName();
702
+ const client = resolvedDeps.createClient();
703
+ const checkedAt = new Date().toISOString();
704
+ try {
705
+ await client.checkHealth();
706
+ const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
707
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
708
+ upstreamStatus: recovery.pending > 0 || recovery.failed > 0 ? "error" : "ok",
709
+ detail: recovery.failed > 0
710
+ ? `recovery failures: ${recovery.failed}`
711
+ : recovery.pending > 0
712
+ ? `pending recovery: ${recovery.pending}`
713
+ : "upstream reachable",
714
+ lastCheckedAt: checkedAt,
715
+ pendingRecoveryCount: recovery.pending,
716
+ lastRecoveredAt: recovery.recovered > 0 ? checkedAt : undefined,
717
+ });
718
+ }
719
+ catch (error) {
720
+ (0, bluebubbles_runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
721
+ upstreamStatus: "error",
722
+ detail: error instanceof Error ? error.message : String(error),
723
+ lastCheckedAt: checkedAt,
724
+ pendingRecoveryCount: countPendingRecoveryCandidates(agentName),
725
+ });
726
+ }
727
+ }
728
+ async function recoverMissedBlueBubblesMessages(deps = {}) {
729
+ const resolvedDeps = { ...defaultDeps, ...deps };
730
+ const agentName = resolvedDeps.getAgentName();
731
+ const client = resolvedDeps.createClient();
732
+ const result = { recovered: 0, skipped: 0, pending: 0, failed: 0 };
733
+ for (const candidate of (0, bluebubbles_mutation_log_1.listBlueBubblesRecoveryCandidates)(agentName)) {
734
+ if ((0, bluebubbles_inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, candidate.sessionKey, candidate.messageGuid)) {
735
+ result.skipped++;
736
+ continue;
737
+ }
738
+ try {
739
+ const repaired = await client.repairEvent(mutationEntryToEvent(candidate));
740
+ if (repaired.kind !== "message") {
741
+ result.pending++;
742
+ continue;
743
+ }
744
+ const handled = await handleBlueBubblesNormalizedEvent(repaired, resolvedDeps, "mutation-recovery");
745
+ if (handled.reason === "already_processed") {
746
+ result.skipped++;
747
+ }
748
+ else {
749
+ result.recovered++;
750
+ }
751
+ }
752
+ catch (error) {
753
+ result.failed++;
754
+ (0, runtime_1.emitNervesEvent)({
755
+ level: "warn",
756
+ component: "senses",
757
+ event: "senses.bluebubbles_recovery_error",
758
+ message: "bluebubbles backlog recovery failed",
759
+ meta: {
760
+ messageGuid: candidate.messageGuid,
761
+ reason: error instanceof Error ? error.message : String(error),
762
+ },
763
+ });
764
+ }
765
+ }
766
+ if (result.recovered > 0 || result.skipped > 0 || result.pending > 0 || result.failed > 0) {
767
+ (0, runtime_1.emitNervesEvent)({
768
+ component: "senses",
769
+ event: "senses.bluebubbles_recovery_complete",
770
+ message: "bluebubbles backlog recovery pass completed",
771
+ meta: { ...result },
772
+ });
773
+ }
774
+ return result;
775
+ }
609
776
  function createBlueBubblesWebhookHandler(deps = {}) {
610
777
  return async (req, res) => {
611
778
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -842,6 +1009,12 @@ function startBlueBubblesApp(deps = {}) {
842
1009
  resolvedDeps.createClient();
843
1010
  const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
844
1011
  const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps));
1012
+ const runtimeTimer = setInterval(() => {
1013
+ void syncBlueBubblesRuntime(resolvedDeps);
1014
+ }, BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS);
1015
+ server.on?.("close", () => {
1016
+ clearInterval(runtimeTimer);
1017
+ });
845
1018
  server.listen(channelConfig.port, () => {
846
1019
  (0, runtime_1.emitNervesEvent)({
847
1020
  component: "channels",
@@ -850,5 +1023,6 @@ function startBlueBubblesApp(deps = {}) {
850
1023
  meta: { port: channelConfig.port, webhookPath: channelConfig.webhookPath },
851
1024
  });
852
1025
  });
1026
+ void syncBlueBubblesRuntime(resolvedDeps);
853
1027
  return server;
854
1028
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.44",
3
+ "version": "0.1.0-alpha.45",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -9,13 +9,14 @@ You are a task executor. Read a doing.md file and execute all units sequentially
9
9
  ## On Startup
10
10
 
11
11
  1. **Find task-doc directory**: Read project instructions (for example `AGENTS.md`) to determine where planning/doing docs live for this repo
12
- 2. **Find doing doc**: Look for `YYYY-MM-DD-HHMM-doing-*.md` in that project-defined task-doc directory
13
- 3. If multiple found, ask which one
14
- 4. If none found, ask user for location
15
- 5. **Check execution_mode**: Read the doing doc's `Execution Mode` field
16
- 6. **Verify artifacts directory exists**: `{task-name}/` next to `{task-name}.md`
12
+ 2. **Confirm worktree**: Run from the dedicated task worktree required by the project. If the current checkout is shared, ambiguous, or not on the task branch, STOP and switch/create the correct worktree first.
13
+ 3. **Find doing doc**: Look for `YYYY-MM-DD-HHMM-doing-*.md` in that project-defined task-doc directory
14
+ 4. If multiple found, ask which one
15
+ 5. If none found, ask user for location
16
+ 6. **Check execution_mode**: Read the doing doc's `Execution Mode` field
17
+ 7. **Verify artifacts directory exists**: `{task-name}/` next to `{task-name}.md`
17
18
  - If missing, create it: `mkdir {task-name}`
18
- 7. **Detect resume vs fresh start:**
19
+ 8. **Detect resume vs fresh start:**
19
20
  - Count completed units (✅) vs total units
20
21
  - Check git status for uncommitted changes
21
22
 
@@ -218,18 +219,19 @@ When all units are `✅`:
218
219
  2. **Location**: Read and update doing docs in the project-defined task-doc directory, which may live outside the repo
219
220
  3. **Artifacts directory**: Use `{task-name}/` for all outputs, logs, data
220
221
  4. **Execution mode**: Honor `pending | spawn | direct` from doing doc
221
- 5. **TDD strictly enforced** tests before implementation, always
222
- 6. **100% coverage** — no exceptions, no exclude attributes
223
- 7. **Atomic commits** — one logical unit per commit, push after each
224
- 8. **Timestamps from git** — `git log -1 --format="%Y-%m-%d %H:%M"`
225
- 9. **Push after each unit phase complete**
226
- 10. **Update doing.md after each unit** status and progress log
227
- 11. **Spawn sub-agents for fixes** — don't ask, just do it
228
- 12. **Update docs immediately** — when decisions made, commit right away
229
- 13. **Stop on actual blocker** — unclear requirements or need user input
230
- 14. **/compact proactively** — preserve context between units
231
- 15. **No warnings** — treat warnings as errors
232
- 16. **Run full test suite** — before marking unit complete, not just new tests
233
- 17. **Always compile** — run the project's build command after every implementation/refactor unit. Tests passing is necessary but not sufficient.
234
- 18. **Checklist hygiene is mandatory** — keep doing/planning `Completion Criteria` checklists synchronized with verified completion evidence.
222
+ 5. **Respect the approved structure**: A `READY_FOR_EXECUTION` doing doc should already be ambiguity-clean. Do not rewrite unit structure unless the user changes scope or the doing doc is actually blocked/inaccurate.
223
+ 6. **TDD strictly enforced** — tests before implementation, always
224
+ 7. **100% coverage** — no exceptions, no exclude attributes
225
+ 8. **Atomic commits** — one logical unit per commit, push after each
226
+ 9. **Timestamps from git** `git log -1 --format="%Y-%m-%d %H:%M"`
227
+ 10. **Push after each unit phase complete**
228
+ 11. **Update doing.md after each unit** — status and progress log
229
+ 12. **Spawn sub-agents for fixes** — don't ask, just do it
230
+ 13. **Update docs immediately** — when decisions made, commit right away
231
+ 14. **Stop on actual blocker** — unclear requirements or need user input
232
+ 15. **/compact proactively** — preserve context between units
233
+ 16. **No warnings** — treat warnings as errors
234
+ 17. **Run full test suite** — before marking unit complete, not just new tests
235
+ 18. **Always compile** — run the project's build command after every implementation/refactor unit. Tests passing is necessary but not sufficient.
236
+ 19. **Checklist hygiene is mandatory** — keep doing/planning `Completion Criteria` checklists synchronized with verified completion evidence.
235
237
  19. **Verify APIs before importing** — before writing `import { Foo } from './bar'`, use `grep` or `read_file` to confirm `Foo` is actually exported from that module. Never assume an export exists — always check the source first. This prevents wasted cycles on "module has no exported member" errors.
@@ -11,10 +11,11 @@ You are a task planner for coding work. Help the user define scope, then convert
11
11
  **Determine task doc directory:**
12
12
  1. Read project instructions (for example `AGENTS.md`) to find the canonical task-doc location for the current repo
13
13
  2. Derive `AGENT` from the current git branch when the project uses agent-scoped task docs
14
- 3. Set `TASK_DIR` to the project-defined planning/doing directory
15
- 4. If the project-defined parent location exists but `TASK_DIR` does not, create it
16
- 5. If the project does not define a task-doc location, STOP and ask the user or caller where planning/doing docs should live
17
- 6. Do not assume task docs live in the repo root; many projects keep them externally
14
+ 3. Confirm the task is running from a dedicated task worktree when the project requires parallel agent work; if the checkout is shared or ambiguous, STOP and tell the caller to create/switch to a dedicated worktree first
15
+ 4. Set `TASK_DIR` to the project-defined planning/doing directory
16
+ 5. If the project-defined parent location exists but `TASK_DIR` does not, create it
17
+ 6. If the project does not define a task-doc location, STOP and ask the user or caller where planning/doing docs should live
18
+ 7. Do not assume task docs live in the repo root; many projects keep them externally
18
19
 
19
20
  **Check for existing planning docs:**
20
21
  1. Look for `YYYY-MM-DD-HHMM-planning-*.md` files in `TASK_DIR`
@@ -160,7 +161,7 @@ User answers questions → agent updates doc → agent sets status to `NEEDS_REV
160
161
 
161
162
  **CRITICAL: Planning doc is KEPT. Conversion creates a NEW doing doc alongside it in `TASK_DIR`.**
162
163
 
163
- Run these passes — announce each. **ALL 4 PASSES ARE MANDATORY. You must run every pass, even if you think nothing changed. Each pass MUST have its own commit (use "no changes needed" in the commit message if the pass found nothing to fix). Do NOT skip or combine passes.**
164
+ Run these passes — announce each. **ALL 5 PASSES ARE MANDATORY. You must run every pass, even if you think nothing changed. Each pass MUST have its own commit (use "no changes needed" in the commit message if the pass found nothing to fix). Do NOT skip or combine passes.**
164
165
 
165
166
  **Pass 1 — First Draft:**
166
167
  - Create `YYYY-MM-DD-HHMM-doing-{short-desc}.md` (same timestamp and short-desc as planning)
@@ -181,7 +182,14 @@ Run these passes — announce each. **ALL 4 PASSES ARE MANDATORY. You must run e
181
182
  - Update units if reality differs from what was assumed during planning
182
183
  - Commit: `git commit -m "docs(doing): validation pass"` (or `"docs(doing): validation pass - no changes needed"` if nothing to fix)
183
184
 
184
- **Pass 4 — Quality:**
185
+ **Pass 4 — Ambiguity:**
186
+ - Remove doer-facing ambiguity before execution starts
187
+ - Tighten units so a `READY_FOR_EXECUTION` doing doc does not require structural rewrites by `work-doer`
188
+ - Resolve fuzzy phrases like "appropriate files", "as needed", or "wherever the bug is" into concrete targets unless the project instructions explicitly require that flexibility
189
+ - If uncertainty remains, keep it in the planning doc's `Open Questions`, set status to `NEEDS_REVIEW`, and STOP instead of shipping an ambiguous doing doc
190
+ - Commit: `git commit -m "docs(doing): ambiguity pass"` (or `"docs(doing): ambiguity pass - no changes needed"` if nothing to fix)
191
+
192
+ **Pass 5 — Quality:**
185
193
  - All units have acceptance criteria?
186
194
  - No TBD items?
187
195
  - Completion criteria testable?