@indigoai-us/hq-cli 5.288.0 → 5.290.0

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.
@@ -33,6 +33,8 @@ export interface MergeCopiesCommandOptions {
33
33
  variants?: boolean;
34
34
  limit?: number;
35
35
  only?: string;
36
+ /** Also re-point refs and links held today (flag merge-rewrite-refs). */
37
+ rewriteRefs?: boolean;
36
38
  json?: boolean;
37
39
  }
38
40
  /** Reads a `--only` file: a JSON array of `{ copy, original }` company-relative paths. */
@@ -44,7 +46,10 @@ export declare function readListedPairs(file: string): ListedPair[];
44
46
  * `checkpoint.ontology.merge-copies` and `checkpoint.ontology.multi-writer`.
45
47
  * With `--variants`, merges two-file duplicate groups whose names differ only
46
48
  * in case, spacing or punctuation instead, gated by
47
- * `checkpoint.ontology.merge-variants`.
49
+ * `checkpoint.ontology.merge-variants`. `--rewrite-refs` (flag
50
+ * `checkpoint.ontology.merge-rewrite-refs`) also re-points signal refs,
51
+ * invoice and bill links, source and knowledge links, and carries external
52
+ * ids and web domains.
48
53
  */
49
54
  export declare function runOntologyMergeCopies(hqRoot: string, slug: string | undefined, options: MergeCopiesCommandOptions, deps?: OntologyCommandDeps): Promise<string>;
50
55
  export declare function registerCoreOntologyCommand(core: Command): void;
@@ -17,7 +17,7 @@ import { applyOntologyRun, ApplyError } from "../lib/checkpoint-ontology/apply.j
17
17
  import { resolveOntologyCompany, WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
18
18
  import { findDuplicateGroups, summarizeMergeBack, } from "../lib/checkpoint-ontology/multi-writer.js";
19
19
  import { buildEntityIndex } from "../lib/checkpoint-ontology/tree.js";
20
- import { MERGE_COPIES_FLAG, MERGE_VARIANTS_FLAG, mergeCopies, } from "../lib/checkpoint-ontology/merge-copies.js";
20
+ import { MERGE_COPIES_FLAG, MERGE_REWRITE_REFS_FLAG, MERGE_VARIANTS_FLAG, mergeCopies, } from "../lib/checkpoint-ontology/merge-copies.js";
21
21
  import { callDeleteEndpoint } from "./files.js";
22
22
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
23
23
  import { registerCoreOntologyNativeCommands } from "./core-ontology-native.js";
@@ -146,11 +146,12 @@ function mergeCopiesSummary(report) {
146
146
  const held = report.plans.filter((plan) => plan.held).length;
147
147
  const lines = [
148
148
  `ontology merge-copies (${report.mode.replace("_", " ")}) in ${report.company} (${report.pair_kind}): ${report.plans.length} of ${report.copies_found} copies, ${held} held, ${report.skipped.length} skipped`,
149
- ` merged ${report.merged.length}, relinked ${report.relinked.length}, deleted ${report.deleted.length}, not ready ${report.not_ready.length}, errors ${report.errors.length}`,
149
+ ` merged ${report.merged.length}, relinked ${report.relinked.length}${report.rewritten !== undefined ? `, rewritten ${report.rewritten.length}` : ""}, deleted ${report.deleted.length}, not ready ${report.not_ready.length}, errors ${report.errors.length}`,
150
150
  ` duplicate groups ${report.duplicate_groups_before} -> ${report.duplicate_groups_after}; broken entity links ${report.broken_links_before} -> ${report.broken_links_after}`,
151
151
  ];
152
- for (const plan of report.plans.filter((entry) => entry.held))
153
- lines.push(` held ${plan.copy}: ${plan.held}`);
152
+ for (const plan of report.plans.filter((entry) => entry.held)) {
153
+ lines.push(` held ${plan.copy}: ${plan.held}${plan.held === "external_id_conflict" ? ` (${plan.detail})` : ""}`);
154
+ }
154
155
  for (const error of report.errors)
155
156
  lines.push(` error ${error.path}: ${error.error}`);
156
157
  for (const entry of report.only_unmatched ?? [])
@@ -176,7 +177,36 @@ export function readListedPairs(file) {
176
177
  })) {
177
178
  throw new OntologyUsageError("ontology: --only must be a non-empty JSON array of { copy, original } paths");
178
179
  }
