@webpresso/agent-kit 3.1.25 → 3.1.28

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.
Files changed (64) hide show
  1. package/catalog/AGENTS.md.tpl +6 -10
  2. package/catalog/agent/commands/verify.md +2 -2
  3. package/catalog/agent/rules/changeset-release.md +7 -1
  4. package/catalog/agent/rules/workflow-skills-routing.md +6 -3
  5. package/catalog/agent/skills/autopilot/SKILL.md +48 -11
  6. package/catalog/agent/skills/claude/SKILL.md +7 -0
  7. package/catalog/agent/skills/codex/SKILL.md +7 -0
  8. package/catalog/agent/skills/fix/SKILL.md +33 -8
  9. package/catalog/agent/skills/grok/SKILL.md +7 -0
  10. package/catalog/agent/skills/investigate/SKILL.md +12 -1
  11. package/catalog/agent/skills/pll/SKILL.md +53 -48
  12. package/catalog/agent/skills/team/SKILL.md +26 -9
  13. package/catalog/agent/skills/ultragoal/SKILL.md +147 -19
  14. package/catalog/agent/skills/verify/SKILL.md +71 -51
  15. package/dist/esm/audit/blueprint-lifecycle-sql.d.ts +8 -0
  16. package/dist/esm/audit/blueprint-lifecycle-sql.js +25 -1
  17. package/dist/esm/ci/native-session-memory-cache.d.ts +9 -1
  18. package/dist/esm/ci/native-session-memory-cache.js +9 -1
  19. package/dist/esm/ci/vitest-ci-shards.d.ts +6 -4
  20. package/dist/esm/ci/vitest-ci-shards.js +6 -4
  21. package/dist/esm/cli/auto-update/guard-tombstone.d.ts +25 -0
  22. package/dist/esm/cli/auto-update/guard-tombstone.js +67 -0
  23. package/dist/esm/cli/auto-update/installer.d.ts +4 -17
  24. package/dist/esm/cli/auto-update/installer.js +6 -57
  25. package/dist/esm/cli/auto-update/self-invocation.d.ts +37 -0
  26. package/dist/esm/cli/auto-update/self-invocation.js +62 -0
  27. package/dist/esm/cli/commands/audit.js +1 -0
  28. package/dist/esm/cli/commands/dash/index.js +3 -3
  29. package/dist/esm/cli/commands/dash/plan-once-attention.d.ts +32 -0
  30. package/dist/esm/cli/commands/dash/plan-once-attention.js +50 -0
  31. package/dist/esm/cli/commands/dash/plan-once-schema.d.ts +73 -0
  32. package/dist/esm/cli/commands/dash/plan-once-schema.js +119 -0
  33. package/dist/esm/cli/commands/dash/plan-once.d.ts +122 -0
  34. package/dist/esm/cli/commands/dash/plan-once.js +224 -0
  35. package/dist/esm/cli/commands/dash/tui/ipc-protocol.d.ts +87 -0
  36. package/dist/esm/cli/commands/dash/tui/ipc-protocol.js +128 -0
  37. package/dist/esm/cli/commands/dash/tui/native-host.d.ts +34 -0
  38. package/dist/esm/cli/commands/dash/tui/native-host.js +256 -0
  39. package/dist/esm/cli/commands/dash/tui/native-path.d.ts +11 -0
  40. package/dist/esm/cli/commands/dash/tui/native-path.js +41 -0
  41. package/dist/esm/cli/commands/dash/tui/runtime.js +72 -0
  42. package/dist/esm/cli/commands/init/scaffolders/agent-kit-global/index.js +1 -1
  43. package/dist/esm/cli/commands/package-manager.js +2 -1
  44. package/dist/esm/cli/commands/quality-log-store.d.ts +6 -0
  45. package/dist/esm/cli/commands/quality-log-store.js +55 -15
  46. package/dist/esm/cli/commands/self-install-guard.d.ts +14 -16
  47. package/dist/esm/cli/commands/self-install-guard.js +17 -23
  48. package/dist/esm/hooks/guard-switch/index.js +8 -4
  49. package/dist/esm/hooks/post-tool/lint-after-edit.js +6 -4
  50. package/dist/esm/hooks/precompact/index.js +5 -3
  51. package/dist/esm/hooks/sessionstart/index.d.ts +10 -0
  52. package/dist/esm/hooks/sessionstart/index.js +61 -4
  53. package/dist/esm/hooks/shared/types.d.ts +11 -0
  54. package/dist/esm/hooks/shared/types.js +21 -0
  55. package/dist/esm/paths/state-root.js +36 -3
  56. package/dist/esm/session-memory/current-session.js +2 -0
  57. package/dist/esm/session-memory/native-runtime.js +5 -3
  58. package/dist/esm/session-memory/native-warm-markers.d.ts +18 -0
  59. package/dist/esm/session-memory/native-warm-markers.js +22 -0
  60. package/dist/esm/session-memory/sync/types.d.ts +4 -0
  61. package/dist/esm/session-memory/types.d.ts +3 -1
  62. package/dist/esm/session-memory/types.js +2 -0
  63. package/dist/esm/ultragoal/runtime.js +44 -2
  64. package/package.json +15 -12
