session-orchestrator 5.6.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 (42) 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 +26 -0
  6. package/README.md +7 -8
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/session-config-reference.md +1 -1
  10. package/docs/session-config-template.md +1 -1
  11. package/hooks/hooks-codex.json +1 -1
  12. package/hooks/hooks.json +1 -1
  13. package/hooks/wave-scope-commit-guard.mjs +41 -10
  14. package/package.json +1 -1
  15. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  16. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  17. package/scripts/lib/dispatcher/rank.mjs +5 -1
  18. package/scripts/lib/events.mjs +62 -49
  19. package/scripts/lib/io.mjs +9 -4
  20. package/scripts/lib/learnings/candidates.mjs +7 -16
  21. package/scripts/lib/memory-proposals/store.mjs +9 -2
  22. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  23. package/scripts/lib/session-close-backfill.mjs +18 -0
  24. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  25. package/scripts/lib/vault-mirror/process.mjs +388 -56
  26. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  27. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  28. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  29. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  30. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  31. package/scripts/print-applicable-rules.mjs +1 -25
  32. package/scripts/release.mjs +4 -0
  33. package/scripts/vault-mirror-prune.mjs +145 -0
  34. package/scripts/vault-mirror.mjs +77 -12
  35. package/skills/claude-md-drift-check/checker.mjs +6 -16
  36. package/skills/vault-mirror/SKILL.md +27 -2
  37. package/skills/wave-executor/references/wave-loop-scope-manifest.md +2 -0
  38. package/hooks/agent-teams-h3-test.sh +0 -362
  39. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  40. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  41. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  42. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "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.",
10
- "version": "5.6.0"
10
+ "version": "5.7.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "session-orchestrator",
4
- "version": "5.6.0",
4
+ "version": "5.7.0",
5
5
  "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.",
