session-orchestrator 5.6.0 → 5.8.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 (207) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +75 -0
  42. package/README.md +10 -10
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/USER-GUIDE.md +1 -1
  45. package/docs/baseline.md +4 -3
  46. package/docs/codex-setup.md +6 -4
  47. package/docs/components.md +5 -4
  48. package/docs/events-schema.md +4 -3
  49. package/docs/github-mirror-protection.md +3 -2
  50. package/docs/install.md +39 -3
  51. package/docs/pi-setup.md +1 -1
  52. package/docs/rule-authoring.md +12 -2
  53. package/docs/session-config-reference.md +8 -6
  54. package/docs/session-config-template.md +4 -3
  55. package/evals/README.md +60 -0
  56. package/evals/instruction-ablation/README.md +147 -0
  57. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  58. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  59. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  60. package/evals/instruction-ablation/run.mjs +336 -0
  61. package/evals/psa-003-foreign-work/case.yaml +16 -0
  62. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  63. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  64. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  65. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  66. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  67. package/evals/psa-003-foreign-work/prompt.md +6 -0
  68. package/hooks/_lib/hook-import-set.json +10 -2
  69. package/hooks/hooks-codex.json +1 -1
  70. package/hooks/hooks.json +1 -1
  71. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  72. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  73. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  74. package/hooks/wave-scope-commit-guard.mjs +41 -10
  75. package/monitors/monitors.json +1 -1
  76. package/package.json +3 -1
  77. package/pi/prompts/architecture.md +11 -0
  78. package/pi/prompts/eval.md +1 -1
  79. package/pi/prompts/tmux-layout.md +11 -0
  80. package/pi/prompts/write-executable-plan.md +11 -0
  81. package/scripts/backfill-abandoned-sessions.mjs +6 -8
  82. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  83. package/scripts/backfill-sessions.mjs +9 -7
  84. package/scripts/codex-install.mjs +108 -23
  85. package/scripts/emit-session.mjs +15 -2
  86. package/scripts/fleet-auflagen.mjs +54 -0
  87. package/scripts/generate-agents-skills.mjs +9 -16
  88. package/scripts/generate-codex-skills.mjs +8 -6
  89. package/scripts/generate-cursor-adapter.mjs +9 -74
  90. package/scripts/generate-pi-prompts.mjs +8 -74
  91. package/scripts/github-protection-audit.mjs +8 -0
  92. package/scripts/lib/auto-dialectic.mjs +10 -9
  93. package/scripts/lib/auto-dream.mjs +8 -10
  94. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  95. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  96. package/scripts/lib/config/drift-check.mjs +1 -1
  97. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  98. package/scripts/lib/config.mjs +5 -1
  99. package/scripts/lib/convergence-monitor.mjs +5 -3
  100. package/scripts/lib/dispatcher/rank.mjs +5 -1
  101. package/scripts/lib/ecosystem-health.mjs +5 -3
  102. package/scripts/lib/eval/engine.mjs +61 -27
  103. package/scripts/lib/eval/judge.mjs +14 -8
  104. package/scripts/lib/eval/session-resolve.mjs +7 -2
  105. package/scripts/lib/events-retention-banner.mjs +5 -2
  106. package/scripts/lib/events.mjs +81 -54
  107. package/scripts/lib/fleet-protocol.mjs +98 -1
  108. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  109. package/scripts/lib/io.mjs +181 -39
  110. package/scripts/lib/learnings/candidates.mjs +14 -44
  111. package/scripts/lib/learnings/io.mjs +7 -2
  112. package/scripts/lib/learnings/judgment.mjs +5 -8
  113. package/scripts/lib/learnings/select.mjs +3 -3
  114. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  115. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  116. package/scripts/lib/memory-proposals/store.mjs +12 -7
  117. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  118. package/scripts/lib/native-command.mjs +48 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  120. package/scripts/lib/peer-cards/writer.mjs +6 -18
  121. package/scripts/lib/platform.mjs +113 -4
  122. package/scripts/lib/plugin-package-stage.mjs +169 -0
  123. package/scripts/lib/plugin-surface.mjs +121 -0
  124. package/scripts/lib/quality-gate.mjs +30 -25
  125. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  126. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
  127. package/scripts/lib/reconcile/writer.mjs +11 -17
  128. package/scripts/lib/rule-loader.mjs +32 -6
  129. package/scripts/lib/scope-echo.mjs +12 -2
  130. package/scripts/lib/scope-gate.mjs +2 -1
  131. package/scripts/lib/session-close-backfill.mjs +21 -4
  132. package/scripts/lib/session-id.mjs +2 -1
  133. package/scripts/lib/session-record-repair.mjs +9 -13
  134. package/scripts/lib/session-schema/constants.mjs +20 -5
  135. package/scripts/lib/session-start-probes.mjs +2 -2
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +585 -72
  156. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  157. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  158. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  159. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  160. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  161. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  162. package/scripts/lib/wave-transcript-tail.mjs +19 -6
  163. package/scripts/lib/worktree/meta.mjs +7 -3
  164. package/scripts/mcp-server.mjs +158 -0
  165. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  166. package/scripts/migrate-vault-paths.mjs +7 -4
  167. package/scripts/print-applicable-rules.mjs +1 -25
  168. package/scripts/print-learnings-index.mjs +2 -1
  169. package/scripts/release.mjs +4 -0
  170. package/scripts/relocate-vault-corpus.mjs +5 -3
  171. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  172. package/scripts/self-update.mjs +179 -39
  173. package/scripts/site-numbers.mjs +4 -3
  174. package/scripts/sweep-expired-learnings.mjs +6 -4
  175. package/scripts/sync-vault-schema.mjs +3 -7
  176. package/scripts/vault-mirror-prune.mjs +145 -0
  177. package/scripts/vault-mirror.mjs +77 -12
  178. package/scripts/windows-setup.mjs +45 -0
  179. package/skills/_shared/fleet-protocol.md +8 -0
  180. package/skills/architecture/SKILL.md +1 -0
  181. package/skills/bootstrap/_shared-template.md +5 -2
  182. package/skills/claude-md-drift-check/SKILL.md +4 -1
  183. package/skills/claude-md-drift-check/checker.mjs +89 -26
  184. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  185. package/skills/eval/SKILL.md +12 -13
  186. package/skills/eval/rubric-v3.md +521 -0
  187. package/skills/frontmatter-guard/SKILL.md +1 -0
  188. package/skills/gitlab-portfolio/SKILL.md +1 -0
  189. package/skills/hook-development/SKILL.md +1 -0
  190. package/skills/mcp-builder/SKILL.md +1 -0
  191. package/skills/repo-audit/SKILL.md +2 -2
  192. package/skills/session-end/SKILL.md +32 -1
  193. package/skills/session-end/vault-operations.md +12 -2
  194. package/skills/session-start/SKILL.md +2 -2
  195. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  196. package/skills/session-start/references/operations-contract.md +4 -1
  197. package/skills/tmux-layout/SKILL.md +1 -0
  198. package/skills/vault-mirror/SKILL.md +28 -2
  199. package/skills/vault-sync/SKILL.md +6 -6
  200. package/skills/vault-sync/validator.sh +29 -10
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
  202. package/skills/write-executable-plan/SKILL.md +1 -0
  203. package/hooks/agent-teams-h3-test.sh +0 -362
  204. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  205. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  206. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  207. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -0,0 +1,121 @@
