@webpresso/agent-kit 3.3.4 → 3.3.6

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 (42) hide show
  1. package/dist/esm/audit/agents.js +126 -26
  2. package/dist/esm/blueprint/trust/dossier.js +41 -1
  3. package/dist/esm/blueprint/verification.d.ts +12 -1
  4. package/dist/esm/blueprint/verification.js +26 -3
  5. package/dist/esm/build/vendor-oxlint-plugins.d.ts +4 -1
  6. package/dist/esm/build/vendor-oxlint-plugins.js +9 -14
  7. package/dist/esm/cli/commands/init/index.js +3 -4
  8. package/dist/esm/cli/commands/init/scaffold-base-kit.js +5 -1
  9. package/dist/esm/cli/commands/mcp.js +2 -0
  10. package/dist/esm/cli/commands/review.d.ts +1 -0
  11. package/dist/esm/cli/commands/review.js +77 -1
  12. package/dist/esm/cli/commands/sync.d.ts +8 -6
  13. package/dist/esm/cli/commands/sync.js +19 -9
  14. package/dist/esm/config/oxlint/oxlintrc.d.ts +1 -1
  15. package/dist/esm/config/oxlint/oxlintrc.js +3 -12
  16. package/dist/esm/content/dispatch.js +4 -0
  17. package/dist/esm/content/loader.d.ts +1 -1
  18. package/dist/esm/content/loader.js +10 -6
  19. package/dist/esm/content/skill-policy.d.ts +11 -0
  20. package/dist/esm/content/skill-policy.js +33 -0
  21. package/dist/esm/hooks/doctor.js +114 -10
  22. package/dist/esm/hooks/pretool-guard/dev-routing.js +67 -4
  23. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  24. package/dist/esm/mcp/blueprint/_shared/lifecycle.d.ts +13 -0
  25. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +40 -8
  26. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +97 -0
  27. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +23 -3
  28. package/dist/esm/mcp/blueprint/handlers/finalize.js +18 -7
  29. package/dist/esm/mcp/blueprint/registration.js +31 -2
  30. package/dist/esm/review/authority.js +56 -1
  31. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  32. package/dist/esm/review/delivery-verifier.js +9 -1
  33. package/dist/esm/symlinker/consumers.d.ts +20 -1
  34. package/dist/esm/symlinker/consumers.js +15 -1
  35. package/dist/esm/symlinker/ownership.d.ts +48 -0
  36. package/dist/esm/symlinker/ownership.js +83 -0
  37. package/dist/esm/symlinker/unified-sync.d.ts +35 -3
  38. package/dist/esm/symlinker/unified-sync.js +112 -14
  39. package/dist/esm/test/shard-durations.json +0 -1
  40. package/package.json +12 -12
  41. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  42. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Compiled plugin module basenames shipped next to the generated oxlintrc.json.
3
- * Must match agent-config policy plugins (guarded by vendor + parity tests).
3
+ * SSOT: `@webpresso/agent-config/oxlint` `OXLINT_POLICY_PLUGIN_BASENAMES`.
4
4
  */
5
5
  export declare const OXLINT_PLUGIN_BASENAMES: readonly ["code-safety", "foundation-purity", "graphql-conventions", "import-hygiene", "monorepo-paths", "query-patterns", "testing-quality", "tier-boundaries"];
6
6
  /** jsPlugins specifiers, sibling-relative to the shipped oxlintrc.json. */
@@ -15,21 +15,12 @@
15
15
  * (`./<plugin>.js` siblings of the shipped JSON).
16
16
  * - `ignorePatterns` apply relative to the LINTED project (cwd).
17
17
  */
18
- import { rules as agentConfigRules } from "@webpresso/agent-config/oxlint";
18
+ import { OXLINT_POLICY_PLUGIN_BASENAMES, rules as agentConfigRules, } from "@webpresso/agent-config/oxlint";
19
19
  /**
20
20
  * Compiled plugin module basenames shipped next to the generated oxlintrc.json.
21
- * Must match agent-config policy plugins (guarded by vendor + parity tests).
21
+ * SSOT: `@webpresso/agent-config/oxlint` `OXLINT_POLICY_PLUGIN_BASENAMES`.
22
22
  */
23
- export const OXLINT_PLUGIN_BASENAMES = [
24
- "code-safety",
25
- "foundation-purity",
26
- "graphql-conventions",
27
- "import-hygiene",
28
- "monorepo-paths",
29
- "query-patterns",
30
- "testing-quality",
31
- "tier-boundaries",
32
- ];
23
+ export const OXLINT_PLUGIN_BASENAMES = OXLINT_POLICY_PLUGIN_BASENAMES;
33
24
  /** jsPlugins specifiers, sibling-relative to the shipped oxlintrc.json. */
34
25
  export const OXLINT_JS_PLUGIN_FILES = OXLINT_PLUGIN_BASENAMES.map((name) => `./${name}.js`);
