@webpresso/agent-kit 3.2.0 → 3.3.1

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.

Potentially problematic release.


This version of @webpresso/agent-kit might be problematic. Click here for more details.

Files changed (103) hide show
  1. package/bin/_run.js +4 -1
  2. package/catalog/agent/rules/pre-implementation.md +15 -0
  3. package/dist/esm/audit/blueprint-trust.js +14 -10
  4. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  5. package/dist/esm/audit/changeset-evidence.js +30 -0
  6. package/dist/esm/audit/changeset-required.d.ts +10 -0
  7. package/dist/esm/audit/changeset-required.js +73 -0
  8. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  9. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  10. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +6 -1
  13. package/dist/esm/blueprint/core/parser.js +61 -4
  14. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  15. package/dist/esm/blueprint/core/schema.js +26 -0
  16. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  17. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  18. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  21. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  22. package/dist/esm/blueprint/sync/client.js +1 -1
  23. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  24. package/dist/esm/blueprint/trust/dossier.js +40 -6
  25. package/dist/esm/blueprint/trust/promotion.js +12 -0
  26. package/dist/esm/build/cli-mcp-parity.js +5 -0
  27. package/dist/esm/cli/cli.d.ts +1 -1
  28. package/dist/esm/cli/cli.js +9 -0
  29. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  30. package/dist/esm/cli/commands/agent-launch.js +31 -20
  31. package/dist/esm/cli/commands/audit.js +4 -0
  32. package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
  33. package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
  34. package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
  35. package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
  36. package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
  37. package/dist/esm/cli/commands/blueprint/router.js +5 -2
  38. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  39. package/dist/esm/cli/commands/init/convergence-apply.d.ts +87 -0
  40. package/dist/esm/cli/commands/init/convergence-apply.js +189 -23
  41. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  42. package/dist/esm/cli/commands/init/index.js +2 -2
  43. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  44. package/dist/esm/cli/commands/init/package-root.js +10 -2
  45. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  46. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  47. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  48. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  49. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  50. package/dist/esm/cli/commands/opencode-probe.d.ts +118 -0
  51. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  52. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  53. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  54. package/dist/esm/cli/commands/quality-runner.js +8 -4
  55. package/dist/esm/cli/commands/review.js +27 -5
  56. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  57. package/dist/esm/cli/commands/rust-check.js +110 -0
  58. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  59. package/dist/esm/cli/commands/typecheck.js +30 -1
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +14 -0
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +138 -2
  62. package/dist/esm/cli/commands/worktree/router.js +10 -0
  63. package/dist/esm/hooks/doctor.d.ts +2 -0
  64. package/dist/esm/hooks/doctor.js +37 -1
  65. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  66. package/dist/esm/hooks/shared/types.js +27 -1
  67. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  68. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  69. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  70. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  71. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  72. package/dist/esm/mcp/tools/audit.js +12 -0
  73. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  74. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  75. package/dist/esm/package.json +2 -0
  76. package/dist/esm/platform/client.d.ts +7 -1
  77. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  78. package/dist/esm/pr-description/contract.js +24 -4
  79. package/dist/esm/review/authority.js +14 -9
  80. package/dist/esm/review/availability.d.ts +11 -0
  81. package/dist/esm/review/availability.js +18 -3
  82. package/dist/esm/review/execution/adapters.d.ts +11 -1
  83. package/dist/esm/review/execution/adapters.js +10 -1
  84. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  85. package/dist/esm/review/execution/review-checkout.d.ts +6 -0
  86. package/dist/esm/review/execution/review-checkout.js +61 -16
  87. package/dist/esm/review/execution/supervisor.js +31 -4
  88. package/dist/esm/review/execution/types.d.ts +1 -0
  89. package/dist/esm/review/subject.d.ts +13 -0
  90. package/dist/esm/review/subject.js +42 -4
  91. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  92. package/dist/esm/rust/affected-manifests.js +69 -0
  93. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  94. package/dist/esm/status/snapshot.js +2 -6
  95. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  96. package/dist/esm/test-helpers/global-setup.js +18 -15
  97. package/dist/esm/typecheck/planner.d.ts +9 -1
  98. package/dist/esm/typecheck/planner.js +31 -0
  99. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  100. package/dist/esm/worktrees/orphan-scan.js +211 -0
  101. package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
  102. package/dist/esm/worktrees/owner-dirt.js +104 -0
  103. package/package.json +14 -12