1
+ /**
2
+ * plugin-surface.mjs — the one enumeration of the plugin's public sources
3
+ * (`commands/*.md`, `skills/<name>/SKILL.md`) and the one hand-written
4
+ * frontmatter parser the Cursor and Pi wrapper generators share (#1240).
5
+ *
6
+ * Four generators derive harness surfaces from the same sources
7
+ * (`generate-agents-skills.mjs`, `generate-codex-skills.mjs`,
8
+ * `generate-cursor-adapter.mjs`, `generate-pi-prompts.mjs`). Before this module
9
+ * the agents/codex pair skipped names with a leading `.` or `_` while the
10
+ * cursor/pi pair did not, so a `commands/_draft.md` produced Cursor and Pi
11
+ * wrappers but no Codex or `.agents` mirror. {@link isHiddenSourceName} is now
12
+ * the single NAME filter all four apply. The file/directory test is not yet
13
+ * shared: the list helpers here follow symlinks (statSync), while
14
+ * generate-codex-skills.mjs keeps its own dirent loop, which does not. No
15
+ * source is a symlink today (`find skills commands -maxdepth 2 -type l` → 0);
16
+ * revisit when one is added.
17
+ */
18
+
19
+ import { existsSync, readdirSync, statSync } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ /**
23
+ * A source name with a leading `.` or `_` is private (drafts, `_shared/`
24
+ * helpers, dotfiles) and never becomes a public command or skill.
25
+ *
26
+ * @param {string} name file or directory name
27
+ * @returns {boolean}
28
+ */
29
+ export function isHiddenSourceName(name) {
30
+ return /^[._]/.test(name);
31
+ }
32
+
33
+ function isDir(p) {
34
+ try { return statSync(p).isDirectory(); } catch { return false; }
35
+ }
36
+
37
+ function isFile(p) {
38
+ try { return statSync(p).isFile(); } catch { return false; }
39
+ }
40
+
41
+ /**
42
+ * Public `commands/*.md` file names (with extension), sorted.
43
+ *
44
+ * @param {string} root plugin root
45
+ * @returns {string[]}
46
+ */
47
+ export function listCommandFiles(root) {
48
+ const dir = path.join(root, 'commands');
49
+ if (!isDir(dir)) return [];
50
+ return readdirSync(dir)
51
+ .filter((name) => name.endsWith('.md') && !isHiddenSourceName(name) && isFile(path.join(dir, name)))
52
+ .sort();
53
+ }
54
+
55
+ /**
56
+ * Public `skills/<name>` directory names that carry a SKILL.md, sorted.
57
+ *
58
+ * @param {string} root plugin root
59
+ * @returns {string[]}
60
+ */
61
+ export function listSkillDirs(root) {
62
+ const dir = path.join(root, 'skills');
63
+ if (!isDir(dir)) return [];
64
+ return readdirSync(dir)
65
+ .filter((name) => !isHiddenSourceName(name) && isDir(path.join(dir, name)) && existsSync(path.join(dir, name, 'SKILL.md')))
66
+ .sort();
67
+ }
68
+
69
+ /**
70
+ * Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
71
+ * DECODED values (one layer of surrounding quotes stripped). Not js-yaml on
72
+ * purpose: the Cursor/Pi wrappers re-emit these values verbatim, and every
73
+ * merged skill uses `description: >`, which this parser folds to its text.
74
+ *
75
+ * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
76
+ * probes below miss the block entirely: the file parsed as "no frontmatter",
77
+ * so every flag in it (`user-invocable`, `disable-model-invocation`) silently
78
+ * disappeared and the skill was demoted out of the generated surface with no
79
+ * diagnostic anywhere. Both are normalised away first — the same treatment
80
+ * `parseAgentFrontmatter` (`scripts/lib/agent-frontmatter.mjs`) gives them.
81
+ *
82
+ * NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
83
+ * `__BLOCK_SCALAR__` sentinel for a `description: >`, which would be emitted
84
+ * as the wrapper description.
85
+ *
86
+ * @param {string} content
87
+ * @returns {Record<string, string>}
88
+ */
89
+ export function parseFrontmatter(content) {
90
+ const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
91
+ if (!text.startsWith('---\n')) return {};
92
+ const end = text.indexOf('\n---\n', 4);
93
+ if (end === -1) return {};
94
+
95
+ const lines = text.slice(4, end).split('\n');
96
+ const fields = {};
97
+ let i = 0;
98
+ while (i < lines.length) {
99
+ const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
100
+ if (!match) {
101
+ i += 1;
102
+ continue;
103
+ }
104
+ const key = match[1];
105
+ const raw = match[2];
106
+ if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
107
+ const folded = [];
108
+ i += 1;
109
+ while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
110
+ folded.push(lines[i].replace(/^\s+/, ''));
111
+ i += 1;
112
+ }
113
+ const joiner = raw.startsWith('|') ? '\n' : ' ';
114
+ fields[key] = folded.filter(Boolean).join(joiner).trim();
115
+ continue;
116
+ }
117
+ fields[key] = raw.replace(/^["']|["']$/g, '');
118
+ i += 1;
119
+ }
120
+ return fields;
121
+ }
@@ -62,7 +62,7 @@
62
62
  */
