@lifeaitools/rdc-skills 0.36.1 → 0.37.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.36.1",
3
+ "version": "0.37.1",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
@@ -53,8 +53,19 @@ jobs:
53
53
  - name: Ensure npm supports trusted publishing
54
54
  run: npm install -g npm@latest && npm --version
55
55
 
56
+ # Every tag from v0.33.0 to v0.37.0 failed here with a bare E404 on the PUT and no
57
+ # reason, and no published version carries provenance, so the OIDC exchange has not
58
+ # succeeded once since this workflow moved to it. npm logs that exchange only at
59
+ # verbose level. The next step pulls those lines out, so the reason is printed
60
+ # rather than inferred.
56
61
  - name: Publish
57
- run: npm publish --access public
62
+ run: |
63
+ set -o pipefail
64
+ npm publish --access public --loglevel verbose 2>&1 | tee publish.log
65
+
66
+ - name: OIDC exchange result
67
+ if: always()
68
+ run: grep -iE 'oidc|provenance|trusted|npm error' publish.log || echo "no oidc lines logged"
58
69
 
59
70
  - name: Notify clauth channel
60
71
  if: always()
package/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.37.1 — one version in every manifest
11
+
12
+ ### Fixed
13
+ - `clauth-plugin.json` declared 0.35.10 while the package shipped 0.37.0, and
14
+ `package.json` `claude.version` still said 0.10.0. Both are now 0.37.1, with
15
+ `.claude-plugin/plugin.json`, `package.json` and `package-lock.json`. Nothing had
16
+ failed on either: the only check, `rdc-skills-self-test`, compared `plugin.json`
17
+ alone.
18
+
19
+ ### Added
20
+ - `scripts/lib/manifest-versions.mjs` is one list of every place the package
21
+ declares its version, built from a repository search for `"version"`. Three
22
+ things read it:
23
+ - `tests/manifest-version-parity.test.mjs` (in `test:acceptance`) fails on any
24
+ declaration that differs from `package.json`. It also scans the repository and
25
+ fails on any JSON file with a top-level `version` that the list does not name.
26
+ - `rdc-skills-self-test` now checks every packaged declaration, not only
27
+ `plugin.json`.
28
+ - The npm `version` lifecycle hook, `scripts/sync-manifest-versions.mjs --stage`,
29
+ means `npm version patch|minor|major` sets and stages every manifest in the
30
+ same step. `--check` changes nothing and exits 1 on drift.
31
+
32
+ ### Changed
33
+ - `RELEASE.md` step 2 drops the hand edit of `plugin.json`: `npm version` now sets
34
+ every manifest, and `node scripts/sync-manifest-versions.mjs --check` confirms it.
35
+
36
+ ---
37
+
38
+ ## v0.37.0 — review gates run the deterministic validators, in 1–2 minutes
39
+
40
+ ### Changed
41
+ - The code-review step in `rdc:build` (9b), `rdc:fixit` (5.5), `rdc:review`
42
+ (8b), `rdc:release` and `rdc:overnight` now runs one deterministic gate,
43
+ `guides/review-gate.md`:
44
+ - `rdc-solid-score --diff <base>`;
45
+ - the repo linter on changed files (`--write`, then check);
46
+ - `tsc`;
47
+ - the touched package's tests;
48
+ - the UI spec when a route changed.
49
+
50
+ On the PRT launch build it took 9.3 s over 33 changed code files, where each
51
+ open-ended `pr-review-toolkit:code-reviewer` pass had taken 4–10 minutes. The
52
+ reviewer agent remains, but only for a named question the validators cannot
53
+ answer.
54
+ - `rdc-clean-code-score` is documented as advisory. It has no `--diff`, and in a
55
+ monorepo it needs `--project-root <package>`; its default dead-export scope ran
56
+ out of 4 GB of heap on regen-root.
57
+
58
+ ### Fixed
59
+ - `rdc-solid-score --diff` exited 2 on any diff that added a file. Git's
60
+ "exists on disk, but not in <ref>" wording was not recognized as absent at the
61
+ ref; it now is. A bad ref or a path-form mismatch is still rethrown.
62
+ - `rdc-solid-score --diff` exited 2 with ENOENT on any diff that deleted a file.
63
+ Deletions are now split out with `--diff-filter=D` and reported in a new
64
+ `removedFiles` output field.
65
+ - The README File Structure omitted `convo` (shipped in 0.36.0), which failed
66
+ `npm run test:acceptance`.
67
+
68
+ ---
69
+
10
70
  ## v0.35.11 — Design Review is explicit opt-in