@@ -2,21 +2,179 @@ import { spawnSync } from "node:child_process";
2
2
  import { join } from "node:path";
3
3
  import { convergeUnitFromTarget, deriveCommitAllowlist } from "./convergence.js";
4
4
  export { resolveUntrackedCollision } from "./untracked-collision.js";
5
- const GIT_TIMEOUT_MS = 15_000;
6
- function git(repoRoot, args) {
5
+ /** Bounded timeout for non-hook Git probes (status, add, rev-parse, diff-tree). */
6
+ export const GIT_PROBE_TIMEOUT_MS = 15_000;
7
+ /**
8
+ * Default wall for the single hookful `git commit`. Consumer pre-commit chains
9
+ * (format + audits) routinely exceed the probe budget; Node's spawnSync kills
10
+ * the child with SIGTERM when `timeout` elapses, which left staged residue and
11
+ * multi-line partial hook output that looked like success.
12
+ *
13
+ * 10 minutes is a durable consumer budget, not infinite hang. Operators can
14
+ * raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
15
+ */
16
+ export const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600_000;
17
+ /** Max stdout+stderr retained from probe git subprocesses (Node spawnSync maxBuffer). */
18
+ export const GIT_MAX_BUFFER = 1024 * 1024;
19
+ /**
20
+ * Max stdout+stderr for the single hookful `git commit`. Quiet hooks cost nothing
21
+ * (Node does not preallocate); chatty consumer format+audit chains can exceed 1 MiB
22
+ * and would otherwise die with ENOBUFS+SIGTERM mid-commit.
23
+ */
24
+ export const GIT_COMMIT_MAX_BUFFER = 16 * 1024 * 1024;
25
+ /** Hook output snippet size kept in first-class failure diagnoses (tail, not head). */
26
+ const HOOK_OUTPUT_SNIPPET_CHARS = 800;
27
+ /** spawnSync error codes that mean maxBuffer / stdio overflow killed the child. */
28
+ const BUFFER_OVERFLOW_ERROR_CODES = new Set(["ENOBUFS", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]);
29
+ function git(repoRoot, args, timeoutMs, maxBuffer = GIT_MAX_BUFFER) {
7
30
  const result = spawnSync("git", [...args], {
8
31
  cwd: repoRoot,
9
32
  encoding: "utf8",
10
- timeout: GIT_TIMEOUT_MS,
11
- maxBuffer: 1024 * 1024,
33
+ // Node: timeout > 0 arms SIGTERM; 0 / omitted means no artificial wall.
34
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
35
+ maxBuffer,
12
36
  stdio: ["ignore", "pipe", "pipe"],
13
37
  });
38
+ const errorCode = result.error !== undefined && "code" in result.error && result.error.code != null
39
+ ? String(result.error.code)
40
+ : null;
41
+ const timedOut = errorCode === "ETIMEDOUT";
14
42
  return {
15
43
  ok: result.status === 0 && !result.error,
16
- stdout: result.stdout.trim(),
17
- stderr: result.stderr.trim(),
44
+ stdout: (result.stdout ?? "").trim(),
45
+ stderr: (result.stderr ?? "").trim(),
46
+ timedOut,
47
+ signal: result.signal,
48
+ errorCode,
18
49
  };
19
50
  }
51
+ /** True when spawnSync killed the child for maxBuffer / stdio overflow. */
52
+ export function isBufferOverflowErrorCode(code) {
53
+ return typeof code === "string" && BUFFER_OVERFLOW_ERROR_CODES.has(code);
54
+ }
55
+ /**
56
+ * Resolve the hookful-commit timeout. Preference order:
57
+ * 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
58
+ * 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
59
+ * 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
60
+ *
61
+ * Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
62
+ */
63
+ export function resolveCommitTimeoutMs(options) {
64
+ const env = options?.env ?? process.env;
65
+ if (typeof options?.commitTimeoutMs === "number" &&
66
+ Number.isFinite(options.commitTimeoutMs) &&
67
+ options.commitTimeoutMs >= 0) {
68
+ return options.commitTimeoutMs;
69
+ }
70
+ const raw = env.WP_SETUP_COMMIT_TIMEOUT_MS;
71
+ if (typeof raw === "string" && raw.trim().length > 0) {
72
+ const parsed = Number.parseInt(raw, 10);
73
+ if (Number.isFinite(parsed) && parsed >= 0)
74
+ return parsed;
75
+ }
76
+ return DEFAULT_GIT_COMMIT_TIMEOUT_MS;
77
+ }
78
+ function appendHookOutput(diagnosis, hookOutput) {
79
+ const trimmed = hookOutput?.trim();
80
+ if (!trimmed)
81
+ return diagnosis;
82
+ // Keep the tail: failing audits / last lines at kill time matter more than husky banner.
83
+ const snippet = trimmed.length > HOOK_OUTPUT_SNIPPET_CHARS
84
+ ? `…${trimmed.slice(-HOOK_OUTPUT_SNIPPET_CHARS)}`
85
+ : trimmed;
86
+ return `${diagnosis}\n--- hook output (truncated) ---\n${snippet}`;
87
+ }
88
+ /** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
89
+ export function formatCommitTimeoutError(options) {
90
+ const budget = options.timeoutMs > 0
91
+ ? `${options.timeoutMs}ms`
92
+ : "the configured budget (no artificial wall was set, but the process was still interrupted)";
93
+ const diagnosis = `Convergence commit timed out after ${budget} while repository hooks were still running. ` +
94
+ `Node spawnSync kills the git process with SIGTERM on timeout, so no setup commit was authored. ` +
95
+ `Staged and worktree changes were preserved for remediation. ` +
96
+ `Re-run after hooks complete, raise WP_SETUP_COMMIT_TIMEOUT_MS / commitTimeoutMs, or fix slow hooks. ` +
97
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
98
+ return appendHookOutput(diagnosis, options.hookOutput);
99
+ }
100
+ /**
101
+ * First-class interrupt diagnosis for non-timeout signal kills (operator kill,
102
+ * external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
103
+ * leading operator message.
104
+ */
105
+ export function formatCommitInterruptError(options) {
106
+ const diagnosis = `Convergence commit was interrupted by ${options.signal} before completion, so no setup commit was authored. ` +
107
+ `Staged and worktree changes were preserved for remediation. ` +
108
+ `This is not a successful setup run. ` +
109
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
110
+ return appendHookOutput(diagnosis, options.hookOutput);
111
+ }
112
+ /**
113
+ * First-class diagnosis when Node spawnSync kills the child because hook/git
114
+ * output exceeded maxBuffer (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER).
115
+ * Distinct from budget timeout and from external signal kills.
116
+ */
117
+ export function formatCommitBufferError(options) {
118
+ const code = options.errorCode?.trim() || "ENOBUFS";
119
+ const diagnosis = `Convergence commit failed because git/hook output exceeded the spawnSync maxBuffer limit ` +
120
+ `(${options.maxBufferBytes} bytes; Node error ${code}), so no setup commit was authored. ` +
121
+ `This is a buffer/output-limit failure, not a time budget timeout and not an external kill. ` +
122
+ `Staged and worktree changes were preserved for remediation. ` +
123
+ `Reduce hook verbosity, raise the maxBuffer only with measured need, or fix chatty hooks. ` +
124
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
125
+ return appendHookOutput(diagnosis, options.hookOutput);
126
+ }
127
+ /**
128
+ * Classify a failed hookful `git commit` result.
129
+ *
130
+ * Order (first match wins):
131
+ * 1. `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
132
+ * 2. buffer overflow (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER) → buffer-limit diagnosis
133
+ * 3. non-null `signal` → interrupt diagnosis (names the signal)
134
+ * 4. otherwise → hook/git stderr when present, else spawn errorCode / generic preserved-index
135
+ *
136
+ * Never lead with bare hook "passed" lines for first-class failure paths.
137
+ */
138
+ export function classifyCommitFailure(result, timeoutMs, maxBufferBytes = GIT_COMMIT_MAX_BUFFER) {
139
+ const hookOutput = [result.stderr, result.stdout].filter(Boolean).join("\n");
140
+ if (result.timedOut) {
141
+ return formatCommitTimeoutError({ timeoutMs, hookOutput });
142
+ }
143
+ if (isBufferOverflowErrorCode(result.errorCode)) {
144
+ return formatCommitBufferError({
145
+ maxBufferBytes,
146
+ errorCode: result.errorCode,
147
+ hookOutput,
148
+ });
149
+ }
150
+ if (result.signal) {
151
+ return formatCommitInterruptError({ signal: result.signal, hookOutput });
152
+ }
153
+ if (hookOutput) {
154
+ if (result.errorCode) {
155
+ return `${hookOutput}\n(spawn error ${result.errorCode})`;
156
+ }
157
+ return hookOutput;
158
+ }
159
+ if (result.errorCode) {
160
+ return `git commit failed (spawn error ${result.errorCode}); hook changes were preserved.`;
161
+ }
162
+ return "git commit failed; hook changes were preserved.";
163
+ }
164
+ /**
165
+ * Prefix every line of a setup failure so multi-line timeout diagnoses stay
166
+ * labeled when printed via console.error (avoids a single `wp setup: ` on the
167
+ * first line and bare hook "passed" lines after it).
168
+ */
169
+ export function formatWpSetupError(message) {
170
+ const body = message.trimEnd();
171
+ if (body.length === 0)
172
+ return "wp setup: Unable to author convergence commit.";
173
+ return body
174
+ .split("\n")
175
+ .map((line) => `wp setup: ${line}`)
176
+ .join("\n");
177
+ }
20
178
  function lines(value) {
21
179
  return value.length === 0 ? [] : value.split("\n").filter(Boolean).sort();
22
180
  }
@@ -32,8 +190,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
32
190
  ], {
33
191
  cwd: repoRoot,
34
192
  encoding: "utf8",
35
- timeout: GIT_TIMEOUT_MS,
36
- maxBuffer: 1024 * 1024,
193
+ timeout: GIT_PROBE_TIMEOUT_MS,
194
+ maxBuffer: GIT_MAX_BUFFER,
37
195
  stdio: ["ignore", "pipe", "pipe"],
38
196
  });
