@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/publish.yml +12 -1
- package/CHANGELOG.md +60 -0
- package/README.md +1 -0
- package/RELEASE.md +11 -8
- package/clauth-plugin.json +1 -1
- package/commands/compare-compliance.md +5 -1
- package/commands/quick-check.md +3 -1
- package/git-sha.json +1 -1
- package/guides/review-gate.md +133 -0
- package/hooks/require-work-item-on-commit.js +11 -0
- package/package.json +4 -3
- package/scripts/lib/manifest-versions.mjs +154 -0
- package/scripts/self-test.mjs +11 -6
- package/scripts/solid-score.mjs +35 -6
- package/scripts/sync-manifest-versions.mjs +67 -0
- package/skills/build/SKILL.md +24 -21
- package/skills/clean-code-analyzer/SKILL.md +6 -0
- package/skills/fixit/SKILL.md +21 -20
- package/skills/overnight/SKILL.md +1 -1
- package/skills/release/SKILL.md +1 -1
- package/skills/review/SKILL.md +39 -32
- package/tests/lib/solid-score-diff.test.mjs +103 -0
- package/tests/manifest-version-parity.test.mjs +155 -0
- package/tests/require-work-item-on-commit.test.mjs +9 -0
- package/.rdc/lessons/2026-08-17-fixit-codex-mcp-only-skills.md +0 -24
- package/.rdc/lessons/2026-09-17-release-mcp-registry-split.md +0 -34
|
@@ -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:
|
|
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
|
|
31
|
-
#
|
|
32
|
-
# and .claude-plugin/plugin.json
|
|
33
|
-
#
|
|
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
|
-
|
|
36
|
-
#
|
|
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
|
|
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
|
package/clauth-plugin.json
CHANGED
|
@@ -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`.
|
package/commands/quick-check.md
CHANGED
|
@@ -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.
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
package/scripts/self-test.mjs
CHANGED
|
@@ -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
|
-
|
|
604
|
-
|
|
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:
|
|
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: "
|
|
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");
|
package/scripts/solid-score.mjs
CHANGED
|
@@ -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
|
|
155
|
-
// "
|
|
156
|
-
|
|
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
|
-
|
|
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)}`);
|