@indigoai-us/hq-cli 5.147.0 → 5.149.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
  3. package/dist/command-catalog.generated.d.ts +129 -0
  4. package/dist/command-catalog.generated.js +168 -0
  5. package/dist/commands/lanes.js +506 -23
  6. package/dist/commands/mcp-registration.d.ts +29 -16
  7. package/dist/commands/mcp-registration.js +254 -17
  8. package/dist/commands/mesh.js +82 -11
  9. package/dist/lib/lanes/index.d.ts +3 -2
  10. package/dist/lib/lanes/index.js +2 -1
  11. package/dist/lib/lanes/mesh-events.d.ts +8 -0
  12. package/dist/lib/lanes/mesh-events.js +83 -6
  13. package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
  14. package/dist/lib/lanes/outpost-dispatch.js +17 -0
  15. package/dist/lib/lanes/reconcile.js +39 -12
  16. package/dist/lib/lanes/session-relink.d.ts +25 -0
  17. package/dist/lib/lanes/session-relink.js +216 -0
  18. package/dist/lib/lanes/spawn.d.ts +1 -0
  19. package/dist/lib/lanes/spawn.js +48 -5
  20. package/dist/lib/lanes/store.js +109 -1
  21. package/dist/lib/lanes/stories.d.ts +17 -0
  22. package/dist/lib/lanes/stories.js +73 -0
  23. package/dist/lib/lanes/types.d.ts +20 -0
  24. package/dist/lib/lanes/types.js +9 -2
  25. package/dist/lib/lanes/watcher.js +69 -9
  26. package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
  27. package/dist/lib/mesh/live/daemon/doctor.js +84 -0
  28. package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
  29. package/dist/lib/mesh/live/daemon/index.js +2 -2
  30. package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
  31. package/dist/lib/mesh/live/daemon/install.js +88 -0
  32. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  33. package/dist/lib/mesh/live/daemon/run.js +12 -1
  34. package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
  35. package/dist/lib/mesh/project-board.d.ts +15 -0
  36. package/dist/lib/mesh/project-board.js +84 -0
  37. package/dist/lib/work-context/prd-sync.d.ts +5 -1
  38. package/dist/lib/work-context/prd-sync.js +22 -1
  39. package/dist/sentry.js +15 -7
  40. package/dist/utils/sentry-fingerprint.d.ts +11 -0
  41. package/dist/utils/sentry-fingerprint.js +56 -11
  42. package/package.json +5 -3
  43. package/scripts/mesh-daemon-if-stale.mjs +16 -0