35
26
  /**
@@ -12,6 +12,10 @@ import { loadContent } from "./loader.js";
12
12
  const CONSUMER_DIR_BY_KIND = {
13
13
  rule: "agent-rules",
14
14
  skill: "agent-skills",
15
+ // Not reachable today (no `wp agent` CRUD command dispatches here), kept
16
+ // only to satisfy Record<ContentKind, string> exhaustiveness now that
17
+ // "agent" is a ContentKind for the unified-sync projection pipeline.
18
+ agent: "agent-agents",
15
19
  };
16
20
  function todayIsoDate() {
17
21
  return new Date().toISOString().slice(0, 10);
@@ -14,7 +14,7 @@
14
14
  * gray-matter object in both `rawFrontmatter` and `parsedFrontmatter` so the
15
15
  * shape stays stable for downstream callers writing tests against it.
16
16
  */
17
- export type ContentKind = "rule" | "skill";
17
+ export type ContentKind = "rule" | "skill" | "agent";
18
18
  export type ContentSource = "canonical" | "consumer";
19
19
  /**
20
20
  * Stub raw frontmatter type. Replaced with schema output in Task 2.x.
@@ -21,10 +21,12 @@ const DEFAULT_KINDS = ["rule", "skill"];
21
21
  const CONSUMER_DIR_BY_KIND = {
22
22
  rule: "agent-rules",
23
23
  skill: "agent-skills",
24
+ agent: "agent-agents",
24
25
  };
25
26
  const CATALOG_DIR_BY_KIND = {
26
27
  rule: "rules",
27
28
  skill: "skills",
29
+ agent: "agents",
28
30
  };
29
31
  export function loadContent(options) {
30
32
  const { catalogDir, consumerRoot, kinds = DEFAULT_KINDS } = options;
@@ -83,9 +85,11 @@ function readKind(args) {
83
85
  const stat = statSync(root);
84
86
  if (!stat.isDirectory())
85
87
  return [];
86
- return kind === "rule" ? readRules(root, source) : readSkills(root, source);
88
+ // agent kind shares the rule shape: flat `<slug>.md` files, README.md
89
+ // excluded. Only `skill` is directory-shaped (`<slug>/SKILL.md`).
90
+ return kind === "skill" ? readSkills(root, source) : readFlatMarkdown(kind, root, source);
87
91
  }
88
- function readRules(root, source) {
92
+ function readFlatMarkdown(kind, root, source) {
89
93
  const out = [];
90
94
  for (const entry of readdirSync(root, { withFileTypes: true })) {
91
95
  if (!entry.isFile())
@@ -93,9 +97,9 @@ function readRules(root, source) {
93
97
  if (!entry.name.endsWith(".md"))
94
98
  continue;
95
99
  // Skip directory-documentation files. README.md exists in both the
96
- // canonical catalog and the consumer-owned agent-rules/ scaffolder
97
- // surface; treating it as a rule would produce a spurious slug
98
- // collision and would also try to project it into per-IDE surfaces.
100
+ // canonical catalog and the consumer-owned agent-rules/ (or agent-agents/)
101
+ // scaffolder surface; treating it as content would produce a spurious
102
+ // slug collision and would also try to project it into per-IDE surfaces.
99
103
  if (entry.name === "README.md")
100
104
  continue;
101
105
  const filePath = realpathSync(join(root, entry.name));
@@ -103,7 +107,7 @@ function readRules(root, source) {
103
107
  const parsed = matter(readFileSync(filePath, "utf8"));
104
108
  const raw = { ...parsed.data };
105
109
  out.push({
106
- kind: "rule",
110
+ kind,
107
111
  slug,
108
112
  source,
109
113
  filePath,
@@ -1,6 +1,17 @@
1
1
  export declare const DEFAULT_PACKAGED_SKILL_SLUGS: readonly ["verify", "fix", "investigate", "ai-deslop", "plan-refine", "ralplan", "best-practice-research", "testing-philosophy", "tph", "claude", "codex", "grok", "opencode-go", "plan-eng-review", "plan-ceo-review", "plan-design-review", "plan-devex-review", "browse", "devex-review", "design-review", "hooks-doctor", "tech-debt", "lore-protocol", "deep-interview", "deep-research", "autoresearch", "ultragoal", "autopilot", "team"];
2
2
  export type DefaultPackagedSkillSlug = (typeof DEFAULT_PACKAGED_SKILL_SLUGS)[number];
3
3
  export declare const DEFAULT_PACKAGED_SKILL_SLUG_SET: ReadonlySet<string>;
4
+ /**
5
+ * Catalog skills that are deliberately NOT in DEFAULT_PACKAGED_SKILL_SLUGS,
6
+ * with a one-line reason each. Every slug under `catalog/agent/skills/` must
7
+ * appear in either that list or this one (enforced by
8
+ * `checkCatalogSkillClassification` in `src/audit/agents.ts`) — an
9
+ * unclassified slug means a new catalog skill shipped without a deliberate
10
+ * packaging decision.
11
+ */
12
+ export declare const UNPACKAGED_SKILL_SLUGS: Readonly<Record<string, string>>;
13
+ export declare const UNPACKAGED_SKILL_SLUG_SET: ReadonlySet<string>;
14
+ export declare function isUnpackagedSkillSlug(slug: string): boolean;
4
15
  export declare const MERGED_DELETED_SKILL_SLUGS: readonly ["autoplan", "health", "systematic-debugging", "test-driven-development"];
