@lifeaitools/rdc-skills 0.24.0 → 0.24.2
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/.claude-plugin/plugin.json +1 -1
- package/git-sha.json +1 -1
- package/hooks/lib/run-evidence-gate.mjs +174 -0
- package/hooks/require-work-item-on-commit.js +265 -55
- package/hooks/work-item-exit-gate.js +260 -2
- package/package.json +2 -1
- package/scripts/install-rdc-skills.js +8 -3
- package/skills/build/SKILL.md +10 -0
- package/tests/require-work-item-on-commit.test.mjs +153 -0
- package/tests/run-evidence-gate.test.mjs +82 -0
- package/tests/work-item-exit-gate-l2.test.mjs +210 -0
package/git-sha.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-evidence-gate.mjs — Truth Gate 3.0 Layer 2, the FUSED evidence primitive.
|
|
4
|
+
*
|
|
5
|
+
* Nidus / D4 principle: "you cannot submit evidence without running the test."
|
|
6
|
+
* This is ONE atomic call that (1) RUNS a verification command, (2) HASHES its
|
|
7
|
+
* captured output server-side, and (3) records the verdict TOGETHER with the
|
|
8
|
+
* hash and a timestamp. Evidence and execution are produced in the same call —
|
|
9
|
+
* an agent can never hand the gate a hash for a run that never happened.
|
|
10
|
+
*
|
|
11
|
+
* The output of runEvidenceGate() is the only legitimate shape of a
|
|
12
|
+
* machine-parseable `verification` artifact: it carries the exact command, the
|
|
13
|
+
* exit code, an SHA-256 of stdout+stderr, and a pass/fail verdict the CALLER
|
|
14
|
+
* did not author. The exit-gate's L2 verifier recognises this shape (and a few
|
|
15
|
+
* other captured-artifact shapes) and rejects anything that is bare prose.
|
|
16
|
+
*
|
|
17
|
+
* Pure-ish: it shells out to run the command but has no DB or network side
|
|
18
|
+
* effects, so it is unit-testable offline.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
/** Stable SHA-256 of a string (server-side hash — the caller cannot forge it). */
|
|
26
|
+
export function hashOutput(text) {
|
|
27
|
+
return createHash('sha256').update(String(text == null ? '' : text), 'utf8').digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The discriminant marker stamped on every fused artifact. The exit-gate keys
|
|
32
|
+
* on this to recognise a real run-and-attest result vs. an agent-typed string.
|
|
33
|
+
*/
|
|
34
|
+
export const EVIDENCE_KIND = 'run-evidence-gate/v1';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run a verification command and atomically produce a hashed, verdicted
|
|
38
|
+
* evidence artifact. There is NO path to a verdict that skips the run: the
|
|
39
|
+
* verdict is derived from the actual exit code of the actual spawn.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} spec
|
|
42
|
+
* @param {string} spec.command executable to run (e.g. "node", "npx")
|
|
43
|
+
* @param {string[]} [spec.args] argv for the command
|
|
44
|
+
* @param {string} [spec.cwd] working directory
|
|
45
|
+
* @param {object} [spec.env] extra env
|
|
46
|
+
* @param {number} [spec.timeoutMs] hard timeout (default 120s)
|
|
47
|
+
* @param {string} [spec.label] human label for the check
|
|
48
|
+
* @param {(spec)=>{status:number,stdout:string,stderr:string}} [runner]
|
|
49
|
+
* injectable runner — defaults to spawnSync. Lets tests drive a fake
|
|
50
|
+
* process WITHOUT removing the "must run" property (the runner is still
|
|
51
|
+
* invoked exactly once and its result is what the verdict is built from).
|
|
52
|
+
* @returns {object} a fused evidence artifact (see EVIDENCE_KIND).
|
|
53
|
+
*/
|
|
54
|
+
export function runEvidenceGate(spec, runner) {
|
|
55
|
+
if (!spec || typeof spec !== 'object' || typeof spec.command !== 'string' || !spec.command) {
|
|
56
|
+
// Fail-closed: a malformed request can never produce a "pass".
|
|
57
|
+
return {
|
|
58
|
+
kind: EVIDENCE_KIND,
|
|
59
|
+
ran: false,
|
|
60
|
+
verdict: 'error',
|
|
61
|
+
reason: 'invalid-spec: command is required',
|
|
62
|
+
ts: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const exec = typeof runner === 'function' ? runner : defaultRunner;
|
|
67
|
+
|
|
68
|
+
let result;
|
|
69
|
+
try {
|
|
70
|
+
result = exec(spec);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Spawn itself threw — fail-closed.
|
|
73
|
+
return {
|
|
74
|
+
kind: EVIDENCE_KIND,
|
|
75
|
+
ran: false,
|
|
76
|
+
verdict: 'error',
|
|
77
|
+
reason: `runner-threw: ${e && e.message ? e.message : String(e)}`,
|
|
78
|
+
command: renderCommand(spec),
|
|
79
|
+
ts: new Date().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The runner MUST return a numeric status for the verdict to exist. No status
|
|
84
|
+
// (e.g. the process could not be spawned) => no run => fail-closed.
|
|
85
|
+
const status = result && typeof result.status === 'number' ? result.status : null;
|
|
86
|
+
const stdout = result && result.stdout != null ? String(result.stdout) : '';
|
|
87
|
+
const stderr = result && result.stderr != null ? String(result.stderr) : '';
|
|
88
|
+
|
|
89
|
+
if (status === null) {
|
|
90
|
+
return {
|
|
91
|
+
kind: EVIDENCE_KIND,
|
|
92
|
+
ran: false,
|
|
93
|
+
verdict: 'error',
|
|
94
|
+
reason: 'runner-produced-no-exit-status (process did not run)',
|
|
95
|
+
command: renderCommand(spec),
|
|
96
|
+
ts: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const combined = `EXIT:${status}\n--STDOUT--\n${stdout}\n--STDERR--\n${stderr}`;
|
|
101
|
+
return {
|
|
102
|
+
kind: EVIDENCE_KIND,
|
|
103
|
+
ran: true,
|
|
104
|
+
label: spec.label || null,
|
|
105
|
+
command: renderCommand(spec),
|
|
106
|
+
exit_code: status,
|
|
107
|
+
verdict: status === 0 ? 'pass' : 'fail',
|
|
108
|
+
output_sha256: hashOutput(combined),
|
|
109
|
+
output_bytes: Buffer.byteLength(combined, 'utf8'),
|
|
110
|
+
ts: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderCommand(spec) {
|
|
115
|
+
return [spec.command, ...(Array.isArray(spec.args) ? spec.args : [])].join(' ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultRunner(spec) {
|
|
119
|
+
const res = spawnSync(spec.command, Array.isArray(spec.args) ? spec.args : [], {
|
|
120
|
+
cwd: spec.cwd || process.cwd(),
|
|
121
|
+
env: { ...process.env, ...(spec.env || {}) },
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
timeout: typeof spec.timeoutMs === 'number' ? spec.timeoutMs : 120000,
|
|
124
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
125
|
+
});
|
|
126
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Is `v` a legitimate machine-parseable verification artifact (NOT prose)?
|
|
131
|
+
*
|
|
132
|
+
* Accepts, in order of strength:
|
|
133
|
+
* 1. A fused run-evidence-gate artifact (object or its JSON string) — strongest.
|
|
134
|
+
* 2. A captured-artifact OBJECT with a recognised machine shape:
|
|
135
|
+
* - { exit_code: <number> } (tsc / test-runner exit)
|
|
136
|
+
* - { http_status: <number> } (captured HTTP status)
|
|
137
|
+
* - { rowcount: <number> } / row_count (SQL rowcount)
|
|
138
|
+
* - { passed: <number>, ... } (test-runner JSON, e.g. vitest)
|
|
139
|
+
* (a JSON string of any of these is also accepted)
|
|
140
|
+
* REJECTS:
|
|
141
|
+
* - bare strings ("HTTP 200", "107 nodes", "works", "done") — proxy/prose.
|
|
142
|
+
* - objects with no machine field.
|
|
143
|
+
*/
|
|
144
|
+
export function isMachineArtifact(v) {
|
|
145
|
+
if (v == null) return false;
|
|
146
|
+
|
|
147
|
+
// String input: only accepted if it parses to a recognised JSON artifact.
|
|
148
|
+
if (typeof v === 'string') {
|
|
149
|
+
const s = v.trim();
|
|
150
|
+
if (!(s.startsWith('{') || s.startsWith('['))) return false; // bare prose
|
|
151
|
+
let parsed;
|
|
152
|
+
try { parsed = JSON.parse(s); } catch { return false; }
|
|
153
|
+
return isMachineArtifact(parsed);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (typeof v !== 'object') return false;
|
|
157
|
+
|
|
158
|
+
// 1. Fused artifact.
|
|
159
|
+
if (v.kind === EVIDENCE_KIND && v.ran === true && typeof v.output_sha256 === 'string') {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Recognised captured-artifact object shapes.
|
|
164
|
+
if (typeof v.exit_code === 'number') return true;
|
|
165
|
+
if (typeof v.http_status === 'number' || typeof v.httpStatus === 'number') return true;
|
|
166
|
+
if (typeof v.status_code === 'number' || typeof v.statusCode === 'number') return true;
|
|
167
|
+
if (typeof v.rowcount === 'number' || typeof v.row_count === 'number' || typeof v.rowCount === 'number') return true;
|
|
168
|
+
if (typeof v.passed === 'number' && (typeof v.failed === 'number' || typeof v.total === 'number')) return true;
|
|
169
|
+
if (typeof v.tsc_errors === 'number' || typeof v.tscErrors === 'number') return true;
|
|
170
|
+
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default { runEvidenceGate, hashOutput, isMachineArtifact, EVIDENCE_KIND };
|
|
@@ -1,25 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Work-item commit hook — Truth Gate 3.0 Layer 1 (commit capture).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
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
|
|
20
|
-
const path
|
|
21
|
-
const os
|
|
22
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Pure helpers (exported for unit tests)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
38
59
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
}
|