session-orchestrator 3.20.0 → 3.21.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 (114) 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/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +414 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/docs/migration-v3.md +9 -6
  23. package/docs/persona-panel.md +3 -1
  24. package/docs/scope-collision-guard.md +167 -0
  25. package/hooks/enforce-scope.mjs +103 -3
  26. package/hooks/hooks-codex.json +1 -1
  27. package/hooks/hooks.json +11 -1
  28. package/hooks/on-session-end.mjs +52 -5
  29. package/hooks/on-stop.mjs +127 -12
  30. package/hooks/post-bash-write-verify.mjs +8 -32
  31. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  32. package/package.json +1 -1
  33. package/pi/prompts/release.md +12 -0
  34. package/scripts/autopilot.mjs +3 -1
  35. package/scripts/emit-session.mjs +42 -0
  36. package/scripts/export-hw-learnings.mjs +61 -2
  37. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  38. package/scripts/lib/backlog-scan.mjs +106 -15
  39. package/scripts/lib/build-live-signals.mjs +7 -3
  40. package/scripts/lib/ci-status-banner.mjs +207 -23
  41. package/scripts/lib/git-config-drift.mjs +471 -0
  42. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  43. package/scripts/lib/io.mjs +193 -7
  44. package/scripts/lib/learnings/select.mjs +157 -3
  45. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  46. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  47. package/scripts/lib/named-vault-resolver.mjs +105 -16
  48. package/scripts/lib/peer-cards/schema.mjs +6 -2
  49. package/scripts/lib/reconcile/writer.mjs +69 -30
  50. package/scripts/lib/redact-spans.mjs +89 -0
  51. package/scripts/lib/scope-baseline.mjs +77 -17
  52. package/scripts/lib/scope-gate.mjs +658 -0
  53. package/scripts/lib/secret-masker.mjs +262 -0
  54. package/scripts/lib/session-lock.mjs +34 -10
  55. package/scripts/lib/session-registry.mjs +9 -1
  56. package/scripts/lib/state-md/mission-status.mjs +21 -12
  57. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  58. package/scripts/lib/validate/check-agents.mjs +77 -5
  59. package/scripts/lib/validate/check-commands.mjs +2 -20
  60. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  61. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  62. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  63. package/scripts/lib/validate/check-skills.mjs +191 -0
  64. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  65. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  66. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  67. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  68. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  69. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  70. package/scripts/lib/validate/tier-inference.mjs +46 -8
  71. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  72. package/scripts/lib/vault-mirror/process.mjs +264 -31
  73. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  74. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  75. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  76. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  77. package/scripts/print-learnings-index.mjs +30 -3
  78. package/scripts/release.mjs +616 -61
  79. package/scripts/site-numbers.mjs +1049 -0
  80. package/scripts/validate-plugin.mjs +61 -0
  81. package/scripts/validate-wave-scope.mjs +281 -12
  82. package/scripts/vault-mirror.mjs +26 -1
  83. package/skills/_shared/monitor-patterns.md +24 -4
  84. package/skills/_shared/state-ownership.md +17 -0
  85. package/skills/claude-md-drift-check/SKILL.md +9 -1
  86. package/skills/debug/SKILL.md +4 -1
  87. package/skills/discovery/issue-templates.md +4 -4
  88. package/skills/discovery/probes-code.md +2 -2
  89. package/skills/discovery/probes-feature.md +6 -6
  90. package/skills/discovery/probes-infra.md +2 -2
  91. package/skills/discovery/probes-session.md +5 -5
  92. package/skills/dispatcher/SKILL.md +10 -1
  93. package/skills/frontmatter-guard/SKILL.md +9 -1
  94. package/skills/gitlab-ops/SKILL.md +49 -39
  95. package/skills/gitlab-portfolio/SKILL.md +10 -1
  96. package/skills/memory-cleanup/SKILL.md +18 -5
  97. package/skills/npm-publish/SKILL.md +22 -50
  98. package/skills/persona-panel/SKILL.md +3 -1
  99. package/skills/plan/mode-new.md +23 -5
  100. package/skills/repo-audit/SKILL.md +10 -1
  101. package/skills/session-end/SKILL.md +44 -2
  102. package/skills/session-end/metrics-collection.md +1 -1
  103. package/skills/session-end/session-metrics-write.md +4 -10
  104. package/skills/session-plan/SKILL.md +2 -2
  105. package/skills/session-plan/wave-template.md +1 -1
  106. package/skills/session-start/SKILL.md +13 -1
  107. package/skills/spinout/SKILL.md +5 -1
  108. package/skills/sunset-review/SKILL.md +11 -1
  109. package/skills/tmux-layout/SKILL.md +7 -2
  110. package/skills/vault-mirror/SKILL.md +10 -1
  111. package/skills/vault-sync/SKILL.md +10 -1
  112. package/skills/vault-sync/validator.mjs +55 -6
  113. package/skills/wave-executor/wave-loop.md +42 -12
  114. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -17,7 +17,16 @@
