session-orchestrator 3.23.0 → 3.24.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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. package/skills/wave-executor/wave-loop.md +43 -5
@@ -58,6 +58,8 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
58
58
  import { validatePathInsideProject } from '../path-utils.mjs';
59
59
  import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
60
60
  import { atomicWriteWithBackup } from '../io.mjs';
61
+ import { withBoardLock } from './board-lock.mjs';
62
+ import { expandTilde } from '../common.mjs';
61
63
 
62
64
  /** Frontmatter sentinel that identifies generator-owned board files. */
63
65
  export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
@@ -180,22 +182,6 @@ const nameSlot = (repo) => `n:${foldKey(repo)}`;
180
182
 
181
183
  // ── Path helpers ────────────────────────────────────────────────────────────────
182
184
 
183
- /**
184
- * Expand a leading `~` to the current user's home directory. Inlined here on
185
- * purpose — the shared helper is private elsewhere, and a shared
186
- * `vault-write-guard.mjs` extraction is deferred to a later epic (W2 forbids a
187
- * new shared file in this slice).
188
- *
189
- * @param {string} p
190
- * @returns {string}
191
- */
192
- function expandHome(p) {
193
- if (typeof p !== 'string' || p.length === 0) return p;
194
- if (p === '~') return os.homedir();
195
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
196
- return p;
197
- }
198
-
199
185
  /**
200
186
  * Resolve the board file path from a vault directory.
201
187
  *
@@ -203,7 +189,7 @@ function expandHome(p) {
203
189
  * @returns {string} `<vaultDir>/01-projects/_active-sessions.md`
204
190
  */
205
191
  export function resolveBoardPath(vaultDir) {
206
- return path.join(expandHome(vaultDir), '01-projects', '_active-sessions.md');
192
+ return path.join(expandTilde(vaultDir), '01-projects', '_active-sessions.md');
207
193
  }
208
194
 
209
195
  // ── Formatting helpers ───────────────────────────────────────────────────────────
@@ -729,6 +715,10 @@ export const BOARD_EVENT = 'orchestrator.vault.board_written';
729
715
  * @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
730
716
  * returned, on the {@link sweepBoard} path only.
731
717
  * @param {number} [opts.durationMs]
718
+ * @param {{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }} [opts.lock]
719
+ * — board-lock outcome, present only when the lock was actually attempted
720
+ * (i.e. not on the early no-op guards, not on dry-run). Never carries the lock
721
+ * PATH — that is a `$HOME`-rooted string, the CP1 shape this payload keeps out.
732
722
  * @returns {Promise<void>}
733
723
  */
