@webpresso/plugin-opencode 0.0.11 → 0.0.13

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/bin/wp ADDED
@@ -0,0 +1,250 @@
1
+ #!/bin/sh
2
+ # webpresso-wp-shim v1 — keep this marker on line 2 (shim loop guard).
3
+ #
4
+ # The one `wp` shim. Copied byte-identically into each plugin's bin/wp
5
+ # (packages/scripts/stage-plugin-runtime-artifacts.ts) and, once the release
6
+ # installer adopts it, into ~/.webpresso/bin/wp. It stores no state, writes
7
+ # nothing, and picks the runtime on EVERY call from where it is called:
8
+ #
9
+ # 1. WEBPRESSO_WP_BIN (an absolute executable) xor WP_FORCE_JIT_PATH (an
10
+ # absolute checkout)
11
+ # 2. the nearest ancestor of $PWD that is a webpresso source checkout
12
+ # (bin/wp + src/cli/cli.ts) ADMITTED in ~/.webpresso/source-checkouts
13
+ # -> <checkout>/bin/wp, whose own launcher guards freshness against HEAD.
14
+ # Layout is not trust: a source-shaped directory whose repository is not
15
+ # listed there fails with checkout-not-admitted instead of running its
16
+ # bin/wp. A linked worktree is admitted through its repository, and only
17
+ # when the admitted repository's own .git/worktrees/*/gitdir names it.
18
+ # 3. else (no checkout ancestor at all) the nearest ancestor
19
+ # .webpressorc.json: its top-level exact-semver "runtime" pin
20
+ # -> ~/.webpresso/runtimes/<pin>/wp (missing = error)
21
+ # 4. else the newest stable ~/.webpresso/runtimes/<x.y.z>/wp
22
+ # 5. else a named error, exit 127 — never a different runtime. A pre-shim
23
+ # ~/.webpresso/bin/wp is never run implicitly (nothing in it proves it is
24
+ # a release rather than a development build); name it with
25
+ # WEBPRESSO_WP_BIN.
26
+ #
27
+ # Bounded: a parent-directory walk, one-line reads of the user's admission file
28
+ # and of admitted repositories' worktree back-links, at most one size-capped
29
+ # JSON read (node, only in a consumer repo with a
30
+ # .webpressorc.json), one directory listing. No git, no network, no retries,
31
+ # no timeouts. Mode and error literals
32
+ # are the closed vocabulary in src/runtime/runtime-selector-vocab.ts.
33
+ #
34
+ # `exec`, not a spawn wrapper: the MCP server this launches is a long-lived
35
+ # stdio service, and a relay process could die and orphan it. Arguments are
36
+ # forwarded verbatim through "$@".
37
+
38
+ set -u
39
+
40
+ announced_modes='explicit-binary explicit-checkout'
41
+
42
+ fail() {
43
+ printf 'wp-shim: error %s: %s\n' "$1" "$2" >&2
44
+ exit 127
45
+ }
46
+
47
+ MODE=
48
+ TARGET=
49
+ choose() {
50
+ MODE=$1
51
+ TARGET=$2
52
+ }
53
+
54
+ admission_file=${HOME:-}/.webpresso/source-checkouts
55
+
56
+ is_source_shaped() {
57
+ [ -f "$1/bin/wp" ] && [ -x "$1/bin/wp" ] && [ -f "$1/src/cli/cli.ts" ]
58
+ }
59
+
60
+ # True when checkout $1 is an admitted repository's primary checkout, or one of
61
+ # its registered linked worktrees. The admission file is user-owned, one
62
+ # absolute primary-checkout path per line. Identity is by directory (-ef), and
63
+ # a linked worktree is recognized only from the admitted repository's own
64
+ # .git/worktrees/*/gitdir back-links: nothing inside the project (its .git
65
+ # file, symlink or metadata) is ever read or trusted, and stat never blocks.
66
+ is_admitted_checkout() {
67
+ [ -f "$admission_file" ] || return 1
68
+ while IFS= read -r entry || [ -n "$entry" ]; do
69
+ case $entry in /*) ;; *) continue ;; esac
70
+ [ "$entry" -ef "$1" ] && return 0
71
+ for meta in "$entry"/.git/worktrees/*/; do
72
+ [ -f "${meta}gitdir" ] || continue
73
+ back=
74
+ IFS= read -r back <"${meta}gitdir" || [ -n "$back" ] || continue
75
+ [ "${back%/.git}" -ef "$1" ] && return 0
76
+ done
77
+ done <"$admission_file"
78
+ return 1
79
+ }
80
+
81
+ # Exact semver x.y.z[-pre], no leading zeros, no build metadata. With a second
82
+ # argument "stable", a prerelease is rejected too.
83
+ is_exact_semver() {
84
+ version=$1
85
+ core=${version%%-*}
86
+ pre=
87
+ if [ "$core" != "$version" ]; then
88
+ pre=${version#*-}
89
+ [ "${2:-}" != stable ] || return 1
90
+ case $pre in '' | .* | *. | *..* | *[!0-9A-Za-z.-]*) return 1 ;; esac
91
+ saved_ifs=$IFS
92
+ IFS=.
93
+ # shellcheck disable=SC2086 # deliberate field split on "."
94
+ set -- $pre
95
+ IFS=$saved_ifs
96
+ for part in "$@"; do
97
+ case $part in 0?*) case $part in *[!0-9]*) ;; *) return 1 ;; esac ;; esac
98
+ done
99
+ fi
100
+ case $core in '' | .* | *. | *..* | *[!0-9.]*) return 1 ;; esac
101
+ saved_ifs=$IFS
102
+ IFS=.
103
+ # shellcheck disable=SC2086 # deliberate field split on "."
104
+ set -- $core
105
+ IFS=$saved_ifs
106
+ [ "$#" -eq 3 ] || return 1
107
+ for part in "$@"; do
108
+ case $part in '' | 0?* | *[!0-9]*) return 1 ;; esac
109
+ done
110
+ return 0
111
+ }
112
+
113
+ runtimes_dir=${HOME:-}/.webpresso/runtimes
114
+
115
+ # Sets PIN to the top-level "runtime" of $1 (empty when absent). The document
116
+ # is opened non-blocking, must be a regular file, and is read through a
117
+ # 64 KiB + 1 buffer on that one descriptor (growth after open cannot exceed
118
+ # it); anything longer is refused. The pin
119
+ # is validated as an exact semver BEFORE it crosses back into the shell, so no
120
+ # shell transport (such as a stripped trailing newline) can change it.
121
+ read_pin() {
122
+ PIN=$(node -e '
123
+ const fs = require("node:fs");
124
+ let fd;
125
+ try { fd = fs.openSync(process.argv[1], fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); } catch { process.exit(2); }
126
+ if (!fs.fstatSync(fd).isFile()) process.exit(3);
127
+ const limit = 65536;
128
+ const buffer = Buffer.alloc(limit + 1);
129
+ let length = 0;
130
+ for (let n; length <= limit && (n = fs.readSync(fd, buffer, length, limit + 1 - length, null)) > 0; ) length += n;
131
+ if (length > limit) process.exit(3);
132
+ let v;
133
+ try { v = JSON.parse(buffer.subarray(0, length).toString("utf8")); } catch { process.exit(2); }
134
+ if (v === null || typeof v !== "object" || Array.isArray(v) || !Object.hasOwn(v, "runtime")) process.exit(0);
135
+ const pattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/u;
136
+ if (typeof v.runtime !== "string" || !pattern.test(v.runtime)) process.exit(4);
137
+ process.stdout.write(v.runtime);
138
+ ' "$1" 2>/dev/null)
139
+ case $? in
140
+ 0) return 0 ;;
141
+ 3) PIN_PROBLEM="is not a regular file of at most 64 KiB" ;;
142
+ 4) PIN_PROBLEM="has a \"runtime\" pin that is not an exact version such as 1.2.3 (no ranges, no build metadata)" ;;
143
+ 127) PIN_PROBLEM="cannot be read: node is not on PATH" ;;
144
+ *) PIN_PROBLEM="is not valid JSON" ;;
145
+ esac
146
+ return 1
147
+ }
148
+
149
+ choose_latest_release() {
150
+ best=
151
+ best_major=
152
+ best_minor=
153
+ best_patch=
154
+ for entry in "$runtimes_dir"/*/; do
155
+ [ -d "$entry" ] || continue
156
+ entry=${entry%/}
157
+ version=${entry##*/}
158
+ is_exact_semver "$version" stable || continue
159
+ [ -f "$entry/wp" ] && [ -x "$entry/wp" ] || continue
160
+ major=${version%%.*}
161
+ rest=${version#*.}
162
+ minor=${rest%%.*}
163
+ patch=${rest#*.}
164
+ if [ -z "$best" ] ||
165
+ [ "$major" -gt "$best_major" ] ||
166
+ { [ "$major" -eq "$best_major" ] && [ "$minor" -gt "$best_minor" ]; } ||
167
+ { [ "$major" -eq "$best_major" ] && [ "$minor" -eq "$best_minor" ] && [ "$patch" -gt "$best_patch" ]; }; then
168
+ best=$version
169
+ best_major=$major
170
+ best_minor=$minor
171
+ best_patch=$patch
172
+ fi
173
+ done
174
+ if [ -n "$best" ]; then
175
+ choose latest-release "$runtimes_dir/$best/wp"
176
+ return 0
177
+ fi
178
+ return 1
179
+ }
180
+
181
+ is_shim_file() {
182
+ [ -f "$1" ] || return 1
183
+ { head -c 256 "$1" | grep -q 'webpresso-wp-shim'; } 2>/dev/null
184
+ }
185
+
186
+ choose_release() {
187
+ if [ -n "${1:-}" ]; then
188
+ rc_file=$1/.webpressorc.json
189
+ read_pin "$rc_file" || fail pin-invalid "$rc_file $PIN_PROBLEM."
190
+ if [ -n "$PIN" ]; then
191
+ is_exact_semver "$PIN" || fail pin-invalid "$rc_file pins runtime \"$PIN\"; use an exact version such as 1.2.3 (no ranges)."
192
+ pinned=$runtimes_dir/$PIN/wp
193
+ [ -f "$pinned" ] && [ -x "$pinned" ] || fail pin-not-installed "$rc_file pins runtime $PIN, but $pinned is not installed under $runtimes_dir."
194
+ choose pinned-release "$pinned"
195
+ return 0
196
+ fi
197
+ fi
198
+ choose_latest_release && return 0
199
+ fail no-release-installed "no webpresso source checkout above $PWD and no release under $runtimes_dir; install the Webpresso app, or name a runtime explicitly with WEBPRESSO_WP_BIN (for example a pre-shim \$HOME/.webpresso/bin/wp release) or WP_FORCE_JIT_PATH (a source checkout)."
200
+ }
201
+
202
+ : "${WEBPRESSO_WP_BIN:=}"
203
+ : "${WP_FORCE_JIT_PATH:=}"
204
+
205
+ if [ -n "$WEBPRESSO_WP_BIN" ] && [ -n "$WP_FORCE_JIT_PATH" ]; then
206
+ fail both-overrides-set "WEBPRESSO_WP_BIN and WP_FORCE_JIT_PATH are both set; unset one."
207
+ elif [ -n "$WEBPRESSO_WP_BIN" ]; then
208
+ case $WEBPRESSO_WP_BIN in /*) ;; *) fail override-missing "WEBPRESSO_WP_BIN must be an absolute path (got $WEBPRESSO_WP_BIN); exec would otherwise search PATH for a different file." ;; esac
209
+ [ -f "$WEBPRESSO_WP_BIN" ] && [ -x "$WEBPRESSO_WP_BIN" ] || fail override-missing "WEBPRESSO_WP_BIN points to $WEBPRESSO_WP_BIN, which is not an executable file."
210
+ choose explicit-binary "$WEBPRESSO_WP_BIN"
211
+ elif [ -n "$WP_FORCE_JIT_PATH" ]; then
212
+ case $WP_FORCE_JIT_PATH in /*) ;; *) fail override-missing "WP_FORCE_JIT_PATH must be an absolute path (got $WP_FORCE_JIT_PATH)." ;; esac
213
+ is_source_shaped "$WP_FORCE_JIT_PATH" || fail override-missing "WP_FORCE_JIT_PATH points to $WP_FORCE_JIT_PATH, which is not a webpresso source checkout (executable bin/wp and src/cli/cli.ts)."
214
+ choose explicit-checkout "$WP_FORCE_JIT_PATH/bin/wp"
215
+ else
216
+ # Checkout first, across every ancestor: a .webpressorc.json nested inside
217
+ # a source checkout must not pin a foreign runtime there. Only when no
218
+ # ancestor is a checkout does the nearest consumer config decide.
219
+ dir=${PWD:-}
220
+ consumer_root=
221
+ while [ -n "$dir" ]; do
222
+ if is_source_shaped "$dir"; then
223
+ is_admitted_checkout "$dir" || fail checkout-not-admitted "$dir looks like a webpresso source checkout, but its repository is not listed in $admission_file; add the absolute path of its primary checkout there (one per line) to run its bin/wp, or set WP_FORCE_JIT_PATH=$dir for this call."
224
+ choose checkout "$dir/bin/wp"
225
+ break
226
+ fi
227
+ if [ -z "$consumer_root" ] && [ -e "$dir/.webpressorc.json" ]; then
228
+ consumer_root=$dir
229
+ fi
230
+ [ "$dir" != / ] || break
231
+ dir=${dir%/*}
232
+ [ -n "$dir" ] || dir=/
233
+ done
234
+ [ -n "$TARGET" ] || choose_release "$consumer_root"
235
+ fi
236
+
237
+ # Never exec a shim: this one (directly or through a symlink) or another
238
+ # byte-identical plugin copy would recurse instead of reaching a runtime.
239
+ if [ "$TARGET" = "$0" ] || [ "$TARGET" -ef "$0" ] || is_shim_file "$TARGET"; then
240
+ fail self-reference "$MODE resolved to a wp shim ($TARGET); point it at a wp runtime instead."
241
+ fi
242
+
243
+ case " $announced_modes " in
244
+ *" $MODE "*) printf 'wp-shim: %s %s\n' "$MODE" "$TARGET" >&2 ;;
245
+ esac
246
+
247
+ WP_SKIP_UPDATE_CHECK=${WP_SKIP_UPDATE_CHECK:-1}
248
+ export WP_SKIP_UPDATE_CHECK
249
+
250
+ exec "$TARGET" "$@"
package/index.js CHANGED
@@ -1,10 +1,13 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { accessSync, constants, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
 
5
7
  const SESSION_START_HOOKS = ["sessionstart-routing"];
6
8
  const PRE_TOOL_HOOKS = ["pretool-guard"];
7
9
  const POST_TOOL_HOOKS = ["post-tool"];
10
+ const HOOK_BUDGET_MS = 5_000;
8
11
 
9
12
  function extractAdditionalContext(envelope) {
10
13
  return envelope?.hookSpecificOutput?.additionalContext ?? null;
@@ -24,11 +27,16 @@ function extractUpdatedInput(envelope) {
24
27
  return output?.permissionDecision === "allow" ? (output.updatedInput ?? null) : null;
25
28
  }
26
29
 
27
- function hookInputForTool(tool, args, directory) {
30
+ function sessionIdFrom(source) {
31
+ return source?.sessionID ?? source?.sessionId ?? source?.session_id ?? null;
32
+ }
33
+
34
+ function hookInputForTool(tool, args, directory, sessionId) {
28
35
  const toolArgs = args && typeof args === "object" && !Array.isArray(args) ? args : {};
29
36
  const base = {
30
37
  cwd: directory,
31
38
  hook_event_name: "PreToolUse",
39
+ ...(sessionId ? { session_id: sessionId } : {}),
32
40
  };
33
41
 
34
42
  switch (tool) {
@@ -81,10 +89,6 @@ function hookInputForTool(tool, args, directory) {
81
89
  }
82
90
  }
83
91
 
84
- function shellQuote(value) {
85
- return `'${String(value).replaceAll("'", "'\\''")}'`;
86
- }
87
-
88
92
  function isExecutableFile(candidate) {
89
93
  try {
90
94
  const stat = statSync(candidate);
@@ -96,92 +100,137 @@ function isExecutableFile(candidate) {
96
100
  }
97
101
  }
98
102
 
99
- function fixedAppWpLauncher() {
100
- const home =
101
- process.platform === "win32"
102
- ? process.env.USERPROFILE || homedir()
103
- : process.env.HOME || homedir();
104
- return process.platform === "win32"
105
- ? join(home, ".webpresso", "bin", "wp.exe")
106
- : join(home, ".webpresso", "bin", "wp");
107
- }
103
+ /**
104
+ * The packaged copy of the canonical `wp` shim (bin/wp-shim). It owns runtime
105
+ * selection: explicit override, then the enclosing webpresso checkout, then the
106
+ * consumer's pinned or newest installed release, else a named error.
107
+ */
108
+ const PACKAGED_WP_SHIM = join(dirname(fileURLToPath(import.meta.url)), "bin", "wp");
108
109
 
109
- async function resolveWpHookCommand($, directory, hookName) {
110
- const explicit = process.env.WEBPRESSO_WP_BIN;
111
- const jitRoot = process.env.WP_FORCE_JIT_PATH;
112
- if (explicit && jitRoot) {
110
+ export function resolveWpLauncher(platform = process.platform, env = process.env) {
111
+ if (platform !== "win32") return PACKAGED_WP_SHIM;
112
+ // The shim is POSIX sh. Until a native Windows shim exists, Windows keeps the
113
+ // explicit overrides (and their conflict refusal) plus the app launcher.
114
+ if (env.WEBPRESSO_WP_BIN && env.WP_FORCE_JIT_PATH) {
113
115
  throw new Error("WEBPRESSO_WP_BIN and WP_FORCE_JIT_PATH are both set; unset one.");
114
116
  }
117
+ if (env.WEBPRESSO_WP_BIN) return env.WEBPRESSO_WP_BIN;
118
+ if (env.WP_FORCE_JIT_PATH) return join(env.WP_FORCE_JIT_PATH, "bin", "wp");
119
+ return join(env.USERPROFILE || homedir(), ".webpresso", "bin", "wp.exe");
120
+ }
115
121
 
116
- let candidate = null;
117
- let label = "Webpresso app launcher";
118
- if (explicit) {
119
- candidate = explicit;
120
- label = "WEBPRESSO_WP_BIN";
121
- } else if (jitRoot) {
122
- candidate = join(jitRoot, "bin", "wp");
123
- label = "WP_FORCE_JIT_PATH";
124
- } else {
125
- candidate = fixedAppWpLauncher();
126
- }
127
-
128
- if (!isExecutableFile(candidate)) {
122
+ function resolveWpHookCommand() {
123
+ const launcher = resolveWpLauncher();
124
+ if (!isExecutableFile(launcher)) {
129
125
  throw new Error(
130
- `${label} points to ${candidate}, but that executable is not available. ` +
131
- "Set WEBPRESSO_WP_BIN, set WP_FORCE_JIT_PATH to an agent-kit checkout, or install the Webpresso app.",
126
+ `the webpresso wp launcher ${launcher} is not available; reinstall @webpresso/plugin-opencode or the Webpresso app.`,
132
127
  );
133
128
  }
134
- return `${shellQuote(candidate)} hook ${hookName}`;
129
+ return launcher;
135
130
  }
136
131
 
137
- async function runHook($, directory, hookName, payload, options = {}) {
138
- const stdinPayload = payload === null ? "" : JSON.stringify(payload);
139
- const command = await resolveWpHookCommand($, directory, hookName);
140
- const result =
141
- await $`printf '%s' ${stdinPayload} | env CLAUDE_PROJECT_DIR=${directory} WP_HOOK_HOST=opencode sh -lc ${command}`
142
- .cwd(directory)
143
- .quiet()
144
- .nothrow();
132
+ function spawnWpHook(candidate, directory, hookName, stdinPayload) {
133
+ return new Promise((resolve) => {
134
+ const child = spawn(candidate, ["hook", hookName], {
135
+ cwd: directory,
136
+ env: {
137
+ ...process.env,
138
+ CLAUDE_PROJECT_DIR: directory,
139
+ WP_HOOK_HOST: "opencode",
140
+ },
141
+ stdio: ["pipe", "pipe", "pipe"],
142
+ });
143
+ const stdoutChunks = [];
144
+ const stderrChunks = [];
145
+ let settled = false;
146
+ const settle = (result) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ clearTimeout(deadline);
150
+ resolve(result);
151
+ };
152
+ // Same hard budget every other host declares for webpresso hooks.
153
+ const deadline = setTimeout(() => {
154
+ child.kill("SIGKILL");
155
+ settle({
156
+ stdout: Buffer.alloc(0),
157
+ stderr: Buffer.from(`timed out after ${HOOK_BUDGET_MS} ms`),
158
+ exitCode: null,
159
+ });
160
+ }, HOOK_BUDGET_MS);
161
+ child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
162
+ child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
163
+ child.on("error", (error) => {
164
+ settle({ stdout: Buffer.alloc(0), stderr: Buffer.from(error.message), exitCode: null });
165
+ });
166
+ child.on("close", (code) => {
167
+ settle({
168
+ stdout: Buffer.concat(stdoutChunks),
169
+ stderr: Buffer.concat(stderrChunks),
170
+ exitCode: code,
171
+ });
172
+ });
173
+ child.stdin.on("error", () => {});
174
+ child.stdin.end(stdinPayload);
175
+ });
176
+ }
145
177
 
146
- const stdout = result.stdout.toString().trim();
147
- const stderr = result.stderr?.toString().trim() ?? "";
148
- const exitCode = result.exitCode ?? 0;
149
- const required = options.required === true;
178
+ function hookFailure(hookName, detail, cause) {
179
+ return new Error(
180
+ `GUARD_PRETOOL_FAILURE: webpresso hook ${hookName} ${detail}`,
181
+ cause === undefined ? undefined : { cause },
182
+ );
183
+ }
150
184
 
151
- if (exitCode !== 0) {
152
- if (required) {
153
- throw new Error(
154
- `webpresso hook ${hookName} failed with exit ${exitCode}${stderr ? `: ${stderr}` : ""}`,
155
- );
156
- }
185
+ /**
186
+ * Required hooks (pretool policy) fail closed on every failure mode: launcher
187
+ * missing, spawn error, timeout, non-zero exit, empty or malformed stdout.
188
+ * Observational hooks return null instead.
189
+ */
190
+ async function runHook(directory, hookName, payload, options = {}) {
191
+ const required = options.required === true;
192
+ const stdinPayload = payload === null ? "" : JSON.stringify(payload);
193
+ let candidate;
194
+ try {
195
+ candidate = resolveWpHookCommand();
196
+ } catch (error) {
197
+ if (required) throw hookFailure(hookName, `cannot start: ${error.message}`, error);
157
198
  return null;
158
199
  }
200
+ const result = await spawnWpHook(candidate, directory, hookName, stdinPayload);
201
+ const stdout = result.stdout.toString().trim();
202
+ const stderr = result.stderr.toString().trim();
203
+ const detail = stderr ? `: ${stderr}` : "";
159
204
 
205
+ if (result.exitCode !== 0) {
206
+ if (!required) return null;
207
+ throw hookFailure(
208
+ hookName,
209
+ result.exitCode === null
210
+ ? `did not complete${detail}`
211
+ : `failed with exit ${result.exitCode}${detail}`,
212
+ );
213
+ }
160
214
  if (stdout === "") {
161
- if (required) {
162
- throw new Error(`webpresso hook ${hookName} produced no JSON response`);
163
- }
215
+ if (required) throw hookFailure(hookName, "produced no JSON response");
164
216
  return null;
165
217
  }
166
-
167
218
  try {
168
219
  return JSON.parse(stdout);
169
220
  } catch (error) {
170
- if (required) {
171
- throw new Error(
172
- `webpresso hook ${hookName} produced invalid JSON${stderr ? `: ${stderr}` : ""}`,
173
- { cause: error },
174
- );
175
- }
221
+ if (required) throw hookFailure(hookName, `produced invalid JSON${detail}`, error);
176
222
  return null;
177
223
  }
178
224
  }
179
225
 
180
- async function collectSessionStartContext($, directory) {
226
+ async function collectSessionStartContext(directory, sessionId) {
181
227
  const contexts = [];
228
+ const payload = sessionId
229
+ ? { cwd: directory, hook_event_name: "SessionStart", session_id: sessionId }
230
+ : null;
182
231
 
183
232
  for (const hookName of SESSION_START_HOOKS) {
184
- const envelope = await runHook($, directory, hookName, null);
233
+ const envelope = await runHook(directory, hookName, payload);
185
234
  const message = extractAdditionalContext(envelope);
186
235
  if (message === null) {
187
236
  continue;
@@ -193,26 +242,31 @@ async function collectSessionStartContext($, directory) {
193
242
  return contexts;
194
243
  }
195
244
 
196
- export const WebpressoHooksPlugin = async ({ $, directory }) => {
245
+ export const WebpressoHooksPlugin = async ({ directory }) => {
197
246
  let cachedSessionContexts = [];
198
247
 
199
248
  return {
200
249
  event: async ({ event }) => {
201
250
  if (event?.type === "session.created") {
202
- cachedSessionContexts = await collectSessionStartContext($, directory);
251
+ cachedSessionContexts = await collectSessionStartContext(directory, sessionIdFrom(event));
203
252
  }
204
253
  },
205
254
  "shell.env": async (input, output) => {
206
255
  output.env.CLAUDE_PROJECT_DIR = input.cwd;
207
256
  },
208
257
  "tool.execute.before": async (input, output) => {
209
- const payload = hookInputForTool(input?.tool, output?.args ?? input?.args ?? {}, directory);
258
+ const payload = hookInputForTool(
259
+ input?.tool,
260
+ output?.args ?? input?.args ?? {},
261
+ directory,
262
+ sessionIdFrom(input),
263
+ );
210
264
  if (payload === null) {
211
265
  return;
212
266
  }
213
267
 
214
268
  for (const hookName of PRE_TOOL_HOOKS) {
215
- const envelope = await runHook($, directory, hookName, payload, { required: true });
269
+ const envelope = await runHook(directory, hookName, payload, { required: true });
216
270
  const denyReason = extractDenyReason(envelope);
217
271
  if (denyReason !== null) {
218
272
  throw new Error(denyReason);
@@ -225,17 +279,22 @@ export const WebpressoHooksPlugin = async ({ $, directory }) => {
225
279
  }
226
280
  },
227
281
  "tool.execute.after": async (input, output) => {
228
- const payload = hookInputForTool(input?.tool, output?.args ?? input?.args ?? {}, directory);
282
+ const payload = hookInputForTool(
283
+ input?.tool,
284
+ output?.args ?? input?.args ?? {},
285
+ directory,
286
+ sessionIdFrom(input),
287
+ );
229
288
  if (payload === null) {
230
289
  return;
231
290
  }
232
291
 
233
292
  for (const hookName of POST_TOOL_HOOKS) {
234
- await runHook($, directory, hookName, payload);
293
+ await runHook(directory, hookName, payload);
235
294
  }
236
295
  },
237
296
  "experimental.session.compacting": async (_input, output) => {
238
- cachedSessionContexts = await collectSessionStartContext($, directory);
297
+ cachedSessionContexts = await collectSessionStartContext(directory);
239
298
  if (output && Array.isArray(output.context)) {
240
299
  for (const message of cachedSessionContexts) {
241
300
  output.context.push(message);
@@ -245,6 +304,9 @@ export const WebpressoHooksPlugin = async ({ $, directory }) => {
245
304
  };
246
305
  };
247
306
 
248
- const webpressoOpenCodePlugins = [WebpressoHooksPlugin];
307
+ const webpressoOpenCodePlugin = {
308
+ id: "@webpresso/plugin-opencode",
309
+ server: WebpressoHooksPlugin,
310
+ };
249
311
 
250
- export default webpressoOpenCodePlugins;
312
+ export default webpressoOpenCodePlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpresso/plugin-opencode",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "private": false,
5
5
  "description": "OpenCode plugin adapter package for Webpresso agent-kit.",
6
6
  "homepage": "https://github.com/webpresso/app#readme",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "index.js",
18
+ "bin/wp",
18
19
  "plugin-skill-ownership.json",
19
20
  "skills/**/SKILL.md",
20
21
  "skills/**/references/**",
@@ -29,13 +30,15 @@
29
30
  "sideEffects": false,
30
31
  "main": "./index.js",
31
32
  "exports": {
32
- ".": "./index.js"
33
+ ".": "./index.js",
34
+ "./server": "./index.js"
33
35
  },
34
36
  "publishConfig": {
35
37
  "access": "public",
36
38
  "registry": "https://registry.npmjs.org/"
37
39
  },
38
40
  "engines": {
39
- "node": ">=24"
41
+ "node": ">=24",
42
+ "opencode": ">=1.18.0"
40
43
  }
41
44
  }
@@ -2,14 +2,14 @@
2
2
  "schemaVersion": 1,
3
3
  "host": "opencode",
4
4
  "packageName": "@webpresso/plugin-opencode",
5
- "packageVersion": "0.0.11",
5
+ "packageVersion": "0.0.13",
6
6
  "runtimeDirs": [],
7
7
  "skills": {
8
8
  "ai-deslop": {
9
9
  "digest": "sha256:cf07df5835037f10401400e220c1c923f5325a6149d59c231ce316e10595fdfc"
10
10
  },
11
11
  "autopilot": {
12
- "digest": "sha256:fc0f77917bd7af3d6e99ce3a96067394308a3b8c94c416b5076167b19e00a754"
12
+ "digest": "sha256:86610df486eba87af41a5478efd6970312f24ac198a9351de9675649d5ccd75d"
13
13
  },
14
14
  "autoresearch": {
15
15
  "digest": "sha256:b4d51cd53beb4a3271827172dfbd0b1d27bd58761ca07a35d49a5d6324b87fdf"
@@ -21,10 +21,10 @@
21
21
  "digest": "sha256:21fd24862e7f7c8a1feadea6bc376492b136b99fc381cf1b8927c9db80615431"
22
22
  },
23
23
  "claude": {
24
- "digest": "sha256:e1e97e5419249fbb0cb8446e96fc8e80c165c149db563784570f96ad629ab54d"
24
+ "digest": "sha256:0205d693121b740b76041f1e25925a70d665774f7493070cdd6df40d0058ffe6"
25
25
  },
26
26
  "codex": {
27
- "digest": "sha256:1f93db92066e5b39ba334ec1d8c3af3de79dea13947dba97b8b7340302906d63"
27
+ "digest": "sha256:88837502404449b641b71dbc4f018c75dab6294ad98af29d41de50e98649ff0e"
28
28
  },
29
29
  "deep-interview": {
30
30
  "digest": "sha256:79fbf86a86024a311583a1500bb6960c82ad1383cc9f8ed3f04bbace41848ff7"
@@ -42,10 +42,10 @@
42
42
  "digest": "sha256:306355888da0827e3523022077f731845726bbaaf595ca12515cc91d8605b5e6"
43
43
  },
44
44
  "fix": {
45
- "digest": "sha256:38ddebb82a73e9e18e5b9909585a8477285387ac7bbad2574e3708362c4d0475"
45
+ "digest": "sha256:4804c546291316439aad3ea05fb7d3f043bc191abd8b9c180eb1d349f19f64cf"
46
46
  },
47
47
  "grok": {
48
- "digest": "sha256:1a6bf596fde9ce50a06ea767c06e65d4845c52d5af085644c958964385363b1e"
48
+ "digest": "sha256:e0093092dd84b7f8dc4aad09f7d1cd5cb104728194a20ef5ed37520e1f53403e"
49
49
  },
50
50
  "handoff": {
51
51
  "digest": "sha256:eb34b39fc6d416fe8203eea979b4b99ab3150c44da6dad3ca4afb8fe8fb75b85"
@@ -60,7 +60,7 @@
60
60
  "digest": "sha256:a80fbacc765e8b886437b9fdf7dd71e1ceb1b98b939078ec90fc88d06a8b4c69"
61
61
  },
62
62
  "opencode-go": {
63
- "digest": "sha256:c47cbe07f96510112d1bccae0a6319d3f74a5e2f3827a59865d45aea757c6dc6"
63
+ "digest": "sha256:3100cf1116402a63bd8bc34b1b544e68a90fe5fb899ebbbdfab73d28200ce379"
64
64
  },
65
65
  "plan-ceo-review": {
66
66
  "digest": "sha256:4d426158518dd71f8998770e8d4037bcbfa3c65d7265a93a4a78a9b015ba881e"
@@ -78,7 +78,7 @@
78
78
  "digest": "sha256:cf2c0743a81b0d8954417482e7812212a6a90e5046926c44cd82a6a309dfdc26"
79
79
  },
80
80
  "ralplan": {
81
- "digest": "sha256:47cf47910b50471eb92f9487a018311442c95b26907c5b21c3f5b87947bf7e7e"
81
+ "digest": "sha256:d3e8ab6285f9882d18ee9feabf51bb6dd9d81f8aaa243c050e9eeaa3436927af"
82
82
  },
83
83
  "team": {
84
84
  "digest": "sha256:c27107b46e0cd7b9c264a6f51f74f505920d63e7ee2a2d26d2f288592fd10bd2"
@@ -90,16 +90,16 @@
90
90
  "digest": "sha256:23fdad944b089de3cfcd13957a5ac10c37bd3d136b192029af69368bca1470db"
91
91
  },
92
92
  "tooling-friction": {
93
- "digest": "sha256:569904882036df09b52864411332ad5ea53e2914bc58d90030a0676f50bee228"
93
+ "digest": "sha256:7e1ff82147559624c39b1e4e1837d6bde0f102ecd65ec01358987d92ff42f7ad"
94
94
  },
95
95
  "tph": {
96
96
  "digest": "sha256:bed3684a850651534f315d355cdeae50d951c429817df0f105cccdb7f4e1561b"
97
97
  },
98
98
  "ultragoal": {
99
- "digest": "sha256:caa61789eb5ce01940cfaccf7e70e9290c1f318cb31e25d038eb45b26970c39c"
99
+ "digest": "sha256:31de0808e7e3458ebc3c59227353dfaf49ec8bd48429ae5e0b58b9e0597aa39f"
100
100
  },
101
101
  "verify": {
102
- "digest": "sha256:03bd3b922a24d19ccc0b942d4f56d97f09e24939220a69d8fad8ba1144085d12"
102
+ "digest": "sha256:20ae2b56b4041cbb2e7e0cfe535bf2d1f7d26579371ce2b5e0cd1c31b09e23fd"
103
103
  }
104
104
  }
105
105
  }
@@ -25,7 +25,7 @@ using `wp_ultragoal_run` / `wp ultragoal run` and the current handoff.
25
25
 
26
26
  ## Outside-voice (required)
27
27
 
28
- After each ultragoal **phase or plan-gate milestone**, run an **OpenCode Go** outside-voice review and pick the model by purpose. The purpose→family ordering lives in one place — the committed reviewer policy rendered into the `opencode-go` skill — so follow that skill and do not restate the ordering here. Resolve IDs from live `opencode models opencode-go`. `wp_review_gate` in the default authoritative mode is only for exact-version draft plan approval/auto-promotion; the same tool called with `mode: "advisory"` is for implementation/phase review and never mutates blueprint approval state on its own — a later authoritative call may promote a matching advisory approval without re-invoking a provider. Never self-approve. Full protocol: the `ultragoal` skill. Respect `review_budget` (default owned by `workflow-skills-routing.md`): one sequential path, no multi-host stampede.
28
+ After each ultragoal **phase or plan-gate milestone**, run an **OpenCode Go** outside-voice review and pick the model by purpose. The purpose→family ordering lives in one place — the committed reviewer policy rendered into the `opencode-go` skill — so follow that skill and do not restate the ordering here. Resolve IDs from live `opencode models opencode-go`. `wp_review_gate` has one mode: purpose plan for exact-version draft plan approval/auto-promotion, purpose delivery for implementation/phase review; each new provider verdict is a formal round and an approval is authority immediately. Never self-approve. Full protocol: the `ultragoal` skill. Respect `review_budget` (default owned by `workflow-skills-routing.md`): one sequential path, no multi-host stampede.
29
29
 
30
30
  ## Vague gate
31
31
 
@@ -16,7 +16,7 @@ until independently verified.
16
16
  - Never invoke a provider command, the legacy review CLI, or a removed review-run surface.
17
17
  - If the review MCP is unavailable, stop and report it unavailable. There is no CLI fallback.
18
18
 
19
- ## Advisory call
19
+ ## Gate call
20
20
 
21
21
  ```jsonc
22
22
  {
@@ -27,11 +27,14 @@ until independently verified.
27
27
  "authority_ref": "<full-head-sha>",
28
28
  "provider": "claude",
29
29
  "repository_access": "none",
30
- "mode": "advisory",
31
30
  }
32
31
  ```
33
32
 
34
- Use authoritative mode only when formal plan or delivery approval is requested.
33
+ Each newly executed provider review that reaches a verdict is one formal round of the
34
+ slug's budget, and its approval is authority. An identical-subject call replays the recorded
35
+ verdict with no new review and no new round. A partial approval, withheld because adjudication
36
+ is incomplete (not every prior finding was briefed, or a briefed prior claim was marked
37
+ still-open without a matching finding), grants no authority and spends no round.
35
38
 
36
39
  ## Model policy
37
40
 
@@ -16,7 +16,7 @@ until independently verified.
16
16
  - Never invoke a provider command, the legacy review CLI, or a removed review-run surface.
17
17
  - If the review MCP is unavailable, stop and report it unavailable. There is no CLI fallback.
18
18
 
19
- ## Advisory call
19
+ ## Gate call
20
20
 
21
21
  ```jsonc
22
22
  {
@@ -27,11 +27,14 @@ until independently verified.
27
27
  "authority_ref": "<full-head-sha>",
28
28
  "provider": "codex",
29
29
  "repository_access": "none",
30
- "mode": "advisory",
31
30
  }
32
31
  ```
33
32
 
34
- Use authoritative mode only when formal plan or delivery approval is requested.
33
+ Each newly executed provider review that reaches a verdict is one formal round of the
34
+ slug's budget, and its approval is authority. An identical-subject call replays the recorded
35
+ verdict with no new review and no new round. A partial approval, withheld because adjudication
36
+ is incomplete (not every prior finding was briefed, or a briefed prior claim was marked
37
+ still-open without a matching finding), grants no authority and spends no round.
35
38
 
36
39
  ## Model policy
37
40
 
@@ -61,7 +61,7 @@ the blast radius is broader than a local fix.
61
61
  - **Tooling / harness findings** ("fix inefficiency you come across", MCP vs shell,
62
62
  hook thrash) honor the same budget. Excess → record via the `tech-debt` skill
63
63
  (or backlog handoff); do not expand the current epic unbounded.
64
- - Prefer MCP `wp_*` and session-memory tools over `bin/wp` / raw shell for
64
+ - Prefer MCP `wp_*` and session-memory tools over the `wp` CLI / raw shell for
65
65
  quality and discovery paths (see tooling map).
66
66
 
67
67
  ## Protocol
@@ -156,6 +156,24 @@ Escalate to `/verify <target>` (local) or `/verify <target> --merge-ready` when
156
156
  If the repaired behavior is already green but the diff still needs a bounded
157
157
  cleanup pass, use `ai-deslop` before the final verify handoff.
158
158
 
159
+ ### Tool recovery and delivery intent
160
+
161
+ - Discover the managed owner's current identity with `wp_blueprint_projects`
162
+ using its absolute `cwd`; pass the returned project ID and matching checkout
163
+ to session tools. A blueprint ID is not a project ID.
164
+ - If quality tooling refuses stale or cross-checkout execution, follow its
165
+ checkout-local recovery. Attach the escape to the actual command:
166
+ `cd <owner> && WP_MCP_UNAVAILABLE=1 wp test --files <test>`.
167
+ Attaching it to `cd` does not set it for `wp`.
168
+ - For external tools whose schema requires exactly one of command or WebSocket
169
+ input, omit the unused option entirely; an empty object still supplies it.
170
+ - When the request includes merge/landing, continue through verification,
171
+ exact-head delivery, PR merge, and managed-owner cleanup. A handoff or
172
+ continuation summary does not satisfy that request. If a required gate is
173
+ blocked, report its concrete evidence and remaining action; preserve the
174
+ persisted completion requirements. Run additional named stages only when
175
+ the user's request actually includes them.
176
+
159
177
  ### Step 6 — Report with evidence
160
178
 
161
179
  Keep it short:
@@ -16,7 +16,7 @@ until independently verified.
16
16
  - Never invoke a provider command, the legacy review CLI, or a removed review-run surface.
17
17
  - If the review MCP is unavailable, stop and report it unavailable. There is no CLI fallback.
18
18
 
19
- ## Advisory call
19
+ ## Gate call
20
20
 
21
21
  ```jsonc
22
22
  {
@@ -27,11 +27,14 @@ until independently verified.
27
27
  "authority_ref": "<full-head-sha>",
28
28
  "provider": "grok",
29
29
  "repository_access": "none",
30
- "mode": "advisory",
31
30
  }
32
31
  ```
33
32
 
34
- Use authoritative mode only when formal plan or delivery approval is requested.
33
+ Each newly executed provider review that reaches a verdict is one formal round of the
34
+ slug's budget, and its approval is authority. An identical-subject call replays the recorded
35
+ verdict with no new review and no new round. A partial approval, withheld because adjudication
36
+ is incomplete (not every prior finding was briefed, or a briefed prior claim was marked
37
+ still-open without a matching finding), grants no authority and spends no round.
35
38
 
36
39
  ## Model policy
37
40
 
@@ -16,7 +16,7 @@ until independently verified.
16
16
  - Never invoke a provider command, the legacy review CLI, or a removed review-run surface.
17
17
  - If the review MCP is unavailable, stop and report it unavailable. There is no CLI fallback.
18
18
 
19
- ## Advisory call
19
+ ## Gate call
20
20
 
21
21
  ```jsonc
22
22
  {
@@ -26,20 +26,24 @@ until independently verified.
26
26
  "base_ref": "<full-base-sha>",
27
27
  "authority_ref": "<full-head-sha>",
28
28
  "provider": "opencode",
29
- "model": "opencode-go/deepseek-v4-pro",
29
+ "model": "opencode-go/deepseek-v4.1-flash",
30
30
  "repository_access": "none",
31
- "mode": "advisory",
32
31
  }
33
32
  ```
34
33
 
35
- Use authoritative mode only when formal plan or delivery approval is requested.
34
+ Each newly executed provider review that reaches a verdict is one formal round of the
35
+ slug's budget, and its approval is authority. An identical-subject call replays the recorded
36
+ verdict with no new review and no new round. A partial approval, withheld because adjudication
37
+ is incomplete (not every prior finding was briefed, or a briefed prior claim was marked
38
+ still-open without a matching finding), grants no authority and spends no round.
36
39
 
37
40
  ## Model policy
38
41
 
39
- Default exact model: `opencode-go/deepseek-v4-pro`.
40
- Committed exact options: `opencode-go/deepseek-v4-pro`, `opencode-go/deepseek-v4-flash`, `opencode-go/kimi-k2.7-code`, `opencode-go/glm-5.3`, `opencode-go/minimax-m3`, `opencode-go/mimo-v2.5-pro`, `opencode-go/qwen3.8-max`, `opencode-go/mimo-v2.5`, `opencode-go/qwen3.7-plus`, `opencode-go/qwen3.7-max`, `opencode-go/qwen3.6-plus`, `opencode-go/gpt-5.6-luna`, `opencode-go/minimax-m2.7`, `opencode-go/kimi-k3`, `opencode-go/glm-5.2`, `opencode-go/kimi-k2.6`, `opencode-go/hy3`, `opencode-go/glm-5.1`, `opencode-go/grok-4.5`.
42
+ Default exact model: `opencode-go/deepseek-v4.1-flash`.
43
+ Committed exact options: `opencode-go/deepseek-v4.1-flash`, `opencode-go/glm-5.3-flash`, `opencode-go/deepseek-v4-pro`, `opencode-go/kimi-k2.7-code`, `opencode-go/qwen3.8-max`, `opencode-go/minimax-m3`, `opencode-go/muse-spark-1.3-contributor`, `opencode-go/mimo-v2.5-pro`, `opencode-go/muse-spark-1.2-contributor`, `opencode-go/union-alpha`, `opencode-go/qwen3.8-flash`, `opencode-go/mimo-v2.5`, `opencode-go/qwen3.7-plus`, `opencode-go/qwen3.7-max`, `opencode-go/qwen3.6-plus`, `opencode-go/minimax-m2.7`, `opencode-go/gpt-5.6-luna`, `opencode-go/longcat-2.0`, `opencode-go/kimi-k3`, `opencode-go/deepseek-v4-flash`, `opencode-go/deepseek-v4-flash-vision-exp`, `opencode-go/kimi-k2.6`, `opencode-go/hy4-preview`, `opencode-go/hy3`, `opencode-go/grok-4.6`, `opencode-go/glm-5.3`, `opencode-go/glm-5.2`, `opencode-go/glm-5.1`.
41
44
  Use a named exact option when requested; otherwise use the default. Provider catalog discovery
42
- belongs to the snapshot drift workflow, never to a skill invocation.
45
+ belongs to the snapshot drift workflow, never to a skill invocation; reading the box-local
46
+ credential store at gate time is a local file read, not catalog discovery.
43
47
 
44
48
  ## Review quality
45
49
 
@@ -40,11 +40,25 @@ Use when the user invokes `$ralplan`, asks for RALPLAN, or wants consensus plann
40
40
  ## Iron laws
41
41
 
42
42
  1. **Blueprint is the plan.** Freeform notes are not durable consensus. Create or bind a blueprint before reviews.
43
- 2. **Ground before drafting.** Code dive (+ bounded external research when needed) happens **before** the blueprint draft is treated as serious.
44
- 3. **Reviews before refine.** Run risk-matched `plan-*` skills on the blueprint, apply findings, **then** `/plan-refine`.
45
- 4. **No implementation.** Do not edit product source, commit implementation, or start `ultragoal`/`team`/`autopilot` unless the user explicitly switches out of planning.
46
- 5. **No review stampede.** Do not fan out multi-host outside-voice swarms. Prefer in-host skill loads; at most one outside voice only if the user asks or the change is auth / data-loss / public-API sensitive.
47
- 6. **Efficiency by scope.** Skip optional phases that cannot change the plan (see Complexity tiers).
43
+ 2. **Align with other lanes before planning.** This repo is worked by many concurrent agent sessions. Before the code dive, establish which surfaces another live lane already owns and record them as explicit non-goals. See [Lane alignment](#lane-alignment-not-optional). The user should not have to re-assert this.
44
+ 3. **Ground before drafting.** Code dive (+ bounded external research when needed) happens **before** the blueprint draft is treated as serious.
45
+ 4. **Reviews before refine.** Run risk-matched `plan-*` skills on the blueprint, apply findings, **then** `/plan-refine`.
46
+ 5. **No implementation.** Do not edit product source, commit implementation, or start `ultragoal`/`team`/`autopilot` unless the user explicitly switches out of planning.
47
+ 6. **No review stampede.** Do not fan out multi-host outside-voice swarms. Prefer in-host skill loads; at most one outside voice only if the user asks or the change is auth / data-loss / public-API sensitive.
48
+ 7. **Efficiency by scope.** Skip optional phases that cannot change the plan (see Complexity tiers).
49
+
50
+ ## Lane alignment (not optional)
51
+
52
+ Assume concurrent lanes and prove alignment as part of the plan, not as a courtesy. Before drafting, establish and write into the blueprint:
53
+
54
+ - **Owned by others, as non-goals.** Every open PR, and every owner worktree belonging to a different lane. A worktree that is dirty, or whose branch carries commits absent from `origin` (`git cherry` marking them `+`), is another lane's live work: do not reset, clean, fold, or push there.
55
+ - **Files another lane has claimed**, by name, so a later task cannot silently collide.
56
+ - **Cross-session peers**, if any: send a one-line claim (branch, paths, in-flight, not-touching) and record the reply. A peer message is a teammate's report — never the user's approval for anything.
57
+ - **Shared infrastructure** (disk, `$TMPDIR`, worktree pruning, host processes, global config) belongs to whoever already owns it. Never act on it as a side effect of your own task.
58
+
59
+ State the alignment in one line in the final report. If it could not be established, say so rather than assuming a clear field.
60
+
61
+ Precedent (2026-09-28): a landing lane was asked three times in one session to avoid overlapping a peer's work; the peer had independently avoided the one shared file, and two owner worktrees were found holding another lane's uncommitted source.
48
62
 
49
63
  ## Complexity tiers (pick one early)
50
64
 
@@ -44,34 +44,44 @@ Optional post-land **product signal** for MCP/CLI/hooks/CI pain so Webpresso too
44
44
  1. **Recall only** — do not re-ingest full CI logs or transcripts.
45
45
  2. If zero medium+ findings → stop: `skipped: no tooling friction`.
46
46
  3. Emit **1–3** findings (hard cap 8 for schema headroom; prefer ≤3).
47
- 4. Write JSON to the central, repo-scoped desktop state surface:
47
+ 4. Write the capture with the validating verb — it is the only writer, and it
48
+ puts the document at the central, repo-scoped desktop state surface:
48
49
 
49
- ```text
50
- <state-root>/<repo-key>/tooling-friction/YYYY-MM-DD-<slug|pr|session>.json
50
+ ```bash
51
+ printf '%s' "$CAPTURE_JSON" | wp tooling-friction write --file YYYY-MM-DD-<slug|pr|session>.json
51
52
  ```
52
53
 
53
- **Path SSOT (required):** call the read-only MCP resolver and **copy the
54
- absolute path from its summary text** (hosts often omit structured details):
54
+ The verb parses the document through the shared schema helper **before** it
55
+ touches disk, so a capture that violates v1 is refused (non-zero exit,
56
+ naming every failing field path) and nothing is written. It prints the
57
+ absolute path it wrote, creates parent dirs, and refuses to overwrite an
58
+ existing capture — pick a unique session/HHMM suffix instead.
59
+
60
+ **Path SSOT (required):** that target is the same location the read-only MCP
61
+ resolver reports, so call the resolver and **copy the absolute path from its
62
+ summary text** (hosts often omit structured details), then confirm the
63
+ verb's printed path matches:
55
64
 
56
65
  ```text
57
66
  wp_tooling_friction_path({"file":"YYYY-MM-DD-<slug|pr|session>.json"})
58
67
  → summary: tooling-friction path: <absolute-path>
59
68
  ```
60
69
 
61
- Write only to that absolute path. Do **not** construct a path by hand.
70
+ Never write the capture to a hand-constructed path — not to a fleet folder
71
+ slug (`github.com-org-repo-*`), not to worktree-keyed desktop-state, not
72
+ under `Application Support` or `~/.webpresso`.
62
73
 
63
74
  - `repo-key` is a **16-hex** `repoIdentityV1(git-common-dir)` segment from
64
75
  `repoIdentityV1` under the pure preferred state root — **not** a fleet worktree folder slug such
65
76
  as `github.com-org-repo-<hash>` under `~/.webpresso/worktrees/repos/`.
66
77
  - Scope is always **repo** (shared by every worktree of that git common
67
78
  dir). Never use a worktree-keyed desktop-state surface for captures.
68
- - Human CLI equivalent: `wp tooling-friction path --file <file>` (prints
69
- the same absolute path on stdout).
79
+ - Human CLI surface: `wp tooling-friction path --file <file>` prints that
80
+ absolute path; `wp tooling-friction write --file <file>` writes it.
70
81
 
71
- Create parent dirs as needed. If the returned path already exists, choose a
72
- unique session/HHMM suffix rather than overwriting it. If the surface cannot
73
- be resolved or written, report `tooling-friction: skipped: no state surface`
74
- and stop; never fall back to a worktree or `.agent/` path.
82
+ If the surface cannot be resolved or written, report
83
+ `tooling-friction: skipped: no state surface` and stop; never fall back to a
84
+ worktree or `.agent/` path.
75
85
 
76
86
  5. Optional `--promote` or severity `high`/`critical`:
77
87
  `wp tech-debt new "<short title>" --severity <s> --category tooling --status accepted`
@@ -195,10 +195,10 @@ Fallback to `wp …` CLI only when the matching MCP tool is unavailable. Prefer
195
195
  the user sets N (the default value lives in `workflow-skills-routing.md`).
196
196
  Controller validation evidence is **machine-qa (prior QA phase) + N outside voices**
197
197
  (default N=1), not two outside model reviewers at budget 1.
198
- Use `wp_review_gate` in the default authoritative mode only for exact-version
199
- draft plan approval/auto-promotion. Use the same tool with `mode: "advisory"`
200
- for implementation/phase outside voice; it never mutates blueprint approval
201
- state on its own. A CLI delivery gate is explicit MCP-unavailable
198
+ `wp_review_gate` has one mode: purpose plan for exact-version draft plan
199
+ approval/auto-promotion, purpose delivery for implementation/phase outside
200
+ voice. Each new provider verdict is a formal round of the slug's budget and
201
+ an approval is authority immediately. A CLI delivery gate is explicit MCP-unavailable
202
202
  compatibility only, not this MCP contract. Use `wp_pr_*` for PR babysit — no
203
203
  multi-sleep poll loops; no parallel multi-reviewer stampede.
204
204
  8. Stop on `complete`, `cancelled`, blocked guard exhaustion, or `WP_ULTRAGOAL_DISABLE=1`.
@@ -217,16 +217,15 @@ primary checkout.
217
217
 
218
218
  ## Outside-voice review (required for autopilot)
219
219
 
220
- Autopilot and agent-driven ultragoal loops **must** request an **OpenCode Go** outside-voice review after each **goal/phase milestone**, and must pick the model by purpose. Use **`wp_review_gate`** in the default authoritative mode only for exact-version draft plan approval/auto-promotion. Use a single-shot **`wp_review_gate`** call with **`mode: "advisory"`** for implementation/phase outside voice; it never mutates blueprint approval state on its own — a later authoritative call may promote a matching advisory approval without re-invoking a provider. CLI delivery gates are explicit MCP-unavailable compatibility only, not this MCP contract.
220
+ Autopilot and agent-driven ultragoal loops **must** request an **OpenCode Go** outside-voice review after each **goal/phase milestone**, and must pick the model by purpose. Use **`wp_review_gate`** purpose plan for exact-version draft plan approval/auto-promotion and one single-shot **`wp_review_gate`** purpose delivery call for implementation/phase outside voice. The gate has one mode: each newly executed provider review that reaches a verdict is a formal round of the slug's budget, an approval is authority immediately, and an identical-subject call replays the recorded verdict without a new round. CLI delivery gates are explicit MCP-unavailable compatibility only, not this MCP contract.
221
221
 
222
222
  Count each milestone review against `review_budget` for merge-ready claims: **exactly one sequential** reviewer path unless the user sets N; the default value is owned by `workflow-skills-routing.md`. Prefer purpose-matched OpenCode Go first; do not fan out multi-host review swarms.
223
223
 
224
224
  ### Milestones that require a review
225
225
 
226
- Run `wp_review_gate` (default authoritative mode) only for the plan approval
227
- milestone below. Run one single-shot `wp_review_gate` call with `mode:
228
- "advisory"` for implementation/phase milestones; it is advisory and does not
229
- promote or otherwise mutate blueprint approval state on its own.
226
+ Run `wp_review_gate` purpose plan for the plan approval milestone below and one
227
+ single-shot `wp_review_gate` purpose delivery call for implementation/phase
228
+ milestones. Each new provider verdict is one formal round of the slug's budget.
230
229
 
231
230
  | Milestone | When |
232
231
  | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
@@ -41,18 +41,18 @@ the preferred skill (`claude` / `codex` / `grok` / one OpenCode Go), then stops.
41
41
 
42
42
  ## Tooling map
43
43
 
44
- | Gate | Prefer |
45
- | ------------------------- | ------------------------------------------------------------------------------------------------------- |
46
- | Lint / types / tests / QA | `wp_lint`, `wp_typecheck`, `wp_test`, `wp_qa` |
47
- | Format | `wp_format` |
48
- | Audits | `wp_audit`, `wp_audits` |
49
- | Blueprint | blueprint MCP tools + `wp_audit` |
50
- | PR / CI wait | `wp_pr_status`, `wp_pr_wait` (not multi-sleep loops) |
51
- | Release | `wp_release_progress` for live Release phases; `wp_release_readiness` for package gates |
52
- | Large output | `wp_session_execute` / `batch_execute` / `execute_file` |
53
- | Continuity | `wp_session_capture` for verify summary; avoid paste-back walls |
54
- | E2E / act | `wp_e2e`, `wp_ci_act` when the change requires them |
55
- | Outside authority | `wp_review_gate` (authoritative) for plans; `wp_review_gate` with `mode: "advisory"` for implementation |
44
+ | Gate | Prefer |
45
+ | ------------------------- | ---------------------------------------------------------------------------------------- |
46
+ | Lint / types / tests / QA | `wp_lint`, `wp_typecheck`, `wp_test`, `wp_qa` |
47
+ | Format | `wp_format` |
48
+ | Audits | `wp_audit`, `wp_audits` |
49
+ | Blueprint | blueprint MCP tools + `wp_audit` |
50
+ | PR / CI wait | `wp_pr_status`, `wp_pr_wait` (not multi-sleep loops) |
51
+ | Release | `wp_release_progress` for live Release phases; `wp_release_readiness` for package gates |
52
+ | Large output | `wp_session_execute` / `batch_execute` / `execute_file` |
53
+ | Continuity | `wp_session_capture` for verify summary; avoid paste-back walls |
54
+ | E2E / act | `wp_e2e`, `wp_ci_act` when the change requires them |
55
+ | Outside authority | `wp_review_gate` (purpose plan or delivery); each new provider verdict is a formal round |
56
56
 
57
57
  ## Iron law — evidence before claims
58
58
 
@@ -245,7 +245,7 @@ in outside-voice usage — stop and fix the gate, do not multiply reviewers.
245
245
  **Skip this entire phase** for default `/verify` and `/verify --full`. Local
246
246
  `done` claims do not require outside voice.
247
247
 
248
- This phase implements **land-policy M7** (delivery vs advisory). Full land
248
+ This phase implements **land-policy M7** (delivery vs outside voice). Full land
249
249
  language and dual-label contract:
250
250
  [`catalog/agent/rules/land-policy.md`](../../rules/land-policy.md).
251
251
 
@@ -269,34 +269,37 @@ When classification is **sensitive**:
269
269
 
270
270
  - Require delivery review via `risk-review-coverage` / `wp_review_gate`
271
271
  (purpose delivery) / qualifying lane approvals for the current
272
- review subject until `policySatisfied` (**approval** or valid same-subject
273
- **maintainer-override**). A formal **reject** is a terminal automated-review
272
+ review subject until `policySatisfied` (**approval**). A formal **reject** is a terminal automated-review
274
273
  outcome but does **not** satisfy land policy: Product status stays
275
- `incomplete`, Process status stays `sensitive-blocked` until approval or
276
- override. Tech-debt is **not** a substitute for delivery approval.
274
+ `incomplete`, Process status stays `sensitive-blocked` until approval.
275
+ Tech-debt is **not** a substitute for delivery approval.
277
276
  - Launch **exactly one at a time** `wp_review_gate` delivery attempt with
278
277
  `--base-ref <base-sha>` and `--authority-ref <head-sha>`. Do not substitute
279
278
  `HEAD` for the authority SHA, and do not relaunch while the same subject has
280
279
  an in-progress gate.
281
- - On formal **reject**: surface the gate `next_action` (`report_delivery_reject`)
282
- / reject projection to the user; free-triage against blueprint non-goals;
283
- address the findings, then re-review only a changed exact subject. The shared
284
- gate permits five distinct rejected delivery subjects by default. Once that
285
- budget is exhausted, **do not** set `scope_continue=true` without explicit
286
- user authorization; agents must not invent the grant. For plan or delivery,
287
- only an operator may request an absolute finite same-slug ceiling through MCP
288
- `scope_continue_limit` (10..32); the field still requires
289
- `scope_continue=true`. MCP wins over `WP_REVIEW_SCOPE_CONTINUE_LIMIT`; when
290
- neither is present, the plan/delivery defaults are 16/10. Machine enforcement
291
- does not link successor slugs; creating one to launder or bypass the budget
292
- remains procedurally forbidden, as do resets and retry schemes.
280
+ - On formal **reject**: record the gate `next_action` (`report_delivery_reject`)
281
+ / reject projection, triage against blueprint non-goals, address the findings,
282
+ then re-review only a changed exact subject. The gate counts formal verdicts
283
+ per slug and purpose, starting with five rounds.
284
+ After all allowance is spent, a corrected subject may open one
285
+ extra lane on an explicitly selected provider and model, including one that
286
+ already reviewed the lineage. The gate records that grant, adds five formal
287
+ rounds, and permits at most five distinct provider attempts in the new lane
288
+ that reach a verdict or an unproven outcome; provider infrastructure failures
289
+ spend no attempt but are bounded separately at five per lane. The provider and model remain pinned.
290
+ Once that lane is spent, this blueprint's review lineage is terminal: no
291
+ gate call on its slug can reopen it, whatever the subject. Re-plan the
292
+ narrowed scope as a new blueprint with its own plan review.
293
+ Machine enforcement does not link successor slugs; creating one to launder
294
+ or bypass the budget remains procedurally forbidden, as do resets and retry
295
+ schemes.
293
296
  - `Review-skip` is **FORBIDDEN** (`sensitive-review-skip-forbidden`).
294
297
  - Completed owner blueprint must be present in the PR
295
298
  (`sensitive-no-completed-owner` otherwise).
296
299
  - Process label when blocked: `sensitive-blocked`. When delivery lane
297
300
  approves: `delivery-approved`.
298
301
 
299
- ### 3. Standard → skip or one advisory outside voice
302
+ ### 3. Standard → skip or one outside voice
300
303
 
301
304
  When classification is **standard**:
302
305
 
@@ -308,8 +311,8 @@ When classification is **standard**:
308
311
 
309
312
  Must be start-of-line. Process label: `standard-skip`.
310
313
 
311
- - Otherwise obtain **exactly one sequential** advisory outside voice
312
- (`wp_review_gate` with `mode: "advisory"`, or host skill). Process label: `outside-approved`.
314
+ - Otherwise obtain **exactly one sequential** outside voice: one
315
+ `wp_review_gate` delivery gate (or host skill). Process label: `outside-approved`.
313
316
  - At most **one OpenCode Go reviewer** counts unless the user asks for more.
314
317
  - Invoke the selected reviewer **once**. Do not probe, retry, or rotate
315
318
  providers automatically, and **never** fan out parallel multi-host review
@@ -345,26 +348,20 @@ Never invent a land-now skip. See `land-policy` for the full table.
345
348
  observed; wait timeout/abort/in-progress/not-found are not reviewer verdicts.
346
349
  `not_found` means there is no active or terminal gate for that subject; refresh
347
350
  state and start the gate again instead of continuing to wait.
348
- - Implementation / delivery approval: for sensitive PRs use `wp_review_gate`
349
- purpose delivery, default authoritative mode, on the immutable subject; for
350
- standard advisory use the same `wp_review_gate` call with `mode: "advisory"`
351
- exactly once with the selected provider. Do not run a separate provider
352
- probe. A later authoritative call on the same subject may promote a
353
- matching complete advisory approval to authority with zero provider
354
- invocation, unless a completed reject already exists for that content at
355
- either stage.
356
- - `advisory-exhausted` is a terminal outcome with no ledger event and no
357
- verdict — treat it as terminal-without-authority. It means this exact
358
- digest already used its advisory attempt budget; do not retry the same
359
- content or report it as a failure. Change the reviewed content (which mints
360
- a new digest) or spend real authoritative budget instead. It is always
361
- returned synchronously; no `wp_review_gate_wait` poll is ever needed or
362
- issued for it.
363
- - An advisory gate and an authoritative gate on the same subject cannot run
364
- concurrently — unlike the removed `wp_review_run`, an advisory
365
- `wp_review_gate` call claims the same active marker an authoritative call
366
- does. If a call reports marker contention, serialize the two calls rather
367
- than treating contention as an error.
351
+ - Implementation / delivery approval: use `wp_review_gate` purpose delivery
352
+ on the immutable subject, exactly once with the selected provider, for
353
+ sensitive and standard PRs alike. The gate has one mode: each newly executed
354
+ provider review that reaches a verdict is one formal round of the slug's
355
+ budget, an approval is authority immediately, and an identical-subject call
356
+ replays the recorded verdict without a new round (an approval withheld for
357
+ incomplete adjudication of prior findings — not all briefed, or a briefed
358
+ claim still open without a matching finding — grants no authority and
359
+ spends none). Do not run a separate
360
+ provider probe.
361
+ - Two gates on the same subject cannot run concurrently — every
362
+ `wp_review_gate` call claims the subject's active marker. If a call reports
363
+ marker contention, serialize the two calls rather than treating contention
364
+ as an error.
368
365
  - Each gate reviewer must return exactly one schema-valid JSON review object;
369
366
  never infer a verdict from Markdown. The gate derives approval from typed
370
367
  findings: blockers reject, nits approve with nits, and no findings approve.