17
17
  */
18
18
 
19
19
  import { writeFile, rename, mkdir } from 'node:fs/promises';
20
- import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, writeSync } from 'node:fs';
20
+ import {
21
+ mkdirSync,
22
+ writeFileSync,
23
+ renameSync,
24
+ readFileSync,
25
+ existsSync,
26
+ writeSync,
27
+ copyFileSync,
28
+ unlinkSync,
29
+ } from 'node:fs';
21
30
  import path, { dirname } from 'node:path';
22
31
  import { randomBytes, randomUUID } from 'node:crypto';
23
32
 
@@ -556,6 +565,26 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
556
565
  * used from any layer without violating the layering rule in
557
566
  * `scripts/lib/hardening.mjs`.
558
567
  *
568
+ * Implementation: this is the JSON-serializing façade over
569
+ * {@link atomicWriteWithBackup} (`backup: false`) — the two functions carried
570
+ * the identical `mkdir → tmp → write → rename → catch` body 60 lines apart,
571
+ * which is the very duplication `atomicWriteWithBackup` was introduced to end.
572
+ * Everything below the `JSON.stringify` is the delegate's; the wrapper exists
573
+ * for the serialization step and for NARROWING the envelope (see @returns).
574
+ *
575
+ * Two things are deliberately kept here rather than pushed into the delegate:
576
+ *
577
+ * - **The stringify stays inside a try.** `JSON.stringify` throws on a cycle
578
+ * (and on a BigInt). Hoisting it above the delegation call would turn that
579
+ * into an escaping TypeError, and 4 of the 9 caller files (`loop-guard`,
580
+ * `lock-bootstrap`, `file-lock`, `issue-budget`) invoke this without a
581
+ * try/catch of their own. A serialization failure reports as `fs-error`
582
+ * because that is what this helper has always returned for it.
583
+ * - **The success envelope is narrowed back to `{ ok: true }`.** The delegate
584
+ * also reports `path`/`bytes`/`backupPath`; `session-lock.mjs#writeOwnerProof`
585
+ * propagates THIS object verbatim on failure (`if (!w.ok) return w`), so the
586
+ * key set is part of a contract that reaches further than this file.
587
+ *
559
588
  * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
560
589
  * @param {*} data JSON-serializable value.
561
590
  * @param {object} [opts]
@@ -565,15 +594,172 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
565
594
  */
