@lifeaitools/rdc-skills 0.37.1 → 0.37.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.37.1",
3
+ "version": "0.37.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,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.37.2 — the release doc says what actually happens
11
+
12
+ ### Fixed
13
+ - RELEASE.md step 3 is one command, `work-ship --publish`, which waits for the tag's CI publish, falls back to the token route with npm's reason printed, and installs the exact version once npm serves it (lifeai-env d4ff3f6).
14
+ - RELEASE.md step 4 records the measured CI state: npm's OIDC exchange answers "package not found" for @lifeaitools/rdc-skills, so no trusted publisher matches LIFEAI/rdc-skills publish.yml, and names what corrects it.
15
+
16
+ ---
17
+
10
18
  ## v0.37.1 — one version in every manifest
11
19
 
12
20
  ### Fixed
package/RELEASE.md CHANGED
@@ -38,17 +38,27 @@ node scripts/sync-manifest-versions.mjs --check # exit 1 names any declaration
38
38
  # manifest FAIL) both fail on drift. 0.37.0 shipped clauth-plugin.json at 0.35.10
39
39
  # because the self-test then checked plugin.json alone.
40
40
 
41
- # 3. Commit, tag, and push (the hook already staged every manifest)
42
- git add CHANGELOG.md
43
- git commit -m "release: v$(node -e \"console.log(require('./package.json').version)\")"
44
- git tag "v$(node -e \"console.log(require('./package.json').version)\")"
45
- git push origin master --tags
46
-
47
- # 4. Publish through the tag-triggered GitHub workflow
48
-
49
- # 5. Verify the published package
50
- npm view @lifeaitools/rdc-skills version
51
- npm install -g @lifeaitools/rdc-skills@latest
41
+ # 3. Ship it — ONE command. Write the release commit message to a file first.
42
+ # work-ship checks every version field, commits, runs test:acceptance, pushes
43
+ # master, pushes the tag, WAITS for the tag's publish.yml run, and:
44
+ # - CI published -> reports the run id (the version carries provenance)
45
+ # - CI refused -> prints npm's own reason as `warn ci-publish`, then
46
+ # publishes with the vault's npm token instead
47
+ # Then it waits until npm SERVES that exact version (npm processes a publish
48
+ # first; 0.37.1 took 239 s) and installs that version, failing on a mismatch.
49
+ node "$LIFEAI_ENV/sessions/work-ship.mjs" --repo rdc-skills --lane <this worktree> -F <msgfile> --publish
50
+
51
+ # 4. CI's publish, as measured 2026-09-21: every tag since v0.33.0 was refused at
52
+ # npm's OIDC exchange with "OIDC token exchange error - package not found" --
53
+ # npm holds no trusted publisher for @lifeaitools/rdc-skills that matches
54
+ # LIFEAI / rdc-skills / publish.yml. publish.yml prints that line on every run
55
+ # (step "OIDC exchange result"). Correcting it takes the npm owner's 2FA:
56
+ # npmjs.com -> @lifeaitools/rdc-skills -> Settings -> Trusted Publisher, or
57
+ # `npm trust github @lifeaitools/rdc-skills --file publish.yml --repository LIFEAI/rdc-skills --allow-publish`.
58
+ # Until then step 3 finishes every release through the token route and says so.
59
+
60
+ # 5. Refresh the local plugin cache and read the verdict
61
+ rdc-skills-install --profile lifeai
52
62
  npm list -g @lifeaitools/rdc-skills --depth=0
53
63
  rdc-skills-self-test
54
64
  # ^ run this LAST and read the verdict line, not just the section pass counts —
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "lifeai.plugin.v1",
3
3
  "id": "rdc-skills",
4
- "version": "0.37.1",
4
+ "version": "0.37.4",
5
5
  "publisher": "LIFEAI",
6
6
  "core": false,
7
7
  "documentation": {
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "ed080efda2d2537ca723128a016188aefd8f8fb5"
2
+ "sha": "7174da1ee88b03b186bfc102a951ac2f770aec6c"
3
3
  }
@@ -35,7 +35,7 @@ propose-rule for one domain. This generalizes that pattern fleet-wide.
35
35
 
36
36
  ### Why lessons are not files any more
37
37
 
38
- Until 2026-09 every lesson was a markdown file in the capturing repository's
38
+ Until 2026-09-17 every lesson was a markdown file in the capturing repository's
39
39
  `.rdc/lessons/`. A census of one repository found 315 of them: 157 already enforced in code,