39
197
  if (result.status !== 0 || result.error)
@@ -52,7 +210,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
52
210
  return entries;
53
211
  }
54
212
  function isIgnored(repoRoot, gitPath) {
55
- return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath]).ok;
213
+ return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath], GIT_PROBE_TIMEOUT_MS)
214
+ .ok;
56
215
  }
57
216
  export function collectAppliedConvergeUnits(repoRoot, candidates) {
58
217
  const units = [];
@@ -107,31 +266,42 @@ export function authorConvergenceCommit(options) {
107
266
  return { status: "skipped-ci", ok: true, allowlist };
108
267
  if (allowlist.length === 0)
109
268
  return { status: "no-change", ok: true, allowlist };
110
- const before = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
269
+ const probe = (args) => git(options.repoRoot, args, GIT_PROBE_TIMEOUT_MS);
270
+ const before = probe(["rev-parse", "--verify", "HEAD"]);
111
271
  if (!before.ok) {
112
272
  return { status: "failed", ok: false, allowlist, error: before.stderr || "Missing HEAD." };
113
273
  }
114
- const add = git(options.repoRoot, ["add", "--", ...allowlist]);
274
+ const add = probe(["add", "--", ...allowlist]);
115
275
  if (!add.ok) {
116
276
  return { status: "failed", ok: false, allowlist, error: add.stderr || "git add failed." };
117
277
  }
118
- const staged = git(options.repoRoot, ["diff", "--cached", "--name-only"]);
278
+ const staged = probe(["diff", "--cached", "--name-only"]);
119
279
  if (!staged.ok) {
120
280
  return { status: "failed", ok: false, allowlist, error: staged.stderr || "git diff failed." };
121
281
  }
122
282
  if (lines(staged.stdout).length === 0) {
123
283
  return { status: "no-change", ok: true, allowlist };
124
284
  }
125
- const commit = git(options.repoRoot, ["commit", "-m", options.message]);
285
+ const commitTimeoutMs = resolveCommitTimeoutMs({
286
+ commitTimeoutMs: options.commitTimeoutMs,
287
+ });
288
+ const budgetLabel = commitTimeoutMs > 0
289
+ ? commitTimeoutMs >= 60_000
290
+ ? `up to ${Math.round(commitTimeoutMs / 60_000)}m`
291
+ : `up to ${commitTimeoutMs}ms`
292
+ : "no artificial time wall";
293
+ // stderr: setup success path stays machine-friendly on stdout; hooks can take minutes.
294
+ console.error(`wp setup: running repository pre-commit hooks (${budgetLabel})…`);
295
+ const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs, GIT_COMMIT_MAX_BUFFER);
126
296
  if (!commit.ok) {
127
297
  return {
128
298
  status: "failed",
129
299
  ok: false,
130
300
  allowlist,
131
- error: commit.stderr || commit.stdout || "git commit failed; hook changes were preserved.",
301
+ error: classifyCommitFailure(commit, commitTimeoutMs, GIT_COMMIT_MAX_BUFFER),
132
302
  };
133
303
  }
134
- const committedHead = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
304
+ const committedHead = probe(["rev-parse", "--verify", "HEAD"]);
135
305
  if (!committedHead.ok) {
136
306
  return {
137
307
  status: "failed",
@@ -140,11 +310,7 @@ export function authorConvergenceCommit(options) {
140
310
  error: committedHead.stderr || "Unable to validate the authored commit.",
141
311
  };
142
312
  }
143
- const committedParent = git(options.repoRoot, [
144
- "rev-parse",
145
- "--verify",
146
- `${committedHead.stdout}^`,
147
- ]);
313
+ const committedParent = probe(["rev-parse", "--verify", `${committedHead.stdout}^`]);
148
314
  if (!committedParent.ok || committedParent.stdout !== before.stdout) {
149
315
  return {
150
316
  status: "failed",
@@ -154,7 +320,7 @@ export function authorConvergenceCommit(options) {
154
320
  error: "The observed commit does not directly follow the recorded HEAD; no rollback was attempted. Inspect Git history and recover manually.",
155
321
  };
156
322
  }
157
- const committedPathsResult = git(options.repoRoot, [
323
+ const committedPathsResult = probe([
158
324
  "diff-tree",
159
325
  "--root",
160
326
  "--no-commit-id",
@@ -184,7 +350,7 @@ export function authorConvergenceCommit(options) {
184
350
  commit: committedHead.stdout,
185
351
  };
186
352
  }
187
- const currentHead = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
353
+ const currentHead = probe(["rev-parse", "--verify", "HEAD"]);
188
354
  if (!currentHead.ok || currentHead.stdout !== committedHead.stdout) {
189
355
  return {
190
356
  status: "failed",
@@ -196,7 +362,7 @@ export function authorConvergenceCommit(options) {
196
362
  error: "The setup-authored commit changed before validation; no rollback was attempted. Inspect Git history and recover manually.",
197
363
  };
198
364
  }
199
- const rollback = git(options.repoRoot, ["reset", "--mixed", before.stdout]);
365
+ const rollback = probe(["reset", "--mixed", before.stdout]);
200
366
  if (!rollback.ok) {
201
367
  return {
202
368
  status: "failed",
@@ -84,6 +84,8 @@ export interface ResolveCatalogDirOptions {
84
84
  readonly argv0?: string;
85
85
  readonly argv1?: string;
86
86
  readonly pathEnv?: string;
87
+ readonly pathExtEnv?: string;
88
+ readonly platform?: NodeJS.Platform;
87
89
  }
88
90
  export declare function resolveCatalogDir(options?: ResolveCatalogDirOptions): string;
89
91
  export declare function formatHostSetupSurfaceVisibility(input: {
@@ -24,7 +24,7 @@ import { isAgentKitSourceRepo } from "./source-repo-hook-policy.js";
24
24
  import { summarizeResults } from "./merge.js";
25
25
  import { WritePlan, collectRepoWriteEntries, renderWritePlan } from "./write-plan.js";
26
26
  import { writeConvergenceState } from "./convergence-state.js";
27
- import { authorConvergenceCommit, collectAppliedConvergeUnits, } from "./convergence-apply.js";
27
+ import { authorConvergenceCommit, collectAppliedConvergeUnits, formatWpSetupError, } from "./convergence-apply.js";
28
28
  import { prepareRepositoryForConvergence } from "./git-convergence.js";
29
29
  import { collectUserScopeSteps, userScopeStepsToEntries, } from "./user-scope-plan.js";
30
30
  import { clearUserScopeConsentMarker, resolveUserScopeConsent, writeUserScopeConsentMarker, } from "./user-scope-consent.js";
@@ -1979,7 +1979,7 @@ export async function runInit(flags, deps = {}) {
1979
1979
  isCi: isCiEnvironment,
1980
1980
  });
1981
1981
  if (!commitResult.ok) {
1982
- console.error(`wp setup: ${commitResult.error ?? "Unable to author convergence commit."}`);
1982
+ console.error(formatWpSetupError(commitResult.error ?? "Unable to author convergence commit."));
1983
1983
  return EXIT_WRITE_FAIL;
1984
1984
  }
1985
1985
  if (commitResult.status === "committed") {
@@ -8,6 +8,13 @@ export interface ResolveAgentKitPackageRootOptions {
8
8
  readonly pathExtEnv?: string;
9
9
  readonly platform?: NodeJS.Platform;
10
10
  readonly requireCatalog?: boolean;
11
+ /**
12
+ * Optional cwd anchor used as a last-resort walk start when module/argv/PATH
13
+ * anchors cannot locate a root (virtual bunfs/vite moduleUrl, vitest entry).
14
+ * Not defaulted to process.cwd() so hermetic tests stay isolated from the
15
+ * monorepo checkout the test process runs in.
16
+ */
17
+ readonly cwd?: string;
11
18
  }
12
19
  export declare function isAgentKitPackageRoot(dir: string, options?: {
13
20
  readonly requireCatalog?: boolean;
@@ -84,13 +84,15 @@ export function isAgentKitPackageRoot(dir, options = {}) {
84
84
  return false;
85
85
  return hasStableAgentKitRootMarker(dir);
86
86
  }
87
+ /** Max parent-walk hops from a start path. Deep pnpm/vitest/store layouts exceed 10. */
88
+ const PACKAGE_ROOT_WALK_MAX_DEPTH = 32;
87
89
  export function findAgentKitPackageRoot(startPath, options = {}) {
88
90
  if (!isRunnablePath(startPath))
89
91
  return null;
90
92
  const platform = options.platform ?? process.platform;
91
93
  const pathModule = pathModuleForPlatform(platform);
92
94
  let dir = pathModule.dirname(startPath);
93
- for (let depth = 0; depth < 10; depth++) {
95
+ for (let depth = 0; depth < PACKAGE_ROOT_WALK_MAX_DEPTH; depth++) {
94
96
  if (isAgentKitPackageRoot(dir, options))
95
97
  return dir;
96
98
  const parent = pathModule.dirname(dir);
@@ -113,7 +115,13 @@ export function resolveAgentKitPackageRoot(options = {}) {
113
115
  platform,
114
116
  });
115
117
  const requireCatalog = options.requireCatalog;
116
- for (const startPath of [modulePath, argv1, execPath, pathResolvedBin, argv0]) {
118
+ // Opt-in cwd last: monorepo/test hosts often run from the package root when
119
+ // moduleUrl is virtual (bunfs/vite) or argv points outside the tree (vitest).
120
+ // Callers that want the fallback pass `cwd` explicitly (see hook launchers).
121
+ const cwdStart = typeof options.cwd === "string" && options.cwd.length > 0
122
+ ? join(options.cwd, "package.json")
123
+ : undefined;
124
+ for (const startPath of [modulePath, argv1, execPath, pathResolvedBin, argv0, cwdStart]) {
117
125
  const root = findAgentKitPackageRoot(startPath ?? undefined, { requireCatalog, platform });
118
126
  if (root)
119
127
  return root;
@@ -21,10 +21,19 @@ export declare function readSetupWpActionRef(metaUrl: string): string;
21
21
  * consumer who deleted the step (with or without its comments) gets it back —
22
22
  * and therefore keeps inheriting new guardrails (e.g. typescript-version) on
23
23
  * their next agent-kit bump. Returns the content unchanged when the step is
24
- * already present, the workflow is not a wp quality pipeline, or no test step
25
- * anchor exists.
24
+ * already present, the workflow is not a wp quality pipeline, no test step
25
+ * anchor exists, or `repoAlreadyWired` is true (guardrails already run in a
26
+ * sibling workflow of the same repo — see `migrateConsumerWorkflows`, and
27
+ * tech-debt h-017: without this check a consumer who deliberately hosts
28
+ * guardrails in a dedicated `guardrails.yml` got a duplicate step injected
29
+ * into `ci.yml` on every `wp setup`).
30
+ *
31
+ * `hasGuardrailsInvocation`/`findLiveTestRunAnchor` come from the shared
32
+ * `ci-guardrails-detection` module also used by `auditCiGuardrailsWiring`, so
33
+ * the audit and this injector can never disagree about what counts as a wired
34
+ * guardrails step or a live test-run anchor (tech-debt h-016).
26
35
  */
27
- export declare function ensureGuardrailsCiStep(content: string): string;
36
+ export declare function ensureGuardrailsCiStep(content: string, repoAlreadyWired?: boolean): string;
28
37
  export declare function readAgentConfigVersion(metaUrl: string): string;
29
38
  export declare function collectRuntimeContractGuidance(packageJson: PackageJsonLike | null | undefined): RuntimeContractGuidance;
30
39
  export declare const BASE_KIT_QUALITY_TARGETS: string[];
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
4
  import { writeFileMerged } from "./merge.js";
5
5
  import { resolveAgentKitPackageRoot } from "./package-root.js";
6
+ import { findLiveTestRunAnchor, hasGuardrailsInvocation } from "#audit/ci-guardrails-detection.js";
6
7
  import { readPackageVersion } from "#cli/utils";
7
8
  import { SETUP_WP_ACTION_REF } from "#build/release-action-ref.js";
8
9
  const AUTHORING_TIME_DEPENDENCIES = [
@@ -162,12 +163,6 @@ function migrateWorkflowContent(content, actionRef, version) {
162
163
  })
163
164
  .join("\n");
164
165
  }
165
- // Detects whether a workflow already invokes `wp audit guardrails`, keyed on the
166
- // command itself (not a `wp` prefix nor a marker comment) — mirrors how
167
- // `hasAuditBlock()` (audit-hooks scaffolder) self-heals the pre-commit hook by
168
- // scanning for the invocation string, so re-injection works even if a consumer
169
- // stripped every surrounding comment.
170
- const GUARDRAILS_CI_INVOCATION = /\baudit guardrails\b/u;
171
166
  // A workflow is a wp-enabled quality pipeline when it both installs the `wp`
172
167
  // runtime and runs the default test suite. Gating on a wp-runtime anchor keeps
173
168
  // injection off non-wp workflows (a reusable-workflow release.yml, or
@@ -178,7 +173,6 @@ const GUARDRAILS_CI_INVOCATION = /\baudit guardrails\b/u;
178
173
  // `package-conventions.md`; e.g. ozby/edge-matte's ci.yml).
179
174
  const SETUP_WP_MARKER = "setup-wp@";
180
175
  const GLOBAL_AGENT_KIT_INSTALL = /^(?=.*\b(?:npm|pnpm|vp)\s+(?:install|i|add)\b)(?=.*(?:-g\b|--global\b))(?=.*@webpresso\/agent-kit\b).*$/imu;
181
- const GUARDRAILS_CI_TEST_RUN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
182
176
  function hasWpRuntimeAnchor(content) {
183
177
  return content.includes(SETUP_WP_MARKER) || GLOBAL_AGENT_KIT_INSTALL.test(content);
184
178
  }
@@ -189,30 +183,31 @@ function hasWpRuntimeAnchor(content) {
189
183
  * consumer who deleted the step (with or without its comments) gets it back —
190
184
  * and therefore keeps inheriting new guardrails (e.g. typescript-version) on
191
185
  * their next agent-kit bump. Returns the content unchanged when the step is
192
- * already present, the workflow is not a wp quality pipeline, or no test step
193
- * anchor exists.
186
+ * already present, the workflow is not a wp quality pipeline, no test step
187
+ * anchor exists, or `repoAlreadyWired` is true (guardrails already run in a
188
+ * sibling workflow of the same repo — see `migrateConsumerWorkflows`, and
189
+ * tech-debt h-017: without this check a consumer who deliberately hosts
190
+ * guardrails in a dedicated `guardrails.yml` got a duplicate step injected
191
+ * into `ci.yml` on every `wp setup`).
192
+ *
193
+ * `hasGuardrailsInvocation`/`findLiveTestRunAnchor` come from the shared
194
+ * `ci-guardrails-detection` module also used by `auditCiGuardrailsWiring`, so
195
+ * the audit and this injector can never disagree about what counts as a wired
196
+ * guardrails step or a live test-run anchor (tech-debt h-016).
194
197
  */
195
- export function ensureGuardrailsCiStep(content) {
196
- if (GUARDRAILS_CI_INVOCATION.test(content))
198
+ export function ensureGuardrailsCiStep(content, repoAlreadyWired = false) {
199
+ if (hasGuardrailsInvocation(content))
200
+ return content;
201
+ if (repoAlreadyWired)
197
202
  return content;
198
203
  if (!hasWpRuntimeAnchor(content))
199
204
  return content;
200
- const lines = content.split("\n");
201
- let anchorStepStart = -1;
202
- let lastStepStart = -1;
203
- for (let index = 0; index < lines.length; index += 1) {
204
- const line = lines[index];
205
- if (line.trim().startsWith("#"))
206
- continue;
207
- if (/^\s*-\s/u.test(line))
208
- lastStepStart = index;
209
- if (GUARDRAILS_CI_TEST_RUN.test(line)) {
210
- anchorStepStart = lastStepStart;
211
- break;
212
- }
213
- }
205
+ const anchorStepStart = findLiveTestRunAnchor(content);
214
206
  if (anchorStepStart === -1)
215
207
  return content;
208
+ const lines = content.split("\n");
209
+ // Safe: anchorStepStart is an index returned by findLiveTestRunAnchor over
210
+ // this same content.split("\n"), so it is always within lines' bounds here.
216
211
  const indent = lines[anchorStepStart].match(/^(\s*)/u)?.[1] ?? "";
217
212
  lines.splice(anchorStepStart, 0, `${indent}- name: Run wp audit guardrails`, `${indent} run: wp audit guardrails`);
218
213
  return lines.join("\n");
@@ -221,12 +216,18 @@ function migrateConsumerWorkflows(repoRoot, actionRef, version, options) {
221
216
  const workflowDir = join(repoRoot, ".github", "workflows");
222
217
  if (!existsSync(workflowDir))
223
218
  return [];
224
- return readdirSync(workflowDir)
225
- .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
226
- .map((name) => {
219
+ const names = readdirSync(workflowDir).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"));
220
+ // Read every workflow's original content once, up front, so per-file
221
+ // injection below can see the REPO-LEVEL wiring state (mirrors how
222
+ // `auditCiGuardrailsWiring` reasons across all workflows) instead of only
223
+ // the file it happens to be looking at. Without this, a consumer who
224
+ // deliberately hosts guardrails in a dedicated `guardrails.yml` gets a
225
+ // duplicate step injected into `ci.yml` on every `wp setup` (h-017).
226
+ const entries = names.map((name) => [name, readFileSync(join(workflowDir, name), "utf8")]);
227
+ const repoAlreadyWired = entries.some(([, content]) => hasGuardrailsInvocation(content));
228
+ return entries.map(([name, existing]) => {
227
229
  const targetPath = join(workflowDir, name);
228
- const existing = readFileSync(targetPath, "utf8");
229
- const migrated = ensureGuardrailsCiStep(migrateWorkflowContent(existing, actionRef, version));
230
+ const migrated = ensureGuardrailsCiStep(migrateWorkflowContent(existing, actionRef, version), repoAlreadyWired);
230
231
  if (migrated === existing)
231
232
  return { targetPath, action: "identical" };
232
233
  if (options.dryRun)
@@ -581,7 +581,9 @@ export function disableManagedHooksFromManifest(input, manifest, vendors) {
581
581
  return result;
582
582
  }
583
583
  export function resolvePackageRootForHookLaunchers(options = {}) {
584
- return resolveAgentKitPackageRootOrThrow("wp setup: could not locate @webpresso/agent-kit package root for hook launchers.", options);
584
+ // Default cwd so vitest/bunfs hosts that lose moduleUrl still resolve from
585
+ // the monorepo/package checkout the process is running in (CI flake fix).
586
+ return resolveAgentKitPackageRootOrThrow("wp setup: could not locate @webpresso/agent-kit package root for hook launchers.", { cwd: process.cwd(), ...options });
585
587
  }
586
588
  /**
587
589
  * The `wp hook <sub>` subcommand maps each catalogued `wp-*` hook bin to the
@@ -1,5 +1,20 @@
1
- /** Serialize a complete read/modify/write transaction for a host config file. */
2
- export declare function withScaffolderFileLock<T>(path: string, action: () => T): T;
1
+ /**
2
+ * Test-only seams following the `claimInstallLock` options precedent. `isAlive`
3
+ * and `now` make liveness/age deterministic; `onBeforeReclaimDelete` fires while
4
+ * the reaper is held, just before a stale lock is removed, so a test can prove
5
+ * reclaim is serialized. Never supplied in production.
6
+ */
7
+ export interface ScaffolderLockSeams {
8
+ readonly isAlive?: (pid: number) => boolean;
9
+ readonly now?: () => number;
10
+ readonly onBeforeReclaimDelete?: () => void;
11
+ }
12
+ /**
13
+ * Serialize a complete read/modify/write transaction for a host config file.
14
+ * These are local single-machine home-config locks (`~/.codex`, `~/.grok`), so
15
+ * owner-pid liveness is meaningful; this is not a shared-filesystem lock.
16
+ */
17
+ export declare function withScaffolderFileLock<T>(path: string, action: () => T, seams?: ScaffolderLockSeams): T;
3
18
  /**
4
19
  * Serialize a text-file read/modify/write and install the replacement strictly
5
20
  * atomically without weakening an existing file's permissions.