@windyroad/voice-tone 0.8.4 → 0.8.5-preview.1191

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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-voice-tone",
126
- "version": "0.8.4"
126
+ "version": "0.8.5"
127
127
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-voice-tone",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Voice, tone, and external-communications governance",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -138,6 +138,24 @@ _gh_api_has_body() {
138
138
  printf '%s' "$1" | grep -qE '(^|[[:space:]])(--field|--raw-field|--input|-f|-F)([[:space:]]|=)'
139
139
  }
140
140
 
141
+ # P537: return success only when at least one matched command segment can
142
+ # publish. Every publish segment must opt into a true dry run before the
143
+ # external-comms review can be skipped; mixed commands therefore fail closed.
144
+ _npm_publish_has_real_invocation() {
145
+ local segment
146
+ while IFS= read -r segment; do
147
+ if printf '%s' "$segment" | grep -qE '^\s*npm publish(\s|$)'; then
148
+ if ! printf '%s' "$segment" | grep -qE '(^|[[:space:]])--dry-run(=true)?($|[[:space:]])' \
149
+ || printf '%s' "$segment" | grep -qE '(^|[[:space:]])(--dry-run=false|--no-dry-run)($|[[:space:]])'; then
150
+ return 0
151
+ fi
152
+ fi
153
+ done <<EOF
154
+ $(printf '%s' "$1" | tr ';&|' '\n\n\n')
155
+ EOF
156
+ return 1
157
+ }
158
+
141
159
  # ---------- Surface detection ----------
142
160
  SURFACE=""
143
161
  DRAFT=""
@@ -180,7 +198,11 @@ except Exception:
180
198
  exit 0
181
199
  fi
182
200
  elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm publish(\s|$)'; then
183
- SURFACE="npm-publish"
201
+ if _npm_publish_has_real_invocation "$COMMAND"; then
202
+ SURFACE="npm-publish"
203
+ else
204
+ exit 0
205
+ fi
184
206
  elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git commit(\s|$)'; then
185
207
  # P082 Phase 1: gate `git commit -m / --message / HEREDOC` so commit
186
208
  # message bodies are reviewed by the voice-tone + risk evaluators
@@ -431,7 +453,7 @@ fi
431
453
  # (sha256(DRAFT + '\n' + SURFACE)). Single fire per gate cycle.
432
454
  VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
433
455
  if [ -n "${CODEX_THREAD_ID:-}" ]; then
434
- COMPLETION_GUIDANCE='On Codex, wait for the reviewer to finish, then close that completed agent once before retrying; the PostToolUse compatibility hook consumes the completed close response and persists its structured verdict, with no transcript parsing or nested codex exec.'
456
+ COMPLETION_GUIDANCE='On Codex, the calling agent waits for the reviewer to finish, then invokes `interrupt_agent` once on that completed target before retrying; the PostToolUse compatibility hook consumes the completed response and persists its structured verdict, with no transcript parsing or nested codex exec.'
435
457
  else
436
458
  COMPLETION_GUIDANCE='On Claude Code, dispatch the reviewer SYNCHRONOUSLY (run_in_background: false): a background-launched reviewer does not fire its PostToolUse mark hook, so the marker never persists and this gate re-blocks (P402).'
437
459
  fi
package/hooks/hooks.json CHANGED
@@ -81,6 +81,10 @@
81
81
  {
82
82
  "type": "command",
83
83
  "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
84
+ },
85
+ {
86
+ "type": "command",
87
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
84
88
  }
85
89
  ]
86
90
  }
@@ -94,6 +98,15 @@
94
98
  "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
95
99
  }
96
100
  ]
101
+ },
102
+ {
103
+ "matcher": "^wr-voice-tone:external-comms$",
104
+ "hooks": [
105
+ {
106
+ "type": "command",
107
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
108
+ }
109
+ ]
97
110
  }
98
111
  ]
99
112
  }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const hookDir = dirname(fileURLToPath(import.meta.url));