11
71
 
12
72
  ### Changed
package/README.md CHANGED
@@ -321,6 +321,7 @@ skills/
321
321
  co-develop/SKILL.md (peer-aware Claude/Codex co-development)
322
322
  collab/SKILL.md (bidirectional claude.ai ↔ Claude Code relay)
323
323
  convert/SKILL.md (Office/Markdown conversion)
324
+ convo/SKILL.md (attributed discussion that converges to a decision)
324
325
  deploy/SKILL.md (Coolify ops: deploy, new, diagnose, audit)
325
326
  design/SKILL.md (RDC/Studio design, Palette Library, Rampa CLI)
326
327
  edit/SKILL.md (local website editor launcher)
package/RELEASE.md CHANGED
@@ -27,16 +27,19 @@ git checkout master
27
27
  git pull --ff-only origin master
28
28
  git merge <feature-branch> --no-edit
29
29
 
30
- # 2. Bump the version — package.json AND the plugin manifest, they must match.
31
- # rdc-skills-self-test fails the whole run (plugin manifest FAIL) if they drift,
32
- # and .claude-plugin/plugin.json ships inside the published npm package, so a
33
- # stale plugin.json version is a real defect in the release, not a lint nit.
30
+ # 2. Bump the version. `npm version` bumps package.json and package-lock.json, then
31
+ # its `version` lifecycle hook (scripts/sync-manifest-versions.mjs --stage) sets
32
+ # every other declaration to match and stages it: .claude-plugin/plugin.json,
33
+ # clauth-plugin.json, and package.json `claude.version`. The list lives in
34
+ # scripts/lib/manifest-versions.mjs. Never hand-edit a manifest version.
34
35
  npm version patch|minor|major --no-git-tag-version
35
- # then hand-edit .claude-plugin/plugin.json's top-level "version" field to match —
36
- # there is no script for this yet, only the self-test catches drift after the fact.
36
+ node scripts/sync-manifest-versions.mjs --check # exit 1 names any declaration that drifted
37
+ # tests/manifest-version-parity.test.mjs and rdc-skills-self-test (plugin
38
+ # manifest FAIL) both fail on drift. 0.37.0 shipped clauth-plugin.json at 0.35.10
39
+ # because the self-test then checked plugin.json alone.
37
40
 
38
- # 3. Commit, tag, and push
39
- git add package.json .claude-plugin/plugin.json
41
+ # 3. Commit, tag, and push (the hook already staged every manifest)
42
+ git add CHANGELOG.md
40
43
  git commit -m "release: v$(node -e \"console.log(require('./package.json').version)\")"
41
44
  git tag "v$(node -e \"console.log(require('./package.json').version)\")"
42
45
  git push origin master --tags
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "lifeai.plugin.v1",
3
3
  "id": "rdc-skills",
4
- "version": "0.35.10",
4
+ "version": "0.37.1",
5
5
  "publisher": "LIFEAI",
6
6
  "core": false,
7
7
  "documentation": {
@@ -8,4 +8,8 @@ description: rdc:compare-compliance <path> --diff <ref> — gate a change agains
8
8
  Use Skill tool with skill: "solid-validator", passing the path and a
9
9
  required `--diff <ref>` as args. Read `regressions` and `boundaryViolations`
10
10
  from the output — either non-empty means the change made compliance worse
11
- than the base ref, not merely "still imperfect."
11
+ than the base ref, not merely "still imperfect." `removedFiles` lists what the
12
+ change deleted; those are reported, never scored.
13
+
14
+ This is step 1 of the review gate. For the whole gate (lint, tsc, tests and UI
15
+ spec as well), follow `guides/review-gate.md`.
@@ -7,4 +7,6 @@ description: rdc:quick-check <path> — fast mechanical pass, SOLID score and bo
7
7
 
8
8
  Use Skill tool with skill: "solid-validator", passing the path as args.
9
9
  This is the mechanical-only subset — no judgment dispatch, meant to run in
10
- seconds during active editing. Use `full-analysis` before a merge.
10
+ seconds during active editing. Before a merge, run the review gate in
11
+ `guides/review-gate.md`, which takes one to two minutes. `full-analysis` is a
12
+ deeper advisory pass, not the merge gate.
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "a5325ff86c953a4f64b3deef96551975d530f528"
2
+ "sha": "ed080efda2d2537ca723128a016188aefd8f8fb5"
3
3
  }
