session-orchestrator 5.5.0 → 5.7.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 (51) 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-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +46 -0
  6. package/README.md +27 -13
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/scope-collision-guard.md +5 -3
  10. package/docs/session-config-reference.md +1 -1
  11. package/docs/session-config-template.md +1 -1
  12. package/hooks/hooks-codex.json +1 -1
  13. package/hooks/hooks.json +1 -1
  14. package/hooks/on-stop.mjs +8 -2
  15. package/hooks/post-bash-write-verify.mjs +121 -7
  16. package/hooks/post-tool-batch-wave-signal.mjs +11 -4
  17. package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
  18. package/hooks/pre-task-scope-disjoint.mjs +20 -35
  19. package/hooks/wave-scope-commit-guard.mjs +117 -20
  20. package/package.json +8 -3
  21. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  22. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  23. package/scripts/lib/dispatcher/rank.mjs +5 -1
  24. package/scripts/lib/events.mjs +62 -49
  25. package/scripts/lib/io.mjs +9 -4
  26. package/scripts/lib/learnings/candidates.mjs +7 -16
  27. package/scripts/lib/memory-proposals/store.mjs +9 -2
  28. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  29. package/scripts/lib/scope-gate.mjs +8 -1
  30. package/scripts/lib/session-close-backfill.mjs +104 -13
  31. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  32. package/scripts/lib/vault-mirror/process.mjs +388 -56
  33. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  34. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  35. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  36. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  37. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  38. package/scripts/print-applicable-rules.mjs +1 -25
  39. package/scripts/release.mjs +4 -0
  40. package/scripts/validate-wave-scope.mjs +39 -4
  41. package/scripts/vault-mirror-prune.mjs +145 -0
  42. package/scripts/vault-mirror.mjs +77 -12
  43. package/skills/_shared/parallel-aware-auq.md +1 -1
  44. package/skills/claude-md-drift-check/checker.mjs +6 -16
  45. package/skills/vault-mirror/SKILL.md +27 -2
  46. package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
  47. package/hooks/agent-teams-h3-test.sh +0 -362
  48. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  49. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  50. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  51. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -267,6 +267,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
267
267
  /** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
268
268
  /** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
269
269
  let findScopeCollisions;
270
+ /** @type {typeof import('../scripts/lib/scope-gate.mjs').scopeEntrySpelling} */ let scopeEntrySpelling;
270
271
  let findOwnScopeFile;
271
272
  let readProcessLocalSessionIds;
272
273
  let classifyManifestSession;
@@ -581,7 +582,7 @@ async function bootstrap() {
581
582
  );
582
583
 
