@windyroad/jtbd 0.14.6 → 0.14.7
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/hooks/hooks.json
CHANGED
|
@@ -27,9 +27,26 @@
|
|
|
27
27
|
"command": "${PLUGIN_ROOT}/hooks/staleness-check.sh"
|
|
28
28
|
}
|
|
29
29
|
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
|
|
36
|
+
}
|
|
37
|
+
]
|
|
30
38
|
}
|
|
31
39
|
],
|
|
32
40
|
"PreToolUse": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "Bash|Edit|Write|ExitPlanMode",
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
33
50
|
{
|
|
34
51
|
"matcher": "Edit|Write",
|
|
35
52
|
"hooks": [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { spawnSync } from "node:child_process";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,6 +11,9 @@ const hookDir = dirname(fileURLToPath(import.meta.url));
|
|
|
10
11
|
const role = "wr-jtbd:agent";
|
|
11
12
|
const writer = join(hookDir, "..", "hooks", "jtbd-mark-reviewed.sh");
|
|
12
13
|
const policy = "docs/jtbd";
|
|
14
|
+
const passPattern = new RegExp("^[ \\t]*>?[ \\t]*\\*\\*JTBD Review: PASS\\*\\*[ \\t]*$", "m");
|
|
15
|
+
const firstLineOnly = true;
|
|
16
|
+
const verdictFile = "/tmp/jtbd-verdict";
|
|
13
17
|
const ttlSeconds = process.env.REVIEW_TTL ?? "3600";
|
|
14
18
|
const ttl = Number(ttlSeconds) * 1000;
|
|
15
19
|
|
|
@@ -17,10 +21,26 @@ function stateDir(sessionId) {
|
|
|
17
21
|
return join(process.env.TMPDIR || "/tmp", `claude-risk-${sessionId}`);
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
function transportDir() {
|
|
25
|
+
return join(process.env.TMPDIR || "/tmp", "codex-review-transport");
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
function statePath(input, target, suffix = "") {
|
|
21
29
|
return join(stateDir(input.session_id), `codex-review-${Buffer.from(role + ":" + target).toString("base64url")}${suffix}`);
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
function transportId(sessionId, target) {
|
|
33
|
+
return createHash("sha256").update(sessionId + "\0" + role + "\0" + target).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function registrationPath(sessionId, target) {
|
|
37
|
+
return join(transportDir(), `registration-${transportId(sessionId, target)}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function receiptPath(sessionId, target, suffix = "") {
|
|
41
|
+
return join(transportDir(), `receipt-${transportId(sessionId, target)}.json${suffix}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
function normalizeTarget(target) {
|
|
25
45
|
if (typeof target !== "string") return "";
|
|
26
46
|
return target.startsWith("/root/") ? target.slice("/root/".length) : target;
|
|
@@ -33,6 +53,47 @@ function policyHash(root) {
|
|
|
33
53
|
return result.status === 0 && /^[a-f0-9]{64}$/.test(hash || "") ? hash : null;
|
|
34
54
|
}
|
|
35
55
|
|
|
56
|
+
function expectedExternalKey(prompt) {
|
|
57
|
+
if (role !== "wr-voice-tone:external-comms" || typeof prompt !== "string" || !prompt) return null;
|
|
58
|
+
const result = spawnSync("bash", ["-c", 'source "$1"; value="$(cat)"; derive_external_comms_key_from_prompt "$value"',
|
|
59
|
+
"external-key", join(hookDir, "..", "hooks", "lib", "external-comms-key.sh")], { input: prompt, encoding: "utf8" });
|
|
60
|
+
const key = result.stdout?.trim();
|
|
61
|
+
return result.status === 0 && /^[a-f0-9]{64}$/.test(key || "") ? key : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function outputAllowed(output, registered) {
|
|
65
|
+
const candidate = firstLineOnly ? output.split(/\r?\n/).find((line) => line.trim()) || "" : output;
|
|
66
|
+
if (!passPattern.test(candidate)) return false;
|
|
67
|
+
if (verdictFile) {
|
|
68
|
+
try { if (readFileSync(verdictFile, "utf8").trim() !== "PASS") return false; }
|
|
69
|
+
catch { return false; }
|
|
70
|
+
}
|
|
71
|
+
if (role !== "wr-voice-tone:external-comms") return true;
|
|
72
|
+
const keys = [...output.matchAll(/^EXTERNAL_COMMS_VOICE_TONE_KEY:[ \t]*([a-f0-9]{64})$/gm)];
|
|
73
|
+
if (keys.length !== 1) return false;
|
|
74
|
+
return Boolean(registered.expectedKey && registered.expectedKey === keys[0][1]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function reapTransport() {
|
|
78
|
+
if (!existsSync(transportDir())) return;
|
|
79
|
+
for (const name of readdirSync(transportDir())) {
|
|
80
|
+
const path = join(transportDir(), name);
|
|
81
|
+
try {
|
|
82
|
+
const statAge = Date.now() - statSync(path).mtimeMs;
|
|
83
|
+
if (/^(?:receipt|risk-receipt)-[a-f0-9]{64}\.json\.(?:claim|done)$/.test(name)) {
|
|
84
|
+
if (!Number.isFinite(statAge) || statAge < 0 || statAge >= ttl) rmSync(path, { force: true });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!/^(?:registration|receipt)-[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
88
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
89
|
+
if (value.role !== role) continue;
|
|
90
|
+
const timestamp = name.startsWith("receipt-") ? value.completedAt : value.createdAt;
|
|
91
|
+
const age = Date.now() - timestamp;
|
|
92
|
+
if (!Number.isFinite(age) || age < 0 || age >= ttl) rmSync(path, { force: true });
|
|
93
|
+
} catch { /* Another completion may own or remove this transport file. */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
36
97
|
function diagnostic(reason, input) {
|
|
37
98
|
const dir = process.env.TMPDIR || "/tmp";
|
|
38
99
|
const path = join(dir, "codex-review-completion-diagnostic.json");
|
|
@@ -71,9 +132,12 @@ function targetFromSpawn(input) {
|
|
|
71
132
|
|
|
72
133
|
function clear(input, target) {
|
|
73
134
|
for (const suffix of ["", ".claim", ".done"]) rmSync(statePath(input, target, suffix), { force: true });
|
|
135
|
+
rmSync(registrationPath(input.session_id, target), { force: true });
|
|
136
|
+
for (const suffix of ["", ".claim", ".done"]) rmSync(receiptPath(input.session_id, target, suffix), { force: true });
|
|
74
137
|
}
|
|
75
138
|
|
|
76
139
|
function remember(input) {
|
|
140
|
+
reapTransport();
|
|
77
141
|
const target = normalizeTarget(targetFromSpawn(input));
|
|
78
142
|
if (!target) return;
|
|
79
143
|
mkdirSync(stateDir(input.session_id), { recursive: true });
|
|
@@ -89,7 +153,17 @@ function remember(input) {
|
|
|
89
153
|
diagnostic("policy-hash-failed", input);
|
|
90
154
|
return;
|
|
91
155
|
}
|
|
92
|
-
|
|
156
|
+
const prompt = input.tool_input?.message ?? input.tool_input?.prompt ?? "";
|
|
157
|
+
const externalKey = expectedExternalKey(prompt);
|
|
158
|
+
const registered = { parentSession: input.session_id, role, target, ...bound, policyHash: hash,
|
|
159
|
+
promptDigest: createHash("sha256").update(prompt).digest("hex"), expectedKey: externalKey, createdAt: Date.now() };
|
|
160
|
+
writeFileSync(statePath(input, target), JSON.stringify(registered), { mode: 0o600 });
|
|
161
|
+
if (role === "wr-voice-tone:external-comms" && !externalKey) {
|
|
162
|
+
diagnostic("missing-external-comms-key-binding", input);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
mkdirSync(transportDir(), { recursive: true, mode: 0o700 });
|
|
166
|
+
writeFileSync(registrationPath(input.session_id, target), JSON.stringify(registered), { mode: 0o600 });
|
|
93
167
|
}
|
|
94
168
|
|
|
95
169
|
function claim(input, target) {
|
|
@@ -104,54 +178,36 @@ function claim(input, target) {
|
|
|
104
178
|
return { path, done };
|
|
105
179
|
}
|
|
106
180
|
|
|
107
|
-
function
|
|
108
|
-
target = normalizeTarget(target);
|
|
109
|
-
if (!target || typeof output !== "string" || !output) return;
|
|
110
|
-
const path = statePath(input, target);
|
|
111
|
-
if (existsSync(statePath(input, target, ".done"))) return;
|
|
112
|
-
if (!existsSync(path)) {
|
|
113
|
-
diagnostic("missing-parent-registration", input);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let registered, age;
|
|
118
|
-
try {
|
|
119
|
-
registered = JSON.parse(readFileSync(path, "utf8"));
|
|
120
|
-
age = Date.now() - Math.floor(statSync(path).mtimeMs);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
diagnostic("malformed-registration", input);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
181
|
+
function validRegistration(registered, input, target, age) {
|
|
126
182
|
if (registered.role !== role || registered.target !== target) {
|
|
127
183
|
diagnostic("registration-mismatch", input);
|
|
128
|
-
return;
|
|
184
|
+
return false;
|
|
129
185
|
}
|
|
130
186
|
if (!Number.isFinite(age) || age < 0) {
|
|
131
187
|
diagnostic("invalid-registration-age", input);
|
|
132
|
-
return;
|
|
188
|
+
return false;
|
|
133
189
|
}
|
|
134
190
|
if (age >= ttl) {
|
|
135
191
|
diagnostic("stale-registration", input);
|
|
136
|
-
return;
|
|
192
|
+
return false;
|
|
137
193
|
}
|
|
138
|
-
|
|
139
194
|
const current = checkout(input.cwd || process.cwd());
|
|
140
195
|
if (!current || current.root !== registered.root || current.physical !== registered.physical) {
|
|
141
196
|
diagnostic("checkout-mismatch", input);
|
|
142
|
-
return;
|
|
197
|
+
return false;
|
|
143
198
|
}
|
|
144
|
-
|
|
145
199
|
const hash = policyHash(registered.root);
|
|
146
200
|
if (!hash || hash !== registered.policyHash) {
|
|
147
201
|
diagnostic(hash ? "policy-changed" : "policy-hash-failed", input);
|
|
148
|
-
return;
|
|
202
|
+
return false;
|
|
149
203
|
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
150
206
|
|
|
151
|
-
|
|
152
|
-
if (!claimed) return;
|
|
207
|
+
function writeMarker(input, registered, target, output, completedAt, claimed) {
|
|
153
208
|
const synthetic = {
|
|
154
209
|
...input,
|
|
210
|
+
session_id: registered.parentSession,
|
|
155
211
|
cwd: registered.root,
|
|
156
212
|
tool_name: "Agent",
|
|
157
213
|
tool_input: { subagent_type: role, prompt: "" },
|
|
@@ -163,14 +219,129 @@ function complete(input, target, output) {
|
|
|
163
219
|
input: JSON.stringify(synthetic),
|
|
164
220
|
encoding: "utf8",
|
|
165
221
|
});
|
|
166
|
-
if (result.status
|
|
167
|
-
|
|
222
|
+
if (result.status !== 0) {
|
|
223
|
+
rmSync(claimed.path, { force: true });
|
|
224
|
+
diagnostic("marker-writer-failed", input);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
renameSync(claimed.path, claimed.done);
|
|
228
|
+
rmSync(statePath({ session_id: registered.parentSession }, target), { force: true });
|
|
229
|
+
rmSync(registrationPath(registered.parentSession, target), { force: true });
|
|
230
|
+
const assessedAt = new Date(completedAt);
|
|
231
|
+
for (const candidate of [
|
|
232
|
+
join(process.env.TMPDIR || "/tmp", `jtbd-reviewed-${registered.parentSession}`),
|
|
233
|
+
join(process.env.TMPDIR || "/tmp", `jtbd-plan-reviewed-${registered.parentSession}`),
|
|
234
|
+
]) {
|
|
235
|
+
if (existsSync(candidate)) utimesSync(candidate, assessedAt, assessedAt);
|
|
236
|
+
if (existsSync(candidate + ".hash")) utimesSync(candidate + ".hash", assessedAt, assessedAt);
|
|
237
|
+
}
|
|
238
|
+
if (role === "wr-voice-tone:external-comms") {
|
|
239
|
+
const keys = [...output.matchAll(/^EXTERNAL_COMMS_VOICE_TONE_KEY:[ \t]*([a-f0-9]{64})$/gm)];
|
|
240
|
+
if (keys.length === 1) {
|
|
241
|
+
const keyed = join(stateDir(registered.parentSession), `external-comms-voice-tone-reviewed-${keys[0][1]}`);
|
|
242
|
+
if (existsSync(keyed)) utimesSync(keyed, assessedAt, assessedAt);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function complete(input, target, output) {
|
|
249
|
+
target = normalizeTarget(target);
|
|
250
|
+
if (!target || typeof output !== "string" || !output) return;
|
|
251
|
+
const path = statePath(input, target);
|
|
252
|
+
if (existsSync(statePath(input, target, ".done"))) return;
|
|
253
|
+
if (!existsSync(path)) {
|
|
254
|
+
diagnostic("missing-parent-registration", input);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let registered, age;
|
|
259
|
+
try {
|
|
260
|
+
registered = JSON.parse(readFileSync(path, "utf8"));
|
|
261
|
+
age = Date.now() - Math.floor(statSync(path).mtimeMs);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
diagnostic("malformed-registration", input);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!validRegistration(registered, input, target, age)) {
|
|
168
268
|
rmSync(path, { force: true });
|
|
269
|
+
rmSync(registrationPath(registered.parentSession || input.session_id, target), { force: true });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const claimed = claim(input, target);
|
|
274
|
+
if (!claimed) return;
|
|
275
|
+
writeMarker(input, registered, target, output, Date.now(), claimed);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function registrations(input, target) {
|
|
279
|
+
reapTransport();
|
|
280
|
+
if (!existsSync(transportDir())) return [];
|
|
281
|
+
const current = checkout(input.cwd || process.cwd());
|
|
282
|
+
if (!current) return [];
|
|
283
|
+
return readdirSync(transportDir())
|
|
284
|
+
.filter((name) => /^registration-[a-f0-9]{64}\.json$/.test(name))
|
|
285
|
+
.flatMap((name) => {
|
|
286
|
+
const path = join(transportDir(), name);
|
|
287
|
+
try {
|
|
288
|
+
const registered = JSON.parse(readFileSync(path, "utf8"));
|
|
289
|
+
return registered.role === role && registered.target === target &&
|
|
290
|
+
registered.root === current.root && registered.physical === current.physical
|
|
291
|
+
? [{ path, registered, age: Date.now() - Math.floor(statSync(path).mtimeMs) }]
|
|
292
|
+
: [];
|
|
293
|
+
} catch { return []; }
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function persistPending(input, target, output) {
|
|
298
|
+
target = normalizeTarget(target);
|
|
299
|
+
if (!target || typeof output !== "string" || !output) return;
|
|
300
|
+
const candidates = registrations(input, target);
|
|
301
|
+
if (candidates.length !== 1) {
|
|
302
|
+
diagnostic(candidates.length ? "ambiguous-parent-registration" : "missing-parent-registration", input);
|
|
169
303
|
return;
|
|
170
304
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
305
|
+
const { registered, age } = candidates[0];
|
|
306
|
+
if (!validRegistration(registered, input, target, age)) return;
|
|
307
|
+
if (!outputAllowed(output, registered)) {
|
|
308
|
+
diagnostic("non-pass-or-mismatched-output", input);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const path = receiptPath(registered.parentSession, target);
|
|
312
|
+
if (existsSync(path) || existsSync(path + ".done")) return;
|
|
313
|
+
try {
|
|
314
|
+
writeFileSync(path, JSON.stringify({ ...registered, output, completedAt: Date.now() }), { flag: "wx", mode: 0o600 });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error?.code !== "EEXIST") diagnostic("receipt-write-failed", input);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function consumePending(input) {
|
|
321
|
+
reapTransport();
|
|
322
|
+
if (!existsSync(transportDir())) return;
|
|
323
|
+
const prefix = `receipt-`;
|
|
324
|
+
for (const name of readdirSync(transportDir()).filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))) {
|
|
325
|
+
const path = join(transportDir(), name);
|
|
326
|
+
let pending, age;
|
|
327
|
+
try {
|
|
328
|
+
pending = JSON.parse(readFileSync(path, "utf8"));
|
|
329
|
+
age = Date.now() - pending.completedAt;
|
|
330
|
+
} catch {
|
|
331
|
+
diagnostic("malformed-receipt", input);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (pending.parentSession !== input.session_id || pending.role !== role) continue;
|
|
335
|
+
if (!validRegistration(pending, input, pending.target, age)) continue;
|
|
336
|
+
const claimPath = path + ".claim";
|
|
337
|
+
const donePath = path + ".done";
|
|
338
|
+
try { writeFileSync(claimPath, "", { flag: "wx", mode: 0o600 }); }
|
|
339
|
+
catch (error) { if (error?.code === "EEXIST") continue; throw error; }
|
|
340
|
+
const claimed = { path: claimPath, done: donePath };
|
|
341
|
+
if (writeMarker(input, pending, pending.target, pending.output, pending.completedAt, claimed)) {
|
|
342
|
+
rmSync(path, { force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
174
345
|
}
|
|
175
346
|
|
|
176
347
|
function close(input) {
|
|
@@ -194,10 +365,21 @@ if (!/^[0-9]+$/.test(ttlSeconds) || !Number.isSafeInteger(ttl) || ttl <= 0) {
|
|
|
194
365
|
process.exit(0);
|
|
195
366
|
}
|
|
196
367
|
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
if (
|
|
200
|
-
if (input.
|
|
201
|
-
if (input.
|
|
202
|
-
|
|
368
|
+
try {
|
|
369
|
+
if (SPAWN_TOOLS.has(input.tool_name)) remember(input);
|
|
370
|
+
if (CLOSE_TOOLS.has(input.tool_name)) close(input);
|
|
371
|
+
if (WAIT_TOOLS.has(input.tool_name)) wait(input);
|
|
372
|
+
if (input.hook_event_name === "UserPromptSubmit" || input.hook_event_name === "PreToolUse" || ["Bash", "Edit", "Write", "ExitPlanMode"].includes(input.tool_name)) consumePending(input);
|
|
373
|
+
if (input.hook_event_name === "SubagentStop") {
|
|
374
|
+
if (input.agent_type !== role) diagnostic("unrelated-subagent-stop", input);
|
|
375
|
+
else {
|
|
376
|
+
const target = input.task_name || input.agent_name || input.agent_id;
|
|
377
|
+
const local = statePath(input, normalizeTarget(target));
|
|
378
|
+
if (existsSync(local)) complete(input, target, input.last_assistant_message);
|
|
379
|
+
else persistPending(input, target, input.last_assistant_message);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
diagnostic("transport-error", input);
|
|
384
|
+
process.exitCode = 0;
|
|
203
385
|
}
|
package/hooks-codex/hooks.json
CHANGED
|
@@ -27,9 +27,26 @@
|
|
|
27
27
|
"command": "${PLUGIN_ROOT}/hooks/staleness-check.sh"
|
|
28
28
|
}
|
|
29
29
|
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
|
|
36
|
+
}
|
|
37
|
+
]
|
|
30
38
|
}
|
|
31
39
|
],
|
|
32
40
|
"PreToolUse": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "Bash|Edit|Write|ExitPlanMode",
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
33
50
|
{
|
|
34
51
|
"matcher": "Edit|Write",
|
|
35
52
|
"hooks": [
|