@windyroad/voice-tone 0.9.2 → 0.9.3-preview.1265

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.
@@ -145,5 +145,5 @@
145
145
  }
146
146
  },
147
147
  "name": "wr-voice-tone",
148
- "version": "0.9.2"
148
+ "version": "0.9.3"
149
149
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-voice-tone",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Voice, tone, and external-communications governance",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
package/hooks/hooks.json CHANGED
@@ -18,9 +18,34 @@
18
18
  "additionalContextLimit": 0
19
19
  }
20
20
  ]
21
+ },
22
+ {
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
27
+ },
28
+ {
29
+ "type": "command",
30
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
31
+ }
32
+ ]
21
33
  }
22
34
  ],
23
35
  "PreToolUse": [
36
+ {
37
+ "matcher": "Bash|Edit|Write|ExitPlanMode",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
46
+ }
47
+ ]
48
+ },
24
49
  {
25
50
  "matcher": "Edit|Write",
26
51
  "hooks": [
@@ -88,24 +88,43 @@ print(hashlib.sha256((draft + '\n' + surface).encode('utf-8')).hexdigest())
88
88
  derive_external_comms_key_from_prompt() {
89
89
  local prompt="$1"
90
90
  [ -n "$prompt" ] || { echo ""; return 0; }
91
- # Extract SURFACE + <draft> body in one pass. The two fields are emitted
92
- # \x1f-separated (ASCII unit separator) so a body containing newlines — or
93
- # an empty body — round-trips through command substitution intact (only
94
- # trailing newlines are dropped, which compute_external_comms_key rstrips
95
- # anyway). Empty output when either marker is absent → empty key.
91
+ # Extract the last valid SURFACE + <draft> pair. Codex completion payloads
92
+ # may prefix the caller prompt with the custom agent's developer
93
+ # instructions, which themselves mention illustrative <draft> markers.
94
+ # A pair is valid only when the draft envelope follows the SURFACE line
95
+ # without intervening non-whitespace prose, and the SURFACE line is not
96
+ # itself inside another draft envelope. Scanning valid candidates from
97
+ # right to left selects the caller's trailing prompt rather than a decoy
98
+ # in the prefixed instructions.
99
+ #
100
+ # The two fields are emitted \x1f-separated (ASCII unit separator) so a
101
+ # body containing newlines — or an empty body — round-trips through command
102
+ # substitution intact (only trailing newlines are dropped, which
103
+ # compute_external_comms_key rstrips anyway). Empty output when no valid
104
+ # pair is present → empty key.
96
105
  local extracted
97
106
  extracted=$(printf '%s' "$prompt" | python3 -c "
98
107
  import sys, re
99
108
  text = sys.stdin.read()
100
- # DRAFT: non-greedy match between <draft>...</draft>, tolerating an optional
101
- # newline immediately after <draft> and before </draft>.
102
- draft_match = re.search(r'<draft>\n?(.*?)\n?</draft>', text, re.DOTALL)
103
- # SURFACE: anchored to line start (MULTILINE) so prose like 'context says
104
- # SURFACE: x' does not match. Surface name is a single letter+word/hyphen token.
105
- surface_match = re.search(r'^SURFACE:\s*([A-Za-z][\w-]*)', text, re.MULTILINE)
106
- if not draft_match or not surface_match:
107
- sys.exit(0)
108
- sys.stdout.write(surface_match.group(1) + '\x1f' + draft_match.group(1))
109
+ surface_pattern = re.compile(
110
+ r'^SURFACE:\s*([A-Za-z][\w-]*)[^\r\n]*(?:\r?\n|$)',
111
+ re.MULTILINE,
112
+ )
113
+ for surface_match in reversed(list(surface_pattern.finditer(text))):
114
+ prefix = text[:surface_match.start()]
115
+ # Reject a SURFACE line quoted inside an existing draft body.
116
+ if prefix.rfind('<draft>') > prefix.rfind('</draft>'):
117
+ continue
118
+ remainder = text[surface_match.end():]
119
+ draft_match = re.match(
120
+ r'[ \t\r\n]*<draft>\r?\n?(.*?)\r?\n?</draft>',
121
+ remainder,
122
+ re.DOTALL,
123
+ )
124
+ if not draft_match:
125
+ continue
126
+ sys.stdout.write(surface_match.group(1) + '\x1f' + draft_match.group(1))
127
+ break
109
128
  " 2>/dev/null) || extracted=""
110
129
  [ -n "$extracted" ] || { echo ""; return 0; }
111
130
  local surface="${extracted%%$'\x1f'*}"
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
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-voice-tone:external-comms";
11
12
  const writer = join(hookDir, "..", "hooks", "external-comms-mark-reviewed.sh");
12
13
  const policy = "docs/VOICE-AND-TONE.md";
14
+ const passPattern = new RegExp("^EXTERNAL_COMMS_VOICE_TONE_VERDICT:[ \\t]*PASS$", "m");
15
+ const firstLineOnly = false;
16
+ const verdictFile = null;
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
- writeFileSync(statePath(input, target), JSON.stringify({ role, target, ...bound, policyHash: hash }), { mode: 0o600 });
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 complete(input, target, output) {
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
- const claimed = claim(input, target);
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 === 0) {
167
- renameSync(claimed.path, claimed.done);
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", `voice-tone-reviewed-${registered.parentSession}`),
233
+ join(process.env.TMPDIR || "/tmp", `voice-tone-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
- rmSync(claimed.path, { force: true });
172
- diagnostic("marker-writer-failed", input);
173
- process.exitCode = 1;
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
- if (SPAWN_TOOLS.has(input.tool_name)) remember(input);
198
- if (CLOSE_TOOLS.has(input.tool_name)) close(input);
199
- if (WAIT_TOOLS.has(input.tool_name)) wait(input);
200
- if (input.hook_event_name === "SubagentStop") {
201
- if (input.agent_type !== role) diagnostic("unrelated-subagent-stop", input);
202
- else complete(input, input.task_name || input.agent_name || input.agent_id, input.last_assistant_message);
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
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
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-voice-tone:agent";
11
12
  const writer = join(hookDir, "..", "hooks", "voice-tone-mark-reviewed.sh");
12
13
  const policy = "docs/VOICE-AND-TONE.md";
14
+ const passPattern = new RegExp("^[ \\t]*>?[ \\t]*\\*\\*Voice & Tone Review: PASS\\*\\*.*$", "m");
15
+ const firstLineOnly = false;
16
+ const verdictFile = null;
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
- writeFileSync(statePath(input, target), JSON.stringify({ role, target, ...bound, policyHash: hash }), { mode: 0o600 });
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 complete(input, target, output) {
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
- const claimed = claim(input, target);
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 === 0) {
167
- renameSync(claimed.path, claimed.done);
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", `voice-tone-reviewed-${registered.parentSession}`),
233
+ join(process.env.TMPDIR || "/tmp", `voice-tone-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
- rmSync(claimed.path, { force: true });
172
- diagnostic("marker-writer-failed", input);
173
- process.exitCode = 1;
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
- if (SPAWN_TOOLS.has(input.tool_name)) remember(input);
198
- if (CLOSE_TOOLS.has(input.tool_name)) close(input);
199
- if (WAIT_TOOLS.has(input.tool_name)) wait(input);
200
- if (input.hook_event_name === "SubagentStop") {
201
- if (input.agent_type !== role) diagnostic("unrelated-subagent-stop", input);
202
- else complete(input, input.task_name || input.agent_name || input.agent_id, input.last_assistant_message);
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
  }
@@ -18,9 +18,34 @@
18
18
  "additionalContextLimit": 0
19
19
  }
20
20
  ]
21
+ },
22
+ {
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
27
+ },
28
+ {
29
+ "type": "command",
30
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
31
+ }
32
+ ]
21
33
  }
22
34
  ],
23
35
  "PreToolUse": [
36
+ {
37
+ "matcher": "Bash|Edit|Write|ExitPlanMode",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
46
+ }
47
+ ]
48
+ },
24
49
  {
25
50
  "matcher": "Edit|Write",
26
51
  "hooks": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/voice-tone",
3
- "version": "0.9.2",
3
+ "version": "0.9.3-preview.1265",
4
4
  "description": "Voice and tone enforcement for user-facing copy",
5
5
  "bin": {
6
6
  "windyroad-voice-tone": "./bin/install.mjs"