@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
@@ -12,6 +12,7 @@ import { loadOpencodeAccountAvailability, pruneExpiredOpencodeAccountSuppression
12
12
  import { selectActiveOpencodeAccount } from "#review/opencode-account.js";
13
13
  import { materializeOpencodeGoAuth } from "#review/opencode-auth.js";
14
14
  import { describeOpencodeRotate, OPENCODE_ROTATE_FLAG, rotateActiveOpencodeAccount, } from "#cli/commands/opencode-rotate.js";
15
+ import { OPENCODE_SKIP_PROBE_ENV, resolveHealthyOpencodeAccount, } from "#cli/commands/opencode-probe.js";
15
16
  export const AGENT_LAUNCH_COMMANDS = DASH_PROVIDERS;
16
17
  export const DEFAULT_AGENT_SECRET_PROFILE = "preview";
17
18
  export const AGENT_SECRET_PROFILE_ENV = "WP_AGENT_SECRET_PROFILE";
@@ -89,21 +90,26 @@ function ensureCodexSkillSurface(cwd, env = process.env) {
89
90
  }
90
91
  }
91
92
  /**
92
- * Choose the active opencode-go account and materialize its key into the
93
- * global opencode `auth.json` before the TUI launches.
93
+ * Choose the active opencode-go account, verify it against the real provider,
94
+ * and materialize its key into the global opencode `auth.json` before the TUI
95
+ * launches.
96
+ *
97
+ * A launch-time health probe (`resolveHealthyOpencodeAccount`) spawns a bounded
98
+ * `opencode run` against the candidate's isolated credential store; a detected
99
+ * usage limit cools that account with the provider's real reset window and the
100
+ * next account is probed instead. So a plain relaunch now *does* rotate past an
101
+ * exhausted account, and `wp opencode --wp-rotate` is only needed to force a
102
+ * swap off an account that is still answering.
94
103
  *
95
104
  * Honest limitation: interactive opencode is a long-lived TUI — the account is
96
- * chosen once at launch and a mid-session dollar-cap is NOT auto-swapped. A
97
- * plain relaunch does NOT rotate either: nothing on this path records a
98
- * cooldown, so an exhausted account stays "first non-suppressed" and gets
99
- * re-picked every time. Run `wp opencode --wp-rotate` to cool the current
100
- * account and launch on the next one. Only the review gate does automatic
101
- * in-flight failover, because only it can observe the usage-limit response.
105
+ * chosen once at launch, so a cap hit *mid-session* is still NOT auto-swapped.
106
+ * Only the review gate does in-flight failover, because only it observes every
107
+ * provider response for the life of the run.
102
108
  *
103
- * Never throws: a Doppler outage or materialize failure must not block the
104
- * TUI from launching, so every failure is logged and swallowed.
109
+ * Never throws: a Doppler outage, a failed probe, or a materialize failure must
110
+ * not block the TUI from launching, so every failure is logged and swallowed.
105
111
  */
106
- function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
112
+ async function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env) {
107
113
  try {
108
114
  const resolution = (deps.resolveOpencodeAccounts ?? resolveOpencodeAccountsGracefully)({
109
115
  cwd,
@@ -112,11 +118,15 @@ function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
112
118
  if (!resolution.ok)
113
119
  return;
114
120
  const pruned = pruneExpiredOpencodeAccountSuppressions(loadOpencodeAccountAvailability());
115
- const selection = selectActiveOpencodeAccount(resolution.accounts, pruned.state);
116
- if (!selection)
121
+ const initial = selectActiveOpencodeAccount(resolution.accounts, pruned.state);
122
+ if (!initial)
117
123
  return;
124
+ const probed = await (deps.resolveHealthyOpencodeAccount ?? resolveHealthyOpencodeAccount)(resolution.accounts, initial, { env });
125
+ for (const line of probed.lines)
126
+ console.error(line);
127
+ const selection = probed.selection;
118
128
  (deps.materializeGlobalOpencodeAuth ?? ((key) => materializeOpencodeGoAuth(key)))(selection.account.key);
119
- if (selection.cooled) {
129
+ if (selection.cooled && probed.lines.length === 0) {
120
130
  console.error(`wp opencode: all OpenCode Go accounts are within their usage cooldown; launching on the soonest-expiring account (${selection.account.id}).`);
121
131
  }
122
132
  }
@@ -130,10 +140,11 @@ function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
130
140
  * forward to the binary. `--wp-rotate` is consumed here and must never reach
131
141
  * opencode, which would treat it as a prompt.
132
142
  */
133
- async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile) {
143
+ async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile, env) {
134
144
  if (!args.includes(OPENCODE_ROTATE_FLAG)) {
135
- if (secretsConfig !== null)
136
- selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile);
145
+ if (secretsConfig !== null) {
146
+ await selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env);
147
+ }
137
148
  return args;
138
149
  }
139
150
  const outcome = await rotateActiveOpencodeAccount(cwd, secretProfile, deps);
@@ -144,7 +155,7 @@ async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfi
144
155
  // rotation could not run, fall through to that selection so the launch still
145
156
  // gets the best account it can rather than whatever the store happens to hold.
146
157
  if (outcome.kind === "unavailable" && secretsConfig !== null) {
147
- selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile);
158
+ await selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env);
148
159
  }