9
+ const role = "wr-voice-tone:external-comms";
10
+ const writer = join(hookDir, "..", "hooks", "external-comms-mark-reviewed.sh");
11
+ const policy = "docs/VOICE-AND-TONE.md";
12
+ const ttlSeconds = process.env.REVIEW_TTL ?? "3600";
13
+ const ttl = Number(ttlSeconds) * 1000;
14
+
15
+ function response(input) {
16
+ if (typeof input.tool_response === "object" && input.tool_response) return input.tool_response;
17
+ try { return JSON.parse(input.tool_response); } catch { return {}; }
18
+ }
19
+
20
+ function stateDir(sessionId) {
21
+ return join(process.env.TMPDIR || "/tmp", `claude-risk-${sessionId}`);
22
+ }
23
+
24
+ function statePath(input, target, suffix = "") {
25
+ return join(stateDir(input.session_id), `codex-review-${Buffer.from(role + ":" + target).toString("base64url")}${suffix}`);
26
+ }
27
+
28
+ function normalizeTarget(target) {
29
+ if (typeof target !== "string") return "";
30
+ return target.startsWith("/root/") ? target.slice("/root/".length) : target;
31
+ }
32
+
33
+ function policyHash(root) {
34
+ const result = spawnSync("bash", ["-c", 'source "$1"; _substance_hash_path "$2"', "review-policy",
35
+ join(hookDir, "..", "hooks", "lib", "gate-helpers.sh"), policy], { cwd: root, encoding: "utf8" });
36
+ const hash = result.stdout?.trim();
37
+ return result.status === 0 && /^[a-f0-9]{64}$/.test(hash || "") ? hash : null;
38
+ }
39
+
40
+ function diagnostic(reason, input) {
41
+ const dir = process.env.TMPDIR || "/tmp";
42
+ const path = join(dir, "codex-review-completion-diagnostic.json");
43
+ const temporary = join(dir, `.codex-review-completion-diagnostic-${process.pid}.tmp`);
44
+ try {
45
+ mkdirSync(dir, { recursive: true });
46
+ writeFileSync(temporary, JSON.stringify({
47
+ timestamp: new Date().toISOString(),
48
+ reason,
49
+ event: input?.hook_event_name === "SubagentStop" ? "SubagentStop" : input?.tool_name || "unknown",
50
+ role,
51
+ }), { mode: 0o600 });
52
+ renameSync(temporary, path);
53
+ } catch {
54
+ rmSync(temporary, { force: true });
55
+ }
56
+ }
57
+
58
+ function checkout(cwd) {
59
+ if (typeof cwd !== "string" || !cwd) return null;
60
+ let root;
61
+ try { root = realpathSync(cwd); } catch { return null; }
62
+ const git = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
63
+ if (git.status !== 0) return null;
64
+ let gitRoot;
65
+ try { gitRoot = realpathSync(git.stdout.trim()); } catch { return null; }
66
+ if (gitRoot !== root) return null;
67
+ const stat = statSync(root);
68
+ return { root, physical: `${stat.dev}:${stat.ino}` };
69
+ }
70
+
71
+ function targetFromSpawn(input) {
72
+ const result = response(input);
73
+ return result.agent_id ?? result.task_name;
74
+ }
75
+
76
+ function clear(input, target) {
77
+ for (const suffix of ["", ".claim", ".done"]) rmSync(statePath(input, target, suffix), { force: true });
78
+ }
79
+
80
+ function remember(input) {
81
+ const target = normalizeTarget(targetFromSpawn(input));
82
+ if (!target) return;
83
+ mkdirSync(stateDir(input.session_id), { recursive: true });
84
+ clear(input, target);
85
+ if (input.tool_input?.agent_type !== role) return;
86
+ const bound = checkout(input.cwd || process.cwd());
87
+ if (!bound) {
88
+ diagnostic("invalid-spawn-checkout", input);
89
+ return;
90
+ }
91
+ const hash = policyHash(bound.root);
92
+ if (!hash) {
93
+ diagnostic("policy-hash-failed", input);
94
+ return;
95
+ }
96
+ writeFileSync(statePath(input, target), JSON.stringify({ role, target, ...bound, policyHash: hash }), { mode: 0o600 });
97
+ }
98
+
99
+ function claim(input, target) {
100
+ const path = statePath(input, target, ".claim");
101
+ const done = statePath(input, target, ".done");
102
+ if (existsSync(done)) return null;
103
+ try { writeFileSync(path, "", { flag: "wx", mode: 0o600 }); }
104
+ catch (error) {
105
+ if (error?.code === "EEXIST") return null;
106
+ throw error;
107
+ }
108
+ return { path, done };
109
+ }
110
+
111
+ function complete(input, target, output) {
112
+ target = normalizeTarget(target);
113
+ if (!target || typeof output !== "string" || !output) return;
114
+ const path = statePath(input, target);
115
+ if (existsSync(statePath(input, target, ".done"))) return;
116
+ if (!existsSync(path)) {
117
+ diagnostic("missing-parent-registration", input);
118
+ return;
119
+ }
120
+
121
+ let registered, age;
122
+ try {
123
+ registered = JSON.parse(readFileSync(path, "utf8"));
124
+ age = Date.now() - Math.floor(statSync(path).mtimeMs);
125
+ }
126
+ catch {
127
+ diagnostic("malformed-registration", input);
128
+ return;
129
+ }
130
+ if (registered.role !== role || registered.target !== target) {
131
+ diagnostic("registration-mismatch", input);
132
+ return;
133
+ }
134
+ if (!Number.isFinite(age) || age < 0) {
135
+ diagnostic("invalid-registration-age", input);
136
+ return;
137
+ }
138
+ if (age >= ttl) {
139
+ diagnostic("stale-registration", input);
140
+ return;
141
+ }
142
+
143
+ const current = checkout(input.cwd || process.cwd());
144
+ if (!current || current.root !== registered.root || current.physical !== registered.physical) {
145
+ diagnostic("checkout-mismatch", input);
146
+ return;
147
+ }
148
+
149
+ const hash = policyHash(registered.root);
150
+ if (!hash || hash !== registered.policyHash) {
151
+ diagnostic(hash ? "policy-changed" : "policy-hash-failed", input);
152
+ return;
153
+ }
154
+
155
+ const claimed = claim(input, target);
156
+ if (!claimed) return;
157
+ const synthetic = {
158
+ ...input,
159
+ cwd: registered.root,
160
+ tool_name: "Agent",
161
+ tool_input: { subagent_type: role, prompt: "" },
162
+ tool_response: { content: [{ type: "text", text: output }] },
163
+ };
164
+ const result = spawnSync(writer, {
165
+ cwd: registered.root,
166
+ env: process.env,
167
+ input: JSON.stringify(synthetic),
168
+ encoding: "utf8",
169
+ });
170
+ if (result.status === 0) {
171
+ renameSync(claimed.path, claimed.done);
172
+ rmSync(path, { force: true });
173
+ return;
174
+ }
175
+ rmSync(claimed.path, { force: true });
176
+ diagnostic("marker-writer-failed", input);
177
+ process.exitCode = 1;
178
+ }
179
+
180
+ function close(input) {
181
+ complete(input, input.tool_input?.target, response(input).previous_status?.completed);
182
+ }
183
+
184
+ function wait(input) {
185
+ const statuses = response(input).status;
186
+ if (!statuses || typeof statuses !== "object") return;
187
+ for (const [target, status] of Object.entries(statuses)) complete(input, target, status?.completed);
188
+ }
189
+
190
+ let body = "";
191
+ process.stdin.setEncoding("utf8");
192
+ for await (const chunk of process.stdin) body += chunk;
193
+ let input;
194
+ try { input = JSON.parse(body); } catch { process.exit(0); }
195
+ if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) process.exit(0);
196
+ if (!/^[0-9]+$/.test(ttlSeconds) || !Number.isSafeInteger(ttl) || ttl <= 0) {
197
+ diagnostic("invalid-review-ttl", input);
198
+ process.exit(0);
199
+ }
200
+
201
+ if (["collaborationspawn_agent", "spawn_agent", "multi_agent_v1__spawn_agent"].includes(input.tool_name)) remember(input);
202
+ if (["collaborationinterrupt_agent", "interrupt_agent", "close_agent", "multi_agent_v1__close_agent"].includes(input.tool_name)) close(input);
203
+ if (["collaborationwait_agent", "wait_agent", "multi_agent_v1__wait_agent"].includes(input.tool_name)) wait(input);
204
+ if (input.hook_event_name === "SubagentStop") {
205
+ if (input.agent_type !== role) diagnostic("unrelated-subagent-stop", input);
206
+ else complete(input, input.agent_id, input.last_assistant_message);
207
+ }
@@ -81,6 +81,10 @@
81
81
  {
82
82
  "type": "command",
83
83
  "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
84
+ },
85
+ {
86
+ "type": "command",
87
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
84
88
  }