@@ -0,0 +1,133 @@
1
+ # Review Gate — deterministic validators on the diff
2
+
3
+ > The code-review gate used by `rdc:build` (step 9b), `rdc:fixit` (5.5),
4
+ > `rdc:review` (8b), `rdc:release` and `rdc:overnight`. It runs the specific
5
+ > validators over the changed files and finishes in one to two minutes.
6
+ >
7
+ > Approved: direct operator instruction — "we need to tune our code reviews to
8
+ > be specific -- if you ran the validator for solid and lint etc... it should
9
+ > take 1-2 minutes". 2026-09-21, Dave, in session.
10
+
11
+ ## Why this replaced the open-ended reviewer
12
+
13
+ The previous default dispatched one `pr-review-toolkit:code-reviewer` agent to
14
+ read the whole diff. On the PRT launch build (2026-09-21) four such passes took
15
+ 4 to 10 minutes and roughly 200–300k tokens each. Each one was a new judgment
16
+ call, so two runs could not be compared.
17
+
18
+ The gate below took **9.3 seconds** over the same build's 33 changed code files.
19
+ It found two things that had shipped:
20
+
21
+ - a SOLID regression: `investors/page.tsx` went from 96.3 to 85, on OCP;
22
+ - 45 lint diagnostics in 14 touched files. Most were import order. Three were
23
+ accessibility defects: two buttons with no `type`, and an SVG with no title.
24
+
25
+ A number that two runs agree on can block a merge. An opinion cannot.
26
+
27
+ ## The gate
28
+
29
+ Run every step and report every result. A red step does not stop the others,
30
+ because the reader needs the whole picture in one pass.
31
+
32
+ ```bash
33
+ BASE=<ref the change is measured against> # wave base sha, origin/develop, last release tag
34
+ SCOPE=<package or app dir the change touches> # never the monorepo root
35
+ CHANGED=$(git diff --name-only --diff-filter=d "$BASE" -- "$SCOPE" | grep -E '\.(ts|tsx|js|jsx|mjs|cjs)$')
36
+ ```
37
+
38
+ | # | Check | Command | Blocks when |
39
+ |---|---|---|---|
40
+ | 1 | **FORM**: SOLID and boundaries | `rdc-solid-score "$SCOPE" --diff "$BASE" --format json` (add `--config <repo>/.solid-score.yml` if the repo has one) | `regressions`, `boundaryViolations` or `boundaryMisconfigured` is non-empty |
41
+ | 2 | **LINT**: changed files only | the repo's own linter; fix first, then check: `npx biome check --write $CHANGED` then `npx biome check $CHANGED` (ESLint: `npx eslint --fix` then `npx eslint`) | the second command exits non-zero |
42
+ | 3 | **TYPES** | `npx tsc --noEmit -p <package>/tsconfig.json`, once per touched package | exit non-zero |
43
+ | 4 | **TESTS**, scoped | the touched package's own test command, e.g. `pnpm --filter <pkg> exec vitest run` | any failure |
44
+ | 5 | **RENDERED UI**, if a route changed | the project's pinned `test:ui` Playwright spec against a fresh app-owned server (`rdc:review` step 9a) | any red assertion, or no harness |
45
+
46
+ Read the scorer output fully:
47
+
48
+ - `removedFiles` lists deletions. They are reported, not scored: a deleted file has no units left that can regress.
49
+ - `unresolvedLanguages` lists files no plugin parses, such as `.css`. They are unmeasured, never passed.
50
+
51
+ ### Rules that keep it honest
52
+
53
+ - **Lint is fixed, never baselined.** Diagnostics already in a touched file
54
+ are fixed in the same change. Run `--write` first, and most of them clear
55
+ mechanically. Do not add an ignore list or a count ratchet.
56
+ - **SOLID is a ratchet by design.** `--diff` compares each unit with itself at
57
+ `$BASE`. OCP and LSP report `low` confidence, and that confidence is data. A
58
+ regression still blocks. The usual fix for an OCP drop is to move the
59
+ branching into a named module; for `investors/page.tsx` that means taking the
60
+ data read and FAQ shaping out into a loader.
61
+ - **Stay inside the time budget by narrowing scope.** Changed files and touched
62
+ packages only. If the gate takes more than two minutes, it is scanning too
63
+ wide. Narrow `$SCOPE` rather than dropping a step.
64
+ - **Never edit what a gate measures to turn it green.** A red check is either
65
+ fixed properly or reported as a finding.
66
+
67
+ ### New UI tests: prove them before trusting them
68
+
69
+ Three lessons from the same build:
70
+
71
+ - **Negative-control every new UI assertion.** Run it once against the broken
72
+ or old state and watch it go red. A test that has never failed has not
73
+ proved anything. The CTA-clipping spec was trusted only after it failed on
74
+ the old CSS.
75
+ - **Measure settled layout.** Take bounding boxes after motion finishes, using
76
+ reduced motion or waiting for the animation to end. A panel measured
77
+ mid-slide reports the wrong size.
78
+ - **Include a 1280×800 viewport.** A laptop screen is where a tall panel
79
+ clips its call-to-action. 1440 and 390 alone missed it.
80
+
81
+ ## When an LLM reviewer is still right
82
+
83
+ Only for a **named question the validators cannot answer**, scoped to the
84
+ files that question touches. Examples:
85
+
86
+ - "Does this middleware bypass skip auth on any protected path?"
87
+ - "Does any public string state a return figure?"
88
+
89
+ ```
90
+ Agent({
91
+ subagent_type: "pr-review-toolkit:code-reviewer",
92
+ description: "<the question, in five words>",
93
+ prompt: "Answer one question about <files>: <question>. Read only those files.
94
+ Return CODE_REVIEW_COMPLETE with { critical_count, high_count, medium_count,
95
+ low_count, findings: [{severity, file:line, issue, suggested_fix}] }."
96
+ })
97
+ ```
98
+
99
+ A reviewer asked to "review the diff" with no question is the pattern this
100
+ guide retired. Critical and high findings from a named-question pass block, the
101
+ same as a red gate step.
102
+
103
+ ## What is not in the default gate
104
+
105
+ | Tool | Why it is not in the default gate | When to run it |
106
+ |---|---|---|
107
+ | `rdc-clean-code-score` | No `--diff`, so it reports absolute counts. On the PRT build it reported 475 findings in 17 files, 459 of them N4 "magic number" on SVG coordinates. Its default dead-export scope is the whole git repo, which ran out of 4 GB of heap on regen-root. | Advisory, in `rdc:full-analysis`. Always pass `--project-root <package dir>`; that run took 7.9 s. |
108
+ | `rdc-architecture-score` | No `--diff`. With no `layers:` config, every file is `unclassified`, so it measures nothing. | When the repo declares layers. |
109
+ | `rdc-duplication-score` | Absolute, not a delta. | Advisory. |
110
+
111
+ ## Review is not closure
112
+
113
+ The gate replaces the per-wave LLM code review. It does **not** replace the
114
+ validator.
115
+
116
+ A non-epic work item still reaches `done` only through
117
+ `update_work_item_status(..., 'done', ..., <validator session>, 'validator')`,
118
+ and the database refuses an actor session equal to the item's own. A forked
119
+ agent shares its parent's session id, so a fork cannot validate the parent's
120
+ item. Dispatch a fresh agent for that.
121
+
122
+ ## Receipt
123
+
124
+ Emit this line. The skill that ran the gate quotes it in the implementation
125
+ report or review report:
126
+
127
+ ```
128
+ REVIEW_GATE: { base, scope, files, solid: { regressions, boundaryViolations, removedFiles, unresolved },
129
+ lint: <exit>, tsc: <exit>, tests: "<N passed>", ui: "<spec + viewports> | n/a",
130
+ elapsed_s, verdict: "CLEAN|HAS_ISSUES" }
131
+ ```
132
+
133
+ Under `RDC_TEST=1`: echo `[RDC_TEST] skipping review gate` and continue.
@@ -156,6 +156,17 @@ function isGitCommit(command) {
156
156
  function extractCommitMessage(command) {
157
157
  const cmd = String(command || '');
158
158
 
159
+ // `-m "$(cat <<'EOF' … EOF)"` / `-m "$(cat <<-'EOF' … EOF)"`: the plain -m
160
+ // regexes below match the FIRST unrelated quote inside the substitution
161
+ // (the opening `'` of `<<'EOF'` reads as a closing delimiter to the
162
+ // non-greedy pattern) and return early with literal shell text as the
163
+ // "message" -- before the heredoc fallback ever runs. This is the same
164
+ // defect class the 2026-08-29 fix addressed for a bare heredoc, just one
165
+ // wrapper layer further out: detect the substitution shape first and
166
+ // extract from its OWN heredoc body.
167
+ const mSub = cmd.match(/-m\s+"\$\(\s*cat\s+<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1\s*\n([\s\S]*?)\n[ \t]*\2\s*\)"/);
168
+ if (mSub) return { message: mSub[3], readable: true };
169
+
159
170
  const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/s) ||
160
171
  cmd.match(/-m\s+"([\s\S]+?)"\s*(?:&&|$)/);
161
172
  if (msgMatch) return { message: msgMatch[1], readable: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.36.1",
3
+ "version": "0.37.1",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -19,7 +19,7 @@
19
19
  "type": "plugin",
20
20
  "skills": "skills/",
21
21
  "guides": "guides/",
22
- "version": "0.10.0",
22
+ "version": "0.37.1",
23
23
  "commands": "commands/"
24
24
  },
25
25
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "rdc-design": "node scripts/rdc-design-cli.mjs",
46
46
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
47
47
  "test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/work-item-exit-gate-l3.test.mjs && node tests/require-work-item-on-commit.test.mjs && node tests/harness-gates.test.mjs && node tests/foreground-process-gate.test.mjs",
48
- "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
48
+ "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node --test tests/manifest-version-parity.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
49
49
  "acceptance": "node scripts/acceptance.mjs --changed",
50
50
  "test:mcp": "node tests/mcp.test.mjs",
51
51
  "test:mcp:remote": "node tests/mcp.test.mjs --remote",
@@ -56,6 +56,7 @@
56
56
  "test:brochure": "node tests/rdc-brochure.test.mjs",
57
57
  "mcp": "node bin/rdc-skills-mcp.mjs",
58
58
  "prepack": "node scripts/prepack.mjs",
59
+ "version": "node scripts/sync-manifest-versions.mjs --stage",
59
60
  "test:deploy": "node tests/deploy-updater.test.mjs"
60
61
  },
