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
@@ -21,12 +21,18 @@
21
21
  * expired lease is `none`, a lease that cannot be read or validated is
22
22
  * `unreadable`, and neither is ever `active`.
23
23
  *
24
+ * An auflagen file is keyed by the semantic session id, which collides across
25
+ * repos (`main-<date>-session-N`), so it is only accepted when its mandatory
26
+ * `repo` field resolves to the reader's own git common dir ({@link readAuflagen},
27
+ * #1520 / navigator#190).
28
+ *
24
29
  * Session ids become file names, so {@link isSafeSessionId} is the security
25
30
  * boundary between check-in input and the filesystem: every path builder that
26
31
  * takes a session id refuses an unsafe one with a TypeError.
27
32
  */
28
33
 
29
- import { promises as fs } from 'node:fs';
34
+ import { execFileSync } from 'node:child_process';
35
+ import fsSync, { promises as fs } from 'node:fs';
30
36
  import os from 'node:os';
31
37
  import path from 'node:path';
32
38
 
@@ -286,3 +292,94 @@ export async function readNavigatorLease({ now = Date.now(), env, home } = {}) {
286
292
  if (Date.parse(lease.laeuft_ab) <= now) return { state: 'none' };
287
293
  return { state: 'active', lease };
288
294
  }
295
+
296
+ /** Timeout for the `git rev-parse` calls in {@link readAuflagen}. */
297
+ const GIT_TIMEOUT_MS = 5_000;
298
+
299
+ /**
300
+ * Absolute, realpath'd git common dir of `dir` — the same value for a main tree,
301
+ * any of its linked worktrees, and the `.git` directory itself — or null when
302
+ * `dir` is not inside a git repository (or git is unavailable).
303
+ * @param {string} dir
304
+ * @returns {string|null}
305
+ */
306
+ function gitCommonDir(dir) {
307
+ try {
308
+ const out = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
309
+ env: gitEnv(),
310
+ encoding: 'utf8',
311
+ stdio: ['ignore', 'pipe', 'ignore'],
312
+ timeout: GIT_TIMEOUT_MS,
313
+ }).trim();
314
+ if (!out) return null;
315
+ return realpathSyncSafe(out);
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The only environment variables forwarded to `git`. An inherited `GIT_DIR`
323
+ * outranks `-C`, so both sides of the repo comparison would resolve to the
324
+ * same directory and a foreign repo's Auflagen would be ACCEPTED. Same
325
+ * allowlist as `git-config-drift.mjs` / `check-banner-parity.mjs`.
326
+ */
327
+ const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
328
+
329
+ function gitEnv() {
330
+ /** @type {Record<string, string>} */
331
+ const env = {};
332
+ for (const k of GIT_ENV_ALLOWLIST) if (process.env[k] !== undefined) env[k] = /** @type {string} */ (process.env[k]);
333
+ return env;
334
+ }
335
+
336
+ /** @param {string} p */
337
+ function realpathSyncSafe(p) {
338
+ try {
339
+ return fsSync.realpathSync(p);
340
+ } catch {
341
+ return path.resolve(p);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Read this session's conditions file and accept it only when it belongs to the
347
+ * reader's repository. Never throws; fail-closed.
348
+ *
349
+ * The file's mandatory `repo` field (an absolute path to the target repo's
350
+ * toplevel or its git common dir, written by the navigator) and `repoRoot` are
351
+ * both resolved to their git common dir, so a session in a linked worktree
352
+ * matches a file naming the main tree. Every `rejected` result means: behave as
353
+ * if no file existed (Standard-Auflagen apply).
354
+ *
355
+ * @param {string} sessionId
356
+ * @param {{ repoRoot?: string, env?: Record<string, string|undefined>, home?: string }} [opts]
357
+ * @returns {Promise<
358
+ * | { state: 'absent' }
359
+ * | { state: 'accepted', auflagen: Record<string, unknown> }
360
+ * | { state: 'rejected', reason: 'unsafe-session-id'|'unreadable'|'invalid-json'|'repo-missing'|'repo-unresolved'|'repo-mismatch' }
361
+ * >}
362
+ */
363
+ export async function readAuflagen(sessionId, { repoRoot = process.cwd(), env, home } = {}) {
364
+ if (!isSafeSessionId(sessionId)) return { state: 'rejected', reason: 'unsafe-session-id' };
365
+ let raw;
366
+ try {
367
+ raw = await fs.readFile(auflagenPath(sessionId, { env, home }), 'utf8');
368
+ } catch (err) {
369
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return { state: 'absent' };
370
+ return { state: 'rejected', reason: 'unreadable' };
371
+ }
372
+ let auflagen;
373
+ try {
374
+ auflagen = JSON.parse(raw);
375
+ } catch {
376
+ return { state: 'rejected', reason: 'invalid-json' };
377
+ }
378
+ if (!isPlainObject(auflagen)) return { state: 'rejected', reason: 'invalid-json' };
379
+ const fileRepo = auflagen.repo;
380
+ if (typeof fileRepo !== 'string' || !path.isAbsolute(fileRepo)) return { state: 'rejected', reason: 'repo-missing' };
381
+ const own = gitCommonDir(repoRoot);
382
+ if (!own) return { state: 'rejected', reason: 'repo-unresolved' };
383
+ if (gitCommonDir(fileRepo) !== own) return { state: 'rejected', reason: 'repo-mismatch' };
384
+ return { state: 'accepted', auflagen };
385
+ }
@@ -88,11 +88,13 @@
88
88
 
89
89
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
90
90
  import { basename, join } from 'node:path';
91
- import { scanEventsBackwards } from './events.mjs';
91
+ import { eventsFilePath, scanEventsBackwards } from './events.mjs';
92
92
  import {
93
93
  FRONTMATTER_NOT_AT_TOP,
94
94
  loadApplicableRules,
95
+ isRuleExpired,
95
96
  parseGlobsFrontmatter,
97
+ ruleExpiryInstantMs,
96
98
  } from './rule-loader.mjs';
97
99
 
98
100
  /** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
@@ -846,11 +848,11 @@ const MS_PER_DAY = 86_400_000;
846
848
  * declares no expiry or an unparseable one.
847
849
  *
848
850
  * Negative ⇒ already expired. The comparison basis is deliberately the SAME one
849
- * `rule-loader.mjs` `applyGates` uses — `Date.parse(expires-at) < now`, i.e. a
850
- * bare `YYYY-MM-DD` is UTC midnight — so "expired" here means exactly "the
851
- * loader already filters it out of every wave's rule set", never a second
852
- * definition of the same word. `Math.floor` therefore returns `-1` for a rule
853
- * that expired at midnight today: past, but by less than a day.
851
+ * `rule-loader.mjs` `applyGates` uses — `ruleExpiryInstantMs`, where a bare
852
+ * `YYYY-MM-DD` is INCLUSIVE and expires at the FOLLOWING UTC midnight (#1521) —
853
+ * so "expired" here means exactly "the loader already filters it out of every
854
+ * wave's rule set", never a second definition of the same word. `0` therefore
855
+ * means "last day: still loads today", `-1` "dropped at the last midnight".
854
856
  *
855
857
  * An unparseable date returns `null` (fail-OPEN, matching the loader, which
856
858
  * warns and ignores the expiry rather than excluding the file) — the malformed
@@ -863,17 +865,18 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
863
865
  const meta = opts.meta;
864
866
  if (!meta || typeof meta !== 'object') return null;
865
867
  if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return null;
866
- const expiresAt = Date.parse(String(meta['expires-at']));
867
- if (!Number.isFinite(expiresAt)) return null;
868
+ const expiresAt = ruleExpiryInstantMs(meta['expires-at']);
869
+ if (expiresAt === null) return null;
868
870
 
869
- const raw = opts.now;
870
- let now;
871
- if (raw instanceof Date) now = raw.getTime();
872
- else if (typeof raw === 'number' && Number.isFinite(raw)) now = raw;
873
- else if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) now = Date.parse(raw);
874
- else now = Date.now();
871
+ return Math.floor((expiresAt - nowMs(opts.now)) / MS_PER_DAY);
872
+ }
875
873
 
876
- return Math.floor((expiresAt - now) / MS_PER_DAY);
874
+ /** `now` as epoch ms: Date, number or parseable string; anything else → the process clock. */
875
+ function nowMs(raw) {
876
+ if (raw instanceof Date) return raw.getTime();
877
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
878
+ if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) return Date.parse(raw);
879
+ return Date.now();
877
880
  }
