@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.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-manifest-versions.mjs — copy package.json's version into every other place the
4
+ * package declares it (scripts/lib/manifest-versions.mjs holds the list).
5
+ *
6
+ * Wired as the npm `version` lifecycle script (with --stage), so
7
+ * `npm version patch|minor|major` bumps package.json and then every manifest in the
8
+ * same step, and stages them for the version commit. Before this, plugin.json was a
9
+ * hand edit and clauth-plugin.json was never bumped at all (0.35.10 through 0.37.0).
10
+ *
11
+ * node scripts/sync-manifest-versions.mjs rewrite what differs, print it
12
+ * node scripts/sync-manifest-versions.mjs --stage the same, then `git add` every
13
+ * declaration file (npm version)
14
+ * node scripts/sync-manifest-versions.mjs --check change nothing; exit 1 on drift
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { realpathSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { VERSION_DECLARATIONS, describeValue, mismatches, syncDeclarations } from './lib/manifest-versions.mjs';
21
+
22
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const log = (line) => console.log(`[sync-manifest-versions] ${line}`);
24
+
25
+ /** One spelling per directory: long names (not 8.3), and case-folded on Windows. */
26
+ function canonical(dir) {
27
+ const real = realpathSync.native(path.resolve(dir));
28
+ return process.platform === 'win32' ? real.toLowerCase() : real;
29
+ }
30
+
31
+ /**
32
+ * True only when this package root IS the top of a git work tree. A package unpacked
33
+ * inside some other repository must not stage into that repository's index.
34
+ */
35
+ function ownsGitWorkTree() {
36
+ try {
37
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
38
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
39
+ }).trim();
40
+ return canonical(top) === canonical(root);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ if (process.argv.includes('--check')) {
47
+ const drift = mismatches(root);
48
+ for (const d of drift) {
49
+ console.error(`[sync-manifest-versions] ${d.label} is ${describeValue(d)}, package.json is ${d.expected}`);
50
+ }
51
+ if (drift.length) process.exit(1);
52
+ log('every declaration matches package.json');
53
+ } else {
54
+ const changed = syncDeclarations(root);
55
+ for (const c of changed) log(`${c.label}: ${c.from} -> ${c.to}`);
56
+ if (!changed.length) log('nothing to change');
57
+ if (process.argv.includes('--stage')) {
58
+ // npm version skips its own git steps outside a work tree; so does this.
59
+ if (!ownsGitWorkTree()) {
60
+ log('this package is not the top of a git work tree — nothing staged');
61
+ } else {
62
+ const files = [...new Set(VERSION_DECLARATIONS.map((d) => d.file))];
63
+ execFileSync('git', ['add', '--', ...files], { cwd: root, stdio: 'inherit' });
64
+ log(`staged ${files.join(', ')}`);
65
+ }
66
+ }
67
+ }
@@ -644,27 +644,30 @@ Read the task title and description, then:
644
644
 
645
645
  ⛔ **NO wave may dispatch until the previous wave's code-review pass clears.** Memory `feedback_code_review_per_wave.md` — Davesend 2026-05-24 incident.
646
646
 
647
- Dispatch ONE `pr-review-toolkit:code-reviewer` agent with the wave's merged diff:
648
-
649
- ```
650
- Agent({
651
- subagent_type: "pr-review-toolkit:code-reviewer",
652
- description: "Wave <N> code review",
653
- prompt: "Review the diff `git diff <wave-base-sha>..HEAD` on develop.
654
- Focus on: bugs, logic errors, security vulnerabilities, project-convention adherence
655
- (.claude/rules/*, CLAUDE.md). Apply confidence-based filtering — high-confidence
656
- findings only. Report severity per finding: critical | high | medium | low.
657
- Return CODE_REVIEW_COMPLETE with: { critical_count, high_count, medium_count,
658
- low_count, findings: [{severity, file:line, issue, suggested_fix}] }."
659
- })
660
- ```
661
-
662
- **Severity gate (default — high+ blocks):**
663
- - `critical` or `high` findings → reopen affected work items to `todo` with finding text in notes; fix in a new wave before continuing
664
- - `medium` or `low` findings → append to each work item's `implementation_report.flags`; do NOT reopen; validator sees them
665
- - Zero findings → log `CODE_REVIEW: CLEAN` and proceed to next wave
666
-
667
- Under `RDC_TEST=1`: echo `[RDC_TEST] skipping code-review dispatch` and proceed.
647
+ Run the deterministic review gate in `guides/review-gate.md` over the wave's
648
+ merged diff, with `BASE=<wave-base-sha>` and `SCOPE` set to each package or
649
+ app the wave touched. That covers `rdc-solid-score --diff`, the repo linter on
650
+ changed files, `tsc`, scoped tests, and the UI spec when a route changed. It
651
+ takes one to two minutes; an open-ended reviewer agent took 4–10.
652
+ Approved: direct operator instruction — "we need to tune our code reviews to be
653
+ specific -- if you ran the validator for solid and lint etc... it should take 1-2
654
+ minutes". 2026-09-21, Dave, in session.
655
+
656
+ Dispatch `pr-review-toolkit:code-reviewer` only for a **named question** the
657
+ validators cannot answer, scoped to the files it touches (the guide's
658
+ "When an LLM reviewer is still right" section). Never dispatch it with a bare
659
+ "review the diff".
660
+
661
+ **Gate outcome:**
662
+ - A red gate step, or a critical/high finding from a named-question pass →
663
+ reopen the affected work items to `todo` with the `REVIEW_GATE` receipt and
664
+ the failing output in notes. Fix in a new wave before continuing.
665
+ - Medium/low findings from a named-question pass → append them to each work
666
+ item's `implementation_report.flags`. Do not reopen; the validator sees them.
667
+ - All steps green → log the `REVIEW_GATE` receipt with `verdict: "CLEAN"` and
668
+ proceed to the next wave.
669
+
670
+ Under `RDC_TEST=1`: echo `[RDC_TEST] skipping review gate` and proceed.
668
671
 
669
672
  10. **Mandatory validator gate (runs after ALL waves complete — before any work item closes):**
670
673
 
@@ -65,6 +65,12 @@ matches the numeric-suffix heuristic) alongside real hits (`data`, `temp`,
65
65
  `bin.rdc-clean-code-score`; falls back to
66
66
  `node <rdc-skills-install-path>/scripts/clean-code-score.mjs`.)
