@windyroad/architect 0.23.3-preview.1246 → 0.23.4-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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-architect",
126
- "version": "0.23.3"
126
+ "version": "0.23.4"
127
127
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-architect",
3
- "version": "0.23.3",
3
+ "version": "0.23.4",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -57,12 +57,17 @@ if messages:
57
57
  fi
58
58
  ;;
59
59
  user-prompt)
60
+ printf '%s' "$INPUT" | node "$SCRIPT_DIR/codex-agent-completion.mjs"
60
61
  detect_output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/architect-detect.sh")"
61
62
  stale_output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/staleness-check.sh")"
62
63
  [ -z "$detect_output" ] || printf '%s\n' "$detect_output"
63
64
  [ -z "$stale_output" ] || printf '%s\n' "$stale_output"
64
65
  ;;
66
+ subagent-stop)
67
+ printf '%s' "$INPUT" | node "$SCRIPT_DIR/codex-agent-completion.mjs"
68
+ ;;
65
69
  pre-tool)
70
+ printf '%s' "$INPUT" | node "$SCRIPT_DIR/codex-agent-completion.mjs"
66
71
  case "$(tool_name)" in
67
72
  Edit|Write)
68
73
  run_gate architect-enforce-edit.sh
@@ -6,6 +6,7 @@
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
8
  source "$SCRIPT_DIR/lib/gate-helpers.sh"
9
+ source "$SCRIPT_DIR/lib/architect-verdict.sh"
9
10
 
10
11
  # P191 Phase 2: anchor docs/decisions on the project root, not the hook's
11
12
  # runtime CWD (see architect-enforce-edit.sh). A false-negative here never
@@ -36,15 +37,7 @@ case "$SUBAGENT" in
36
37
  # canonical verdict on the first nonblank line is required so examples
37
38
  # and conflicting output fail closed.
38
39
  AGENT_OUTPUT=$(_get_tool_output)
39
- _architect_verdicts() {
40
- sed -nE \
41
- -e 's/^[[:space:]]*>?[[:space:]]*\*\*Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)\*\*[[:space:]]*$/\1/p' \
42
- -e 's/^[[:space:]]*>?[[:space:]]*##[[:space:]]+Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)[[:space:]]*$/\1/p'
43
- }
44
- VERDICT=$(printf '%s\n' "$AGENT_OUTPUT" | _architect_verdicts)
45
- FIRST_VERDICT=$(printf '%s\n' "$AGENT_OUTPUT" | awk 'NF { print; exit }' | _architect_verdicts)
46
- [ "$FIRST_VERDICT" = "$VERDICT" ] || VERDICT=""
47
- [ "$VERDICT" = "ISSUES FOUND" ] && VERDICT="FAIL"
40
+ VERDICT=$(printf '%s\n' "$AGENT_OUTPUT" | architect_verdict)
48
41
 
49
42
  # Substance-aware drift hash + atomic verdict-write (ADR-009 amendment
50
43
  # 2026-06-06). The marker + hash file are written as an atomic pair via
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, rmSync, 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";
@@ -8,54 +9,198 @@ import { CLOSE_TOOLS, SPAWN_TOOLS, WAIT_TOOLS, response } from "./lib/codex-comp
8
9
 
9
10
  const hookDir = dirname(fileURLToPath(import.meta.url));
10
11
  const role = "wr-architect:agent";
12
+ const ttlText = process.env.REVIEW_TTL ?? "3600";
13
+ const ttl = Number(ttlText) * 1000;
11
14
 