179
- return parsed.map((entry) => ({ copy: entry.copy, original: entry.original }));
180
+ return parsed.map((entry) => {
181
+ if (entry.resolve_external_ids === undefined)
182
+ return { copy: entry.copy, original: entry.original };
183
+ return { copy: entry.copy, original: entry.original, resolve_external_ids: readResolution(entry.copy, entry.resolve_external_ids) };
184
+ });
185
+ }
186
+ /** `{ provider: { primary, namespace, object_type? } }`, every value a non-empty string. */
187
+ function readResolution(copy, raw) {
188
+ const usage = `ontology: --only entry ${copy}: resolve_external_ids must map each provider to { primary, namespace, object_type? } strings`;
189
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length === 0)
190
+ throw new OntologyUsageError(usage);
191
+ const out = {};
192
+ for (const [provider, value] of Object.entries(raw)) {
193
+ if (value === null || typeof value !== "object" || Array.isArray(value))
194
+ throw new OntologyUsageError(usage);
195
+ const record = value;
196
+ const nonEmpty = (field) => typeof field === "string" && field.length > 0;
197
+ if (!Object.keys(record).every((key) => key === "primary" || key === "namespace" || key === "object_type") ||
198
+ !nonEmpty(record.primary) ||
199
+ !nonEmpty(record.namespace) ||
200
+ (record.object_type !== undefined && !nonEmpty(record.object_type))) {
201
+ throw new OntologyUsageError(usage);
202
+ }
203
+ out[provider] = {
204
+ primary: record.primary,
205
+ namespace: record.namespace,
206
+ ...(record.object_type !== undefined ? { object_type: record.object_type } : {}),
207
+ };
208
+ }
209
+ return out;
180
210
  }
181
211
  /**
182
212
  * `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
@@ -185,7 +215,10 @@ export function readListedPairs(file) {
185
215
  * `checkpoint.ontology.merge-copies` and `checkpoint.ontology.multi-writer`.
186
216
  * With `--variants`, merges two-file duplicate groups whose names differ only
187
217
  * in case, spacing or punctuation instead, gated by
188
- * `checkpoint.ontology.merge-variants`.
218
+ * `checkpoint.ontology.merge-variants`. `--rewrite-refs` (flag
219
+ * `checkpoint.ontology.merge-rewrite-refs`) also re-points signal refs,
220
+ * invoice and bill links, source and knowledge links, and carries external
221
+ * ids and web domains.
189
222
  */
190
223
  export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