67
67
 
68
+ **In a monorepo, always pass `--project-root <package or app dir>`.** Without
69
+ it, the dead-export scan walks the whole git repo. On regen-root that ran out
70
+ of 4 GB of heap after 18 s (exit 134); scoped to `apps/prtrust` it took 7.9 s.
71
+ This scorer has no `--diff` and reports absolute counts, so it is advisory,
72
+ not part of the merge gate (`guides/review-gate.md`).
73
+
68
74
  2. **Read `results`** — per-unit findings for N1 (cryptic names), N2 (heuristic
69
75
  meaningless-distinction names, low confidence), N4 (magic numbers), N7
70
76
  (generic class/function names), F1 (>20 statements), F2 (>3 params), E1
@@ -111,26 +111,27 @@ fi
111
111
 
112
112
  ⛔ **No fixit closes without a code-review pass.** Even single-file changes go through review.
113
113
 
114
- Dispatch ONE `pr-review-toolkit:code-reviewer` agent on the fixit commit:
115
-
116
- ```
117
- Agent({
118
- subagent_type: "pr-review-toolkit:code-reviewer",
119
- description: "fixit code review",
120
- prompt: "Review `git show HEAD` on the development branch. Focus on:
121
- bugs, logic errors, security, project-convention adherence (.claude/rules/*).
122
- Confidence-based filtering — high-confidence findings only.
123
- Return CODE_REVIEW_COMPLETE with: { critical_count, high_count, medium_count,
124
- low_count, findings: [{severity, file:line, issue, suggested_fix}] }."
125
- })
126
- ```
127
-
128
- **Severity gate:**
129
- - `critical` or `high` findings → fix in this same fixit session (do not escalate to rdc:build for the fix itself; the original fixit owns the cleanup), re-commit, re-run review until clean
130
- - `medium` or `low` findings → record in `implementation_report.flags`; proceed to close
131
- - Zero findings → proceed to close
132
-
133
- Under `RDC_TEST=1`: echo `[RDC_TEST] skipping code-review dispatch` and proceed.
114
+ Run the deterministic review gate in `guides/review-gate.md` with
115
+ `BASE=HEAD~1` (or the commit before the fixit's first commit) and `SCOPE` set
116
+ to the touched package. For a fixit this usually takes seconds.
117
+ Approved: direct operator instruction — "we need to tune our code reviews to be
118
+ specific -- if you ran the validator for solid and lint etc... it should take 1-2
119
+ minutes". 2026-09-21, Dave, in session.
120
+
121
+ Dispatch `pr-review-toolkit:code-reviewer` only for a **named question** the
122
+ validators cannot answer, scoped to the files it touches. Never dispatch it with
123
+ a bare "review the commit".
124
+
125
+ **Gate outcome:**
126
+ - A red gate step, or a critical/high finding from a named-question pass → fix
127
+ it in this same fixit session. Do not escalate the fix to rdc:build; the
128
+ original fixit owns the cleanup. Re-commit and re-run the gate until it is clean.
129
+ - Medium/low findings from a named-question pass → record them in
130
+ `implementation_report.flags` and proceed to close.
131
+ - All steps green → quote the `REVIEW_GATE` receipt in the implementation report
132
+ and proceed to close.
133
+
134
+ Under `RDC_TEST=1`: echo `[RDC_TEST] skipping review gate` and proceed.
134
135
 
135
136
  ### 5.7 Deliver to done (MANDATORY — a fixit is not done until the change is LIVE)
136
137
 
@@ -144,7 +144,7 @@ After each wave: check `BUILD_STATUS`. If `escalated: true`, log the escalation
144
144
  in the overnight doc and continue — don't stop the loop.
145
145
  After each wave and after every resumed epic, require the `runOrchestrator()` receipt from `rdc:build` **where the orchestrator applies** — the target contains `corpus/_shared/build/phase-manifest.json` (today: regen-root; see `rdc:build` §1a). Elsewhere, require the contract checklist (`rdc-work check`) instead; an absent orchestrator is not a hold. `admission_refocus` or `pipeline_blocked` means the epic is held for an explicitly requested Design Review or validator closure; log that state and do not hand-reconstruct a dispatch wave.
146
146
 
147
- **Mandatory code-review gate inherited from rdc:build (Step 9b).** Every wave inside `rdc:build` runs a `pr-review-toolkit:code-reviewer` pass before the next wave dispatches. Critical/high findings reopen the affected work items to `todo` and the next wave fixes them. Overnight does not skip or weaken this gate. If a wave's code-review escalates twice, advisor decides; otherwise the loop continues.
147
+ **Mandatory code-review gate inherited from rdc:build (Step 9b).** Every wave inside `rdc:build` runs the deterministic review gate (`guides/review-gate.md`: solid-score --diff, linter on changed files, tsc, scoped tests, UI spec) before the next wave dispatches. It takes one to two minutes, where an open-ended reviewer took 4–10, which matters most across an overnight queue. A red step reopens the affected work items to `todo` and the next wave fixes them. Overnight does not skip or weaken this gate. If a wave's gate escalates twice, the advisor decides; otherwise the loop continues.
148
148
 
149
149
  ### 3d. Review
150
150
 
@@ -41,7 +41,7 @@ rdc:release: <repo> vX.Y.Z -> vA.B.C
41
41
  [ ] Dry-run gate handled
42
42
  [ ] Version files updated
43
43
  [ ] Tests/self-test passed
44
- [ ] Mandatory release code-review (pr-review-toolkit:code-reviewer on `git diff <last-released-tag>..HEAD`). Block release on `critical`/`high` findings; record `medium`/`low` in the release notes and proceed.
44
+ [ ] Mandatory release review gate (`guides/review-gate.md`, BASE=<last-released-tag>): solid-score --diff, linter on changed files, tsc, scoped tests. Block the release on any red step; LLM reviewer only for a named question.
45
45
  [ ] Commit created
46
46
  [ ] Tag created
47
47
  [ ] Branch and tag pushed
@@ -12,8 +12,9 @@ description: rdc:review () - [--unattended] — post-build gate over tsc, tests,
12
12
  > **Sandbox contract:** This skill honors `RDC_TEST=1` per `guides/agent-bootstrap.md` § RDC_TEST Sandbox Contract. Destructive external calls short-circuit under the flag.
13
13
 
14
14
  > **Standard being enforced:** `docs/CODING-STANDARDS.md` (SOLID/Clean-Architecture —
15
- > regen-root; skip if absent). Step 8c is where it's actually checked, via this package's
15
+ > regen-root; skip if absent). Steps 8b and 8c check it, via this package's
16
16
  > own `rdc-solid-score` (also invoked by ATF's Test-Ladder as a consumer, not an owner).
17
+ > Step 8b runs the deterministic review gate, `guides/review-gate.md`.
17
18
 
18
19
 
19
20
  # rdc:review — Quality Gate
@@ -84,41 +85,47 @@ description: rdc:review () - [--unattended] — post-build gate over tsc, tests,
84
85
  - Never silently leave orphaned tasks
85
86
  - **⛔ Raw `UPDATE work_items SET parent_id = ...` is forbidden** — bypasses RLS and all constraint checks
86
87
 
87
- 8b. **Mandatory code-review gate — dispatch pr-review-toolkit:code-reviewer:**
88
-
89
- ⛔ **No CLEAN verdict without a code-review pass.** This is the second half of the quality gate; tsc/vitest catch type and behavior errors, code-reviewer catches logic, security, and convention drift.
90
-
91
- Dispatch ONE `pr-review-toolkit:code-reviewer` agent on the full diff under review:
92
-
93
- ```
94
- Agent({
95
- subagent_type: "pr-review-toolkit:code-reviewer",
96
- description: "rdc:review code-review pass",
97
- prompt: "Review `git diff origin/main...HEAD` on the development branch.
98
- Focus on: bugs, logic errors, security vulnerabilities, project-convention
99
- adherence (.claude/rules/*, CLAUDE.md, engineering-behavior.md).
100
- Confidence-based filtering — high-confidence findings only.
101
- Return CODE_REVIEW_COMPLETE with: { critical_count, high_count, medium_count,
102
- low_count, findings: [{severity, file:line, issue, suggested_fix}] }."
103
- })
104
- ```
105
-
106
- **Severity gate:**
107
- - `critical` or `high` → verdict cannot be CLEAN. Interactive: report and pause. Unattended: auto-fix if mechanical (rename, missing await, obvious null check), otherwise escalate via advisor and mark HAS_ISSUES.
108
- - `medium` or `low` → record in the review report's "Issues" section; verdict can still be CLEAN.
109
- - Zero findings → log `CODE_REVIEW: CLEAN` and continue.
110
-
111
- Under `RDC_TEST=1`: echo `[RDC_TEST] skipping code-review dispatch` and continue.
88
+ 8b. **Mandatory code-review gate — deterministic validators on the diff:**
89
+
90
+ ⛔ **No CLEAN verdict without a review-gate pass.** Run `guides/review-gate.md`
91
+ over the diff under review, with `BASE` set to the review's base ref and `SCOPE`
92
+ set to each modified package from step 1. Its checks are
93
+ `rdc-solid-score --diff`, the repo linter on changed files (`--write`, then
94
+ check), `tsc`, scoped tests, and the UI spec for changed routes. It finishes in
95
+ one to two minutes and gives the same answer twice.
96
+ Approved: direct operator instruction — "we need to tune our code reviews to be
97
+ specific -- if you ran the validator for solid and lint etc... it should take 1-2
98
+ minutes". 2026-09-21, Dave, in session.
99
+
100
+ Dispatch `pr-review-toolkit:code-reviewer` only for a **named question** the
101
+ validators cannot answer, such as auth bypass on a protected path or a return
102
+ figure in public copy. Scope it to the files that question touches, following
103
+ the guide's "When an LLM reviewer is still right" section. A bare "review the
104
+ diff" dispatch is retired.
105
+
106
+ **Gate outcome:**
107
+ - A red gate step, or a critical/high finding from a named-question pass →
108
+ the verdict cannot be CLEAN. Interactive: report and pause. Unattended:
109
+ fix it if the fix is mechanical (`--write` lint fixes, a missing await, an
110
+ obvious null check); otherwise escalate via the advisor and mark HAS_ISSUES.
111
+ - Medium/low findings from a named-question pass → record them in the review
112
+ report's "Issues" section. The verdict can still be CLEAN.
113
+ - All steps green → quote the `REVIEW_GATE` receipt and continue.
114
+
115
+ Under `RDC_TEST=1`: echo `[RDC_TEST] skipping review gate` and continue.
112
116
 
113
117
  8c. **Form/fit/function gate — solid-validator + architecture-reviewer:**
114
118
 
115
- ⛔ **No CLEAN verdict without this pass either.** Step 8b catches logic,
116
- security, and convention drift. This step catches a different failure
117
- shape entirely: code that is logically correct and convention-clean but
118
- architecturally wrong — the `Harness` case (a use-case orchestrator that
119
- reimplements what sibling packages exist to do, scoring fine on every
120
- generic metric while failing the one check that names the actual rule).
119
+ ⛔ **No CLEAN verdict without this pass either.** Step 8b's lint, tsc and
120
+ tests catch convention drift and broken behavior. This step catches a
121
+ different failure shape: code that is logically correct and
122
+ convention-clean but architecturally wrong. The `Harness` case is the
123
+ example: a use-case orchestrator that reimplements what sibling packages
124
+ exist to do, scores fine on every generic metric, and fails the one check
125
+ that names the actual rule.
121
126
 
127
+ Reuse the `rdc-solid-score` JSON from step 8b rather than running the scorer
128
+ twice. If 8b ran without the repo's boundary config, re-run it with the config:
122
129
  ```bash
123
130
  rdc-solid-score <modified-package-path> --diff origin/main --config <repo>/.solid-score.yml --format json
124
131
  ```
@@ -0,0 +1,103 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { isAbsentAtRef } from '../../scripts/solid-score.mjs';
10
+
11
+ const SCORER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'solid-score.mjs');
12
+
13
+ // ── isAbsentAtRef: git's two "not at this ref" messages, and nothing else ──
14
+
15
+ test('isAbsentAtRef: "does not exist in" (absent at ref and on disk) is absent', () => {
16
+ assert.equal(isAbsentAtRef("fatal: path 'src/a.ts' does not exist in 'origin/develop'\n"), true);
17
+ });
18
+
19
+ test('isAbsentAtRef: "exists on disk, but not in" (a NEW working-tree file) is absent', () => {
20
+ // The message every --diff over a change that adds a file produces; it was
21
+ // unmatched until 2026-09-21 and crashed the gate.
22
+ assert.equal(isAbsentAtRef("fatal: path 'apps/x/tests/ui/home.spec.ts' exists on disk, but not in 'origin/develop'\n"), true);
23
+ });
24
+
25
+ test('isAbsentAtRef: a bad ref is NOT absent — it must be rethrown', () => {
26
+ assert.equal(isAbsentAtRef("fatal: invalid object name 'no-such-ref'.\n"), false);
27
+ });
28
+
29
+ test('isAbsentAtRef: a path-form mismatch is NOT absent — it must be rethrown', () => {
30
+ assert.equal(
31
+ isAbsentAtRef("fatal: path 'src/a.ts' exists, but not 'a.ts'\nhint: Did you mean 'HEAD:src/a.ts' aka 'HEAD:./a.ts'?\n"),
32
+ false,
33
+ );
34
+ });
35
+
36
+ test('isAbsentAtRef: empty or missing stderr is NOT absent', () => {
37
+ assert.equal(isAbsentAtRef(''), false);
38
+ assert.equal(isAbsentAtRef(undefined), false);
39
+ });
40
+
41
+ // ── end to end: --diff over a change that adds a file ──
42
+
43
+ function git(cwd, ...args) {
44
+ return execFileSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], {
45
+ cwd,
46
+ encoding: 'utf8',
47
+ });
48
+ }
49
+
50
+ const CLEAN_CLASS = (name) => `export class ${name} {\n private n = 0;\n add(x: number): number {\n this.n += x;\n return this.n;\n }\n}\n`;
51
+
52
+ test('solid-score --diff scores a new file as a new unit instead of crashing', () => {
53
+ const dir = mkdtempSync(join(tmpdir(), 'solid-diff-'));
54
+ try {
55
+ git(dir, 'init', '-q');
56
+ writeFileSync(join(dir, 'a.ts'), CLEAN_CLASS('Alpha'));
57
+ git(dir, 'add', 'a.ts');
58
+ git(dir, 'commit', '-q', '-m', 'base');
59
+
60
+ // One untracked new file, and one committed-after-base new file: both are
61
+ // "exists on disk, but not in <ref>" at the base ref.
62
+ writeFileSync(join(dir, 'b.ts'), CLEAN_CLASS('Beta'));
63
+ writeFileSync(join(dir, 'c.ts'), CLEAN_CLASS('Gamma'));
64
+ git(dir, 'add', 'c.ts');
65
+ git(dir, 'commit', '-q', '-m', 'add c');
66
+
67
+ const run = spawnSync(process.execPath, [SCORER, dir, '--diff', 'HEAD~1', '--format', 'json'], { encoding: 'utf8' });
68
+ assert.equal(run.status, 0, `scorer exited ${run.status}: ${run.stderr}`);
69
+ const out = JSON.parse(run.stdout);
70
+ const scored = out.results.map((r) => r.file).sort();
71
+ assert.deepEqual(scored, ['b.ts', 'c.ts'], 'both new files are scored');
72
+ assert.deepEqual(out.regressions, [], 'clean new units are not regressions');
73
+ assert.deepEqual(out.removedFiles, [], 'nothing was deleted');
74
+ } finally {
75
+ rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('solid-score --diff reports a deleted file in removedFiles instead of crashing', () => {
80
+ // A deleted file is listed by `git diff --name-only <ref>`; before
81
+ // 2026-09-21 it went to extractUnits and the gate died with ENOENT.
82
+ const dir = mkdtempSync(join(tmpdir(), 'solid-diff-del-'));
83
+ try {
84
+ git(dir, 'init', '-q');
85
+ writeFileSync(join(dir, 'a.ts'), CLEAN_CLASS('Alpha'));
86
+ writeFileSync(join(dir, 'gone.ts'), CLEAN_CLASS('Gone'));
87
+ git(dir, 'add', 'a.ts', 'gone.ts');
88
+ git(dir, 'commit', '-q', '-m', 'base');
89
+
90
+ // One deletion committed after base, one edit left uncommitted.
91
+ git(dir, 'rm', '-q', 'gone.ts');
92
+ git(dir, 'commit', '-q', '-m', 'remove gone');
93
+ writeFileSync(join(dir, 'a.ts'), `${CLEAN_CLASS('Alpha')}\nexport const touched = 1;\n`);
94
+
95
+ const run = spawnSync(process.execPath, [SCORER, dir, '--diff', 'HEAD~1', '--format', 'json'], { encoding: 'utf8' });
96
+ assert.equal(run.status, 0, `scorer exited ${run.status}: ${run.stderr}`);
97
+ const out = JSON.parse(run.stdout);
98
+ assert.deepEqual(out.removedFiles, ['gone.ts'], 'the deletion is reported, not dropped');
99
+ assert.deepEqual(out.results.map((r) => r.file), ['a.ts'], 'only the surviving changed file is scored');
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
@@ -0,0 +1,155 @@
1
+ // Every place the package declares its version equals package.json.
2
+ //
3
+ // 0.37.0 shipped with clauth-plugin.json at 0.35.10 and package.json `claude.version`
4
+ // at 0.10.0. The only check, self-test, compared plugin.json alone, so both drifted for
5
+ // releases without a single red. This test reads the shared declaration list, and it
6
+ // also scans the repository, so a new manifest with a `version` cannot slip past the list.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import {
15
+ VERSION_DECLARATIONS, VERSION_SOURCE, describeValue, label, mismatches, readDeclarations, replaceJsonString,
16
+ syncDeclarations,
17
+ } from '../scripts/lib/manifest-versions.mjs';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ /**
22
+ * The JSON files this repository holds: tracked, plus new files git does not ignore.
23
+ * Read from git, not the disk, so local reports, stamps and editor JSONC that git
24
+ * ignores can neither fail this test on one machine nor pass it on another.
25
+ */
26
+ function jsonFilesWithTopLevelVersion() {
27
+ const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.json'], {
28
+ cwd: ROOT, encoding: 'utf8',
29
+ }).split('\0').filter(Boolean);
30
+ return [...new Set(listed)].filter((file) => {
31
+ const json = JSON.parse(readFileSync(path.join(ROOT, file), 'utf8'));
32
+ return json && typeof json === 'object' && !Array.isArray(json) && typeof json.version === 'string';
33
+ });
34
+ }
35
+
36
+ test('every version declaration equals package.json', () => {
37
+ const drift = mismatches(ROOT);
38
+ assert.deepEqual(
39
+ drift.map((d) => `${d.label} = ${describeValue(d)}`),
40
+ [],
41
+ `package.json is ${readDeclarations(ROOT).source}; run \`node scripts/sync-manifest-versions.mjs\``,
42
+ );
43
+ });
44
+
45
+ test('no JSON file declares a top-level version the list does not know about', () => {
46
+ const known = new Set([VERSION_SOURCE.file, ...VERSION_DECLARATIONS.filter((d) => d.keyPath.length === 1).map((d) => d.file)]);
47
+ const found = jsonFilesWithTopLevelVersion();
48
+ // Positive controls: a root file, and one inside a dot-directory.
49
+ assert.ok(found.includes('package.json'), 'the scan must find package.json');
50
+ assert.ok(found.includes('.claude-plugin/plugin.json'), 'the scan must find .claude-plugin/plugin.json');
51
+ assert.deepEqual(found.filter((f) => !known.has(f)), [], 'add each to VERSION_DECLARATIONS in scripts/lib/manifest-versions.mjs');
52
+ });
53
+
54
+ test('the declaration list names every file the fleet reads a version from', () => {
55
+ const labels = VERSION_DECLARATIONS.map(label);
56
+ for (const expected of ['.claude-plugin/plugin.json version', 'clauth-plugin.json version', 'package.json claude.version']) {
57
+ assert.ok(labels.includes(expected), `${expected} must be a declaration`);
58
+ }
59
+ });
60
+
61
+ test('npm version runs the sync hook, which stages every declaration file', () => {
62
+ const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
63
+ assert.equal(pkg.scripts?.version, 'node scripts/sync-manifest-versions.mjs --stage');
64
+ });
65
+
66
+ test('replaceJsonString edits only the named key and keeps the formatting', () => {
67
+ const text = '{\n "name": "x",\n "version": "1.0.0",\n "packages": {\n "": { "version": "1.0.0" },\n "node_modules/a": {\n "version": "1.0.0"\n }\n }\n}\n';
68
+ const nested = replaceJsonString(text, ['packages', '', 'version'], '1.0.0', '2.0.0');
69
+ assert.equal(nested, text.replace('{ "version": "1.0.0" }', '{ "version": "2.0.0" }'));
70
+ const top = replaceJsonString(text, ['version'], '1.0.0', '2.0.0');
71
+ assert.equal(top, text.replace('"version": "1.0.0",', '"version": "2.0.0",'));
72
+ assert.throws(() => replaceJsonString(text, ['version'], '9.9.9', '2.0.0'), /could not locate/);
73
+ });
74
+
75
+ function copyManifests(tmp) {
76
+ for (const file of new Set([VERSION_SOURCE.file, ...VERSION_DECLARATIONS.map((d) => d.file)])) {
77
+ mkdirSync(path.dirname(path.join(tmp, file)), { recursive: true });
78
+ cpSync(path.join(ROOT, file), path.join(tmp, file));
79
+ }
80
+ }
81
+
82
+ test('an unreadable declaration is reported on its own and hides no other', () => {
83
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-read-'));
84
+ try {
85
+ copyManifests(tmp);
86
+ writeFileSync(path.join(tmp, 'clauth-plugin.json'), '{ "version": ');
87
+ const drift = mismatches(tmp);
88
+ assert.deepEqual(drift.map((d) => d.label), ['clauth-plugin.json version']);
89
+ assert.match(describeValue(drift[0]), /^\(unreadable: /);
90
+ } finally {
91
+ rmSync(tmp, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test('sync writes nothing when any declaration cannot be set', () => {
96
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-sync-'));
97
+ try {
98
+ copyManifests(tmp);
99
+ const pkgFile = path.join(tmp, 'package.json');
100
+ writeFileSync(pkgFile, readFileSync(pkgFile, 'utf8').replace(/"version": "[^"]+"/, '"version": "9.8.7"'));
101
+ const clauth = path.join(tmp, 'clauth-plugin.json');
102
+ writeFileSync(clauth, readFileSync(clauth, 'utf8').replace(/"version": "[^"]+"/, '"version": 1'));
103
+ const before = readFileSync(path.join(tmp, '.claude-plugin', 'plugin.json'), 'utf8');
104
+ assert.throws(() => syncDeclarations(tmp), /clauth-plugin\.json version is 1, not a version string/);
105
+ assert.equal(readFileSync(path.join(tmp, '.claude-plugin', 'plugin.json'), 'utf8'), before, 'plugin.json must be untouched');
106
+ } finally {
107
+ rmSync(tmp, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('the hook stages nothing into a repository the package is merely inside', () => {
112
+ const parent = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-parent-'));
113
+ try {
114
+ const pkg = path.join(parent, 'vendor', 'rdc-skills');
115
+ copyManifests(pkg);
116
+ for (const file of ['scripts/sync-manifest-versions.mjs', 'scripts/lib/manifest-versions.mjs']) {
117
+ mkdirSync(path.dirname(path.join(pkg, file)), { recursive: true });
118
+ cpSync(path.join(ROOT, file), path.join(pkg, file));
119
+ }
120
+ execFileSync('git', ['init', '-q'], { cwd: parent });
121
+ const out = execFileSync(process.execPath, ['scripts/sync-manifest-versions.mjs', '--stage'], { cwd: pkg, encoding: 'utf8' });
122
+ assert.match(out, /nothing staged/);
123
+ assert.equal(execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: parent, encoding: 'utf8' }), '');
124
+ } finally {
125
+ rmSync(parent, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test('npm version in a throwaway copy moves every declaration and stages it', () => {
130
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'rdc-version-hook-'));
131
+ try {
132
+ copyManifests(tmp);
133
+ for (const file of ['scripts/sync-manifest-versions.mjs', 'scripts/lib/manifest-versions.mjs']) {
134
+ mkdirSync(path.dirname(path.join(tmp, file)), { recursive: true });
135
+ cpSync(path.join(ROOT, file), path.join(tmp, file));
136
+ }
137
+ execFileSync('git', ['init', '-q'], { cwd: tmp });
138
+ // npm is a .cmd shim on Windows; run it through cmd.exe rather than `shell: true`,
139
+ // which newer Node deprecates for argument arrays (DEP0190).
140
+ const npmArgs = ['version', '9.8.7', '--no-git-tag-version', '--ignore-scripts=false'];
141
+ const [cmd, args] = process.platform === 'win32'
142
+ ? ['cmd.exe', ['/d', '/s', '/c', 'npm', ...npmArgs]]
143
+ : ['npm', npmArgs];
144
+ execFileSync(cmd, args, { cwd: tmp, stdio: 'pipe' });
145
+ const { source, declarations } = readDeclarations(tmp);
146
+ assert.equal(source, '9.8.7');
147
+ assert.deepEqual(declarations.filter((d) => d.value !== '9.8.7').map((d) => `${d.label} = ${d.value}`), []);
148
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: tmp, encoding: 'utf8' }).split('\n').filter(Boolean);
149
+ for (const file of new Set(VERSION_DECLARATIONS.map((d) => d.file))) {
150
+ assert.ok(staged.includes(file), `${file} must be staged for the version commit (staged: ${staged.join(', ')})`);
151
+ }
152
+ } finally {
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ });
@@ -219,8 +219,17 @@ EOF
219
219
  ['heredoc with a quoted delimiter passes', 'git commit -F - <<"MSG"\nfix(a): b\nMSG', 'pass'],
220
220
  ['heredoc carrying only a UUID passes', "git commit -F - <<'EOF'\nwip\n\nWork-Item: f27ff5fa-0000-4000-8000-000000000000\nEOF", 'pass'],
221
221
  ['-m still passes', 'git commit -m "fix(x): y"', 'pass'],
222
+ // -m "$(cat <<'EOF' … EOF)" — the -m regex used to match the FIRST
223
+ // unrelated quote inside the substitution (the heredoc delimiter's own
224
+ // opening quote) and return early with literal shell text as the
225
+ // "message", never reaching the heredoc fallback below. Same defect
226
+ // class as the 2026-08-29 fix, one wrapper layer further out. Filed as
227
+ // regen-root#243.
228
+ ['-m "$(cat <<EOF …)" substitution passes', "git commit -m \"$(cat <<'EOF'\nfix(guards): correct the heredoc substitution match\n\nBody line two.\nEOF\n)\"", 'pass'],
229
+ ['-m "$(cat <<-EOF …)" dash-form substitution passes', "git commit -m \"$(cat <<-'EOF'\nfix(a): b\nEOF\n)\"", 'pass'],
222
230
  // Negative controls — reading the message is not a bypass.
223
231
  ['heredoc without a type or UUID still blocks', "git commit -F - <<'EOF'\nrandom words\nEOF", 'block'],
232
+ ['-m "$(cat <<EOF …)" without a type or UUID still blocks', "git commit -m \"$(cat <<'EOF'\nrandom words\nEOF\n)\"", 'block'],
224
233
  ['a bare commit still blocks', 'git commit', 'block'],
225
234
  // Honest about what it cannot see, rather than calling it absent.
226
235
  ['a real stdin pipe reports unreadable', 'cat msg.txt | git commit -q -F -', 'unreadable'],
@@ -1,24 +0,0 @@
1
- ---
2
- id: 2026-08-17-fixit-codex-mcp-only-skills
3
- date: "2026-08-17"
4
- skill: fixit
5
- session: codex-mcp-startup-20260817
6
- scope: simple
7
- status: applied
8
- area: skill
9
- links:
10
- commits:
11
- - 263231a
12
- memory: []
13
- work_items:
14
- - dc076f35-236b-4f8d-be3f-646a66514ba5
15
- ---
16
-
17
- ## What happened
18
- Codex's real numbered-lane startup completed MCP initialization but emitted a red skill-budget error on every launch. The machine had 36 RDC skill directories in each of the global `.codex/skills` and `.agents/skills` roots while the same 36-skill catalog was already registered through the RDC-Skills MCP endpoint.
19
-
20
- ## Root cause
21
- `scripts/install-rdc-skills.js` registered the MCP endpoint and then copied the full RDC catalog into every detected Codex skill directory. Codex loaded those duplicate surfaces together, inflating startup metadata to 113 skills. The same install also left a legacy RDC marketplace block that Codex rejected as an unsupported plugin source.
22
-
23
- ## The fix / rule
24
- Codex receives RDC skills exclusively through MCP. The installer now verifies the MCP config before purging legacy file copies, fails closed if the replacement cannot be established, removes the obsolete marketplace/plugin blocks with CRLF coverage, and verifies that live Codex skill roots contain zero RDC duplicates. Implemented in commit `263231a`.