5
16
  export declare const MERGED_DELETED_SKILL_SLUG_SET: ReadonlySet<string>;
6
17
  /**
@@ -30,6 +30,39 @@ export const DEFAULT_PACKAGED_SKILL_SLUGS = [
30
30
  "team",
31
31
  ];
32
32
  export const DEFAULT_PACKAGED_SKILL_SLUG_SET = new Set(DEFAULT_PACKAGED_SKILL_SLUGS);
33
+ /**
34
+ * Catalog skills that are deliberately NOT in DEFAULT_PACKAGED_SKILL_SLUGS,
35
+ * with a one-line reason each. Every slug under `catalog/agent/skills/` must
36
+ * appear in either that list or this one (enforced by
37
+ * `checkCatalogSkillClassification` in `src/audit/agents.ts`) — an
38
+ * unclassified slug means a new catalog skill shipped without a deliberate
39
+ * packaging decision.
40
+ */
41
+ export const UNPACKAGED_SKILL_SLUGS = {
42
+ qa: "source-catalog QA skill, not package-visible in the default plugin skill set today (see workflow-skills-routing.md)",
43
+ "qa-only": "source-catalog QA skill, not package-visible in the default plugin skill set today (see workflow-skills-routing.md)",
44
+ review: "listed as a default workflow skill in workflow-skills-routing.md but not yet added to DEFAULT_PACKAGED_SKILL_SLUGS",
45
+ pll: "ultragoal's parallel-execute companion (ready-queue/worktrees driver); not independently default-packaged",
46
+ "frontend-design": "opt-in design-guidance skill, not part of the default packaged workflow set",
47
+ "monorepo-navigation": "tier-3 opt-in preset (wp setup --with monorepo-navigation), not default-packaged",
48
+ "tanstack-query": "tier-3 opt-in preset (wp setup --with tanstack-query), not default-packaged",
49
+ "react-doctor": "opt-in framework best-practice skill, not default-packaged",
50
+ "better-auth-best-practices": "opt-in best-practice reference skill, not default-packaged",
51
+ "logging-best-practices": "opt-in best-practice reference skill, not default-packaged",
52
+ "vercel-react-best-practices": "opt-in best-practice reference skill, not default-packaged",
53
+ "web-design-guidelines": "opt-in best-practice reference skill, not default-packaged",
54
+ deepseek: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
55
+ glm: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
56
+ hy3: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
57
+ kimi: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
58
+ mimo: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
59
+ minimax: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
60
+ qwen: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
61
+ };
62
+ export const UNPACKAGED_SKILL_SLUG_SET = new Set(Object.keys(UNPACKAGED_SKILL_SLUGS));
63
+ export function isUnpackagedSkillSlug(slug) {
64
+ return UNPACKAGED_SKILL_SLUG_SET.has(slug);
65
+ }
33
66
  export const MERGED_DELETED_SKILL_SLUGS = [
34
67
  "autoplan",
35
68
  "health",
@@ -28,7 +28,7 @@ import { findAgentKitPackageRoot, resolveAgentKitPackageRoot, } from "#cli/comma
28
28
  import { readInstalledHooksMap } from "#hooks/shared/installed-hooks.js";
29
29
  import { auditCodexConfigPaths, checkCodexConfigPathIntegrity } from "#hooks/codex-config-paths.js";
30
30
  import { findInstalledCodexPluginMcpManifests, pruneOwnedMcpServerBlock, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
31
- import { isAgentKitOwnedWpCommand, WEBPRESSO_MCP_SERVER_NAME, } from "#cli/commands/init/mcp-spec.js";
31
+ import { resolveExecutableOnPath, WEBPRESSO_MCP_SPEC, isAgentKitOwnedWpCommand, WEBPRESSO_MCP_SERVER_NAME, } from "#cli/commands/init/mcp-spec.js";
32
32
  import { parseProjectMcpServers, scanCodexConfigDrift } from "#hooks/host-config-contract.js";
33
33
  import { defaultCodexConfigFilePath } from "#cli/commands/init/scaffolders/agent-hooks/codex-trust-sync.js";
34
34
  import { expectedRootWpBinRelativePath, formatRootLauncherContractFailure, rootContractMode, validateRootLauncherContract, } from "#launcher/root-contract.js";
@@ -1201,31 +1201,135 @@ function runCommand(command, args, cwd = process.cwd(), timeoutMs = DEFAULT_HOOK
1201
1201
  });
1202
1202
  });
1203
1203
  }
