@webpresso/agent-kit 3.2.0 → 3.3.0

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 (93) hide show
  1. package/bin/_run.js +4 -1
  2. package/dist/esm/audit/blueprint-trust.js +14 -10
  3. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  4. package/dist/esm/audit/changeset-evidence.js +30 -0
  5. package/dist/esm/audit/changeset-required.d.ts +10 -0
  6. package/dist/esm/audit/changeset-required.js +73 -0
  7. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  8. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  9. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  10. package/dist/esm/audit/registry.d.ts +4 -4
  11. package/dist/esm/audit/registry.js +6 -1
  12. package/dist/esm/blueprint/core/parser.js +61 -4
  13. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  14. package/dist/esm/blueprint/core/schema.js +26 -0
  15. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  16. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  17. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  18. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  20. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  21. package/dist/esm/blueprint/sync/client.js +1 -1
  22. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  23. package/dist/esm/blueprint/trust/dossier.js +40 -6
  24. package/dist/esm/blueprint/trust/promotion.js +12 -0
  25. package/dist/esm/build/cli-mcp-parity.js +5 -0
  26. package/dist/esm/cli/cli.d.ts +1 -1
  27. package/dist/esm/cli/cli.js +9 -0
  28. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  29. package/dist/esm/cli/commands/agent-launch.js +31 -20
  30. package/dist/esm/cli/commands/audit.js +4 -0
  31. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  32. package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
  33. package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
  34. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  35. package/dist/esm/cli/commands/init/index.js +2 -2
  36. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  37. package/dist/esm/cli/commands/init/package-root.js +10 -2
  38. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  39. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  40. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  41. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  42. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  43. package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
  44. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  45. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  46. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  47. package/dist/esm/cli/commands/quality-runner.js +3 -3
  48. package/dist/esm/cli/commands/review.js +27 -5
  49. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  50. package/dist/esm/cli/commands/rust-check.js +110 -0
  51. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  52. package/dist/esm/cli/commands/typecheck.js +30 -1
  53. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
  54. package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
  55. package/dist/esm/cli/commands/worktree/router.js +10 -0
  56. package/dist/esm/hooks/doctor.d.ts +2 -0
  57. package/dist/esm/hooks/doctor.js +37 -1
  58. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  59. package/dist/esm/hooks/shared/types.js +27 -1
  60. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  61. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  62. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  63. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  64. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  65. package/dist/esm/mcp/tools/audit.js +12 -0
  66. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  67. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  68. package/dist/esm/package.json +2 -0
  69. package/dist/esm/platform/client.d.ts +7 -1
  70. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  71. package/dist/esm/pr-description/contract.js +24 -4
  72. package/dist/esm/review/authority.js +14 -9
  73. package/dist/esm/review/availability.d.ts +11 -0
  74. package/dist/esm/review/availability.js +18 -3
  75. package/dist/esm/review/execution/adapters.d.ts +11 -1
  76. package/dist/esm/review/execution/adapters.js +10 -1
  77. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  78. package/dist/esm/review/execution/review-checkout.js +35 -16
  79. package/dist/esm/review/execution/supervisor.js +31 -4
  80. package/dist/esm/review/execution/types.d.ts +1 -0
  81. package/dist/esm/review/subject.d.ts +13 -0
  82. package/dist/esm/review/subject.js +42 -4
  83. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  84. package/dist/esm/rust/affected-manifests.js +69 -0
  85. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  86. package/dist/esm/status/snapshot.js +2 -6
  87. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  88. package/dist/esm/test-helpers/global-setup.js +18 -15
  89. package/dist/esm/typecheck/planner.d.ts +9 -1
  90. package/dist/esm/typecheck/planner.js +31 -0
  91. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  92. package/dist/esm/worktrees/orphan-scan.js +211 -0
  93. package/package.json +14 -12
@@ -146,6 +146,10 @@ export const REPO_AUDIT_REGISTRY = {
146
146
  "legacy-workflow-identity": async (root) => (await import("#audit/legacy-workflow-identity")).auditLegacyWorkflowIdentity(root),
147
147
  rules: async (root) => runContentAudit(root, "rule"),
148
148
  skills: async (root) => runContentAudit(root, "skill"),
149
+ "changeset-required": async (root, options) => (await import("#audit/changeset-required")).auditChangesetRequired(root, {
150
+ baseRef: options.baseRef ?? options.base,
151
+ changedFiles: options.affectedFiles,
152
+ }),
149
153
  };