878
881
 
879
882
  /**
@@ -888,8 +891,12 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
888
891
  * @returns {boolean}
889
892
  */
890
893
  export function isExpiredGeneratedRule(opts = {}) {
891
- const days = daysUntilGeneratedRuleExpiry(opts);
892
- return days !== null && days < 0;
894
+ const meta = opts.meta;
895
+ if (!meta || typeof meta !== 'object') return false;
896
+ if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return false;
897
+ // The loader's own predicate, not `days < 0`: flooring whole days would call
898
+ // the rule live for the first millisecond after the boundary (#1521).
899
+ return isRuleExpired(meta['expires-at'], nowMs(opts.now));
893
900
  }
894
901
 
895
902
  /**
@@ -1335,7 +1342,7 @@ const RECONCILE_SCAN_BUDGET_MS = 150;
1335
1342
  */
1336
1343
  function readLastReconcileCapped(repoRoot) {
1337
1344
  if (typeof repoRoot !== 'string' || repoRoot === '') return null;
1338
- const filePath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
1345
+ const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
1339
1346
  let capped = null;
1340
1347
  let scan;
1341
1348
  try {
@@ -16,7 +16,7 @@
16
16
  * #457; writeJsonAtomicSync extracted for #558 M1.
17
17
  */
18
18
 
19
- import { writeFile, rename, mkdir } from 'node:fs/promises';
19
+ import { writeFile, rename, mkdir, copyFile, unlink } from 'node:fs/promises';
20
20
  import {
21
21
  mkdirSync,
22
22
  writeFileSync,
@@ -888,7 +888,10 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
888
888
  * @param {object} [opts]
889
889
  * @param {number} [opts.indent=2] JSON.stringify indent.
890
890
  * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
891
- * @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string }}
891
+ * @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string, code: string|null }}
892
+ * `code` is the failing call's `err.code` (`'ENOSPC'`, `'EACCES'`, …), or `null`
893
+ * when the error carried none (a serialization TypeError, the partial-adapter
894
+ * refusal). Additive (#1032): `error` keeps its exact text.
892
895
  */
893
896
  export function writeJsonAtomicSync(filePath, data, opts = {}) {
894
897
  const { indent = 2, tmpPrefix = '.tmp' } = opts;
@@ -897,7 +900,7 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
897
900
  try {
898
901
  body = JSON.stringify(data, null, indent) + '\n';
899
902
  } catch (err) {
900
- return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
903
+ return _fsErrorEnvelope(err);
901
904
  }
902
905
 
903
906
  const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
@@ -941,26 +944,32 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
941
944
  * envelope is returned. Without that, every failed write leaves a
942
945
  * `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
943
946
  * so the litter accumulates in the operator's vault directory. The cleanup is
944
- * attempted only when the write actually created the tmp file, and its own
945
- * failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
946
- * neither may mask the original error.
947
+ * attempted once the tmp path has been chosen — including a write that created
948
+ * the file and then failed mid-way — and its own failure is swallowed: a leaked
949
+ * tmp is worse than a silent unlink miss, and neither may mask the original
950
+ * error.
947
951
  *
948
952
  * ── 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
953
+ * THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
954
+ * `rg -n "atomicWriteWithBackup\(" scripts hooks skills --glob '*.mjs'`):
955
+ * `vault-status/board-writer.mjs#writeBoard` (`backup: false`),
956
+ * `reconcile/rule-expiry-sweep.mjs` (no `backup`, i.e. `false`) and
957
+ * {@link writeJsonAtomicSync}, which carries 14 further call-sites across 11
958
+ * files behind it (`rg -n "writeJsonAtomicSync\("` over the same scope, minus
959
+ * the definition and `host-identity.mjs`'s private `_writeJsonAtomicSync`
960
+ * copy, which does NOT route through this helper). That last one is the load-bearing
952
961
  * evidence — the previous revision of this note recorded ONE call-site and
953
962
  * concluded the signature was unproven, while the function with an identical
954
963
  * body sat 60 lines above in this same file, unmigrated. The cheapest possible
955
964
  * migration going unmade is not a neutral fact about a helper: it is the
956
965
  * measurement that the helper is not paying for itself.
957
966
  *
958
- * What the second call-site does NOT prove: `writeJsonAtomicSync` passes
967
+ * What the `writeJsonAtomicSync` call-site does NOT prove: it passes
959
968
  * `backup: false` and no `fs`, so the backup half and the injection seam still
960
969
  * rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
961
970
  * migrates the remaining hand-rolled sites, re-check before widening:
962
- * (a) whether an `async` twin is needed rather than bolting a promise mode onto
963
- * this one (three known sites are `fs/promises`), and (b) whether rotation
971
+ * (a) answered (#1032): the async twin is {@link atomicWriteText}, not a
972
+ * promise mode on this function; and (b) whether rotation
964
973
  * belongs here after all (it does only if ≥2 migrated callers want the SAME
965
974
  * keep-N). If NO caller ever passes `backup: true` in production, that half is
966
975
  * still the part to shrink back.
@@ -977,7 +986,9 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
977
986
  * Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
978
987
  * the backup path, which fails closed (see below).
979
988
  * @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
980
- * | { ok: false, reason: 'fs-error', error: string }}
989
+ * | { ok: false, reason: 'fs-error', error: string, code: string|null }}
990
+ * `code` is the failing call's `err.code`, or `null` when there was none
991
+ * (additive, #1032 — a caller that must re-throw with the same code can now).
981
992
  */
982
993
  export function atomicWriteWithBackup(filePath, body, opts = {}) {
983
994
  const {
@@ -1011,20 +1022,15 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
1011
1022
  // methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
1012
1023
  // the real target and silently decides the backup branch from it, which is the
1013
1024
  // same escape one step earlier.
1014
- if (backup && injectedFs) {
1015
- for (const method of ['existsSync', 'copyFileSync']) {
1016
- if (typeof injectedFs[method] !== 'function') {
1017
- return {
1018
- ok: false,
1019
- reason: 'fs-error',
1020
- error: `partial fs adapter: ${method} required for backup`,
1021
- };
1022
- }
1023
- }
1024
- }
1025
+ const refusal = _partialAdapterRefusal(backup, injectedFs, ['existsSync', 'copyFileSync']);
1026
+ if (refusal) return refusal;
1025
1027
 
1028
+ // Assigned BEFORE the write, and cleanup keys on it rather than on the write
1029
+ // having returned: `writeFileSync` can create the tmp file and THEN throw
1030
+ // (ENOSPC / EIO mid-write), and a flag set only after it returned leaked
1031
+ // exactly that file (#1032). Unlinking a tmp that was never created throws
1032
+ // ENOENT, which the cleanup swallows.
1026
1033
  let tmpFile = null;
1027
- let tmpCreated = false;
1028
1034
 
1029
1035
  try {
1030
1036
  const dir = dirname(filePath);
@@ -1032,34 +1038,170 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
1032
1038
 
1033
1039
  let backupPath = null;
1034
1040
  if (backup && fsExists(filePath)) {
1035
- const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
1036
- backupPath = `${filePath}.bak-${stamp}`;
1041
+ backupPath = _backupPathFor(filePath, now);
1037
1042
  fsCopyFile(filePath, backupPath);
1038
1043
  }
1039
1044
 
1040
- tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
1045
+ tmpFile = _tmpPathFor(dir, tmpPrefix);
1041
1046
  fsWriteFile(tmpFile, body, encoding);
1042
- tmpCreated = true;
1043
1047
  fsRename(tmpFile, filePath);
1044
1048
 
1045
- return {
1046
- ok: true,
1047
- path: filePath,
1048
- bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
1049
- backupPath,
1050
- };
1049
+ return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
1051
1050
  } catch (err) {
1052
- // Only when the write got far enough to create it. The name carries 12 hex
1053
- // chars of entropy, so this cannot collide with a caller's real file.
1054
- if (tmpCreated) {
1051
+ // The name carries 12 hex chars of entropy, so this cannot collide with a
1052
+ // caller's real file.
1053
+ if (tmpFile !== null) {
1055
1054
  try {
1056
1055
  fsUnlink(tmpFile);
1057
1056
  } catch {
1058
1057
  // Best-effort: never let cleanup replace the error the caller needs.
1059
1058
  }
1060
1059
  }
1061
- return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
1060
+ return _fsErrorEnvelope(err);
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Async twin of {@link atomicWriteWithBackup} (#1032): same tmp naming
1066
+ * (`<dir>/<tmpPrefix>.<12 hex>`), same backup stamp, SAME envelope — and like
1067
+ * the sync helper it RESOLVES on failure and never rejects. A `promise` mode on
1068
+ * the sync function was rejected on purpose: one contract with two failure
1069
+ * directions (`ok:false` vs. reject) is the bug class this envelope exists to
1070
+ * end. Callers whose contract is to throw re-raise via {@link envelopeToError},
1071
+ * which keeps the message and `err.code`.
1072
+ *
1073
+ * Shared with the sync helper: tmp/backup naming, byte count, the error
1074
+ * envelope and the partial-adapter refusal. The five-step flow itself is
1075
+ * spelled twice — sync calls and awaited calls cannot share one body without a
1076
+ * generator driver, which would put an indirection on the hook-hot sync path
1077
+ * for ~15 lines of saved control flow.
1078
+ *
1079
+ * Backup differs in one detail: it copies and treats `ENOENT` as "first write,
1080
+ * nothing to snapshot" instead of probing `existsSync` first — the same result
1081
+ * without a check-then-act window.
1082
+ *
1083
+ * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
1084
+ * @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
1085
+ * @param {object} [opts]
1086
+ * @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
1087
+ * @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
1088
+ * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
1089
+ * @param {Date} [opts.now] Clock seam for the backup stamp (tests).
1090
+ * @param {{ mkdir?: Function, writeFile?: Function, rename?: Function,
1091
+ * copyFile?: Function, unlink?: Function }} [opts.fs]
1092
+ * Injectable `fs/promises`-shaped object (tests). Omitted methods fall back to
1093
+ * `node:fs/promises` — except `copyFile` under `backup: true`, which fails closed.
1094
+ * @returns {Promise<{ ok: true, path: string, bytes: number, backupPath: string|null }
1095
+ * | { ok: false, reason: 'fs-error', error: string, code: string|null }>}
1096
+ */
1097
+ export async function atomicWriteText(filePath, body, opts = {}) {
1098
+ const {
1099
+ encoding = 'utf8',
1100
+ backup = false,
1101
+ tmpPrefix = '.tmp',
1102
+ now = new Date(),
1103
+ fs: injectedFs,
1104
+ } = opts;
1105
+
1106
+ const fsMkdir = injectedFs?.mkdir ?? mkdir;
1107
+ const fsWriteFile = injectedFs?.writeFile ?? writeFile;
1108
+ const fsRename = injectedFs?.rename ?? rename;
1109
+ const fsCopyFile = injectedFs?.copyFile ?? copyFile;
1110
+ const fsUnlink = injectedFs?.unlink ?? unlink;
1111
+
1112
+ const refusal = _partialAdapterRefusal(backup, injectedFs, ['copyFile']);
1113
+ if (refusal) return refusal;
1114
+
1115
+ let tmpFile = null;
1116
+
1117
+ try {
1118
+ const dir = dirname(filePath);
1119
+ await fsMkdir(dir, { recursive: true });
1120
+
1121
+ let backupPath = null;
1122
+ if (backup) {
1123
+ const candidate = _backupPathFor(filePath, now);
1124
+ try {
1125
+ await fsCopyFile(filePath, candidate);
1126
+ backupPath = candidate;
1127
+ } catch (err) {
1128
+ if (err?.code !== 'ENOENT') throw err;
1129
+ }
1130
+ }
1131
+
1132
+ tmpFile = _tmpPathFor(dir, tmpPrefix);
1133
+ await fsWriteFile(tmpFile, body, encoding);
1134
+ await fsRename(tmpFile, filePath);
1135
+
1136
+ return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
1137
+ } catch (err) {
1138
+ if (tmpFile !== null) {
1139
+ try {
1140
+ await fsUnlink(tmpFile);
1141
+ } catch {
1142
+ // Best-effort: never let cleanup replace the error the caller needs.
1143
+ }
1144
+ }
1145
+ return _fsErrorEnvelope(err);
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Re-raise a failed {@link atomicWriteText} / {@link atomicWriteWithBackup}
1151
+ * envelope as an Error carrying the original message and `code`, for call-sites
1152
+ * whose own contract is to throw (#1032).
1153
+ *
1154
+ * @param {{ error: string, code?: string|null }} envelope
1155
+ * @returns {Error & { code?: string }}
1156
+ */
1157
+ export function envelopeToError(envelope) {
1158
+ const err = new Error(envelope.error);
1159
+ if (envelope.code) err.code = envelope.code;
1160
+ return err;
1161
+ }
1162
+
1163
+ /** @returns {{ ok: false, reason: 'fs-error', error: string, code: string|null }} */
1164
+ function _fsErrorEnvelope(err) {
1165
+ return {
1166
+ ok: false,
1167
+ reason: 'fs-error',
1168
+ error: err?.message ?? String(err),
1169
+ code: typeof err?.code === 'string' ? err.code : null,
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * Backup-path fail-closed for a partial injected adapter (see the rationale in
1175
+ * {@link atomicWriteWithBackup}). Returns the refusal envelope, or null.
1176
+ */
1177
+ function _partialAdapterRefusal(backup, injectedFs, methods) {
1178
+ if (!backup || !injectedFs) return null;
1179
+ for (const method of methods) {
1180
+ if (typeof injectedFs[method] !== 'function') {
1181
+ return {
1182
+ ok: false,
1183
+ reason: 'fs-error',
1184
+ error: `partial fs adapter: ${method} required for backup`,
1185
+ code: null,
1186
+ };
1187
+ }
1062
1188
  }
1189
+ return null;
1190
+ }
1191
+
1192
+ /** `<filePath>.bak-<ISO>` with `:`/`.` → `-`, so a lexical sort is chronological. */
1193
+ function _backupPathFor(filePath, now) {
1194
+ const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
1195
+ return `${filePath}.bak-${stamp}`;
1196
+ }
1197
+
1198
+ /** Sibling tmp path (same filesystem → atomic rename): `<dir>/<tmpPrefix>.<12 hex>`. */
1199
+ function _tmpPathFor(dir, tmpPrefix) {
1200
+ return path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
1201
+ }
1202
+
1203
+ function _byteLength(body, encoding) {
1204
+ return Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding);
1063
1205
  }
1064
1206
 
1065
1207
  /**
@@ -48,11 +48,11 @@
48
48
  * re-derived (not re-cited) if a threshold is ever revisited.
49
49
  *
50
50
  * **Stage 0 — corpus prep (once per run).**
51
- * 1. Read through the existing funnel (`io.mjs::readLearnings` →
52
- * `schema.mjs::normalizeLearning`) so producer dialects (`files` →
53
- * `file_paths`) and type aliases (`gotcha` → `anti-pattern`) are already
54
- * canonical. The in-memory entry point re-applies {@link normalizeDialects}
55
- * so both entry points score the same shape.
51
+ * 1. Canonicalise producer dialects (`files` → `file_paths`) and type
52
+ * aliases (`gotcha` → `anti-pattern`): the caller reads through the
53
+ * existing funnel (`io.mjs::readLearnings` → `schema.mjs::normalizeLearning`),
54
+ * and the entry point re-applies {@link normalizeDialects} so raw
55
+ * in-memory records score the same shape.
56
56
  * 2. Drop expired records — they may neither seed a pool nor join one.
57
57
  * 3. Derive `learning_key` = `` `${type}/${kebab(title || subject)}` `` using
58
58
  * the SHARED {@link kebab}. A divergent kebab does not produce an ugly
@@ -184,8 +184,7 @@
184
184
  */
185
185
 
186
186
  import { tokenize } from './affinity.mjs';
187
- import { readLearnings } from './io.mjs';
188
- import { kebab } from './kebab.mjs';
187
+ import { learningKeyOf } from './kebab.mjs';
189
188
  import { normalizeDialects } from './schema.mjs';
190
189
 
191
190
  // ---------------------------------------------------------------------------
@@ -218,7 +217,7 @@ export const PATH_BOOST_EXACT = 0.05;
218
217
  export const PATH_BOOST_DIR = 0.025;
219
218
 
220
219
  /** How many leading directory segments define the directory-prefix boost. */
221
- export const DIR_PREFIX_SEGMENTS = 2;
220
+ const DIR_PREFIX_SEGMENTS = 2;
222
221
 
223
222
  /**
224
223
  * Documented ceiling for consumers (accepted failure mode 2): a pool graph walk
@@ -229,7 +228,7 @@ export const DIR_PREFIX_SEGMENTS = 2;
229
228
  export const MAX_POOL_HOPS = 2;
230
229
 
231
230
  /** Token length floor — tokens shorter than this are dropped before stoplisting. */
232
- export const MIN_TOKEN_LENGTH = 3;
231
+ const MIN_TOKEN_LENGTH = 3;
233
232
 
234
233
  /** Cap on the reported `sharedTokens` — a diagnostic for the judge, not a payload. */
235
234
  const SHARED_TOKEN_CAP = 8;
@@ -441,27 +440,18 @@ export function emptyPools() {
441
440
  /**
442
441
  * Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
443
442
  *
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
443
+ * Returns `null` when either half is unusable (non-record input, blank
444
+ * type/title/subject, or an all-symbol title that kebabs to '') — delegates to
445
+ * the shared `kebab.mjs::learningKeyOf` (#1021) so the key space cannot fork
446
+ * from the provenance checker's. An unkeyable record simply does not
447
+ * participate in the exact-key pass. It still seeds and
447
448
  * joins pools: a missing title is not a reason to hide a learning from dedupe.
448
449
  *
449
450
  * @param {unknown} record
450
451
  * @returns {string|null}
451
452
  */
452
453
  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}`;
454
+ return learningKeyOf(record);
465
455
  }
466
456
 
467
457
  /**
@@ -714,23 +704,3 @@ export function buildCandidatePools(records, opts = {}) {
714
704
  return emptyPools();
715
705
  }
716
706
  }
717
-
718
- /**
719
- * File entry-point: read through the existing funnel (`readLearnings` →
720
- * `normalizeLearning`, so dialects and type aliases are canonical), then pool.
721
- *
722
- * There is no second reader here on purpose — a private read path would be a
723
- * second place for the dialect normalization to drift out of.
724
- *
725
- * @param {string} filePath — absolute path to learnings.jsonl
726
- * @param {object} [opts] — everything {@link buildCandidatePools} accepts
727
- * @returns {Promise<CandidatePools>} {@link emptyPools} on a missing/unreadable file
728
- */
729
- export async function buildCandidatePoolsFromFile(filePath, opts = {}) {
730
- try {
731
- const { entries } = await readLearnings(filePath);
732
- return buildCandidatePools(entries, opts);
733
- } catch {
734
- return emptyPools();
735
- }
736
- }
@@ -262,6 +262,11 @@ export async function readLearningsSnapshot(filePath) {
262
262
  * malformed too (#1500): `normalizeLearning` turned it into a record of
263
263
  * defaults alone, and every rewrite wrote that invented record back.
264
264
  *
265
+ * A UTF-8 BOM (U+FEFF) in front of the FIRST line — an editor's save — is
266
+ * stripped before parsing, so that line is an entry, not malformed (#1506.5b).
267
+ * A BOM inside the file (line 2+) stays malformed: that is a concatenation
268
+ * artefact the drop-malformed preview reports as `bom`.
269
+ *
265
270
  * @param {string} raw
266
271
  * @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
267
272
  * `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
@@ -273,7 +278,7 @@ export function parseLearningsText(raw) {
273
278
  raw.split('\n').forEach((line, i) => {
274
279
  if (line.trim().length === 0) return;
275
280
  try {
276
- const parsed = JSON.parse(line);
281
+ const parsed = JSON.parse(i === 0 && line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
277
282
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
278
283
  throw new TypeError('not a JSON object');
279
284
  }
@@ -414,7 +419,7 @@ const BACKUP_KEEP = 3;
414
419
  * @param {string} fileName — sibling filename to classify
415
420
  * @returns {string|null} the suffix after the delimiter, or `null`
416
421
  */
417
- export function backupSuffixOf(baseName, fileName) {
422
+ function backupSuffixOf(baseName, fileName) {
418
423
  const stem = `${baseName}.bak`;
419
424
  if (!fileName.startsWith(stem)) return null;
420
425
  const delim = fileName[stem.length];