149
160
  return args.filter((arg) => arg !== OPENCODE_ROTATE_FLAG);
150
161
  }
@@ -166,7 +177,7 @@ export async function runAgentLaunchCommand(command, args = [], options = {}, de
166
177
  // worktree, CI clone, and fresh checkout.
167
178
  const secretProfile = resolveAgentSecretProfile(options.env ?? process.env);
168
179
  const launchArgs = command === "opencode"
169
- ? await prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile)
180
+ ? await prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile, options.env ?? process.env)
170
181
  : args;
171
182
  const runtimeOptions = buildAgentRuntimeCommandOptions({
172
183
  command,
@@ -184,7 +195,7 @@ export async function runAgentLaunchCommand(command, args = [], options = {}, de
184
195
  }
185
196
  export function registerAgentLaunchCommand(cli, command) {
186
197
  const rotateHint = command === "opencode"
187
- ? ` (pass \`${OPENCODE_ROTATE_FLAG}\` to cool the current OpenCode Go account and launch on the next one)`
198
+ ? ` (the selected OpenCode Go account is health-probed first and a capped one is rotated past automatically; pass \`${OPENCODE_ROTATE_FLAG}\` to force a rotation, or set \`${OPENCODE_SKIP_PROBE_ENV}=1\` to skip the probe)`
188
199
  : "";
189
200
  cli
190
201
  .command(`${command} [...args]`, `Launch ${command} with provider-backed agent secrets and safe-by-default permissions (run \`wp yolo on\` for persistent full-auto)${rotateHint}`)
@@ -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");
@@ -0,0 +1,21 @@
1
+ export interface AbandonBlueprintOptions {
2
+ readonly projectRoot?: string;
3
+ readonly confirm?: boolean;
4
+ /** Injected for tests. */
5
+ readonly resolveOwnerPath?: (repoRoot: string, slug: string) => string;
6
+ readonly readPorcelainZ?: (cwd: string) => string;
7
+ readonly hasCommittedDraft?: (cwd: string, slug: string) => boolean;
8
+ readonly isPrimaryCheckout?: (ownerPath: string, repoRoot: string) => boolean;
9
+ }
10
+ export interface AbandonBlueprintResult {
11
+ readonly slug: string;
12
+ readonly ownerPath: string;
13
+ readonly preview: boolean;
14
+ readonly message: string;
15
+ readonly paths: readonly string[];
16
+ }
17
+ /**
18
+ * Abandon an uncommitted draft scaffold so merge-cleanup can proceed.
19
+ * Without --confirm: print preview and throw (exit non-zero at CLI).
20
+ */
21
+ export declare function abandonBlueprint(rawSlug: string, options?: AbandonBlueprintOptions): Promise<AbandonBlueprintResult>;
@@ -0,0 +1,178 @@
1
+ import { existsSync, lstatSync, rmSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { isValidBlueprintSlug, relativeBlueprintSlug } from "#lifecycle/local.js";
5
+ import { resolveProjectRoot } from "#mcp/tools/_shared/project-root.js";
6
+ import { classifyOwnerDirt, draftScaffoldPrefix, formatBoundedPaths, } from "#worktrees/owner-dirt.js";
7
+ import { resolveOwnerWorktreePath } from "#worktrees/location.js";
8
+ function gitStdout(cwd, args) {
9
+ const result = spawnSync("git", [...args], { cwd, encoding: "utf8" });
10
+ if (result.status !== 0) {
11
+ const err = String(result.stderr ?? result.stdout ?? "git failed").trim();
12
+ throw new Error(err || `git ${args.join(" ")} failed`);
13
+ }
14
+ return String(result.stdout ?? "");
15
+ }
16
+ function defaultReadPorcelainZ(cwd) {
17
+ return gitStdout(cwd, [
18
+ "status",
19
+ "--porcelain=v1",
20
+ "--untracked-files=all",
21
+ "--ignored=no",
22
+ "-z",
23
+ ]);
24
+ }
25
+ function defaultHasCommittedDraft(cwd, slug) {
26
+ const prefix = draftScaffoldPrefix(slug);
27
+ const mergeBase = spawnSync("git", ["merge-base", "HEAD", "origin/main"], {
28
+ cwd,
29
+ encoding: "utf8",
30
+ });
31
+ const range = mergeBase.status === 0 && String(mergeBase.stdout ?? "").trim()
32
+ ? `${String(mergeBase.stdout).trim()}..HEAD`
33
+ : "HEAD";
34
+ const log = spawnSync("git", ["log", "--oneline", range, "--", prefix], {
35
+ cwd,
36
+ encoding: "utf8",
37
+ });
38
+ if (log.status !== 0) {
39
+ // Fail closed: if history probe fails, do not delete.
40
+ return true;
41
+ }
42
+ return String(log.stdout ?? "").trim().length > 0;
43
+ }
44
+ function assertPathInsideDraftRoot(ownerPath, draftAbs) {
45
+ const draftRoot = resolve(ownerPath, "blueprints", "draft") + sep;
46
+ const target = resolve(draftAbs);
47
+ if (target !== resolve(ownerPath, "blueprints", "draft") && !target.startsWith(draftRoot)) {
48
+ throw new Error(`Refusing abandon: path escapes blueprints/draft/ (${target}). next: inspect worktree layout`);
49
+ }
50
+ }
51
+ function isOwnerPathForSlug(path, slug) {
52
+ const normalized = path.replace(/\\/gu, "/");
53
+ return (normalized.endsWith(`/blueprints/${slug}/owner`) ||
54
+ normalized.endsWith(`/blueprints/${slug}/owner/`));
55
+ }
56
+ function resolveOwnerForSlug(repoRoot, slug, resolveOwnerPath) {
57
+ const cwd = resolve(repoRoot);
58
+ // Already inside the owner worktree for this slug (typical agent cwd).
59
+ if (isOwnerPathForSlug(cwd, slug)) {
60
+ return cwd;
61
+ }
62
+ const ownerPath = resolve(resolveOwnerPath(repoRoot, slug));
63
+ if (ownerPath === cwd) {
64
+ throw new Error(`Refusing abandon: resolved owner is the primary checkout (${ownerPath}). next: abandon only from a bp/<slug> owner worktree, or remove primary draft manually after review`);
65
+ }
66
+ if (!isOwnerPathForSlug(ownerPath, slug)) {
67
+ throw new Error(`Refusing abandon: worktree ${ownerPath} is not the owner path for ${slug}. next: run from bp/${slug} owner or pass the correct project root`);
68
+ }
69
+ return ownerPath;
70
+ }
71
+ /**
72
+ * Abandon an uncommitted draft scaffold so merge-cleanup can proceed.
73
+ * Without --confirm: print preview and throw (exit non-zero at CLI).
74
+ */
75
+ export async function abandonBlueprint(rawSlug, options = {}) {
76
+ const slug = relativeBlueprintSlug(rawSlug.trim());
77
+ if (!isValidBlueprintSlug(slug)) {
78
+ throw new Error(`Refusing abandon: invalid blueprint slug ${JSON.stringify(rawSlug)}. next: use a kebab-case slug like my-feature`);
79
+ }
80
+ const projectRoot = options.projectRoot
81
+ ? resolveProjectRoot({ cwd: options.projectRoot, explicitCwd: options.projectRoot })
82
+ : resolveProjectRoot();
83
+ const resolveOwnerPath = options.resolveOwnerPath ?? resolveOwnerWorktreePath;
84
+ const readPorcelainZ = options.readPorcelainZ ?? defaultReadPorcelainZ;
85
+ const hasCommittedDraft = options.hasCommittedDraft ?? defaultHasCommittedDraft;
86
+ const ownerPath = resolveOwnerForSlug(projectRoot, slug, resolveOwnerPath);
87
+ const draftRel = draftScaffoldPrefix(slug);
88
+ const draftAbs = join(ownerPath, draftRel);
89
+ assertPathInsideDraftRoot(ownerPath, draftAbs);
90
+ if (existsSync(draftAbs)) {
91
+ const st = lstatSync(draftAbs);
92
+ if (st.isSymbolicLink()) {
93
+ // Only remove the link itself when confirmed — never follow.
94
+ if (options.confirm !== true) {
95
+ throw new Error([
96
+ `Would remove symlink draft scaffold ${draftRel} in ${ownerPath}`,
97
+ "Re-run with --confirm to remove the symlink only (not the target).",
98
+ ].join("\n"));
99
+ }
100
+ rmSync(draftAbs, { force: true });
101
+ const afterLink = classifyOwnerDirt(readPorcelainZ(ownerPath), slug);
102
+ if (afterLink.kind !== "clean") {
103
+ throw new Error(`Abandon incomplete; remaining dirt:\n${formatBoundedPaths(afterLink.paths)}\nnext: git status in ${ownerPath}`);
104
+ }
105
+ return {
106
+ slug,
107
+ ownerPath,
108
+ preview: false,
109
+ paths: [draftRel],
110
+ message: `Abandoned draft symlink scaffold for ${slug}; worktree clean — next: wp worktree merge-cleanup bp/${slug}`,
111
+ };
112
+ }
113
+ }
114
+ if (hasCommittedDraft(ownerPath, slug)) {
115
+ throw new Error([
116
+ `Refusing abandon: commits on this branch touch ${draftRel}.`,
117
+ "next: land the draft with the PR, or revert those commits (git revert / interactive rebase) — abandon only deletes uncommitted scaffolds",
118
+ ].join("\n"));
119
+ }
120
+ const porcelainZ = readPorcelainZ(ownerPath);
121
+ const classification = classifyOwnerDirt(porcelainZ, slug);
122
+ if (classification.kind === "clean" && !existsSync(draftAbs)) {
123
+ throw new Error(`Nothing to abandon for ${slug}: no draft scaffold dirt. next: wp worktree merge-cleanup bp/${slug}`);
124
+ }
125
+ if (classification.kind === "other") {
126
+ throw new Error([
127
+ `Refusing abandon: owner has non-scaffold dirt for ${slug}:`,
128
+ formatBoundedPaths(classification.paths),
129
+ `next: commit or stash non-scaffold paths in ${ownerPath}, then re-run abandon or merge-cleanup`,
130
+ ].join("\n"));
131
+ }
132
+ // scaffold-only or clean-but-empty-dir leftover with no porcelain (empty dirs invisible)
133
+ const paths = classification.paths.length > 0 ? classification.paths : existsSync(draftAbs) ? [draftRel] : [];
134
+ if (options.confirm !== true) {
135
+ throw new Error([
136
+ `Would abandon draft scaffold for ${slug} in ${ownerPath}:`,
137
+ formatBoundedPaths(paths.length > 0 ? paths : [draftRel]),
138
+ "Re-run with --confirm to delete. next: wp blueprint abandon <slug> --confirm",
139
+ ].join("\n"));
140
+ }
141
+ // Unstage any index entries under the scaffold, then delete the tree.
142
+ const cached = spawnSync("git", ["rm", "-r", "--cached", "--ignore-unmatch", "--", draftRel], {
143
+ cwd: ownerPath,
144
+ encoding: "utf8",
145
+ });
146
+ if (cached.status !== 0 && cached.status !== null) {
147
+ // ignore-unmatch should make this soft; still fail closed on hard errors
148
+ const err = String(cached.stderr ?? "").trim();
149
+ if (err && !/did not match/iu.test(err)) {
150
+ throw new Error(`git rm --cached failed: ${err}. next: git status in ${ownerPath}`);
151
+ }
152
+ }
153
+ if (existsSync(draftAbs)) {
154
+ assertPathInsideDraftRoot(ownerPath, draftAbs);
155
+ const st = lstatSync(draftAbs);
156
+ if (st.isSymbolicLink()) {
157
+ rmSync(draftAbs, { force: true });
158
+ }
159
+ else if (st.isDirectory() || st.isFile()) {
160
+ rmSync(draftAbs, { recursive: true, force: true });
161
+ }
162
+ }
163
+ const after = classifyOwnerDirt(readPorcelainZ(ownerPath), slug);
164
+ if (after.kind !== "clean") {
165
+ throw new Error([
166
+ `Abandon incomplete; remaining dirt:`,
167
+ formatBoundedPaths(after.paths),
168
+ `next: git status in ${ownerPath}`,
169
+ ].join("\n"));
170
+ }
171
+ return {
172
+ slug,
173
+ ownerPath,
174
+ preview: false,
175
+ paths,
176
+ message: `Abandoned draft scaffold for ${slug}; worktree clean — next: wp worktree merge-cleanup bp/${slug}`,
177
+ };
178
+ }
@@ -6,6 +6,7 @@ import { executeBlueprintDbSubcommand } from "./db-commands.js";
6
6
  import { getBlueprintSubcommandNames } from "./router-output.js";
7
7
  import { listTemplates, resolveTemplate } from "./template-resolver.js";
8
8
  import { TRUST_DOSSIER_SCAFFOLD } from "#trust/scaffold.js";
9
+ import { abandonBlueprint } from "./abandon.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // Platform template fetcher (injectable for tests)
11
12
  // ---------------------------------------------------------------------------
@@ -261,6 +262,26 @@ export async function executeBlueprintSubcommand(subcommand, args, options, deps
261
262
  deps.printBlueprintOutput(options.json ? result : result.message, options.json);
262
263
  return;
263
264
  }
265
+ case "abandon": {
266
+ const slug = args[0];
267
+ if (!slug) {
268
+ throw new Error("Usage: wp blueprint abandon <slug> --confirm");
269
+ }
270
+ const result = await abandonBlueprint(slug, {
271
+ projectRoot: options.projectRoot,
272
+ confirm: options.confirm === true,
273
+ });
274
+ deps.printBlueprintOutput(options.json
275
+ ? {
276
+ slug: result.slug,
277
+ ownerPath: result.ownerPath,
278
+ preview: result.preview,
279
+ paths: result.paths,
280
+ message: result.message,
281
+ }
282
+ : result.message, options.json);
283
+ return;
284
+ }
264
285
  case "finalize": {
265
286
  const slug = args[0];
266
287
  if (!slug) {
@@ -18,6 +18,7 @@ export const BLUEPRINT_SUBCOMMANDS = [
18
18
  },
19
19
  { name: "start", usage: ["start <slug>"] },
20
20
  { name: "park", usage: ["park <slug>"] },
21
+ { name: "abandon", usage: ["abandon <slug> --confirm"] },
21
22
  {
22
23
  name: "task",
23
24
  usage: [
@@ -42,6 +42,8 @@ export interface BlueprintCommandOptions extends BlueprintAuditOptions, Blueprin
42
42
  template?: string;
43
43
  templatesDir?: string;
44
44
  to?: string;
45
+ /** Required for destructive `blueprint abandon`. */
46
+ confirm?: boolean;
45
47
  "--": string[];
46
48
  }
47
49
  export type { AdvanceTaskResult, PromoteBlueprintResult };
@@ -175,9 +175,11 @@ export async function createBlueprint(goal, options = {}) {
175
175
  : probeService;
176
176
  // Single full compile lives inside create() on the write root.
177
177
  const created = await writeService.create({ complexity, goal, type });
178
+ const draftHint = "Draft is uncommitted — include blueprints/draft/" +
179
+ `${created.slug}/ in your first commit, or run: wp blueprint abandon ${created.slug} --confirm before merge-cleanup.`;
178
180
  const message = authoring.routed
179
- ? `Created ${created.type} draft/${created.slug} (routed from primary-like checkout).`
180
- : `Created ${created.type} draft/${created.slug}.`;
181
+ ? `Created ${created.type} draft/${created.slug} (routed from primary-like checkout).\n${draftHint}`
182
+ : `Created ${created.type} draft/${created.slug}.\n${draftHint}`;
181
183
  return {
182
184
  ...created,
183
185
  message,
@@ -541,6 +543,7 @@ export function registerBlueprintRouter(cli) {
541
543
  .option("--template <name>", "Template name to scaffold new blueprint from (see --list-templates)")
542
544
  .option("--list-templates", "List available template names and exit")
543
545
  .option("--templates-dir <path>", "Override the templates directory (default: docs/templates/)")
546
+ .option("--confirm", "Confirm destructive blueprint abandon")
544
547
  .action(async (subcommand, args, options) => {
545
548
  try {
546
549
  await executeBlueprintSubcommand(subcommand, args, options, {
@@ -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,87 @@
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
+ /** Max stdout+stderr retained from probe git subprocesses (Node spawnSync maxBuffer). */
16
+ export declare const GIT_MAX_BUFFER: number;
17
+ /**
18
+ * Max stdout+stderr for the single hookful `git commit`. Quiet hooks cost nothing
19
+ * (Node does not preallocate); chatty consumer format+audit chains can exceed 1 MiB
20
+ * and would otherwise die with ENOBUFS+SIGTERM mid-commit.
21
+ */
22
+ export declare const GIT_COMMIT_MAX_BUFFER: number;
23
+ /** True when spawnSync killed the child for maxBuffer / stdio overflow. */
24
+ export declare function isBufferOverflowErrorCode(code: string | null | undefined): boolean;
25
+ /**
26
+ * Resolve the hookful-commit timeout. Preference order:
27
+ * 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
28
+ * 2. `WP_SETUP_COMMIT_TIMEOUT_MS` env (operator override)
29
+ * 3. {@link DEFAULT_GIT_COMMIT_TIMEOUT_MS}
30
+ *
31
+ * Values must be finite numbers ≥ 0. `0` disables the artificial wall (Node default).
32
+ */
33
+ export declare function resolveCommitTimeoutMs(options?: {
34
+ readonly commitTimeoutMs?: number;
35
+ readonly env?: NodeJS.ProcessEnv;
36
+ }): number;
37
+ /** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
38
+ export declare function formatCommitTimeoutError(options: {
39
+ readonly timeoutMs: number;
40
+ readonly hookOutput?: string;
41
+ }): string;
42
+ /**
43
+ * First-class interrupt diagnosis for non-timeout signal kills (operator kill,
44
+ * external SIGTERM/SIGKILL). Must not surface raw hook "passed" lines as the
45
+ * leading operator message.
46
+ */
47
+ export declare function formatCommitInterruptError(options: {
48
+ readonly signal: NodeJS.Signals;
49
+ readonly hookOutput?: string;
50
+ }): string;
51
+ /**
52
+ * First-class diagnosis when Node spawnSync kills the child because hook/git
53
+ * output exceeded maxBuffer (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER).
54
+ * Distinct from budget timeout and from external signal kills.
55
+ */
56
+ export declare function formatCommitBufferError(options: {
57
+ readonly maxBufferBytes: number;
58
+ readonly errorCode?: string | null;
59
+ readonly hookOutput?: string;
60
+ }): string;
61
+ /**
62
+ * Classify a failed hookful `git commit` result.
63
+ *
64
+ * Order (first match wins):
65
+ * 1. `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
66
+ * 2. buffer overflow (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER) → buffer-limit diagnosis
67
+ * 3. non-null `signal` → interrupt diagnosis (names the signal)
68
+ * 4. otherwise → hook/git stderr when present, else spawn errorCode / generic preserved-index
69
+ *
70
+ * Never lead with bare hook "passed" lines for first-class failure paths.
71
+ */
72
+ export declare function classifyCommitFailure(result: {
73
+ readonly timedOut: boolean;
74
+ readonly signal: NodeJS.Signals | null;
75
+ readonly stderr: string;
76
+ readonly stdout: string;
77
+ readonly errorCode?: string | null;
78
+ }, timeoutMs: number, maxBufferBytes?: number): string;
79
+ /**
80
+ * Prefix every line of a setup failure so multi-line timeout diagnoses stay
81
+ * labeled when printed via console.error (avoids a single `wp setup: ` on the
82
+ * first line and bare hook "passed" lines after it).
83
+ */
84
+ export declare function formatWpSetupError(message: string): string;
3
85
  export type AuthorCommitStatus = "committed" | "no-change" | "skipped-ci" | "failed" | "rolled-back";
4
86
  export interface AuthorCommitResult {
5
87
  readonly status: AuthorCommitStatus;
@@ -15,6 +97,11 @@ export interface AuthorCommitOptions {
15
97
  readonly appliedUnits: readonly ConvergeUnit[];
16
98
  readonly message: string;
17
99
  readonly isCi?: boolean;
100
+ /**
101
+ * Override wall for the single hookful `git commit` (ms). Prefer this in tests.
102
+ * `0` disables the artificial timeout. Probe Git calls always stay on the short budget.
103
+ */
104
+ readonly commitTimeoutMs?: number;
18
105
  }
19
106
  export interface AppliedConvergenceCandidate {
20
107
  readonly targetPath: string;