1204
- async function checkCodexHost() {
1204
+ function asRecord(value) {
1205
+ return value && typeof value === "object" && !Array.isArray(value)
1206
+ ? value
1207
+ : null;
1208
+ }
1209
+ function unsupportedCodexJsonFlag(result) {
1210
+ const text = `${result.stderr}\n${result.stdout}`;
1211
+ return (/--json/u.test(text) && /(unexpected|unknown|unrecognized|invalid|found argument)/iu.test(text));
1212
+ }
1213
+ function classifyCodexMcpListJson(stdout) {
1214
+ let parsed;
1215
+ try {
1216
+ parsed = JSON.parse(stdout);
1217
+ }
1218
+ catch (error) {
1219
+ return {
1220
+ kind: "malformed",
1221
+ reason: error instanceof Error ? error.message : "invalid JSON",
1222
+ };
1223
+ }
1224
+ if (!Array.isArray(parsed)) {
1225
+ return { kind: "malformed", reason: "expected a top-level array" };
1226
+ }
1227
+ const webpressoEntry = parsed
1228
+ .map((entry) => asRecord(entry))
1229
+ .find((entry) => entry?.name === WEBPRESSO_MCP_SERVER_NAME);
1230
+ if (!webpressoEntry)
1231
+ return { kind: "missing" };
1232
+ if (webpressoEntry.enabled !== true) {
1233
+ const reason = typeof webpressoEntry.disabled_reason === "string" &&
1234
+ webpressoEntry.disabled_reason.length > 0
1235
+ ? webpressoEntry.disabled_reason
1236
+ : "enabled=false";
1237
+ return { kind: "disabled", reason };
1238
+ }
1239
+ const transport = asRecord(webpressoEntry.transport);
1240
+ if (!transport || transport.type !== "stdio" || typeof transport.command !== "string") {
1241
+ return { kind: "nonstdio" };
1242
+ }
1243
+ if (transport.command === "wp" && stringArrayEquals(transport.args, WEBPRESSO_MCP_SPEC.args)) {
1244
+ return { kind: "canonical", command: "wp", args: ["mcp"] };
1245
+ }
1246
+ const args = Array.isArray(transport.args)
1247
+ ? transport.args.map((arg) => String(arg)).join(" ")
1248
+ : "";
1249
+ return { kind: "noncanonical", command: `${transport.command}${args ? ` ${args}` : ""}` };
1250
+ }
1251
+ function classifyCodexMcpLaunchability(stdout, env) {
1252
+ const classification = classifyCodexMcpListJson(stdout);
1253
+ switch (classification.kind) {
1254
+ case "canonical": {
1255
+ const resolved = resolveExecutableOnPath(classification.command, env);
1256
+ if (resolved) {
1257
+ return null;
1258
+ }
1259
+ const pathValue = env.PATH && env.PATH.length > 0 ? env.PATH : "(empty)";
1260
+ return {
1261
+ kind: "failure",
1262
+ detail: `@webpresso/codex-plugin/.mcp.json configures webpresso MCP as \`wp mcp\`, but \`wp\` is not resolvable on this doctor's PATH (${pathValue}); fix is not automatic: restore global \`wp\` in the Codex launch environment or run \`wp install codex\` after PATH is fixed`,
1263
+ };
1264
+ }
1265
+ case "disabled":
1266
+ return {
1267
+ kind: "failure",
1268
+ detail: `webpresso MCP entry is configured but disabled by \`codex mcp list --json\` (${classification.reason}); fix is not automatic: inspect Codex MCP configuration and run \`wp install codex\` if the bundled plugin is stale`,
1269
+ };
1270
+ case "malformed":
1271
+ return {
1272
+ kind: "failure",
1273
+ detail: `codex mcp list --json returned malformed output (${classification.reason}); cannot verify webpresso MCP launchability; fix is not automatic: run \`wp install codex\` if the Codex CLI is stale`,
1274
+ };
1275
+ case "missing":
1276
+ return {
1277
+ kind: "failure",
1278
+ detail: "missing MCP entry (webpresso=false); run `wp install codex` to install the bundled plugin",
1279
+ };
1280
+ case "nonstdio":
1281
+ return {
1282
+ kind: "advisory",
1283
+ detail: "webpresso MCP configured with a non-stdio transport; launchability was not verified for user-owned transports",
1284
+ };
1285
+ case "noncanonical":
1286
+ return {
1287
+ kind: "advisory",
1288
+ detail: `webpresso MCP configured with non-canonical command \`${classification.command}\`; launchability was not verified for user-owned commands`,
1289
+ };
1290
+ }
1291
+ }
1292
+ async function checkCodexHost(env = process.env) {
1205
1293
  const available = await runCommand("codex", ["--version"]);
1206
1294
  if (!available.ok) {
1207
1295
  return { name: "Codex host integration", ok: true, detail: "skipped (codex not on PATH)" };
1208
1296
  }
1209
- const result = await runCommand("codex", ["mcp", "list"]);
1297
+ const result = await runCommand("codex", ["mcp", "list", "--json"]);
1210
1298
  if (!result.ok) {
1299
+ if (unsupportedCodexJsonFlag(result)) {
1300
+ return {
1301
+ name: "Codex host integration",
1302
+ ok: false,
1303
+ detail: "codex mcp list --json is unsupported by the installed Codex CLI; fix is not automatic: run `wp install codex` to refresh the Codex integration",
1304
+ };
1305
+ }
1211
1306
  return {
1212
1307
  name: "Codex host integration",
1213
1308
  ok: false,
1214
1309
  detail: result.stderr.trim() || `exit ${result.code}`,
1215
1310
  };
1216
1311
  }
1217
- const hasAgentKit = result.stdout.includes("webpresso");
1218
- if (!hasAgentKit) {
1312
+ const launchability = classifyCodexMcpLaunchability(result.stdout, env);
1313
+ const findings = launchability?.kind === "failure" ? [launchability.detail] : [];
1314
+ const advisories = launchability?.kind === "advisory" ? [launchability.detail] : [];
1315
+ const legacy = classifyLegacyCodexWebpressoChannel();
1316
+ if (legacy)
1317
+ findings.push(legacy);
1318
+ if (findings.length > 0) {
1219
1319
  return {
1220
1320
  name: "Codex host integration",
1221
1321
  ok: false,
1222
- detail: `missing MCP entry (webpresso=${hasAgentKit})`,
1322
+ detail: [...findings, ...advisories].join("; "),
1223
1323
  };
1224
1324
  }
1225
- const legacy = classifyLegacyCodexWebpressoChannel();
1226
- if (legacy)
1227
- return { name: "Codex host integration", ok: false, detail: legacy };
1228
- return { name: "Codex host integration", ok: true, detail: "webpresso MCP visible" };
1325
+ if (advisories.length > 0) {
1326
+ return { name: "Codex host integration", ok: true, detail: advisories.join("; ") };
1327
+ }
1328
+ return {
1329
+ name: "Codex host integration",
1330
+ ok: true,
1331
+ detail: "webpresso MCP visible (wp mcp launch command resolves on PATH)",
1332
+ };
1229
1333
  }
1230
1334
  /**
1231
1335
  * Codex must receive `webpresso` over exactly one channel — the bundled Codex
@@ -296,11 +296,11 @@ const SAFE_PASSTHROUGH_PREFIXES = [
296
296
  const SANDBOX_PREFIXES = [
297
297
  {
298
298
  prefix: "grep",
299
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
299
+ guidance: "Recursive/directory-wide grep is unbounded; narrow to explicit file target(s) (`grep -n pattern file`) to pass through directly. Otherwise use wp_session_batch_execute for bounded search so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
300
300
  },
301
301
  {
302
302
  prefix: "find",
303
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
303
+ guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
304
304
  },
305
305
  {
306
306
  prefix: "cat",
@@ -324,7 +324,7 @@ const SANDBOX_PREFIXES = [
324
324
  },
325
325
  {
326
326
  prefix: "git log",
327
- guidance: "Use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use explicit bounds such as `git log --oneline -n 50`",
327
+ guidance: "Add an explicit count bound (`-N`, `-n N`, or `--max-count=N`) to pass through directly — e.g. `git log --oneline -50`. Otherwise use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use an explicit bound directly, e.g. `git log --oneline -n 50`",
328
328
  },
329
329
  {
330
330
  prefix: "git diff",
@@ -332,7 +332,7 @@ const SANDBOX_PREFIXES = [
332
332
  },
333
333
  {
334
334
  prefix: "git show",
335
- guidance: "Use wp_session_batch_execute or wp_session_execute for bounded git show gathering instead of raw large output. If MCP is unavailable, use `git show --stat` or a targeted excerpt",
335
+ guidance: "Add `--stat`/`--name-only`/`--name-status`/`--shortstat` to pass through directly — e.g. `git show --stat HEAD` or use wp_session_batch_execute or wp_session_execute for bounded gathering of the full patch",
336
336
  },
337
337
  {
338
338
  prefix: "vp run build",
@@ -782,6 +782,63 @@ const BOUNDED_GIT_DIFF = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTIO
782
782
  function isBoundedGitDiffRead(command) {
783
783
  return BOUNDED_GIT_DIFF.test(command);
784
784
  }
785
+ // `git log`/`git show` reads are bounded (small, predictable output) exactly when
786
+ // they carry an explicit count/summary bound the same way BOUNDED_GIT_DIFF already
787
+ // recognizes `--stat`/`--name-only`/etc. as bounded. Unbounded forms (bare `git log`,
788
+ // full-patch `git show <ref>`) still fall through to the SANDBOX_PREFIXES rules below.
789
+ const SIMPLE_GIT_LOG_PREFIX = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}log\b`, "u");
790
+ const GIT_LOG_COUNT_BOUND = /(?:^|\s)(?:-\d+|-n[=\s]?\d+|--max-count(?:=|\s+)\d+)(?:\s|$)/u;
791
+ function isBoundedGitLogRead(command) {
792
+ return SIMPLE_GIT_LOG_PREFIX.test(command) && GIT_LOG_COUNT_BOUND.test(command);
793
+ }
794
+ const BOUNDED_GIT_SHOW = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}show\b(?=[\s\S]*(?:--stat\b|--shortstat\b|--name-only\b|--name-status\b))(?![\s\S]*(?:--patch\b|-p\b))`, "u");
795
+ function isBoundedGitShowRead(command) {
796
+ return BOUNDED_GIT_SHOW.test(command);
797
+ }
798
+ // A grep read is bounded when it targets explicit file argument(s) (not a whole
799
+ // directory tree via -r/-R/--recursive/--include/--exclude*) and supplies at least
800
+ // one non-flag file target beyond the pattern itself. Recursive/directory-wide grep
801
+ // stays sandboxed since its output size is unpredictable.
802
+ const GREP_RECURSIVE_LONG_FLAGS = new Set([
803
+ "--recursive",
804
+ "--include",
805
+ "--exclude",
806
+ "--exclude-dir",
807
+ "--exclude-from",
808
+ ]);
809
+ function isBoundedGrepRead(command) {
810
+ const tokens = tokenizeCommand(command);
811
+ if (tokens[0] !== "grep")
812
+ return false;
813
+ const positionals = [];
814
+ let optionsEnded = false;
815
+ for (let index = 1; index < tokens.length; index += 1) {
816
+ const token = tokens[index];
817
+ if (!token)
818
+ continue;
819
+ if (!optionsEnded && token === "--") {
820
+ optionsEnded = true;
821
+ continue;
822
+ }
823
+ if (!optionsEnded && token !== "-" && token.startsWith("-")) {
824
+ const longFlag = token.split("=")[0] ?? token;
825
+ if (GREP_RECURSIVE_LONG_FLAGS.has(longFlag))
826
+ return false;
827
+ // Strip a trailing bundled numeric argument (e.g. the `2` in `-rA2` for
828
+ // `-r -A 2`) before checking for a bundled `r`/`R` — otherwise a
829
+ // recursive short flag bundled with a value-taking flag like `-A`/`-B`/
830
+ // `-C`/`-m` slips past undetected.
831
+ const shortCluster = token.slice(1).replace(/\d+$/u, "");
832
+ if (/^[A-Za-z]+$/u.test(shortCluster) && /[rR]/u.test(shortCluster))
833
+ return false;
834
+ continue;
835
+ }
836
+ positionals.push(token);
837
+ }
838
+ // First positional is the search pattern; at least one more explicit file
839
+ // target is required to count as a bounded (non-stdin, non-directory) read.
840
+ return positionals.length >= 2;
841
+ }
785
842
  function escapedRegexToken(value) {
786
843
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
787
844
  }
@@ -982,6 +1039,12 @@ export function routeCommand(command, _sessionId) {
982
1039
  }
983
1040
  if (isBoundedGitDiffRead(trimmed))
984
1041
  return { action: { action: "passthrough" } };
1042
+ if (isBoundedGitLogRead(trimmed))
1043
+ return { action: { action: "passthrough" } };
1044
+ if (isBoundedGitShowRead(trimmed))
1045
+ return { action: { action: "passthrough" } };
1046
+ if (isBoundedGrepRead(trimmed))
1047
+ return { action: { action: "passthrough" } };
985
1048
  // Explicit passthroughs (audits, safe git/nav commands)
986
1049
  for (const prefix of PASSTHROUGH_PREFIXES) {
987
1050
  if (matchesPrefix(trimmed, prefix))
@@ -850,7 +850,9 @@ const GIT_GLOBAL_RUN = `(?:-C\\s+${QUOTED_ARG}\\s+|-c\\s+${QUOTED_ARG}\\s+|--git
850
850
  // branch flags are allowed.
851
851
  const FORBIDDEN_SUBCOMMAND = "(commit\\b|switch\\b(?!\\s+(?:-h|--help)\\b)|checkout\\s+(?!--(?:\\s|$))(?:(?:\\S+\\s+)*)\\S|branch\\s+(?:(?!-|--)\\S|--track\\b|--no-track\\b|--create-reflog\\b|--force\\b|-f\\b|-c\\b|-C\\b|-m\\b|-M\\b))";
852
852
  const FORBIDDEN_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})${FORBIDDEN_SUBCOMMAND}`, "gu");
853
- const MAIN_OWNERSHIP_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b[^;&|(){}]*|checkout\\b[^;&|(){}]*|branch\\b[^;&|(){}]*|worktree\\s+add\\b[^;&|(){}]*)`, "gu");
853
+ // Verb-only: deliberately does NOT slurp trailing arguments itself (see
854
+ // mainOwnershipGitOps below for why a naive `[^;&|(){}]*` capture is unsafe).
855
+ const MAIN_OWNERSHIP_VERB = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b|checkout\\b|branch\\b|worktree\\s+add\\b)`, "gu");
854
856
  function labelFor(subcommand) {
855
857
  if (subcommand.startsWith("commit"))
856
858
  return "git commit";
@@ -958,12 +960,69 @@ function mainOwnershipLabel(words) {
958
960
  return "git worktree add main";
959
961
  return null;
960
962
  }
963
+ /**
964
+ * Quote-aware segment bounds for `command`, split on the same unquoted
965
+ * `;&|(){}` control characters as {@link splitShellSegments} (kept in sync
966
+ * intentionally), but returning OFFSETS instead of trimmed substrings so a
967
+ * caller can slice the original command text for a match that falls inside a
968
+ * given segment.
969
+ */
970
+ function segmentBounds(command) {
971
+ const bounds = [];
972
+ let start = 0;
973
+ let quote = null;
974
+ let escaped = false;
975
+ for (let i = 0; i < command.length; i += 1) {
976
+ const ch = command[i];
977
+ if (ch === undefined)
978
+ continue;
979
+ if (escaped) {
980
+ escaped = false;
981
+ continue;
982
+ }
983
+ if (quote !== "'" && ch === "\\") {
984
+ escaped = true;
985
+ continue;
986
+ }
987
+ if ((ch === '"' || ch === "'") && (quote === null || quote === ch)) {
988
+ quote = quote === ch ? null : ch;
989
+ continue;
990
+ }
991
+ if (!quote && /[;&|(){}]/u.test(ch)) {
992
+ bounds.push({ start, end: i });
993
+ start = i + 1;
994
+ }
995
+ }
996
+ bounds.push({ start, end: command.length });
997
+ return bounds;
998
+ }
999
+ /**
1000
+ * Main-ownership candidate ops (`git switch|checkout|branch|worktree add`).
1001
+ *
1002
+ * MAIN_OWNERSHIP_VERB intentionally does NOT slurp trailing arguments itself
1003
+ * (unlike the pre-fix implementation) — a naive `[^;&|(){}]*` character class
1004
+ * is not quote-aware, so a read-only `git branch --format='%(refname:short)'`
1005
+ * has its captured subcommand text truncated mid-quote at the `(` inside the
1006
+ * quoted format string, producing an unterminated quote that shellWords()
1007
+ * reports as "ambiguous" and gets fail-closed blocked — purely because of a
1008
+ * paren inside its OWN quoted argument, not because it mutates anything.
1009
+ * Bounding the subcommand text to the enclosing quote-aware shell segment
1010
+ * (segmentBounds, sharing splitShellSegments' control-char set) fixes that
1011
+ * without weakening real-mutation detection: genuinely ambiguous syntax
1012
+ * (nested shells, unresolved substitutions, unterminated quotes) still
1013
+ * produces "ambiguous" and fails closed exactly as before.
1014
+ */
961
1015
  function mainOwnershipGitOps(command) {
962
1016
  if (!/\bgit\b/u.test(command))
963
1017
  return [];
964
1018
  const ops = [];
965
- for (const m of command.matchAll(MAIN_OWNERSHIP_OP)) {
966
- const subcommand = m[2] ?? "";
1019
+ const segments = segmentBounds(command);
1020
+ for (const m of command.matchAll(MAIN_OWNERSHIP_VERB)) {
1021
+ const verb = m[2] ?? "";
1022
+ const verbStart = (m.index ?? 0) + m[0].length - verb.length;
1023
+ const segment = segments.find((s) => verbStart >= s.start && verbStart < s.end) ??
1024
+ { start: verbStart, end: command.length };
1025
+ const subcommand = command.slice(verbStart, segment.end);
967
1026
  const words = shellWords(subcommand);
968
1027
  if (words === "ambiguous") {
969
1028
  ops.push({ label: "git main", globals: m[1] ?? "", index: m.index ?? 0 });
@@ -1,5 +1,18 @@
1
1
  export declare const ALL_BLUEPRINT_STATES: readonly ["draft", "planned", "in-progress", "parked", "archived", "completed"];
2
2
  export declare const NON_COMPLETED_BLUEPRINT_STATES: readonly ["draft", "planned", "in-progress", "parked", "archived"];
3
+ /**
4
+ * Collect every reason "${slug}" cannot move to `completed`, without
5
+ * stopping at the first one: zero-task / unfinished-task blockers, missing
6
+ * task-local evidence per done task, `runValidate` document gaps, and the
7
+ * completed approval/digest gate. Callers that need to report every
8
+ * blocking condition in a single response (e.g. `wp_blueprint_finalize`)
9
+ * use this directly, before attempting any external side effect (platform
10
+ * sync) or local move — so a refusal never fires mid-move and a caller
11
+ * fixing issues one at a time never needs one failed round-trip per
12
+ * condition. {@link assertBlueprintCanComplete} throws the same combined
13
+ * message for callers that only need a single throw.
14
+ */
15
+ export declare function collectBlueprintCompletionBlockers(overviewPath: string, slug: string): string[];
3
16
  export declare function assertBlueprintCanComplete(overviewPath: string, slug: string): void;
4
17
  export declare function applyLocalBlueprintTransition(input: {
5
18
  projectCwd: string;
@@ -6,7 +6,7 @@ import { setBlueprintFrontmatterFields } from "#lifecycle/engine";
6
6
  import { assertCompletedApprovalGate, evaluateApprovalGate, formatApprovalGateRequirement, } from "#lifecycle/audit";
7
7
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
8
8
  import { getBlueprintDocumentPaths } from "#utils/document-paths.js";
9
- import { assertAllTasksHaveCanonicalPassingEvidence } from "#verification.js";
9
+ import { collectMissingCanonicalPassingEvidence } from "#verification.js";
10
10
  import { writeFileAtomic } from "#shared-utils/write-json-file.js";
11
11
  import { applyPromotionTrustGate } from "#trust/promotion.js";
12
12
  import { reIngest } from "#mcp/blueprint/_shared/db";
@@ -34,18 +34,50 @@ function formatBlueprintProgress(totalTasks, doneTasks, blockedTasks) {
34
34
  const percent = totalTasks === 0 ? 0 : Math.round((doneTasks / totalTasks) * 100);
35
35
  return `${percent}% (${doneTasks}/${totalTasks} tasks done, ${blockedTasks} blocked, updated ${todayIsoDate()})`;
36
36
  }
37
- export function assertBlueprintCanComplete(overviewPath, slug) {
37
+ /**
38
+ * Collect every reason "${slug}" cannot move to `completed`, without
39
+ * stopping at the first one: zero-task / unfinished-task blockers, missing
40
+ * task-local evidence per done task, `runValidate` document gaps, and the
41
+ * completed approval/digest gate. Callers that need to report every
42
+ * blocking condition in a single response (e.g. `wp_blueprint_finalize`)
43
+ * use this directly, before attempting any external side effect (platform
44
+ * sync) or local move — so a refusal never fires mid-move and a caller
45
+ * fixing issues one at a time never needs one failed round-trip per
46
+ * condition. {@link assertBlueprintCanComplete} throws the same combined
47
+ * message for callers that only need a single throw.
48
+ */
49
+ export function collectBlueprintCompletionBlockers(overviewPath, slug) {
50
+ const blockers = [];
38
51
  const markdown = readFileSync(overviewPath, "utf8");
39
52
  const blueprint = parseBlueprint(markdown, slug);
40
53
  if (blueprint.tasks.length === 0) {
41
- throw new Error(`Cannot complete "${slug}": zero-task blueprints cannot move to completed through the public lifecycle surface`);
54
+ blockers.push(`Cannot complete "${slug}": zero-task blueprints cannot move to completed through the public lifecycle surface`);
55
+ }
56
+ else {
57
+ const unfinished = blueprint.tasks.filter((task) => task.status !== "done" && task.status !== "dropped");
58
+ if (unfinished.length > 0) {
59
+ const list = unfinished.map((task) => `${task.id} (${task.status})`).join(", ");
60
+ blockers.push(`Cannot complete "${slug}": the following tasks are not done: ${list}`);
61
+ }
62
+ blockers.push(...collectMissingCanonicalPassingEvidence(markdown, blueprint.tasks.filter((task) => task.status !== "dropped").map((task) => task.id)));
63
+ }
64
+ const validated = runValidate(overviewPath);
65
+ if (!validated.valid) {
66
+ blockers.push(...validated.gaps.map((gap) => `Cannot complete "${slug}": ${gap}`));
67
+ }
68
+ try {
69
+ assertCompletedApprovalGate(overviewPath, markdown);
70
+ }
71
+ catch (error) {
72
+ blockers.push(`Cannot complete "${slug}": ${error instanceof Error ? error.message : String(error)}`);
42
73
  }
43
- const unfinished = blueprint.tasks.filter((task) => task.status !== "done" && task.status !== "dropped");
44
- if (unfinished.length > 0) {
45
- const list = unfinished.map((task) => `${task.id} (${task.status})`).join(", ");
46
- throw new Error(`Cannot complete "${slug}": the following tasks are not done: ${list}`);
74
+ return blockers;
75
+ }
76
+ export function assertBlueprintCanComplete(overviewPath, slug) {
77
+ const blockers = collectBlueprintCompletionBlockers(overviewPath, slug);
78
+ if (blockers.length > 0) {
79
+ throw new Error(blockers.join("; "));
47
80
  }
48
- assertAllTasksHaveCanonicalPassingEvidence(markdown, blueprint.tasks.filter((task) => task.status !== "dropped").map((task) => task.id));
49
81
  }
50
82
  export async function applyLocalBlueprintTransition(input) {
51
83
  const { projectCwd, slug, to_state, found, trustedMarkdown: preflightTrustedMarkdown } = input;