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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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
|
-
*
|
|
177
|
-
*
|
|
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
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
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
|
|
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,
|
|
288
|
-
*
|
|
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
|
|
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,
|
|
360
|
-
*
|
|
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 →
|
|
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,
|
|
410
|
-
* }
|
|
411
|
-
*
|
|
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
|
-
|
|
460
|
-
|
|
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
|
|
814
|
+
const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
|
|
815
|
+
const priorStatusByKey = new Map(); // boardKey → status (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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
// key
|
|
670
|
-
//
|
|
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)
|
|
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
|
|
752
|
-
*
|
|
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
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
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
|