12
- function riskDir(sessionId) {
13
- return join(process.env.TMPDIR || "/tmp", `claude-risk-${sessionId}`);
15
+ function riskDir(sessionId) { return join(process.env.TMPDIR || "/tmp", `claude-risk-${sessionId}`); }
16
+ function transportDir() { return join(process.env.TMPDIR || "/tmp", "codex-review-transport"); }
17
+ function normalizeTarget(target) {
18
+ if (typeof target !== "string") return "";
19
+ return target.startsWith("/root/") ? target.slice(6) : target;
20
+ }
21
+ function id(sessionId, target) { return createHash("sha256").update(`${sessionId}\0${role}\0${target}`).digest("hex"); }
22
+ function statePath(sessionId, target, suffix = "") {
23
+ return join(riskDir(sessionId), `codex-architect-${Buffer.from(target).toString("base64url")}${suffix}`);
14
24
  }
25
+ function registrationPath(sessionId, target) { return join(transportDir(), `registration-${id(sessionId, target)}.json`); }
26
+ function receiptPath(sessionId, target, suffix = "") { return join(transportDir(), `receipt-${id(sessionId, target)}.json${suffix}`); }
15
27
 
16
- function statePath(input, target) {
17
- return join(riskDir(input.session_id), `codex-architect-${Buffer.from(normalizeTarget(target)).toString("base64url")}`);
28
+ function checkout(cwd) {
29
+ if (typeof cwd !== "string" || !cwd) return null;
30
+ let root;
31
+ try { root = realpathSync(cwd); } catch { return null; }
32
+ const git = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
33
+ if (git.status !== 0) return null;
34
+ let gitRoot;
35
+ try { gitRoot = realpathSync(git.stdout.trim()); } catch { return null; }
36
+ if (gitRoot !== root) return null;
37
+ const stat = statSync(root);
38
+ return { root, physical: `${stat.dev}:${stat.ino}` };
18
39
  }
19
40
 
20
- function normalizeTarget(target) {
21
- return target.startsWith("/root/") ? target.slice(6) : target;
41
+ function policyHash(root) {
42
+ const result = spawnSync("bash", ["-c", 'source "$1"; _substance_hash_path docs/decisions', "architect-policy", join(hookDir, "lib", "gate-helpers.sh")], { cwd: root, encoding: "utf8" });
43
+ const hash = result.stdout?.trim();
44
+ return result.status === 0 && /^[a-f0-9]{64}$/.test(hash || "") ? hash : null;
45
+ }
46
+
47
+ function parsedVerdict(output) {
48
+ const result = spawnSync("bash", ["-c", 'source "$1"; architect_verdict', "architect-verdict", join(hookDir, "lib", "architect-verdict.sh")], {
49
+ input: output,
50
+ encoding: "utf8",
51
+ });
52
+ return result.status === 0 ? result.stdout.trim() : "";
53
+ }
54
+
55
+ function diagnostic(reason, input) {
56
+ const path = join(process.env.TMPDIR || "/tmp", "codex-review-completion-diagnostic.json");
57
+ const temporary = `${path}.${process.pid}.tmp`;
58
+ try {
59
+ writeFileSync(temporary, JSON.stringify({ timestamp: new Date().toISOString(), reason, role, event: input?.hook_event_name || input?.tool_name || "unknown" }), { mode: 0o600 });
60
+ renameSync(temporary, path);
61
+ } catch { rmSync(temporary, { force: true }); }
62
+ }
63
+
64
+ function reapTransport() {
65
+ if (!existsSync(transportDir())) return;
66
+ for (const name of readdirSync(transportDir())) {
67
+ const path = join(transportDir(), name);
68
+ try {
69
+ const statAge = Date.now() - statSync(path).mtimeMs;
70
+ if (/^(?:receipt|risk-receipt)-[a-f0-9]{64}\.json\.(?:claim|done)$/.test(name)) {
71
+ if (!Number.isFinite(statAge) || statAge < 0 || statAge >= ttl) rmSync(path, { force: true });
72
+ continue;
73
+ }
74
+ if (!/^(?:registration|receipt)-[a-f0-9]{64}\.json$/.test(name)) continue;
75
+ const value = JSON.parse(readFileSync(path, "utf8"));
76
+ if (value.role !== role) continue;
77
+ const timestamp = name.startsWith("receipt-") ? value.completedAt : value.createdAt;
78
+ const age = Date.now() - timestamp;
79
+ if (!Number.isFinite(age) || age < 0 || age >= ttl) rmSync(path, { force: true });
80
+ } catch { /* Another completion may own or remove this transport file. */ }
81
+ }
82
+ }
83
+
84
+ function clear(sessionId, target) {
85
+ for (const suffix of ["", ".claim", ".done"]) rmSync(statePath(sessionId, target, suffix), { force: true });
86
+ rmSync(registrationPath(sessionId, target), { force: true });
87
+ for (const suffix of ["", ".claim", ".done"]) rmSync(receiptPath(sessionId, target, suffix), { force: true });
22
88
  }
23
89
 
24
90
  function remember(input) {
25
- const target = response(input).agent_id ?? response(input).task_name;
26
- if (typeof target !== "string" || !target) return;
91
+ reapTransport();
92
+ const result = response(input);
93
+ const target = normalizeTarget(result.agent_id ?? result.task_name);
94
+ if (!target) return;
27
95
  mkdirSync(riskDir(input.session_id), { recursive: true });
28
- rmSync(statePath(input, target), { force: true });
29
- if (input.tool_input?.agent_type === role) writeFileSync(statePath(input, target), role, "utf8");
30
- }
31
-
32
- function complete(input) {
33
- const target = input.tool_input?.target;
34
- const output = response(input).previous_status?.completed;
35
- if (typeof target !== "string" || typeof output !== "string") return;
36
- const state = statePath(input, target);
37
- if (!existsSync(state) || readFileSync(state, "utf8") !== role) return;
38
- rmSync(state, { force: true });
39
- const synthetic = {
40
- ...input,
41
- tool_name: "Agent",
42
- tool_input: { subagent_type: role },
43
- tool_response: { content: [{ type: "text", text: output }] },
44
- };
45
- spawnSync(join(hookDir, "architect-mark-reviewed.sh"), {
46
- cwd: input.cwd || process.cwd(),
47
- env: process.env,
48
- input: JSON.stringify(synthetic),
49
- encoding: "utf8",
96
+ clear(input.session_id, target);
97
+ if (input.tool_input?.agent_type !== role) return;
98
+ const bound = checkout(input.cwd || process.cwd());
99
+ const hash = bound && policyHash(bound.root);
100
+ if (!bound || !hash) return diagnostic(bound ? "policy-hash-failed" : "invalid-spawn-checkout", input);
101
+ const registered = { parentSession: input.session_id, role, target, ...bound, policyHash: hash, createdAt: Date.now() };
102
+ writeFileSync(statePath(input.session_id, target), JSON.stringify(registered), { mode: 0o600 });
103
+ mkdirSync(transportDir(), { recursive: true, mode: 0o700 });
104
+ writeFileSync(registrationPath(input.session_id, target), JSON.stringify(registered), { mode: 0o600 });
105
+ }
106
+
107
+ function valid(registered, input, target, age) {
108
+ if (registered.role !== role || registered.target !== target) return diagnostic("registration-mismatch", input), false;
109
+ if (!Number.isFinite(age) || age < 0) return diagnostic("invalid-registration-age", input), false;
110
+ if (age >= ttl) return diagnostic("stale-registration", input), false;
111
+ const current = checkout(input.cwd || process.cwd());
112
+ if (!current || current.root !== registered.root || current.physical !== registered.physical) return diagnostic("checkout-mismatch", input), false;
113
+ const hash = policyHash(current.root);
114
+ if (!hash || hash !== registered.policyHash) return diagnostic(hash ? "policy-changed" : "policy-hash-failed", input), false;
115
+ return true;
116
+ }
117
+
118
+ function writeMarker(input, registered, target, output, completedAt, claim, done) {
119
+ const synthetic = { ...input, session_id: registered.parentSession, cwd: registered.root, tool_name: "Agent", tool_input: { subagent_type: role }, tool_response: { content: [{ type: "text", text: output }] } };
120
+ const result = spawnSync(join(hookDir, "architect-mark-reviewed.sh"), { cwd: registered.root, env: process.env, input: JSON.stringify(synthetic), encoding: "utf8" });
121
+ if (result.status !== 0) {
122
+ rmSync(claim, { force: true });
123
+ diagnostic("marker-writer-failed", input);
124
+ return false;
125
+ }
126
+ renameSync(claim, done);
127
+ rmSync(statePath(registered.parentSession, target), { force: true });
128
+ rmSync(registrationPath(registered.parentSession, target), { force: true });
129
+ const assessedAt = new Date(completedAt);
130
+ for (const marker of [`/tmp/architect-reviewed-${registered.parentSession}`, `/tmp/architect-reviewed-${registered.parentSession}.hash`, `/tmp/architect-plan-reviewed-${registered.parentSession}`]) {
131
+ if (existsSync(marker)) utimesSync(marker, assessedAt, assessedAt);
132
+ }
133
+ return true;
134
+ }
135
+
136
+ function complete(input, rawTarget, output) {
137
+ const target = normalizeTarget(rawTarget);
138
+ if (!target || typeof output !== "string" || !output) return;
139
+ const state = statePath(input.session_id, target);
140
+ if (!existsSync(state) || existsSync(`${state}.done`)) return;
141
+ let registered, age;
142
+ try { registered = JSON.parse(readFileSync(state, "utf8")); age = Date.now() - statSync(state).mtimeMs; }
143
+ catch { return diagnostic("malformed-registration", input); }
144
+ if (!valid(registered, input, target, age)) {
145
+ rmSync(state, { force: true });
146
+ rmSync(registrationPath(registered.parentSession || input.session_id, target), { force: true });
147
+ return;
148
+ }
149
+ const claim = `${state}.claim`;
150
+ try { writeFileSync(claim, "", { flag: "wx", mode: 0o600 }); }
151
+ catch (error) { if (error?.code === "EEXIST") return; throw error; }
152
+ writeMarker(input, registered, target, output, Date.now(), claim, `${state}.done`);
153
+ }
154
+
155
+ function registrations(input, target) {
156
+ reapTransport();
157
+ const current = checkout(input.cwd || process.cwd());
158
+ if (!current || !existsSync(transportDir())) return [];
159
+ return readdirSync(transportDir()).filter((name) => /^registration-[a-f0-9]{64}\.json$/.test(name)).flatMap((name) => {
160
+ const path = join(transportDir(), name);
161
+ try {
162
+ const registered = JSON.parse(readFileSync(path, "utf8"));
163
+ return registered.role === role && registered.target === target && registered.root === current.root && registered.physical === current.physical ? [{ registered, age: Date.now() - statSync(path).mtimeMs }] : [];
164
+ } catch { return []; }
50
165
  });
51
166
  }
52
167
 
168
+ function persist(input) {
169
+ const target = normalizeTarget(input.task_name || input.agent_name || input.agent_id);
170
+ const output = input.last_assistant_message;
171
+ if (!target || typeof output !== "string" || !output) return;
172
+ if (parsedVerdict(output) !== "PASS") return diagnostic("non-pass-output", input);
173
+ const candidates = registrations(input, target);
174
+ if (candidates.length !== 1) return diagnostic(candidates.length ? "ambiguous-parent-registration" : "missing-parent-registration", input);
175
+ const { registered, age } = candidates[0];
176
+ if (!valid(registered, input, target, age)) return;
177
+ const path = receiptPath(registered.parentSession, target);
178
+ if (existsSync(path) || existsSync(`${path}.done`)) return;
179
+ try { writeFileSync(path, JSON.stringify({ ...registered, output, completedAt: Date.now() }), { flag: "wx", mode: 0o600 }); }
180
+ catch (error) { if (error?.code !== "EEXIST") diagnostic("receipt-write-failed", input); }
181
+ }
182
+
183
+ function consume(input) {
184
+ reapTransport();
185
+ if (!existsSync(transportDir())) return;
186
+ for (const name of readdirSync(transportDir()).filter((entry) => /^receipt-[a-f0-9]{64}\.json$/.test(entry))) {
187
+ const path = join(transportDir(), name);
188
+ let pending;
189
+ try { pending = JSON.parse(readFileSync(path, "utf8")); } catch { diagnostic("malformed-receipt", input); continue; }
190
+ if (pending.parentSession !== input.session_id || pending.role !== role) continue;
191
+ if (!valid(pending, input, pending.target, Date.now() - pending.completedAt)) continue;
192
+ const claim = `${path}.claim`;
193
+ try { writeFileSync(claim, "", { flag: "wx", mode: 0o600 }); }
194
+ catch (error) { if (error?.code === "EEXIST") continue; throw error; }
195
+ if (writeMarker(input, pending, pending.target, pending.output, pending.completedAt, claim, `${path}.done`)) rmSync(path, { force: true });
196
+ }
197
+ }
198
+
199
+ function close(input) { complete(input, input.tool_input?.target, response(input).previous_status?.completed); }
53
200
  function wait(input) {
54
201
  const statuses = response(input).status;
55
202
  if (!statuses || typeof statuses !== "object") return;
56
- for (const [target, status] of Object.entries(statuses)) {
57
- complete({ ...input, tool_input: { target }, tool_response: { previous_status: status } });
58
- }
203
+ for (const [target, status] of Object.entries(statuses)) complete(input, target, status?.completed);
59
204
  }
60
205
 
61
206
  let body = "";
@@ -64,7 +209,19 @@ for await (const chunk of process.stdin) body += chunk;
64
209
  let input;
65
210
  try { input = JSON.parse(body); } catch { process.exit(0); }
66
211
  if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) process.exit(0);
212
+ if (!/^[0-9]+$/.test(ttlText) || !Number.isSafeInteger(ttl) || ttl <= 0) process.exit(0);
67
213
 
68
- if (SPAWN_TOOLS.has(input.tool_name)) remember(input);
69
- if (CLOSE_TOOLS.has(input.tool_name)) complete(input);
70
- if (WAIT_TOOLS.has(input.tool_name)) wait(input);
214
+ try {
215
+ if (SPAWN_TOOLS.has(input.tool_name)) remember(input);
216
+ if (CLOSE_TOOLS.has(input.tool_name)) close(input);
217
+ if (WAIT_TOOLS.has(input.tool_name)) wait(input);
218
+ if (input.hook_event_name === "UserPromptSubmit" || input.hook_event_name === "PreToolUse" || ["Bash", "Edit", "Write", "ExitPlanMode"].includes(input.tool_name)) consume(input);
219
+ if (input.hook_event_name === "SubagentStop") {
220
+ if (input.agent_type !== role) diagnostic("unrelated-subagent-stop", input);
221
+ else if (existsSync(statePath(input.session_id, normalizeTarget(input.agent_id)))) complete(input, input.agent_id, input.last_assistant_message);
222
+ else persist(input);
223
+ }
224
+ } catch {
225
+ diagnostic("transport-error", input);
226
+ process.exitCode = 0;
227
+ }
package/hooks/hooks.json CHANGED
@@ -13,6 +13,9 @@
13
13
  ],
14
14
  "PostToolUse": [
15
15
  { "matcher": "Agent|Bash|Edit|Skill|Write|collaboration.spawn_agent|collaboration.wait_agent|collaboration.interrupt_agent|collaborationspawn_agent|collaborationwait_agent|collaborationinterrupt_agent|spawn_agent|wait_agent|interrupt_agent|close_agent|multi_agent_v1__spawn_agent|multi_agent_v1__wait_agent|multi_agent_v1__close_agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-dispatch.sh post-tool" }] }
16
+ ],
17
+ "SubagentStop": [
18
+ { "matcher": "^wr-architect:agent$", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-dispatch.sh subagent-stop" }] }
16
19
  ]
17
20
  }
18
21
  }
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+
3
+ # Print the one authoritative architect verdict. Exactly one canonical verdict
4
+ # must be the first nonblank line; examples and conflicting headings fail closed.
5
+ architect_verdict() {
6
+ local output verdict first
7
+ output="$(cat)"
8
+ verdict="$(printf '%s\n' "$output" | sed -nE \
9
+ -e 's/^[[:space:]]*>?[[:space:]]*\*\*Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)\*\*[[:space:]]*$/\1/p' \
10
+ -e 's/^[[:space:]]*>?[[:space:]]*##[[:space:]]+Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)[[:space:]]*$/\1/p')"
11
+ first="$(printf '%s\n' "$output" | awk 'NF { print; exit }' | sed -nE \
12
+ -e 's/^[[:space:]]*>?[[:space:]]*\*\*Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)\*\*[[:space:]]*$/\1/p' \
13
+ -e 's/^[[:space:]]*>?[[:space:]]*##[[:space:]]+Architecture Review: (PASS|ISSUES FOUND|NEEDS DIRECTION)[[:space:]]*$/\1/p')"
14
+ [ "$first" = "$verdict" ] || verdict=""
15
+ [ "$verdict" != "ISSUES FOUND" ] || verdict="FAIL"
16
+ printf '%s' "$verdict"
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/architect",
3
- "version": "0.23.3-preview.1246",
3
+ "version": "0.23.4-preview.1265",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "bin": {
6
6
  "windyroad-architect": "./bin/install.mjs"