734
724
  /**
@@ -765,7 +755,7 @@ function telemetrySafePath(outputPath) {
765
755
  return base.length > 0 ? base : undefined;
766
756
  }
767
757
 
768
- async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs }) {
758
+ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs, lock }) {
769
759
  // Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
770
760
  // Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
771
761
  // and writes into whatever tree the ambient env points at — so `mirrorBoard()`
@@ -798,6 +788,12 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
798
788
  ...(Number.isFinite(rows) ? { rows } : {}),
799
789
  ...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
800
790
  ...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
791
+ // Lock diagnostics (absent on every path that never took the lock: the
792
+ // early no-op guards and dry-run). `lock.locked === false` marks a
793
+ // fail-open unlocked write; `lock.stale_override` marks an acquire that
794
+ // aged out someone else's lock — the observable behind board-lock's
795
+ // DEFAULT_STALE_MS revisit trigger.
796
+ ...(lock && typeof lock === 'object' ? { lock } : {}),
801
797
  // #1147: join key parity with the sibling `narrative_mirrored` event,
802
798
  // which has carried attribution since #1073. Without it a board record
803
799
  // cannot be joined to the session that wrote it. Both keys are OMITTED
@@ -851,7 +847,10 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
851
847
  * `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
852
848
  * and bleeds into the assertion (issue #783). Production callers omit this — the
853
849
  * default (real owner.yaml resolution) is the correct host-local behavior there.
854
- * @returns {Promise<{ result: { action: string, path?: string }, rows?: number }>}
850
+ * @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
851
+ * lock?: { locked: boolean, reason?: string, stale_override?: string, waited_ms: number } }>}
852
+ * `lock` is present only on the locked path (absent on the early no-op guards
853
+ * and on dry-run, which deliberately takes no lock).
855
854
  * `rows` is present only once the render was reached — see
856
855
  * {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
857
856
  * wrapper unwraps `result` so the caller-visible return shape is unchanged.
@@ -880,7 +879,7 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
880
879
  }
881
880
 
882
881
  // Safety: the resolved vault dir must live under $HOME.
883
- const expandedVault = expandHome(vaultDir);
882
+ const expandedVault = expandTilde(vaultDir);
884
883
  const home = os.homedir();
885
884
  const inHome = validatePathInsideProject(expandedVault, home);
886
885
  if (!inHome.ok) {
@@ -934,132 +933,171 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
934
933
 
935
934
  const outputPath = resolveBoardPath(vaultDir);
936
935
 
937
- // Read the EXISTING generator-owned board (if any) to:
938
- // 1. preserve its `created:` otherwise every render differs on `created:`
939
- // and the noop-skip in writeBoard would never fire.
940
- // 2. recover the prior per-repo status drives the `closed` derivation for
941
- // repos NOT in this update (idempotent merge: their rows are re-derived).
942
- // Both maps are keyed by {@link foldKey}(repo) case-insensitively folded
943
- // (issue #719) so two prior rows differing only by case (e.g.
944
- // `some-repo` vs `Some-Repo`, the same physical directory on a
945
- // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
946
- // instead of coexisting as duplicates. The row OBJECTS keep their original
947
- // `repo` string untouched, so `renderBoard` still displays true casing.
948
- const fsReadFile = fs?.readFileSync ?? readFileSync;
949
- const fsExists = fs?.existsSync ?? existsSync;
950
- let createdIso;
951
- const priorStatusByRepo = new Map(); // LEGACY rows only see collectRows contract
952
- const priorStatusByKey = new Map(); // boardKey status (authoritative since #871)
953
- const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) prior row
954
- if (fsExists(outputPath)) {
955
- let existing;
956
- try {
957
- existing = fsReadFile(outputPath, 'utf8');
958
- } catch {
959
- existing = null;
960
- }
961
- if (existing) {
962
- const fm = parseFrontmatter(existing);
963
- if (fm && fm['_generator'] === GENERATOR_MARKER) {
964
- if (fm['created']) createdIso = fm['created'];
965
- for (const prior of parseBoardRows(existing)) {
966
- // Dual-key slotting (#871): a keyed row owns its own hash slot; a
967
- // legacy (6-column) row falls back to its folded display name. Two
968
- // keyed rows can only collide when they resolve to the SAME path, so
969
- // the heartbeat-preference resolution below is now reached almost
970
- // exclusively by legacy rows which is precisely the case it was
971
- // written for (#719).
972
- const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
973
- const collidingPrior = preservedRows.get(key);
974
- if (collidingPrior) {
975
- // Collision WITHIN parseBoardRows output two prior rows fold to
976
- // the same key with no fresh row in play yet (that upsert happens
977
- // below). Prefer the row with the most-recent `heartbeat` rather
978
- // than silently last-in-file-order. Guard: if either heartbeat is
979
- // unparsable, fall through to last-written-wins (the pre-#719
980
- // default) by NOT skipping the overwrite below.
981
- const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
982
- const priorTs = Date.parse(prior.heartbeat ?? '');
983
- if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
984
- // The already-preserved row is strictly newer keep it, skip
985
- // this older colliding row entirely.
986
- continue;
936
+ // Everything below — the two reads of the existing board, the merge, and the
937
+ // write is ONE read-modify-write over a file shared by every repo on the
938
+ // host (issue #1180). Serialise it on the vault-scoped board lock so a
939
+ // concurrent sweepBoard() from another repo cannot compute its merge from a
940
+ // base we are about to replace. Fail-open: withBoardLock runs the closure
941
+ // unlocked (with a WARN) rather than let a contended lock abort the phase.
942
+ const mergeAndWrite = async () => {
943
+ // Read the EXISTING generator-owned board (if any) to:
944
+ // 1. preserve its `created:` otherwise every render differs on `created:`
945
+ // and the noop-skip in writeBoard would never fire.
946
+ // 2. recover the prior per-repo status drives the `closed` derivation for
947
+ // repos NOT in this update (idempotent merge: their rows are re-derived).
948
+ // Both maps are keyed by {@link foldKey}(repo) — case-insensitively folded
949
+ // (issue #719) — so two prior rows differing only by case (e.g.
950
+ // `some-repo` vs `Some-Repo`, the same physical directory on a
951
+ // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
952
+ // instead of coexisting as duplicates. The row OBJECTS keep their original
953
+ // `repo` string untouched, so `renderBoard` still displays true casing.
954
+ const fsReadFile = fs?.readFileSync ?? readFileSync;
955
+ const fsExists = fs?.existsSync ?? existsSync;
956
+ let createdIso;
957
+ const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
958
+ const priorStatusByKey = new Map(); // boardKey → status (authoritative since #871)
959
+ const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
960
+ if (fsExists(outputPath)) {
961
+ let existing;
962
+ try {
963
+ existing = fsReadFile(outputPath, 'utf8');
964
+ } catch {
965
+ existing = null;
966
+ }
967
+ if (existing) {
968
+ const fm = parseFrontmatter(existing);
969
+ if (fm && fm['_generator'] === GENERATOR_MARKER) {
970
+ if (fm['created']) createdIso = fm['created'];
971
+ for (const prior of parseBoardRows(existing)) {
972
+ // Dual-key slotting (#871): a keyed row owns its own hash slot; a
973
+ // legacy (6-column) row falls back to its folded display name. Two
974
+ // keyed rows can only collide when they resolve to the SAME path, so
975
+ // the heartbeat-preference resolution below is now reached almost
976
+ // exclusively by legacy rows which is precisely the case it was
977
+ // written for (#719).
978
+ const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
979
+ const collidingPrior = preservedRows.get(key);
980
+ if (collidingPrior) {
981
+ // Collision WITHIN parseBoardRows output — two prior rows fold to
982
+ // the same key with no fresh row in play yet (that upsert happens
983
+ // below). Prefer the row with the most-recent `heartbeat` rather
984
+ // than silently last-in-file-order. Guard: if either heartbeat is
985
+ // unparsable, fall through to last-written-wins (the pre-#719
986
+ // default) by NOT skipping the overwrite below.
987
+ const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
988
+ const priorTs = Date.parse(prior.heartbeat ?? '');
989
+ if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
990
+ // The already-preserved row is strictly newer — keep it, skip
991
+ // this older colliding row entirely.
992
+ continue;
993
+ }
987
994
  }
995
+ if (prior.key) {
996
+ priorStatusByKey.set(prior.key, prior.status);
997
+ } else {
998
+ // LEGACY rows only. Seeding this map from keyed rows too would let
999
+ // repo B (never seen, same basename) inherit repo A's terminal
1000
+ // status through the name fallback in collectRows — reintroducing
1001
+ // the identity collision #871 exists to remove, one layer down.
1002
+ priorStatusByRepo.set(foldKey(prior.repo), prior.status);
1003
+ }
1004
+ preservedRows.set(key, prior);
988
1005
  }
989
- if (prior.key) {
990
- priorStatusByKey.set(prior.key, prior.status);
991
- } else {
992
- // LEGACY rows only. Seeding this map from keyed rows too would let
993
- // repo B (never seen, same basename) inherit repo A's terminal
994
- // status through the name fallback in collectRows — reintroducing
995
- // the identity collision #871 exists to remove, one layer down.
996
- priorStatusByRepo.set(foldKey(prior.repo), prior.status);
997
- }
998
- preservedRows.set(key, prior);
999
1006
  }
1000
1007
  }
1001
1008
  }
1002
- }
1003
1009
 
1004
- const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
1005
-
1006
- // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
1007
- // Without this pass, a preserved `in-progress` row (a repo NOT in this
1008
- // update) is copied forward FOREVER — a crashed/never-closed session's row
1009
- // never flips even after its heartbeat has aged well past the lock's TTL,
1010
- // because `collectRows` only re-derives status for repos actually IN
1011
- // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
1012
- // freshly-derived `rows` are upserted over it below (fresh data always
1013
- // wins regardless of this pass — a live lock or an explicit-closed update
1014
- // always takes precedence over the TTL flip).
1015
- //
1016
- // Board rows carry only a raw `heartbeat` string, never the lock's own
1017
- // `ttl_hours` (that field is not part of the rendered board) — so this
1018
- // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
1019
- // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
1020
- // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
1021
- // never crash on a malformed prior board).
1022
- const nowMs = now instanceof Date ? now.getTime() : Date.now();
1023
- const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
1024
- const staleRederivedRows = new Map();
1025
- for (const [key, row] of preservedRows) {
1026
- if (row.status === STATUS_IN_PROGRESS) {
1027
- const heartbeatMs = Date.parse(row.heartbeat ?? '');
1028
- if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
1029
- staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
1030
- continue;
1010
+ const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
1011
+
1012
+ // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
1013
+ // Without this pass, a preserved `in-progress` row (a repo NOT in this
1014
+ // update) is copied forward FOREVER — a crashed/never-closed session's row
1015
+ // never flips even after its heartbeat has aged well past the lock's TTL,
1016
+ // because `collectRows` only re-derives status for repos actually IN
1017
+ // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
1018
+ // freshly-derived `rows` are upserted over it below (fresh data always
1019
+ // wins regardless of this pass — a live lock or an explicit-closed update
1020
+ // always takes precedence over the TTL flip).
1021
+ //
1022
+ // Board rows carry only a raw `heartbeat` string, never the lock's own
1023
+ // `ttl_hours` (that field is not part of the rendered board) — so this
1024
+ // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
1025
+ // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
1026
+ // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
1027
+ // never crash on a malformed prior board).
1028
+ const nowMs = now instanceof Date ? now.getTime() : Date.now();
1029
+ const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
1030
+ const staleRederivedRows = new Map();
1031
+ for (const [key, row] of preservedRows) {
1032
+ if (row.status === STATUS_IN_PROGRESS) {
1033
+ const heartbeatMs = Date.parse(row.heartbeat ?? '');
1034
+ if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
1035
+ staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
1036
+ continue;
1037
+ }
1031
1038
  }
1039
+ staleRederivedRows.set(key, row);
1032
1040
  }
1033
- staleRederivedRows.set(key, row);
1034
- }
1035
1041
 
1036
- // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
1037
- // update, then upsert the freshly-derived rows over them so repeated writes
1038
- // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
1039
- // same slot — that is what collapses a live row over a stale preserved one.
1040
- //
1041
- // Dual-key upsert (#871). A naive switch from the folded name to the path
1042
- // key would make the two key spaces DISJOINT: the fresh row would never
1043
- // overwrite the legacy row, the legacy row would become immortal (the sweep
1044
- // skips `frei` candidates and the TTL pass only rewrites `status`, never
1045
- // removes a row), and the board would grow a permanent duplicate per repo.
1046
- // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1047
- // the legacy name slot for the same folded name — one board write converts
1048
- // the row, and the migration is complete for that repo.
1049
- const merged = new Map(staleRederivedRows);
1050
- for (const row of rows) {
1051
- const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
1052
- if (row.key && !merged.has(slot)) {
1053
- // First keyed write for this repo — take over its legacy row rather than
1054
- // rendering a second one beside it.
1055
- merged.delete(nameSlot(row.repo));
1042
+ // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
1043
+ // update, then upsert the freshly-derived rows over them so repeated writes
1044
+ // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
1045
+ // same slot — that is what collapses a live row over a stale preserved one.
1046
+ //
1047
+ // Dual-key upsert (#871). A naive switch from the folded name to the path
1048
+ // key would make the two key spaces DISJOINT: the fresh row would never
1049
+ // overwrite the legacy row, the legacy row would become immortal (the sweep
1050
+ // skips `frei` candidates and the TTL pass only rewrites `status`, never
1051
+ // removes a row), and the board would grow a permanent duplicate per repo.
1052
+ // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1053
+ // the legacy name slot for the same folded name — one board write converts
1054
+ // the row, and the migration is complete for that repo.
1055
+ const merged = new Map(staleRederivedRows);
1056
+ for (const row of rows) {
1057
+ const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
1058
+ if (row.key && !merged.has(slot)) {
1059
+ // First keyed write for this repo — take over its legacy row rather than
1060
+ // rendering a second one beside it.
1061
+ merged.delete(nameSlot(row.repo));
1062
+ }
1063
+ merged.set(slot, row);
1056
1064
  }
1057
- merged.set(slot, row);
1058
- }
1059
1065
 
1060
- const content = renderBoard([...merged.values()], { now, createdIso });
1066
+ const content = renderBoard([...merged.values()], { now, createdIso });
1067
+
1068
+ return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1069
+ };
1070
+
1071
+ // dry-run never touches disk (writeBoard guard 1) — so it must not create a
1072
+ // lock file in the operator's vault either. Nothing to serialise.
1073
+ if (dryRun) return await mergeAndWrite();
1074
+
1075
+ // The lock outcome is diagnostic, and until now it went nowhere: `withBoardLock`
1076
+ // has exposed `onLockOutcome` since #1180, and NO production caller passed one
1077
+ // (measured 2026-09-02: `grep -rn onLockOutcome scripts hooks` → board-lock.mjs
1078
+ // and its test, nothing else). So the two states that silently weaken the mutex —
1079
+ // a fail-open unlocked write, and a stale-override that can override a LIVE
1080
+ // writer (see board-lock's DEFAULT_STALE_MS § CEILING) — were unobservable in
1081
+ // aggregate, which is exactly what the revisit trigger needs. Capture it here and
1082
+ // ride it out on the ONE board_written event rather than adding a second event.
1083
+ let lockOutcome;
1084
+ const acquireStartedAt = Date.now();
1085
+ const inner = await withBoardLock(expandedVault, mergeAndWrite, {
1086
+ onLockOutcome: (o) => {
1087
+ // Called exactly once, strictly BEFORE `fn` — so the elapsed time is the
1088
+ // acquire wait, not the merge. `lockPath` is deliberately DROPPED: it is
1089
+ // `<vault>/.orchestrator/board.lock` under $HOME, i.e. the CP1 (OS username)
1090
+ // shape `telemetrySafePath` exists to keep out of the payload.
1091
+ lockOutcome = {
1092
+ locked: o?.locked === true,
1093
+ ...(typeof o?.reason === 'string' ? { reason: o.reason } : {}),
1094
+ ...(typeof o?.staleOverride === 'string' ? { stale_override: o.staleOverride } : {}),
1095
+ waited_ms: Date.now() - acquireStartedAt,
1096
+ };
1097
+ },
1098
+ });
1061
1099
 
1062
- return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1100
+ return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
1063
1101
  }
1064
1102
 
1065
1103
  /**
@@ -1095,7 +1133,7 @@ export async function mirrorBoard(opts = {}) {
1095
1133
  // throws exactly as it did before this wrapper existed.
1096
1134
  const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
1097
1135
 
1098
- const { result, rows } = await mirrorBoardInner(opts);
1136
+ const { result, rows, lock } = await mirrorBoardInner(opts);
1099
1137
 
1100
1138
  await emitBoardEvent({
1101
1139
  repoRoot,
@@ -1105,6 +1143,7 @@ export async function mirrorBoard(opts = {}) {
1105
1143
  rows,
1106
1144
  reposSwept,
1107
1145
  durationMs: Date.now() - startedAt,
1146
+ lock,
1108
1147
  });
1109
1148
 
1110
1149
  return result;
@@ -29,7 +29,6 @@
29
29
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
30
30
  import { readFile } from 'node:fs/promises';
31
31
  import path from 'node:path';
32
- import os from 'node:os';
33
32
 
34
33
  import { emitEvent, sessionAttribution } from '../events.mjs';
35
34
  import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
@@ -43,6 +42,7 @@ import { matchesModuloRedaction } from '../vault-mirror/process.mjs';
43
42
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
44
43
  import { validatePathInsideProject } from '../path-utils.mjs';
45
44
  import { createSecretValueMasker } from '../secret-masker.mjs';
45
+ import { expandTilde } from '../common.mjs';
46
46
 
47
47
  /** Frontmatter sentinel that identifies generator-owned narrative files. */