63
63
 
64
64
  import { spawnSync } from 'node:child_process';
65
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
65
+ import { existsSync, readFileSync } from 'node:fs';
66
66
  import { dirname, join, resolve } from 'node:path';
67
67
  import { fileURLToPath } from 'node:url';
68
68
 
@@ -75,6 +75,7 @@ import {
75
75
  spawnInGroup,
76
76
  } from './process-group.mjs';
77
77
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
78
+ import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
78
79
  import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
79
80
 
80
81
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
@@ -406,12 +407,10 @@ function writeLastGreenSha(repoRoot) {
406
407
  if (sha.status !== 0 || !sha.stdout) return;
407
408
  const head = sha.stdout.trim();
408
409
  if (!head) return;
409
- const runtimeDir = join(repoRoot, '.orchestrator', 'runtime');
410
- mkdirSync(runtimeDir, { recursive: true });
411
- const target = join(runtimeDir, 'last-green-sha.txt');
412
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
413
- writeFileSync(tmp, head + '\n', 'utf8');
414
- renameSync(tmp, target);
410
+ // Never throws; an fs failure is the same silent best-effort as before.
411
+ atomicWriteWithBackup(join(repoRoot, '.orchestrator', 'runtime', 'last-green-sha.txt'), head + '\n', {
412
+ tmpPrefix: '.tmp-last-green-sha',
413
+ });
415
414
  } catch {
416
415
  // best-effort
417
416
  }
