@lifeaitools/rdc-skills 0.23.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.
package/git-sha.json
CHANGED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -696,9 +696,14 @@ function buildHooksConfig(hooksDir, profile = 'core') {
|
|
|
696
696
|
cmd('require-work-item-on-commit.js'),
|
|
697
697
|
]},
|
|
698
698
|
],
|
|
699
|
-
PostToolUse: [
|
|
700
|
-
|
|
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...'),
|
package/skills/build/SKILL.md
CHANGED
|
@@ -214,24 +214,42 @@ Read the task title and description, then:
|
|
|
214
214
|
7. **For each wave — dispatch typed agents in parallel:**
|
|
215
215
|
|
|
216
216
|
### ⛔ Agent Dispatch Non-Negotiable Defaults
|
|
217
|
-
Every `Agent()` call MUST include these parameters
|
|
217
|
+
Every `Agent()` call MUST include these parameters:
|
|
218
218
|
```
|
|
219
219
|
model: <chosen per the routing table below>
|
|
220
220
|
max_turns: 70
|
|
221
|
-
isolation: "worktree"
|
|
222
221
|
```
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
222
|
+
`isolation` is chosen per the dispatch-mode rule below — it is NOT a blanket
|
|
223
|
+
default. Worktree isolation is reserved for true parallel MULTI-committer waves
|
|
224
|
+
in the same repo; doc-only and single-committer waves default to NON-isolated.
|
|
225
|
+
|
|
226
|
+
### ⛔ Dispatch mode — non-isolated is the default for single-committer / doc-only waves
|
|
227
|
+
Worktree isolation exists to protect a SHARED working tree from a git-index race
|
|
228
|
+
between MULTIPLE agents committing in parallel. When there is only ONE committer,
|
|
229
|
+
that race cannot occur, and the stale-base hazard of worktrees (see the HARD GATE
|
|
230
|
+
below — recurring across 2026-06-10/11/15/16/17/23) is pure downside. Pick the
|
|
231
|
+
dispatch mode by committer count, not by reflex:
|
|
232
|
+
|
|
233
|
+
| Wave shape | Dispatch mode | isolation |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| **Doc-only wave** (markdown/docs/plans, no code build) | NON-isolated, one committer on `develop` | omit `isolation` |
|
|
236
|
+
| **Single-committer wave** (only one agent commits, or work serializes to the supervisor) | NON-isolated, one committer on `develop` | omit `isolation` |
|
|
237
|
+
| **True parallel MULTI-committer wave** (2+ agents committing concurrently in the same repo, disjoint files) | worktree-isolated, supervisor merges | `isolation: "worktree"` |
|
|
238
|
+
|
|
239
|
+
- **Default = NON-isolated.** Only escalate to `isolation: "worktree"` when the
|
|
240
|
+
wave genuinely has 2+ concurrent committers in the same repo.
|
|
241
|
+
- A pure docs wave is single-committer by default: either dispatch ONE doc agent,
|
|
242
|
+
or have parallel doc agents return diffs/patches that the supervisor commits
|
|
243
|
+
serially. Do NOT fan out 2+ concurrent committers onto one shared `develop`
|
|
244
|
+
tree (lesson 2026-06-13-build-concurrent-agents-same-branch-git-race: three
|
|
245
|
+
parallel doc agents on one `develop` tree raced on the git index/stash — one
|
|
246
|
+
commit swept in another agent's staged-but-uncommitted files under the wrong
|
|
247
|
+
message, two agents reported the SAME SHA, a pre-commit `sync:docs` ref-lock
|
|
248
|
+
race misattributed authorship). The fix for that race is single-committer
|
|
249
|
+
serialization, NOT blanket worktree isolation.
|
|
250
|
+
- When you DO need a true MULTI-committer parallel wave, worktree isolation is
|
|
251
|
+
mandatory AND the HARD GATE below (base == develop HEAD) MUST pass before
|
|
252
|
+
dispatch — a stale-base worktree wave is a build failure, not a warning.
|
|
235
253
|
|
|
236
254
|
### ⛔ Foreign concurrent session guard — `git status` BEFORE the build
|
|
237
255
|
Worktree isolation protects against THIS build's own agents, not against a
|
|
@@ -270,25 +288,34 @@ Read the task title and description, then:
|
|
|
270
288
|
Without `max_turns: 70`, agents hit the default turn cap mid-task and stop.
|
|
271
289
|
`isolation: "worktree"` gives each agent its own git worktree and branch — eliminates push race conditions and index lock contention when multiple agents commit in parallel. The supervisor merges worktree branches after each wave (Step 9).
|
|
272
290
|
|
|
273
|
-
### ⛔ Worktree base
|
|
291
|
+
### ⛔ HARD GATE — Worktree base MUST equal develop HEAD (blocking, not advisory)
|
|
274
292
|
The worktree-isolation harness has shipped worktrees pinned to a STALE base
|
|
275
|
-
commit (lessons 2026-06-10-build-worktree-stale-base, 2026-06-11-build-worktree-stale-base
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
293
|
+
commit (lessons 2026-06-10-build-worktree-stale-base, 2026-06-11-build-worktree-stale-base,
|
|
294
|
+
2026-06-15-build-worktree-stale-base, 2026-06-16-build-worktree-stale-base,
|
|
295
|
+
2026-06-17-build-worktree-stale-base, 2026-06-23-build-worktree-stale-base:
|
|
296
|
+
agents branched hundreds of commits / multiple days behind develop HEAD, on a
|
|
297
|
+
tree where the target app did not yet exist — their diffs would have silently
|
|
298
|
+
reverted merged work or operated on a deleted structure). This is a recurring
|
|
299
|
+
harness defect, so the check is a **HARD BLOCKING GATE, not a suggestion.**
|
|
300
|
+
|
|
301
|
+
**Before dispatching ANY `isolation:"worktree"` wave**, the supervisor MUST run
|
|
302
|
+
the base==HEAD assertion and MUST abort isolation on any mismatch — there is no
|
|
303
|
+
"proceed anyway" path:
|
|
281
304
|
```bash
|
|
282
305
|
DEV_HEAD=$(git rev-parse develop)
|
|
283
306
|
# After worktrees are created, for each agent worktree:
|
|
284
307
|
git worktree list # compare each agent worktree's SHA to $DEV_HEAD
|
|
285
|
-
# If
|
|
308
|
+
# If ANY worktree base != $DEV_HEAD (it is behind), the wave is UNSAFE — ABORT.
|
|
286
309
|
```
|
|
287
|
-
-
|
|
288
|
-
Do NOT merge stale worktree output.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
310
|
+
- **MANDATORY ABORT:** if ANY worktree base != `$DEV_HEAD`, you MUST abort
|
|
311
|
+
isolation for this wave. Do NOT merge stale worktree output. Do NOT "fast-forward
|
|
312
|
+
and continue". Do NOT proceed with the isolated wave under any circumstance.
|
|
313
|
+
Pivot to **sequential, non-isolated dispatch on a real `develop` checkout**
|
|
314
|
+
(one disjoint WP at a time to avoid `.git/index` races; the supervisor pushes) —
|
|
315
|
+
the same reason the validator runs non-isolated (it must see merged develop).
|
|
316
|
+
- This gate is blocking by design: an isolated wave dispatched on a stale base
|
|
317
|
+
is treated as a build failure, not a warning. Skipping or downgrading this
|
|
318
|
+
assertion to advisory is a contract violation.
|
|
292
319
|
- Also at every merge: `git show <branch>:<key-file> | grep -c <symbol-a-prior-wave-introduced>`
|
|
293
320
|
— a 0 where there should be ≥1 means the branch is stale or deleted a shared
|
|
294
321
|
export; resolve to `--ours` and re-apply that wave's real delta on current HEAD.
|
|
@@ -509,7 +536,7 @@ NEVER run pnpm build or pnpm turbo. Use npx vitest run only.
|
|
|
509
536
|
- Push after each wave, not just at the end
|
|
510
537
|
- Unattended: NEVER pause — continue automatically
|
|
511
538
|
- Unattended: max 2 retries per task before escalating to advisor
|
|
512
|
-
- Every Agent() dispatch: `model: <routed>` + `max_turns: 70`
|
|
539
|
+
- Every Agent() dispatch: `model: <routed>` + `max_turns: 70` — non-negotiable. `isolation` is per the dispatch-mode rule in Step 7: NON-isolated (omit `isolation`) for doc-only and single-committer waves; `isolation: "worktree"` ONLY for true parallel MULTI-committer waves, and only after the HARD GATE (worktree base == develop HEAD) passes — a stale-base isolated wave is a build failure. Model is chosen per task per the routing table in Step 7: Sonnet 4.6 for updates/edits, Opus 4.6 for harder coding, Opus 4.8 for design/innovative thought (CS 2.0, brand/UX, architecture). Supervisor logs `model=<chosen> reason=<phrase>` per agent. Exception: validator agent in Step 10 always omits isolation; validator model stays `claude-sonnet-4-6` (verification, not generation).
|
|
513
540
|
- Finding an existing file is NOT task completion — verify it satisfies the spec
|
|
514
541
|
|
|
515
542
|
## Capture lessons (exit step)
|
|
@@ -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');
|