@@ -0,0 +1,256 @@
1
+ // Domain host for the native Ratatui shell: maps AppViewModel ↔ IPC and keys → reduceTuiKey.
2
+ import { spawn } from "node:child_process";
3
+ import { emitDashE2eState } from "#cli/commands/dash/e2e-state.js";
4
+ import { buildRows } from "#cli/commands/dash/menu-state.js";
5
+ import { filterPaletteCommands, groupPaletteCommands } from "#cli/commands/dash/tui/palette.js";
6
+ import { reduceTuiKey } from "#cli/commands/dash/tui/state.js";
7
+ import { decodeIpcFrames, encodeIpcFrame, parseClientMessage, } from "#cli/commands/dash/tui/ipc-protocol.js";
8
+ import { sanitizeTuiText } from "#cli/commands/dash/tui/sanitize.js";
9
+ function hostAvailabilityNote(agentKey, availability) {
10
+ const row = availability.find((item) => item.id === agentKey);
11
+ if (!row || row.available)
12
+ return null;
13
+ return row.reason ?? "unavailable";
14
+ }
15
+ export function projectNativeViewModel(state, input) {
16
+ const rows = buildRows(state.agents, state.menu.expandedAgent);
17
+ const menuLines = rows.map((row) => {
18
+ const mark = row.key === state.menu.selectedKey ? "▸" : " ";
19
+ const note = row.kind === "agent" ? hostAvailabilityNote(row.agentKey, state.hostAvailability) : null;
20
+ const label = note ? `${row.label} · ${note}` : row.label;
21
+ return sanitizeTuiText(`${mark} ${label}`);
22
+ });
23
+ let overlayKind = state.overlay.kind;
24
+ let overlayLines = [];
25
+ if (state.overlay.kind === "palette") {
26
+ const matches = filterPaletteCommands(state.overlay.query);
27
+ const groups = groupPaletteCommands(matches);
28
+ overlayLines = [sanitizeTuiText(`query: ${state.overlay.query || "…"}`)];
29
+ for (const group of groups) {
30
+ overlayLines.push(sanitizeTuiText(`— ${group.category}`));
31
+ for (const [index, command] of group.commands.entries()) {
32
+ // Approximate global cursor: mark the selected match row.
33
+ const globalIndex = matches.indexOf(command);
34
+ const mark = globalIndex === state.overlay.cursor ? "▸" : " ";
35
+ overlayLines.push(sanitizeTuiText(`${mark} ${command.title} · ${command.keys}`));
36
+ void index;
37
+ }
38
+ }
39
+ if (matches.length === 0) {
40
+ overlayLines.push("(no matches)");
41
+ }
42
+ }
43
+ else if (state.overlay.kind === "help") {
44
+ overlayLines = [
45
+ "^p palette",
46
+ "^o mode",
47
+ "^s sessions",
48
+ "^x help",
49
+ "j/k move · h/l fold · g/G jump",
50
+ "q quit",
51
+ ];
52
+ }
53
+ else if (state.overlay.kind === "wizard") {
54
+ overlayLines = state.overlay.lines.map((line) => sanitizeTuiText(line));
55
+ }
56
+ else if (state.overlay.kind === "browser") {
57
+ overlayLines = state.overlay.lines.map((line) => sanitizeTuiText(line));
58
+ }
59
+ else if (state.overlay.kind === "lifecycle") {
60
+ overlayLines = [
61
+ sanitizeTuiText(`target ${state.overlay.target}`),
62
+ "d detach · s stop · f force-kill · a archive",
63
+ "Backspace delete · y confirm · Esc cancel",
64
+ ];
65
+ }
66
+ return {
67
+ repoLabel: sanitizeTuiText(input.repoLabel),
68
+ width: input.width,
69
+ height: input.height,
70
+ permissionMode: state.permissionMode,
71
+ hostAvailability: state.hostAvailability.map((h) => ({
72
+ id: h.id,
73
+ available: h.available,
74
+ ...(h.reason ? { reason: sanitizeTuiText(h.reason) } : {}),
75
+ })),
76
+ status: state.status ? sanitizeTuiText(state.status) : null,
77
+ warnings: state.warnings.map((w) => sanitizeTuiText(w)),
78
+ menuLines,
79
+ overlayKind,
80
+ overlayLines,
81
+ paletteQuery: state.overlay.kind === "palette" ? state.overlay.query : null,
82
+ };
83
+ }
84
+ export function clientMessageToParsedKey(message) {
85
+ switch (message.kind) {
86
+ case "char":
87
+ return message.value ? { kind: "char", value: message.value } : null;
88
+ case "up":
89
+ case "down":
90
+ case "left":
91
+ case "right":
92
+ case "enter":
93
+ case "escape":
94
+ case "tab":
95
+ case "backspace":
96
+ return { kind: message.kind };
97
+ case "ctrl":
98
+ return message.value ? { kind: "ctrl", value: message.value } : null;
99
+ default:
100
+ return null;
101
+ }
102
+ }
103
+ /**
104
+ * Drive pure TUI state from a native binary over length-prefixed JSON frames.
105
+ * State is owned by the caller (runtime) so launch/HUD/browser updates paint.
106
+ */
107
+ export async function runNativeTuiHost(input) {
108
+ const spawnFn = input.spawnImpl ?? spawn;
109
+ const child = spawnFn(input.binaryPath, [], {
110
+ stdio: ["pipe", "pipe", "inherit"],
111
+ });
112
+ if (!child.stdin || !child.stdout) {
113
+ throw new Error("native shell spawn did not provide stdio pipes");
114
+ }
115
+ let stdinBuf = Buffer.alloc(0);
116
+ let width = 80;
117
+ let height = 24;
118
+ const childStdin = child.stdin;
119
+ const childStdout = child.stdout;
120
+ const pushView = () => {
121
+ if (!childStdin.writable)
122
+ return;
123
+ const view = projectNativeViewModel(input.getState(), {
124
+ repoLabel: input.repoLabel,
125
+ width,
126
+ height,
127
+ });
128
+ childStdin.write(encodeIpcFrame({ type: "view", view }));
129
+ };
130
+ input.onBridge?.({ pushView });
131
+ childStdin.write(encodeIpcFrame({ type: "hello", schemaVersion: 1 }));
132
+ pushView();
133
+ /**
134
+ * Handshake hang detection: fail closed if the shell never greets.
135
+ * After ready, probe process liveness so a zombie/stuck native binary
136
+ * cannot pin the domain host forever (runtime falls back to pure-ANSI).
137
+ */
138
+ const HANDSHAKE_MS = 30_000;
139
+ const LIVENESS_MS = 5_000;
140
+ return await new Promise((resolve) => {
141
+ let settled = false;
142
+ let handshakeDone = false;
143
+ let handshakeTimer = setTimeout(() => {
144
+ if (handshakeDone || settled)
145
+ return;
146
+ input.handlers.onQuit();
147
+ child.kill("SIGTERM");
148
+ setTimeout(() => child.kill("SIGKILL"), 2_000);
149
+ finish(1);
150
+ }, HANDSHAKE_MS);
151
+ let livenessTimer;
152
+ const finish = (code) => {
153
+ if (settled)
154
+ return;
155
+ settled = true;
156
+ if (handshakeTimer)
157
+ clearTimeout(handshakeTimer);
158
+ if (livenessTimer)
159
+ clearInterval(livenessTimer);
160
+ resolve(code);
161
+ };
162
+ const armLiveness = () => {
163
+ if (livenessTimer)
164
+ return;
165
+ livenessTimer = setInterval(() => {
166
+ if (settled)
167
+ return;
168
+ // exitCode set when the process has already exited without us finishing.
169
+ if (child.exitCode !== null || child.signalCode !== null) {
170
+ finish(child.exitCode ?? 1);
171
+ return;
172
+ }
173
+ const pid = child.pid;
174
+ if (pid === undefined) {
175
+ finish(1);
176
+ return;
177
+ }
178
+ try {
179
+ process.kill(pid, 0);
180
+ }
181
+ catch {
182
+ finish(1);
183
+ }
184
+ }, LIVENESS_MS);
185
+ };
186
+ childStdout.on("data", (chunk) => {
187
+ stdinBuf = Buffer.concat([stdinBuf, chunk]);
188
+ let decoded;
189
+ try {
190
+ decoded = decodeIpcFrames(stdinBuf);
191
+ }
192
+ catch {
193
+ // Drop a corrupted frame prefix rather than crashing the domain host.
194
+ // Keep a bounded tail so a subsequent well-formed frame can resync.
195
+ stdinBuf = stdinBuf.length > 64 * 1024 ? stdinBuf.subarray(-4096) : Buffer.alloc(0);
196
+ return;
197
+ }
198
+ stdinBuf = Buffer.from(decoded.rest);
199
+ for (const raw of decoded.messages) {
200
+ let msg;
201
+ try {
202
+ msg = parseClientMessage(raw);
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ if (msg.type === "hello" || msg.type === "ready") {
208
+ handshakeDone = true;
209
+ if (handshakeTimer)
210
+ clearTimeout(handshakeTimer);
211
+ armLiveness();
212
+ pushView();
213
+ continue;
214
+ }
215
+ if (msg.type === "resize") {
216
+ width = msg.width;
217
+ height = msg.height;
218
+ pushView();
219
+ continue;
220
+ }
221
+ if (msg.type === "key") {
222
+ const key = clientMessageToParsedKey(msg);
223
+ if (!key)
224
+ continue;
225
+ const result = reduceTuiKey(input.getState(), key);
226
+ input.setState(result.state);
227
+ // Match pure-ANSI path: selection transitions ride the E2E channel so
228
+ // CI tmux harness can assert menu navigation without screen scrapes.
229
+ if (result.state.menu.selectedKey) {
230
+ emitDashE2eState({ kind: "selection", row: result.state.menu.selectedKey });
231
+ }
232
+ for (const effect of result.effects) {
233
+ void input.handlers.onEffect(effect);
234
+ if (effect.kind === "quit") {
235
+ input.handlers.onQuit();
236
+ child.kill("SIGTERM");
237
+ setTimeout(() => child.kill("SIGKILL"), 2_000);
238
+ finish(0);
239
+ return;
240
+ }
241
+ }
242
+ if (result.state.quit) {
243
+ input.handlers.onQuit();
244
+ child.kill("SIGTERM");
245
+ setTimeout(() => child.kill("SIGKILL"), 2_000);
246
+ finish(0);
247
+ return;
248
+ }
249
+ pushView();
250
+ }
251
+ }
252
+ });
253
+ child.on("exit", (code) => finish(code ?? 0));
254
+ child.on("error", () => finish(1));
255
+ });
256
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Env contract:
3
+ * - `WP_DASH_NATIVE=0|false|off` → force pure-ANSI
4
+ * - `WP_DASH_NATIVE=1|true|on` → prefer native when binary present
5
+ * - unset → prefer native when binary present (cutover default)
6
+ * - `WP_DASH_TUI_BIN` → absolute path override
7
+ * - `WP_DASH_FORCE_ANSI=1` → runtime force pure-ANSI even if a binary resolves
8
+ */
9
+ export declare function shouldPreferNativeDashTui(env?: NodeJS.ProcessEnv): boolean;
10
+ export declare function resolveNativeDashTuiBinary(env?: NodeJS.ProcessEnv, candidates?: readonly string[]): string | null;
11
+ export declare function defaultBinaryCandidates(cwd?: string): readonly string[];
@@ -0,0 +1,41 @@
1
+ // Resolve the optional native wp-dash-tui binary for the control-plane shell.
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { findPackageAsset } from "#utils/package-assets.js";
5
+ /**
6
+ * Env contract:
7
+ * - `WP_DASH_NATIVE=0|false|off` → force pure-ANSI
8
+ * - `WP_DASH_NATIVE=1|true|on` → prefer native when binary present
9
+ * - unset → prefer native when binary present (cutover default)
10
+ * - `WP_DASH_TUI_BIN` → absolute path override
11
+ * - `WP_DASH_FORCE_ANSI=1` → runtime force pure-ANSI even if a binary resolves
12
+ */
13
+ export function shouldPreferNativeDashTui(env = process.env) {
14
+ const flag = env["WP_DASH_NATIVE"]?.trim().toLowerCase();
15
+ if (flag === "0" || flag === "false" || flag === "off" || flag === "no") {
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ export function resolveNativeDashTuiBinary(env = process.env, candidates = defaultBinaryCandidates()) {
21
+ const override = env["WP_DASH_TUI_BIN"]?.trim();
22
+ if (override && existsSync(override))
23
+ return override;
24
+ for (const candidate of candidates) {
25
+ if (existsSync(candidate))
26
+ return candidate;
27
+ }
28
+ return null;
29
+ }
30
+ export function defaultBinaryCandidates(cwd = process.cwd()) {
31
+ const fromPackage = [
32
+ findPackageAsset("native/dash-tui/target/release/wp-dash-tui", { cwd }),
33
+ findPackageAsset("native/dash-tui/target/debug/wp-dash-tui", { cwd }),
34
+ ].filter((path) => path !== null);
35
+ return [
36
+ join(cwd, "native/dash-tui/target/release/wp-dash-tui"),
37
+ join(cwd, "native/dash-tui/target/debug/wp-dash-tui"),
38
+ ...fromPackage,
39
+ join(cwd, "bin/wp-dash-tui"),
40
+ ];
41
+ }
@@ -234,6 +234,8 @@ export async function runTuiMenu(input) {
234
234
  let dirty = true;
235
235
  let lastFrame = "";
236
236
  let running = true;
237
+ /** When set, domain state paints via the native shell instead of pure-ANSI. */
238
+ let nativePushView = null;
237
239
  /** Once true, no new launch effects may call selectAgent. */
238
240
  let shuttingDown = false;
239
241
  let initialLaunchConsumed = false;
@@ -261,6 +263,14 @@ export async function runTuiMenu(input) {
261
263
  const paint = () => {
262
264
  if (shuttingDown)
263
265
  return;
266
+ // Native shell owns the terminal; never write pure-ANSI frames over it.
267
+ if (nativePushView) {
268
+ if (!dirty && !input.smokeOnce)
269
+ return;
270
+ nativePushView();
271
+ dirty = false;
272
+ return;
273
+ }
264
274
  if (!dirty && !input.smokeOnce)
265
275
  return;
266
276
  const frame = renderFrame(toViewModel(state, {
@@ -621,6 +631,68 @@ export async function runTuiMenu(input) {
621
631
  stdout.write(`\n${note}\n`);
622
632
  return 0;
623
633
  }
634
+ // Primary path: native Ratatui shell when binary is available (WP_DASH_NATIVE=0 forces pure-ANSI).
635
+ const { shouldPreferNativeDashTui, resolveNativeDashTuiBinary } = await import("#cli/commands/dash/tui/native-path.js");
636
+ const nativeBinary = shouldPreferNativeDashTui() && !process.env["WP_DASH_FORCE_ANSI"]
637
+ ? resolveNativeDashTuiBinary()
638
+ : null;
639
+ if (nativeBinary) {
640
+ try {
641
+ const { runNativeTuiHost } = await import("#cli/commands/dash/tui/native-host.js");
642
+ process.stderr.write(`wp dash: native control-plane shell (${nativeBinary})\n`);
643
+ // Ensure agent list is loaded before first native frame when possible.
644
+ state = { ...state, loading: false };
645
+ const code = await runNativeTuiHost({
646
+ binaryPath: nativeBinary,
647
+ repoLabel: input.repoLabel,
648
+ getState: () => state,
649
+ setState: (next) => {
650
+ state = next;
651
+ },
652
+ onBridge: (bridge) => {
653
+ nativePushView = bridge.pushView;
654
+ },
655
+ handlers: {
656
+ onEffect: (effect) => {
657
+ // Match pure-ANSI: repaint after async effects settle so launch /
658
+ // browser / status mutations reach the native shell without waiting
659
+ // for the next key or timer tick.
660
+ void enqueueEffects([effect]).then(() => {
661
+ if (!shuttingDown)
662
+ paint();
663
+ });
664
+ },
665
+ onQuit: () => {
666
+ shuttingDown = true;
667
+ running = false;
668
+ coordinator.discardPending();
669
+ },
670
+ },
671
+ });
672
+ nativePushView = null;
673
+ if (code !== 0) {
674
+ throw new Error(`native shell exited with code ${code}`);
675
+ }
676
+ shuttingDown = true;
677
+ running = false;
678
+ coordinator.discardPending();
679
+ clearInterval(usageTimer);
680
+ clearInterval(ultragoalTimer);
681
+ clearInterval(statusTimer);
682
+ stopObservingLayout?.();
683
+ const note = await controller?.dispose();
684
+ await effectQueue.catch(() => {
685
+ /* best-effort drain after dispose */
686
+ });
687
+ if (note)
688
+ stdout.write(`\n${note}\n`);
689
+ return code;
690
+ }
691
+ catch (error) {
692
+ nativePushView = null;
693
+ process.stderr.write(`wp dash: native shell failed (${error instanceof Error ? error.message : String(error)}); falling back to pure-ANSI\n`);
694
+ }
695
+ }
624
696
  const wasRaw = stdin.isRaw;
625
697
  if (typeof stdin.setRawMode === "function")
626
698
  stdin.setRawMode(true);
@@ -26,7 +26,7 @@ import { spawnSync } from "node:child_process";
26
26
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
27
27
  import { dirname, join } from "node:path";
28
28
  import { buildVpGlobalInstallCommand, confirmInstalledGlobally, PUBLIC_PACKAGE_NAME, resolveBundledVpCommand, } from "#cli/auto-update/detect-pm.js";
29
- import { buildSelfInvocationCommand } from "#cli/commands/self-install-guard.js";
29
+ import { buildSelfInvocationCommand } from "#cli/auto-update/self-invocation.js";
30
30
  import { findAgentKitPackageRoot, resolveAgentKitPackageRoot, } from "#cli/commands/init/package-root";
31
31
  import { makeNoopSpinnerFactory, } from "#cli/commands/init/scaffolders/spinner";
32
32
  import { UPDATE_CACHE_FILENAME, readFreshCachedLatestVersion } from "#cli/auto-update/cache.js";
@@ -7,7 +7,8 @@ import { readToolingOwnershipState, tryReadRepoKey, writeToolingOwnershipState,
7
7
  import { resolveGlobalCapableVpCommand } from "#cli/global-vp.js";
8
8
  import { UPDATE_CACHE_FILENAME, formatStaleAgentKitVersionWarning, reconcileUpdateNotifierCache, } from "#cli/auto-update/cache.js";
9
9
  import { buildVpGlobalUpdateCommand, resolveBundledVpCommand } from "#cli/auto-update/detect-pm.js";
10
- import { GUARD_EXIT_CHILD_FAILED, GUARD_EXIT_LEASE_HELD, GUARD_EXIT_OWNERSHIP_LOST, GUARD_EXIT_UNRESOLVABLE, buildSelfInvocationCommand, } from "#cli/commands/self-install-guard.js";
10
+ import { buildSelfInvocationCommand } from "#cli/auto-update/self-invocation.js";
11
+ import { GUARD_EXIT_CHILD_FAILED, GUARD_EXIT_LEASE_HELD, GUARD_EXIT_OWNERSHIP_LOST, GUARD_EXIT_UNRESOLVABLE, } from "#cli/commands/self-install-guard.js";
11
12
  import { readPackageVersion } from "#cli/utils";
12
13
  import { resolveFreshAgentKitPackageRoot } from "#cli/commands/init/package-root";
13
14
  import { DEFAULT_PLUGIN_CACHE_PRUNE_HOSTS, pruneOutdatedAgentKitPluginCaches, } from "#cli/commands/init/plugin-cache-prune.js";
@@ -10,6 +10,12 @@ export interface CliLogEntry {
10
10
  readonly summary?: string;
11
11
  }
12
12
  export declare function isCliLogCommandName(value: string): value is CliLogCommandName;
13
+ /**
14
+ * Create a durable CLI log sink. When the preferred repo state surface is not
15
+ * writable (EPERM/EACCES/EROFS — common in Codex/read-only sandboxes), fall
16
+ * back to TMPDIR so `wp audit`/`wp test` still execute. The fallback is loud
17
+ * on stderr; it is not a silent swallow.
18
+ */
13
19
  export declare function createCliLogSink(command: CliLogCommandName, cwd?: string): CliLogSink;
14
20
  export interface CliLogSink {
15
21
  readonly command: CliLogCommandName;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { closeSync, createWriteStream, mkdirSync, openSync, readdirSync, readFileSync, statSync, renameSync, utimesSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import { getSurfacePath } from "#paths/state-root.js";
5
6
  import { readTrustedJsonFile } from "#shared-utils/read-json-file.js";
@@ -18,9 +19,17 @@ const ORPHAN_LOG_GRACE_MS = 60_000;
18
19
  export function isCliLogCommandName(value) {
19
20
  return CLI_LOG_COMMANDS.includes(value);
20
21
  }
21
- export function createCliLogSink(command, cwd = process.cwd()) {
22
+ function isUnwritableFsError(error) {
23
+ if (typeof error !== "object" || error === null || !("code" in error))
24
+ return false;
25
+ const code = error.code;
26
+ return code === "EPERM" || code === "EACCES" || code === "EROFS";
27
+ }
28
+ /**
29
+ * Open a CLI log sink in an explicit directory (preferred state surface or fallback).
30
+ */
31
+ function openCliLogSink(commandDir, command, cwd) {
22
32
  const id = createLogId();
23
- const commandDir = getCommandLogDir(command, cwd);
24
33
  mkdirSync(commandDir, { recursive: true });
25
34
  const absoluteLogPath = join(commandDir, `${id}.log`);
26
35
  const activeMarkerPath = getActiveMarkerPath(absoluteLogPath);
@@ -80,7 +89,8 @@ export function createCliLogSink(command, cwd = process.cwd()) {
80
89
  };
81
90
  try {
82
91
  markLogRecentlyFinalized(absoluteLogPath);
83
- writeLogEntry(entry, cwd);
92
+ // Index/lock live next to the log file so fallback dirs stay consistent.
93
+ writeLogEntryInDir(entry, commandDir);
84
94
  }
85
95
  finally {
86
96
  rmSync(activeMarkerPath, { force: true });
@@ -89,6 +99,28 @@ export function createCliLogSink(command, cwd = process.cwd()) {
89
99
  },
90
100
  };
91
101
  }
102
+ /**
103
+ * Create a durable CLI log sink. When the preferred repo state surface is not
104
+ * writable (EPERM/EACCES/EROFS — common in Codex/read-only sandboxes), fall
105
+ * back to TMPDIR so `wp audit`/`wp test` still execute. The fallback is loud
106
+ * on stderr; it is not a silent swallow.
107
+ */
108
+ export function createCliLogSink(command, cwd = process.cwd()) {
109
+ const preferredDir = getCommandLogDir(command, cwd);
110
+ try {
111
+ return openCliLogSink(preferredDir, command, cwd);
112
+ }
113
+ catch (error) {
114
+ if (!isUnwritableFsError(error))
115
+ throw error;
116
+ const fallbackDir = join(tmpdir(), "webpresso", "cli-logs", command);
117
+ const code = typeof error === "object" && error !== null && "code" in error
118
+ ? String(error.code)
119
+ : "unknown";
120
+ process.stderr.write(`wp: CLI log directory not writable (${preferredDir}): ${code}; using ${fallbackDir}\n`);
121
+ return openCliLogSink(fallbackDir, command, cwd);
122
+ }
123
+ }
92
124
  export function readCliLogEntries(command, cwd = process.cwd()) {
93
125
  const indexPath = getCommandIndexPath(command, cwd);
94
126
  try {
@@ -104,11 +136,21 @@ export function readCliLogEntry(command, ordinal = 1, cwd = process.cwd()) {
104
136
  return;
105
137
  return readCliLogEntries(command, cwd)[ordinal - 1];
106
138
  }
107
- function writeLogEntry(entry, cwd) {
108
- withCommandIndexLock(entry.command, cwd, () => {
109
- const indexPath = getCommandIndexPath(entry.command, cwd);
139
+ function readCliLogEntriesFromDir(commandDir) {
140
+ const indexPath = join(commandDir, "index.json");
141
+ try {
142
+ const parsed = readTrustedJsonFile(indexPath);
143
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ }
149
+ function writeLogEntryInDir(entry, commandDir) {
150
+ withCommandIndexLockInDir(commandDir, () => {
151
+ const indexPath = join(commandDir, "index.json");
110
152
  mkdirSync(dirname(indexPath), { recursive: true });
111
- const currentEntries = readCliLogEntries(entry.command, cwd).filter((current) => current.id !== entry.id);
153
+ const currentEntries = readCliLogEntriesFromDir(commandDir).filter((current) => current.id !== entry.id);
112
154
  const nextEntries = [entry, ...currentEntries].slice(0, 10);
113
155
  const retainedLogPaths = new Set(nextEntries.map((item) => item.logPath));
114
156
  for (const removed of currentEntries.slice(9)) {
@@ -116,7 +158,7 @@ function writeLogEntry(entry, cwd) {
116
158
  rmSync(removed.logPath, { force: true });
117
159
  }
118
160
  }
119
- pruneInactiveOrphanedLogFiles(entry.command, retainedLogPaths, cwd);
161
+ pruneInactiveOrphanedLogFilesInDir(commandDir, retainedLogPaths);
120
162
  const index = {
121
163
  version: 1,
122
164
  command: entry.command,
@@ -125,8 +167,7 @@ function writeLogEntry(entry, cwd) {
125
167
  writeIndexAtomically(indexPath, index);
126
168
  });
127
169
  }
128
- function withCommandIndexLock(command, cwd, fn) {
129
- const commandDir = getCommandLogDir(command, cwd);
170
+ function withCommandIndexLockInDir(commandDir, fn) {
130
171
  mkdirSync(commandDir, { recursive: true });
131
172
  const lockPath = join(commandDir, "index.lock");
132
173
  const started = Date.now();
@@ -157,13 +198,12 @@ function writeIndexAtomically(indexPath, index) {
157
198
  writeJsonFile(tmpPath, index);
158
199
  renameSync(tmpPath, indexPath);
159
200
  }
160
- function pruneInactiveOrphanedLogFiles(command, retainedLogPaths, cwd) {
161
- const directory = getCommandLogDir(command, cwd);
162
- mkdirSync(directory, { recursive: true });
163
- for (const file of readdirSync(directory)) {
201
+ function pruneInactiveOrphanedLogFilesInDir(commandDir, retainedLogPaths) {
202
+ mkdirSync(commandDir, { recursive: true });
203
+ for (const file of readdirSync(commandDir)) {
164
204
  if (!file.endsWith(".log"))
165
205
  continue;
166
- const absolutePath = join(directory, file);
206
+ const absolutePath = join(commandDir, file);
167
207
  if (!retainedLogPaths.has(absolutePath) && canPruneLogPath(absolutePath)) {
168
208
  rmSync(absolutePath, { force: true });
169
209
  }
@@ -30,13 +30,17 @@
30
30
  * chain unresolvable after repair; 12 lease held; 13 ownership lost.
31
31
  * 7. Release the lease owner-checked in a finally.
32
32
  *
33
- * Post-swap import rule: the child `vp update -g @webpresso/agent-kit` may
34
- * delete this process's on-disk package tree (old installId) before probe/
35
- * repair run. Post-child helpers MUST be static imports loaded at process
36
- * start never `await import("#cli/…")` after the child exits, or Node
37
- * re-resolves against a removed path (`Cannot find module …/global-vp.js`).
38
- * Probe/repair logic itself reads the vite-plus store SSOT + PATH, not this
39
- * process's dying package root.
33
+ * Survival layers (in-repo SOTA bar):
34
+ * 1. Prefer a **stable** non-versioned launcher when re-execing the guard
35
+ * (`#cli/auto-update/self-invocation.js`) primary defense.
36
+ * 2. Self-package helpers (probe, repair, tombstone) MUST be static imports
37
+ * loaded at process start never package-local dynamic import, or Node
38
+ * re-resolves against a removed installId path after `vp update -g`.
39
+ * 3. Argument builders depend on cycle-free leaves (self-invocation,
40
+ * guard-tombstone), never on this command module solely for argv.
41
+ *
42
+ * Probe/repair reads the vite-plus store SSOT + PATH, not this process's
43
+ * dying package root.
40
44
  *
41
45
  * Custody boundary (stated exactly, mirrored from install-lock.ts): a stale,
42
46
  * OS-suspended holder whose child completes a mutation AFTER the successor
@@ -45,6 +49,9 @@
45
49
  * resource-side fencing.
46
50
  */
47
51
  import type { CAC } from "cac";
52
+ import { buildSelfInvocationCommand, isVersionedAgentKitPayloadPath, resolveStableWpLauncher } from "#cli/auto-update/self-invocation.js";
53
+ /** Re-export leaf helpers for public ergonomics / existing imports. */
54
+ export { buildSelfInvocationCommand, isVersionedAgentKitPayloadPath, resolveStableWpLauncher };
48
55
  export declare function registerSelfInstallGuardCommand(cli: CAC): void;
49
56
  export declare const GUARD_EXIT_CHILD_FAILED = 10;
50
57
  export declare const GUARD_EXIT_UNRESOLVABLE = 11;
@@ -85,12 +92,3 @@ export interface SelfInstallGuardDeps {
85
92
  * Testable core. The CLI action wires `process.exit(await runSelfInstallGuard(...))`.
86
93
  */
87
94
  export declare function runSelfInstallGuard(rawArgs: readonly string[], deps?: SelfInstallGuardDeps): Promise<number>;
88
- /**
89
- * How wp re-invokes itself: the bun-compiled binary IS process.execPath (its
90
- * argv[1] is a virtual /$bunfs path), while the source/dev lane runs
91
- * `<runtime> <script>`.
92
- */
93
- export declare function buildSelfInvocationCommand(args: readonly string[], options?: {
94
- readonly execPath?: string;
95
- readonly argv1?: string;
96
- }): string[];