@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.9.36",
3
+ "version": "0.9.37",
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,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(/^\/(?:rdc[:-])?([a-z][a-z0-9-]*)\b/i);
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(markerPath(raw.session_id), JSON.stringify(marker, null, 2));
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.36",
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.36",
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
  }
@@ -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");