session-orchestrator 3.19.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 (158) 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 +494 -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/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -79,6 +79,12 @@ const DOCUMENTED_ASYMMETRIES = {
79
79
  // no-op, not enforcement (#919-P2 class) — "pretending the payloads are
80
80
  // compatible would create false enforcement".
81
81
  PreToolUse: [
82
+ // pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
83
+ // matches the `Agent` dispatch tool, which this platform does not have —
84
+ // Cursor IDE and Pi v1 have no parallel agent dispatch at all
85
+ // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
86
+ // adapter for it. A matcher that can never fire is not enforcement.
87
+ 'pre-task-scope-disjoint.mjs',
82
88
  'skill-invocation-telemetry.mjs',
83
89
  'enforce-scope.mjs',
84
90
  'config-protection.mjs',
@@ -121,6 +127,12 @@ const DOCUMENTED_ASYMMETRIES = {
121
127
  // surface, and the operator's 2026-07-31 rule is that a gap gets
122
128
  // registered rather than faked. #958 tracks the port-or-justify.
123
129
  PreToolUse: [
130
+ // pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
131
+ // matches the `Agent` dispatch tool, which this platform does not have —
132
+ // Cursor IDE and Pi v1 have no parallel agent dispatch at all
133
+ // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
134
+ // adapter for it. A matcher that can never fire is not enforcement.
135
+ 'pre-task-scope-disjoint.mjs',
124
136
  'skill-invocation-telemetry.mjs',
125
137
  'pre-bash-sessions-ledger-guard.mjs', // #958
126
138
  'pre-bash-templates-first.mjs', // #946
@@ -143,6 +155,12 @@ const DOCUMENTED_ASYMMETRIES = {
143
155
  // mapped at all; the other eight PreToolUse handlers have no Cursor
144
156
  // mapping whatsoever.
145
157
  PreToolUse: [
158
+ // pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
159
+ // matches the `Agent` dispatch tool, which this platform does not have —
160
+ // Cursor IDE and Pi v1 have no parallel agent dispatch at all
161
+ // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
162
+ // adapter for it. A matcher that can never fire is not enforcement.
163
+ 'pre-task-scope-disjoint.mjs',
146
164
  'skill-invocation-telemetry.mjs', // #919
147
165
  'enforce-scope.mjs', // #919
148
166
  'config-protection.mjs', // #919
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-learning-provenance.mjs — census of DANGLING provenance pointers in
4
+ * `.claude/rules/*.md`.
5
+ *
6
+ * ## The defect class
7
+ *
8
+ * Every rule the reconciliation engine emits carries a `## Provenance` block
9
+ * naming the learning it was derived from:
10
+ *
11
+ * - learning-key: `anti-pattern/some-subject`
12
+ * - learning-id: `70c9c7b7-d8f3-4363-b170-0b8973d52df3`
13
+ *
14
+ * That pointer is the ONLY link between a live, always-injected instruction and
15
+ * the evidence that justified it. When the pointer rots, the rule becomes
16
+ * unauditable: nobody can re-confidence it, expire it on purpose, or check
17
+ * whether the evidence it cites still holds. It keeps loading forever on the
18
+ * strength of a citation that resolves to nothing.
19
+ *
20
+ * Measured on this repo at 2026-08-12 (`main`, issue #1017): of 13 rule files
21
+ * carrying provenance, 11 cite an id present in NEITHER the live store NOR the
22
+ * archive — 85% of the pointers point at nothing. The rot is invisible to every
23
+ * other gate because a dangling id is still perfectly well-formed Markdown.
24
+ *
25
+ * This check is deterministic — set membership over two JSONL stores — which is
26
+ * exactly why it is worth having: it needs no model, no network, and no
27
+ * judgement call.
28
+ *
29
+ * ## Resolution: live store OR archive
30
+ *
31
+ * A record legitimately MOVES from `learnings.jsonl` into
32
+ * `learnings-archive.jsonl` when it expires past its grace window
33
+ * (`scripts/sweep-expired-learnings.mjs --apply`). Resolving against the live
34
+ * store alone would therefore report every correctly-archived record as rot.
35
+ * Only "present in neither" is a finding.
36
+ *
37
+ * ## Two axes, because an id is not the only pointer
38
+ *
39
+ * The id is the record's UUID; the key (`${type}/${kebab(title||subject)}`,
40
+ * derived by the shared `learnings/kebab.mjs::learningKeyOf`) is
41
+ * its LOGICAL identity, stable across a re-write that mints a new UUID. Checking
42
+ * both separates two findings with very different remedies:
43
+ *
44
+ * `dangling-learning-id` — neither the id nor the key resolves. The evidence
45
+ * is genuinely gone; the rule needs re-derivation
46
+ * or retirement.
47
+ * `superseded-learning-id` — the id does not resolve but the key DOES. The
48
+ * record was re-created under a new UUID; the fix
49
+ * is a one-line re-stamp of the id, not a
50
+ * re-derivation.
51
+ * `dangling-learning-key` — a key-only provenance block whose key resolves
52
+ * nowhere (same rot, no id to re-stamp).
53
+ *
54
+ * On 2026-08-12 all 11 findings are `dangling-learning-id` (0 superseded), but
55
+ * `superseded` is the state any backfill that re-mints ids lands in, so the two
56
+ * are distinguished at the point where the operator reads the output.
57
+ *
58
+ * ## Mode: WARN, never blocking
59
+ *
60
+ * Findings print as `WARN:` and the runner returns 0. This is deliberate, and
61
+ * mirrors the rationale already written down in `check-unwired-features.mjs`:
62
+ * 11 of 13 pointers dangle at HEAD, so a blocking gate would be red on arrival,
63
+ * and a gate that is red on arrival gets disabled — the same disease this file
64
+ * exists to treat, one level up. It also decouples the gate's green-ness from a
65
+ * backfill landing: the census reports the number, the operator decides.
66
+ * Only a genuine tool error (an unreadable rules directory) prints `FAIL:` and
67
+ * returns 2 — a check that could not run must be visible.
68
+ *
69
+ * ## Defined behaviour for every degenerate input
70
+ *
71
+ * - No `.claude/rules/` directory, or no `.md` files → PASS, nothing to audit.
72
+ * - No rule carries provenance → PASS, nothing to audit.
73
+ * - BOTH stores missing → a single `stores-absent` WARN instead of N dangling
74
+ * findings. With no evidence corpus present, "dangling" and "not checkable
75
+ * here" are indistinguishable, and claiming the former would be a lie in any
76
+ * consumer repo that has not started collecting learnings.
77
+ * - One store missing → the other still resolves; absence is reported in the
78
+ * summary and in `stores.*.present`.
79
+ * - Unparseable JSONL line → skipped by `readLearnings` and COUNTED into
80
+ * `summary.malformedStoreLines`, which is surfaced in the summary line. The
81
+ * rest of the store is still used; a corrupt line never silently swallows
82
+ * the corpus, and never crashes the gate.
83
+ * - Provenance block with a key but no id → audited on the key axis.
84
+ * - A single rule file unreadable → skipped with a per-file `tool-error`
85
+ * finding (printed as WARN, since the census still completes over the rest).
86
+ * Only the DIRECTORY being unenumerable sets `toolError` and prints FAIL.
87
+ *
88
+ * ## Named residuals (so nobody over-reads the coverage claim)
89
+ *
90
+ * - **Structured pointers only.** Scope is the list-item form
91
+ * (`- learning-id:` / `- learning-key:`), which is what the emitter writes.
92
+ * A HAND-WRITTEN prose citation is not covered — e.g. `.claude/rules/testing.md`
93
+ * cites `learning id \`mac-gitlab-runner-cpu-starvation-...\`` mid-sentence
94
+ * for a record that exists nowhere in the repo. Catching that class needs a
95
+ * free-text scanner with a false-positive budget; this check deliberately
96
+ * stays on the machine-written form it can resolve exactly.
97
+ * - **First pointer wins.** The first `- learning-id:` and the first
98
+ * `- learning-key:` line in a file are audited (mirroring the `grep -m1`
99
+ * measurement the finding was reported with). Every emitted rule carries
100
+ * exactly one of each; a hypothetical second id is not audited.
101
+ * - **Existence, not agreement.** A resolving id whose record has since been
102
+ * re-worded, re-confidenced, or contradicted still reads as resolved. This
103
+ * check answers "does the evidence exist", not "does the rule still match
104
+ * it".
105
+ *
106
+ * ## Read discipline
107
+ *
108
+ * Files are read with `readFileSync`, never via a `grep` spawn. One NUL byte
109
+ * makes a text file invisible to grep-based audits (exit 1, no output, no
110
+ * warning — see the `anti-pattern-a-nul-byte-in-a-tracked-production-file-...`
111
+ * rule; `scripts/lib/reconcile/emitter.mjs` in this very repo is such a file),
112
+ * and a silently-skipped file reads exactly like a file that passed.
113
+ *
114
+ * ## Usage
115
+ *
116
+ * check-learning-provenance.mjs [<plugin-root>] [--json] [--help]
117
+ *
118
+ * Exit codes:
119
+ * 0 — census completed (findings are WARN-only and do NOT change this)
120
+ * 1 — usage error (unknown flag)
121
+ * 2 — tool error (the rules directory could not be enumerated)
122
+ *
123
+ * Import-safety: importing this module exposes the inspector and runner only;
124
+ * the CLI path is guarded at the bottom of the file.
125
+ */
126
+
127
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
128
+ import path from 'node:path';
129
+ import { pathToFileURL } from 'node:url';
130
+ import { readLearnings } from '../learnings/io.mjs';
131
+ // THE shared key derivation. This check resolves a rule's STAMPED
132
+ // `learning-key` against keys it derives from the store, so it is the one place
133
+ // where writer-vs-reader disagreement shows up as a false `dangling-learning-key`
134
+ // finding — it must derive the key with the emitter's function, not a copy of
135
+ // the emitter's formula.
136
+ import { learningKeyOf } from '../learnings/kebab.mjs';
137
+
138
+ /** Directory holding the rule corpus, relative to the plugin root. */
139
+ const RULES_REL = path.join('.claude', 'rules');
140
+
141
+ /** Live learnings store, relative to the plugin root. */
142
+ const LIVE_STORE_REL = path.join('.orchestrator', 'metrics', 'learnings.jsonl');
143
+
144
+ /** Append-only archive sidecar the expiry sweep moves records into. */
145
+ const ARCHIVE_STORE_REL = path.join('.orchestrator', 'metrics', 'learnings-archive.jsonl');
146
+
147
+ /** First `- learning-id: <value>` list item in a rule body. */
148
+ const LEARNING_ID_RE = /^[-*][ \t]+learning-id:[ \t]*(.+)$/m;
149
+
150
+ /** First `- learning-key: <value>` list item in a rule body. */
151
+ const LEARNING_KEY_RE = /^[-*][ \t]+learning-key:[ \t]*(.+)$/m;
152
+
153
+ /**
154
+ * @typedef {{
155
+ * kind: 'dangling-learning-id' | 'superseded-learning-id' | 'dangling-learning-key'
156
+ * | 'stores-absent' | 'tool-error',
157
+ * file: string,
158
+ * learningId: string | null,
159
+ * learningKey: string | null,
160
+ * message: string,
161
+ * }} Finding
162
+ */
163
+
164
+ /**
165
+ * @typedef {{
166
+ * ok: boolean,
167
+ * summary: {
168
+ * rulesScanned: number,
169
+ * rulesWithProvenance: number,
170
+ * resolved: number,
171
+ * dangling: number,
172
+ * superseded: number,
173
+ * malformedStoreLines: number,
174
+ * },
175
+ * stores: {
176
+ * live: {path: string, present: boolean, records: number},
177
+ * archive: {path: string, present: boolean, records: number},
178
+ * },
179
+ * findings: Finding[],
180
+ * toolError: boolean,
181
+ * }} Inspection
182
+ */
183
+
184
+ /**
185
+ * Display form of a store path: repo-relative when it sits inside the plugin
186
+ * root, absolute otherwise (a test override or an out-of-tree store).
187
+ *
188
+ * @param {string} pluginRoot
189
+ * @param {string} absolutePath
190
+ * @returns {string}
191
+ */
192
+ function displayPath(pluginRoot, absolutePath) {
193
+ const rel = path.relative(pluginRoot, absolutePath);
194
+ return rel === '' || rel.startsWith('..') || path.isAbsolute(rel) ? absolutePath : rel;
195
+ }
196
+
197
+ /**
198
+ * Strip the decoration the emitter writes around a provenance value: trailing
199
+ * CR (CRLF files), surrounding backticks, surrounding quotes, and whitespace.
200
+ *
201
+ * @param {string} raw
202
+ * @returns {string}
203
+ */
204
+ function cleanValue(raw) {
205
+ return raw
206
+ .replace(/\r$/, '')
207
+ .trim()
208
+ .replace(/^[`'"]+|[`'"]+$/g, '')
209
+ .trim();
210
+ }
211
+
212
+ /**
213
+ * Extract the first structured provenance pointer pair from a rule body.
214
+ *
215
+ * @param {string} body raw rule-file contents
216
+ * @returns {{id: string|null, key: string|null}}
217
+ */
218
+ export function extractProvenance(body) {
219
+ const idMatch = LEARNING_ID_RE.exec(body);
220
+ const keyMatch = LEARNING_KEY_RE.exec(body);
221
+ const id = idMatch ? cleanValue(idMatch[1]) : '';
222
+ const key = keyMatch ? cleanValue(keyMatch[1]) : '';
223
+ return { id: id === '' ? null : id, key: key === '' ? null : key };
224
+ }
225
+
226
+ /**
227
+ * Read one JSONL store into id + logical-key index sets.
228
+ *
229
+ * Never throws: a missing file yields an empty, `present: false` index, and an
230
+ * unparseable line is counted rather than fatal (`readLearnings` isolates it).
231
+ *
232
+ * @param {string} absolutePath
233
+ * @returns {Promise<{present: boolean, records: number, malformed: number, ids: Set<string>, keys: Set<string>}>}
234
+ */
235
+ async function indexStore(absolutePath) {
236
+ const present = existsSync(absolutePath);
237
+ /** @type {Set<string>} */
238
+ const ids = new Set();
239
+ /** @type {Set<string>} */
240
+ const keys = new Set();
241
+ if (!present) return { present, records: 0, malformed: 0, ids, keys };
242
+
243
+ /** @type {{entries: Record<string, unknown>[], malformed: string[]}} */
244
+ let read;
245
+ try {
246
+ read = await readLearnings(absolutePath);
247
+ } catch {
248
+ // An unreadable-but-existing store (permissions, a directory in its place)
249
+ // must not crash the gate. Report it as present-but-empty; every pointer
250
+ // then resolves against the OTHER store, and the zero record count in the
251
+ // summary line is the visible signal that something is wrong here.
252
+ return { present, records: 0, malformed: 0, ids, keys };
253
+ }
254
+
255
+ const { entries, malformed } = read;
256
+ for (const entry of entries) {
257
+ if (entry && typeof entry.id === 'string' && entry.id !== '') ids.add(entry.id);
258
+ const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
259
+ if (key !== null) keys.add(key);
260
+ }
261
+ return { present, records: entries.length, malformed: malformed.length, ids, keys };
262
+ }
263
+
264
+ /**
265
+ * Census every `.claude/rules/*.md` provenance pointer against the live store
266
+ * and the archive. Pure with respect to the repo: reads only, writes nothing.
267
+ *
268
+ * @param {string} pluginRoot absolute plugin root
269
+ * @param {{livePath?: string, archivePath?: string}} [opts] store overrides (tests)
270
+ * @returns {Promise<Inspection>}
271
+ */
272
+ export async function inspectLearningProvenance(pluginRoot, opts = {}) {
273
+ /** @type {Finding[]} */
274
+ const findings = [];
275
+ const livePath = opts.livePath ?? path.join(pluginRoot, LIVE_STORE_REL);
276
+ const archivePath = opts.archivePath ?? path.join(pluginRoot, ARCHIVE_STORE_REL);
277
+
278
+ /** @type {Inspection} */
279
+ const result = {
280
+ ok: false,
281
+ summary: {
282
+ rulesScanned: 0,
283
+ rulesWithProvenance: 0,
284
+ resolved: 0,
285
+ dangling: 0,
286
+ superseded: 0,
287
+ malformedStoreLines: 0,
288
+ },
289
+ stores: {
290
+ live: { path: displayPath(pluginRoot, livePath), present: false, records: 0 },
291
+ archive: { path: displayPath(pluginRoot, archivePath), present: false, records: 0 },
292
+ },
293
+ findings,
294
+ toolError: false,
295
+ };
296
+
297
+ const rulesDir = path.join(pluginRoot, RULES_REL);
298
+ if (!existsSync(rulesDir)) {
299
+ result.ok = true;
300
+ return result;
301
+ }
302
+
303
+ /** @type {string[]} */
304
+ let ruleFiles;
305
+ try {
306
+ ruleFiles = readdirSync(rulesDir)
307
+ .filter((name) => name.endsWith('.md'))
308
+ .sort();
309
+ } catch (error) {
310
+ result.toolError = true;
311
+ findings.push({
312
+ kind: 'tool-error',
313
+ file: RULES_REL,
314
+ learningId: null,
315
+ learningKey: null,
316
+ message: `cannot enumerate the rules directory: ${error instanceof Error ? error.message : String(error)}`,
317
+ });
318
+ return result;
319
+ }
320
+ result.summary.rulesScanned = ruleFiles.length;
321
+
322
+ // Collect the pointers BEFORE touching the stores: a corpus with no
323
+ // provenance at all needs no store read.
324
+ /** @type {{file: string, id: string|null, key: string|null}[]} */
325
+ const pointers = [];
326
+ for (const name of ruleFiles) {
327
+ let body;
328
+ try {
329
+ // readFileSync, never a grep spawn — see § Read discipline in the header.
330
+ body = readFileSync(path.join(rulesDir, name), 'utf8');
331
+ } catch (error) {
332
+ findings.push({
333
+ kind: 'tool-error',
334
+ file: path.join(RULES_REL, name),
335
+ learningId: null,
336
+ learningKey: null,
337
+ message: `cannot read rule file: ${error instanceof Error ? error.message : String(error)}`,
338
+ });
339
+ continue;
340
+ }
341
+ const { id, key } = extractProvenance(body);
342
+ if (id === null && key === null) continue;
343
+ pointers.push({ file: path.join(RULES_REL, name), id, key });
344
+ }
345
+ result.summary.rulesWithProvenance = pointers.length;
346
+
347
+ const [live, archive] = await Promise.all([indexStore(livePath), indexStore(archivePath)]);
348
+ result.stores.live.present = live.present;
349
+ result.stores.live.records = live.records;
350
+ result.stores.archive.present = archive.present;
351
+ result.stores.archive.records = archive.records;
352
+ result.summary.malformedStoreLines = live.malformed + archive.malformed;
353
+
354
+ if (pointers.length === 0) {
355
+ result.ok = findings.length === 0;
356
+ return result;
357
+ }
358
+
359
+ // No evidence corpus at all → "dangling" is not a claim this check can honestly
360
+ // make. Report the absence once instead of N times.
361
+ if (!live.present && !archive.present) {
362
+ findings.push({
363
+ kind: 'stores-absent',
364
+ file: RULES_REL,
365
+ learningId: null,
366
+ learningKey: null,
367
+ message:
368
+ `${pointers.length} rule file(s) carry provenance but neither ${result.stores.live.path} nor ` +
369
+ `${result.stores.archive.path} exists — pointers cannot be resolved here (not reported as dangling)`,
370
+ });
371
+ return result;
372
+ }
373
+
374
+ const idResolves = (/** @type {string} */ id) => live.ids.has(id) || archive.ids.has(id);
375
+ const keyResolves = (/** @type {string} */ key) => live.keys.has(key) || archive.keys.has(key);
376
+
377
+ for (const { file, id, key } of pointers) {
378
+ if (id !== null) {
379
+ if (idResolves(id)) {
380
+ result.summary.resolved += 1;
381
+ continue;
382
+ }
383
+ if (key !== null && keyResolves(key)) {
384
+ result.summary.superseded += 1;
385
+ findings.push({
386
+ kind: 'superseded-learning-id',
387
+ file,
388
+ learningId: id,
389
+ learningKey: key,
390
+ message:
391
+ `learning-id \`${id}\` resolves in neither store, but learning-key \`${key}\` does — the record was ` +
392
+ 're-created under a new id; re-stamp the learning-id from the record carrying this key',
393
+ });
394
+ continue;
395
+ }
396
+ result.summary.dangling += 1;
397
+ findings.push({
398
+ kind: 'dangling-learning-id',
399
+ file,
400
+ learningId: id,
401
+ learningKey: key,
402
+ message:
403
+ `learning-id \`${id}\`${key === null ? '' : ` (key \`${key}\`)`} resolves in neither the live store ` +
404
+ 'nor the archive',
405
+ });
406
+ continue;
407
+ }
408
+
409
+ // Key-only provenance block: audit the axis that IS present.
410
+ const presentKey = /** @type {string} */ (key);
411
+ if (keyResolves(presentKey)) {
412
+ result.summary.resolved += 1;
413
+ continue;
414
+ }
415
+ result.summary.dangling += 1;
416
+ findings.push({
417
+ kind: 'dangling-learning-key',
418
+ file,
419
+ learningId: null,
420
+ learningKey: presentKey,
421
+ message:
422
+ `learning-key \`${presentKey}\` matches no record in the live store or the archive, and the block ` +
423
+ 'carries no learning-id to resolve instead',
424
+ });
425
+ }
426
+
427
+ result.ok = findings.length === 0;
428
+ return result;
429
+ }
430
+
431
+ /**
432
+ * Run the human-readable validator CLI.
433
+ *
434
+ * WARN-ONLY: findings print as WARN and still return 0. See § Mode in the
435
+ * header for why a blocking gate would be red on arrival on this repo.
436
+ *
437
+ * @param {string} pluginRoot absolute plugin root
438
+ * @returns {Promise<number>} 0 = census completed, 2 = tool error
439
+ */
440
+ export async function runCheckLearningProvenance(pluginRoot) {
441
+ console.log('--- Check: learning provenance pointers in .claude/rules/ (WARN-only) ---');
442
+ const inspection = await inspectLearningProvenance(pluginRoot);
443
+
444
+ if (inspection.toolError) {
445
+ for (const item of inspection.findings) {
446
+ console.log(` FAIL: ${item.file} — ${item.message}`);
447
+ }
448
+ console.log('');
449
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
450
+ return 2;
451
+ }
452
+
453
+ for (const item of inspection.findings) {
454
+ console.log(` WARN: [${item.kind}] ${item.file} — ${item.message}`);
455
+ }
456
+
457
+ const { rulesScanned, rulesWithProvenance, resolved, dangling, superseded, malformedStoreLines } =
458
+ inspection.summary;
459
+ if (dangling > 0) {
460
+ console.log(
461
+ ` WARN: ${dangling} of ${rulesWithProvenance} provenance pointer(s) resolve in neither ` +
462
+ `${inspection.stores.live.path} nor ${inspection.stores.archive.path} — those rules cite evidence ` +
463
+ 'that no longer exists and cannot be re-confidenced, audited, or expired on purpose',
464
+ );
465
+ }
466
+ const storeNote =
467
+ `${inspection.stores.live.records} live + ${inspection.stores.archive.records} archived record(s)` +
468
+ (malformedStoreLines > 0 ? `, ${malformedStoreLines} unparseable store line(s) skipped` : '');
469
+ console.log(
470
+ ` PASS: checked ${rulesWithProvenance} provenance pointer(s) across ${rulesScanned} rule file(s) ` +
471
+ `against ${storeNote} — ${resolved} resolved, ${dangling} dangling, ${superseded} superseded`,
472
+ );
473
+ console.log('');
474
+ console.log('Results: 1 passed, 0 failed');
475
+ return 0;
476
+ }
477
+
478
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
479
+ if (isMain) {
480
+ const argv = process.argv.slice(2);
481
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
482
+ const positional = argv.filter((a) => !a.startsWith('--'));
483
+ const usage =
484
+ 'Usage: check-learning-provenance.mjs [<plugin-root>] [--json]\n' +
485
+ ' --json emit the inspection envelope as a single JSON object on stdout\n' +
486
+ 'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
487
+
488
+ if (flags.has('--help')) {
489
+ console.log(usage);
490
+ process.exitCode = 0;
491
+ } else {
492
+ const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
493
+ if (unknown.length > 0) {
494
+ console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
495
+ process.exitCode = 1;
496
+ } else {
497
+ const pluginRoot = path.resolve(positional[0] ?? process.cwd());
498
+ if (flags.has('--json')) {
499
+ const inspection = await inspectLearningProvenance(pluginRoot);
500
+ // Data on stdout, diagnostics on stderr (cli-design.md).
501
+ console.log(JSON.stringify(inspection, null, 2));
502
+ process.exitCode = inspection.toolError ? 2 : 0;
503
+ } else {
504
+ process.exitCode = await runCheckLearningProvenance(pluginRoot);
505
+ }
506
+ }
507
+ }
508
+ // Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout writes
509
+ // still queued in the async write buffer (the `--json` envelope can outgrow the
510
+ // ~64 KiB pipe capacity). Setting exitCode lets the writes drain first.
511
+ }