@lifeaitools/rdc-skills 0.35.3 → 0.35.4

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.35.3",
3
+ "version": "0.35.4",
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",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.35.4 — marketplace sync false-positive: untracked file blocked it forever
11
+
12
+ ### Fixed
13
+ - `syncMarketplaceCheckout()`'s dirty-check used plain `git status --porcelain`,
14
+ which also reports untracked files. A single harmless untracked file in a
15
+ marketplace clone (e.g. a stray local scratch file) made the check treat the
16
+ clone as "has local changes" and permanently refuse to sync it, on every
17
+ single install run, forever — even though `git reset --hard` never touches
18
+ untracked files and there was no real edit to preserve. This is the
19
+ confirmed root cause of the marketplace checkout sitting stale for over a
20
+ week despite repeated installer runs. Fixed to `--untracked-files=no`, which
21
+ checks only tracked-file modifications — genuine local edits still block the
22
+ sync exactly as before (PRESERVE-DIRTY intact); a stray untracked file no
23
+ longer does.
24
+ - Regression test added: a real temp git origin+clone pair reproduces both the
25
+ untracked-cruft case (must sync) and the genuine-local-edit case (must still
26
+ block), so this cannot silently regress.
27
+ - `tests/install-rdc-skills.test.mjs` also had two stale fixtures caught while
28
+ fixing this: `package-lock.json` version drifted from `package.json`
29
+ (0.35.2 vs 0.35.3+), and a hardcoded `skillCount === 36` assertion had rotted
30
+ against the real, current 43 skill directories. The count assertion now
31
+ compares against the actual `skills/*/SKILL.md` directories on disk instead
32
+ of a hardcoded number, so it can't go stale the same way again.
33
+
34
+ ---
35
+
10
36
  ## v0.35.3 — coding standards wired into plan/preplan/build/fixit/review
11
37
 