6
6
  "author": {
7
7
  "name": "Bernhard Goetzendorfer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.6.0+codex.20261003103620",
3
+ "version": "5.7.0+codex.20261003172657",
4
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.",
5
5
  "keywords": [
6
6
  "session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
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.",
5
5
  "author": {
6
6
  "name": "Bernhard Goetzendorfer"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.7.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - **`scripts/vault-mirror-prune.mjs`** (#1513): applies the vault-mirror retention rules to the existing `40-learnings/` and `50-sessions/` notes. Dry run by default with a JSON manifest (`--manifest`: path, reason, action, target, kept copy, rollup); `--apply` writes the month rollups, then moves archived notes to `90-archive/mirror/<zone>/<same path>` with `status: archived` + `archived-reason`, then renames alias-folder and flat notes into their namespace. Reasons: `duplicate`, `superseded`, `expired`, `metrics-only-session`, `namespace-alias`, `flat-relocate`. Only notes with the mirror's `_generator` marker are touched; nothing is deleted; a second run plans nothing.
15
+
16
+ ### Changed
17
+
18
+ - **vault-mirror narrative gate for sessions** (#1513): `vault-mirror.quality.min-narrative-chars` now measures the record's free text (`notes`, `narrative`, `summary`) instead of the rendered body with its tables. A record below it gets no note but one row in `50-sessions/<repo>/_rollup-YYYY-MM.md` (action `skipped-metrics-only`; idempotent). `0` disables the gate.
19
+ - **vault-mirror learnings lifecycle** (#1513): the pre-#725 slug variant of a note is updated in place instead of getting a hyphenated twin; a new note whose normalised insight already exists in the namespace is refused (`skipped-duplicate-insight`); an expired learning is never created (`skipped-expired`) and its existing note becomes `status: archived` + `archived-reason: expired`; `--kind learning` also reads the sibling `learnings-archive.jsonl` and marks the notes of archived records (`expired` / `superseded`). `backfill-learnings-from-vault.mjs` falls back to `90-archive/mirror/40-learnings/`.
20
+ - **`session-close-backfill.log` carries events only** (#1513): `skipped-*` outcomes are no longer appended (86 % of the Meta-Vault's 723 KB log); they remain queryable as ledger events.
21
+ - **`sweep-expired-rules` removes expired single-entry rule files** (#1513): a generated rule with one learning and no counter sentence was skipped as `no-counter-sentence` forever; it is now deleted (pair stamped first) once the later of its frontmatter `expires-at` and the learning's store date has passed.
22
+ - **Commit guard names the manifest** (#1514): a parse error and each block header now carry the manifest path (`.claude/wave-scope.json` vs `pkg/.claude/wave-scope.json`), and the "To proceed" hint prints once when both manifests block.
23
+ - **Read failures other than a missing file are reported** (#1216): `countProposalsForWave` and the transcript tail's `readAgentType` now warn on stderr instead of silently returning `0` / `unknown`.
24
+ - **Lint rejects the deprecated locks shim and the deleted `OWNER_YAML_PATH`** (#1234): `no-restricted-imports` with the replacement named in the message.
25
+ - **ADR-0002 verdict corrected to Stay** (#1053): native session↔session and agent→coordinator messaging (Claude Code 2.1.224) removed the one capability Agent Teams added; ADR-0003/0007/0010 and the rules point at the addendum. LM-002a separates Channels from cross-session messaging.
26
+
27
+ ### Fixed
28
+
29
+ - **Drift-check Check 7 derives learning keys with the shared `learningKeyOf`** (#1021): a learning with a blank title or an untrimmed type no longer produces a key that differs from the one the rule emitter stamps.
30
+ - Shared helpers instead of private copies: one `tombstoneGap` for the three archive-gap sites (#1505), and `deriveFenceToken`, `learningKeyOf`, `kebab` imported where they were copied (#1021). Behaviour unchanged.
31
+
32
+ ### Removed
33
+
34
+ - **H3 Agent Teams test harness** (`hooks/agent-teams-h3-test.sh`, `scripts/spikes/h3-agent-teams/`) — the question it measured is closed (#1061).
35
+
10
36
  ## [5.6.0] - 2026-10-03
11
37
 
12
38
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Session Orchestrator
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Version](https://img.shields.io/badge/version-5.6.0-blue.svg)](CHANGELOG.md)
4
+ [![Version](https://img.shields.io/badge/version-5.7.0-blue.svg)](CHANGELOG.md)
5
5
  [![npm](https://img.shields.io/npm/v/session-orchestrator.svg)](https://www.npmjs.com/package/session-orchestrator)
6
6
 
7
7
  **Give your agents a working rhythm.**
@@ -139,15 +139,14 @@ How this compares to other orchestrators, with measured results kept separate fr
139
139
 
140
140
  All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
141
141
 
142
- ## Recent highlights (v5.6.0)
142
+ ## Recent highlights (v5.7.0)
143
143
 
144
- Highlights of the v5.6.0 line:
144
+ Highlights of the v5.7.0 line:
145
145
 
146
- - **Clearer about what you get.** The website, this README and the npm page now open with the problem the plugin solves and list the features by benefit: what each part does for you, then how it works.
147
- - **Sessions in a worktree or a subdirectory report their own tree.** A wave's start commit, changed files, HEAD and the stop record's branch now describe the tree the session works in, and the commit guard also checks the manifest of a session launched in a subdirectory, never less strictly than before (#1511).
148
- - **One rule for how scope paths are spelled.** The overlap check before dispatch and the scope tools now compare paths the same way, and `validate-wave-scope --assert-disjoint --no-manifest` checks a plan's file lists without a dummy manifest (#1510).
149
- - **A changed scope sidecar can no longer hide a violation.** If the per-wave sidecar changes under a standing manifest, peer records stop counting and you get one notice (#1504).
150
- - **Close-backfill reads ledger gaps only inside the session's own time window**, so one pruned archive no longer holds back every later session (#1512).
146
+ - **Tidy up the vault mirror in place.** `scripts/vault-mirror-prune.mjs` applies the retention rules to existing learning and session notes: dry run with a manifest first, then `--apply` archives duplicates, superseded and expired notes and writes month rollups. Nothing is deleted, and a second run plans nothing (#1513).
147
+ - **Fewer, better vault notes from now on.** A session record without enough free text gets one rollup row instead of a note, an expired learning never becomes a note, and a learning whose insight already exists is refused (#1513).
148
+ - **The commit guard says which manifest it means.** Parse errors and block messages name the manifest file, and the "To proceed" hint prints once (#1514).
149
+ - **Silent failures made visible.** Read errors other than a missing file are now reported instead of turning into `0` or `unknown` (#1216), and lint blocks imports of the deprecated locks shim (#1234).
151
150
 
152
151
  Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
153
152
 
@@ -454,7 +454,7 @@ Add a `## Session Config` section to your project's Session Config host file to
454
454
  | `plan-retro-location` | string | `docs/retro/` | Directory where retrospective documents are saved (relative to project root). |
455
455
  | `memory-cleanup-threshold` | integer | `5` | Recommend `/memory-cleanup` after N accumulated session memory files. |
456
456
  | `memory-cleanup-soft-limit` | integer | `180` | Hard ceiling on accumulated memory files before the cleanup nudge escalates from soft suggestion to strong recommendation. PRD F2.2 / issue #502. |
457
- | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum body length (characters) before a learning or session note is mirrored to the vault. Notes shorter than this threshold are skipped. PRD F1.2 / issue #504. |
457
+ | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note; below it the session becomes a row in the month rollup `50-sessions/<repo>/_rollup-YYYY-MM.md`. PRD F1.2 / #504, #1513. |
458
458
  | `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Set to `0.0` to mirror every learning regardless of confidence. PRD F1.2 / issue #504. |
459
459
  | `cold-start.enabled` | boolean | `true` | Master toggle for the cold-start detector. When `false`, no idle-time nudges fire at session-start. PRD F1.3 / issue #500. |
460
460
  | `cold-start.nudge-after-hours` | integer | `1` | Hours of wall-clock idle (since the last session-end) before the cold-start detector fires a nudge. PRD F1.3 / issue #500. |
@@ -466,6 +466,16 @@ under four contracts, all of them consequences of the four merge rules above:
466
466
  `--apply` emits `orchestrator.rules.expiry_sweep_applied` after the writes
467
467
  succeed (see `docs/events-schema.md`); a dry run emits nothing.
468
468
 
469
+ **Single-entry files (#1513).** A generated rule holding exactly ONE learning
470
+ (one provenance pair, no `###` entry, no counter sentence — the per-learning
471
+ shape a consumer's reconciliation engine writes; all 21 generated rules of the
472
+ Meta-Vault on 2026-10-03) is dated by its own frontmatter `expires-at`, the
473
+ date `rule-loader.mjs` acts on. There is no body sentence to keep in agreement,
474
+ so the `no-counter-sentence` refusal does not apply: the file is deleted
475
+ (`reason: 'single-entry-expired'`, pair stamped terminal first) once the LATER
476
+ of that date and the learning's store `expires_at` (when it resolves) has
477
+ passed. A re-stamped learning therefore keeps its rule.
478
+
469
479
  ## Authoring Examples
470
480
 
471
481
  ### (a) Hand-authored always-on rule (no frontmatter)
@@ -837,7 +837,7 @@ vault-mirror:
837
837
 
838
838
  | Field | Type | Default | Description |
839
839
  |-------|------|---------|-------------|
840
- | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum body length (characters) before a learning or session note is mirrored to the vault. Notes shorter than this threshold are skipped — useful to prevent low-information notes from cluttering the vault during onboarding or when a session yields only stubs. Bounds: integer ≥ 0. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504. |
840
+ | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note (#1513). Below it the session becomes one row in `50-sessions/<repo>/_rollup-YYYY-MM.md`; `0` disables the gate. Learnings are not gated by length. Bounds: integer ≥ 0. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504, narrative semantics #1513. |
841
841
  | `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Confidence is read from the source learning record. Notes below this threshold are skipped. Set to `0.0` to mirror every learning regardless of confidence. Bounds: `0.0 ≤ value ≤ 1.0`. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504. |
842
842
 
843
843
  **Used by:** `scripts/vault-mirror.mjs`.
@@ -235,7 +235,7 @@ Quality thresholds applied by `scripts/vault-mirror.mjs` before mirroring a lear
235
235
  ```yaml
236
236
  vault-mirror:
237
237
  quality:
238
- min-narrative-chars: 400 # integer ≥ 0 — minimum body length to mirror
238
+ min-narrative-chars: 400 # integer ≥ 0 — minimum session free text (notes/narrative/summary) for an own note; below → month rollup (#1513)
239
239
  min-confidence: 0.5 # float 0.0..1.0 — minimum learning confidence to mirror
240
240
  ```
241
241
 
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.6.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.7.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v5.6.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.7.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -154,6 +154,29 @@ function findOverlaps(fenceJsonPath, ourStaged) {
154
154
  return matches;
155
155
  }
156
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
+
157
180
  /** mtime of a file in ms, or `null` when it cannot be stat'ed. */
158
181
  function mtimeMsOf(file) {
159
182
  try {
@@ -183,11 +206,14 @@ function mtimeMsOf(file) {
183
206
  * without pattern matching, since a `../`-prefixed path would match a glob
184
207
  * like `**\/*.mjs` and enforce-scope denies every write outside its root
185
208
  * (REQ-04).
186
- * @param {string} p.scopeRel manifest path for messages, repo-relative
209
+ * @param {string} p.scopeRel manifest path for messages, repo-relative (absolute
210
+ * when outside the repo root) — `manifestLabel`
187
211
  * @param {Set<string>} p.ownIds this process's session ids
188
212
  * @param {number|null} p.scopeMtimeMs
189
213
  * @param {number|null} p.sessionAge ms since the newest session clock
190
- * @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).
191
217
  */
192
218
  function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
193
219
  if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
@@ -218,14 +244,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeR
218
244
  return {
219
245
  action: 'block',
220
246
  lines: [
221
- '✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:',
247
+ `✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths (${scopeRel}):`,
222
248
  ...violations.map((v) => ` - ${v}`),
223
- '',
224
- 'These files were likely added by lint-staged eslint --fix / prettier --write.',
225
- 'To proceed:',
226
- ' 1) git restore --staged <path> # for each foreign path',
227
- ' 2) git commit # retry',
228
249
  ],
250
+ hint: ALLOWED_PATHS_HINT,
229
251
  };
230
252
  }
231
253
 
@@ -354,15 +376,22 @@ async function main() {
354
376
  const stagedFiles = stagedOutput.split('\n').filter(Boolean);
355
377
 
356
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
+ };
357
384
  for (const { root, scopePathOf } of judgements) {
358
385
  const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
359
386
  const scopePath = located.path ?? located.foreignPath;
360
387
  if (!scopePath) continue;
388
+ const scopeRel = manifestLabel(repoRoot, scopePath);
361
389
  let scope;
362
390
  try {
363
391
  scope = JSON.parse(readFileSync(scopePath, 'utf8'));
364
392
  } catch (err) {
365
- 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();
366
395
  process.exit(1);
367
396
  }
368
397
 
@@ -370,14 +399,16 @@ async function main() {
370
399
  scope: scope !== null && typeof scope === 'object' ? scope : {},
371
400
  stagedFiles,
372
401
  scopePathOf,
373
- scopeRel: relative(repoRoot, scopePath) || scopePath,
402
+ scopeRel,
374
403
  ownIds,
375
404
  scopeMtimeMs: mtimeMsOf(scopePath),
376
405
  sessionAge: sessionAgeMs(root),
377
406
  });
378
407
  for (const line of verdict.lines) process.stderr.write(`${line}\n`);
408
+ if (verdict.hint) hint = verdict.hint;
379
409
  if (verdict.action === 'block') blocked = true;
380
410
  }
411
+ flushHint();
381
412
  if (blocked) process.exit(1);
382
413
 
383
414
  // -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
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",
@@ -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
  /**
@@ -413,7 +413,10 @@ export async function appendProposal({
413
413
  * Reads proposals.jsonl without acquiring the lock — suitable for diagnostics
414
414
  * and reads where eventual consistency is acceptable.
415
415
  *
416
- * Returns 0 when the file does not exist.
416
+ * Returns 0 when the file does not exist (silently — `countWaveLines` maps
417
+ * ENOENT to 0). Any other read failure also returns 0, but with a stderr WARN
418
+ * so a 0 caused by an unreadable file is distinguishable from an empty queue
419
+ * (#1216).
417
420
  *
418
421
  * @param {object} opts
419
422
  * @param {string} opts.repoRoot
@@ -424,7 +427,11 @@ export async function countProposalsForWave({ repoRoot, waveId }) {
424
427
  const jsonlPath = jsonlPathFor(repoRoot);
425
428
  try {
426
429
  return countWaveLines(jsonlPath, waveId);
427
- } catch {
430
+ } catch (err) {
431
+ process.stderr.write(
432
+ `[memory-proposals] WARN: countProposalsForWave: ` +
433
+ `${err?.code ?? '?'}: ${err?.message ?? String(err)} — returning 0\n`,
434
+ );
428
435
  return 0;
429
436
  }
430
437
  }