61
62
  "dependencies": {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Every place this package states its own version, and one way to keep them equal.
3
+ *
4
+ * package.json `version` is the source. Every entry in VERSION_DECLARATIONS must equal
5
+ * it. The list was built from a repository search for `"version"`, not from memory:
6
+ * 0.37.0 shipped with clauth-plugin.json at 0.35.10 and package.json `claude.version`
7
+ * at 0.10.0, because the only check (self-test) compared plugin.json alone.
8
+ *
9
+ * Three readers share this list, so none can drift from the others:
10
+ * - tests/manifest-version-parity.test.mjs fails on any mismatch, and on any JSON
11
+ * file with a top-level `version` that is not listed here
12
+ * - scripts/self-test.mjs the packaged declarations, post-install
13
+ * - scripts/sync-manifest-versions.mjs the `version` lifecycle hook, so
14
+ * `npm version <x>` moves every declaration and the test never has to catch it
15
+ */
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { isDeepStrictEqual } from 'node:util';
18
+ import path from 'node:path';
19
+
20
+ export const VERSION_SOURCE = Object.freeze({ file: 'package.json', keyPath: Object.freeze(['version']) });
21
+
22
+ /** `packaged: false` — npm never ships the file, so an installed copy cannot check it. */
23
+ export const VERSION_DECLARATIONS = Object.freeze([
24
+ { file: 'package.json', keyPath: ['claude', 'version'], packaged: true },
25
+ { file: '.claude-plugin/plugin.json', keyPath: ['version'], packaged: true },
26
+ { file: 'clauth-plugin.json', keyPath: ['version'], packaged: true },
27
+ { file: 'package-lock.json', keyPath: ['version'], packaged: false },
28
+ { file: 'package-lock.json', keyPath: ['packages', '', 'version'], packaged: false },
29
+ ].map((d) => Object.freeze({ ...d, keyPath: Object.freeze([...d.keyPath]) })));
30
+
31
+ export function label({ file, keyPath }) {
32
+ return `${file} ${keyPath.map((k) => (k === '' ? '""' : k)).join('.')}`;
33
+ }
34
+
35
+ function getPath(obj, keyPath) {
36
+ let cur = obj;
37
+ for (const key of keyPath) {
38
+ if (cur === null || typeof cur !== 'object' || !(key in cur)) return undefined;
39
+ cur = cur[key];
40
+ }
41
+ return cur;
42
+ }
43
+
44
+ function setPath(obj, keyPath, value) {
45
+ const copy = structuredClone(obj);
46
+ let cur = copy;
47
+ for (const key of keyPath.slice(0, -1)) cur = cur[key];
48
+ cur[keyPath[keyPath.length - 1]] = value;
49
+ return copy;
50
+ }
51
+
52
+ /** `{ exists, text, json, error }` — an unreadable file is reported, never thrown past the caller. */
53
+ function readJson(root, file) {
54
+ const full = path.join(root, file);
55
+ if (!existsSync(full)) return { exists: false, text: null, json: null, error: null };
56
+ try {
57
+ const text = readFileSync(full, 'utf8');
58
+ return { exists: true, text, json: JSON.parse(text), error: null };
59
+ } catch (error) {
60
+ return { exists: true, text: null, json: null, error: error.message };
61
+ }
62
+ }
63
+
64
+ /** The source version: package.json `version`. Throws when package.json cannot say. */
65
+ export function sourceVersion(root) {
66
+ const { json, error } = readJson(root, VERSION_SOURCE.file);
67
+ if (error) throw new Error(`${VERSION_SOURCE.file} is unreadable: ${error}`);
68
+ const version = json ? getPath(json, VERSION_SOURCE.keyPath) : undefined;
69
+ if (typeof version !== 'string' || !version) throw new Error(`${VERSION_SOURCE.file} has no version`);
70
+ return version;
71
+ }
72
+
73
+ /**
74
+ * Every declaration with its current value. Each is read on its own: one unreadable
75
+ * file is that declaration's `error`, and every other declaration is still checked.
76
+ * @returns {{ source: string, declarations: Array<{file, keyPath, packaged, label, exists, value, error}> }}
77
+ */
78
+ export function readDeclarations(root, { packagedOnly = false } = {}) {
79
+ const source = sourceVersion(root);
80
+ const cache = new Map();
81
+ const declarations = VERSION_DECLARATIONS
82
+ .filter((d) => !packagedOnly || d.packaged)
83
+ .map((d) => {
84
+ if (!cache.has(d.file)) cache.set(d.file, readJson(root, d.file));
85
+ const { exists, json, error } = cache.get(d.file);
86
+ return { ...d, label: label(d), exists, error, value: json ? getPath(json, d.keyPath) : undefined };
87
+ });
88
+ return { source, declarations };
89
+ }
90
+
91
+ /** The declarations that do not equal package.json's version (missing or unreadable counts). */
92
+ export function mismatches(root, options = {}) {
93
+ const { source, declarations } = readDeclarations(root, options);
94
+ return declarations.filter((d) => d.error || d.value !== source).map((d) => ({ ...d, expected: source }));
95
+ }
96
+
97
+ /** What a declaration currently says, for a message: the value, or why there is none. */
98
+ export function describeValue(d) {
99
+ if (!d.exists) return '(file missing)';
100
+ if (d.error) return `(unreadable: ${d.error})`;
101
+ if (d.value === undefined) return '(key missing)';
102
+ return JSON.stringify(d.value);
103
+ }
104
+
105
+ /**
106
+ * Replace one string value in JSON text without reformatting the file. Each
107
+ * `"<key>": "<old>"` occurrence is tried in turn and kept only when the result parses
108
+ * to exactly the original document with that one value changed — so a same-named key
109
+ * elsewhere (package-lock.json has hundreds of `version`s) is never the one edited.
110
+ */
111
+ export function replaceJsonString(text, keyPath, oldValue, newValue) {
112
+ const original = JSON.parse(text);
113
+ const want = setPath(original, keyPath, newValue);
114
+ const key = keyPath[keyPath.length - 1];
115
+ const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ const pattern = new RegExp(`("${literal(key)}"\\s*:\\s*)"${literal(oldValue)}"`, 'g');
117
+ for (const match of text.matchAll(pattern)) {
118
+ const at = match.index;
119
+ const candidate = `${text.slice(0, at)}${match[1]}${JSON.stringify(newValue)}${text.slice(at + match[0].length)}`;
120
+ let parsed;
121
+ try { parsed = JSON.parse(candidate); } catch { continue; }
122
+ if (isDeepStrictEqual(parsed, want)) return candidate;
123
+ }
124
+ throw new Error(`could not locate ${keyPath.join('.')} = "${oldValue}" as a plain string to rewrite`);
125
+ }
126
+
127
+ /**
128
+ * Set every declaration to package.json's version, formatting untouched. All or
129
+ * nothing: every new file text is computed first and nothing is written unless every
130
+ * declaration could be set, so a failure never leaves some manifests bumped. A missing
131
+ * file or a non-string value is an error, never skipped.
132
+ * @returns {Array<{label, from, to}>} what changed
133
+ */
134
+ export function syncDeclarations(root) {
135
+ const source = sourceVersion(root);
136
+ const texts = new Map();
137
+ const changed = [];
138
+ for (const d of VERSION_DECLARATIONS) {
139
+ const full = path.join(root, d.file);
140
+ if (!texts.has(full)) {
141
+ if (!existsSync(full)) throw new Error(`${d.file} is listed as a version declaration but does not exist`);
142
+ texts.set(full, { original: readFileSync(full, 'utf8'), text: null });
143
+ }
144
+ const entry = texts.get(full);
145
+ const text = entry.text ?? entry.original;
146
+ const current = getPath(JSON.parse(text), d.keyPath);
147
+ if (current === source) continue;
148
+ if (typeof current !== 'string') throw new Error(`${label(d)} is ${JSON.stringify(current)}, not a version string`);
149
+ entry.text = replaceJsonString(text, d.keyPath, current, source);
150
+ changed.push({ label: label(d), from: current, to: source });
151
+ }
152
+ for (const [full, { text }] of texts) if (text !== null) writeFileSync(full, text);
153
+ return changed;
154
+ }
@@ -33,6 +33,7 @@ import { join, basename, dirname, resolve } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { spawnSync } from "node:child_process";
35
35
  import { loadAllManifests } from "./lib/manifest-schema.mjs";
36
+ import { describeValue as describeVersionValue, mismatches as versionMismatches } from "./lib/manifest-versions.mjs";
36
37
  import { runManifest } from "./lib/runner.mjs";
37
38
  import {
38
39
  generateRunId,
@@ -48,7 +49,6 @@ const GUIDES_DIR = join(REPO_ROOT, "guides");
48
49
  const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
49
50
  const HOOKS_DIR = join(REPO_ROOT, "hooks");
50
51
  const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
51
- const PACKAGE_JSON = join(REPO_ROOT, "package.json");
52
52
 
53
53
  // ─── Guide-content validator constants ───────────────────────────────────────
54
54
  // Path to the regen-root project (sibling of rdc-skills).
@@ -600,19 +600,24 @@ function auditPluginManifest() {
600
600
  });
601
601
  }