150
154
  async function runContentAudit(root, kind) {
151
155
  const { auditContent } = await import("../../content/audit.js");
@@ -24,9 +24,12 @@ export const CI_PREFLIGHT_COMMAND_HELP = [
24
24
  "Runs, in order, stopping at the first failure:",
25
25
  " 1. wp format --affected --branch --check",
26
26
  " 2. wp audit guardrails --affected --branch",
27
- " 3. wp typecheck --affected --branch",
28
- " 4. wp lint --affected --branch",
29
- " 5. wp test --affected --branch",
27
+ " 3. wp audit changeset-required --affected --branch",
28
+ " 4. wp typecheck --affected --branch",
29
+ " 5. wp typecheck --tests",
30
+ " 6. wp lint --affected --branch",
31
+ " 7. wp rust-check --affected --branch",
32
+ " 8. wp test --affected --branch",
30
33
  "",
31
34
  "This is what `.husky/pre-push` runs. A normal `git push` (no --no-verify)",
32
35
  "must pass it. Network-bound / whole-repo gates (dependency freshness) and",
@@ -42,9 +45,27 @@ export const CI_PREFLIGHT_COMMAND_HELP = [
42
45
  export const CI_PREFLIGHT_CHECKS = [
43
46
  { label: "format", args: ["format", "--affected", "--branch", "--check"] },
44
47
  { label: "guardrails", args: ["audit", "guardrails", "--affected", "--branch"] },
48
+ // Mirrors the `changeset-missing` blocker `wp_pr_status` computes against a
49
+ // live PR (`#status/snapshot.js`), scoped to the local branch diff so a
50
+ // missing changeset surfaces before push instead of after — see
51
+ // `catalog/agent/rules/ci-cost-local-first.md`. Deliberately excluded from
52
+ // the `wp audit guardrails` composite (see `changeset-required`'s
53
+ // `includedInGuardrails: false` in `#audit/registry.js`), so it gets its own
54
+ // preflight step.
55
+ { label: "changeset", args: ["audit", "changeset-required", "--affected", "--branch"] },
45
56
  { label: "typecheck", args: ["typecheck", "--affected", "--branch"] },
57
+ // Required test-surface typecheck (tsconfig.test.json). Not branch-scoped:
58
+ // the tests lane is a single whole-repo tsc project and is the burn-down
59
+ // gate that keeps test/script types from rotting again.
60
+ { label: "typecheck-tests", args: ["typecheck", "--tests"] },
46
61
  // CI runs lint after typecheck in the Lint & typecheck job; mirror that order.
47
62
  { label: "lint", args: ["lint", "--affected", "--branch"] },
63
+ // TS/JS affectedness (above) says nothing about the Rust crates
64
+ // (native/session-memory-engine, native/dash-tui, apps/desktop/src-tauri) —
65
+ // without this stage a Rust-only branch reported every stage above as "no
66
+ // affected files" and exited green having proven nothing about the Rust
67
+ // (see `#rust/affected-manifests.js`).
68
+ { label: "rust", args: ["rust-check", "--affected", "--branch"] },
48
69
  { label: "test", args: ["test", "--affected", "--branch"] },
49
70
  ];
50
71
  function reproduceLine(check) {
@@ -1,5 +1,64 @@
1
1
  import { type ConvergeUnit } from "./convergence.js";
2
2
  export { resolveUntrackedCollision } from "./untracked-collision.js";
3
+ /** Bounded timeout for non-hook Git probes (status, add, rev-parse, diff-tree). */
4
+ export declare const GIT_PROBE_TIMEOUT_MS = 15000;
5
+ /**
6
+ * Default wall for the single hookful `git commit`. Consumer pre-commit chains
7
+ * (format + audits) routinely exceed the probe budget; Node's spawnSync kills
8
+ * the child with SIGTERM when `timeout` elapses, which left staged residue and
9
+ * multi-line partial hook output that looked like success.
10
+ *
11
+ * 10 minutes is a durable consumer budget, not infinite hang. Operators can
12
+ * raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
13
+ */
14
+ export declare const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600000;
15
+ /**
16
+ * Resolve the hookful-commit timeout. Preference order:
17
+ * 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
18
+ * 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
19
+ * 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
20
+ *
21
+ * Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
22
+ */
23
+ export declare function resolveCommitTimeoutMs(options?: {
24
+ readonly commitTimeoutMs?: number;
25
+ readonly env?: NodeJS.ProcessEnv;
26
+ }): number;
27
+ /** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
28
+ export declare function formatCommitTimeoutError(options: {
29
+ readonly timeoutMs: number;
30
+ readonly hookOutput?: string;
31
+ }): string;
32
+ /**
33
+ * First-class interrupt diagnosis for non-timeout signal kills (operator kill,
34
+ * external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
35
+ * leading operator message.
36
+ */
37
+ export declare function formatCommitInterruptError(options: {
38
+ readonly signal: NodeJS.Signals;
39
+ readonly hookOutput?: string;
40
+ }): string;
41
+ /**
42
+ * Classify a failed hookful `git commit` result.
43
+ *
44
+ * - `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
45
+ * - non-null `signal` without timedOut → interrupt diagnosis (names the signal)
46
+ * - otherwise → hook/git stderr when present, else a generic preserved-index message
47
+ *
48
+ * Never lead with bare hook "passed" lines for timeout/interrupt paths.
49
+ */
50
+ export declare function classifyCommitFailure(result: {
51
+ readonly timedOut: boolean;
52
+ readonly signal: NodeJS.Signals | null;
53
+ readonly stderr: string;
54
+ readonly stdout: string;
55
+ }, timeoutMs: number): string;
56
+ /**
57
+ * Prefix every line of a setup failure so multi-line timeout diagnoses stay
58
+ * labeled when printed via console.error (avoids a single `wp setup: ` on the
59
+ * first line and bare hook "passed" lines after it).
60
+ */
61
+ export declare function formatWpSetupError(message: string): string;
3
62
  export type AuthorCommitStatus = "committed" | "no-change" | "skipped-ci" | "failed" | "rolled-back";
4
63
  export interface AuthorCommitResult {
5
64
  readonly status: AuthorCommitStatus;
@@ -15,6 +74,11 @@ export interface AuthorCommitOptions {
15
74
  readonly appliedUnits: readonly ConvergeUnit[];
16
75
  readonly message: string;
17
76
  readonly isCi?: boolean;
77
+ /**
78
+ * Override wall for the single hookful `git commit` (ms). Prefer this in tests.
79
+ * `0` disables the artificial timeout. Probe Git calls always stay on the short budget.
80
+ */
81
+ readonly commitTimeoutMs?: number;
18
82
  }
19
83
  export interface AppliedConvergenceCandidate {
20
84
  readonly targetPath: string;
@@ -2,21 +2,124 @@ 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
+ const GIT_MAX_BUFFER = 1024 * 1024;
18
+ function git(repoRoot, args, timeoutMs) {
7
19
  const result = spawnSync("git", [...args], {
8
20
  cwd: repoRoot,
9
21
  encoding: "utf8",
10
- timeout: GIT_TIMEOUT_MS,
11
- maxBuffer: 1024 * 1024,
22
+ // Node: timeout > 0 arms SIGTERM; 0 / omitted means no artificial wall.
23
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
24
+ maxBuffer: GIT_MAX_BUFFER,
12
25
  stdio: ["ignore", "pipe", "pipe"],
13
26
  });
27
+ const timedOut = result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT";
14
28
  return {
15
29
  ok: result.status === 0 && !result.error,
16
- stdout: result.stdout.trim(),
17
- stderr: result.stderr.trim(),
30
+ stdout: (result.stdout ?? "").trim(),
31
+ stderr: (result.stderr ?? "").trim(),
32
+ timedOut,
33
+ signal: result.signal,
18
34
  };
19
35
  }
36
+ /**
37
+ * Resolve the hookful-commit timeout. Preference order:
38
+ * 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
39
+ * 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
40
+ * 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
41
+ *
42
+ * Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
43
+ */
44
+ export function resolveCommitTimeoutMs(options) {
45
+ const env = options?.env ?? process.env;
46
+ if (typeof options?.commitTimeoutMs === "number" &&
47
+ Number.isFinite(options.commitTimeoutMs) &&
48
+ options.commitTimeoutMs >= 0) {
49
+ return options.commitTimeoutMs;
50
+ }
51
+ const raw = env.WP_SETUP_COMMIT_TIMEOUT_MS;
52
+ if (typeof raw === "string" && raw.trim().length > 0) {
53
+ const parsed = Number.parseInt(raw, 10);
54
+ if (Number.isFinite(parsed) && parsed >= 0)
55
+ return parsed;
56
+ }
57
+ return DEFAULT_GIT_COMMIT_TIMEOUT_MS;
58
+ }
59
+ function appendHookOutput(diagnosis, hookOutput) {
60
+ const trimmed = hookOutput?.trim();
61
+ if (!trimmed)
62
+ return diagnosis;
63
+ const snippet = trimmed.length > 400 ? `${trimmed.slice(0, 400)}…` : trimmed;
64
+ return `${diagnosis}\n--- hook output (truncated) ---\n${snippet}`;
65
+ }
66
+ /** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
67
+ export function formatCommitTimeoutError(options) {
68
+ const budget = options.timeoutMs > 0
69
+ ? `${options.timeoutMs}ms`
70
+ : "the configured budget (no artificial wall was set, but the process was still interrupted)";
71
+ const diagnosis = `Convergence commit timed out after ${budget} while repository hooks were still running. ` +
72
+ `Node spawnSync kills the git process with SIGTERM on timeout, so no setup commit was authored. ` +
73
+ `Staged and worktree changes were preserved for remediation. ` +
74
+ `Re-run after hooks complete, raise WP_SETUP_COMMIT_TIMEOUT_MS / commitTimeoutMs, or fix slow hooks. ` +
75
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
76
+ return appendHookOutput(diagnosis, options.hookOutput);
77
+ }
78
+ /**
79
+ * First-class interrupt diagnosis for non-timeout signal kills (operator kill,
80
+ * external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
81
+ * leading operator message.
82
+ */
83
+ export function formatCommitInterruptError(options) {
84
+ const diagnosis = `Convergence commit was interrupted by ${options.signal} before completion, so no setup commit was authored. ` +
85
+ `Staged and worktree changes were preserved for remediation. ` +
86
+ `This is not a successful setup run. ` +
87
+ `Partial hook lines such as "format applied" / "audit … passed" are not success.`;
88
+ return appendHookOutput(diagnosis, options.hookOutput);
89
+ }
90
+ /**
91
+ * Classify a failed hookful `git commit` result.
92
+ *
93
+ * - `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
94
+ * - non-null `signal` without timedOut → interrupt diagnosis (names the signal)
95
+ * - otherwise → hook/git stderr when present, else a generic preserved-index message
96
+ *
97
+ * Never lead with bare hook "passed" lines for timeout/interrupt paths.
98
+ */
99
+ export function classifyCommitFailure(result, timeoutMs) {
100
+ const hookOutput = [result.stderr, result.stdout].filter(Boolean).join("\n");
101
+ if (result.timedOut) {
102
+ return formatCommitTimeoutError({ timeoutMs, hookOutput });
103
+ }
104
+ if (result.signal) {
105
+ return formatCommitInterruptError({ signal: result.signal, hookOutput });
106
+ }
107
+ return hookOutput || "git commit failed; hook changes were preserved.";
108
+ }
109
+ /**
110
+ * Prefix every line of a setup failure so multi-line timeout diagnoses stay
111
+ * labeled when printed via console.error (avoids a single `wp setup: ` on the
112
+ * first line and bare hook "passed" lines after it).
113
+ */
114
+ export function formatWpSetupError(message) {
115
+ const body = message.trimEnd();
116
+ if (body.length === 0)
117
+ return "wp setup: Unable to author convergence commit.";
118
+ return body
119
+ .split("\n")
120
+ .map((line) => `wp setup: ${line}`)
121
+ .join("\n");
122
+ }
20
123
  function lines(value) {
21
124
  return value.length === 0 ? [] : value.split("\n").filter(Boolean).sort();
22
125
  }
@@ -32,8 +135,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
32
135
  ], {
33
136
  cwd: repoRoot,
34
137
  encoding: "utf8",
35
- timeout: GIT_TIMEOUT_MS,
36
- maxBuffer: 1024 * 1024,
138
+ timeout: GIT_PROBE_TIMEOUT_MS,
139
+ maxBuffer: GIT_MAX_BUFFER,
37
140
  stdio: ["ignore", "pipe", "pipe"],
38
141
  });
39
142
  if (result.status !== 0 || result.error)
@@ -52,7 +155,8 @@ function readPorcelainStatus(repoRoot, gitPath) {
52
155
  return entries;
53
156
  }
54
157
  function isIgnored(repoRoot, gitPath) {
55
- return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath]).ok;
158
+ return git(repoRoot, ["check-ignore", "--no-index", "-q", "--", gitPath], GIT_PROBE_TIMEOUT_MS)
159
+ .ok;
56
160
  }
57
161
  export function collectAppliedConvergeUnits(repoRoot, candidates) {
58
162
  const units = [];
@@ -107,31 +211,35 @@ export function authorConvergenceCommit(options) {
107
211
  return { status: "skipped-ci", ok: true, allowlist };
108
212
  if (allowlist.length === 0)
109
213
  return { status: "no-change", ok: true, allowlist };
110
- const before = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
214
+ const probe = (args) => git(options.repoRoot, args, GIT_PROBE_TIMEOUT_MS);
215
+ const before = probe(["rev-parse", "--verify", "HEAD"]);
111
216
  if (!before.ok) {
112
217
  return { status: "failed", ok: false, allowlist, error: before.stderr || "Missing HEAD." };
113
218
  }
114
- const add = git(options.repoRoot, ["add", "--", ...allowlist]);
219
+ const add = probe(["add", "--", ...allowlist]);
115
220
  if (!add.ok) {
116
221
  return { status: "failed", ok: false, allowlist, error: add.stderr || "git add failed." };
117
222
  }
118
- const staged = git(options.repoRoot, ["diff", "--cached", "--name-only"]);
223
+ const staged = probe(["diff", "--cached", "--name-only"]);
119
224
  if (!staged.ok) {
120
225
  return { status: "failed", ok: false, allowlist, error: staged.stderr || "git diff failed." };
121
226
  }
122
227
  if (lines(staged.stdout).length === 0) {
123
228
  return { status: "no-change", ok: true, allowlist };
124
229
  }
125
- const commit = git(options.repoRoot, ["commit", "-m", options.message]);
230
+ const commitTimeoutMs = resolveCommitTimeoutMs({
231
+ commitTimeoutMs: options.commitTimeoutMs,
232
+ });
233
+ const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs);
126
234
  if (!commit.ok) {
127
235
  return {
128
236
  status: "failed",
129
237
  ok: false,
130
238
  allowlist,
131
- error: commit.stderr || commit.stdout || "git commit failed; hook changes were preserved.",
239
+ error: classifyCommitFailure(commit, commitTimeoutMs),
132
240
  };
133
241
  }
134
- const committedHead = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
242
+ const committedHead = probe(["rev-parse", "--verify", "HEAD"]);
135
243
  if (!committedHead.ok) {
136
244
  return {
137
245
  status: "failed",
@@ -140,11 +248,7 @@ export function authorConvergenceCommit(options) {
140
248
  error: committedHead.stderr || "Unable to validate the authored commit.",
141
249
  };
142
250
  }
143
- const committedParent = git(options.repoRoot, [
144
- "rev-parse",
145
- "--verify",
146
- `${committedHead.stdout}^`,
147
- ]);
251
+ const committedParent = probe(["rev-parse", "--verify", `${committedHead.stdout}^`]);
148
252
  if (!committedParent.ok || committedParent.stdout !== before.stdout) {
149
253
  return {
150
254
  status: "failed",
@@ -154,7 +258,7 @@ export function authorConvergenceCommit(options) {
154
258
  error: "The observed commit does not directly follow the recorded HEAD; no rollback was attempted. Inspect Git history and recover manually.",
155
259
  };
156
260
  }
157
- const committedPathsResult = git(options.repoRoot, [
261
+ const committedPathsResult = probe([
158
262
  "diff-tree",
159
263
  "--root",
160
264
  "--no-commit-id",
@@ -184,7 +288,7 @@ export function authorConvergenceCommit(options) {
184
288
  commit: committedHead.stdout,
185
289
  };
186
290
  }
187
- const currentHead = git(options.repoRoot, ["rev-parse", "--verify", "HEAD"]);
291
+ const currentHead = probe(["rev-parse", "--verify", "HEAD"]);
188
292
  if (!currentHead.ok || currentHead.stdout !== committedHead.stdout) {
189
293
  return {
190
294
  status: "failed",
@@ -196,7 +300,7 @@ export function authorConvergenceCommit(options) {
196
300
  error: "The setup-authored commit changed before validation; no rollback was attempted. Inspect Git history and recover manually.",
197
301
  };
198
302
  }
199
- const rollback = git(options.repoRoot, ["reset", "--mixed", before.stdout]);
303
+ const rollback = probe(["reset", "--mixed", before.stdout]);
200
304
  if (!rollback.ok) {
201
305
  return {
202
306
  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.