566
595
  export function writeJsonAtomicSync(filePath, data, opts = {}) {
567
596
  const { indent = 2, tmpPrefix = '.tmp' } = opts;
597
+
598
+ let body;
599
+ try {
600
+ body = JSON.stringify(data, null, indent) + '\n';
601
+ } catch (err) {
602
+ return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
603
+ }
604
+
605
+ const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
606
+ return res.ok ? { ok: true } : res;
607
+ }
608
+
609
+ /**
610
+ * Content-agnostic sibling of {@link writeJsonAtomicSync}: atomically replace a
611
+ * file with an arbitrary string/Buffer body, optionally snapshotting the
612
+ * previous contents to a timestamped `.bak-<ISO>` sidecar first (issue #734).
613
+ *
614
+ * The repo carries the copy→tmp→rename idiom in ~25 hand-rolled places
615
+ * (`learnings/io.mjs#rewriteLearnings`, `session-record-repair.mjs`,
616
+ * `owner-interview.mjs`, `backfill-learnings.mjs`, …). Each spelling differs in
617
+ * small ways — some take the backup, some do not; some `mkdir -p`, some assume
618
+ * the directory exists — which is exactly how a crash-safety guarantee rots.
619
+ * This helper is the shared spelling; **validation policy stays with the
620
+ * callers** (this function never inspects `body`).
621
+ *
622
+ * Crash-safety: write `<dir>/<tmpPrefix>.<rand>`, then `renameSync` over the
623
+ * target. Same-filesystem rename is atomic on POSIX, so an observer sees either
624
+ * the previous contents or the new ones — never a half-written file. The tmp
625
+ * file is created as a SIBLING of the target on purpose: a tmp in `os.tmpdir()`
626
+ * may sit on a different filesystem, where `rename` degrades to a non-atomic
627
+ * copy (EXDEV).
628
+ *
629
+ * Backup (`backup: true`) copies the CURRENT file to `<filePath>.bak-<ISO>`
630
+ * before the rename, with `:`/`.` swapped for `-` so a lexical sort of the
631
+ * siblings is chronological (same convention as `learnings/io.mjs`). A
632
+ * first-time write has nothing to lose, so no backup is taken when the target
633
+ * does not exist. **Rotation is deliberately NOT done here** — how many
634
+ * snapshots a store is worth is a per-caller policy (`learnings/io.mjs` keeps
635
+ * 3; a re-derivable file wants 0), and a keep-N default baked into the
636
+ * primitive would silently unlink a caller's snapshots.
637
+ *
638
+ * Caller is responsible for path-confinement — this helper does NOT validate
639
+ * that `filePath` lives inside the project (mirrors {@link writeJsonAtomic} /
640
+ * {@link writeJsonAtomicSync}).
641
+ *
642
+ * On a FAILED write the tmp sibling is unlinked (best-effort) before the error
643
+ * envelope is returned. Without that, every failed write leaves a
644
+ * `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
645
+ * so the litter accumulates in the operator's vault directory. The cleanup is
646
+ * attempted only when the write actually created the tmp file, and its own
647
+ * failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
648
+ * neither may mask the original error.
649
+ *
650
+ * ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
651
+ * TWO PRODUCTION CALL-SITES: `vault-status/board-writer.mjs#writeBoard`
652
+ * (`backup: false`) and {@link writeJsonAtomicSync}, which carries 12 further
653
+ * call-sites across 9 files behind it. That second one is the load-bearing
654
+ * evidence — the previous revision of this note recorded ONE call-site and
655
+ * concluded the signature was unproven, while the function with an identical
656
+ * body sat 60 lines above in this same file, unmigrated. The cheapest possible
657
+ * migration going unmade is not a neutral fact about a helper: it is the
658
+ * measurement that the helper is not paying for itself.
659
+ *
660
+ * What the second call-site does NOT prove: `writeJsonAtomicSync` passes
661
+ * `backup: false` and no `fs`, so the backup half and the injection seam still
662
+ * rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
663
+ * migrates the remaining hand-rolled sites, re-check before widening:
664
+ * (a) whether an `async` twin is needed rather than bolting a promise mode onto
665
+ * this one (three known sites are `fs/promises`), and (b) whether rotation
666
+ * belongs here after all (it does only if ≥2 migrated callers want the SAME
667
+ * keep-N). If NO caller ever passes `backup: true` in production, that half is
668
+ * still the part to shrink back.
669
+ *
670
+ * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
671
+ * @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
672
+ * @param {object} [opts]
673
+ * @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
674
+ * @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
675
+ * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
676
+ * @param {Date} [opts.now] Clock seam for the backup stamp (tests).
677
+ * @param {{ mkdirSync?: Function, writeFileSync?: Function, renameSync?: Function,
678
+ * copyFileSync?: Function, existsSync?: Function, unlinkSync?: Function }} [opts.fs]
679
+ * Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
680
+ * the backup path, which fails closed (see below).
681
+ * @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
682
+ * | { ok: false, reason: 'fs-error', error: string }}
683
+ */
684
+ export function atomicWriteWithBackup(filePath, body, opts = {}) {
685
+ const {
686
+ encoding = 'utf8',
687
+ backup = false,
688
+ tmpPrefix = '.tmp',
689
+ now = new Date(),
690
+ fs: injectedFs,
691
+ } = opts;
692
+
693
+ const fsMkdir = injectedFs?.mkdirSync ?? mkdirSync;
694
+ const fsWriteFile = injectedFs?.writeFileSync ?? writeFileSync;
695
+ const fsRename = injectedFs?.renameSync ?? renameSync;
696
+ const fsCopyFile = injectedFs?.copyFileSync ?? copyFileSync;
697
+ const fsExists = injectedFs?.existsSync ?? existsSync;
698
+ const fsUnlink = injectedFs?.unlinkSync ?? unlinkSync;
699
+
700
+ // ── Partial-adapter fail-closed, backup path only ──────────────────────────
701
+ //
702
+ // Per-method fallback to the real `node:fs` is the right default for the
703
+ // three ALWAYS-used methods: `board-writer.mjs#writeBoard` passes an fs object
704
+ // on EVERY call, including production, where `renameSync`/`copyFileSync` are
705
+ // present-but-`undefined` because nothing was injected. "An injected object
706
+ // must be total" would therefore reject the only real caller — the shape is
707
+ // not evidence of a fake.
708
+ //
709
+ // The backup path is different in kind. It runs ONLY under `backup: true`, and
710
+ // there a missing method routes a real `copyFileSync`/`existsSync` at the real
711
+ // filesystem while the write goes to the fake: a suite that believes itself
712
+ // hermetic drops `.bak-<ISO>` files into the repo, and nothing says so. Both
713
+ // methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
714
+ // the real target and silently decides the backup branch from it, which is the
715
+ // same escape one step earlier.
716
+ if (backup && injectedFs) {
717
+ for (const method of ['existsSync', 'copyFileSync']) {
718
+ if (typeof injectedFs[method] !== 'function') {
719
+ return {
720
+ ok: false,
721
+ reason: 'fs-error',
722
+ error: `partial fs adapter: ${method} required for backup`,
723
+ };
724
+ }
725
+ }
726
+ }
727
+
728
+ let tmpFile = null;
729
+ let tmpCreated = false;
730
+
568
731
  try {
569
732
  const dir = dirname(filePath);
570
- mkdirSync(dir, { recursive: true });
571
- const tmpSuffix = randomBytes(6).toString('hex');
572
- const tmpFile = path.join(dir, `${tmpPrefix}.${tmpSuffix}`);
573
- writeFileSync(tmpFile, JSON.stringify(data, null, indent) + '\n', { encoding: 'utf8' });
574
- renameSync(tmpFile, filePath);
575
- return { ok: true };
733
+ fsMkdir(dir, { recursive: true });
734
+
735
+ let backupPath = null;
736
+ if (backup && fsExists(filePath)) {
737
+ const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
738
+ backupPath = `${filePath}.bak-${stamp}`;
739
+ fsCopyFile(filePath, backupPath);
740
+ }
741
+
742
+ tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
743
+ fsWriteFile(tmpFile, body, encoding);
744
+ tmpCreated = true;
745
+ fsRename(tmpFile, filePath);
746
+
747
+ return {
748
+ ok: true,
749
+ path: filePath,
750
+ bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
751
+ backupPath,
752
+ };
576
753
  } catch (err) {
754
+ // Only when the write got far enough to create it. The name carries 12 hex
755
+ // chars of entropy, so this cannot collide with a caller's real file.
756
+ if (tmpCreated) {
757
+ try {
758
+ fsUnlink(tmpFile);
759
+ } catch {
760
+ // Best-effort: never let cleanup replace the error the caller needs.
761
+ }
762
+ }
577
763
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
578
764
  }
579
765
  }
