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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +494 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +757 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- 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
|
+
}
|