40
40
  where the file only repeated what a guard or test already guaranteed, and 32 enforced
41
41
  nowhere, sitting in a directory the owner of the fix never reads. A lesson filed where it
@@ -48,6 +48,13 @@ So the record now follows the fix:
48
48
  - **An unencoded lesson is a GitHub issue in the repository the fix belongs to**, labelled
49
49
  `lesson` + `proposal`. **An issue proposes; a work item commits.**
50
50
 
51
+ ### Retired on-disk log
52
+
53
+ The old lesson log and its `.session-*.done` sentinels were retired from the active
54
+ regen-root tree by `247571602` on 2026-09-17. They remain recoverable from Git history;
55
+ they are not a conversion backlog and must not be recreated. The active record is now the
56
+ owning-repository lesson issue, its work item when accepted, and the enforcing commit.
57
+
51
58
  ---
52
59
 
53
60
  ## When to capture (at skill exit)
@@ -218,9 +225,8 @@ the command exits 1. Treat that as an incomplete triage, never as "no lessons th
218
225
  API/MCP changes, skill-contract changes affecting multiple skills). When unsure, it is
219
226
  architectural.
220
227
  3. Route per the mode below.
221
- 4. Work the conversion backlog (§ Existing `.rdc/lessons/` files) with whatever capacity is left.
222
- 5. Summarize in the housekeeping report: open / recurred / accepted / closed-fixed /
223
- closed-won't-fix counts with issue URLs, plus backlog files converted and remaining.
228
+ 4. Summarize in the housekeeping report: open / recurred / accepted / closed-fixed /
229
+ closed-won't-fix counts with issue URLs.
224
230
 
225
231
  ### Attended mode (default — a human is present)
226
232
 
@@ -255,17 +261,13 @@ reason, is the audit trail.
255
261
 
256
262
  ---
257
263
 
258
- ## Existing `.rdc/lessons/` files — the conversion backlog
259
-
260
- Files written under the old procedure are retired by state: an encoded lesson's file is
261
- deleted and links to it point at the enforcing artifact; an unencoded one becomes an issue in
262
- its owning repository. **The files that remain — doc-only lessons and product lessons — are a
263
- backlog to convert, not a place to capture.** Each is done when a guard or test carries it:
264
- ship that artifact, point any live link at it, and delete the file in the same commit.
264
+ ## Historical records and archive policy
265
265
 
266
- Housekeeping lists the remaining `.rdc/lessons/*.md` files for exactly that purpose and
267
- reports the count converted and remaining. Nothing new is written there: a new lesson — a
268
- doc-only one included — is an issue (§ Capture procedure).
266
+ Do not create, scan, or modify `.rdc/lessons/` as part of normal capture or triage. The
267
+ retired files are preserved by Git history. Lesson issues are also never deleted: a closed
268
+ issue retains its evidence, comments, labels, and linked commit and can be reopened if the
269
+ lesson recurs. Close an issue only after the fixing commit lands or with a clear won't-fix /
270
+ duplicate reason; leave an unaccepted `proposal` issue open as the active queue.
269
271
 
270
272
  ---
271
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.37.1",
3
+ "version": "0.37.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",
@@ -19,7 +19,7 @@
19
19
  "type": "plugin",
20
20
  "skills": "skills/",
21
21
  "guides": "guides/",
22
- "version": "0.37.1",
22
+ "version": "0.37.4",
23
23
  "commands": "commands/"
24
24
  },