85
89
  ]
86
90
  }
@@ -94,6 +98,15 @@
94
98
  "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
95
99
  }
96
100
  ]
101
+ },
102
+ {
103
+ "matcher": "^wr-voice-tone:external-comms$",
104
+ "hooks": [
105
+ {
106
+ "type": "command",
107
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion-2.mjs\""
108
+ }
109
+ ]
97
110
  }
98
111
  ]
99
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/voice-tone",
3
- "version": "0.8.4",
3
+ "version": "0.8.5-preview.1191",
4
4
  "description": "Voice and tone enforcement for user-facing copy",
5
5
  "bin": {
6
6
  "windyroad-voice-tone": "./bin/install.mjs"
@@ -92,6 +92,11 @@ prompt: <constructed review prompt from step 3>
92
92
 
93
93
  Wait for the subagent to complete. The subagent outputs a structured verdict block (`EXTERNAL_COMMS_VOICE_TONE_VERDICT: PASS|FAIL` + optional `EXTERNAL_COMMS_VOICE_TONE_REASON: ...` on FAIL). The `PostToolUse:Agent` hook (`external-comms-mark-reviewed.sh`) parses the verdict, derives the marker key from the prompt's `SURFACE:` + `<draft>` structure, and writes the per-evaluator marker automatically on PASS.
94
94
 
95
+ On Codex, use `agent_type: wr-voice-tone:external-comms` with
96
+ `fork_turns: "none"`. After it reports completion, invoke `interrupt_agent`
97
+ exactly once on that completed target so the compatibility hook can persist the
98
+ structured verdict.
99
+
95
100
  **Do not write to `${TMPDIR:-/tmp}/claude-risk-*` yourself.** The hook is the only correct mechanism.
96
101
 
97
102
  ### 5. Present results