191
224
  if (options.apply && options.delete) {
@@ -200,7 +233,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
200
233
  throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
201
234
  const company = resolution.company;
202
235
  const readFlag = deps.readFlag ?? registryFlagReader;
203
- for (const flag of [options.variants ? MERGE_VARIANTS_FLAG : MERGE_COPIES_FLAG, MULTI_WRITER_FLAG]) {
236
+ for (const flag of [options.variants ? MERGE_VARIANTS_FLAG : MERGE_COPIES_FLAG, MULTI_WRITER_FLAG, ...(options.rewriteRefs ? [MERGE_REWRITE_REFS_FLAG] : [])]) {
204
237
  if (!(await readFlag(flag, company))) {
205
238
  return options.json ? JSON.stringify({ company: company.slug, skipped: `${flag} off` }) : `ontology merge-copies: skipped (${flag} is off)`;
206
239
  }
@@ -220,6 +253,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
220
253
  ...(options.variants ? { pairKind: "variants" } : {}),
221
254
  ...(options.limit !== undefined ? { limit: options.limit } : {}),
222
255
  ...(only ? { only } : {}),
256
+ ...(options.rewriteRefs ? { rewriteRefs: true } : {}),
223
257
  ...(deleteVaultKey ? { deleteVaultKey } : {}),
224
258
  });
225
259
  return options.json ? JSON.stringify(report, null, 2) : mergeCopiesSummary(report);
@@ -274,6 +308,7 @@ export function registerCoreOntologyCommand(core) {
274
308
  .option("--variants", "merge two-file duplicate groups whose names differ only in case, spacing or punctuation (checkpoint.ontology.merge-variants)")
275
309
  .option("--limit <n>", "handle at most this many copies", (value) => Number(value))
276
310
  .option("--only <file>", "handle only the copy -> original pairs listed in this JSON file")
311
+ .option("--rewrite-refs", "also re-point signal refs, invoice, source and knowledge links to the survivor and carry external ids and web domains (checkpoint.ontology.merge-rewrite-refs)")
277
312
  .option("--json", "print JSON")
278
313
  .option("--hq-root <path>", "HQ installation to operate on")
279
314
  .action(async (options) => {
@@ -31,6 +31,7 @@
31
31
  import { Option } from "commander";
32
32
  import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
33
33
  import { registerCoreDelegateCommand } from "./core-delegate.js";
34
+ import { registerCoreHandoffCommand } from "./core-handoff.js";
34
35
  import { registerCoreOntologyCommand } from "./core-ontology.js";
35
36
  import { registerCoreKnowledgePrefsCommand } from "./core-knowledge-prefs.js";
36
37
  import { registerCorePolicyCommand } from "./core-policy.js";
@@ -241,7 +242,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
241
242
  { asset: "core/scripts/ontology-source-worklist.sh", root: "live", summary: "Rollback companion for native ontology source worklist" },
242
243
  { asset: "core/scripts/source-yaml-validate.sh", root: "cwd", summary: "Rollback companion for native source YAML validation" },
243
244
  // Shared by native knowledge-prefs and the ontology candidate shell fallback.
244
- { asset: "core/scripts/knowledge-prefs.sh", root: "live", summary: "Rollback companion for native knowledge-prefs and ontology candidate" },
245
+ { asset: "core/scripts/knowledge-prefs.sh", root: "live", summary: "Sibling dependency for ontology candidate rollback companion" },
245
246
  // Rollback companions for the default-off native knowledge and policy routes.
246
247
  // policy-retire also runs the scaffold validator in-process when native, but
247
248
  // its bundled shell fallback needs both helper assets at these exact paths.
@@ -250,6 +251,11 @@ export const SCAFFOLD_ONLY_ASSETS = [
250
251
  { asset: "core/scripts/read-policy-frontmatter.sh", root: "cwd", summary: "Rollback companion for native policy frontmatter" },
251
252
  { asset: "core/scripts/eval-trigger.sh", root: "live", summary: "Trigger grammar helper used by the policy validator companion" },
252
253
  { asset: ".claude/hooks/validate-policy-frontmatter.sh", root: "live", summary: "Policy validator companion used by policy-retire rollback" },
254
+ // Wave 2a handoff shells remain bundled as the default-off rollback path.
255
+ { asset: "core/scripts/handoff-finalize.sh", root: "live", summary: "Rollback shell for native handoff finalize" },
256
+ { asset: "core/scripts/handoff-post.sh", root: "live", summary: "Rollback shell for native handoff post" },
257
+ { asset: "core/scripts/handoff-open-steps.sh", root: "live", summary: "Rollback shell for native handoff open-steps" },
258
+ { asset: "core/scripts/qmd-reindex-bg.sh", root: "live", summary: "Rollback handoff reindex forwarder" },
253
259
  ...NATIVE_UTILITY_COMMANDS.map(({ asset, root, summary }) => ({
254
260
  asset,
255
261
  root,
@@ -326,6 +332,7 @@ export function registerCoreCommands(program) {
326
332
  registerCoreOntologyCommand(core);
327
333
  registerCoreCheckpointGateSettingCommand(core);
328
334
  registerCoreDelegateCommand(core);
335
+ registerCoreHandoffCommand(core);
329
336
  registerCoreKnowledgePrefsCommand(core);
330
337
  registerCorePolicyCommand(core);
331
338
  // Wave 2 handoff/worktree commands retain their byte-identical shell assets
@@ -79,6 +79,7 @@ export { MAX_LANE_DEPTH };
79
79
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
80
80
  export const LANE_REMOTE_MIRROR_SYNC_FLAG = "lanes.remote-done-mirror";
81
81
  const REMOTE_MIRROR_REFRESH_BATCH_SIZE = 100;
82
+ const REMOTE_LANE_NOT_FOUND_GRACE_MS = 15 * 60_000;
82
83
  /** The lane → mesh seam. Defined in lib/lanes/mesh-events.ts; the watcher shares it. */
83
84
  export { emitLaneDecisionEvents };
84
85
  export { LANE_OUTPOST_SNAPSHOT_CACHE_FLAG };
@@ -1129,6 +1130,7 @@ function normalizedLaneListQuery(query) {
1129
1130
  ...(hierarchy === undefined ? {} : { hierarchy }),
1130
1131
  ...(query.snapshot ? { snapshot: true } : {}),
1131
1132
  ...(query.strictSnapshot ? { strictSnapshot: true } : {}),
1133
+ ...(query.includeForeignHostRows ? { includeForeignHostRows: true } : {}),
1132
1134
  };
1133
1135
  }
1134
1136
  async function readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOptions = {}) {
@@ -1191,7 +1193,7 @@ async function readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOpt
1191
1193
  // Outpost's own store, so preserve those local identities for accounting.
1192
1194
  const hasLocalHostIdentity = rowHost.kind === "local" &&
1193
1195
  typeof rowHost.id === "string" && rowHost.id.trim() !== "";
1194
- if (!belongsToOutpost && !hasLocalHostIdentity)
1196
+ if (!belongsToOutpost && !hasLocalHostIdentity && !query.includeForeignHostRows)
1195
1197
  continue;
1196
1198
  try {
1197
1199
  lanes.push(parseLaneRecord(normalizeLaneRecordView(raw)));
@@ -1216,6 +1218,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1216
1218
  const result = {
1217
1219
  updated: new Map(),
1218
1220
  unverifiedRemote: new Map(),
1221
+ missingOnOwner: new Map(),
1219
1222
  };
1220
1223
  const host = reconcileHostFromCtx(ctx);
1221
1224
  if (ctx.env.HQ_LANES_REMOTE_READ === "1" || parentLaneIds?.size === 0)
@@ -1285,6 +1288,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1285
1288
  laneIds: batch.map((lane) => lane.lane_id),
1286
1289
  snapshot: true,
1287
1290
  strictSnapshot: true,
1291
+ includeForeignHostRows: true,
1288
1292
  });
1289
1293
  }
1290
1294
  catch (error) {
@@ -1306,6 +1310,8 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1306
1310
  !ownerIsHostedByOutpost ||
1307
1311
  (owner.company?.uid ?? null) !== (mirror.company?.uid ?? null) ||
1308
1312
  (owner.company?.slug ?? null) !== (mirror.company?.slug ?? null)) {
1313
+ if (matches.length === 0)
1314
+ result.missingOnOwner.set(mirror.lane_id, mirror);
1309
1315
  const detail = matches.length === 0
1310
1316
  ? "owner did not return the requested lane"
1311
1317
  : matches.length > 1
@@ -1329,6 +1335,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1329
1335
  }
1330
1336
  const needsUpdate = before.state !== owner.state ||
1331
1337
  before.queued_reason !== (owner.state === "queued" ? owner.queued_reason : undefined) ||
1338
+ JSON.stringify(before.queued_details) !== JSON.stringify(owner.state === "queued" ? owner.queued_details : undefined) ||
1332
1339
  before.ended_at !== endedAt ||
1333
1340
  before.envelope_summary !== envelopeSummary ||
1334
1341
  (isTerminalLaneState(owner.state) && (JSON.stringify(before.last_envelope) !== JSON.stringify(owner.last_envelope) ||
@@ -1348,6 +1355,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1348
1355
  ...current,
1349
1356
  state: owner.state,
1350
1357
  queued_reason: owner.state === "queued" ? owner.queued_reason : undefined,
1358
+ queued_details: owner.state === "queued" ? owner.queued_details : undefined,
1351
1359
  ended_at: endedAt,
1352
1360
  envelope_summary: envelopeSummary,
1353
1361
  ...(isTerminalLaneState(owner.state) ? {
@@ -2445,6 +2453,47 @@ function markDispatchFailure(hqRoot, laneId, details) {
2445
2453
  };
2446
2454
  });
2447
2455
  }
2456
+ function retainQueuedOutpostAfterUnknownDelivery(hqRoot, laneId, outpostId, commandId) {
2457
+ return casUpdateLane(hqRoot, laneId, (current) => {
2458
+ if (current.state !== "queued" ||
2459
+ current.host.kind !== "outpost" ||
2460
+ current.host.id !== outpostId)
2461
+ return current;
2462
+ return {
2463
+ ...current,
2464
+ queued_details: {
2465
+ reason: "outpost_dispatch_unknown",
2466
+ outcome_unknown: true,
2467
+ command_id: commandId,
2468
+ },
2469
+ };
2470
+ });
2471
+ }
2472
+ function markRemoteLaneLost(hqRoot, laneId, outpostId, at, writer, operatorReason, expectedVersion) {
2473
+ return retryUpdateLane(hqRoot, laneId, writer, (current) => {
2474
+ if (current.host.kind !== "outpost" ||
2475
+ current.host.id !== outpostId ||
2476
+ (expectedVersion !== undefined && current.version !== expectedVersion) ||
2477
+ isTerminalLaneState(current.state))
2478
+ return current;
2479
+ return {
2480
+ ...current,
2481
+ state: "failed",
2482
+ queued_reason: undefined,
2483
+ queued_details: undefined,
2484
+ terminal_reason: "remote_lost",
2485
+ terminal_details: {
2486
+ outpost_id: outpostId,
2487
+ owner_error: "lane_not_found",
2488
+ ...(operatorReason === undefined ? {} : { operator_reason: operatorReason }),
2489
+ },
2490
+ stop_history: [
2491
+ ...(current.stop_history ?? []),
2492
+ { at, reason: "remote_lost" },
2493
+ ],
2494
+ };
2495
+ });
2496
+ }
2448
2497
  /**
2449
2498
  * The CAS updater a pickup failure applies. A lane that is already terminal
2450
2499
  * for any reason (a cancel that landed after the caller's last read, an
@@ -3528,6 +3577,22 @@ async function dispatchCreatedOutpostLane(ctx, input) {
3528
3577
  exit_code: dispatched.exitCode,
3529
3578
  });
3530
3579
  }
3580
+ const commandId = dispatched.commandId?.trim();
3581
+ if (dispatched.done !== true || !commandId) {
3582
+ const details = {
3583
+ outpost_id: outpostId,
3584
+ lane_id: lane.lane_id,
3585
+ command_id: commandId || null,
3586
+ status: dispatched.status,
3587
+ done: dispatched.done ?? false,
3588
+ exit_code: dispatched.exitCode,
3589
+ outcome_unknown: true,
3590
+ };
3591
+ throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not confirm pickup for lane ${lane.lane_id}; the queued lane will be reconciled against its owner`, {
3592
+ ...details,
3593
+ state: retainQueuedOutpostAfterUnknownDelivery(hqRoot, lane.lane_id, outpostId, commandId || null).state,
3594
+ });
3595
+ }
3531
3596
  const stdoutTrimmed = dispatched.stdout.trim();
3532
3597
  if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
3533
3598
  if (preserveQueuedOnFailure) {
@@ -3606,6 +3671,9 @@ async function dispatchCreatedOutpostLane(ctx, input) {
3606
3671
  catch (err) {
3607
3672
  if (preserveQueuedOnFailure)
3608
3673
  throw err;
3674
+ if (isLanesError(err) && err.error === "outpost_delivery_unconfirmed" && err.details.outcome_unknown === true) {
3675
+ throw err;
3676
+ }
3609
3677
  if (isLanesError(err) &&
3610
3678
  err.error === DISPATCH_TERMINAL_REASON &&
3611
3679
  readLane(hqRoot, lane.lane_id).state === "failed") {
@@ -4551,7 +4619,7 @@ async function runList(ctx, opts, fastRouteFlags) {
4551
4619
  }
4552
4620
  const remoteRead = ctx.env[REMOTE_LANE_READ_ENV] === "1";
4553
4621
  const remoteRefresh = remoteRead
4554
- ? { updated: new Map(), unverifiedRemote: new Map() }
4622
+ ? { updated: new Map(), unverifiedRemote: new Map(), missingOnOwner: new Map() }
4555
4623
  : await refreshRemoteLaneMirrors(ctx, hqRoot, laneSource, new Set(selectedLanes
4556
4624
  .filter((lane) => !isTerminalLaneState(lane.state))
4557
4625
  .map((lane) => lane.lane_id)));
@@ -7035,11 +7103,21 @@ async function readOutpostTerminalLane(ctx, laneId, outpostId, stopCommandId, wa
7035
7103
  commandId,
7036
7104
  };
7037
7105
  }
7106
+ function isDefiniteOutpostLaneNotFound(dispatched, laneId, ownerError) {
7107
+ return dispatched.done === true &&
7108
+ Boolean(dispatched.commandId?.trim()) &&
7109
+ dispatched.status === "Failed" &&
7110
+ dispatched.exitCode !== null &&
7111
+ dispatched.exitCode !== 0 &&
7112
+ ownerError?.error === "lane_not_found" &&
7113
+ ownerError.details.lane_id === laneId;
7114
+ }
7038
7115
  async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
7039
7116
  if (lane.host.kind !== "outpost") {
7040
7117
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
7041
7118
  }
7042
7119
  const outpostId = lane.host.id;
7120
+ const hqRoot = ctx.hqRoot();
7043
7121
  const waitMs = outpostSingleLaneWaitMs(ctx.env);
7044
7122
  let dispatched;
7045
7123
  try {
@@ -7063,7 +7141,36 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
7063
7141
  required_option: "--reason",
7064
7142
  });
7065
7143
  }
7144
+ const commandId = dispatched.commandId?.trim();
7066
7145
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
7146
+ if (isDefiniteOutpostLaneNotFound(dispatched, lane.lane_id, ownerError)) {
7147
+ const at = ctx.now().toISOString();
7148
+ const updated = markRemoteLaneLost(ctx.hqRoot(), lane.lane_id, outpostId, at, "command", reason);
7149
+ if (updated.terminal_reason !== "remote_lost") {
7150
+ throw new LanesError("already_terminal", `Lane ${lane.lane_id} became terminal before its missing Outpost record could be marked remote_lost`, { lane_id: lane.lane_id, state: updated.state });
7151
+ }
7152
+ const cancelled = cancelPendingQuestionsForLane(hqRoot, lane.lane_id, at, "remote_lost");
7153
+ await emitLaneTerminalStateSoft(ctx, updated);
7154
+ if (!quiet)
7155
+ printJson({
7156
+ ok: true,
7157
+ action: "stop",
7158
+ reason: "remote_lost",
7159
+ lane: updated,
7160
+ terminal: true,
7161
+ resumable: false,
7162
+ cancelled_questions: cancelled.map((question) => question.question_id),
7163
+ outpost_id: outpostId,
7164
+ outpost_command_id: commandId,
7165
+ outpost_result: {
7166
+ state: "failed",
7167
+ reason: "remote_lost",
7168
+ status: dispatched.status,
7169
+ exit_code: dispatched.exitCode,
7170
+ },
7171
+ });
7172
+ return;
7173
+ }
7067
7174
  if (ownerError) {
7068
7175
  throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the stop command: ${ownerError.message}`, {
7069
7176
  lane_id: lane.lane_id,
@@ -7075,7 +7182,6 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
7075
7182
  owner_error: ownerError,
7076
7183
  });
7077
7184
  }
7078
- const commandId = dispatched.commandId?.trim();
7079
7185
  if (dispatched.done !== true || !commandId) {
7080
7186
  throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a completed stop command for lane ${lane.lane_id}`, {
7081
7187
  lane_id: lane.lane_id,
@@ -7559,8 +7665,42 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
7559
7665
  const fromEnv = ctx.env.HQ_ROOT?.trim();
7560
7666
  const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
7561
7667
  const mirrorRefresh = opts.dryRun
7562
- ? { updated: new Map(), unverifiedRemote: new Map() }
7668
+ ? {
7669
+ updated: new Map(),
7670
+ unverifiedRemote: new Map(),
7671
+ missingOnOwner: new Map(),
7672
+ }
7563
7673
  : await refreshRemoteLaneMirrors(ctx, hqRoot, loadAllLanes(hqRoot));
7674
+ const remoteLost = [];
7675
+ const remoteLostErrors = [];
7676
+ if (!opts.dryRun && mirrorRefresh.missingOnOwner.size > 0) {
7677
+ const at = ctx.now().toISOString();
7678
+ const nowMs = Date.parse(at);
7679
+ for (const [laneId, mirror] of mirrorRefresh.missingOnOwner) {
7680
+ const createdAtMs = Date.parse(mirror.created_at);
7681
+ if (!Number.isFinite(createdAtMs) ||
7682
+ nowMs - createdAtMs < REMOTE_LANE_NOT_FOUND_GRACE_MS)
7683
+ continue;
7684
+ try {
7685
+ const updated = markRemoteLaneLost(hqRoot, laneId, mirror.host.id, at, "reconcile", undefined, mirror.version);
7686
+ if (updated.terminal_reason !== "remote_lost") {
7687
+ if (updated.version !== mirror.version) {
7688
+ mirrorRefresh.unverifiedRemote.set(laneId, "mirror changed after the owner snapshot");
7689
+ }
7690
+ continue;
7691
+ }
7692
+ remoteLost.push({ id: laneId, reason: "remote_lost" });
7693
+ cancelPendingQuestionsForLane(hqRoot, laneId, at, "remote_lost");
7694
+ await emitLaneTerminalStateSoft(ctx, updated);
7695
+ }
7696
+ catch (err) {
7697
+ remoteLostErrors.push({
7698
+ laneId,
7699
+ message: err instanceof Error ? err.message : String(err),
7700
+ });
7701
+ }
7702
+ }
7703
+ }
7564
7704
  const summary = await runReconcilePass(hqRoot, {
7565
7705
  now: ctx.now,
7566
7706
  host: reconcileHostFromCtx(ctx),
@@ -7676,10 +7816,22 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
7676
7816
  }
7677
7817
  },
7678
7818
  });
7819
+ for (const failure of remoteLostErrors) {
7820
+ summary.errors.push({
7821
+ code: "remote_lane_terminalize_failed",
7822
+ message: `Could not mark remote lane ${failure.laneId} lost: ${failure.message}`,
7823
+ });
7824
+ summary.ok = false;
7825
+ }
7826
+ const terminalizedRemoteIds = new Set(remoteLost.map(({ id }) => id));
7679
7827
  printJson({
7680
7828
  ...summary,
7829
+ ...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
7681
7830
  ...(mirrorRefresh.unverifiedRemote.size > 0
7682
- ? { unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()] }
7831
+ ? {
7832
+ unverified_remote_lanes: [...mirrorRefresh.unverifiedRemote.keys()]
7833
+ .filter((id) => !terminalizedRemoteIds.has(id)),
7834
+ }
7683
7835
  : {}),
7684
7836
  });
7685
7837
  if (!summary.ok) {
@@ -52,7 +52,7 @@ import * as path from "node:path";
52
52
  import { fullJitterDelayMs } from "../mesh/live/backoff.js";
53
53
  import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
54
54
  import { BotApiError } from "./api.js";
55
- import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, SETUP_BOT_WORKER_ID } from "./config.js";
55
+ import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, resolveBotMemoryDir, SETUP_BOT_WORKER_ID } from "./config.js";
56
56
  import { ensureBotSessionMeta } from "./company-bind.js";
57
57
  import { readBotCredsIdentity } from "./creds.js";
58
58
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
@@ -61,6 +61,7 @@ import { containsNoReplySentinel, ProgressPoster, } from "./progress.js";
61
61
  import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
64
+ import { loadSetupExplainer, patchSetupIntroState, readSetupIntroState, readSetupProgressIntro, replyOffersExplain, SETUP_EXPLAINED_NOTE, setupExplainDue, } from "./setup-explain.js";
64
65
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
65
66
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
66
67
  import { botAttachmentsDir, dmSessionScope } from "./paths.js";
@@ -296,6 +297,26 @@ export async function runBot(deps) {
296
297
  const turnCwd = config.hqRoot;
297
298
  // HQ's setup bot is a guided conversation: no "working on it" filler between steps.
298
299
  const isSetupBot = config.workerId === SETUP_WORKER_ID;
300
+ /**
301
+ * Setup bot, owner DM: remember whether the bot's latest reply asked the
302
+ * explain-or-jump question, so a click on "Explain HQ first" can be answered
303
+ * at once (setup-explain.ts). Never lets a bookkeeping error touch the turn.
304
+ */
305
+ const noteSetupReply = (reply) => {
306
+ if (!isSetupBot)
307
+ return;
308
+ try {
309
+ const offered = replyOffersExplain(reply);
310
+ const current = readSetupIntroState(dir);
311
+ if (offered && !current.offeredAt)
312
+ patchSetupIntroState(dir, { offeredAt: now().toISOString() });
313
+ else if (!offered && current.offeredAt)
314
+ patchSetupIntroState(dir, { offeredAt: undefined });
315
+ }
316
+ catch (err) {
317
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
318
+ }
319
+ };
299
320
  // Scheduled runs are reported in the owner's own zone: "every weekday at 9am"
300
321
  // was set up here, so "due 13:00 UTC" would read as the wrong time.
301
322
  const timeZone = machineTimeZone();
@@ -1105,6 +1126,7 @@ export async function runBot(deps) {
1105
1126
  return;
1106
1127
  }
1107
1128
  clearInflight(dir, KICKOFF_MESSAGE_ID);
1129
+ noteSetupReply(outcome.reply);
1108
1130
  if (readBotConfig(dir)?.kickoffPendingSignIn)
1109
1131
  patchBotConfig(dir, { kickoffPendingSignIn: false });
1110
1132
  const st = readStatus();
@@ -1323,6 +1345,54 @@ export async function runBot(deps) {
1323
1345
  await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1324
1346
  }
1325
1347
  };
1348
+ /**
1349
+ * The instant answer to "Explain HQ first" (setup-explain.ts). Returns true
1350
+ * when it answered the message (sent, marked processed, acked); false sends
1351
+ * the message down the normal model path, so nothing here can leave a
1352
+ * message unanswered.
1353
+ */
1354
+ const sendSetupExplanation = async (item, text, attachments) => {
1355
+ const id = item.messageId;
1356
+ let reply;
1357
+ try {
1358
+ const due = setupExplainDue({
1359
+ text,
1360
+ attachments,
1361
+ state: readSetupIntroState(dir),
1362
+ progress: readSetupProgressIntro(resolveBotMemoryDir(config)),
1363
+ });
1364
+ if (!due)
1365
+ return false;
1366
+ const explainer = loadSetupExplainer(config.hqRoot, config.workerDir);
1367
+ if (!explainer) {
1368
+ log("warn", `no usable explain-hq.md in ${config.workerDir}; answering ${id} with a model turn`);
1369
+ return false;
1370
+ }
1371
+ reply = explainer.reply;
1372
+ }
1373
+ catch (err) {
1374
+ log("warn", `setup explanation check failed (${err instanceof Error ? err.message : String(err)}); answering ${id} with a model turn`);
1375
+ return false;
1376
+ }
1377
+ try {
1378
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, reply, item.rootEventId, "human");
1379
+ }
1380
+ catch (err) {
1381
+ log("warn", `setup explanation for ${id} could not be sent (${err instanceof Error ? err.message : String(err)}); answering with a model turn`);
1382
+ return false;
1383
+ }
1384
+ markProcessed(dir, id, now().toISOString());
1385
+ try {
1386
+ patchSetupIntroState(dir, { explainedAt: now().toISOString(), notePending: true, offeredAt: undefined });
1387
+ }
1388
+ catch (err) {
1389
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1390
+ }
1391
+ await api.ackInbox(config.agentUid, id);
1392
+ setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
1393
+ log("info", `answered ${id} with the setup explanation (no model turn)`);
1394
+ return true;
1395
+ };
1326
1396
  const handleItem = async (item) => {
1327
1397
  const id = item.messageId;
1328
1398
  const inboxState = readInboxState(dir);
@@ -1381,6 +1451,11 @@ export async function runBot(deps) {
1381
1451
  await api.ackInbox(config.agentUid, id);
1382
1452
  return;
1383
1453
  }
1454
+ // Setup bot: "Explain HQ first" clicked while that question is pending is
1455
+ // answered with the fixed explanation, with no model turn.
1456
+ const ownerDm = target.kind === "dm" && target.peerUid === config.ownerUid;
1457
+ if (isSetupBot && ownerDm && (await sendSetupExplanation(item, messageText, attachmentRefs.length)))
1458
+ return;
1384
1459
  const attempt = (attempts.get(id) ?? 0) + 1;
1385
1460
  attempts.set(id, attempt);
1386
1461
  const reactionId = item.eventId ?? id;
@@ -1441,7 +1516,10 @@ export async function runBot(deps) {
1441
1516
  text = text ? `${text}\n\n${block}` : block;
1442
1517
  }
1443
1518
  const sessionScope = target.kind === "room" ? target.sessionScope : dmSessionScope(target.peerUid, config.ownerUid);
1444
- const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? text :
1519
+ // The turn after the app sent the explanation: tell the model it went out.
1520
+ const setupNote = isSetupBot && ownerDm && readSetupIntroState(dir).notePending === true;
1521
+ const ownerText = setupNote ? `${SETUP_EXPLAINED_NOTE}\n\n${text}` : text;
1522
+ const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
1445
1523
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1446
1524
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
1447
1525
  const { send, sendFinal, saved } = trackingSend(markerTarget, item.rootEventId);
@@ -1463,6 +1541,18 @@ export async function runBot(deps) {
1463
1541
  ? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
1464
1542
  : {}),
1465
1543
  });
1544
+ // The model saw the note once its turn ran. A turn that never started, or
1545
+ // one held for a coding-tool sign-in, runs again and keeps the note for it.
1546
+ if (setupNote && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1547
+ try {
1548
+ patchSetupIntroState(dir, { notePending: false });
1549
+ }
1550
+ catch (err) {
1551
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1552
+ }
1553
+ }
1554
+ if (outcome.ok && ownerDm)
1555
+ noteSetupReply(outcome.reply);
1466
1556
  if (outcome.ok) {
1467
1557
  // A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
1468
1558
  if (outcome.silent) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The setup bot's instant "Explain HQ first" answer.
3
+ *
4
+ * The setup bot's first question is whether to explain HQ or jump straight in,
5
+ * with "Explain HQ first" as a suggested reply. The explanation is fixed text,
6
+ * so a click on that button does not need a model turn: the runner sends the
7
+ * text from the setup worker's `skills/explain-hq.md` at once, and the bot's
8
+ * next turn starts with a note saying the explanation already went out.
9
+ *
10
+ * The text has one home, the worker file. The model reads the same file when
11
+ * someone asks for the explanation in their own words, so the two paths never
12
+ * drift. Anything unexpected (file missing or malformed, the question not
13
+ * pending, any other message) falls back to the normal model turn.
14
+ */
15
+ /** The suggested reply that triggers the instant answer. Exact match only. */
16
+ export declare const EXPLAIN_HQ_SUGGESTION = "Explain HQ first";
17
+ /** Where the explanation lives, relative to the setup worker folder. */
18
+ export declare const EXPLAIN_HQ_FILE: string;
19
+ /** The setup bot's progress note in its memory folder (setup worker.yaml). */
20
+ export declare const SETUP_PROGRESS_FILE = "setup-progress.md";
21
+ /** Runner-owned state, in the bot's own folder (never the model's memory). */
22
+ export declare const SETUP_INTRO_STATE_FILE = "setup-intro.json";
23
+ /**
24
+ * Put in front of the next message from the person, so the model knows the
25
+ * explanation went out without it. It never reaches the person.
26
+ */
27
+ export declare const SETUP_EXPLAINED_NOTE: string;
28
+ export interface SetupExplainer {
29
+ /** The explanation, word for word (shared with the model path). */
30
+ explanation: string;
31
+ /** What the app adds after it: a line offering to continue, and suggestions. */
32
+ closing: string;
33
+ /** The whole message the app sends. */
34
+ reply: string;
35
+ }
36
+ /** Parse the worker file. Null when it is not the shape the app can send. */
37
+ export declare function parseSetupExplainer(body: string): SetupExplainer | null;
38
+ /** Read the explanation from the setup worker folder the bot runs. */
39
+ export declare function loadSetupExplainer(hqRoot: string, workerDir: string): SetupExplainer | null;
40
+ export type SetupProgressIntro = "asked" | "answered" | "finished" | null;
41
+ /** What the model's progress note says about the opening question. */
42
+ export declare function readSetupProgressIntro(memoryDir: string): SetupProgressIntro;
43
+ export interface SetupIntroState {
44
+ v: 1;
45
+ /** The bot's latest owner-DM reply asked the explain-or-jump question. */
46
+ offeredAt?: string;
47
+ /** The app sent the explanation (never sent twice). */
48
+ explainedAt?: string;
49
+ /** The model has not yet been told that the explanation went out. */
50
+ notePending?: boolean;
51
+ }
52
+ export declare function readSetupIntroState(dir: string): SetupIntroState;
53
+ export declare function patchSetupIntroState(dir: string, patch: Partial<Omit<SetupIntroState, "v">>): SetupIntroState;
54
+ /** A bot reply that asks the explain-or-jump question (the words or the button). */
55
+ export declare function replyOffersExplain(reply: string): boolean;
56
+ /**
57
+ * The instant answer is due only when ALL of these hold: the message is exactly
58
+ * the button text with nothing attached, the question is pending (the bot's
59
+ * last reply offered it, or its note says it asked), and the explanation has
60
+ * not gone out yet (neither by the app nor, per the note, by the model).
61
+ */
62
+ export declare function setupExplainDue(input: {
63
+ text: string;
64
+ attachments: number;
65
+ state: SetupIntroState;
66
+ progress: SetupProgressIntro;
67
+ }): boolean;
68
+ //# sourceMappingURL=setup-explain.d.ts.map