@windyroad/style-guide 0.6.4 → 0.6.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.
@@ -79,5 +79,5 @@
79
79
  }
80
80
  },
81
81
  "name": "wr-style-guide",
82
- "version": "0.6.4"
82
+ "version": "0.6.5"
83
83
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-style-guide",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Style-guide governance for CSS and UI components",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
package/hooks/hooks.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-eval.sh"
8
+ "command": "${PLUGIN_ROOT}/hooks/style-guide-eval.sh"
9
9
  }
10
10
  ]
11
11
  },
@@ -13,12 +13,29 @@
13
13
  "hooks": [
14
14
  {
15
15
  "type": "command",
16
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/staleness-check.sh"
16
+ "command": "${PLUGIN_ROOT}/hooks/staleness-check.sh"
17
+ }
18
+ ]
19
+ },
20
+ {
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
17
25
  }
18
26
  ]
19
27
  }
20
28
  ],
21
29
  "PreToolUse": [
30
+ {
31
+ "matcher": "Bash|Edit|Write|ExitPlanMode",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
36
+ }
37
+ ]
38
+ },
22
39
  {
23
40
  "matcher": "Edit|Write",
24
41
  "hooks": [
@@ -33,7 +50,7 @@
33
50
  "hooks": [
34
51
  {
35
52
  "type": "command",
36
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/bash-write-dispatch.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-enforce-edit.sh\""
53
+ "command": "\"${PLUGIN_ROOT}/hooks/bash-write-dispatch.sh\" \"${PLUGIN_ROOT}/hooks/style-guide-enforce-edit.sh\""
37
54
  }
38
55
  ]
39
56
  }
@@ -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-style-guide:agent";
11
12
  const writer = join(hookDir, "..", "hooks", "style-guide-mark-reviewed.sh");
12
13
  const policy = "docs/STYLE-GUIDE.md";
14
+ const passPattern = new RegExp("^[ \\t]*>?[ \\t]*\\*\\*Style Guide 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", `style-guide-reviewed-${registered.parentSession}`),
233
+ join(process.env.TMPDIR || "/tmp", `style-guide-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.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
  }
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-eval.sh"
8
+ "command": "${PLUGIN_ROOT}/hooks/style-guide-eval.sh"
9
9
  }
10
10
  ]
11
11
  },
@@ -13,12 +13,29 @@
13
13
  "hooks": [
14
14
  {
15
15
  "type": "command",
16
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/staleness-check.sh"
16
+ "command": "${PLUGIN_ROOT}/hooks/staleness-check.sh"
17
+ }
18
+ ]
19
+ },
20
+ {
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
17
25
  }
18
26
  ]
19
27
  }
20
28
  ],
21
29
  "PreToolUse": [
30
+ {
31
+ "matcher": "Bash|Edit|Write|ExitPlanMode",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${PLUGIN_ROOT}/hooks-codex/codex-agent-completion.mjs\""
36
+ }
37
+ ]
38
+ },
22
39
  {
23
40
  "matcher": "Edit|Write",
24
41
  "hooks": [
@@ -33,7 +50,7 @@
33
50
  "hooks": [
34
51
  {
35
52
  "type": "command",
36
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/bash-write-dispatch.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-enforce-edit.sh\""
53
+ "command": "\"${PLUGIN_ROOT}/hooks/bash-write-dispatch.sh\" \"${PLUGIN_ROOT}/hooks/style-guide-enforce-edit.sh\""
37
54
  }
38
55
  ]
39
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/style-guide",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Style guide enforcement for CSS and UI components",
5
5
  "bin": {
6
6
  "windyroad-style-guide": "./bin/install.mjs"