48
48
  export const GENERATOR_MARKER = 'session-orchestrator-vault-status-narrative@1';
@@ -63,23 +63,6 @@ const SECTION_TITLES = {
63
63
 
64
64
  // ── Raw section extraction ──────────────────────────────────────────────────────
65
65
 
66
- /**
67
- * Expand a leading `~` to the current user's home directory.
68
- *
69
- * NOTE: deferred shared-helper extraction. The same `expandHome` pattern lives in
70
- * other vault-status modules; W2 forbids introducing a shared new file, so this is
71
- * inlined here. Consolidate into a shared util in a follow-up wave.
72
- *
73
- * @param {string} p
74
- * @returns {string}
75
- */
76
- function expandHome(p) {
77
- if (typeof p !== 'string' || p.length === 0) return p;
78
- if (p === '~') return os.homedir();
79
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
80
- return p;
81
- }
82
-
83
66
  /**
84
67
  * Match an ATX markdown heading line. Returns `{ level, text }` or null.
85
68
  *
@@ -795,7 +778,7 @@ async function runNarrativeMirror(opts) {
795
778
  return { result: { action: 'skipped-vault-disabled' } };
796
779
  }
797
780
 
798
- const vaultDir = path.resolve(expandHome(rawVaultDir));
781
+ const vaultDir = path.resolve(expandTilde(rawVaultDir));
799
782
  const candidateSlug = subjectToSlug(repoName) || 'unknown';
800
783
  // Loose-match against existing 01-projects/ folders before minting a new
801
784
  // slug (issue #829 Finding 3) — see resolveLooseSlug for the ambiguity
@@ -165,7 +165,7 @@ const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
165
165
  * @param {unknown} runId
166
166
  * @returns {boolean} true when the id is safe to use as a path segment.
167
167
  */
168
- function isSafeRunId(runId) {
168
+ export function isSafeRunId(runId) {
169
169
  const s = String(runId ?? '');
170
170
  if (s === '.' || s === '..') return false; // admitted by SAFE_RUN_ID, still an escape
171
171
  return SAFE_RUN_ID.test(s);
@@ -330,7 +330,7 @@ export const MEASUREMENT_EXCLUDES = Object.freeze(['node_modules']);
330
330
  * @returns {Promise<{capture: string, exitCode: number|null, signal: string|null,
331
331
  * timerFired: boolean, killSignals: string[], errorCode: string|null}>}
332
332
  */
333
- function runChild(cmd, args, options, { spawnFn, timeoutMs, killGraceMs }) {
333
+ export function runChild(cmd, args, options, { spawnFn, timeoutMs, killGraceMs }) {
334
334
  return new Promise((resolve) => {
335
335
  /** @type {string[]} */
336
336
  const killSignals = [];