@@ -52,6 +52,35 @@
52
52
  * #1015 hardened for `.claude/rules/`. The primitives are imported, never
53
53
  * re-implemented: a second copy is how this channel shipped raw beside the
54
54
  * hardened one in the first place.
55
+ * - `extractProvenance()` from `../validate/check-learning-provenance.mjs` and
56
+ * `learningKeyOf()` from `./kebab.mjs` — the already-delivered filter below.
57
+ * Both readers already exist; a second provenance parser here would be the
58
+ * "same fact in two copies" defect this repo keeps paying for.
59
+ *
60
+ * ## Already-delivered filter (#1019)
61
+ *
62
+ * `/reconcile` converts a learning into a `.claude/rules/*.md` file, and Claude
63
+ * Code delivers every such file to every dispatched agent NATIVELY, in full.
64
+ * Measured first-person 2026-08-15 @fd73548+wave-2: a wave subagent's context
65
+ * carried all 29 of `.claude/rules/*.md` — `alwaysApply: false` and `globs:`
66
+ * notwithstanding, because `rule-loader.mjs` (the only code that understands
67
+ * that frontmatter) does not run on the delivery path
68
+ * (`docs/instruction-delivery.md` §1/§1.1). So a rule-derived learning that also
69
+ * enters this index arrives TWICE, and the second copy costs a slot in a
70
+ * 2000-char budget — displacing a learning the agent would otherwise never see.
71
+ *
72
+ * The filter therefore runs BEFORE the split caps, not after: dropping a
73
+ * duplicate after the Top-N cut would remove the line without freeing its slot,
74
+ * which is the whole harm. Two axes, mirroring the provenance checker's own
75
+ * `dangling` / `superseded` split: the rule's `learning-id` (exact record) and
76
+ * its `learning-key` (logical identity, stable across a re-minted UUID — the
77
+ * state any id backfill lands in). 13 of 29 rules carry provenance and all 13
78
+ * resolve by id today, so the key axis is currently inert by measurement, not
79
+ * by design.
80
+ *
81
+ * SILENT NO-OP is a hard requirement: no rules directory, no `.md` files, or no
82
+ * provenance block anywhere ⇒ empty sets ⇒ byte-identical output. A repo without
83
+ * `/reconcile` must never see FEWER learnings because this filter exists.
55
84
  *
56
85
  * NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
57
86
  * the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
@@ -96,12 +125,17 @@
96
125
  * 5. Expired and sub-floor entries are never selected.
97
126
  */
98
127
 
