session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -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
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * mirror-issues-banner.mjs — the mirror blind spot (#1022 follow-up)
3
+ *
4
+ * `skills/gitlab-ops/SKILL.md` § VCS Auto-Detection picks EXACTLY ONE platform
5
+ * via if/else. In a repo whose `origin` is GitLab and whose `github` remote is
6
+ * a public mirror, no code path ever reads issues from the mirror — so issues
7
+ * filed by external reporters against the mirror are structurally invisible to
8
+ * every session. This probe is the counter-measure: it asks the OTHER side.
9
+ *
10
+ * The VCS family is therefore HARD-PINNED to `'github'`, never auto-detected.
11
+ * Auto-detection is the defect this module exists to compensate for; deriving
12
+ * the family here would reproduce it.
13
+ *
14
+ * No new Session Config key by design. `resolveRepoSpec({ vcs:'github' })`
15
+ * derives the `gh -R` spec straight from `git remote` (its `REMOTE_PREFERENCE`
16
+ * tries the remote literally named `github` first, and its `WRONG_FAMILY_HOST`
17
+ * guard discards a cross-family match). That makes the probe SELF-DISABLING: a
18
+ * repo without a GitHub mirror resolves to `undefined` → `null` → no spawn, no
19
+ * network call. A config key would be a second SSOT drifting against
20
+ * `git remote`.
21
+ *
22
+ * Plain-JS — no Zod dependency. Never throws.
23
+ *
24
+ * Mirrors the Phase 4 banner contract documented in
25
+ * `scripts/lib/loop-readiness-banner.mjs:23-26`: a single `checkXxx()` entry
26
+ * point returning `null` or `{ severity, message, ... }`.
27
+ *
28
+ * Cross-references:
29
+ * - `scripts/lib/ci-status-banner.mjs` — the sibling project-facing probe
30
+ * (whose `null`-collapsing this module deliberately does NOT copy, see below).
31
+ * - `scripts/lib/reconcile-nudge-banner.mjs:183-186` — the same
32
+ * absence-preserving discipline applied to a count.
33
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site.
34
+ */
35
+
36
+ import { execFile as _execFile } from 'node:child_process';
37
+ import { promisify } from 'node:util';
38
+ import { resolveRepoSpec as _resolveRepoSpec } from './vcs-repo-spec.mjs';
39
+
40
+ const execFileAsync = promisify(_execFile);
41
+
42
+ /** Default timeout in ms for the `gh` invocation. Mirrors `ci-status-banner.mjs:20`. */
43
+ export const DEFAULT_TIMEOUT_MS = 8000;
44
+
45
+ /** Default `--limit` passed to `gh issue list`. */
46
+ export const DEFAULT_LIMIT = 20;
47
+
48
+ /**
49
+ * The VCS family this probe queries. Deliberately a module constant, not a
50
+ * parameter: querying the auto-detected platform would make this probe a no-op
51
+ * in exactly the repos it exists for.
52
+ */
53
+ const MIRROR_VCS = 'github';
54
+
55
+ /** How many issue numbers the banner message names before summarising. */
56
+ const MESSAGE_ISSUE_CAP = 5;
57
+
58
+ /**
59
+ * Closed set of `degraded` reasons. A degraded result means "the mirror was
60
+ * NOT successfully read" — never "the mirror is clean".
61
+ *
62
+ * `query-failed` is the residual bucket for a `gh` that ran and exited
63
+ * non-zero for a reason that is neither missing-CLI, timeout, nor auth
64
+ * (network down, repo renamed, rate limit). It is a deliberate fifth member
65
+ * beyond the four originally specified: folding those into `parse-error`
66
+ * would mislabel a network failure as malformed output, reintroducing the
67
+ * dishonest-state class this module was built to remove.
68
+ *
69
+ * @type {readonly ['cli-missing','timeout','parse-error','auth-error','query-failed']}
70
+ */
71
+ export const DEGRADED_REASONS = Object.freeze([
72
+ 'cli-missing',
73
+ 'timeout',
74
+ 'parse-error',
75
+ 'auth-error',
76
+ 'query-failed',
77
+ ]);
78
+
79
+ /**
80
+ * Run `cmd` with a timeout race. Mirrors `ci-status-banner.mjs:30-38`.
81
+ *
82
+ * The timer is cleared and `unref`ed so a fast success does not hold the event
83
+ * loop open for the full budget (`.claude/rules/testing.md` § Async & Timeout
84
+ * Patterns). KNOWN LIMIT, inherited from the shared pattern and NOT fixed
85
+ * here: losing the race abandons the `gh` child process rather than killing
86
+ * it — a hung `gh` is left orphaned.
87
+ *
88
+ * @param {string} cmd
89
+ * @param {string[]} args
90
+ * @param {{ cwd?: string, timeoutMs?: number, execFile?: Function }} [opts]
91
+ * @returns {Promise<{ stdout?: string, stderr?: string }>}
92
+ */
93
+ async function execWithTimeout(cmd, args, opts = {}) {
94
+ const { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, execFile = execFileAsync } = opts;
95
+ /** @type {NodeJS.Timeout | undefined} */
96
+ let timer;
97
+ try {
98
+ return await Promise.race([
99
+ execFile(cmd, args, { cwd, env: process.env }),
100
+ new Promise((_, reject) => {
101
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
102
+ if (typeof timer.unref === 'function') timer.unref();
103
+ }),
104
+ ]);
105
+ } finally {
106
+ if (timer) clearTimeout(timer);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Map a failed `gh` invocation onto a {@link DEGRADED_REASONS} member.
112
+ *
113
+ * @param {unknown} err
114
+ * @returns {'cli-missing'|'timeout'|'auth-error'|'query-failed'}
115
+ */
116
+ function classifyFailure(err) {
117
+ const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
118
+ if (code === 'ENOENT') return 'cli-missing';
119
+
120
+ const message = err instanceof Error ? err.message : String(err ?? '');
121
+ if (message === 'timeout') return 'timeout';
122
+
123
+ const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
124
+ const haystack = `${message}\n${stderr}`.toLowerCase();
125
+ if (
126
+ haystack.includes('gh auth login') ||
127
+ haystack.includes('not logged in') ||
128
+ haystack.includes('authentication') ||
129
+ haystack.includes('http 401') ||
130
+ haystack.includes('http 403')
131
+ ) {
132
+ return 'auth-error';
133
+ }
134
+ return 'query-failed';
135
+ }
136
+
137
+ /**
138
+ * Build the degraded result. Distinct from `null` on purpose: `null` in the
139
+ * banner contract reads as "all clear", which a failed query has NOT
140
+ * established.
141
+ *
142
+ * @param {string} repoSpec
143
+ * @param {string} reason
144
+ * @returns {{ severity: 'warn', message: string, repoSpec: string, degraded: string }}
145
+ */
146
+ function degradedResult(repoSpec, reason) {
147
+ return {
148
+ severity: 'warn',
149
+ message:
150
+ `⚠ mirror-issues: Mirror ${repoSpec} konnte nicht abgefragt werden (${reason}) ` +
151
+ `— Zustand unbekannt, nicht "sauber".`,
152
+ repoSpec,
153
+ degraded: reason,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Render the found-issues banner message, naming issue numbers up to
159
+ * {@link MESSAGE_ISSUE_CAP} and summarising any remainder.
160
+ *
161
+ * @param {string} repoSpec
162
+ * @param {Array<{ number: number, title: string }>} issues
163
+ * @returns {string}
164
+ */
165
+ function formatMessage(repoSpec, issues) {
166
+ const named = issues.slice(0, MESSAGE_ISSUE_CAP).map((i) => `#${i.number}`).join(', ');
167
+ const rest = issues.length - Math.min(issues.length, MESSAGE_ISSUE_CAP);
168
+ const tail = rest > 0 ? ` (+${rest} weitere)` : '';
169
+ const plural = issues.length === 1 ? 'offenes Issue' : 'offene Issues';
170
+ return (
171
+ `⚠ mirror-issues: ${issues.length} ${plural} im Mirror ${repoSpec}: ${named}${tail} ` +
172
+ `— kein anderer Codepfad dieser Session liest sie.`
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Check the GitHub mirror for open issues and produce a session-start banner.
178
+ *
179
+ * Return contract — three states, not two:
180
+ * - `null` when the mirror remote does not resolve (repo genuinely has no
181
+ * mirror; NO subprocess is spawned) or when the query SUCCEEDED and found
182
+ * zero open issues (measured and clean).
183
+ * - `{ severity:'warn', message, count, repoSpec, issues }` when N > 0.
184
+ * - `{ severity:'warn', message, repoSpec, degraded }` when the query did not
185
+ * succeed. `degraded` is present ONLY in this case, so its absence proves
186
+ * the mirror was actually read — the distinction `ci-status-banner.mjs`
187
+ * collapses (CLI-missing, bad JSON and no-remote all return `null` there,
188
+ * which is why the mirror gap went unseen for months).
189
+ *
190
+ * Never throws.
191
+ *
192
+ * @param {{ repoRoot?: string, timeoutMs?: number, limit?: number }} [opts]
193
+ * @param {{ execFile?: Function, resolveRepoSpec?: Function }} [deps] DI for tests only.
194
+ * @returns {Promise<null | { severity: 'warn', message: string, repoSpec: string, count?: number, issues?: Array<{number:number,title:string}>, degraded?: string }>}
195
+ */
196
+ export async function checkMirrorIssues(opts = {}, deps = {}) {
197
+ try {
198
+ const { repoRoot, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT } = opts ?? {};
199
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
200
+
201
+ const { execFile = execFileAsync, resolveRepoSpec = _resolveRepoSpec } = deps ?? {};
202
+
203
+ // Step 1 — resolve the mirror spec from `git remote`. `undefined` here is
204
+ // the self-disabling path: no mirror, no spawn, no network cost.
205
+ let repoSpec;
206
+ try {
207
+ repoSpec = resolveRepoSpec({ repoRoot, vcs: MIRROR_VCS });
208
+ } catch {
209
+ return null;
210
+ }
211
+ if (!repoSpec || typeof repoSpec !== 'string') return null;
212
+
213
+ // Step 2 — query. Args array, never a shell string.
214
+ let stdout;
215
+ try {
216
+ const result = await execWithTimeout(
217
+ 'gh',
218
+ [
219
+ 'issue',
220
+ 'list',
221
+ '-R',
222
+ repoSpec,
223
+ '--state',
224
+ 'open',
225
+ '--limit',
226
+ String(limit),
227
+ '--json',
228
+ 'number,title',
229
+ ],
230
+ { cwd: repoRoot, timeoutMs, execFile },
231
+ );
232
+ stdout = String(result?.stdout ?? '');
233
+ } catch (err) {
234
+ return degradedResult(repoSpec, classifyFailure(err));
235
+ }
236
+
237
+ // Step 3 — parse. A CLI that exited 0 with unusable output is degraded,
238
+ // NOT clean.
239
+ let issues;
240
+ try {
241
+ const parsed = JSON.parse(stdout);
242
+ if (!Array.isArray(parsed)) return degradedResult(repoSpec, 'parse-error');
243
+ issues = parsed
244
+ .filter((entry) => entry && typeof entry === 'object' && Number.isFinite(Number(entry.number)))
245
+ .map((entry) => ({ number: Number(entry.number), title: String(entry.title ?? '') }));
246
+ } catch {
247
+ return degradedResult(repoSpec, 'parse-error');
248
+ }
249
+
250
+ // Step 4 — measured and clean.
251
+ if (issues.length === 0) return null;
252
+
253
+ return {
254
+ severity: 'warn',
255
+ message: formatMessage(repoSpec, issues),
256
+ count: issues.length,
257
+ repoSpec,
258
+ issues,
259
+ };
260
+ } catch {
261
+ // Defensive catch-all: a banner must never break session-start.
262
+ return null;
263
+ }
264
+ }
265
+
266
+ export default checkMirrorIssues;