@lifeaitools/rdc-skills 0.37.0 → 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.37.0",
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,34 @@ 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
+
10
38
  ## v0.37.0 — review gates run the deterministic validators, in 1–2 minutes
11
39
 
12
40
  ### Changed
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": {
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "15c0b76b2fa48b6ab335478041940337a84da588"
2
+ "sha": "ed080efda2d2537ca723128a016188aefd8f8fb5"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.37.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.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");
@@ -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
+ }
@@ -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
+ });