@windyroad/voice-tone 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/hooks/external-comms-gate.sh +24 -2
- package/hooks/hooks.json +13 -0
- package/hooks-codex/codex-agent-completion-2.mjs +207 -0
- package/hooks-codex/hooks.json +13 -0
- package/package.json +1 -1
- package/skills/assess-external-comms/SKILL.md +5 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|
+
}
|
package/hooks-codex/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
|
}
|
package/package.json
CHANGED
|
@@ -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
|