602
602
  try {
603
- const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
604
- if (manifest.version && pkg.version && manifest.version !== pkg.version) {
603
+ // Every declaration an installed copy carries, from the one list the parity test and
604
+ // the `npm version` hook also read (scripts/lib/manifest-versions.mjs). This checked
605
+ // plugin.json alone, and clauth-plugin.json sat at 0.35.10 through 0.37.0. Each
606
+ // declaration is read on its own, so an unreadable file is its own error and never
607
+ // hides the others.
608
+ for (const d of versionMismatches(REPO_ROOT, { packagedOnly: true })) {
605
609
  findings.push({
606
610
  level: "error",
607
611
  code: "manifest-version-mismatch",
608
- message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
612
+ message: `${d.label} ${describeVersionValue(d)} ≠ package.json version "${d.expected}"`,
609
613
  });
610
614
  }
611
615
  } catch (e) {
616
+ // Only package.json itself lands here: without it no version can be verified.
612
617
  findings.push({
613
- level: "warn",
618
+ level: "error",
614
619
  code: "package-json-unreadable",
615
- message: `could not read package.json for version cross-check: ${e.message}`,
620
+ message: `could not read package.json for the version cross-check: ${e.message}`,
616
621
  });
617
622
  }
618
623
  const ok = findings.every((f) => f.level !== "error");
@@ -141,6 +141,23 @@ function walk(dir, exclude, out = []) {
141
141
  return out;
142
142
  }
143
143
 
144
+ /**
145
+ * Does this `git show <ref>:<path>` stderr mean "the file is not at <ref>"?
146
+ *
147
+ * Git words it two ways, and the common one was missing until 2026-09-21:
148
+ * - `fatal: path 'p' does not exist in 'ref'` — absent at ref AND on disk
149
+ * - `fatal: path 'p' exists on disk, but not in 'ref'` — a NEW file in the
150
+ * working tree, i.e. every `--diff` over a change that adds a file
151
+ * Matching only the first made `--diff` crash on any diff with a new file.
152
+ * Anything else (a bad ref, a path-form mismatch) is not "absent" and must be
153
+ * rethrown, so a real error never passes silently as a new unit.
154
+ */
155
+ export function isAbsentAtRef(stderr) {
156
+ const msg = String(stderr ?? '');
157
+ if (!msg.includes('fatal: path')) return false;
158
+ return msg.includes('does not exist in') || msg.includes('exists on disk, but not in');
159
+ }
160
+
144
161
  /** Score a file's text as it existed at a given git ref, without touching the working tree. */
145
162
  function scoreAtRef(repoRoot, filePath, ref, weights) {
146
163
  const relPath = relative(repoRoot, filePath).split('\\').join('/');
@@ -151,10 +168,9 @@ function scoreAtRef(repoRoot, filePath, ref, weights) {
151
168
  // `git show` fails both when the file is genuinely new at `ref` AND for
152
169
  // unrelated reasons (a path-form mismatch, a bad ref). Collapsing both
153
170
  // into "it's new" hides the second case — the exact class of silent
154
- // path-form bug found three times in this file already tonight. Only a
155
- // "does not exist" message is treated as new; anything else is rethrown.
156
- const msg = String(err.stderr ?? err.message ?? '');
157
- if (msg.includes('does not exist in') || msg.includes('fatal: path') && msg.includes('does not exist')) return null;
171
+ // path-form bug found three times in this file already. Only git's two
172
+ // "not at this ref" messages are treated as new; anything else is rethrown.
173
+ if (isAbsentAtRef(err.stderr ?? err.message)) return null;
158
174
  throw err;
159
175
  }
160
176
  const plugin = pluginFor(filePath);
@@ -237,6 +253,7 @@ async function main() {
237
253
  }
238
254
 
239
255
  let files;
256
+ let removedFiles = [];
240
257
  if (diffRef) {
241
258
  // `git diff --name-only <ref> -- <path>` (no `...HEAD`) compares `<ref>`
242
259
  // directly against the WORKING TREE — committed history AND uncommitted
@@ -244,8 +261,16 @@ async function main() {
244
261
  // diff` form, so they're unioned in separately. Before this fix, only
245
262
  // `<ref>...HEAD` (committed history alone) was scored — a dirty tree
246
263
  // with real regressions scored zero files and exited 0.
247
- const committed = execFileSync('git', ['diff', '--name-only', diffRef, '--', targetPath], { cwd: repoRoot, encoding: 'utf8' })
264
+ //
265
+ // Deleted paths are split out by git's own account (`--diff-filter=D`),
266
+ // not by an existsSync guess, so a path-form bug can never pass as a
267
+ // deletion. A deleted file has no current units and so nothing that can
268
+ // regress; before 2026-09-21 it reached extractUnits and crashed the gate
269
+ // with ENOENT. It is reported in `removedFiles`, never dropped silently.
270
+ const nameOnly = (filter) => execFileSync('git', ['diff', '--name-only', `--diff-filter=${filter}`, diffRef, '--', targetPath], { cwd: repoRoot, encoding: 'utf8' })
248
271
  .split('\n').filter(Boolean);
272
+ const committed = nameOnly('d');
273
+ removedFiles = nameOnly('D');
249
274
  const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--', targetPath], { cwd: repoRoot, encoding: 'utf8' })
250
275
  .split('\n').filter(Boolean);
251
276
  files = [...new Set([...committed, ...untracked])]
@@ -285,7 +310,7 @@ async function main() {
285
310
  const boundaryViolations = boundaryFindings.filter((f) => !f.satisfied);
286
311
 
287
312
  const output = {
288
- results, regressions, boundaryFindings, boundaryViolations, boundaryMisconfigured, unresolvedLanguages,
313
+ results, regressions, removedFiles, boundaryFindings, boundaryViolations, boundaryMisconfigured, unresolvedLanguages,
289
314
  config: { weights: config.weights, diff: config.diff, boundaryRuleCount: (config.boundaries ?? []).length },
290
315
  configPath, configLoaded,
291
316
  parser: parserName,
@@ -312,6 +337,10 @@ async function main() {
312
337
  console.log('\nREGRESSIONS:');
313
338
  for (const r of regressions) console.log(` ${r.file} :: ${r.unit} — ${r.reason} ${JSON.stringify(r)}`);
314
339
  }
340
+ if (removedFiles.length) {
341
+ console.log(`\n${removedFiles.length} file(s) deleted since ${diffRef} — no units left to score:`);
342
+ for (const f of removedFiles) console.log(` ${f}`);
343
+ }
315
344
  if (unresolvedLanguages.length) {
316
345
  console.log(`\n${unresolvedLanguages.length} file(s) matched no registered language plugin — skipped, not silently passed:`);
317
346
  for (const f of unresolvedLanguages) console.log(` ${relative(repoRoot, f)}`);