@lifeaitools/rdc-skills 0.9.36 → 0.9.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## v0.9.37 — RDC hook loop guard and behavior tests
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Stop hook reentry.** `rdc-output-contract-gate.js` now exits cleanly when Claude is already inside an active Stop-hook retry, preventing noncompliant-but-legitimate turns from hard-looping the session.
|
|
15
|
+
- **Marker deduplication.** `rdc-invocation-marker.js` now preserves the original marker when `UserPromptExpansion` and `UserPromptSubmit` both fire for the same RDC command within the same second.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Hook behavioral tests.** New `scripts/test-rdc-hooks.mjs` executes the marker and Stop hooks against real JSON payloads, and `self-test --strict` now fails if those behavioral checks fail.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
10
23
|
## v0.9.36 — Portable installer profiles and startup onboarding
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -76,7 +76,7 @@ function detectRdc(raw) {
|
|
|
76
76
|
|
|
77
77
|
if (event === 'UserPromptSubmit') {
|
|
78
78
|
const prompt = String(raw.prompt || '').trim();
|
|
79
|
-
const m = prompt.match(/^\/
|
|
79
|
+
const m = prompt.match(/^\/rdc[:-]([a-z][a-z0-9-]*)\b/i);
|
|
80
80
|
if (!m) return null;
|
|
81
81
|
const command = normalizeCommandName(m[1]);
|
|
82
82
|
if (RDC_COMMANDS.has(command)) return command;
|
|
@@ -87,6 +87,20 @@ function detectRdc(raw) {
|
|
|
87
87
|
|
|
88
88
|
function writeMarker(raw, command) {
|
|
89
89
|
fs.mkdirSync(markerDir(), { recursive: true });
|
|
90
|
+
const p = markerPath(raw.session_id);
|
|
91
|
+
if (fs.existsSync(p)) {
|
|
92
|
+
try {
|
|
93
|
+
const existing = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
94
|
+
const started = Date.parse(existing.started_at || '');
|
|
95
|
+
const isRecentDuplicate = Number.isFinite(started) &&
|
|
96
|
+
Date.now() - started <= 1000 &&
|
|
97
|
+
existing.session_id === (raw.session_id || null) &&
|
|
98
|
+
existing.command === command;
|
|
99
|
+
if (isRecentDuplicate) {
|
|
100
|
+
return { ...existing, deduped: true };
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
90
104
|
const marker = {
|
|
91
105
|
session_id: raw.session_id || null,
|
|
92
106
|
command,
|
|
@@ -98,7 +112,7 @@ function writeMarker(raw, command) {
|
|
|
98
112
|
started_at: new Date().toISOString(),
|
|
99
113
|
hook_event_name: raw.hook_event_name || null,
|
|
100
114
|
};
|
|
101
|
-
fs.writeFileSync(
|
|
115
|
+
fs.writeFileSync(p, JSON.stringify(marker, null, 2));
|
|
102
116
|
return marker;
|
|
103
117
|
}
|
|
104
118
|
|
|
@@ -127,7 +141,7 @@ async function main() {
|
|
|
127
141
|
|
|
128
142
|
try {
|
|
129
143
|
const marker = writeMarker(raw, command);
|
|
130
|
-
hookLog('rdc-invocation-marker', raw.hook_event_name || 'unknown', 'marked', {
|
|
144
|
+
hookLog('rdc-invocation-marker', raw.hook_event_name || 'unknown', marker.deduped ? 'deduped' : 'marked', {
|
|
131
145
|
command,
|
|
132
146
|
session_id: marker.session_id,
|
|
133
147
|
});
|
|
@@ -60,6 +60,14 @@ function pass(marker) {
|
|
|
60
60
|
process.exit(0);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function passReentry(marker) {
|
|
64
|
+
try { fs.unlinkSync(marker.path); } catch {}
|
|
65
|
+
hookLog('rdc-output-contract-gate', 'Stop', 'pass-reentry', {
|
|
66
|
+
command: marker.data.command || null,
|
|
67
|
+
});
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
async function main() {
|
|
64
72
|
let raw;
|
|
65
73
|
try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
|
|
@@ -70,6 +78,7 @@ async function main() {
|
|
|
70
78
|
const checklist = hasChecklist(message);
|
|
71
79
|
const verdict = hasVerdict(message);
|
|
72
80
|
if (checklist && verdict) pass(marker);
|
|
81
|
+
if (raw.stop_hook_active === true) passReentry(marker);
|
|
73
82
|
|
|
74
83
|
const command = marker.data.command || 'rdc';
|
|
75
84
|
const missing = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/rdc-skills",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.37",
|
|
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.9.
|
|
22
|
+
"version": "0.9.37",
|
|
23
23
|
"commands": "commands/"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"uninstall:win": "powershell -ExecutionPolicy Bypass -File scripts/uninstall.ps1",
|
|
28
28
|
"uninstall:unix": "bash scripts/uninstall.sh",
|
|
29
29
|
"validate": "node tests/validate-skills.js",
|
|
30
|
-
"rdc-design": "node scripts/rdc-design-cli.mjs"
|
|
30
|
+
"rdc-design": "node scripts/rdc-design-cli.mjs",
|
|
31
|
+
"test:hooks": "node scripts/test-rdc-hooks.mjs"
|
|
31
32
|
}
|
|
32
33
|
}
|
package/scripts/self-test.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { readFileSync, readdirSync, existsSync, writeFileSync, appendFileSync, renameSync, mkdirSync, statSync } from "node:fs";
|
|
32
32
|
import { join, basename, dirname, resolve } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
34
35
|
import { loadAllManifests } from "./lib/manifest-schema.mjs";
|
|
35
36
|
import { runManifest } from "./lib/runner.mjs";
|
|
36
37
|
import {
|
|
@@ -802,6 +803,19 @@ function auditRequiredHookWiring() {
|
|
|
802
803
|
return findings;
|
|
803
804
|
}
|
|
804
805
|
|
|
806
|
+
function runHookBehaviorTests() {
|
|
807
|
+
const result = spawnSync(process.execPath, [join(REPO_ROOT, "scripts", "test-rdc-hooks.mjs")], {
|
|
808
|
+
cwd: REPO_ROOT,
|
|
809
|
+
encoding: "utf8",
|
|
810
|
+
});
|
|
811
|
+
return {
|
|
812
|
+
ok: result.status === 0,
|
|
813
|
+
status: result.status,
|
|
814
|
+
stdout: (result.stdout || "").trim(),
|
|
815
|
+
stderr: (result.stderr || "").trim(),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
805
819
|
// ─── Tier 2 behavioral runner ──────────────────────────────────────────────
|
|
806
820
|
|
|
807
821
|
async function runPool(items, concurrency, worker) {
|
|
@@ -1038,6 +1052,7 @@ function main() {
|
|
|
1038
1052
|
}
|
|
1039
1053
|
|
|
1040
1054
|
const guideValidatorResultJson = !ONLY_SKILL ? runGuideContentValidator() : null;
|
|
1055
|
+
const hookBehaviorResultJson = !ONLY_SKILL ? runHookBehaviorTests() : null;
|
|
1041
1056
|
|
|
1042
1057
|
const failed = results.filter((r) => r.errors.length > 0);
|
|
1043
1058
|
const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
@@ -1050,6 +1065,11 @@ function main() {
|
|
|
1050
1065
|
...duplicateFindings.filter((f) => f.level === "error"),
|
|
1051
1066
|
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1052
1067
|
...(guideValidatorResultJson ? guideValidatorResultJson.errors : []),
|
|
1068
|
+
...(hookBehaviorResultJson && !hookBehaviorResultJson.ok ? [{
|
|
1069
|
+
level: "error",
|
|
1070
|
+
code: "hook-behavior-test-failed",
|
|
1071
|
+
message: hookBehaviorResultJson.stderr || hookBehaviorResultJson.stdout || `exit ${hookBehaviorResultJson.status}`,
|
|
1072
|
+
}] : []),
|
|
1053
1073
|
];
|
|
1054
1074
|
const globalWarnings = [
|
|
1055
1075
|
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
@@ -1092,6 +1112,7 @@ function main() {
|
|
|
1092
1112
|
findings: guideValidatorResultJson.findings,
|
|
1093
1113
|
}
|
|
1094
1114
|
: null,
|
|
1115
|
+
hook_behavior_tests: hookBehaviorResultJson,
|
|
1095
1116
|
results: results.map((r) => ({
|
|
1096
1117
|
skill: r.skill,
|
|
1097
1118
|
file: r.file,
|
|
@@ -1217,10 +1238,12 @@ function main() {
|
|
|
1217
1238
|
let duplicateFindings = [];
|
|
1218
1239
|
let orphanHookFindings = [];
|
|
1219
1240
|
let requiredHookFindings = [];
|
|
1241
|
+
let hookBehaviorResult = null;
|
|
1220
1242
|
if (!ONLY_SKILL) {
|
|
1221
1243
|
duplicateFindings = auditDuplicates(results);
|
|
1222
1244
|
orphanHookFindings = auditOrphanHooks(results);
|
|
1223
1245
|
requiredHookFindings = auditRequiredHookWiring();
|
|
1246
|
+
hookBehaviorResult = runHookBehaviorTests();
|
|
1224
1247
|
}
|
|
1225
1248
|
|
|
1226
1249
|
if (duplicateFindings.length + orphanHookFindings.length + requiredHookFindings.length > 0) {
|
|
@@ -1230,6 +1253,14 @@ function main() {
|
|
|
1230
1253
|
}
|
|
1231
1254
|
}
|
|
1232
1255
|
|
|
1256
|
+
if (hookBehaviorResult) {
|
|
1257
|
+
console.log("\nhook behavior tests\n");
|
|
1258
|
+
console.log("─".repeat(80));
|
|
1259
|
+
console.log(hookBehaviorResult.ok ? " pass rdc marker/gate behavioral smoke tests" : " FAIL rdc marker/gate behavioral smoke tests");
|
|
1260
|
+
if (hookBehaviorResult.stdout) console.log(` ${hookBehaviorResult.stdout}`);
|
|
1261
|
+
if (hookBehaviorResult.stderr) console.log(` ${hookBehaviorResult.stderr}`);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1233
1264
|
if (FIXED_FILES.length > 0) {
|
|
1234
1265
|
console.log("\nfixed files (review with git diff):");
|
|
1235
1266
|
for (const p of FIXED_FILES) console.log(` ${p}`);
|
|
@@ -1243,6 +1274,11 @@ function main() {
|
|
|
1243
1274
|
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1244
1275
|
...requiredHookFindings.filter((f) => f.level === "error"),
|
|
1245
1276
|
...(guideValidatorResult ? guideValidatorResult.errors : []),
|
|
1277
|
+
...(hookBehaviorResult && !hookBehaviorResult.ok ? [{
|
|
1278
|
+
level: "error",
|
|
1279
|
+
code: "hook-behavior-test-failed",
|
|
1280
|
+
message: hookBehaviorResult.stderr || hookBehaviorResult.stdout || `exit ${hookBehaviorResult.status}`,
|
|
1281
|
+
}] : []),
|
|
1246
1282
|
];
|
|
1247
1283
|
const globalWarnings = [
|
|
1248
1284
|
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Behavioral smoke tests for the RDC marker and Stop output-contract hooks.
|
|
3
|
+
|
|
4
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const REPO_ROOT = resolve(__dirname, "..");
|
|
12
|
+
const MARKER_HOOK = join(REPO_ROOT, "hooks", "rdc-invocation-marker.js");
|
|
13
|
+
const STOP_HOOK = join(REPO_ROOT, "hooks", "rdc-output-contract-gate.js");
|
|
14
|
+
|
|
15
|
+
const tmpHome = mkdtempSync(join(tmpdir(), "rdc-hooks-"));
|
|
16
|
+
const env = { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome };
|
|
17
|
+
const failures = [];
|
|
18
|
+
|
|
19
|
+
function markerPath(sessionId) {
|
|
20
|
+
return join(tmpHome, ".claude", "rdc-active", `${sessionId}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runHook(script, payload) {
|
|
24
|
+
return spawnSync(process.execPath, [script], {
|
|
25
|
+
input: JSON.stringify(payload),
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
env,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assert(name, condition, detail = "") {
|
|
32
|
+
if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readMarker(sessionId) {
|
|
36
|
+
return JSON.parse(readFileSync(markerPath(sessionId), "utf8"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const expansion = runHook(MARKER_HOOK, {
|
|
41
|
+
hook_event_name: "UserPromptExpansion",
|
|
42
|
+
session_id: "s-expansion",
|
|
43
|
+
command_source: "plugin",
|
|
44
|
+
command_name: "rdc:design",
|
|
45
|
+
prompt: "/rdc:design palette audit",
|
|
46
|
+
});
|
|
47
|
+
assert("expansion exits zero", expansion.status === 0, expansion.stderr);
|
|
48
|
+
assert("expansion emits context", /RDC CONTRACT ACTIVE/.test(expansion.stdout), expansion.stdout);
|
|
49
|
+
assert("expansion writes marker", existsSync(markerPath("s-expansion")));
|
|
50
|
+
assert("expansion marker event preserved", readMarker("s-expansion").hook_event_name === "UserPromptExpansion");
|
|
51
|
+
|
|
52
|
+
const builtin = runHook(MARKER_HOOK, {
|
|
53
|
+
hook_event_name: "UserPromptExpansion",
|
|
54
|
+
session_id: "s-builtin",
|
|
55
|
+
command_source: "builtin",
|
|
56
|
+
command_name: "help",
|
|
57
|
+
prompt: "/help",
|
|
58
|
+
});
|
|
59
|
+
assert("builtin exits zero", builtin.status === 0, builtin.stderr);
|
|
60
|
+
assert("builtin emits no context", builtin.stdout.trim() === "", builtin.stdout);
|
|
61
|
+
assert("builtin writes no marker", !existsSync(markerPath("s-builtin")));
|
|
62
|
+
|
|
63
|
+
const submit1 = runHook(MARKER_HOOK, {
|
|
64
|
+
hook_event_name: "UserPromptExpansion",
|
|
65
|
+
session_id: "s-dedup",
|
|
66
|
+
command_source: "plugin",
|
|
67
|
+
command_name: "rdc:design",
|
|
68
|
+
prompt: "/rdc:design button audit",
|
|
69
|
+
});
|
|
70
|
+
assert("dedup first mark exits zero", submit1.status === 0, submit1.stderr);
|
|
71
|
+
const first = readMarker("s-dedup");
|
|
72
|
+
const submit2 = runHook(MARKER_HOOK, {
|
|
73
|
+
hook_event_name: "UserPromptSubmit",
|
|
74
|
+
session_id: "s-dedup",
|
|
75
|
+
prompt: "/rdc:design button audit",
|
|
76
|
+
});
|
|
77
|
+
assert("dedup second mark exits zero", submit2.status === 0, submit2.stderr);
|
|
78
|
+
const second = readMarker("s-dedup");
|
|
79
|
+
assert("dedup preserves started_at", second.started_at === first.started_at);
|
|
80
|
+
assert("dedup preserves hook event", second.hook_event_name === "UserPromptExpansion");
|
|
81
|
+
|
|
82
|
+
const genericHelp = runHook(MARKER_HOOK, {
|
|
83
|
+
hook_event_name: "UserPromptSubmit",
|
|
84
|
+
session_id: "s-help",
|
|
85
|
+
prompt: "/help",
|
|
86
|
+
});
|
|
87
|
+
assert("generic help exits zero", genericHelp.status === 0, genericHelp.stderr);
|
|
88
|
+
assert("generic help writes no marker", !existsSync(markerPath("s-help")));
|
|
89
|
+
|
|
90
|
+
const noMarker = runHook(STOP_HOOK, {
|
|
91
|
+
hook_event_name: "Stop",
|
|
92
|
+
session_id: "s-no-marker",
|
|
93
|
+
last_assistant_message: "plain chat",
|
|
94
|
+
});
|
|
95
|
+
assert("stop without marker exits zero", noMarker.status === 0, noMarker.stderr);
|
|
96
|
+
assert("stop without marker is silent", noMarker.stdout.trim() === "", noMarker.stdout);
|
|
97
|
+
|
|
98
|
+
writeFileSync(markerPath("s-block"), JSON.stringify({
|
|
99
|
+
session_id: "s-block",
|
|
100
|
+
command: "design",
|
|
101
|
+
started_at: new Date().toISOString(),
|
|
102
|
+
}, null, 2));
|
|
103
|
+
const blocked = runHook(STOP_HOOK, {
|
|
104
|
+
hook_event_name: "Stop",
|
|
105
|
+
session_id: "s-block",
|
|
106
|
+
last_assistant_message: "No contract artifacts here.",
|
|
107
|
+
});
|
|
108
|
+
assert("noncompliant stop exits zero", blocked.status === 0, blocked.stderr);
|
|
109
|
+
assert("noncompliant stop blocks", /"decision"\s*:\s*"block"/.test(blocked.stdout), blocked.stdout);
|
|
110
|
+
assert("noncompliant stop retains marker", existsSync(markerPath("s-block")));
|
|
111
|
+
|
|
112
|
+
const reentry = runHook(STOP_HOOK, {
|
|
113
|
+
hook_event_name: "Stop",
|
|
114
|
+
session_id: "s-block",
|
|
115
|
+
stop_hook_active: true,
|
|
116
|
+
last_assistant_message: "Still no contract artifacts.",
|
|
117
|
+
});
|
|
118
|
+
assert("stop reentry exits zero", reentry.status === 0, reentry.stderr);
|
|
119
|
+
assert("stop reentry allows silent pass", reentry.stdout.trim() === "", reentry.stdout);
|
|
120
|
+
assert("stop reentry clears marker", !existsSync(markerPath("s-block")));
|
|
121
|
+
|
|
122
|
+
writeFileSync(markerPath("s-pass"), JSON.stringify({
|
|
123
|
+
session_id: "s-pass",
|
|
124
|
+
command: "review",
|
|
125
|
+
started_at: new Date().toISOString(),
|
|
126
|
+
}, null, 2));
|
|
127
|
+
const compliant = runHook(STOP_HOOK, {
|
|
128
|
+
hook_event_name: "Stop",
|
|
129
|
+
session_id: "s-pass",
|
|
130
|
+
last_assistant_message: "[x] Verified hook behavior\n\n✅ Complete in 2s",
|
|
131
|
+
});
|
|
132
|
+
assert("compliant stop exits zero", compliant.status === 0, compliant.stderr);
|
|
133
|
+
assert("compliant stop is silent", compliant.stdout.trim() === "", compliant.stdout);
|
|
134
|
+
assert("compliant stop clears marker", !existsSync(markerPath("s-pass")));
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (failures.length > 0) {
|
|
140
|
+
console.error("\nrdc hook behavior tests — FAIL\n");
|
|
141
|
+
for (const failure of failures) console.error(` - ${failure}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log("rdc hook behavior tests — PASS");
|