25
25
  "bin": {
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Commit and tag one declared release change set.
4
+ *
5
+ * The wrapper owns the only documented commit/tag path so the staged-path
6
+ * guard runs while there is still something to reject.
7
+ */
8
+ import { execFileSync, spawnSync } from 'node:child_process';
9
+ import path from 'node:path';
10
+
11
+ function fail(message) {
12
+ process.stderr.write(`rdc-release: ${message}\n`);
13
+ process.exit(2);
14
+ }
15
+
16
+ function git(repo, args, options = {}) {
17
+ return execFileSync('git', args, { cwd: repo, encoding: 'utf8', ...options });
18
+ }
19
+
20
+ function readArgs(argv) {
21
+ const result = { allow: [], dryRun: false };
22
+ for (let index = 0; index < argv.length; index += 1) {
23
+ const arg = argv[index];
24
+ if (arg === '--dry-run') {
25
+ result.dryRun = true;
26
+ continue;
27
+ }
28
+ if (!['--repo', '--allow', '--message', '--tag'].includes(arg)) fail(`unknown argument ${arg}`);
29
+ const value = argv[index + 1];
30
+ if (!value || value.startsWith('--')) fail(`${arg} requires a value`);
31
+ index += 1;
32
+ if (arg === '--allow') result.allow.push(value);
33
+ else result[arg.slice(2)] = value;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ function normalizedPath(value) {
39
+ const normalized = value.replaceAll('\\', '/');
40
+ if (!normalized || path.posix.isAbsolute(normalized) || normalized.split('/').includes('..')) {
41
+ fail(`--allow must be a repository-relative path: ${value}`);
42
+ }
43
+ return normalized.replace(/^\.\//, '');
44
+ }
45
+
46
+ const options = readArgs(process.argv.slice(2));
47
+ if (!options.repo || !options.message || !options.tag || options.allow.length === 0) {
48
+ fail('usage: rdc-release --repo <path> --allow <path> [--allow <path>] --message <message> --tag <tag> [--dry-run]');
49
+ }
50
+
51
+ const repo = path.resolve(options.repo);
52
+ try {
53
+ git(repo, ['rev-parse', '--is-inside-work-tree']);
54
+ } catch {
55
+ fail(`not a Git worktree: ${repo}`);
56
+ }
57
+
58
+ const allowed = new Set(options.allow.map(normalizedPath));
59
+ // A rename is two release-relevant paths: the removed source and the added
60
+ // destination. Disable Git's similarity presentation so neither can disappear
61
+ // behind a single destination path in the allowlist check.
62
+ const staged = git(repo, ['diff', '--cached', '--no-renames', '--name-only', '-z'])
63
+ .split('\0')
64
+ .filter(Boolean)
65
+ .map((value) => value.replaceAll('\\', '/'));
66
+ if (staged.length === 0) fail('no staged release paths; run this before committing');
67
+
68
+ const stagedSet = new Set(staged);
69
+ const undeclared = staged.filter((file) => !allowed.has(file));
70
+ const missing = [...allowed].filter((file) => !stagedSet.has(file));
71
+ if (undeclared.length || missing.length) {
72
+ const details = [
73
+ undeclared.length ? `undeclared staged path(s): ${undeclared.join(', ')}` : null,
74
+ missing.length ? `declared path(s) not staged: ${missing.join(', ')}` : null,
75
+ ].filter(Boolean);
76
+ fail(details.join('; '));
77
+ }
78
+
79
+ const tagCheck = spawnSync('git', ['check-ref-format', `refs/tags/${options.tag}`], { cwd: repo, encoding: 'utf8' });
80
+ if (tagCheck.status !== 0) fail(`invalid tag: ${options.tag}`);
81
+ const existingTag = spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${options.tag}`], { cwd: repo, encoding: 'utf8' });
82
+ if (existingTag.status === 0) fail(`tag already exists: ${options.tag}`);
83
+
84
+ if (options.dryRun) {
85
+ process.stdout.write(`rdc-release: dry-run passed for ${staged.length} declared path(s); would commit and tag ${options.tag}\n`);
86
+ process.exit(0);
87
+ }
88
+
89
+ git(repo, ['commit', '-m', options.message], { stdio: 'inherit' });
90
+ git(repo, ['tag', options.tag], { stdio: 'inherit' });
91
+ process.stdout.write(`rdc-release: committed ${staged.length} declared path(s) and created ${options.tag}\n`);
@@ -185,11 +185,14 @@ When the weekly triage runs unattended, follow `.rdc/guides/lessons-learned-spec
185
185
  - **architectural with a single clear correct fix** (records an already-learned lesson — e.g. "add a gate", "encode X as a test") → accept, route through `rdc:plan` → `rdc:build` (or `rdc:fixit` if genuinely <5 files).
186
186
  - **architectural and genuinely ambiguous** (multiple valid approaches, real tradeoffs) → write a `human_items` row (`item_type='decision'`, with options in `suggested_agent_prompt`, `source_type='lesson'`, `source_fingerprint` = the issue URL for dedupe), accept the issue against a linked `work_item`. Decided in the morning. This is the asynchronous equivalent of the attended interview and honors `.claude/rules/architectural-change-approval.md`.
187
187
 
188
- ### Conversion backlog — remaining lesson files
189
-
190
- List the lesson files still in the project (`.rdc/lessons/*.md`: doc-only and product lessons from before issues). They are a backlog to convert, not a place to capture: with the capacity left after the issues, pick files and encode each as a guard or test, point any live link at that artifact, and delete the file in the same commit (spec § Existing `.rdc/lessons/` files). Nothing new is written there — a new lesson, doc-only included, is an issue.
191
-
192
- Issues are closed, never deleted — a closed issue with its fixing commit or won't-fix reason is the audit trail. Report open / recurred / accepted / closed-fixed / closed-won't-fix counts with issue URLs, and backlog files converted / remaining, in the housekeeping report.
188
+ ### Retired lesson log
189
+
190
+ Do not scan, recreate, or convert `.rdc/lessons/*.md`. The on-disk log and its sentinels
191
+ were retired from the active tree by regen-root commit `247571602`; Git history preserves
192
+ them for recovery. New and unresolved lessons live as owning-repository `lesson` + `proposal`
193
+ issues. Issues are closed, never deleted — a closed issue with its fixing commit or won't-fix
194
+ reason is the archive. Report open / recurred / accepted / closed-fixed / closed-won't-fix
195
+ counts with issue URLs in the housekeeping report.
193
196
 
194
197
  ## Rules
195
198
  - Never run `pnpm build` — not needed for this audit
@@ -118,13 +118,19 @@ Use repo-local package tooling when available. Examples:
118
118
  npm version patch --no-git-tag-version
119
119
  npm test
120
120
  git add package.json package-lock.json
121
- git commit -m "chore(release): vA.B.C"
122
- git tag vA.B.C
121
+ node scripts/rdc-release.mjs --repo <repo-path> \
122
+ --allow package.json --allow package-lock.json \
123
+ --message "chore(release): vA.B.C" --tag vA.B.C
123
124
  git push origin HEAD
124
125
  git push origin vA.B.C
125
126
  npm view <package-name> version
126
127
  ```
127
128
 
129
+ The wrapper owns this documented commit-and-tag step. It compares the exact
130
+ staged paths with the declared `--allow` set and refuses undeclared or missing
131
+ paths before creating either a commit or a tag. Use `--dry-run` to prove the
132
+ set without mutating the repository.
133
+
128
134
  Never use `--force` or bypass hooks. If a hook fails, fix the cause.
129
135
 
130
136
  ## ⛔ Standalone-repo staging guard (no `git add -A`)
@@ -136,11 +142,11 @@ doc-sync/scope guard to catch contamination. In them:
136
142
  (`git add skills/<name>/SKILL.md package.json ...`).
137
143
  - Run `git status --porcelain` FIRST. If untracked files exist that are not part
138
144
  of the declared change, STOP — list or stash them; do not sweep them in.
139
- - **Pre-tag guard: refuse to tag if `git diff --cached --name-only` includes any
140
- path outside the declared change set.** A broad add swept 4 pre-existing
141
- untracked skill files into a tagged release that CI published before anyone
142
- noticed (retired lesson 2026-06-08-release-git-add-all-swept-untracked-wip). Same
143
- dirty-tree contamination class as a lockfile generated against a dirty tree.
145
+ - **Release-scope guard: run `scripts/rdc-release.mjs` before committing. Refuse
146
+ undeclared staged paths before creating the commit or tag.** A broad add swept
147
+ 4 pre-existing untracked skill files into a tagged release that CI published
148
+ before anyone noticed (retired lesson 2026-06-08-release-git-add-all-swept-untracked-wip).
149
+ Same dirty-tree contamination class as a lockfile generated against a dirty tree.
144
150
 
145
151
  ## ⛔ Cross-platform prepack + verify the PUBLISHED tarball
146
152
 
@@ -91,7 +91,7 @@ If you are touching an entity, read its generated `_context.md` before any local
91
91
  | `operational-door.rdc.plans` | `.rdc/areas/**/{plans,research,reports,decisions,logs,arch,guides}/**` | `authored` | One Home Per Fact, One Direction | `allow_declared_home`<br />`path_conformance` | RDC planning workflow | `active` |
92
92
  | `operational-door.rdc.plans.compatibility` | `.rdc/plans/**` | `generated` | One Home Per Fact, One Direction | `allow_declared_home`<br />`path_conformance`<br />Retire only through the approved area-first-v1 activation receipt. | RDC path migration compiler | `active` |
93
93
  | `operational-door.rdc.reports` | `.rdc/reports/**` | `generated` | One Direction | `allow_declared_home`<br />`path_conformance` | rdc:report | `active` |
94
- | `operational-door.rdc.lessons` | `.rdc/lessons/**` | `append_only` | One Direction, One Home Per Fact | `allow_declared_home`<br />`path_conformance` | RDC lesson capture workflow | `active` |
94
+ | `operational-door.rdc.lesson-issues` | `GitHub: owning repository issues labelled lesson` | `stateful` | One Direction, One Home Per Fact | `rdc-lesson submit/accept/close`<br />`issue labels: proposal -> accepted -> closed` | RDC lesson capture workflow | `active` |
95
95
  | `operational-door.rpms-filemap.rule` | `.claude/rules/rpms-filemap.md`<br />created_by: `scripts/rpms/gen-filemap.ts` | `generated` | One Home Per Fact, One Direction, One Door | `block_manual_edit`<br />`projection_drift` | scripts/rpms/gen-filemap.ts | `active` |
96
96
 
97
97
  ### App Guides
@@ -97,7 +97,7 @@ If you are touching an entity, read its generated `_context.md` before any local
97
97
  | `operational-door.rdc.plans` | `.rdc/areas/**/{plans,research,reports,decisions,logs,arch,guides}/**` | `authored` | One Home Per Fact, One Direction | `allow_declared_home`<br />`path_conformance` | RDC planning workflow | `active` |
98
98
  | `operational-door.rdc.plans.compatibility` | `.rdc/plans/**` | `generated` | One Home Per Fact, One Direction | `allow_declared_home`<br />`path_conformance`<br />Retire only through the approved area-first-v1 activation receipt. | RDC path migration compiler | `active` |
99
99
  | `operational-door.rdc.reports` | `.rdc/reports/**` | `generated` | One Direction | `allow_declared_home`<br />`path_conformance` | rdc:report | `active` |
100
- | `operational-door.rdc.lessons` | `.rdc/lessons/**` | `append_only` | One Direction, One Home Per Fact | `allow_declared_home`<br />`path_conformance` | RDC lesson capture workflow | `active` |
100
+ | `operational-door.rdc.lesson-issues` | `GitHub: owning repository issues labelled lesson` | `stateful` | One Direction, One Home Per Fact | `rdc-lesson submit/accept/close`<br />`issue labels: proposal -> accepted -> closed` | RDC lesson capture workflow | `active` |
101
101
  | `operational-door.rpms-filemap.rule` | `.claude/rules/rpms-filemap.md`<br />created_by: `scripts/rpms/gen-filemap.ts` | `generated` | One Home Per Fact, One Direction, One Door | `block_manual_edit`<br />`projection_drift` | scripts/rpms/gen-filemap.ts | `active` |
102
102
 
103
103
  ### App Guides
@@ -0,0 +1,93 @@
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 path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { tmpdir } from 'node:os';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const SCRIPT = path.join(ROOT, 'scripts', 'rdc-release.mjs');
11
+
12
+ function git(repo, args) {
13
+ return execFileSync('git', args, { cwd: repo, encoding: 'utf8' });
14
+ }
15
+
16
+ function fixture() {
17
+ const repo = mkdtempSync(path.join(tmpdir(), 'rdc-release-guard-'));
18
+ git(repo, ['init']);
19
+ git(repo, ['config', 'user.email', 'test@example.invalid']);
20
+ git(repo, ['config', 'user.name', 'Release Guard Test']);
21
+ writeFileSync(path.join(repo, 'README.md'), 'base\n');
22
+ git(repo, ['add', 'README.md']);
23
+ git(repo, ['commit', '-m', 'base']);
24
+ return { repo, cleanup: () => rmSync(repo, { recursive: true, force: true }) };
25
+ }
26
+
27
+ function run(repo, args) {
28
+ return spawnSync(process.execPath, [SCRIPT, '--repo', repo, ...args], { encoding: 'utf8' });
29
+ }
30
+
31
+ test('release guard rejects an extra staged path and creates no tag', (t) => {
32
+ const sandbox = fixture();
33
+ t.after(sandbox.cleanup);
34
+ writeFileSync(path.join(sandbox.repo, 'package.json'), '{"version":"1.0.1"}\n');
35
+ writeFileSync(path.join(sandbox.repo, 'unrelated.md'), 'do not ship\n');
36
+ git(sandbox.repo, ['add', 'package.json', 'unrelated.md']);
37
+
38
+ const result = run(sandbox.repo, ['--allow', 'package.json', '--message', 'release: v1.0.1', '--tag', 'v1.0.1']);
39
+ assert.equal(result.status, 2, result.stderr);
40
+ assert.match(result.stderr, /undeclared staged path\(s\): unrelated\.md/);
41
+ assert.equal(git(sandbox.repo, ['tag', '--list']).trim(), '');
42
+ assert.equal(git(sandbox.repo, ['log', '--format=%s', '-1']).trim(), 'base');
43
+ });
44
+
45
+ test('release guard rejects a rename whose removed source is not declared', (t) => {
46
+ const sandbox = fixture();
47
+ t.after(sandbox.cleanup);
48
+ git(sandbox.repo, ['mv', 'README.md', 'package.json']);
49
+
50
+ const result = run(sandbox.repo, ['--allow', 'package.json', '--message', 'release: v1.0.1', '--tag', 'v1.0.1']);
51
+ assert.equal(result.status, 2, result.stderr);
52
+ assert.match(result.stderr, /undeclared staged path\(s\): README\.md/);
53
+ assert.equal(git(sandbox.repo, ['tag', '--list']).trim(), '');
54
+ assert.equal(git(sandbox.repo, ['log', '--format=%s', '-1']).trim(), 'base');
55
+ });
56
+
57
+ test('release guard rejects a declared path that was not staged', (t) => {
58
+ const sandbox = fixture();
59
+ t.after(sandbox.cleanup);
60
+ writeFileSync(path.join(sandbox.repo, 'package.json'), '{"version":"1.0.1"}\n');
61
+ git(sandbox.repo, ['add', 'package.json']);
62
+
63
+ const result = run(sandbox.repo, ['--allow', 'package.json', '--allow', 'README.md', '--message', 'release: v1.0.1', '--tag', 'v1.0.1']);
64
+ assert.equal(result.status, 2, result.stderr);
65
+ assert.match(result.stderr, /declared path\(s\) not staged: README\.md/);
66
+ assert.equal(git(sandbox.repo, ['tag', '--list']).trim(), '');
67
+ assert.equal(git(sandbox.repo, ['log', '--format=%s', '-1']).trim(), 'base');
68
+ });
69
+
70
+ test('release guard commits and tags only the declared staged set', (t) => {
71
+ const sandbox = fixture();
72
+ t.after(sandbox.cleanup);
73
+ writeFileSync(path.join(sandbox.repo, 'package.json'), '{"version":"1.0.1"}\n');
74
+ git(sandbox.repo, ['add', 'package.json']);
75
+
76
+ const result = run(sandbox.repo, ['--allow', 'package.json', '--message', 'release: v1.0.1', '--tag', 'v1.0.1']);
77
+ assert.equal(result.status, 0, result.stderr);
78
+ assert.equal(git(sandbox.repo, ['tag', '--list']).trim(), 'v1.0.1');
79
+ assert.deepEqual(git(sandbox.repo, ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD']).trim().split('\n'), ['package.json']);
80
+ });
81
+
82
+ test('release guard dry-run validates the exact set without committing or tagging', (t) => {
83
+ const sandbox = fixture();
84
+ t.after(sandbox.cleanup);
85
+ writeFileSync(path.join(sandbox.repo, 'package.json'), '{"version":"1.0.1"}\n');
86
+ git(sandbox.repo, ['add', 'package.json']);
87
+
88
+ const result = run(sandbox.repo, ['--allow', 'package.json', '--message', 'release: v1.0.1', '--tag', 'v1.0.1', '--dry-run']);
89
+ assert.equal(result.status, 0, result.stderr);
90
+ assert.match(result.stdout, /dry-run passed/);
91
+ assert.equal(git(sandbox.repo, ['tag', '--list']).trim(), '');
92
+ assert.equal(git(sandbox.repo, ['status', '--short']).trim(), 'A package.json');
93
+ });
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { test } from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const skill = readFileSync(path.join(ROOT, 'skills', 'release', 'SKILL.md'), 'utf8');
9
+
10
+ test('release skill routes the scoped commit and tag through the executable guard', () => {
11
+ assert.match(skill, /node scripts\/rdc-release\.mjs --repo <repo-path>/);
12
+ assert.match(skill, /--allow package\.json --allow package-lock\.json/);
13
+ assert.match(skill, /staged paths.*before creating either a commit or a tag/is);
14
+ });