128
+ import { readFileSync, readdirSync } from 'node:fs';
129
+ import { join } from 'node:path';
130
+
99
131
  import { affinity } from './affinity.mjs';
100
132
  import {
101
133
  INSIGHT_MAX_BYTES,
102
134
  TITLE_MAX_BYTES,
103
135
  sanitizeProse,
104
136
  } from '../reconcile/sanitize.mjs';
137
+ import { learningKeyOf } from './kebab.mjs';
138
+ import { extractProvenance } from '../validate/check-learning-provenance.mjs';
105
139
  import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
106
140
 
107
141
  // ---------------------------------------------------------------------------
@@ -178,9 +212,15 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
178
212
  * @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
179
213
  * text: string, chars: number, scopeMatched: number,
180
214
  * globalCount: number, candidates: number, truncated: boolean,
181
- * rejected: number}} Selection
215
+ * rejected: number, deliveredFiltered: number}} Selection
182
216
  * `rejected` counts records dropped by the untrusted-text guard — surfaced so
183
217
  * a drop is observable in the injection event rather than silent.
218
+ * `deliveredFiltered` counts records dropped because a `.claude/rules/*.md`
219
+ * file already delivers them natively (#1019) — same reason, and it is the
220
+ * only way to tell "the filter bit" from "the corpus has no such learning".
221
+ *
222
+ * @typedef {{ids: Set<string>, keys: Set<string>}} DeliveredProvenance
223
+ * Learning ids and logical keys already delivered as `.claude/rules/*.md`.
184
224
  */
185
225
 
186
226
  // ---------------------------------------------------------------------------
@@ -214,6 +254,43 @@ function _isActive(entry, nowMs, confidenceFloor) {
214
254
  return true;
215
255
  }
216
256
 
257
+ /**
258
+ * Normalize a caller-supplied delivered-provenance option into two Sets, or
259
+ * `null` when there is nothing to filter against.
260
+ *
261
+ * `null` (not empty Sets) is the no-filter signal so the hot loop can skip the
262
+ * per-entry `learningKeyOf()` call entirely — and so the SILENT NO-OP guarantee
263
+ * is one explicit branch rather than an emergent property of empty membership.
264
+ *
265
+ * @param {unknown} v `{ids, keys}` with Sets or arrays; anything else ⇒ `null`
266
+ * @returns {DeliveredProvenance|null}
267
+ */
268
+ function _resolveDelivered(v) {
269
+ if (!_isRecord(v)) return null;
270
+ const toSet = (x) => (x instanceof Set ? x : Array.isArray(x) ? new Set(x) : new Set());
271
+ const ids = toSet(v.ids);
272
+ const keys = toSet(v.keys);
273
+ return ids.size === 0 && keys.size === 0 ? null : { ids, keys };
274
+ }
275
+
276
+ /**
277
+ * True when this learning already reaches the agent as a natively-delivered
278
+ * `.claude/rules/*.md` file.
279
+ *
280
+ * Id first (exact record), then the logical key — a rule whose `learning-id`
281
+ * was re-minted by a backfill still delivers the same content, which is the
282
+ * `superseded-learning-id` state `check-learning-provenance.mjs` names.
283
+ *
284
+ * @param {object} entry
285
+ * @param {DeliveredProvenance} delivered
286
+ * @returns {boolean}
287
+ */
288
+ function _isDelivered(entry, delivered) {
289
+ if (typeof entry.id === 'string' && entry.id !== '' && delivered.ids.has(entry.id)) return true;
290
+ const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
291
+ return key !== null && delivered.keys.has(key);
292
+ }
293
+
217
294
  /** Epoch ms from a Date | number | undefined clock option. */
218
295
  function _resolveNowMs(now) {
219
296
  if (now instanceof Date) return now.getTime();
@@ -274,9 +351,60 @@ export function emptySelection() {
274
351
  candidates: 0,
275
352
  truncated: false,
276
353
  rejected: 0,
354
+ deliveredFiltered: 0,
277
355
  };
278
356
  }
279
357
 
358
+ /**
359
+ * Read the provenance pointers of every `.claude/rules/*.md` — the set of
360
+ * learnings the agent already receives natively (#1019).
361
+ *
362
+ * Reuses `extractProvenance()` rather than re-deriving the block format. Total
363
+ * by construction: an absent, unreadable or provenance-free directory yields
364
+ * empty sets, which {@link selectLearnings} reads as "no filter" (SILENT NO-OP).
365
+ *
366
+ * Read discipline mirrors `check-learning-provenance.mjs`: `readFileSync`, never
367
+ * a `grep` spawn — one NUL byte makes a text file invisible to a grep-based
368
+ * audit, and a silently-skipped rule file reads exactly like a rule with no
369
+ * provenance.
370
+ *
371
+ * Ceiling: one synchronous read per rule file, linear in the corpus (29 files /
372
+ * ~170 KB today). Revisit — cache per process or read async — if
373
+ * `.claude/rules/` passes a few hundred files.
374
+ *
375
+ * @param {string} rulesDir absolute path to the `.claude/rules` directory
376
+ * @returns {DeliveredProvenance}
377
+ */
378
+ export function readDeliveredProvenance(rulesDir) {
379
+ /** @type {Set<string>} */
380
+ const ids = new Set();
381
+ /** @type {Set<string>} */
382
+ const keys = new Set();
383
+ if (typeof rulesDir !== 'string' || rulesDir.trim() === '') return { ids, keys };
384
+
385
+ /** @type {string[]} */
386
+ let names;
387
+ try {
388
+ names = readdirSync(rulesDir);
389
+ } catch {
390
+ return { ids, keys }; // no rules directory ⇒ nothing is natively delivered
391
+ }
392
+
393
+ for (const name of names) {
394
+ if (!name.endsWith('.md')) continue;
395
+ let body;
396
+ try {
397
+ body = readFileSync(join(rulesDir, name), 'utf8');
398
+ } catch {
399
+ continue; // one unreadable rule must not cost the whole census
400
+ }
401
+ const { id, key } = extractProvenance(body);
402
+ if (id) ids.add(id);
403
+ if (key) keys.add(key);
404
+ }
405
+ return { ids, keys };
406
+ }
407
+
280
408
  /**
281
409
  * Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
282
410
  * boundary.
@@ -413,6 +541,9 @@ export function scoreLearning(entry, scope, opts = {}) {
413
541
  * @param {Date|number} [opts.now] — injectable clock
414
542
  * @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
415
543
  * @param {object} [opts.affinityOpts] — forwarded to affinity()
544
+ * @param {DeliveredProvenance} [opts.delivered] — learnings already delivered
545
+ * natively as `.claude/rules/*.md` (#1019); omitted/empty ⇒ no filtering, and
546
+ * the selection is byte-identical to the pre-filter one
416
547
  * @returns {Selection}
417
548
  */
418
549
  export function selectLearnings(entries, scope, opts = {}) {
@@ -433,6 +564,7 @@ export function selectLearnings(entries, scope, opts = {}) {
433
564
  ? o.confidenceFloor
434
565
  : DEFAULT_CONFIDENCE_FLOOR;
435
566
  const nowMs = _resolveNowMs(o.now);
567
+ const delivered = _resolveDelivered(o.delivered);
436
568
  const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
437
569
 
438
570
  /** @type {SelectedLearning[]} */
@@ -441,12 +573,23 @@ export function selectLearnings(entries, scope, opts = {}) {
441
573
  const global = [];
442
574
  let candidates = 0;
443
575
  let rejected = 0;
576
+ let deliveredFiltered = 0;
444
577
 
445
578
  for (const entry of entries) {
446
579
  if (!_isRecord(entry)) continue;
447
580
  if (!_isActive(entry, nowMs, confidenceFloor)) continue;
448
581
  candidates++;
449
582
 
583
+ // #1019 — BEFORE the split caps below, never after. This entry already
584
+ // reaches the agent in full as a `.claude/rules/*.md` file; dropping it
585
+ // here frees its slot for a learning the agent would otherwise never see,
586
+ // whereas dropping it after the Top-N cut would only shorten the index.
587
+ // `candidates` still counts it: the pool it was drawn from is unchanged.
588
+ if (delivered !== null && _isDelivered(entry, delivered)) {
589
+ deliveredFiltered++;
590
+ continue;
591
+ }
592
+
450
593
  const s = scoreLearning(entry, scope, scoreOpts);
451
594
  // Fail CLOSED per entry: a record whose text forges the delivery wrapper
452
595
  // is dropped, not neutralised in place — with 100 candidates competing for
@@ -510,6 +653,7 @@ export function selectLearnings(entries, scope, opts = {}) {
510
653
  candidates,
511
654
  truncated,
512
655
  rejected,
656
+ deliveredFiltered,
513
657
  };
514
658
  } catch {
515
659
  // Contract point 1 — a ranking primitive on the dispatch hot path must
@@ -525,7 +669,11 @@ export function selectLearnings(entries, scope, opts = {}) {
525
669
  * @param {string} filePath — absolute path to learnings.jsonl
526
670
  * @param {AgentScope} scope
527
671
  * @param {object} [opts] — everything {@link selectLearnings} accepts, plus
528
- * `poolSize` (how many active entries to pull before ranking).
672
+ * `poolSize` (how many active entries to pull before ranking) and `rulesDir`
673
+ * (absolute `.claude/rules` path; read via {@link readDeliveredProvenance} into
674
+ * the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
675
+ * `filePath`: guessing a repo root from a metrics path is the hand-maintained
676
+ * fact this repo keeps getting wrong. Omit it and nothing is filtered.
529
677
  * @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
530
678
  */
531
679
  export async function selectLearningsFromFile(filePath, scope, opts = {}) {
@@ -538,12 +686,18 @@ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
538
686
  ? o.confidenceFloor
539
687
  : DEFAULT_CONFIDENCE_FLOOR;
540
688
 
689
+ const delivered = _isRecord(o.delivered)
690
+ ? o.delivered
691
+ : typeof o.rulesDir === 'string' && o.rulesDir.trim() !== ''
692
+ ? readDeliveredProvenance(o.rulesDir)
693
+ : undefined;
694
+
541
695
  const entries = await surfaceTopN(filePath, poolSize, {
542
696
  now: nowMs,
543
697
  confidenceFloor,
544
698
  decay: o.decay,
545
699
  });
546
- return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
700
+ return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
547
701
  } catch {
548
702
  return emptySelection();
549
703
  }
@@ -1,20 +1,41 @@
1
1
  /**
2
- * memory-cleanup-stamp.mjs — Pure helper: stamp `memory_cleanup_at` on a
3
- * session record when `/memory-cleanup` ran this session.
2
+ * memory-cleanup-stamp.mjs — stamp `memory_cleanup_at` on a session record when
3
+ * `/memory-cleanup` ran this session, and DERIVE that fact from the event log
4
+ * instead of from coordinator recall.
4
5
  *
5
6
  * Issue #699 fix: a healthy no-op run of `/memory-cleanup` previously left
6
7
  * `memory_cleanup_at` unstamped, so `auto-dream.mjs` `readDreamSignals`
7
8
  * never advanced `lastCleanupAt` and `shouldDispatchAutoDream` kept firing a
8
- * false nudge. This helper stamps the field unconditionally whenever the
9
- * cleanup ran — including a healthy no-op where no memory files were mutated.
9
+ * false nudge. `stampMemoryCleanup()` stamps the field unconditionally whenever
10
+ * the cleanup ran — including a healthy no-op where no memory files were mutated.
11
+ *
12
+ * #699 follow-up (Disziplin statt Mechanik): the `ranCleanup` boolean itself used
13
+ * to come from the coordinator-LLM remembering a prose step at session-end. It
14
+ * measurably failed — a `/memory-cleanup` ran on 2026-08-14 and all three session
15
+ * records of that day read `memory_cleanup_at: null`, so the session-start banner
16
+ * reported "29 days ago" while the operator's own notes said 3. Since then
17
+ * `/memory-cleanup` emits `orchestrator.memory.cleanup_completed` and
18
+ * `deriveMemoryCleanupSignal()` reads that event back out of
19
+ * `.orchestrator/metrics/events.jsonl`, which is what `scripts/emit-session.mjs`
20
+ * calls at write time. Nothing depends on recall any more.
10
21
  *
11
22
  * Design constraints:
12
- * - Pure function — no I/O, no side-effects.
13
- * - No-throw — invalid inputs return the record unchanged (defensive).
14
- * - Testable seam the Quality wave (Q2) unit-tests this function directly.
15
- * - No external deps Node 20+ stdlib only (none needed here).
23
+ * - `stampMemoryCleanup()` stays pure — no I/O, no side-effects.
24
+ * - No-throw — invalid inputs / unreadable-or-corrupt event log return the
25
+ * "no cleanup" answer rather than blocking a session close.
26
+ * - Testable seamboth functions are unit-tested directly.
27
+ * - No external deps — Node stdlib only.
16
28
  */
17
29
 
30
+ import { readFileSync } from 'node:fs';
31
+
32
+ /**
33
+ * Event `/memory-cleanup` emits when a run completes (any mode, any outcome —
34
+ * including a healthy no-op). Emitted via `scripts/emit-event.mjs` → the
35
+ * canonical `emitEvent()` path; see `skills/memory-cleanup/SKILL.md` § Output.
36
+ */
37
+ export const MEMORY_CLEANUP_EVENT = 'orchestrator.memory.cleanup_completed';
38
+
18
39
  /**
19
40
  * Stamp `memory_cleanup_at` on a session record when `/memory-cleanup` ran.
20
41
  *
@@ -54,3 +75,106 @@ export function stampMemoryCleanup(record, { ranCleanup, completedAt } = {}) {
54
75
  // Return a shallow clone with the stamp applied — never mutate the input.
55
76
  return { ...record, memory_cleanup_at: completedAt };
56
77
  }
78
+
79
+ /**
80
+ * Derive "did `/memory-cleanup` run in THIS session?" from the event log.
81
+ *
82
+ * Reads `eventsFile` and looks for `orchestrator.memory.cleanup_completed`
83
+ * records that fall inside the session's own `[startedAt, completedAt]` window.
84
+ * This is the mechanical replacement for the coordinator-supplied boolean.
85
+ *
86
+ * Matching rule (two clauses, both mechanical):
87
+ * 1. the event's `timestamp` lies within `[startedAt, completedAt]` inclusive;
88
+ * 2. IF the event carries a non-empty `semantic_session_id`, it must equal
89
+ * `sessionId` (sessions.jsonl `session_id` is the SEMANTIC id — the UUID
90
+ * `session_id` some events carry lives in a different id space and is
91
+ * deliberately NOT used for matching).
92
+ *
93
+ * Named ceiling (deliberate simplification): an event with NO attribution is
94
+ * claimed on the window alone, so two parallel sessions in one repo with
95
+ * overlapping windows both derive `ranCleanup: true`. The consequence is a
96
+ * marginally generous cadence marker on the peer's record — never a MISSED
97
+ * cleanup, which is the failure this whole path exists to prevent. Revisit
98
+ * trigger: if per-session cleanup accounting is ever needed, attach
99
+ * `semantic_session_id` to the emitted event and clause 2 makes it exact.
100
+ * Scan cost is a single linear pass over `events.jsonl` (~23k lines,
101
+ * size-rotated at SessionStart by `events-rotation.mjs`) — revisit if rotation
102
+ * is ever removed.
103
+ *
104
+ * No-throw: a missing, unreadable, or partly-corrupt event log yields
105
+ * `{ ranCleanup: false, ... }`. A session close must never fail because
106
+ * telemetry is damaged.
107
+ *
108
+ * @param {object} opts
109
+ * @param {string} opts.eventsFile Absolute or CWD-relative path to events.jsonl.
110
+ * @param {string} [opts.sessionId] The record's `session_id` (semantic form).
111
+ * @param {string} opts.startedAt Session `started_at` (ISO-8601).
112
+ * @param {string} opts.completedAt Session `completed_at` (ISO-8601).
113
+ * @returns {{ranCleanup: boolean, at: string|null, matches: number}}
114
+ * `at` is the LATEST matching event timestamp (null when none matched);
115
+ * `matches` is how many events matched (a run can emit more than one).
116
+ */
117
+ export function deriveMemoryCleanupSignal({
118
+ eventsFile,
119
+ sessionId,
120
+ startedAt,
121
+ completedAt,
122
+ } = {}) {
123
+ const none = { ranCleanup: false, at: null, matches: 0 };
124
+
125
+ if (typeof eventsFile !== 'string' || eventsFile.length === 0) return none;
126
+
127
+ const startMs = Date.parse(startedAt);
128
+ const endMs = Date.parse(completedAt);
129
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
130
+ return none;
131
+ }
132
+
133
+ let raw;
134
+ try {
135
+ raw = readFileSync(eventsFile, 'utf8');
136
+ } catch {
137
+ // Missing / unreadable event log — no signal, never an error.
138
+ return none;
139
+ }
140
+
141
+ const wantSession = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
142
+ let matches = 0;
143
+ let latestMs = -Infinity;
144
+ let latestTs = null;
145
+
146
+ for (const line of raw.split('\n')) {
147
+ // Cheap substring prefilter before the JSON.parse cost.
148
+ if (line.length === 0 || !line.includes(MEMORY_CLEANUP_EVENT)) continue;
149
+
150
+ let rec;
151
+ try {
152
+ rec = JSON.parse(line);
153
+ } catch {
154
+ continue; // corrupt line — skip, best-effort reader
155
+ }
156
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
157
+ if (rec.event !== MEMORY_CLEANUP_EVENT) continue;
158
+
159
+ const tsMs = Date.parse(rec.timestamp);
160
+ if (!Number.isFinite(tsMs) || tsMs < startMs || tsMs > endMs) continue;
161
+
162
+ const semantic = rec.semantic_session_id;
163
+ if (
164
+ wantSession !== null &&
165
+ typeof semantic === 'string' &&
166
+ semantic.length > 0 &&
167
+ semantic !== wantSession
168
+ ) {
169
+ continue; // attributed to a DIFFERENT session — not ours
170
+ }
171
+
172
+ matches += 1;
173
+ if (tsMs > latestMs) {
174
+ latestMs = tsMs;
175
+ latestTs = rec.timestamp;
176
+ }
177
+ }
178
+
179
+ return { ranCleanup: matches > 0, at: latestTs, matches };
180
+ }