session-orchestrator 3.20.0 → 3.22.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 (202) 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/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * Exports:
14
14
  * GENERATOR_MARKER — frontmatter sentinel that identifies generator-owned files
15
+ * boardKey — repoRoot → stable path-derived row identity (issue #871)
15
16
  * resolveBoardPath — vaultDir → `<vaultDir>/01-projects/_active-sessions.md`
16
17
  * collectRows — per-repo status derivation (readLock + readRegistry)
17
18
  * renderBoard — pure render: rows[] → full markdown (frontmatter + table)
@@ -47,6 +48,7 @@ import { parseFrontmatter } from '../vault-mirror/utils.mjs';
47
48
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
48
49
  import { validatePathInsideProject } from '../path-utils.mjs';
49
50
  import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
51
+ import { atomicWriteWithBackup } from '../io.mjs';
50
52
 
51
53
  /** Frontmatter sentinel that identifies generator-owned board files. */
52
54
  export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
@@ -78,6 +80,95 @@ const STATUS_FREI = 'frei';
78
80
  */
79
81
  const foldKey = (s) => String(s ?? '').toLowerCase();
80
82
 
83
+ /**
84
+ * Rendered length of the path-derived board key (issue #871).
85
+ *
86
+ * BV-004 ceiling: 8 hex chars = 4.29e9 slots. At host scale (the reference host
87
+ * enumerates ~45 repos at the depth-2 default) the birthday collision
88
+ * probability is ~2.3e-7 — far below the failure modes this key REPLACES. It is
89
+ * a prefix, not a truncated identity: the full `repoPathHash` still drives
90
+ * registry matching. REVISIT TRIGGER — if a host ever enumerates >10 000 repos
91
+ * (p(collision) ≈ 1.2e-2 there), widen to 12 and accept that legacy 8-char rows
92
+ * migrate on their next sweep exactly like the 6-column rows do today.
93
+ */
94
+ const KEY_LENGTH = 8;
95
+
96
+ /**
97
+ * Whether the host filesystem is case-insensitive-preserving (issue #719).
98
+ *
99
+ * The board key is derived from a PATH, so on APFS/NTFS `…/Some-Repo` and
100
+ * `…/some-repo` are the same physical directory but two different strings — and
101
+ * would hash to two different keys, re-introducing the duplicate rows #719 fixed
102
+ * at the name layer. Folding the path before hashing keeps that guarantee.
103
+ * Deliberately NOT applied to {@link repoPathHash}'s registry-matching use: that
104
+ * hash must stay byte-identical to what `session-registry.mjs` writes.
105
+ */
106
+ const CASE_INSENSITIVE_FS = process.platform === 'darwin' || process.platform === 'win32';
107
+
108
+ /**
109
+ * Derive the stable, path-based board identity for a repo (issue #871).
110
+ *
111
+ * Board rows were keyed by `repoName` (`path.basename(repoRoot)`) until #871.
112
+ * Two repos with the same directory name under different parents — e.g.
113
+ * `<org-a>/<name>` and `<org-b>/<name>`, both enumerable since the depth-2 walk
114
+ * of #832 — folded onto ONE row, and whichever was written second silently
115
+ * overwrote the other's status. A display name is not an identity.
116
+ *
117
+ * @param {string} repoRoot — absolute (or resolvable) repo path.
118
+ * @returns {string|null} `KEY_LENGTH`-char hex prefix, or null for an unusable path.
119
+ */
120
+ export function boardKey(repoRoot) {
121
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
122
+ try {
123
+ const resolved = path.resolve(repoRoot);
124
+ return repoPathHash(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // ── Merge slots (dual-key: path-derived key, legacy display name) ───────────────
131
+
132
+ /**
133
+ * Merge-slot id for a row that carries a path-derived {@link boardKey}.
134
+ * @param {string} key
135
+ * @returns {string}
136
+ */
137
+ const hashSlot = (key) => `h:${key}`;
138
+
139
+ /**
140
+ * Merge-slot id for a LEGACY (key-less, 6-column) row, folded by display name.
141
+ *
142
+ * This is the migration bridge, not a second identity scheme: a legacy row has
143
+ * no path in it — {@link parseBoardRows} cannot recover one — so its only
144
+ * available handle is the rendered repo name. A freshly-derived row ADOPTS the
145
+ * matching legacy slot (see {@link mirrorBoard}), which converts the row to a
146
+ * keyed one in a single board write.
147
+ *
148
+ * PRECONDITION on "no `n:` slot exists" — the end state is reached per repo
149
+ * ONLY once that repo has been re-derived at least once, and nothing in this
150
+ * module forces that. A repo that stays `frei` is skipped by
151
+ * {@link buildSweepRepos}, the TTL pass rewrites only `status`, and no code
152
+ * path removes a row (`merged` starts as a copy of ALL prior rows; the single
153
+ * `.delete()` in {@link mirrorBoard} is the adoption, not pruning). So a repo
154
+ * that leaves one legacy row behind and is then permanently free — or removed
155
+ * from the host — keeps its `n:` slot indefinitely. Pruning is deliberately NOT
156
+ * implemented here: deleting rows for repos this host can no longer see is a
157
+ * policy decision over an operator-owned file, not a merge detail.
158
+ *
159
+ * BV-004 DELETION TRIGGER (do not delete on vibes, measure): drop `nameSlot`,
160
+ * the `cells.length === 6` branch in {@link parseBoardRows}, and the
161
+ * `priorStatusByRepo` fallback in {@link collectRows} once a host-wide sweep
162
+ * reports ZERO key-less rows for 30 consecutive days —
163
+ * `parseBoardRows(readFileSync(resolveBoardPath(vaultDir), 'utf8'))
164
+ * .filter((r) => r.key === null).length === 0`. Until that holds, the branch
165
+ * is load-bearing for exactly the boards it still describes.
166
+ *
167
+ * @param {string} repo
168
+ * @returns {string}
169
+ */
170
+ const nameSlot = (repo) => `n:${foldKey(repo)}`;
171
+
81
172
  // ── Path helpers ────────────────────────────────────────────────────────────────
82
173
 
83
174
  /**
@@ -168,15 +259,29 @@ function cell(value) {
168
259
  * @param {Date} [opts.now] — clock seam (defaults to new Date()).
169
260
  * @param {Array<object>} [opts.registry] — pre-read registry (test seam); defaults
170
261
  * to a fresh {@link readRegistry} call.
262
+ * @param {Map<string, string>} [opts.priorStatusByKey] — {@link boardKey} → prior
263
+ * board status. The PRIMARY prior-status lookup since #871; consulted before
264
+ * the name-based map below.
171
265
  * @param {Map<string, string>} [opts.priorStatusByRepo] — {@link foldKey}-folded
172
266
  * (case-insensitive) repoName → prior board status, used to derive `closed`
173
267
  * when a once-active repo now has no lock. Callers MUST fold the key with
174
268
  * {@link foldKey} before inserting (issue #719) — this function folds its
175
- * own lookup key to match.
176
- * @returns {Promise<Array<{ repo: string, status: string, session: string|null,
177
- * branch: string|null, mode: string|null, heartbeat: string|null }>>}
269
+ * own lookup key to match. Since #871 this map is the LEGACY fallback and
270
+ * callers MUST populate it from key-less (6-column) prior rows ONLY: two
271
+ * different repos sharing a basename would otherwise bleed one's terminal
272
+ * status onto the other, which is the very collision #871 fixes.
273
+ * Consulted only when the folded name has exactly ONE claimant in `repos`
274
+ * (#1022) — see the ambiguity gate in the body. LIMIT, stated rather than
275
+ * implied: the census sees only THIS batch, so a lone claimant can still
276
+ * inherit a legacy row that in truth belonged to a same-named sibling absent
277
+ * from the batch. That residual is unfixable from a key-less row (it carries
278
+ * no path) and is bounded to a single migration hop — the row becomes keyed
279
+ * on that write, and from then on only the authoritative key map applies.
280
+ * @returns {Promise<Array<{ repo: string, key: string|null, status: string,
281
+ * session: string|null, branch: string|null, mode: string|null,
282
+ * heartbeat: string|null }>>}
178
283
  */
179
- export async function collectRows({ repos, now = new Date(), registry, priorStatusByRepo } = {}) {
284
+ export async function collectRows({ repos, now = new Date(), registry, priorStatusByRepo, priorStatusByKey } = {}) {
180
285
  if (!Array.isArray(repos)) {
181
286
  throw new TypeError('collectRows: opts.repos must be an array');
182
287
  }
@@ -184,20 +289,51 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
184
289
  const nowMs = now instanceof Date ? now.getTime() : Date.now();
185
290
  const registryEntries = Array.isArray(registry) ? registry : await readRegistry();
186
291
  const priorStatus = priorStatusByRepo instanceof Map ? priorStatusByRepo : new Map();
292
+ const priorStatusKeyed = priorStatusByKey instanceof Map ? priorStatusByKey : new Map();
187
293
 
188
294
  const rows = [];
189
295
 
296
+ // Normalise the descriptors ONCE. The display name + path key computed here
297
+ // are the same values the derivation loop below uses, so the ambiguity census
298
+ // cannot drift from the lookup it gates (a second, independent derivation of
299
+ // `repoName` would silently mis-census).
300
+ const descriptors = [];
190
301
  for (const repo of repos) {
191
302
  if (!repo || typeof repo.repoRoot !== 'string' || repo.repoRoot.length === 0) {
192
303
  // Skip malformed repo descriptors rather than throwing — one bad entry
193
304
  // must not abort the whole board render.
194
305
  continue;
195
306
  }
196
-
197
307
  const repoName = typeof repo.repoName === 'string' && repo.repoName.length > 0
198
308
  ? repo.repoName
199
309
  : path.basename(path.resolve(repo.repoRoot));
310
+ // Path-derived identity (#871). Independent of `repoName`, so two repos
311
+ // sharing a basename get two distinct rows instead of overwriting each other.
312
+ descriptors.push({ repo, repoName, key: boardKey(repo.repoRoot) });
313
+ }
200
314
 
315
+ // Ambiguity census for the LEGACY name fallback (#1022).
316
+ //
317
+ // A key-less prior row carries no path — {@link parseBoardRows} cannot
318
+ // recover one — so its only handle is a display name. When TWO distinct repos
319
+ // in this batch answer to that name, the row provably describes at most one
320
+ // of them and there is no evidence which. Counting the claimants here lets
321
+ // the derivation below withhold the fallback from all of them instead of
322
+ // stamping the terminal status onto every claimant.
323
+ const claimantsByName = new Map();
324
+ for (const d of descriptors) {
325
+ const folded = foldKey(d.repoName);
326
+ let claimants = claimantsByName.get(folded);
327
+ if (!claimants) {
328
+ claimants = new Set();
329
+ claimantsByName.set(folded, claimants);
330
+ }
331
+ // Identity, not object: the same repo listed twice is ONE claimant. Fall
332
+ // back to the raw path when `boardKey` could not derive one.
333
+ claimants.add(d.key ?? `path:${d.repo.repoRoot}`);
334
+ }
335
+
336
+ for (const { repo, repoName, key } of descriptors) {
201
337
  const lock = readLock({ repoRoot: repo.repoRoot });
202
338
 
203
339
  // Match the registry entry for this repo by path hash (branch lives here only).
@@ -241,9 +377,33 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
241
377
  status = STATUS_FORCE_CLOSED;
242
378
  } else {
243
379
  // No live lock. Derive status from the prior board state + registry freshness.
244
- // Key is folded (issue #719) so `Some-Repo` and `some-repo` resolve
245
- // to the same prior-status entry on case-insensitive-preserving filesystems.
246
- const prior = priorStatus.get(foldKey(repoName));
380
+ //
381
+ // Dual lookup (#871): the path-derived key is authoritative; the folded
382
+ // NAME map is only the legacy bridge for prior rows written before the
383
+ // 7-column format (they carry no key, so the name is all there is). The
384
+ // name map holds legacy rows ONLY — see the caller contract on
385
+ // `priorStatusByRepo` — otherwise a same-basename sibling repo would
386
+ // inherit this repo's terminal status. Folded (issue #719) so `Some-Repo`
387
+ // and `some-repo` still resolve to the same legacy entry.
388
+ //
389
+ // AMBIGUITY GATE (#1022): the legacy fallback is withheld entirely when
390
+ // more than one repo in this batch claims the name. On the MIGRATION run
391
+ // — every operator's board is still 6-column, so NO repo has a keyed
392
+ // prior yet — both same-basename repos would otherwise inherit the one
393
+ // legacy row's terminal status, each writing it under its own key. From
394
+ // the next run on that wrong status is keyed and therefore STICKY (a
395
+ // terminal status is never reset without a live lock), so a repo that
396
+ // never had a session would stand `closed` on the board permanently.
397
+ // Awarding the row to the FIRST claimant instead is not a repair: the
398
+ // batch order comes from `enumerateCandidates`' unsorted `readdirSync`
399
+ // walk, which would make a permanent status depend on directory-entry
400
+ // order — and it would still be a coin flip between two repos, one of
401
+ // which never owned that status. Withholding costs at most one migration
402
+ // hop: each claimant is re-derived from its own lock/registry this run
403
+ // and is keyed from here on.
404
+ const nameIsAmbiguous = (claimantsByName.get(foldKey(repoName))?.size ?? 0) > 1;
405
+ const prior = (key !== null ? priorStatusKeyed.get(key) : undefined)
406
+ ?? (nameIsAmbiguous ? undefined : priorStatus.get(foldKey(repoName)));
247
407
  if (prior === STATUS_CLOSED || prior === STATUS_FORCE_CLOSED) {
248
408
  // Terminal prior state is STICKY absent a live lock. A still-fresh registry
249
409
  // entry must NOT resurrect a cleanly-closed (or force-closed) repo to
@@ -266,6 +426,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
266
426
 
267
427
  rows.push({
268
428
  repo: repoName,
429
+ key,
269
430
  status,
270
431
  session: status === STATUS_FREI ? null : session,
271
432
  branch: status === STATUS_FREI ? null : branch,
@@ -282,10 +443,19 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
282
443
  /**
283
444
  * Render the board markdown from the rows array.
284
445
  *
285
- * Rows are sorted alphabetically by repo name for stable, diff-friendly output.
446
+ * Rows are sorted alphabetically by repo name, then by {@link boardKey}, for
447
+ * stable diff-friendly output. The key tiebreak is load-bearing since #871: two
448
+ * repos sharing a basename now render as two ADJACENT rows, and without it their
449
+ * relative order would depend on Map insertion order (i.e. on enumeration order),
450
+ * making the file churn between otherwise-identical writes.
451
+ *
452
+ * Column 7 (`Key`) carries the path-derived identity so the next
453
+ * {@link parseBoardRows} can recover it — the six original columns contain no
454
+ * path, which is exactly why the pre-#871 board could not be re-keyed in place.
286
455
  *
287
- * @param {Array<{ repo: string, status: string, session?: string|null,
288
- * branch?: string|null, mode?: string|null, heartbeat?: string|null }>} rows
456
+ * @param {Array<{ repo: string, key?: string|null, status: string,
457
+ * session?: string|null, branch?: string|null, mode?: string|null,
458
+ * heartbeat?: string|null }>} rows
289
459
  * @param {{ now: Date, createdIso?: string, updatedPlaceholder?: string }} opts
290
460
  * @returns {string} full markdown (frontmatter + table)
291
461
  */
@@ -297,9 +467,11 @@ export function renderBoard(rows, opts = {}) {
297
467
  const updatedValue = updatedPlaceholder ?? nowIso;
298
468
  const createdValue = createdIso ?? nowIso;
299
469
 
300
- const sortedRows = [...(Array.isArray(rows) ? rows : [])].sort((a, b) =>
301
- String(a?.repo ?? '').localeCompare(String(b?.repo ?? '')),
302
- );
470
+ const sortedRows = [...(Array.isArray(rows) ? rows : [])].sort((a, b) => {
471
+ const byRepo = String(a?.repo ?? '').localeCompare(String(b?.repo ?? ''));
472
+ if (byRepo !== 0) return byRepo;
473
+ return String(a?.key ?? '').localeCompare(String(b?.key ?? ''));
474
+ });
303
475
 
304
476
  const lines = [];
305
477
 
@@ -320,12 +492,13 @@ export function renderBoard(rows, opts = {}) {
320
492
  lines.push('');
321
493
 
322
494
  // Board table
323
- lines.push('| Repo | Status | Session | Branch | Mode | Last heartbeat |');
324
- lines.push('|---|---|---|---|---|---|');
495
+ lines.push('| Repo | Status | Session | Branch | Mode | Last heartbeat | Key |');
496
+ lines.push('|---|---|---|---|---|---|---|');
325
497
  for (const row of sortedRows) {
326
498
  lines.push(
327
499
  `| ${cell(row?.repo)} | ${cell(row?.status)} | ${cell(row?.session)} | ` +
328
- `${cell(row?.branch)} | ${cell(row?.mode)} | ${cell(fmtHeartbeat(row?.heartbeat))} |`,
500
+ `${cell(row?.branch)} | ${cell(row?.mode)} | ${cell(fmtHeartbeat(row?.heartbeat))} | ` +
501
+ `${cell(row?.key)} |`,
329
502
  );
330
503
  }
331
504
  lines.push('');
@@ -352,12 +525,19 @@ export function normalizeUpdated(content) {
352
525
  * (status carry-over + row preservation for repos not in the current update).
353
526
  *
354
527
  * Tolerant by design: skips the header + separator rows, ignores any line that
355
- * is not a 6-column table row, and maps the literal '—' placeholder back to
356
- * null. Unescapes the `\|` pipe-escaping applied by {@link renderBoard}.
528
+ * is not a 6- or 7-column table row, and maps the literal '—' placeholder back
529
+ * to null. Unescapes the `\|` pipe-escaping applied by {@link renderBoard}.
530
+ *
531
+ * SIX **or** seven columns (#871): the pre-#871 board rendered 6. A hard
532
+ * `length !== 7` filter would silently DROP every row on an operator's existing
533
+ * board on the first run after upgrade — the board would appear to reset. A
534
+ * 6-column row parses as a LEGACY row with `key: null`; {@link mirrorBoard}
535
+ * adopts it into a keyed row the next time that repo is actually derived.
357
536
  *
358
537
  * @param {string} content — full board markdown
359
- * @returns {Array<{ repo: string, status: string, session: string|null,
360
- * branch: string|null, mode: string|null, heartbeat: string|null }>}
538
+ * @returns {Array<{ repo: string, key: string|null, status: string,
539
+ * session: string|null, branch: string|null, mode: string|null,
540
+ * heartbeat: string|null }>}
361
541
  */
362
542
  export function parseBoardRows(content) {
363
543
  const rows = [];
@@ -370,7 +550,7 @@ export function parseBoardRows(content) {
370
550
  .split(/(?<!\\)\|/)
371
551
  .slice(1, -1)
372
552
  .map((c) => c.trim());
373
- if (cells.length !== 6) continue;
553
+ if (cells.length !== 6 && cells.length !== 7) continue;
374
554
  // Skip the header row and the |---|---| separator row.
375
555
  if (cells[0] === 'Repo' || /^-+$/.test(cells[0])) continue;
376
556
  const unesc = (v) => (v === '—' ? null : v.replace(/\\\|/g, '|'));
@@ -378,6 +558,7 @@ export function parseBoardRows(content) {
378
558
  if (repo === null) continue;
379
559
  rows.push({
380
560
  repo,
561
+ key: cells.length === 7 ? unesc(cells[6]) : null,
381
562
  status: cells[1],
382
563
  session: unesc(cells[2]),
383
564
  branch: unesc(cells[3]),
@@ -400,15 +581,24 @@ export function parseBoardRows(content) {
400
581
  * - !fm || !fm._generator → skipped-handwritten
401
582
  * - fm._generator !== GENERATOR_MARKER → skipped-handwritten
402
583
  * - normalizeUpdated(existing) === new → skipped-noop
403
- * 4. else → mkdirSync(recursive) + writeFileSync → written.
584
+ * 4. else → {@link atomicWriteWithBackup} (mkdir -p + tmp + rename) → written.
585
+ * A failed write returns `skipped-write-failed` rather than throwing: a
586
+ * board update is best-effort telemetry and must never abort a session
587
+ * phase (mirrors {@link sweepBoard}'s degrade-don't-throw contract).
404
588
  *
405
589
  * @param {{
406
590
  * outputPath: string,
407
591
  * content: string,
408
592
  * dryRun?: boolean,
409
- * fs?: { readFileSync?: Function, writeFileSync?: Function, mkdirSync?: Function, existsSync?: Function },
410
- * }} opts
411
- * @returns {{ action: 'written'|'skipped-handwritten'|'skipped-noop'|'dry-run', path: string }}
593
+ * fs?: { readFileSync?: Function, writeFileSync?: Function, mkdirSync?: Function,
594
+ * existsSync?: Function, renameSync?: Function, copyFileSync?: Function },
595
+ * }} opts an injected `fs` MUST provide `renameSync` alongside `writeFileSync`
596
+ * since #734c; a stub that mocks the write but not the rename would otherwise
597
+ * have the real `renameSync` look for a tmp file the stub never created.
598
+ * `copyFileSync` is pass-through only (forwarded to {@link atomicWriteWithBackup},
599
+ * never called here) and stays optional while this call site pins `backup: false`.
600
+ * @returns {{ action: 'written'|'skipped-handwritten'|'skipped-noop'|'dry-run'
601
+ * |'skipped-write-failed', path: string, error?: string }}
412
602
  */
413
603
  export function writeBoard(opts) {
414
604
  const { outputPath, content, dryRun = false, fs: injectedFs } = opts;
@@ -455,9 +645,39 @@ export function writeBoard(opts) {
455
645
  }
456
646
  }
457
647
 
458
- // 4. Write.
459
- fsMkdir(path.dirname(outputPath), { recursive: true });
460
- fsWriteFile(outputPath, content, 'utf8');
648
+ // 4. Write — atomically (issue #734c).
649
+ //
650
+ // The board is a file the operator reads WHILE sessions write it. A plain
651
+ // writeFileSync truncates in place, so a crash (or a reader arriving between
652
+ // truncate and flush) can surface a half-board. tmp+rename removes that
653
+ // window; the tmp file is a sibling, so the rename stays same-filesystem.
654
+ //
655
+ // `backup: false` on purpose: the board is 100% re-derivable from the per-repo
656
+ // locks plus the host registry (that is what `sweepBoard` does on every
657
+ // session-start), so a `.bak-<ISO>` sidecar would buy nothing — and it would
658
+ // drop generator litter into the operator's `01-projects/` vault directory,
659
+ // which is precisely the surface the `_overview.md` refusal above protects.
660
+ //
661
+ // `copyFileSync` is forwarded even though `backup: false` makes it inert
662
+ // today: {@link atomicWriteWithBackup} falls back to the REAL `node:fs` per
663
+ // MISSING method, so a 4-of-5 adapter would silently route the backup copy
664
+ // to the real filesystem the moment anyone flips `backup` — a test that
665
+ // believes itself hermetic would drop `.bak-<ISO>` files into the repo.
666
+ // Forwarding the fifth method keeps the adapter total over io.mjs's
667
+ // injectable surface.
668
+ const result = atomicWriteWithBackup(outputPath, content, {
669
+ tmpPrefix: '.active-sessions',
670
+ fs: {
671
+ mkdirSync: fsMkdir,
672
+ writeFileSync: fsWriteFile,
673
+ existsSync: fsExists,
674
+ renameSync: injectedFs?.renameSync,
675
+ copyFileSync: injectedFs?.copyFileSync,
676
+ },
677
+ });
678
+ if (!result.ok) {
679
+ return { action: 'skipped-write-failed', path: outputPath, error: result.error };
680
+ }
461
681
  return { action: 'written', path: outputPath };
462
682
  }
463
683
 
@@ -591,8 +811,9 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
591
811
  const fsReadFile = fs?.readFileSync ?? readFileSync;
592
812
  const fsExists = fs?.existsSync ?? existsSync;
593
813
  let createdIso;
594
- const priorStatusByRepo = new Map();
595
- const preservedRows = new Map(); // foldKey(repoName)prior row (for merge)
814
+ const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
815
+ const priorStatusByKey = new Map(); // boardKeystatus (authoritative since #871)
816
+ const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
596
817
  if (fsExists(outputPath)) {
597
818
  let existing;
598
819
  try {
@@ -605,7 +826,13 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
605
826
  if (fm && fm['_generator'] === GENERATOR_MARKER) {
606
827
  if (fm['created']) createdIso = fm['created'];
607
828
  for (const prior of parseBoardRows(existing)) {
608
- const key = foldKey(prior.repo);
829
+ // Dual-key slotting (#871): a keyed row owns its own hash slot; a
830
+ // legacy (6-column) row falls back to its folded display name. Two
831
+ // keyed rows can only collide when they resolve to the SAME path, so
832
+ // the heartbeat-preference resolution below is now reached almost
833
+ // exclusively by legacy rows — which is precisely the case it was
834
+ // written for (#719).
835
+ const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
609
836
  const collidingPrior = preservedRows.get(key);
610
837
  if (collidingPrior) {
611
838
  // Collision WITHIN parseBoardRows output — two prior rows fold to
@@ -622,14 +849,22 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
622
849
  continue;
623
850
  }
624
851
  }
625
- priorStatusByRepo.set(key, prior.status);
852
+ if (prior.key) {
853
+ priorStatusByKey.set(prior.key, prior.status);
854
+ } else {
855
+ // LEGACY rows only. Seeding this map from keyed rows too would let
856
+ // repo B (never seen, same basename) inherit repo A's terminal
857
+ // status through the name fallback in collectRows — reintroducing
858
+ // the identity collision #871 exists to remove, one layer down.
859
+ priorStatusByRepo.set(foldKey(prior.repo), prior.status);
860
+ }
626
861
  preservedRows.set(key, prior);
627
862
  }
628
863
  }
629
864
  }
630
865
  }
631
866
 
632
- const rows = await collectRows({ repos: repoList, now, priorStatusByRepo });
867
+ const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
633
868
 
634
869
  // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
635
870
  // Without this pass, a preserved `in-progress` row (a repo NOT in this
@@ -662,14 +897,28 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
662
897
  }
663
898
 
664
899
  // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
665
- // update, then upsert the freshly-derived rows over them so repeated
666
- // writes stay stable. Both the seed (`staleRederivedRows`) and the upsert
667
- // key below are folded via {@link foldKey} (issue #719) a
668
- // freshly-derived row ALWAYS wins over a preserved row sharing its folded
669
- // key, which is what collapses a live `Some-Repo` row over a stale
670
- // preserved `some-repo` row on the next board write.
900
+ // update, then upsert the freshly-derived rows over them so repeated writes
901
+ // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
902
+ // same slot that is what collapses a live row over a stale preserved one.
903
+ //
904
+ // Dual-key upsert (#871). A naive switch from the folded name to the path
905
+ // key would make the two key spaces DISJOINT: the fresh row would never
906
+ // overwrite the legacy row, the legacy row would become immortal (the sweep
907
+ // skips `frei` candidates and the TTL pass only rewrites `status`, never
908
+ // removes a row), and the board would grow a permanent duplicate per repo.
909
+ // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
910
+ // the legacy name slot for the same folded name — one board write converts
911
+ // the row, and the migration is complete for that repo.
671
912
  const merged = new Map(staleRederivedRows);
672
- for (const row of rows) merged.set(foldKey(row.repo), row);
913
+ for (const row of rows) {
914
+ const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
915
+ if (row.key && !merged.has(slot)) {
916
+ // First keyed write for this repo — take over its legacy row rather than
917
+ // rendering a second one beside it.
918
+ merged.delete(nameSlot(row.repo));
919
+ }
920
+ merged.set(slot, row);
921
+ }
673
922
 
674
923
  const content = renderBoard([...merged.values()], { now, createdIso });
675
924
 
@@ -748,8 +997,9 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
748
997
  * timeout is applied: a sync call cannot be preempted in-process, so a
749
998
  * timeout would only convert a slow sweep into a thrown error, not a
750
999
  * faster one.
751
- * (d) Merge key is `repoName` (`path.basename`), case-insensitively folded via
752
- * {@link foldKey} (issue #719) two rows differing only by case (e.g.
1000
+ * (d) Merge key is the path-derived {@link boardKey} (issue #871), with a
1001
+ * one-shot fallback to the case-insensitively folded `repoName` for LEGACY
1002
+ * 6-column rows (issue #719) — two rows differing only by case (e.g.
753
1003
  * `some-repo` vs `Some-Repo`, the same physical directory on a
754
1004
  * case-insensitive-preserving filesystem like APFS) now collapse to ONE
755
1005
  * board row instead of rendering as duplicates. The survivor is whichever
@@ -757,19 +1007,13 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
757
1007
  * `collectRows` output) always wins over a preserved stale row; among two
758
1008
  * PRESERVED rows colliding on the folded key, the more-recent `heartbeat`
759
1009
  * wins (see the collision-resolution loop inside {@link mirrorBoard}).
760
- * Two GENUINELY different, differently-rooted repos that happen to share
761
- * a basename (case-insensitively) still collapse to one row — that
762
- * remains a known limitation, inherited from {@link collectRows}/
763
- * {@link mirrorBoard}; not addressed here.
764
- *
765
- * This limitation got materially WORSE with the depth-2 walk (#832):
766
- * under the old depth-1 scan, `<org-a>/<name>` and `<org-b>/<name>` were
767
- * both un-enumerable, so they could not collide. Both are now enumerated
768
- * and fold to a single row. Two such basename collisions were measured on
769
- * the reference host immediately after the change (same repo name under
770
- * two different org directories). Fixing this requires re-keying rows on
771
- * something path-derived rather than `path.basename` — deliberately out
772
- * of scope for #832 and tracked as a follow-up.
1010
+ * Two GENUINELY different, differently-rooted repos sharing a basename
1011
+ * no longer collapse (#871, the follow-up #832 named): the merge key is
1012
+ * the path-derived {@link boardKey}, so `<org-a>/<name>` and
1013
+ * `<org-b>/<name>` render as two rows. The depth-2 walk (#832) is what
1014
+ * made both enumerable and turned the old basename key into silent
1015
+ * cross-repo status loss two such collisions were measured on the
1016
+ * reference host immediately after that change.
773
1017
  *
774
1018
  * Best-effort contract: `sweepBoard` itself never throws for an enumeration
775
1019
  * failure — `enumerateCandidates` is wrapped in try/catch; on ANY failure the