@@ -74,6 +74,15 @@ export declare class ConfigParseError extends McpRegistrationError {
74
74
  export declare class ConfigPermissionError extends McpRegistrationError {
75
75
  readonly code: "ConfigPermissionError";
76
76
  }
77
+ export type CodexMcpCollisionOwner = 'hq' | 'foreign' | 'unknown';
78
+ export type CodexMcpCollisionField = 'command' | 'args' | 'env' | 'url' | 'other';
79
+ /** Safe, value-free context for a Codex name collision reported to Sentry. */
80
+ export interface CodexMcpCollisionDiagnostics {
81
+ client: 'codex';
82
+ serverName: string;
83
+ existingEntryOwner: CodexMcpCollisionOwner;
84
+ diffFields: CodexMcpCollisionField[];
85
+ }
77
86
  /**
78
87
  * A server name is already present in the target config with a DIFFERENT
79
88
  * definition than the one being merged (def-equal = idempotent no-op;
@@ -82,6 +91,8 @@ export declare class ConfigPermissionError extends McpRegistrationError {
82
91
  */
83
92
  export declare class McpNameCollisionError extends McpRegistrationError {
84
93
  readonly code: "McpNameCollisionError";
94
+ readonly codexDiagnostics?: CodexMcpCollisionDiagnostics;
95
+ constructor(message: string, codexDiagnostics?: CodexMcpCollisionDiagnostics);
85
96
  }
86
97
  /**
87
98
  * The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
@@ -494,7 +505,8 @@ export declare function serverDefsEqual(a: unknown, b: unknown): boolean;
494
505
  * it — but byte-equality of the rest means {@link writeConfigAtomic} writes
495
506
  * nothing unless the stamp itself is the only delta, which we treat as a no-op
496
507
  * by returning the original doc.
497
- * - name PRESENT, def-DIFFERS -> {@link McpNameCollisionError} ABORT.
508
+ * - name PRESENT, def-DIFFERS, same non-empty `_hqPack` -> replace the entry.
509
+ * - name PRESENT, def-DIFFERS, unmarked or different owner -> collision ABORT.
498
510
  *
499
511
  * Never touches any OTHER server entry or any other top-level key.
500
512
  */
@@ -646,8 +658,8 @@ type TomlTable = Record<string, unknown>;
646
658
  * The Codex doc threaded through the safe-write core. We carry BOTH the parsed
647
659
  * value (`value`, for the merge fn + verify) AND the ORIGINAL file text
648
660
  * (`originalText`, `null` for a fresh/empty file). {@link codexConfigFormat.serialize}
649
- * uses `originalText` to APPEND the one new table without re-serializing — and so
650
- * without dropping — the user's existing comments and tables. This is the seam that
661
+ * uses `originalText` to APPEND a new table or replace sections of a same-pack
662
+ * owned table without re-serializing unrelated content. This is the seam that
651
663
  * makes "preserves comments" honest within a value-only TOML library.
652
664
  */
653
665
  export interface CodexTomlDoc {
@@ -661,7 +673,7 @@ export declare const CODEX_MCP_SERVERS_KEY = "mcp_servers";
661
673
  /**
662
674
  * Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
663
675
  * by wrapping the def under `mcp_servers.<name>` and round-tripping it through
664
- * smol-toml. Used both for the append path (preserve-comments) and for the
676
+ * smol-toml. Used for appending new servers, replacing an owned table, and the
665
677
  * collision/equality comparison. The output is deterministic for a given def so a
666
678
  * re-install produces byte-identical text (idempotency depends on it).
667
679
  */
@@ -670,15 +682,15 @@ export declare function serializeOneCodexServer(name: string, def: TomlTable): s
670
682
  * The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
671
683
  * (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
672
684
  * {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
673
- * the doc carries `originalText` (the file pre-existed) it APPENDS the new
674
- * `mcp_servers.<name>` table to the original bytes rather than re-serializing the
675
- * whole value; only when there is no original (`null`) does it serialize the full
676
- * value from scratch. `assertEntry` checks `mcp_servers.<name>` is present.
677
- *
678
- * Because `serialize` needs the merged value to know WHICH server to append, the
679
- * merge fn ({@link mergeCodexServer}) records the just-added server on the doc; the
680
- * append path reads it back. For an idempotent no-op (server already present,
681
- * def-equal) the merge returns the doc UNCHANGED with no pending append, so
685
+ * the doc carries `originalText` (the file pre-existed) it APPENDS a new server
686
+ * table or replaces only a same-pack owned table, rather than re-serializing the
687
+ * whole value. Only a fresh file (`null`) is serialized from scratch.
688
+ * `assertEntry` checks `mcp_servers.<name>` is present.
689
+ *
690
+ * Because `serialize` needs to know WHICH server to append or replace, the merge
691
+ * fn ({@link mergeCodexServer}) records the operation on the doc. For an
692
+ * idempotent no-op (server already present, def-equal) the merge returns the doc
693
+ * UNCHANGED, so
682
694
  * `serialize` reproduces the original text exactly (no write happens).
683
695
  */
684
696
  export declare const codexConfigFormat: ConfigFormat<CodexTomlDoc>;
@@ -716,9 +728,10 @@ export declare function buildCodexToolsTable(tools: McpManifest['tools']): TomlT
716
728
  * - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
717
729
  * PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
718
730
  * original text round-trips byte-for-byte.
719
- * - name PRESENT, def-DIFFERS → {@link McpNameCollisionError} ABORT (never mutate
720
- * an existing table — which is also what keeps the comment-preserving append
721
- * sound: we only ever ADD).
731
+ * - name PRESENT, def-DIFFERS, same `_hqPack` owner → update that owned table
732
+ * while preserving unrelated TOML bytes and comments.
733
+ * - name PRESENT, def-DIFFERS, any other owner → {@link McpNameCollisionError}
734
+ * ABORT with value-free diagnostics.
722
735
  *
723
736
  * Never touches any OTHER server table or any other top-level table.
724
737
  */
@@ -99,6 +99,11 @@ export class ConfigPermissionError extends McpRegistrationError {
99
99
  */
100
100
  export class McpNameCollisionError extends McpRegistrationError {
101
101
  code = 'McpNameCollisionError';
102
+ codexDiagnostics;
103
+ constructor(message, codexDiagnostics) {
104
+ super(message);
105
+ this.codexDiagnostics = codexDiagnostics;
106
+ }
102
107
  }
103
108
  /**
104
109
  * The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
@@ -1009,7 +1014,8 @@ function canonicalJson(v) {
1009
1014
  * it — but byte-equality of the rest means {@link writeConfigAtomic} writes
1010
1015
  * nothing unless the stamp itself is the only delta, which we treat as a no-op
1011
1016
  * by returning the original doc.
1012
- * - name PRESENT, def-DIFFERS -> {@link McpNameCollisionError} ABORT.
1017
+ * - name PRESENT, def-DIFFERS, same non-empty `_hqPack` -> replace the entry.
1018
+ * - name PRESENT, def-DIFFERS, unmarked or different owner -> collision ABORT.
1013
1019
  *
1014
1020
  * Never touches any OTHER server entry or any other top-level key.
1015
1021
  */
@@ -1023,6 +1029,12 @@ export function mergeClaudeServer(name, def) {
1023
1029
  // (do not re-stamp) so a re-install is a true byte-for-byte no-op.
1024
1030
  return doc;
1025
1031
  }
1032
+ const existingPack = provenanceOf(existing);
1033
+ const incomingPack = provenanceOf(def);
1034
+ if (existingPack && existingPack === incomingPack) {
1035
+ servers[name] = def;
1036
+ return { ...doc, mcpServers: servers };
1037
+ }
1026
1038
  throw new McpNameCollisionError(`MCP server "${name}" is already defined in ~/.claude.json with a different ` +
1027
1039
  'definition — refusing to overwrite. Remove or rename the existing server, ' +
1028
1040
  'or uninstall the conflicting pack, then re-install.');
@@ -1302,7 +1314,7 @@ export const CODEX_MCP_SERVERS_KEY = 'mcp_servers';
1302
1314
  /**
1303
1315
  * Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
1304
1316
  * by wrapping the def under `mcp_servers.<name>` and round-tripping it through
1305
- * smol-toml. Used both for the append path (preserve-comments) and for the
1317
+ * smol-toml. Used for appending new servers, replacing an owned table, and the
1306
1318
  * collision/equality comparison. The output is deterministic for a given def so a
1307
1319
  * re-install produces byte-identical text (idempotency depends on it).
1308
1320
  */
@@ -1313,15 +1325,15 @@ export function serializeOneCodexServer(name, def) {
1313
1325
  * The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
1314
1326
  * (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
1315
1327
  * {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
1316
- * the doc carries `originalText` (the file pre-existed) it APPENDS the new
1317
- * `mcp_servers.<name>` table to the original bytes rather than re-serializing the
1318
- * whole value; only when there is no original (`null`) does it serialize the full
1319
- * value from scratch. `assertEntry` checks `mcp_servers.<name>` is present.
1328
+ * the doc carries `originalText` (the file pre-existed) it APPENDS a new server
1329
+ * table or replaces only a same-pack owned table, rather than re-serializing the
1330
+ * whole value. Only a fresh file (`null`) is serialized from scratch.
1331
+ * `assertEntry` checks `mcp_servers.<name>` is present.
1320
1332
  *
1321
- * Because `serialize` needs the merged value to know WHICH server to append, the
1322
- * merge fn ({@link mergeCodexServer}) records the just-added server on the doc; the
1323
- * append path reads it back. For an idempotent no-op (server already present,
1324
- * def-equal) the merge returns the doc UNCHANGED with no pending append, so
1333
+ * Because `serialize` needs to know WHICH server to append or replace, the merge
1334
+ * fn ({@link mergeCodexServer}) records the operation on the doc. For an
1335
+ * idempotent no-op (server already present, def-equal) the merge returns the doc
1336
+ * UNCHANGED, so
1325
1337
  * `serialize` reproduces the original text exactly (no write happens).
1326
1338
  */
1327
1339
  export const codexConfigFormat = {
@@ -1332,7 +1344,16 @@ export const codexConfigFormat = {
1332
1344
  return { value, originalText: text };
1333
1345
  },
1334
1346
  serialize(doc) {
1335
- const pending = doc[PENDING_APPEND];
1347
+ const pendingDoc = doc;
1348
+ const pendingUpdate = pendingDoc[PENDING_UPDATE];
1349
+ if (doc.originalText !== null && pendingUpdate) {
1350
+ const updated = replaceCodexTable(doc.originalText, pendingUpdate.name, pendingUpdate.def);
1351
+ if (updated === null) {
1352
+ throw new McpNameCollisionError(codexCollisionMessage(pendingUpdate.name), pendingUpdate.diagnostics);
1353
+ }
1354
+ return updated;
1355
+ }
1356
+ const pending = pendingDoc[PENDING_APPEND];
1336
1357
  if (doc.originalText !== null && pending) {
1337
1358
  // APPEND path: keep the original bytes verbatim (comments + every existing
1338
1359
  // table/sub-table preserved), then append the one new server table.
@@ -1362,6 +1383,7 @@ export const codexConfigFormat = {
1362
1383
  };
1363
1384
  /** Symbol carrying the pending append on a {@link CodexTomlDoc} (merge → serialize). */
1364
1385
  const PENDING_APPEND = Symbol('hqPendingCodexAppend');
1386
+ const PENDING_UPDATE = Symbol('hqPendingCodexUpdate');
1365
1387
  /** Return a copy of `value` with `mcp_servers.<name> = def` set (used for fresh-file serialize). */
1366
1388
  function withServer(value, name, def) {
1367
1389
  const servers = { ...(value[CODEX_MCP_SERVERS_KEY] ?? {}) };
@@ -1384,6 +1406,171 @@ export function appendCodexTable(originalText, name, def) {
1384
1406
  const body = block.endsWith('\n') ? block : `${block}\n`;
1385
1407
  return `${trimmed}\n\n${body}`;
1386
1408
  }
1409
+ /** Return the decoded table path for a valid TOML table header, or null. */
1410
+ function codexTablePath(headerLine) {
1411
+ try {
1412
+ const value = parseToml(headerLine);
1413
+ const matches = [];
1414
+ const visit = (current, pathParts) => {
1415
+ if (current === null || typeof current !== 'object')
1416
+ return;
1417
+ if (Array.isArray(current)) {
1418
+ current.forEach((item, index) => visit(item, [...pathParts, String(index)]));
1419
+ return;
1420
+ }
1421
+ const table = current;
1422
+ const entries = Object.entries(table);
1423
+ if (entries.length === 0) {
1424
+ matches.push(pathParts);
1425
+ return;
1426
+ }
1427
+ for (const [key, child] of entries) {
1428
+ visit(child, [...pathParts, key]);
1429
+ }
1430
+ };
1431
+ visit(value, []);
1432
+ return matches.length === 1 ? matches[0] : null;
1433
+ }
1434
+ catch {
1435
+ // The caller has already parsed the whole document. Ordinary values are
1436
+ // not table headers.
1437
+ return null;
1438
+ }
1439
+ }
1440
+ /**
1441
+ * Locate real table headers without mistaking bracket-like text in TOML values
1442
+ * for a header. Parsing the preceding prefix ensures a candidate is not inside
1443
+ * an unfinished multiline string or array; smol-toml decodes quoted key paths.
1444
+ */
1445
+ function codexTomlSections(text) {
1446
+ const headers = [];
1447
+ let offset = 0;
1448
+ for (const line of text.match(/[^\n]*(?:\n|$)/g) ?? []) {
1449
+ if (line.length === 0)
1450
+ continue;
1451
+ const content = line.replace(/\r?\n$/, '');
1452
+ if (content.trimStart().startsWith('[')) {
1453
+ try {
1454
+ parseToml(text.slice(0, offset));
1455
+ const path = codexTablePath(content);
1456
+ if (path)
1457
+ headers.push({ start: offset, path });
1458
+ }
1459
+ catch {
1460
+ // The prefix is incomplete TOML, so this bracket is part of a value.
1461
+ }
1462
+ }
1463
+ offset += line.length;
1464
+ }
1465
+ return headers.map((header, index) => {
1466
+ const nextStart = headers[index + 1]?.start ?? text.length;
1467
+ let end = nextStart;
1468
+ const trailingLines = text.slice(header.start, nextStart).match(/[^\n]*(?:\n|$)/g) ?? [];
1469
+ for (let lineIndex = trailingLines.length - 1; lineIndex >= 0; lineIndex -= 1) {
1470
+ const content = trailingLines[lineIndex].replace(/\r?\n$/, '');
1471
+ if (content.trim() === '' || content.trimStart().startsWith('#')) {
1472
+ end -= trailingLines[lineIndex].length;
1473
+ }
1474
+ else {
1475
+ break;
1476
+ }
1477
+ }
1478
+ return { ...header, end };
1479
+ });
1480
+ }
1481
+ /** Extract comment text outside TOML strings so updating an owned table drops no user notes. */
1482
+ function codexTomlComments(text) {
1483
+ const comments = [];
1484
+ let multiline = null;
1485
+ let lineOffset = 0;
1486
+ for (const lineWithEnding of text.match(/[^\n]*(?:\n|$)/g) ?? []) {
1487
+ if (lineWithEnding.length === 0)
1488
+ continue;
1489
+ const line = lineWithEnding.replace(/\r?\n$/, '');
1490
+ let quote = null;
1491
+ let index = 0;
1492
+ while (index < line.length) {
1493
+ if (multiline) {
1494
+ const delimiter = multiline === 'basic' ? '"""' : "'''";
1495
+ if (multiline === 'basic' && line[index] === '\\') {
1496
+ index += 2;
1497
+ }
1498
+ else if (line.startsWith(delimiter, index)) {
1499
+ multiline = null;
1500
+ index += 3;
1501
+ }
1502
+ else {
1503
+ index += 1;
1504
+ }
1505
+ continue;
1506
+ }
1507
+ if (quote) {
1508
+ const closing = quote === 'basic' ? '"' : "'";
1509
+ if (quote === 'basic' && line[index] === '\\') {
1510
+ index += 2;
1511
+ }
1512
+ else if (line[index] === closing) {
1513
+ quote = null;
1514
+ index += 1;
1515
+ }
1516
+ else {
1517
+ index += 1;
1518
+ }
1519
+ continue;
1520
+ }
1521
+ if (line[index] === '#') {
1522
+ comments.push({ offset: lineOffset + index, text: line.slice(index) });
1523
+ break;
1524
+ }
1525
+ if (line.startsWith('"""', index)) {
1526
+ multiline = 'basic';
1527
+ index += 3;
1528
+ }
1529
+ else if (line.startsWith("'''", index)) {
1530
+ multiline = 'literal';
1531
+ index += 3;
1532
+ }
1533
+ else if (line[index] === '"') {
1534
+ quote = 'basic';
1535
+ index += 1;
1536
+ }
1537
+ else if (line[index] === "'") {
1538
+ quote = 'literal';
1539
+ index += 1;
1540
+ }
1541
+ else {
1542
+ index += 1;
1543
+ }
1544
+ }
1545
+ lineOffset += lineWithEnding.length;
1546
+ }
1547
+ return comments;
1548
+ }
1549
+ /** Replace all sections for one same-pack server while retaining unrelated bytes and comments. */
1550
+ function replaceCodexTable(originalText, name, def) {
1551
+ const sections = codexTomlSections(originalText);
1552
+ const target = sections.filter((section) => section.path[0] === CODEX_MCP_SERVERS_KEY && section.path[1] === name);
1553
+ if (!target.some((section) => section.path.length === 2))
1554
+ return null;
1555
+ const newline = originalText.includes('\r\n') ? '\r\n' : '\n';
1556
+ const comments = codexTomlComments(originalText)
1557
+ .filter((comment) => target.some((section) => comment.offset >= section.start && comment.offset < section.end))
1558
+ .map((comment) => comment.text);
1559
+ let output = '';
1560
+ let cursor = 0;
1561
+ let inserted = false;
1562
+ for (const section of target) {
1563
+ output += originalText.slice(cursor, section.start);
1564
+ if (!inserted) {
1565
+ output += serializeOneCodexServer(name, def).replace(/\n/g, newline);
1566
+ if (comments.length > 0)
1567
+ output += comments.join(newline) + newline;
1568
+ inserted = true;
1569
+ }
1570
+ cursor = section.end;
1571
+ }
1572
+ return inserted ? output + originalText.slice(cursor) : null;
1573
+ }
1387
1574
  /**
1388
1575
  * Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
1389
1576
  * transport fields through, resolve `${secret:}` in every argument/header/env value
@@ -1447,9 +1634,10 @@ export function buildCodexToolsTable(tools) {
1447
1634
  * - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
1448
1635
  * PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
1449
1636
  * original text round-trips byte-for-byte.
1450
- * - name PRESENT, def-DIFFERS → {@link McpNameCollisionError} ABORT (never mutate
1451
- * an existing table — which is also what keeps the comment-preserving append
1452
- * sound: we only ever ADD).
1637
+ * - name PRESENT, def-DIFFERS, same `_hqPack` owner → update that owned table
1638
+ * while preserving unrelated TOML bytes and comments.
1639
+ * - name PRESENT, def-DIFFERS, any other owner → {@link McpNameCollisionError}
1640
+ * ABORT with value-free diagnostics.
1453
1641
  *
1454
1642
  * Never touches any OTHER server table or any other top-level table.
1455
1643
  */
@@ -1463,9 +1651,23 @@ export function mergeCodexServer(name, def) {
1463
1651
  // so serialize reproduces the original text exactly.
1464
1652
  return doc;
1465
1653
  }
1466
- throw new McpNameCollisionError(`MCP server "${name}" is already defined in ~/.codex/config.toml with a different ` +
1467
- 'definition — refusing to overwrite. Remove or rename the existing server, ' +
1468
- 'or uninstall the conflicting pack, then re-install.');
1654
+ const diagnostics = codexCollisionDiagnostics(name, existing, def);
1655
+ const existingPack = isTomlTable(existing)
1656
+ ? existing[HQ_PACK_PROVENANCE_KEY]
1657
+ : undefined;
1658
+ if (doc.originalText !== null &&
1659
+ isTomlTable(existing) &&
1660
+ typeof existingPack === 'string' &&
1661
+ existingPack.length > 0 &&
1662
+ existingPack === def[HQ_PACK_PROVENANCE_KEY]) {
1663
+ const next = {
1664
+ value: withServer(doc.value, name, def),
1665
+ originalText: doc.originalText,
1666
+ [PENDING_UPDATE]: { name, def, diagnostics },
1667
+ };
1668
+ return next;
1669
+ }
1670
+ throw new McpNameCollisionError(codexCollisionMessage(name), diagnostics);
1469
1671
  }
1470
1672
  // Insert into the value (for verify-after-write) AND record the pending append
1471
1673
  // (for comment-preserving serialize).
@@ -1478,6 +1680,41 @@ export function mergeCodexServer(name, def) {
1478
1680
  return next;
1479
1681
  };
1480
1682
  }
1683
+ function isTomlTable(value) {
1684
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1685
+ }
1686
+ function codexCollisionMessage(name) {
1687
+ return `MCP server "${name}" is already defined in ~/.codex/config.toml with a different ` +
1688
+ 'definition — refusing to overwrite. Remove or rename the existing server, ' +
1689
+ 'or uninstall the conflicting pack, then re-install.';
1690
+ }
1691
+ function codexCollisionDiagnostics(name, existing, incoming) {
1692
+ const existingTable = isTomlTable(existing) ? existing : {};
1693
+ const owner = typeof existingTable[HQ_PACK_PROVENANCE_KEY] === 'string' &&
1694
+ existingTable[HQ_PACK_PROVENANCE_KEY].length > 0
1695
+ ? 'hq'
1696
+ : 'unknown';
1697
+ const keys = new Set([...Object.keys(existingTable), ...Object.keys(incoming)]);
1698
+ keys.delete(HQ_PACK_PROVENANCE_KEY);
1699
+ const fields = new Set();
1700
+ for (const key of keys) {
1701
+ const existingHasKey = Object.prototype.hasOwnProperty.call(existingTable, key);
1702
+ const incomingHasKey = Object.prototype.hasOwnProperty.call(incoming, key);
1703
+ if (existingHasKey === incomingHasKey &&
1704
+ canonicalJson(existingTable[key]) === canonicalJson(incoming[key])) {
1705
+ continue;
1706
+ }
1707
+ fields.add(key === 'command' || key === 'args' || key === 'env' || key === 'url'
1708
+ ? key
1709
+ : 'other');
1710
+ }
1711
+ return {
1712
+ client: 'codex',
1713
+ serverName: name,
1714
+ existingEntryOwner: owner,
1715
+ diffFields: [...fields].sort(),
1716
+ };
1717
+ }
1481
1718
  /**
1482
1719
  * Emit ONE server into the GLOBAL Codex surface (`~/.codex/config.toml`
1483
1720
  * `[mcp_servers.<name>]`) via {@link writeConfigAtomic}. A FIRST-CLASS SKIP when the
@@ -9,11 +9,12 @@ import { resolve as resolvePath } from "node:path";
9
9
  import * as os from "node:os";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { findHqRoot } from "../utils/manifest.js";
12
+ import { CLI_VERSION } from "../cli-version.js";
12
13
  import chalk from "chalk";
13
14
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
14
15
  import * as readline from "node:readline/promises";
15
16
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
16
- import { closeSupersededProjectThread, ensureProjectBoard } from "../lib/mesh/project-board.js";
17
+ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard } from "../lib/mesh/project-board.js";
17
18
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
18
19
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
19
20
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -34,7 +35,7 @@ import { generateUlid } from "../lib/mesh/live/ulid.js";
34
35
  import { readLegacyBacklog } from "../lib/mesh/live/emit-replay.js";
35
36
  import { resolveMeshEmitMode, writeMeshConfigMode, } from "../lib/mesh/live/daemon/mode.js";
36
37
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
37
- import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncestorHqRoot, resolveInstallHqRoot, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
38
+ import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncestorHqRoot, resolveInstallHqRoot, detectPlatform, formatCliIdentityLines, formatDaemonDoctor, installDaemonIfStale, installDaemonService, readDaemonState, resolveAbsoluteNodeBinary, resolveRunningCliEntry, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
38
39
  import { workContextRoot } from "../lib/work-context/paths.js";
39
40
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
40
41
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
@@ -575,6 +576,29 @@ async function runPrdSync(opts) {
575
576
  ...(bound?.projectId ? { project: bound.projectId } : {}),
576
577
  touchedPaths: [resolvePath(file)],
577
578
  };
579
+ if (bound?.companyUid && bound.projectId) {
580
+ try {
581
+ const source = parseProjectPlan(readFileSync(file, "utf8"), file);
582
+ await ensureMissingBoardStories({
583
+ token,
584
+ company: {
585
+ companyUid: bound.companyUid,
586
+ companySlug: bound.companySlug || bound.companyUid,
587
+ },
588
+ projectId: bound.projectId,
589
+ name: source.name || bound.projectId,
590
+ description: source.description ?? "",
591
+ stories: source.stories,
592
+ repos: source.repos ?? [],
593
+ requiredIds: plan.events.map((event) => event.taskId),
594
+ });
595
+ }
596
+ catch (err) {
597
+ const reason = err instanceof Error ? err.message : String(err);
598
+ console.error(`work-mesh: prd-sync ensure failed; emitting anyway (${reason.slice(0, 160)})`);
599
+ }
600
+ }
601
+ const meshRoot = workMeshHomeRoot();
578
602
  const seqFirst = reserveSessionSeq(sessionId, plan.events.length);
579
603
  const events = plan.events.map((event, index) => buildMeshEmitEvent({
580
604
  eventId: generateUlid(Date.now() + index),
@@ -592,7 +616,7 @@ async function runPrdSync(opts) {
592
616
  }));
593
617
  const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
594
618
  const emitted = await emitEvents({
595
- workMeshRoot: workMeshHomeRoot(),
619
+ workMeshRoot: meshRoot,
596
620
  poster,
597
621
  newEvents: events,
598
622
  });
@@ -601,8 +625,15 @@ async function runPrdSync(opts) {
601
625
  for (const line of formatEmitRejectionLines(emitted.rejections))
602
626
  console.error(line);
603
627
  }
604
- plan.commit();
605
- if (plan.taskId) {
628
+ const blocked = new Set(emitted.rejections.map((row) => row.eventId));
629
+ for (const pending of readEmitRetry(meshRoot)) {
630
+ const id = typeof pending.eventId === "string" ? pending.eventId : "";
631
+ if (id)
632
+ blocked.add(id);
633
+ }
634
+ const advanced = events.flatMap((event) => (event.eventId && event.taskId && !blocked.has(event.eventId) ? [event.taskId] : []));
635
+ plan.commit(advanced);
636
+ if (plan.taskId && advanced.includes(plan.taskId)) {
606
637
  updateSessionState(sessionId, root, (current) => {
607
638
  if (!current)
608
639
  return null;
@@ -639,6 +670,12 @@ async function runDoctor(opts) {
639
670
  fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
640
671
  "Omit --apply to warm the conversation cache (directory, inbox, pair DMs).");
641
672
  }
673
+ const identity = collectDaemonDoctor();
674
+ const identityLines = formatCliIdentityLines(identity);
675
+ if (!opts.json) {
676
+ for (const line of identityLines)
677
+ console.log(line);
678
+ }
642
679
  const token = await requireToken();
643
680
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
644
681
  if (!principalUid) {
@@ -647,7 +684,15 @@ async function runDoctor(opts) {
647
684
  const warmed = await warmMeshConversationCache(token, principalUid);
648
685
  const needsProject = listNeedsProjectSessions(workContextHomeRoot());
649
686
  if (opts.json) {
650
- console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed, needsProject }, null, 2));
687
+ console.log(JSON.stringify({
688
+ ok: true,
689
+ action: "doctor",
690
+ ...warmed,
691
+ needsProject,
692
+ cliMismatchLine: identity.cliMismatchLine ?? null,
693
+ hqOnPath: identity.hqOnPath,
694
+ multipleHqOnPath: identity.multipleHqOnPath,
695
+ }, null, 2));
651
696
  return;
652
697
  }
653
698
  console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
@@ -1938,6 +1983,7 @@ export function registerMeshCommand(program) {
1938
1983
  .description("Install LaunchAgent (macOS), systemd user unit (Linux), or Scheduled Task (Windows)")
1939
1984
  .option("--system-user <user>", "Write a root-owned unit template for agent boxes (e.g. ec2-user)")
1940
1985
  .option("--hq-root <path>", "HQ root to record for the service (else HQ_ROOT, else the nearest HQ root above cwd)")
1986
+ .option("--if-stale", "Reinstall and restart only when this CLI is newer than the daemon's recorded version")
1941
1987
  .option("--json", "Print machine-readable JSON")
1942
1988
  .action((opts) => wrap(() => runDaemonInstall(opts))());
1943
1989
  daemon
@@ -1971,13 +2017,38 @@ async function runDaemonInstall(opts) {
1971
2017
  // Verified root only: a repo's own .claude/ must never become the daemon's
1972
2018
  // HQ root (that path is persisted as trusted and used for attribution).
1973
2019
  const hqRoot = resolveInstallHqRoot({ explicit: opts.hqRoot });
2020
+ const paths = buildInstallPaths({
2021
+ systemUser: opts.systemUser,
2022
+ hqRoot: hqRoot.root,
2023
+ hqRootSource: hqRoot.source,
2024
+ hqBinary: resolveRunningCliEntry(),
2025
+ nodeBinary: resolveAbsoluteNodeBinary(),
2026
+ });
2027
+ if (opts.ifStale) {
2028
+ const stale = installDaemonIfStale({
2029
+ currentVersion: CLI_VERSION,
2030
+ platform: detectPlatform(),
2031
+ paths,
2032
+ systemUser: opts.systemUser,
2033
+ });
2034
+ if (opts.json) {
2035
+ console.log(JSON.stringify({
2036
+ ok: true,
2037
+ action: stale.reinstalled ? "install" : "unchanged",
2038
+ reinstalled: stale.reinstalled,
2039
+ message: stale.message,
2040
+ hqBinary: paths.hqBinary,
2041
+ nodeBinary: paths.nodeBinary,
2042
+ hqRoot: hqRoot.root,
2043
+ }, null, 2));
2044
+ return;
2045
+ }
2046
+ console.log(stale.message);
2047
+ return;
2048
+ }
1974
2049
  const result = installDaemonService({
1975
2050
  platform: detectPlatform(),
1976
- paths: buildInstallPaths({
1977
- systemUser: opts.systemUser,
1978
- hqRoot: hqRoot.root,
1979
- hqRootSource: hqRoot.source,
1980
- }),
2051
+ paths,
1981
2052
  systemUser: opts.systemUser,
1982
2053
  });
1983
2054
  if (opts.json) {
@@ -3,8 +3,9 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
7
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LaneRecord, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
6
+ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
7
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
8
+ export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
8
9
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
9
10
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
10
11
  export { resolveLanesHqRoot } from "./hq-root.js";
@@ -3,7 +3,8 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
6
+ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
7
+ export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
7
8
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
8
9
  export { resolveLanesHqRoot } from "./hq-root.js";
9
10
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
@@ -78,6 +78,14 @@ export interface CreateLaneMeshOptions {
78
78
  interactive?: boolean;
79
79
  /** Test seam. Defaults to the shared `requireToken`. */
80
80
  token?: () => Promise<string>;
81
+ /** Repair a missing project view before retrying a story status patch. */
82
+ repairProjectView?: (input: {
83
+ token: string;
84
+ company: LaneCompanyRef;
85
+ projectId: string;
86
+ storyId: string;
87
+ status: string;
88
+ }) => Promise<void>;
81
89
  }
82
90
  export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
83
91
  /** Resolve the explicit mesh attribution before falling back to the lane tenant. */