@lifeaitools/rdc-skills 0.24.0 → 0.24.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.24.0",
3
+ "version": "0.24.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",
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "46460e5add65d8555ec9246eed10eb2d7c5fcbb4"
2
+ "sha": "e04654eb140982dd3d79fe474a8a681c131e2e3d"
3
3
  }
@@ -1,25 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PreToolUse hook — block git commits without a linked work item.
3
+ * Work-item commit hook — Truth Gate 3.0 Layer 1 (commit capture).
4
4
  *
5
- * Fires before any Bash tool call. If the command contains "git commit",
6
- * checks whether a fixit.marker exists (fixit workflow is self-documenting)
7
- * OR the commit message references a work item ID or conventional type.
5
+ * Registered on BOTH:
6
+ * - PreToolUse(Bash): intent check (preserved legacy behavior WARN only,
7
+ * never hard-blocks a commit).
8
+ * - PostToolUse(Bash): commit capture — after a `git commit` lands, the hook
9
+ * runs `git rev-parse HEAD` and records the REAL SHA +
10
+ * session against the active work item in the
11
+ * `work_item_commits` side table.
8
12
  *
9
- * BLOCKS the commit if:
10
- * - No fixit.marker exists
11
- * - Commit message has no work item reference AND no conventional commit type
13
+ * THE AGENT NEVER WRITES THE COMMIT FIELD. The system does. The work item is
14
+ * identified from the work-item UUID the agent referenced in the commit message
15
+ * (its "active work-item ref"); the SHA is taken from `git rev-parse HEAD`, not
16
+ * from anything the agent typed. This kills the "real-but-wrong commit SHA"
17
+ * fabrication class (FMEA #1) at the source — WP-2's closure gate then asserts a
18
+ * report's codeflow_post.commit is one of these captured, session-authored SHAs.
12
19
  *
13
- * A "work item reference" is any of:
14
- * - A UUID pattern (work item ID)
15
- * - "#<issue>" reference
16
- * - Conventional commit prefix: feat/fix/chore/refactor/test/docs/style/perf/ci/build
20
+ * PreToolUse (intent) behavior is unchanged:
21
+ * - fixit.marker present -> pass
22
+ * - conventional type / UUID / #issue ref -> pass
23
+ * - otherwise -> WARN (proceed anyway)
24
+ *
25
+ * PostToolUse (capture) behavior:
26
+ * - only fires on a `git commit` whose tool result indicates success
27
+ * - extracts the work-item UUID from the commit message (active ref)
28
+ * - no UUID -> NO-OP (no orphan row), logs `capture-no-item`
29
+ * - UUID -> `git rev-parse HEAD` -> INSERT { work_item_id, sha, session_id }
30
+ *
31
+ * Capture sink (for tests / offline): if `RDC_COMMIT_CAPTURE_SINK` is set to a
32
+ * file path, the capture payload is appended there as JSONL in addition to the
33
+ * DB write. This lets the hook be verified deterministically without a live DB.
17
34
  */
35
+ 'use strict';
18
36
 
19
- const fs = require('fs');
20
- const path = require('path');
21
- const os = require('os');
22
- const hookLog = require('./hook-logger');
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const os = require('os');
40
+ const { execFileSync } = require('child_process');
41
+ const hookLog = require('./hook-logger');
23
42
 
24
43
  const MARKER_FILE = path.join(
25
44
  process.env.USERPROFILE || process.env.HOME || os.homedir(),
@@ -30,55 +49,246 @@ const MARKER_FILE = path.join(
30
49
  const CONVENTIONAL_TYPES = /^(feat|fix|chore|refactor|test|docs|style|perf|ci|build|revert)(\(.+\))?:/i;
31
50
  const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
32
51
  const ISSUE_REF = /#[a-zA-Z0-9-]+/;
52
+ const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
33
53
 
34
- function main() {
35
- let input = '';
36
- process.stdin.resume();
37
- process.stdin.setEncoding('utf8');
54
+ const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Pure helpers (exported for unit tests)
58
+ // ---------------------------------------------------------------------------
38
59
 
39
- process.stdin.on('data', chunk => { input += chunk; });
40
- process.stdin.on('end', () => {
41
- let toolInput;
60
+ /** True when the Bash command is a real `git commit` (not e.g. `git commit --help`). */
61
+ function isGitCommit(command) {
62
+ if (typeof command !== 'string') return false;
63
+ if (!/\bgit\b[^\n]*\bcommit\b/.test(command)) return false;
64
+ if (/\bcommit\b[^\n]*--help/.test(command) || /\bcommit\b[^\n]*\s-h\b/.test(command)) return false;
65
+ return true;
66
+ }
67
+
68
+ /** Extract the -m "..." message (or fall back to the whole command). */
69
+ function extractCommitMessage(command) {
70
+ const cmd = String(command || '');
71
+ const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/s) ||
72
+ cmd.match(/-m\s+"([\s\S]+?)"\s*(?:&&|$)/);
73
+ return msgMatch ? msgMatch[1] : cmd;
74
+ }
75
+
76
+ /**
77
+ * Find the work-item UUID the agent referenced in the commit message — the
78
+ * "active work-item ref". Returns the first UUID, or null when none is present.
79
+ * A null result means "no active work item" -> capture must NO-OP.
80
+ */
81
+ function parseCommitMessageWorkItem(message) {
82
+ const m = String(message || '').match(UUID_PATTERN);
83
+ return m ? m[0].toLowerCase() : null;
84
+ }
85
+
86
+ /**
87
+ * Did the committed tool call actually succeed? PostToolUse provides the tool
88
+ * result. A failed commit (nothing to commit / hook reject / non-zero exit)
89
+ * must NOT capture a row. We treat it as success unless we have positive
90
+ * evidence of failure, OR there is positive evidence of success in stdout.
91
+ */
92
+ function commitSucceeded(toolResult) {
93
+ if (!toolResult || typeof toolResult !== 'object') return true; // no signal -> trust HEAD check below
94
+ const code = toolResult.exit_code ?? toolResult.exitCode ?? toolResult.code;
95
+ if (typeof code === 'number') return code === 0;
96
+ const out = `${toolResult.stdout || ''}\n${toolResult.stderr || ''}\n${toolResult.output || ''}`;
97
+ if (/nothing to commit|no changes added|did not match any files|commit failed|error:/i.test(out)) return false;
98
+ return true;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Capture side effects
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** Real `git rev-parse HEAD` in the given cwd. Returns the SHA, or null on error. */
106
+ function gitHead(cwd) {
107
+ try {
108
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
109
+ cwd: cwd || process.cwd(),
110
+ encoding: 'utf8',
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ }).trim();
113
+ return SHA_PATTERN.test(sha) ? sha : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ async function getServiceKey() {
120
+ if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
121
+ if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
122
+ for (const endpoint of ['supabase-service', 'supabase-service-role']) {
42
123
  try {
43
- toolInput = JSON.parse(input);
44
- } catch {
45
- process.exit(0);
46
- }
124
+ const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
125
+ if (res.ok) {
126
+ const text = (await res.text()).trim();
127
+ if (text && !text.startsWith('{')) return text;
128
+ }
129
+ } catch (_) {}
130
+ }
131
+ return null;
132
+ }
47
133
 
48
- if (toolInput.tool_name !== 'Bash') {
49
- process.exit(0);
50
- }
134
+ /** Append the capture payload to the test/offline sink, if configured. */
135
+ function writeSink(payload) {
136
+ const sink = process.env.RDC_COMMIT_CAPTURE_SINK;
137
+ if (!sink) return;
138
+ try {
139
+ fs.mkdirSync(path.dirname(sink), { recursive: true });
140
+ fs.appendFileSync(sink, JSON.stringify(payload) + '\n');
141
+ } catch (_) {}
142
+ }
51
143
 
52
- const command = toolInput.tool_input?.command || '';
53
- if (!command.includes('git commit')) {
54
- process.exit(0);
55
- }
144
+ /**
145
+ * INSERT the captured row into work_item_commits via the Supabase REST API
146
+ * using the service-role key (bypasses RLS — capture is system-only). The SHA
147
+ * is the live `git rev-parse HEAD`, never an agent-supplied value.
148
+ */
149
+ async function insertCommitRow(payload) {
150
+ const key = await getServiceKey();
151
+ if (!key) return { ok: false, reason: 'no-service-key' };
152
+ const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
153
+ try {
154
+ const res = await fetch(`${base}/rest/v1/work_item_commits`, {
155
+ method: 'POST',
156
+ headers: {
157
+ apikey: key,
158
+ Authorization: `Bearer ${key}`,
159
+ 'Content-Type': 'application/json',
160
+ Prefer: 'return=minimal',
161
+ },
162
+ body: JSON.stringify({
163
+ work_item_id: payload.work_item_id,
164
+ sha: payload.sha,
165
+ session_id: payload.session_id,
166
+ source: 'commit-hook',
167
+ }),
168
+ signal: AbortSignal.timeout(3500),
169
+ });
170
+ if (!res.ok) return { ok: false, reason: `http-${res.status}` };
171
+ return { ok: true };
172
+ } catch (e) {
173
+ return { ok: false, reason: e.message };
174
+ }
175
+ }
56
176
 
57
- // fixit.marker means this is a self-documenting fixit session — allow
58
- if (fs.existsSync(MARKER_FILE)) {
59
- hookLog('require-work-item', 'PreToolUse', 'pass-fixit', {});
60
- process.exit(0);
61
- }
177
+ /**
178
+ * Capture orchestration for a single PostToolUse(git commit) event.
179
+ * Returns a result object (also used by tests). NEVER throws.
180
+ */
181
+ async function captureCommit(raw) {
182
+ const command = raw?.tool_input?.command || '';
183
+ if (!isGitCommit(command)) return { captured: false, reason: 'not-git-commit' };
62
184
 
63
- // Extract -m "..." message from command
64
- const msgMatch = command.match(/-m\s+["']([^"']+)["']/s) ||
65
- command.match(/-m\s+"([\s\S]+?)"\s*(?:&&|$)/);
66
- const msg = msgMatch ? msgMatch[1] : command;
185
+ const toolResult = raw.tool_response || raw.tool_result || raw.result || null;
186
+ if (!commitSucceeded(toolResult)) {
187
+ hookLog('require-work-item', 'PostToolUse', 'capture-skip-failed-commit', {});
188
+ return { captured: false, reason: 'commit-not-successful' };
189
+ }
67
190
 
68
- if (CONVENTIONAL_TYPES.test(msg.trim()) || UUID_PATTERN.test(msg) || ISSUE_REF.test(msg)) {
69
- hookLog('require-work-item', 'PreToolUse', 'pass', { msg: msg.slice(0, 80) });
70
- process.exit(0);
71
- }
191
+ const message = extractCommitMessage(command);
192
+ const workItemId = parseCommitMessageWorkItem(message);
193
+ if (!workItemId) {
194
+ // No active work item -> NO-OP. No orphan row is ever written.
195
+ hookLog('require-work-item', 'PostToolUse', 'capture-no-item', { msg: String(message).slice(0, 80) });
196
+ return { captured: false, reason: 'no-active-work-item' };
197
+ }
198
+
199
+ const cwd = raw.cwd || process.cwd();
200
+ const sha = gitHead(cwd);
201
+ if (!sha) {
202
+ hookLog('require-work-item', 'PostToolUse', 'capture-no-head', { work_item_id: workItemId });
203
+ return { captured: false, reason: 'no-head-sha', work_item_id: workItemId };
204
+ }
72
205
 
73
- hookLog('require-work-item', 'PreToolUse', 'warn', { msg: msg.slice(0, 80) });
74
- // Warn only — never hard-block commits. A missing work item is informational,
75
- // not a blocker. Conventional commit format is sufficient self-documentation.
76
- process.stdout.write(JSON.stringify({
77
- systemMessage: `⚠️ Commit has no work item reference or conventional commit type.\n` +
78
- `Preferred format: fix(<scope>): <message> — proceeding anyway.`
79
- }));
80
- process.exit(0);
206
+ const payload = {
207
+ work_item_id: workItemId,
208
+ sha, // <-- system-derived HEAD, never agent text
209
+ session_id: raw.session_id || null,
210
+ };
211
+
212
+ writeSink(payload);
213
+ const insert = await insertCommitRow(payload);
214
+ hookLog('require-work-item', 'PostToolUse', insert.ok ? 'capture-recorded' : 'capture-deferred', {
215
+ work_item_id: workItemId,
216
+ sha,
217
+ insert_reason: insert.reason || null,
81
218
  });
219
+ return { captured: true, sha, work_item_id: workItemId, db: insert };
82
220
  }
83
221
 
84
- main();
222
+ // ---------------------------------------------------------------------------
223
+ // PreToolUse intent check (unchanged legacy behavior)
224
+ // ---------------------------------------------------------------------------
225
+
226
+ function preToolUse(raw) {
227
+ if (raw.tool_name !== 'Bash') return process.exit(0);
228
+ const command = raw.tool_input?.command || '';
229
+ if (!command.includes('git commit')) return process.exit(0);
230
+
231
+ if (fs.existsSync(MARKER_FILE)) {
232
+ hookLog('require-work-item', 'PreToolUse', 'pass-fixit', {});
233
+ return process.exit(0);
234
+ }
235
+
236
+ const msg = extractCommitMessage(command);
237
+ if (CONVENTIONAL_TYPES.test(msg.trim()) || UUID_PATTERN.test(msg) || ISSUE_REF.test(msg)) {
238
+ hookLog('require-work-item', 'PreToolUse', 'pass', { msg: msg.slice(0, 80) });
239
+ return process.exit(0);
240
+ }
241
+
242
+ hookLog('require-work-item', 'PreToolUse', 'warn', { msg: msg.slice(0, 80) });
243
+ // Warn only — never hard-block commits. Conventional commit format is
244
+ // sufficient self-documentation.
245
+ process.stdout.write(JSON.stringify({
246
+ systemMessage: `⚠️ Commit has no work item reference or conventional commit type.\n` +
247
+ `Preferred format: fix(<scope>): <message> — proceeding anyway.`,
248
+ }));
249
+ return process.exit(0);
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Entry point — event-aware
254
+ // ---------------------------------------------------------------------------
255
+
256
+ async function main() {
257
+ let input = '';
258
+ await new Promise((resolve) => {
259
+ process.stdin.resume();
260
+ process.stdin.setEncoding('utf8');
261
+ process.stdin.on('data', (chunk) => { input += chunk; });
262
+ process.stdin.on('end', resolve);
263
+ });
264
+
265
+ let raw;
266
+ try { raw = JSON.parse(input); } catch { return process.exit(0); }
267
+
268
+ const event = raw.hook_event_name || raw.hookEventName || 'PreToolUse';
269
+
270
+ if (event === 'PostToolUse') {
271
+ if (raw.tool_name && raw.tool_name !== 'Bash') return process.exit(0);
272
+ try { await captureCommit(raw); } catch (e) {
273
+ hookLog('require-work-item', 'PostToolUse', 'capture-error', { error: e.message });
274
+ }
275
+ return process.exit(0); // capture is observe-only; never blocks the loop
276
+ }
277
+
278
+ // Default / PreToolUse intent path.
279
+ preToolUse(raw);
280
+ }
281
+
282
+ // Run when executed as a hook; export pure pieces when required by a test.
283
+ if (require.main === module) {
284
+ main();
285
+ } else {
286
+ module.exports = {
287
+ isGitCommit,
288
+ extractCommitMessage,
289
+ parseCommitMessageWorkItem,
290
+ commitSucceeded,
291
+ gitHead,
292
+ captureCommit,
293
+ };
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.24.0",
3
+ "version": "0.24.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",
@@ -696,9 +696,14 @@ function buildHooksConfig(hooksDir, profile = 'core') {
696
696
  cmd('require-work-item-on-commit.js'),
697
697
  ]},
698
698
  ],
699
- PostToolUse: [{ hooks: [
700
- cmd('check-services.js'),
701
- ]}],
699
+ PostToolUse: [
700
+ { hooks: [
701
+ cmd('check-services.js'),
702
+ ]},
703
+ { matcher: 'Bash', hooks: [
704
+ cmd('require-work-item-on-commit.js', 'Capturing commit SHA for work item...'),
705
+ ]},
706
+ ],
702
707
  Stop: [{ hooks: [
703
708
  cmd('rdc-output-contract-gate.js', 'Checking RDC output contract...'),
704
709
  cmd('post-work-check.js', 'Checking for undocumented work...'),
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Truth Gate L1 — commit-capture hook tests.
4
+ *
5
+ * Proves:
6
+ * 1. parse: the work-item UUID is extracted from a commit message; absent UUID -> null.
7
+ * 2. capture: on a PostToolUse(git commit), the captured `sha` EQUALS the real
8
+ * `git rev-parse HEAD` of the repo (verified against a throwaway git repo,
9
+ * via the RDC_COMMIT_CAPTURE_SINK file — no live DB required).
10
+ * 3. no-item: capture NO-OPs (writes nothing) when the commit message carries
11
+ * no work-item UUID. No orphan row.
12
+ *
13
+ * Run: node tests/require-work-item-on-commit.test.mjs (or `node --test tests/`)
14
+ */
15
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync, execFileSync } from 'node:child_process';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const REPO_ROOT = resolve(__dirname, '..');
24
+ const HOOK = join(REPO_ROOT, 'hooks', 'require-work-item-on-commit.js');
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const failures = [];
28
+ function assert(name, condition, detail = '') {
29
+ if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 1. Pure parse assertions
34
+ // ---------------------------------------------------------------------------
35
+ const hook = require(HOOK);
36
+ const WI = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
37
+
38
+ assert('parse: extracts UUID from message',
39
+ hook.parseCommitMessageWorkItem(`feat(x): do thing for ${WI}`) === WI);
40
+ assert('parse: null when no UUID',
41
+ hook.parseCommitMessageWorkItem('feat(x): no work item here') === null);
42
+ assert('isGitCommit: true for real commit',
43
+ hook.isGitCommit('git commit -m "feat: x"') === true);
44
+ assert('isGitCommit: false for --help',
45
+ hook.isGitCommit('git commit --help') === false);
46
+ assert('isGitCommit: false for unrelated',
47
+ hook.isGitCommit('git status') === false);
48
+ assert('commitSucceeded: false on nothing-to-commit',
49
+ hook.commitSucceeded({ stdout: 'nothing to commit, working tree clean' }) === false);
50
+ assert('commitSucceeded: true on exit 0',
51
+ hook.commitSucceeded({ exit_code: 0 }) === true);
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // helper: build a throwaway git repo with one commit
55
+ // ---------------------------------------------------------------------------
56
+ function makeRepo() {
57
+ const dir = mkdtempSync(join(tmpdir(), 'wic-repo-'));
58
+ const g = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
59
+ g('init', '-q');
60
+ g('config', 'user.email', 'test@example.com');
61
+ g('config', 'user.name', 'Test');
62
+ writeFileSync(join(dir, 'f.txt'), 'hello\n');
63
+ g('add', 'f.txt');
64
+ g('commit', '-q', '-m', `feat: seed for ${WI}`);
65
+ const head = g('rev-parse', 'HEAD').trim();
66
+ return { dir, head };
67
+ }
68
+
69
+ function runHook(payload, extraEnv = {}) {
70
+ return spawnSync(process.execPath, [HOOK], {
71
+ input: JSON.stringify(payload),
72
+ encoding: 'utf8',
73
+ env: { ...process.env, ...extraEnv },
74
+ });
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // 2. Capture: sha == git rev-parse HEAD
79
+ // ---------------------------------------------------------------------------
80
+ {
81
+ const { dir, head } = makeRepo();
82
+ const sink = join(dir, 'sink.jsonl');
83
+ // Point Supabase at an unreachable host so the test never touches a real DB;
84
+ // capture still writes the sink, which is what we assert on.
85
+ const res = runHook({
86
+ hook_event_name: 'PostToolUse',
87
+ tool_name: 'Bash',
88
+ session_id: 'sess-capture-1',
89
+ cwd: dir,
90
+ tool_input: { command: `git commit -m "feat: thing for ${WI}"` },
91
+ tool_response: { exit_code: 0, stdout: '1 file changed' },
92
+ }, {
93
+ RDC_COMMIT_CAPTURE_SINK: sink,
94
+ SUPABASE_URL: 'http://127.0.0.1:9', // unreachable -> DB insert fails fast, capture still records sink
95
+ SUPABASE_SERVICE_ROLE_KEY: 'test-key-not-real',
96
+ });
97
+ assert('capture: hook exits zero', res.status === 0, res.stderr);
98
+ assert('capture: sink written', existsSync(sink), 'no sink file');
99
+ if (existsSync(sink)) {
100
+ const row = JSON.parse(readFileSync(sink, 'utf8').trim().split('\n')[0]);
101
+ assert('capture: sha equals real HEAD', row.sha === head, `${row.sha} !== ${head}`);
102
+ assert('capture: work_item_id parsed', row.work_item_id === WI, row.work_item_id);
103
+ assert('capture: session_id recorded', row.session_id === 'sess-capture-1', row.session_id);
104
+ }
105
+ rmSync(dir, { recursive: true, force: true });
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 3. No active work item -> NO-OP (no sink row)
110
+ // ---------------------------------------------------------------------------
111
+ {
112
+ const { dir } = makeRepo();
113
+ const sink = join(dir, 'sink.jsonl');
114
+ const res = runHook({
115
+ hook_event_name: 'PostToolUse',
116
+ tool_name: 'Bash',
117
+ session_id: 'sess-no-item',
118
+ cwd: dir,
119
+ tool_input: { command: 'git commit -m "feat: no work item ref"' },
120
+ tool_response: { exit_code: 0 },
121
+ }, { RDC_COMMIT_CAPTURE_SINK: sink });
122
+ assert('no-item: hook exits zero', res.status === 0, res.stderr);
123
+ assert('no-item: no sink row written', !existsSync(sink), 'orphan capture written for no-item commit');
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // 4. PreToolUse legacy behavior preserved (warn-only, never blocks)
129
+ // ---------------------------------------------------------------------------
130
+ {
131
+ const res = runHook({
132
+ hook_event_name: 'PreToolUse',
133
+ tool_name: 'Bash',
134
+ tool_input: { command: 'git commit -m "no convention and no uuid"' },
135
+ });
136
+ assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
137
+ assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
138
+
139
+ const ok = runHook({
140
+ hook_event_name: 'PreToolUse',
141
+ tool_name: 'Bash',
142
+ tool_input: { command: 'git commit -m "feat(x): conventional"' },
143
+ });
144
+ assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ if (failures.length > 0) {
149
+ console.error('\ncommit-capture hook tests — FAIL\n');
150
+ for (const f of failures) console.error(` - ${f}`);
151
+ process.exit(1);
152
+ }
153
+ console.log('commit-capture hook tests — PASS');