583
584
  ({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
584
- ({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
585
+ ({ findScopeCollisions, findOwnScopeFile, scopeEntrySpelling } = modules.scopeGate);
585
586
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
586
587
  ({ withFileLock } = modules.fileLock);
587
588
  ({ scopeDigest } = modules.scopeEcho);
@@ -739,36 +740,6 @@ function cleanScopeLine(line) {
739
740
  .trim();
740
741
  }
741
742
 
742
- /**
743
- * Canonicalise a scope entry's SPELLING so two agents writing the same file two
744
- * ways are not read as disjoint (review LOW). Measured before this existed:
745
- * `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
746
- * — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
747
- * so both spellings reach the comparison verbatim.
748
- *
749
- * Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
750
- * duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
751
- * the directory-prefix operator of `pathMatchesPattern`, so stripping it would
752
- * silently narrow a scope. The `dir` ↔ `dir/` case is handled by
753
- * {@link promoteDirEntries}, which decides it on evidence rather than guessing.
754
- *
755
- * `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
756
- * so the normalisation lives on THIS side of the call, applied to both sides of
757
- * every comparison.
758
- *
759
- * @param {string} entry
760
- * @returns {string}
761
- */
762
- export function normalizeScopeEntry(entry) {
763
- if (typeof entry !== 'string') return '';
764
- let s = entry.trim();
765
- if (s === '') return '';
766
- s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
767
- s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
768
- while (s.startsWith('./')) s = s.slice(2);
769
- return s;
770
- }
771
-
772
743
  /**
773
744
  * Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
774
745
  * evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
@@ -815,7 +786,11 @@ function collectScopePaths(segments) {
815
786
  for (const raw of segments) {
816
787
  // A trailing sentence period is punctuation, never part of a path
817
788
  // (measured: "docs/events-schema.md." at the end of an inline declaration).
818
- const cleaned = normalizeScopeEntry(cleanScopeLine(raw).replace(/\.$/, ''));
789
+ // Lexical cleaning only (prose → path); the SPELLING rule is
790
+ // `scopeEntrySpelling` in scope-gate.mjs, applied in `decide()` (#1510 g).
791
+ // The second trim: a period stripped after `cleanScopeLine`'s trim can
792
+ // expose a space (`foo.mjs .`), which `looksLikeRepoPath` would reject.
793
+ const cleaned = cleanScopeLine(raw).replace(/\.$/, '').trim();
819
794
  if (!looksLikeRepoPath(cleaned)) continue;
820
795
  if (seen.has(cleaned)) continue;
821
796
  seen.add(cleaned);
@@ -906,7 +881,8 @@ export function extractScopeSignal(prompt) {
906
881
  * (and the tests that pin them) keep the original signature.
907
882
  *
908
883
  * @param {string} prompt
909
- * @returns {string[]} repo-relative paths/globs, normalised, deduped, order preserved
884
+ * @returns {string[]} repo-relative paths/globs as declared (lexically cleaned,
885
+ * deduped, order preserved) — the SPELLING rule runs in `decide()` (#1510 g)
910
886
  */
911
887
  export function extractScopeFromPrompt(prompt) {
912
888
  return extractScopeSignal(prompt).files;
@@ -2070,9 +2046,11 @@ export function scopeDigestFields(files, prompt, digestFn) {
2070
2046
  * @param {string} [params.nowIso] dispatch timestamp recorded on the entry
2071
2047
  * @param {(paths: string[]) => string} [params.digestFn] scope-digest function
2072
2048
  * (injected for testability; defaults to the late-bound `scopeDigest`)
2049
+ * @param {(entry: string) => string} [params.spellFn] scope-entry spelling rule
2050
+ * (injected for testability; defaults to the late-bound `scopeEntrySpelling`)
2073
2051
  * @returns {Verdict}
2074
2052
  */
2075
- export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn }) {
2053
+ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn, spellFn }) {
2076
2054
  const toolName = input?.tool_name;
2077
2055
  // Row 4: not our tool.
2078
2056
  if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
@@ -2083,7 +2061,14 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
2083
2061
  const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
2084
2062
 
2085
2063
  const signal = extractScopeSignal(toolInput.prompt);
2086
- const files = signal.files;
2064
+ // #1510 g — ONE spelling rule, scope-gate's, applied before the ledger, the
2065
+ // digest and `promoteDirEntries` see an entry: `./scripts/lib` must reach the
2066
+ // dir-promotion as `scripts/lib`, or a bare-dir claim stays a literal and
2067
+ // compares disjoint with the files beneath it. Re-deduped on the spelling.
2068
+ // Unbound (a direct importer that never ran `bootstrap()`) ⇒ entries as
2069
+ // extracted; `findScopeCollisions` still spells both sides itself.
2070
+ const spell = typeof spellFn === 'function' ? spellFn : scopeEntrySpelling;
2071
+ const files = typeof spell === 'function' ? [...new Set(signal.files.map(spell))] : signal.files;
2087
2072
  const id = agentIdOf(toolInput);
2088
2073
  const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
2089
2074
  ? ledger.agents
@@ -48,8 +48,8 @@
48
48
  // `git commit --no-verify` skips it entirely (operator opts out by name).
49
49
 
50
50
  import { execSync } from 'node:child_process';
51
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
52
- import { join, relative } from 'node:path';
51
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
52
+ import { isAbsolute, join, relative, sep } from 'node:path';
53
53
 
54
54
  // Re-use existing helpers from scripts/lib/hardening.mjs.
55
55
  // IMPORTANT: import is resolved relative to THIS file's location (the hook
@@ -59,6 +59,7 @@ import { join, relative } from 'node:path';
59
59
  import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
60
60
  import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
61
61
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
62
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
62
63
  import { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
63
64
  import {
64
65
  classifyManifestSession,
@@ -153,6 +154,29 @@ function findOverlaps(fenceJsonPath, ourStaged) {
153
154
  return matches;
154
155
  }
155
156
 
157
+ /** Remediation for an allowedPaths violation, printed once per commit. */
158
+ const ALLOWED_PATHS_HINT = [
159
+ '',
160
+ 'These files were likely added by lint-staged eslint --fix / prettier --write.',
161
+ 'To proceed:',
162
+ ' 1) git restore --staged <path> # for each foreign path',
163
+ ' 2) git commit # retry',
164
+ ];
165
+
166
+ /**
167
+ * A manifest path for messages: relative to the repo root, the absolute path
168
+ * when it does not lie beneath it (#1514.2 — two candidate manifests, so a
169
+ * message must say which one it means).
170
+ *
171
+ * @param {string} repoRoot
172
+ * @param {string} scopePath
173
+ * @returns {string}
174
+ */
175
+ function manifestLabel(repoRoot, scopePath) {
176
+ const rel = relative(repoRoot, scopePath);
177
+ return rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? scopePath : rel;
178
+ }
179
+
156
180
  /** mtime of a file in ms, or `null` when it cannot be stat'ed. */
157
181
  function mtimeMsOf(file) {
158
182
  try {
@@ -175,14 +199,23 @@ function mtimeMsOf(file) {
175
199
  *
176
200
  * @param {object} p
177
201
  * @param {Record<string, unknown>} p.scope parsed manifest
178
- * @param {string[]} p.stagedFiles
179
- * @param {string} p.scopeRel manifest path for messages, repo-relative
202
+ * @param {string[]} p.stagedFiles repo-relative, as `git diff --cached` prints them
203
+ * @param {(f: string) => string|null} [p.scopePathOf] a staged path as the
204
+ * manifest names it — relative to the SESSION root; identity when that is the
205
+ * repo root. `null` = the path lies OUTSIDE the session root: a violation
206
+ * without pattern matching, since a `../`-prefixed path would match a glob
207
+ * like `**\/*.mjs` and enforce-scope denies every write outside its root
208
+ * (REQ-04).
209
+ * @param {string} p.scopeRel manifest path for messages, repo-relative (absolute
210
+ * when outside the repo root) — `manifestLabel`
180
211
  * @param {Set<string>} p.ownIds this process's session ids
181
212
  * @param {number|null} p.scopeMtimeMs
182
213
  * @param {number|null} p.sessionAge ms since the newest session clock
183
- * @returns {{action: 'pass'|'report'|'block', lines: string[]}}
214
+ * @returns {{action: 'pass'|'report'|'block', lines: string[], hint?: string[]}}
215
+ * `hint` = the remediation block; `main()` prints it ONCE after every
216
+ * manifest was judged, so two blocking manifests do not repeat it (#1514.3).
184
217
  */
185
- function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
218
+ function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
186
219
  if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
187
220
 
188
221
  // Same classifier and same disposition as enforce-scope Gate 3b: only a
@@ -200,7 +233,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
200
233
  const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
201
234
  if (allowedPaths.length > 0) {
202
235
  const violations = stagedFiles.filter(
203
- (f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
236
+ (f) => {
237
+ const scoped = scopePathOf(f);
238
+ return scoped === null || !allowedPaths.some((pattern) => pathMatchesPattern(scoped, pattern));
239
+ },
204
240
  );
205
241
  if (violations.length === 0) return { action: 'pass', lines: [] };
206
242
  // Blocks under `warn` too — see the header: in a warn-mode wave this is
@@ -208,14 +244,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
208
244
  return {
209
245
  action: 'block',
210
246
  lines: [
211
- '✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:',
247
+ `✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths (${scopeRel}):`,
212
248
  ...violations.map((v) => ` - ${v}`),
213
- '',
214
- 'These files were likely added by lint-staged eslint --fix / prettier --write.',
215
- 'To proceed:',
216
- ' 1) git restore --staged <path> # for each foreign path',
217
- ' 2) git commit # retry',
218
249
  ],
250
+ hint: ALLOWED_PATHS_HINT,
219
251
  };
220
252
  }
221
253
 
@@ -258,6 +290,30 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
258
290
  };
259
291
  }
260
292
 
293
+ /**
294
+ * The session root a commit at `repoRoot` is judged against when it lies
295
+ * STRICTLY inside the repo, else `null` (judge at `repoRoot`, as before) — see
296
+ * the call site in `main()` (#1511 point d). Both sides realpath-resolved, so
297
+ * macOS `/var` vs `/private/var` cannot split one directory in two.
298
+ *
299
+ * @param {string} repoRoot `git rev-parse --show-toplevel`
300
+ * @returns {{sessionRoot: string, realRepoRoot: string}|null}
301
+ */
302
+ function subdirSessionRoot(repoRoot) {
303
+ const canon = (p) => {
304
+ // `.native` canonicalises letter case on case-insensitive APFS (as
305
+ // enforce-scope's clamp does since #1504 pt 8); the JS realpath keeps a
306
+ // case-different CLAUDE_PROJECT_DIR verbatim, which relativises to `../..`
307
+ // against the git toplevel and switches the subdirectory mode off silently.
308
+ try { return realpathSync.native(p); } catch { return p; }
309
+ };
310
+ const realRepoRoot = canon(repoRoot);
311
+ const sessionRoot = canon(resolveSessionRoot(repoRoot, repoRoot));
312
+ const rel = relative(realRepoRoot, sessionRoot);
313
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
314
+ return { sessionRoot, realRepoRoot };
315
+ }
316
+
261
317
  /**
262
318
  * Both sub-modes, in the order the pre-commit hook needs them. Every statement
263
319
  * here used to sit at module top level, which meant a bare `import()` of this
@@ -282,8 +338,34 @@ async function main() {
282
338
  // committing session's env; there is no hook payload here, so the env tier is
283
339
  // the only process-local identity.
284
340
  const ownIds = new Set(readProcessLocalSessionIds());
285
- const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
286
- const scopePath = located.path ?? located.foreignPath;
341
+
342
+ // #1511 point d — ALSO read the manifest at the SESSION root the scope guards
343
+ // resolve (`resolveSessionRoot`, enforce-scope.mjs): a session launched in a
344
+ // repo SUBDIRECTORY keeps its manifest there, so the repo root held none and
345
+ // sub-mode B never ran for it. git runs this hook at the worktree top level
346
+ // and the committing session's env (the launch dir) is inherited, so the
347
+ // toplevel stands in for the payload `cwd` the Edit/Write guards get. Only a
348
+ // root STRICTLY inside the repo is adopted: the one rung that answers above it
349
+ // (a harness `agent-<hex>` worktree lifted to its launch checkout) would judge
350
+ // this repo's staged paths against another tree's manifest.
351
+ //
352
+ // The repo-root manifest is ALWAYS judged exactly as before #1511 d, and the
353
+ // subdirectory manifest is judged IN ADDITION, each against its own path
354
+ // mapping; either one blocking blocks. So the verdict is never laxer than the
355
+ // repo-root-only guard it extends — choosing ONE of the two manifests let an
356
+ // unbound or `enforcement: off` subdirectory manifest silence a repo-root wave
357
+ // that blocked the same commit (second review, MED-1).
358
+ const subdir = subdirSessionRoot(repoRoot);
359
+ const judgements = [{ root: repoRoot, scopePathOf: (f) => f }];
360
+ if (subdir !== null) {
361
+ judgements.push({
362
+ root: subdir.sessionRoot,
363
+ scopePathOf: (f) => {
364
+ const rel = relative(subdir.sessionRoot, join(subdir.realRepoRoot, f));
365
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel;
366
+ },
367
+ });
368
+ }
287
369
  const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
288
370
 
289
371
  // -------------------------------------------------------------------------
@@ -293,26 +375,41 @@ async function main() {
293
375
  const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
294
376
  const stagedFiles = stagedOutput.split('\n').filter(Boolean);
295
377
 
296
- if (scopePath) {
378
+ let blocked = false;
379
+ /** @type {string[]|null} printed once after the loop (#1514.3) */
380
+ let hint = null;
381
+ const flushHint = () => {
382
+ if (hint !== null) for (const line of hint) process.stderr.write(`${line}\n`);
383
+ };
384
+ for (const { root, scopePathOf } of judgements) {
385
+ const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
386
+ const scopePath = located.path ?? located.foreignPath;
387
+ if (!scopePath) continue;
388
+ const scopeRel = manifestLabel(repoRoot, scopePath);
297
389
  let scope;
298
390
  try {
299
391
  scope = JSON.parse(readFileSync(scopePath, 'utf8'));
300
392
  } catch (err) {
301
- process.stderr.write(`wave-scope-commit-guard: failed to parse wave-scope.json: ${err.message}\n`);
393
+ process.stderr.write(`wave-scope-commit-guard: failed to parse ${scopeRel}: ${err.message}\n`);
394
+ flushHint();
302
395
  process.exit(1);
303
396
  }
304
397
 
305
398
  const verdict = scopeCommitVerdict({
306
399
  scope: scope !== null && typeof scope === 'object' ? scope : {},
307
400
  stagedFiles,
308
- scopeRel: relative(repoRoot, scopePath) || scopePath,
401
+ scopePathOf,
402
+ scopeRel,
309
403
  ownIds,
310
404
  scopeMtimeMs: mtimeMsOf(scopePath),
311
- sessionAge: sessionAgeMs(repoRoot),
405
+ sessionAge: sessionAgeMs(root),
312
406
  });
313
407
  for (const line of verdict.lines) process.stderr.write(`${line}\n`);
314
- if (verdict.action === 'block') process.exit(1);
408
+ if (verdict.hint) hint = verdict.hint;
409
+ if (verdict.action === 'block') blocked = true;
315
410
  }
411
+ flushHint();
412
+ if (blocked) process.exit(1);
316
413
 
317
414
  // -------------------------------------------------------------------------
318
415
  // Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.5.0",
4
- "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
3
+ "version": "5.7.0",
4
+ "description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
7
7
  "keywords": [
@@ -16,7 +16,12 @@
16
16
  "orchestration",
17
17
  "developer-tools",
18
18
  "mcp",
19
- "llm"
19
+ "llm",
20
+ "workflow",
21
+ "quality-gates",
22
+ "ai-coding",
23
+ "gitlab",
24
+ "github"
20
25
  ],
21
26
  "engines": {
22
27
  "node": ">=24.0.0"
@@ -42,8 +42,8 @@ import path from 'node:path';
42
42
  import { parseArgs } from 'node:util';
43
43
 
44
44
  import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
45
- import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
46
- import { ARCHIVE_DIR_NAME, ROTATION_EVENT } from './lib/events-schema.mjs';
45
+ import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst, tombstoneGap } from './lib/events.mjs';
46
+ import { ARCHIVE_DIR_NAME } from './lib/events-schema.mjs';
47
47
  import { getProjectDir } from './lib/platform.mjs';
48
48
  import { isMainModule } from './lib/is-main-module.mjs';
49
49
  import { isLockLive, readLock } from './lib/session-lock.mjs';
@@ -163,7 +163,8 @@ export function planSessions({ repoRoot }) {
163
163
  * not one of the sources read (#1498). Pruned history used to shrink the plan
164
164
  * silently; non-zero `historyGaps` says the candidate count is a floor.
165
165
  *
166
- * The check is `readEventsWithRotations` rule 1 (`scripts/lib/events.mjs`):
166
+ * The check is `tombstoneGap` (`scripts/lib/events.mjs`), the one shared with
167
+ * `readEventsWithRotations` rule 1:
167
168
  * resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
168
169
  * file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
169
170
  * counts as a gap too — existence is not reading (#1423).
@@ -203,10 +204,7 @@ function planWithGaps({ repoRoot }) {
203
204
  for (const source of [...sources].reverse()) {
204
205
  forEachJsonlRecord(source.path, (ev) => {
205
206
  if (!ev || typeof ev !== 'object') return;
206
- if (ev.event === ROTATION_EVENT && typeof ev.archived_as === 'string' && ev.archived_as.length > 0
207
- && !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
208
- historyGaps += 1;
209
- }
207
+ if (tombstoneGap(ev, archiveDir, read) !== null) historyGaps += 1;
210
208
  if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
211
209
  if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
212
210
  else if (ev.event === SESSION_ENDED) {
@@ -837,6 +837,19 @@ export async function main(argv = [], deps = {}) {
837
837
  const archiveIds = idsInStore(archiveAbs);
838
838
  const backups = backupPaths(storeAbs).map((p) => ({ path: p, ids: idsInStore(p) }));
839
839
  const { notes, unreadable } = indexVaultNotes(learningsAbs);
840
+ // #1513: `vault-mirror-prune --apply` moves expired/duplicate/superseded
841
+ // learning notes to `90-archive/mirror/<subdir>/…` with their bytes intact.
842
+ // They are still evidence for this restore tool — but only as a FALLBACK:
843
+ // the archive holds the non-canonical twins of live notes, and searching
844
+ // both at once would turn every such learning into an ambiguous match.
845
+ const archivedAbs = resolve(vaultDir, '90-archive', 'mirror', vaultSubdir);
846
+ const archivedNotes = [];
847
+ if (archivedAbs !== learningsAbs && existsSync(archivedAbs)) {
848
+ const archived = indexVaultNotes(archivedAbs);
849
+ for (const n of archived.notes) n.relPath = join('90-archive', 'mirror', vaultSubdir, n.relPath);
850
+ archivedNotes.push(...archived.notes);
851
+ unreadable.push(...archived.unreadable);
852
+ }
840
853
  for (const u of unreadable) process.stderr.write(`backfill-learnings-from-vault: WARN unreadable vault note ${u}\n`);
841
854
 
842
855
  const repoHint = basename(root);
@@ -879,12 +892,10 @@ export async function main(argv = [], deps = {}) {
879
892
 
880
893
  orphanIndex += 1;
881
894
  const keySubjectSlug = prov.learningKey ? prov.learningKey.split('/').slice(1).join('/') : null;
882
- const { note, strategy, ambiguous } = locateNote(notes, {
883
- subject: prov.subject,
884
- keySlug: keySubjectSlug,
885
- learningId: id,
886
- repoHint,
887
- });
895
+ const query = { subject: prov.subject, keySlug: keySubjectSlug, learningId: id, repoHint };
896
+ let located = locateNote(notes, query);
897
+ if (!located.note && !located.ambiguous && archivedNotes.length > 0) located = locateNote(archivedNotes, query);
898
+ const { note, strategy, ambiguous } = located;
888
899
 
889
900
  const { record, fidelity, conflicts, crossChecks, validates, validationError } = reconstructRecord({
890
901
  rule: prov,
@@ -202,7 +202,11 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
202
202
  const days = (nowMs - latestMs) / MS_PER_DAY;
203
203
  return days > 0 ? days : 0;
204
204
  } catch {
205
- // No sessions file (or unreadable) ⇒ never worked on ⇒ maximally stale.
205
+ // Not the missing/unreadable-ledger path: readCanonicalSessions already
206
+ // returns [] for both (ENOENT silently, any other read failure with a
207
+ // stderr WARN — sessions-canonical.mjs § #1188), handled by the
208
+ // `records.length === 0` branch above. This catch only absorbs an
209
+ // unexpected throw (e.g. a non-string repoRoot) ⇒ maximally stale.
206
210
  return STALENESS_CAP_DAYS;
207
211
  }
208
212
  }
@@ -578,6 +578,60 @@ function discoverArchives(logPath) {
578
578
  return { archives, legacy: present.map((i) => `${logPath}.${i}`), ringHoles, unindexed };
579
579
  }
580
580
 
581
+ /**
582
+ * The one tombstone-to-archive match (#1505 5c) — shared by
583
+ * {@link readEventsWithRotations} rule 1, {@link scanEventsBackwards}, and
584
+ * `planWithGaps` in `scripts/backfill-abandoned-sessions.mjs`, which used to
585
+ * carry three hand-kept copies of it.
586
+ *
587
+ * #1411 — the tombstone resolves against THIS ledger's own `_archive/`, by
588
+ * BASENAME, never against the absolute value it stores. The writer keeps
589
+ * `archived_as` absolute (`events-rotation.mjs` joins the repo root) and that
590
+ * value stays in the record as PROVENANCE. It is not a lookup key: a moved
591
+ * checkout, a clone, or a sibling git worktree — routine here — makes every
592
+ * tombstone name a path that does not exist in THIS tree, so an exact
593
+ * comparison reported a phantom `missing-archive` for an archive sitting right
594
+ * beside the active file. Basename, not `realpath`: realpath cannot resolve a
595
+ * path that no longer exists, which IS the failure mode.
596
+ *
597
+ * ORDER (why the absolute value has no second chance): the sibling answer is
598
+ * consulted first, and an absolute hit would only be trustworthy while it
599
+ * pointed INSIDE this ledger's own archive dir — but any such path resolves to
600
+ * exactly the sibling path already tested, so once the sibling misses, an
601
+ * absolute hit can ONLY be a still-present OLD checkout. Honouring it would
602
+ * validate THIS ledger against a FOREIGN repo's archive: a silent false
603
+ * negative, worse than the phantom gap.
604
+ *
605
+ * #1423 — THREE outcomes, not two: read (`null`), present-but-unread
606
+ * (`unindexed-archive`), gone (`missing-archive`). The middle one used to be
607
+ * folded into the first by `existsSync`, so a tombstone whose archive had been
608
+ * RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its records
609
+ * were absent.
610
+ *
611
+ * @param {unknown} record a parsed ledger line
612
+ * @param {string} ownArchiveDir this ledger's `_archive/`, built exactly as
613
+ * `discoverArchives` builds it so a read archive matches `readSet` by string
614
+ * @param {Set<string>} readSet the paths this read actually took as sources
615
+ * @returns {{kind: 'unindexed-archive'|'missing-archive', path: string,
616
+ * archived_as: string, first_ts: string|null, last_ts: string|null}|null}
617
+ * `null` when `record` is not a rotation tombstone, names no archive, or its
618
+ * archive was read.
619
+ */
620
+ export function tombstoneGap(record, ownArchiveDir, readSet) {
621
+ if (record?.event !== ROTATION_EVENT) return null;
622
+ const target = record.archived_as;
623
+ if (typeof target !== 'string' || target.length === 0) return null;
624
+ const sibling = path.join(ownArchiveDir, path.basename(target));
625
+ if (readSet.has(sibling)) return null;
626
+ return {
627
+ kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
628
+ path: sibling,
629
+ archived_as: target,
630
+ first_ts: record.first_ts ?? null,
631
+ last_ts: record.last_ts ?? null,
632
+ };
633
+ }
634
+
581
635
  /**
582
636
  * Read the events ledger ACROSS rotation boundaries — active file plus every
583
637
  * archive still on disk — in time order, reporting what is missing instead of
@@ -660,44 +714,14 @@ export function readEventsWithRotations(repoRoot, opts = {}) {
660
714
  // `onDisk` still match by string.
661
715
  const ownArchiveDir = path.join(path.dirname(activePath), ARCHIVE_DIR_NAME);
662
716
 
663
- // Rule 1 — every rotation tombstone must still point at a file.
717
+ // Rule 1 — every rotation tombstone must still point at a file (resolution
718
+ // and the three outcomes: {@link tombstoneGap}).
664
719
  for (const source of sources) {
665
720
  for (const record of source.records) {
666
- if (record?.event !== ROTATION_EVENT) continue;
667
- const target = record.archived_as;
668
- if (typeof target !== 'string' || target.length === 0) continue;
669
- // #1411 — resolve the tombstone against THIS ledger's own `_archive/`,
670
- // by BASENAME, and never against the absolute value it stores.
671
- //
672
- // The writer keeps `archived_as` absolute (`events-rotation.mjs` joins
673
- // the repo root) and that value stays in the record as PROVENANCE. It is
674
- // not a lookup key: a moved checkout, a clone, or a sibling git worktree
675
- // — routine here — makes every tombstone name a path that does not exist
676
- // in THIS tree, so the old exact comparison reported a phantom
677
- // `missing-archive` for an archive sitting right beside the active file.
678
- // Basename, not `realpath`: realpath cannot resolve a path that no longer
679
- // exists, which IS the failure mode.
680
- //
681
- // ORDER (why the absolute value has no second chance): the sibling answer
682
- // is consulted first, and an absolute hit would only be trustworthy while
683
- // it pointed INSIDE this ledger's own archive dir — but any such path
684
- // resolves to exactly the sibling path already tested, so once the
685
- // sibling misses, an absolute hit can ONLY be a still-present OLD
686
- // checkout. Honouring it would validate THIS ledger against a FOREIGN
687
- // repo's archive: a silent false negative, worse than the phantom gap.
688
- const sibling = path.join(ownArchiveDir, path.basename(target));
689
- if (onDisk.has(sibling)) continue;
721
+ const gap = tombstoneGap(record, ownArchiveDir, onDisk);
722
+ if (gap === null) continue;
690
723
  gaps.push({
691
- // #1423 — THREE outcomes, not two: read (above), present-but-unread
692
- // (here), gone (below). The middle one used to be silently folded into
693
- // the first by `existsSync`, so a tombstone whose archive had been
694
- // RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its
695
- // records were absent from `events`.
696
- kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
697
- path: sibling,
698
- archived_as: target,
699
- first_ts: record.first_ts ?? null,
700
- last_ts: record.last_ts ?? null,
724
+ ...gap,
701
725
  lines: record.lines ?? null,
702
726
  size_before: record.size_before ?? null,
703
727
  reported_by: source.path,
@@ -866,8 +890,7 @@ export function scanEventsBackwards(opts = {}) {
866
890
  * LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
867
891
  * async SessionStart hook, so a concurrent append can land first and push it
868
892
  * to line 2. Called BEFORE the caller's `filter`, which names another event
869
- * and would otherwise hide the cut. Resolved by basename against this
870
- * ledger's own `_archive/`, never by the absolute provenance value (#1411).
893
+ * and would otherwise hide the cut. Matched by {@link tombstoneGap}.
871
894
  */
872
895
  const noteCut = (line, source) => {
873
896
  if (!line.includes(ROTATION_EVENT)) return;
@@ -877,18 +900,8 @@ export function scanEventsBackwards(opts = {}) {
877
900
  } catch {
878
901
  return; // counted by `consume` like every other malformed line
879
902
  }
880
- const target = record?.event === ROTATION_EVENT ? record.archived_as : null;
881
- if (typeof target !== 'string' || target.length === 0) return;
882
- const sibling = path.join(ownArchiveDir, path.basename(target));
883
- if (listed.has(sibling)) return;
884
- gaps.push({
885
- kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
886
- path: sibling,
887
- archived_as: target,
888
- first_ts: record.first_ts ?? null,
889
- last_ts: record.last_ts ?? null,
890
- reported_by: source.path,
891
- });
903
+ const gap = tombstoneGap(record, ownArchiveDir, listed);
904
+ if (gap !== null) gaps.push({ ...gap, reported_by: source.path });
892
905
  };
893
906
 
894
907
  /** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
@@ -946,16 +946,21 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
946
946
  * neither may mask the original error.
947
947
  *
948
948
  * ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
949
- * TWO PRODUCTION CALL-SITES: `vault-status/board-writer.mjs#writeBoard`
950
- * (`backup: false`) and {@link writeJsonAtomicSync}, which carries 12 further
951
- * call-sites across 9 files behind it. That second one is the load-bearing
949
+ * THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
950
+ * `rg -n "atomicWriteWithBackup\(" scripts hooks skills --glob '*.mjs'`):
951
+ * `vault-status/board-writer.mjs#writeBoard` (`backup: false`),
952
+ * `reconcile/rule-expiry-sweep.mjs` (no `backup`, i.e. `false`) and
953
+ * {@link writeJsonAtomicSync}, which carries 14 further call-sites across 11
954
+ * files behind it (`rg -n "writeJsonAtomicSync\("` over the same scope, minus
955
+ * the definition and `host-identity.mjs`'s private `_writeJsonAtomicSync`
956
+ * copy, which does NOT route through this helper). That last one is the load-bearing
952
957
  * evidence — the previous revision of this note recorded ONE call-site and
953
958
  * concluded the signature was unproven, while the function with an identical
954
959
  * body sat 60 lines above in this same file, unmigrated. The cheapest possible
955
960
  * migration going unmade is not a neutral fact about a helper: it is the
956
961
  * measurement that the helper is not paying for itself.
957
962
  *
958
- * What the second call-site does NOT prove: `writeJsonAtomicSync` passes
963
+ * What the `writeJsonAtomicSync` call-site does NOT prove: it passes
959
964
  * `backup: false` and no `fs`, so the backup half and the injection seam still
960
965
  * rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
961
966
  * migrates the remaining hand-rolled sites, re-check before widening:
@@ -185,7 +185,7 @@
185
185
 
186
186
  import { tokenize } from './affinity.mjs';
187
187
  import { readLearnings } from './io.mjs';
188
- import { kebab } from './kebab.mjs';
188
+ import { learningKeyOf } from './kebab.mjs';
189
189
  import { normalizeDialects } from './schema.mjs';
190
190
 
191
191
  // ---------------------------------------------------------------------------
@@ -441,27 +441,18 @@ export function emptyPools() {
441
441
  /**
442
442
  * Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
443
443
  *
444
- * Returns `null` when either half is unusable — mirroring
445
- * `validate/check-learning-provenance.mjs::learningKeyOf`, so an unkeyable
446
- * record simply does not participate in the exact-key pass. It still seeds and
444
+ * Returns `null` when either half is unusable (non-record input, blank
445
+ * type/title/subject, or an all-symbol title that kebabs to '') — delegates to
446
+ * the shared `kebab.mjs::learningKeyOf` (#1021) so the key space cannot fork
447
+ * from the provenance checker's. An unkeyable record simply does not
448
+ * participate in the exact-key pass. It still seeds and
447
449
  * joins pools: a missing title is not a reason to hide a learning from dedupe.
448
450
  *
449
451
  * @param {unknown} record
450
452
  * @returns {string|null}
451
453
  */
452
454
  export function learningKey(record) {
453
- if (!_isRecord(record)) return null;
454
- const type = typeof record.type === 'string' ? record.type.trim() : '';
455
- const titleOrSubject =
456
- (typeof record.title === 'string' && record.title.trim() !== '' ? record.title : '') ||
457
- (typeof record.subject === 'string' && record.subject.trim() !== '' ? record.subject : '');
458
- if (type === '' || titleOrSubject === '') return null;
459
- const slug = kebab(titleOrSubject);
460
- // `kebab` may legally return '' (all-symbol input) — an empty slug is not an
461
- // identity, so such a record is unkeyable rather than colliding with every
462
- // other empty-slug record of the same type.
463
- if (slug === '') return null;
464
- return `${type}/${slug}`;
455
+ return learningKeyOf(record);
465
456
  }
466
457
 
467
458
  /**