@@ -657,14 +656,12 @@ function readCorrectiveContext(repoRoot) {
657
656
  function writeDiagnosticsBundle(repoRoot, bundle) {
658
657
  try {
659
658
  const dir = join(repoRoot, '.orchestrator', 'metrics', 'verification-failures');
660
- mkdirSync(dir, { recursive: true });
661
659
  // Replace colons in ISO timestamp for cross-fs portability.
662
660
  const ts = new Date().toISOString().replace(/:/g, '-');
663
661
  const target = join(dir, `${ts}.json`);
664
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
665
- writeFileSync(tmp, JSON.stringify(redactDiagnosticsBundle(bundle), null, 2) + '\n', 'utf8');
666
- renameSync(tmp, target);
667
- return target;
662
+ // Same bytes as before: indent 2 + trailing newline, mkdir -p of `dir`.
663
+ const res = writeJsonAtomicSync(target, redactDiagnosticsBundle(bundle), { tmpPrefix: '.tmp-diagnostics' });
664
+ return res.ok ? target : null;
668
665
  } catch {
669
666
  return null;
670
667
  }
@@ -842,19 +839,27 @@ export function gateKillFields(run) {
842
839
  * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
843
840
  * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
844
841
  * the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
845
- * machine-measured emission of the same fact, and as of 2026-07-31 it has zero
846
- * readers (`grep -c '"counts"' .orchestrator/metrics/events.jsonl` → 0 across
847
- * 4404 `orchestrator.quality_gate.*` records).
848
- *
849
- * Retiring the prose path needs a producer change no docblock can make:
850
- * `waves[].*` is PER-WAVE, and gate events carry no `wave_number` (0 of those
851
- * 4404 records). A session-end reader could only attribute a gate event to a
852
- * wave by a wall-clock window whose own boundaries (`waves[].started_at` /
853
- * `completed_at`) are themselves LLM-written — one LLM hop traded for another,
854
- * against the posture `scripts/lib/eval/session-resolve.mjs` already documents
855
- * for window-attributed gate events ("a contaminated window means
856
- * gate-attribution is unsafe"). The concrete remaining work is named in
857
- * `skills/session-end/metrics-collection.md` § 1.7.
842
+ * machine-measured emission of the same fact. Measured 2026-10-05 against this
843
+ * repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
844
+ * file has been rotated since the 4404-record 2026-07-31 reading this note
845
+ * used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
846
+ * `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
847
+ *
848
+ * `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
849
+ * `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
850
+ * sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
851
+ * 37 of the same 82 records carry it
852
+ * (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
853
+ * Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
854
+ * `counts` by `wave_number` first and keeps the STATE.md header only as a
855
+ * fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
856
+ * gate record only when it carries both. THIS emitter still sets no
857
+ * `wave_number` — its records are mid-wave auto-fix retries, so they fall to
858
+ * that fallback by design. A record without the key could only be attributed
859
+ * to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
860
+ * / `completed_at`) are themselves LLM-written, against the posture
861
+ * `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
862
+ * events ("a contaminated window means gate-attribution is unsafe").
858
863
  *
859
864
  * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
860
865
  * true` (default `false`, and `false` in this repo's Session Config). The gate
@@ -57,9 +57,10 @@
57
57
  * (absence reads as `'written'`, the only outcome that existed then).
58
58
  */
59
59
 
60
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
61
- import { dirname, isAbsolute, join } from 'node:path';
62
- import { createHash, randomBytes } from 'node:crypto';
60
+ import { readFileSync } from 'node:fs';
61
+ import { isAbsolute, join } from 'node:path';
62
+ import { createHash } from 'node:crypto';
63
+ import { atomicWriteWithBackup } from '../io.mjs';
63
64
 
64
65
  /** Default repo-relative location of the reconcile-candidate work-queue. */
65
66
  export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
@@ -159,18 +160,16 @@ function readStore(absPath) {
159
160
  * @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
160
161
  */
161
162
  function writeStore(absPath, records) {
163
+ let content;
162
164
  try {
163
- const dir = dirname(absPath);
164
- mkdirSync(dir, { recursive: true });
165
165
  const body = records.map((r) => JSON.stringify(r)).join('\n');
166
- const content = records.length > 0 ? body + '\n' : '';
167
- const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
168
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
169
- renameSync(tmpFile, absPath);
170
- return { ok: true, lines: records.length };
166
+ content = records.length > 0 ? body + '\n' : '';
171
167
  } catch (err) {
172
168
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
173
169
  }
170
+ // mkdir -p + tmp + rename; its failure envelope is this function's own.
171
+ const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
172
+ return res.ok ? { ok: true, lines: records.length } : res;
174
173
  }
175
174
 
176
175
  /**
@@ -146,6 +146,7 @@ import { join, resolve, sep } from 'node:path';
146
146
  import { atomicWriteWithBackup } from '../io.mjs';
147
147
  import { readLearnings } from '../learnings/io.mjs';
148
148
  import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
149
+ import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
149
150
  import { markCandidateProcessed } from './idempotency.mjs';
150
151
 
151
152
  /** Repo-relative learnings store — the only source of a per-entry date. */
@@ -290,6 +291,18 @@ export function parseConsolidatedRule(content) {
290
291
  break;
291
292
  }
292
293
  }
294
+ // The VALUE is taken the loader's way (quote-stripped): a hand-edited
295
+ // `"2026-06-01"` would otherwise reach Date.parse with its quotes and be read
296
+ // as LOCAL midnight — up to 26 h before the loader stops injecting the rule.
297
+ // The line index above stays the rewrite anchor.
298
+ if (expiresAtLine >= 0) {
299
+ try {
300
+ const loaderValue = parseGlobsFrontmatter(String(content ?? '')).meta['expires-at'];
301
+ if (typeof loaderValue === 'string') expiresAt = loaderValue;
302
+ } catch {
303
+ // frontmatter the loader rejects — keep the raw value (fail-open downstream)
304
+ }
305
+ }
293
306
 
294
307
  const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
295
308
  for (let i = bodyStart; i < lines.length; i += 1) {
@@ -403,12 +416,23 @@ function dateOnly(iso) {
403
416
  * `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
404
417
  * rather than a guess.
405
418
  *
419
+ * One exclusion: a markers-only pair whose date lies before `cutoffMs` is
420
+ * dropped. Its substance is already gone, so its elapsed date protects nothing
421
+ * — yet left in, it pins the raise target at or below a header that has already
422
+ * passed, and `rule-loader.mjs` excludes the whole file forever (live
423
+ * 2026-10-04: `guard-design.md` header 2026-10-04 pinned by markers-only
424
+ * `d86c9b5f-…`, next absorbed date 2026-10-07). The pair itself stays in the
425
+ * file and in `N` — it is still a dedupe marker. Expired SUBSTANTIVE pairs stay
426
+ * in the population: raising past one would revive prose not yet swept.
427
+ *
406
428
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
407
429
  * @param {Map<string, string>} expiryById
430
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
408
431
  * @returns {string|null}
409
432
  */
410
- function earliestResolvableDate(parsed, expiryById) {
433
+ function earliestResolvableDate(parsed, expiryById, cutoffMs) {
411
434
  const dates = parsed.pairs
435
+ .filter((p) => !p.markersOnly || !(Date.parse(expiryById.get(p.id) ?? '') < cutoffMs))
412
436
  .map((p) => expiryById.get(p.id))
413
437
  .filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
414
438
  .sort();
@@ -423,15 +447,20 @@ function earliestResolvableDate(parsed, expiryById) {
423
447
  * outlives its content is never lowered here — see the module header for why
424
448
  * the two directions are not symmetric.
425
449
  *
450
+ * Already-expired markers-only pairs do not hold the target down (see
451
+ * {@link earliestResolvableDate}); they stay in the file as dedupe markers and
452
+ * keep counting toward `N`.
453
+ *
426
454
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
427
455
  * @param {Map<string, string>} expiryById
456
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
428
457
  * @returns {string|null}
429
458
  */
430
- function headerRaiseTarget(parsed, expiryById) {
459
+ function headerRaiseTarget(parsed, expiryById, cutoffMs) {
431
460
  if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
432
461
  const headerMs = Date.parse(parsed.expiresAt);
433
462
  if (!Number.isFinite(headerMs)) return null;
434
- const earliest = earliestResolvableDate(parsed, expiryById);
463
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
435
464
  if (earliest === null) return null;
436
465
  const earliestMs = Date.parse(earliest);
437
466
  if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
@@ -450,15 +479,20 @@ function headerRaiseTarget(parsed, expiryById) {
450
479
  *
451
480
  * Deliberately NOT the `newExpiresAt` population: that one excludes expired
452
481
  * pairs so a rewritten file is not instantly expired again. This is a
453
- * diagnostic about the file AS COMMITTED, so it excludes nothing.
482
+ * diagnostic about the file AS COMMITTED, so it excludes nothing — except an
483
+ * already-expired markers-only pair, the same exclusion the raise target makes.
484
+ * Without it, every file the raise (or an expired-entries rewrite) moved past
485
+ * such a pair would carry a permanent advisory naming the elapsed date, i.e.
486
+ * an instruction to lower the header back to where the loader drops the file.
454
487
  *
455
488
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
456
489
  * @param {Map<string, string>} expiryById
490
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
457
491
  * @returns {string|undefined} undefined when there is nothing to report
458
492
  */
459
- function headerAdvisory(parsed, expiryById) {
493
+ function headerAdvisory(parsed, expiryById, cutoffMs) {
460
494
  if (!parsed.expiresAt) return undefined;
461
- const earliest = earliestResolvableDate(parsed, expiryById);
495
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
462
496
  if (earliest === null || earliest === parsed.expiresAt) return undefined;
463
497
  return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
464
498
  }
@@ -587,8 +621,63 @@ export async function planRuleExpirySweep(opts = {}) {
587
621
  // most likely to carry a stale header, `test-hygiene.md` among them — could
588
622
  // never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
589
623
  // where 6 of 7 have the discrepancy.
590
- const advisory = headerAdvisory(parsed, expiryById);
591
- const raiseTo = headerRaiseTarget(parsed, expiryById);
624
+ const advisory = headerAdvisory(parsed, expiryById, cutoffMs);
625
+ const raiseTo = headerRaiseTarget(parsed, expiryById, cutoffMs);
626
+
627
+ if (
628
+ parsed.counterLine === -1 &&
629
+ parsed.entries.length === 0 &&
630
+ parsed.pairs.length === 1 &&
631
+ substantive.length === 1 &&
632
+ parsed.expiresAt !== null &&
633
+ ruleExpiryInstantMs(parsed.expiresAt) !== null
634
+ ) {
635
+ // SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
636
+ // no counter sentence — the shape the reconciliation engine writes per
637
+ // learning in consumer repos (measured 2026-10-03: all 21 generated rules
638
+ // of the Meta-Vault). The fail-closed branch below skipped every one of
639
+ // them as `no-counter-sentence`, so they stayed on disk forever after
640
+ // expiry. Here the date is unambiguous — the file's own frontmatter
641
+ // `expires-at`, the one `rule-loader.mjs` acts on — and there is no
642
+ // body sentence to keep in agreement. The later of that date and the
643
+ // learning's store `expires_at` (when it resolves) decides, so a
644
+ // re-stamped learning never loses its rule. Expired → the same `delete`
645
+ // as a fully expired consolidated file: the pair is stamped terminal
646
+ // before the unlink, so `/reconcile` does not re-propose it.
647
+ const pair = substantive[0];
648
+ const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
649
+ // The file's own `expires-at` goes through the loader's predicate: a bare
650
+ // date is inclusive (#1521), so the file is not deleted while
651
+ // `rule-loader.mjs` still injects it on its last day.
652
+ const fileAt = ruleExpiryInstantMs(parsed.expiresAt);
653
+ const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
654
+ const base = {
655
+ file: rule.file,
656
+ expiredPairIds: [],
657
+ keptPairIds: [pair.id],
658
+ unresolvedPairIds: expiryById.has(pair.id) ? [] : [pair.id],
659
+ bytesBefore: Buffer.byteLength(content, 'utf8'),
660
+ headings: 0,
661
+ substantivePairs: 1,
662
+ };
663
+ if (effectiveAt <= cutoffMs) {
664
+ plans.push({
665
+ ...base,
666
+ expiredPairIds: [pair.id],
667
+ keptPairIds: [],
668
+ unresolvedPairIds: [],
669
+ action: 'delete',
670
+ reason: 'single-entry-expired',
671
+ newExpiresAt: null,
672
+ newAbsorbedCount: 0,
673
+ bytesAfter: 0,
674
+ stampKeys: [pair.key],
675
+ });
676
+ } else {
677
+ plans.push({ ...base, action: 'keep', newExpiresAt: null, newAbsorbedCount: 1, bytesAfter: base.bytesBefore });
678
+ }
679
+ continue;
680
+ }
592
681
 
593
682
  if (parsed.counterLine === -1) {
594
683
  // FAIL-CLOSED (GH#70). Every write this module performs moves the
@@ -39,9 +39,10 @@
39
39
  * pushes an error string.
40
40
  *
41
41
  * Atomic write strategy (rule files):
42
- * - Write content to `<target>.XXXXXXXX.tmp` via `writeFileSync`, then
43
- * `renameSync` over the final path. Same-filesystem rename is atomic on POSIX,
44
- * so the rule file is never partially visible. Mirrors idempotency.mjs pattern.
42
+ * - `atomicWriteWithBackup` (`../io.mjs`): content goes to a sibling
43
+ * `.<file>.tmp.<hex>`, then is renamed over the final path. Same-filesystem
44
+ * rename is atomic on POSIX, so the rule file is never partially visible.
45
+ * Mirrors idempotency.mjs pattern.
45
46
  *
46
47
  * JSONL append strategy (rejected log):
47
48
  * - `appendFileSync` for the rejected log — each record is a self-contained line,
@@ -63,17 +64,16 @@ import {
63
64
  writeFileSync,
64
65
  readFileSync,
65
66
  readdirSync,
66
- renameSync,
67
67
  appendFileSync,
68
68
  realpathSync,
69
69
  rmSync,
70
70
  statSync,
71
71
  } from 'node:fs';
72
- import { randomBytes } from 'node:crypto';
73
72
  import { tmpdir } from 'node:os';
74
73
  import path from 'node:path';
75
74
 
76
75
  import { withFileLock } from '../file-lock.mjs';
76
+ import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
77
77
  import { validatePathInsideProject } from '../path-utils.mjs';
78
78
  import { parseGlobsFrontmatter } from '../rule-loader.mjs';
79
79
  import { markCandidateProcessed } from './idempotency.mjs';
@@ -308,24 +308,18 @@ function resolveDest(item, target, prep, roots, errors) {
308
308
  }
309
309
 
310
310
  /**
311
- * Write `content` to `destPath` atomically via tmp+rename.
311
+ * Write `content` to `destPath` atomically via tmp+rename
312
+ * (`atomicWriteWithBackup`, random tmp suffix, tmp unlinked on failure).
312
313
  *
313
- * Uses a random 8-hex-char suffix for the tmp file to avoid collisions when
314
- * multiple proposals write to the same directory concurrently (defensive;
315
- * under the lock this should not happen, but the pattern is cheap).
316
- *
317
- * Throws on filesystem errors — callers must catch.
314
+ * Throws on filesystem errors — callers must catch. The helper returns an
315
+ * envelope; `envelopeToError` re-raises it with the same message and `code`.
318
316
  *
319
317
  * @param {string} destPath - absolute path of the target rule file.
320
318
  * @param {string} content - UTF-8 text content to write.
321
319
  */
322
320
  function writeTextAtomic(destPath, content) {
323
- const dir = path.dirname(destPath);
324
- mkdirSync(dir, { recursive: true });
325
- const suffix = randomBytes(4).toString('hex');
326
- const tmpPath = `${destPath}.${suffix}.tmp`;
327
- writeFileSync(tmpPath, content, 'utf8');
328
- renameSync(tmpPath, destPath);
321
+ const res = atomicWriteWithBackup(destPath, content, { tmpPrefix: `.${path.basename(destPath)}.tmp` });
322
+ if (!res.ok) throw envelopeToError(res);
329
323
  }
330
324
 
331
325
  /**
@@ -48,8 +48,9 @@
48
48
  * Deterministic gating (issue #694) runs after a successful frontmatter parse
49
49
  * and applies to BOTH always-on and glob-matched candidate rules — a rule must
50
50
  * pass ALL active gates to be included:
51
- * - Expiry: a rule with a parseable `expires-at` strictly before `now` is
52
- * EXCLUDED (with a mandatory stderr WARN). A malformed `expires-at` never
51
+ * - Expiry: a rule whose parseable `expires-at` has passed is EXCLUDED (with
52
+ * a mandatory stderr WARN). A bare date is inclusive — the rule loads
53
+ * through that whole UTC day ({@link ruleExpiryInstantMs}, #1521). A malformed `expires-at` never
53
54
  * excludes (fail-open).
54
55
  * - Mode-gating: when the `mode` param is non-null and the rule declares a
55
56
  * `mode` that differs, the rule is EXCLUDED. A null `mode` param disables
@@ -411,16 +412,41 @@ function metaToEntryFields(meta) {
411
412
  * @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
412
413
  * @param {number} [now] - epoch ms; defaults to `Date.now()`
413
414
  * @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
414
- * @returns {boolean} true only when a PARSEABLE `expires-at` lies before `now`
415
+ * @returns {boolean} true only when `now` has reached a PARSEABLE `expires-at`'s {@link ruleExpiryInstantMs}
415
416
  */
416
417
  export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
417
418
  if (rawExpiry === undefined || rawExpiry === null) return false;
418
- const ts = Date.parse(String(rawExpiry));
419
- if (Number.isNaN(ts)) {
419
+ const ts = ruleExpiryInstantMs(rawExpiry);
420
+ if (ts === null) {
420
421
  if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
421
422
  return false;
422
423
  }
423
- return ts < now;
424
+ return ts <= now;
425
+ }
426
+
427
+ const BARE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
428
+ const MS_PER_DAY = 86_400_000;
429
+
430
+ /**
431
+ * The instant (epoch ms) from which a rule with this `expires-at` counts as
432
+ * expired, or `null` when the value is absent or unparseable.
433
+ *
434
+ * `expires-at` is INCLUSIVE (#1521): a bare `YYYY-MM-DD` keeps the rule live
435
+ * through the whole of that UTC day, so the instant is the FOLLOWING UTC
436
+ * midnight. A full timestamp is taken as written. Every consumer that judges
437
+ * "expired" — the loader gate above, `instruction-budget-guard.mjs`
438
+ * (maintenance probe S7) and `reconcile/rule-expiry-sweep.mjs` — goes through
439
+ * this one function so the three never disagree about the boundary day.
440
+ *
441
+ * @param {unknown} rawExpiry
442
+ * @returns {number|null}
443
+ */
444
+ export function ruleExpiryInstantMs(rawExpiry) {
445
+ if (rawExpiry === undefined || rawExpiry === null) return null;
446
+ const text = String(rawExpiry).trim();
447
+ const ts = Date.parse(text);
448
+ if (Number.isNaN(ts)) return null;
449
+ return BARE_DATE_RE.test(text) ? ts + MS_PER_DAY : ts;
424
450
  }
425
451
 
426
452
  /**
@@ -420,7 +420,8 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
420
420
  * @param {number} params.wave
421
421
  * @param {string} params.stateDir
422
422
  * @param {string} [params.session] semantic (or raw) session id to filter on
423
- * @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl`
423
+ * @param {string} [params.eventsPath] defaults to the cwd-relative `.orchestrator/metrics/events.jsonl`;
424
+ * the CLI passes `eventsFilePath(<state-dir>/..)` (the repo's one ledger, #1514)
424
425
  * @param {typeof readFileSync} [params.readFile]
425
426
  * @param {typeof readdirSync} [params.readDir]
426
427
  * @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
@@ -639,11 +640,20 @@ async function mainVerify(args) {
639
640
  return 1;
640
641
  }
641
642
 
643
+ // #1514 point 1 — read the ledger the send-side hook WRITES: the one ledger
644
+ // of the repo this state dir belongs to (`eventsFilePath(<root>)`, a linked
645
+ // worktree mapped to its main checkout), not a cwd-relative path that, run
646
+ // from an entered worktree, found 0 `scope_checked` records. Resolved here,
647
+ // not in `verifyWaveScope`, so this module stays pure (stdlib +
648
+ // `crypto-digest-utils.mjs`) for the hook that imports it.
649
+ const eventsPath = typeof args.events === 'string'
650
+ ? args.events
651
+ : (await import('./events.mjs')).eventsFilePath(resolve(stateDir, '..'));
642
652
  const report = verifyWaveScope({
643
653
  wave,
644
654
  stateDir,
645
655
  session: typeof args.session === 'string' ? args.session : undefined,
646
- eventsPath: typeof args.events === 'string' ? args.events : undefined,
656
+ eventsPath,
647
657
  });
648
658
 
649
659
  if (args.emit) {
@@ -405,7 +405,8 @@ export function testSiblingExpansionApplies(opts = {}) {
405
405
  * tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
406
406
  * measuring command and the SHA — live in ONE place:
407
407
  * skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
408
- * Expansion (#970). Cite and re-measure them there, never restate them here: an
408
+ * Expansion (#970), measured by `scripts/lib/test-sibling-coverage.mjs` (#1030 P3).
409
+ * Cite and re-measure them there, never restate them here: an
409
410
  * undated copy drifted to a second, contradicting set of numbers (#1026.5,
410
411
  * PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
411
412
  * to files that may not exist. A computed concrete path is wrong more often AND
@@ -66,7 +66,7 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
66
66
  import { canonicalizeSessions } from './sessions-canonical.mjs';
67
67
  // Already in the hook import set (`emitEvent` lives there): the rotation-aware
68
68
  // reader adds no new file to the SessionStart/SessionEnd hook graph.
69
- import { scanEventsBackwards } from './events.mjs';
69
+ import { eventsFilePath, scanEventsBackwards } from './events.mjs'; // eventsFilePath: #1514, the repo's one ledger
70
70
  // Leaf constants module (no imports of its own) and ALREADY in the hook import
71
71
  // set via session-schema/validator.mjs — importing it here adds no new file to
72
72
  // the SessionStart/SessionEnd hook graph. The profile enum must not be
@@ -114,10 +114,25 @@ const EVENT_STOPPED = 'orchestrator.session.stopped';
114
114
  const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
115
115
  const EVENT_ENDED = 'orchestrator.session.ended';
116
116
 
117
- const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
118
117
  const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
119
118
  const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
120
119
 
120
+ /**
121
+ * Which outcomes reach the side-log (#1513). Only EVENTS — `backfilled`,
122
+ * `would-backfill`, `error` and any other non-skip action — are written; every
123
+ * `skipped-*` outcome is the steady state of a session close and stays out.
124
+ * Measured in the Meta-Vault 2026-10-03: 5.786 lines / 723 KB, 86 %
125
+ * `skipped-already-recorded`, one commit-worthy diff per close. The skips lose
126
+ * nothing: on-session-end emits every outcome, skips included, as a queryable
127
+ * event (#1068 AC2), and an injected `log` callback still receives them all.
128
+ *
129
+ * @param {unknown} action
130
+ * @returns {boolean}
131
+ */
132
+ export function isLoggedBackfillAction(action) {
133
+ return typeof action === 'string' && !action.startsWith('skipped-');
134
+ }
135
+
121
136
  // ---------------------------------------------------------------------------
122
137
  // Helpers
123
138
  // ---------------------------------------------------------------------------
@@ -957,7 +972,7 @@ export async function backfillAbandonedSession({
957
972
  return { action: 'skipped-no-identifier' };
958
973
  }
959
974
 
960
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
975
+ const eventsPath = eventsFilePath(repoRoot);
961
976
  const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
962
977
 
963
978
  // -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
@@ -1169,6 +1184,7 @@ export async function backfillAbandonedSession({
1169
1184
  return;
1170
1185
  }
1171
1186
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
1187
+ if (!isLoggedBackfillAction(res.action)) return;
1172
1188
  const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
1173
1189
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
1174
1190
  fs.appendFileSync(
@@ -1351,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
1351
1367
  if (supersedes) recordId = supersedes;
1352
1368
 
1353
1369
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
1354
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
1370
+ const eventsPath = eventsFilePath(repoRoot);
1355
1371
  const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
1356
1372
 
1357
1373
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
@@ -1432,6 +1448,7 @@ export async function backfillCompletedFromStateMd({
1432
1448
  return;
1433
1449
  }
1434
1450
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
1451
+ if (!isLoggedBackfillAction(res.action)) return;
1435
1452
  const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
1436
1453
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
1437
1454
  fs.appendFileSync(