12
38
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.35.3",
3
+ "version": "0.35.4",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -508,8 +508,16 @@ function syncMarketplaceCheckout(mktDir) {
508
508
  const git = (...args) =>
509
509
  execSync(`git -C "${mktDir}" ${args.join(' ')}`, { encoding: 'utf8', stdio: 'pipe' }).trim();
510
510
  try {
511
- if (git('status', '--porcelain')) {
512
- warn(`marketplace clone has local changes at ${mktDir} — left untouched; it may serve stale commands`);
511
+ // PRESERVE-DIRTY means "don't discard a real local edit to a TRACKED file" —
512
+ // `--untracked-files=no` is deliberate. Plain `git status --porcelain` also
513
+ // reports untracked files (`?? some-cruft.txt`), which `git reset --hard`
514
+ // never touches and never conflicts with. Treating that as "dirty" made this
515
+ // sync silently refuse forever the moment ANY stray untracked file appeared
516
+ // in the clone — which is exactly how this checkout sat 39+ commits behind
517
+ // for over a week (2026-08-23), still warning "left untouched" on every run,
518
+ // while a plain `git status` on the same directory read completely clean.
519
+ if (git('status', '--porcelain', '--untracked-files=no')) {
520
+ warn(`marketplace clone has local changes to tracked files at ${mktDir} — left untouched; it may serve stale commands`);
513
521
  return;
514
522
  }
515
523
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD') || 'master';
@@ -1347,7 +1355,7 @@ async function main() {
1347
1355
  // scripts/probe-installed-hooks.mjs verify the real install path instead of
1348
1356
  // re-implementing it — a probe that copies its own way proves nothing about what
1349
1357
  // ships. Without this guard, requiring the module would run a full install.
1350
- module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, RDC_ENV_HOOK_TIMEOUT_SEC };
1358
+ module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC };
1351
1359
 
1352
1360
  if (require.main === module) {
1353
1361
  main().catch(e => { fail(e.message); process.exit(1); });
@@ -44,7 +44,22 @@ assert.match(
44
44
  const skillCount = Array.isArray(plugin.skills_meta)
45
45
  ? plugin.skills_meta.length
46
46
  : Object.keys(plugin.skills_meta || {}).length;
47
- assert.equal(skillCount, 36, 'test fixture should expose all 36 MCP skills from plugin skills_meta');
47
+ // A hardcoded magic number here (previously 36, silently stale against the
48
+ // real 43) rots the instant a skill is added or removed and stops proving
49
+ // anything — it only proves someone remembered to bump a number. Assert
50
+ // against the actual skills/ directory instead: every dir containing a
51
+ // SKILL.md must be represented in plugin.json's skills_meta, and vice versa.
52
+ const skillsDir = join(REPO_ROOT, 'skills');
53
+ const realSkillDirs = require('node:fs')
54
+ .readdirSync(skillsDir, { withFileTypes: true })
55
+ .filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
56
+ .map((e) => e.name)
57
+ .sort();
58
+ assert.equal(
59
+ skillCount,
60
+ realSkillDirs.length,
61
+ `plugin.json skills_meta (${skillCount}) must match the actual skill directories on disk (${realSkillDirs.length}): ${realSkillDirs.join(', ')}`,
62
+ );
48
63
  assert.match(
49
64
  source,
50
65
  /Available MCP skills.*\/rdc:\* command shorthands/,
@@ -97,6 +112,81 @@ try {
97
112
  rmSync(codexSkills, { recursive: true, force: true });
98
113
  }
99
114
 
115
+ // Regression for the 2026-08-23 incident: a marketplace clone with ZERO real
116
+ // local edits — only a stray untracked file — sat 39+ commits behind for over
117
+ // a week because `git status --porcelain` (which also reports untracked
118
+ // files) was treated as "has local changes, never touch it". `git reset
119
+ // --hard` never touches untracked files, so an untracked file is irrelevant
120
+ // to whether the sync is safe. This builds a real origin + clone pair,
121
+ // reproduces the exact scenario, and asserts the clone actually advances.
122
+ const { syncMarketplaceCheckout } = require(script);
123
+ const gitTestRoot = mkdtempSync(join(tmpdir(), 'rdc-marketplace-sync-'));
124
+ try {
125
+ const originDir = join(gitTestRoot, 'origin');
126
+ const cloneDir = join(gitTestRoot, 'clone');
127
+ const runGit = (cwd, args) => {
128
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
129
+ assert.equal(res.status, 0, `git ${args.join(' ')} failed: ${res.stderr}`);
130
+ return res.stdout.trim();
131
+ };
132
+
133
+ mkdirSync(originDir, { recursive: true });
134
+ runGit(originDir, ['init', '--initial-branch=master']);
135
+ runGit(originDir, ['config', 'user.email', 'test@example.com']);
136
+ runGit(originDir, ['config', 'user.name', 'Test']);
137
+ writeFileSync(join(originDir, 'file.txt'), 'v1\n');
138
+ runGit(originDir, ['add', '.']);
139
+ runGit(originDir, ['commit', '-m', 'v1']);
140
+
141
+ runGit(gitTestRoot, ['clone', originDir, cloneDir]);
142
+
143
+ // Advance origin so the clone is genuinely behind.
144
+ writeFileSync(join(originDir, 'file.txt'), 'v2\n');
145
+ runGit(originDir, ['add', '.']);
146
+ runGit(originDir, ['commit', '-m', 'v2']);
147
+ const originHead = runGit(originDir, ['rev-parse', 'HEAD']);
148
+
149
+ // The exact reproduction: a stray UNTRACKED file, nothing modified/staged.
150
+ writeFileSync(join(cloneDir, 'some-local-cruft.cjs'), 'module.exports = {};\n');
151
+ assert.match(
152
+ runGit(cloneDir, ['status', '--porcelain']),
153
+ /^\?\? some-local-cruft\.cjs$/,
154
+ 'precondition: clone must show ONLY an untracked file, nothing modified',
155
+ );
156
+
157
+ syncMarketplaceCheckout(cloneDir);
158
+
159
+ assert.equal(
160
+ runGit(cloneDir, ['rev-parse', 'HEAD']),
161
+ originHead,
162
+ 'a clone with only an untracked file must still advance to origin — untracked cruft is not a local edit',
163
+ );
164
+ assert.equal(
165
+ existsSync(join(cloneDir, 'some-local-cruft.cjs')),
166
+ true,
167
+ 'the untracked file itself must survive the sync — reset --hard never touches it',
168
+ );
169
+
170
+ // Genuine dirt — a MODIFIED tracked file — must still block the sync.
171
+ writeFileSync(join(originDir, 'file.txt'), 'v3\n');
172
+ runGit(originDir, ['add', '.']);
173
+ runGit(originDir, ['commit', '-m', 'v3']);
174
+ const originHeadV3 = runGit(originDir, ['rev-parse', 'HEAD']);
175
+ writeFileSync(join(cloneDir, 'file.txt'), 'local edit, never committed\n');
176
+ const beforeDirtySync = runGit(cloneDir, ['rev-parse', 'HEAD']);
177
+
178
+ syncMarketplaceCheckout(cloneDir);
179
+
180
+ assert.equal(
181
+ runGit(cloneDir, ['rev-parse', 'HEAD']),
182
+ beforeDirtySync,
183
+ 'a real local edit to a TRACKED file must still block the sync (PRESERVE-DIRTY)',
184
+ );
185
+ assert.notEqual(beforeDirtySync, originHeadV3, 'sanity: origin did advance further in this step');
186
+ } finally {
187
+ rmSync(gitTestRoot, { recursive: true, force: true });
188
+ }
189
+
100
190
  const packagedRoot = mkdtempSync(join(tmpdir(), 'rdc-packaged-truth-'));
101
191
  try {
102
192
  writeFileSync(join(packagedRoot, 'package.json'), JSON.stringify({ version: '9.9.9' }));