@webpresso/agent-kit 3.3.4 → 3.3.5

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 (29) 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/build/vendor-oxlint-plugins.d.ts +4 -1
  4. package/dist/esm/build/vendor-oxlint-plugins.js +9 -14
  5. package/dist/esm/cli/commands/init/index.js +3 -4
  6. package/dist/esm/cli/commands/review.d.ts +1 -0
  7. package/dist/esm/cli/commands/review.js +77 -1
  8. package/dist/esm/cli/commands/sync.d.ts +8 -6
  9. package/dist/esm/cli/commands/sync.js +11 -9
  10. package/dist/esm/config/oxlint/oxlintrc.d.ts +1 -1
  11. package/dist/esm/config/oxlint/oxlintrc.js +3 -12
  12. package/dist/esm/content/dispatch.js +4 -0
  13. package/dist/esm/content/loader.d.ts +1 -1
  14. package/dist/esm/content/loader.js +10 -6
  15. package/dist/esm/content/skill-policy.d.ts +11 -0
  16. package/dist/esm/content/skill-policy.js +33 -0
  17. package/dist/esm/hooks/pretool-guard/dev-routing.js +67 -4
  18. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  19. package/dist/esm/review/authority.js +56 -1
  20. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  21. package/dist/esm/review/delivery-verifier.js +9 -1
  22. package/dist/esm/symlinker/consumers.d.ts +3 -1
  23. package/dist/esm/symlinker/consumers.js +7 -1
  24. package/dist/esm/symlinker/unified-sync.d.ts +3 -3
  25. package/dist/esm/symlinker/unified-sync.js +88 -11
  26. package/dist/esm/test/shard-durations.json +0 -1
  27. package/package.json +12 -12
  28. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  29. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
@@ -1,20 +1,13 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { readConfig } from "#cli/commands/init/config";
4
- import { DEFAULT_PACKAGED_SKILL_SLUG_SET } from "#content/skill-policy";
4
+ import { AGENT_KIT_INTERNAL_SKILL_SLUGS, DEFAULT_PACKAGED_SKILL_SLUG_SET, DEFAULT_PACKAGED_SKILL_SLUGS, MERGED_DELETED_SKILL_SLUGS, UNPACKAGED_SKILL_SLUGS, } from "#content/skill-policy";
5
5
  import { readTrustedJsonFile } from "#shared-utils/read-json-file.js";
6
6
  import matter from "gray-matter";
7
7
  import { WP_HOOK_SPECS } from "#cli/commands/init/scaffolders/agent-hooks/ir.js";
8
8
  const REQUIRED_HOOKS = WP_HOOK_SPECS.map(({ event, bin }) => ({ event, bin }));
9
9
  const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
10
10
  const CODEX_HOOKS_PATH = ".codex/hooks.json";
11
- const REQUIRED_SUBAGENTS = [
12
- "code-reviewer.md",
13
- "security-auditor.md",
14
- "doc-writer.md",
15
- "explorer.md",
16
- "implementer.md",
17
- ];
18
11
  const EXPLORER_CONTRACT_MARKERS = [
19
12
  "3–5 tool calls",
20
13
  "Defs:",
@@ -54,6 +47,10 @@ export function auditAgents(rootDirectory = process.cwd()) {
54
47
  checkCatalogAgents(root, violations);
55
48
  checked += 1;
56
49
  checkCatalogReportOnlySkills(root, violations);
50
+ checked += 1;
51
+ checkSelfHostClaudeAgents(root, violations);
52
+ checked += 1;
53
+ checkCatalogSkillClassification(root, violations);
57
54
  }
58
55
  else {
59
56
  checked += 1;
@@ -117,6 +114,19 @@ function checkCatalogSkillHooks(root, violations) {
117
114
  }
118
115
  }
119
116
  }
117
+ /**
118
+ * List the `.md` filenames (e.g. "code-reviewer.md") directly under a
119
+ * catalog/agent/agents-shaped directory, excluding README.md. Returns an
120
+ * empty array when the directory does not exist. This is the single source
121
+ * of truth for "which subagents are required" — no hardcoded list.
122
+ */
123
+ function listCatalogAgentFiles(agentsSourceDir) {
124
+ if (!existsSync(agentsSourceDir))
125
+ return [];
126
+ return readdirSync(agentsSourceDir)
127
+ .filter((file) => file.endsWith(".md") && file !== "README.md")
128
+ .sort();
129
+ }
120
130
  function checkCatalogAgents(root, violations) {
121
131
  const agentsSource = join(root, "catalog", "agent", "agents");
122
132
  if (!existsSync(agentsSource)) {
@@ -126,28 +136,44 @@ function checkCatalogAgents(root, violations) {
126
136
  });
127
137
  return;
128
138
  }
129
- for (const file of REQUIRED_SUBAGENTS) {
139
+ const requiredFiles = listCatalogAgentFiles(agentsSource);
140
+ if (requiredFiles.length === 0) {
141
+ violations.push({
142
+ file: "catalog/agent/agents",
143
+ message: "catalog/agent/agents must contain canonical subagent markdown files.",
144
+ });
145
+ return;
146
+ }
147
+ for (const file of requiredFiles) {
148
+ if (file !== "explorer.md")
149
+ continue;
130
150
  const sourcePath = join(agentsSource, file);
131
- if (!existsSync(sourcePath)) {
151
+ const content = readFileSync(sourcePath, "utf8");
152
+ for (const marker of EXPLORER_CONTRACT_MARKERS) {
153
+ if (content.includes(marker))
154
+ continue;
132
155
  violations.push({
133
156
  file: relative(root, sourcePath),
134
- message: `Missing canonical subagent ${file} in catalog/agent/agents.`,
157
+ message: `Explorer subagent output contract is missing marker: ${marker}`,
135
158
  });
136
- continue;
137
- }
138
- if (file === "explorer.md") {
139
- const content = readFileSync(sourcePath, "utf8");
140
- for (const marker of EXPLORER_CONTRACT_MARKERS) {
141
- if (content.includes(marker))
142
- continue;
143
- violations.push({
144
- file: relative(root, sourcePath),
145
- message: `Explorer subagent output contract is missing marker: ${marker}`,
146
- });
147
- }
148
159
  }
149
160
  }
150
161
  }
162
+ /**
163
+ * Self-host-only: validate `.claude/agents/` (this repo's own generated
164
+ * projection) the same way a consumer's `.claude/agents/` is validated.
165
+ * Absent is acceptable — the surface is generated + gitignored in a clean
166
+ * checkout — but when present, every catalog-required subagent must resolve
167
+ * to a live symlink.
168
+ */
169
+ function checkSelfHostClaudeAgents(root, violations) {
170
+ const agentsTarget = join(root, ".claude", "agents");
171
+ if (!existsSync(agentsTarget))
172
+ return;
173
+ const agentsSource = join(root, "catalog", "agent", "agents");
174
+ const requiredFiles = listCatalogAgentFiles(agentsSource);
175
+ validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
176
+ }
151
177
  function checkCatalogReportOnlySkills(root, violations) {
152
178
  const skillsSource = join(root, "catalog", "agent", "skills");
153
179
  if (!existsSync(skillsSource)) {
@@ -193,6 +219,63 @@ function checkCatalogReportOnlySkills(root, violations) {
193
219
  }
194
220
  }
195
221
  }
222
+ /**
223
+ * Resolve the required subagent file list for a consumer repo. Preferred
224
+ * source: the installed catalog under node_modules (the real consumer-mode
225
+ * source of truth that `.claude/agents/*.md` symlinks point at). When
226
+ * node_modules is not installed (e.g. a partial checkout), fall back to
227
+ * whatever `.claude/agents/*.md` already exists so symlink-health checks
228
+ * still run without false-positiving "missing" for agents we have no
229
+ * catalog reference for.
230
+ */
231
+ function resolveConsumerRequiredAgentFiles(root) {
232
+ const installedAgentsSource = join(root, "node_modules", "@webpresso", "agent-kit", "catalog", "agent", "agents");
233
+ const fromInstalled = listCatalogAgentFiles(installedAgentsSource);
234
+ if (fromInstalled.length > 0)
235
+ return fromInstalled;
236
+ const agentsTarget = join(root, ".claude", "agents");
237
+ if (!existsSync(agentsTarget))
238
+ return [];
239
+ // Restrict to symlinks only — a hand-authored real file (Claude Code's
240
+ // native subagent authoring surface) is never agent-kit-managed and must
241
+ // not be false-flagged as "must remain a symlink" just because node_modules
242
+ // isn't installed to prove otherwise.
243
+ return readdirSync(agentsTarget)
244
+ .filter((file) => file.endsWith(".md") && lstatSync(join(agentsTarget, file)).isSymbolicLink())
245
+ .sort();
246
+ }
247
+ /**
248
+ * Every catalog skill slug must be accounted for as either default-packaged,
249
+ * explicitly unpackaged (with a reason), merged/deleted, or an
250
+ * agent-kit-internal maintainer-only skill. An unclassified slug is a
251
+ * violation — it means a new catalog skill was added without a deliberate
252
+ * packaging decision.
253
+ */
254
+ function checkCatalogSkillClassification(root, violations) {
255
+ const skillsSource = join(root, "catalog", "agent", "skills");
256
+ if (!existsSync(skillsSource))
257
+ return; // reported by checkCatalogReportOnlySkills
258
+ const knownSlugs = new Set([
259
+ ...DEFAULT_PACKAGED_SKILL_SLUGS,
260
+ ...Object.keys(UNPACKAGED_SKILL_SLUGS),
261
+ ...MERGED_DELETED_SKILL_SLUGS,
262
+ ...AGENT_KIT_INTERNAL_SKILL_SLUGS,
263
+ ]);
264
+ for (const entry of readdirSync(skillsSource, { withFileTypes: true })) {
265
+ if (!entry.isDirectory())
266
+ continue;
267
+ if (!existsSync(join(skillsSource, entry.name, "SKILL.md")))
268
+ continue;
269
+ if (knownSlugs.has(entry.name))
270
+ continue;
271
+ violations.push({
272
+ file: `catalog/agent/skills/${entry.name}/SKILL.md`,
273
+ message: `Skill "${entry.name}" is not classified as packaged or unpackaged. Add it to ` +
274
+ "DEFAULT_PACKAGED_SKILL_SLUGS or UNPACKAGED_SKILL_SLUGS in src/content/skill-policy.ts " +
275
+ "with a one-line reason.",
276
+ });
277
+ }
278
+ }
196
279
  function checkClaudeAgents(root, violations) {
197
280
  const agentsTarget = join(root, ".claude", "agents");
198
281
  if (!existsSync(agentsTarget)) {
@@ -202,16 +285,33 @@ function checkClaudeAgents(root, violations) {
202
285
  });
203
286
  return;
204
287
  }
205
- for (const file of REQUIRED_SUBAGENTS) {
288
+ const requiredFiles = resolveConsumerRequiredAgentFiles(root);
289
+ validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
290
+ }
291
+ /**
292
+ * Shared per-file symlink validator for `.claude/agents/<file>`, used by both
293
+ * the self-host and consumer audit paths. Checks: missing entry, not a
294
+ * symlink, wrong/unrecognized target, and dangling target.
295
+ */
296
+ function validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations) {
297
+ for (const file of requiredFiles) {
206
298
  const targetPath = join(agentsTarget, file);
207
- if (!existsSync(targetPath)) {
299
+ // lstatSync (not existsSync) so a dangling symlink — an entry that
300
+ // exists but resolves to nothing — is distinguished from a genuinely
301
+ // missing entry. existsSync follows symlinks, so it would misreport
302
+ // every dangling symlink as "missing" and the dangling check below
303
+ // would never be reachable.
304
+ let stat;
305
+ try {
306
+ stat = lstatSync(targetPath);
307
+ }
308
+ catch {
208
309
  violations.push({
209
310
  file: relative(root, targetPath),
210
311
  message: `Missing Claude subagent ${file}. Run \`wp setup\` to re-sync canonical subagents.`,
211
312
  });
212
313
  continue;
213
314
  }
214
- const stat = lstatSync(targetPath);
215
315
  if (!stat.isSymbolicLink()) {
216
316
  violations.push({
217
317
  file: relative(root, targetPath),
@@ -39,8 +39,15 @@ export function parseTrustDossier(markdown) {
39
39
  const gates = parsePromotionGatesTable(requiredBlock(blocks, "Promotion Gates"), violations);
40
40
  const residualUnknowns = requiredBlock(blocks, "Residual Unknowns").trim();
41
41
  for (const [section, block] of blocks) {
42
- if (containsPlaceholder(block))
42
+ const fields = findPlaceholderFields(block);
43
+ if (fields.length > 0) {
44
+ for (const field of fields) {
45
+ violations.push({ section, message: `placeholder value not filled in: ${field}` });
46
+ }
47
+ }
48
+ else if (containsPlaceholder(block)) {
43
49
  violations.push({ section, message: "placeholder values are not allowed" });
50
+ }
44
51
  }
45
52
  if (violations.length > 0 || readiness === null)
46
53
  return { violations };
@@ -181,6 +188,39 @@ function splitRow(row) {
181
188
  function containsPlaceholder(value) {
182
189
  return /<[^>]+>|\bTBD\b|\bTODO\b/u.test(value);
183
190
  }
191
+ // Names the specific field(s) inside a Trust Dossier subsection block that
192
+ // still carry a scaffold placeholder, instead of only flagging the section as
193
+ // a whole. Covers both shapes the dossier uses: `- key: value` list lines
194
+ // (Readiness Verdict) and markdown table rows (Material Claims/Decisions,
195
+ // Promotion Gates). Free-text sections (Residual Unknowns) have no named
196
+ // field to point at, so the caller falls back to the whole-section message
197
+ // when this returns an empty array.
198
+ function findPlaceholderFields(block) {
199
+ const fields = [];
200
+ for (const line of block.split("\n")) {
201
+ const match = /^-\s*([^:]+):\s*(.+?)\s*$/u.exec(line.trim());
202
+ if (match && containsPlaceholder(match[2] ?? ""))
203
+ fields.push(match[1].trim());
204
+ }
205
+ const rows = block
206
+ .split("\n")
207
+ .map((line) => line.trim())
208
+ .filter((line) => line.startsWith("|") && line.endsWith("|"));
209
+ if (rows.length >= 2) {
210
+ const header = splitRow(rows[0] ?? "");
211
+ for (const row of rows.slice(2)) {
212
+ const cells = splitRow(row);
213
+ for (const [index, cell] of cells.entries()) {
214
+ if (!containsPlaceholder(cell))
215
+ continue;
216
+ const columnName = header[index] ?? `column ${index + 1}`;
217
+ const rowId = cells[0] ? ` (row ${cells[0]})` : "";
218
+ fields.push(`${columnName}${rowId}`);
219
+ }
220
+ }
221
+ }
222
+ return fields;
223
+ }
184
224
  function escapeRegExp(value) {
185
225
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
226
  }
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  export declare const AGENT_CONFIG_OXLINT_DIST: string;
3
3
  export declare const AGENT_KIT_OXLINT_DIST: string;
4
- /** Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles). */
4
+ /**
5
+ * Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles).
6
+ * SSOT: `@webpresso/agent-config/oxlint/basenames`.
7
+ */
5
8
  export declare const VENDOR_PLUGIN_BASENAMES: readonly ["code-safety", "foundation-purity", "graphql-conventions", "import-hygiene", "monorepo-paths", "query-patterns", "testing-quality", "tier-boundaries"];
6
9
  export declare function vendorOxlintPlugins(sourceDir?: string, destDir?: string): {
7
10
  copied: string[];
@@ -9,19 +9,14 @@
9
9
  */
10
10
  import { cpSync, existsSync, mkdirSync } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { OXLINT_PATH_ROLES_BASENAME, OXLINT_POLICY_PLUGIN_BASENAMES, } from "@webpresso/agent-config/oxlint/basenames";
12
13
  export const AGENT_CONFIG_OXLINT_DIST = join(process.cwd(), "packages/agent-config/dist/esm/oxlint");
13
14
  export const AGENT_KIT_OXLINT_DIST = join(process.cwd(), "dist/esm/config/oxlint");
14
- /** Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles). */
15
- export const VENDOR_PLUGIN_BASENAMES = [
16
- "code-safety",
17
- "foundation-purity",
18
- "graphql-conventions",
19
- "import-hygiene",
20
- "monorepo-paths",
21
- "query-patterns",
22
- "testing-quality",
23
- "tier-boundaries",
24
- ];
15
+ /**
16
+ * Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles).
17
+ * SSOT: `@webpresso/agent-config/oxlint/basenames`.
18
+ */
19
+ export const VENDOR_PLUGIN_BASENAMES = OXLINT_POLICY_PLUGIN_BASENAMES;
25
20
  export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDir = AGENT_KIT_OXLINT_DIST) {
26
21
  if (!existsSync(sourceDir)) {
27
22
  throw new Error(`vendor-oxlint-plugins: missing agent-config oxlint dist at ${sourceDir}. Build @webpresso/agent-config first.`);
@@ -39,12 +34,12 @@ export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDi
39
34
  // path-roles is imported by several plugins as a sibling — vendor it too.
40
35
  }
41
36
  // Always vendor path-roles (shared classifier used via relative imports inside plugins).
42
- const pathRoles = join(sourceDir, "path-roles.js");
37
+ const pathRoles = join(sourceDir, `${OXLINT_PATH_ROLES_BASENAME}.js`);
43
38
  if (!existsSync(pathRoles)) {
44
39
  throw new Error(`vendor-oxlint-plugins: missing ${pathRoles}`);
45
40
  }
46
- cpSync(pathRoles, join(destDir, "path-roles.js"));
47
- copied.push("path-roles");
41
+ cpSync(pathRoles, join(destDir, `${OXLINT_PATH_ROLES_BASENAME}.js`));
42
+ copied.push(OXLINT_PATH_ROLES_BASENAME);
48
43
  return { copied };
49
44
  }
50
45
  if (import.meta.main) {
@@ -61,7 +61,6 @@ import { ensureOpenCodeWebpressoPlugin } from "./scaffolders/opencode-plugin/ind
61
61
  import { ensureGrokWebpressoMcp } from "./scaffolders/grok-mcp/index.js";
62
62
  import { ensureRtk } from "./scaffolders/rtk/index.js";
63
63
  import { checkRuntimes } from "./scaffolders/runtime-check/index.js";
64
- import { scaffoldSubagents } from "./scaffolders/subagents/index.js";
65
64
  import { maybeRunVisionInterview } from "./scaffolders/vision/interview.js";
66
65
  import { scaffoldVision } from "./scaffolders/vision/index.js";
67
66
  import { scaffoldWorkspaceConfig } from "./scaffolders/workspace-config/index.js";
@@ -1333,7 +1332,7 @@ export async function runInit(flags, deps = {}) {
1333
1332
  runUnifiedSync({
1334
1333
  catalogDir: join(catalogDir, "agent"),
1335
1334
  consumerRoot: consumer.repoRoot,
1336
- kinds: ["rule", "skill"],
1335
+ kinds: ["rule", "skill", "agent"],
1337
1336
  check: false,
1338
1337
  allowedSkillSlugs,
1339
1338
  hosts: selectedHosts,
@@ -1484,7 +1483,8 @@ export async function runInit(flags, deps = {}) {
1484
1483
  }
1485
1484
  throw error;
1486
1485
  }
1487
- const subagentResults = scaffoldSubagents({ repoRoot: consumer.repoRoot, options });
1486
+ // Canonical subagents (.claude/agents/) are projected by the unified sync
1487
+ // call above (kinds includes "agent") — no standalone scaffolder here.
1488
1488
  // Generated host instructions (AGENTS.md) are a tier-1 (guarded/full)
1489
1489
  // surface; the tier-0 quality profile omits them.
1490
1490
  // Base-kit may have created or updated package metadata earlier in this
@@ -1853,7 +1853,6 @@ export async function runInit(flags, deps = {}) {
1853
1853
  ]
1854
1854
  : []),
1855
1855
  ...claudeRulesResults,
1856
- ...subagentResults,
1857
1856
  ...presetResults,
1858
1857
  ];
1859
1858
  const summary = summarizeResults(all);
@@ -220,6 +220,7 @@ export interface ReviewGateDependencies {
220
220
  */
221
221
  readonly prepareOpencodeCredential?: (account: OpencodeAccount) => string;
222
222
  }
223
+ export declare function describeVerificationFailure(verification: ReviewGateVerification | undefined, gateId: string): string;
223
224
  /**
224
225
  * Sanctioned fallback when `wp review gate` does not reach "approved" — points
225
226
  * at the tracked-provenance path (`wp review log` / `wp_blueprint_review_log`)
@@ -28,6 +28,7 @@ import { isPolicyApprovalVerdict } from "#review/verdict.js";
28
28
  import { verificationArtifactSection, verificationPromptSummary, verifyDeliveryPromotionGates, } from "#review/delivery-verifier.js";
29
29
  import { createDeliverySubjectAtRef, createPlanSubjectAtRef, findUnreviewedWorkingTreeChanges, } from "#review/subject.js";
30
30
  import { runCommand } from "#mcp/tools/_shared/run-command.js";
31
+ import { ALLOWED_WP_COMMAND_TIMEOUT_MS } from "#trust/command-runner.js";
31
32
  import { NotInGitRepoError, withLock } from "#paths/state-root.js";
32
33
  import { writeFileAtomic, writeJsonFileAtomic } from "#shared-utils/write-json-file.js";
33
34
  export const REVIEWER_IDS = [
@@ -1844,6 +1845,64 @@ function selectGateProviders(input) {
1844
1845
  const available = ordered.filter((provider) => !isProviderSuppressed(input.availability, provider));
1845
1846
  return available.length > 0 ? available : [];
1846
1847
  }
1848
+ // Builds the `message` for a "verification-failed" gate result: host-side
1849
+ // verification (delivery Promotion Gates + Trust Dossier parsing) died before
1850
+ // any review provider ran, so `attempts` is empty and the default
1851
+ // approval-gate-template message ("Approval gate not satisfied (...)") reads
1852
+ // like a reviewer rejected the work when no reviewer was ever invoked. Names
1853
+ // the actual cause and the corrective action so a failed gate does not cost
1854
+ // a repeat run just to discover what happened. Exported for tests.
1855
+ export function describeVerificationFailure(verification, gateId) {
1856
+ const resume = `Retry with --resume ${gateId} once fixed.`;
1857
+ const preamble = "Host verification failed before any review provider ran (0 review attempts) -- this is not a provider/quota failure.";
1858
+ if (!verification || verification.status !== "failed") {
1859
+ return `${preamble} ${resume}`;
1860
+ }
1861
+ if (verification.failureCode === "malformed-trust-dossier") {
1862
+ const fields = (verification.violations ?? [])
1863
+ .map((violation) => `${violation.section}: ${violation.message}`)
1864
+ .join("; ");
1865
+ return `${preamble} The Trust Dossier is malformed${fields ? ` (${fields})` : ""}. Fill in the named field(s) via wp_blueprint_put (or wp blueprint promote to see the same detail) before gating again. ${resume}`;
1866
+ }
1867
+ const failedCommand = verification.commands.find((command) => command.outcome !== "passed");
1868
+ if (verification.failureCode === "command-timed-out" && failedCommand) {
1869
+ const budgetSeconds = ALLOWED_WP_COMMAND_TIMEOUT_MS / 1000;
1870
+ return (`${preamble} The Promotion Gate command \`${failedCommand.command}\` exceeded the ` +
1871
+ `${budgetSeconds}s sandboxed gate budget and was killed -- this is a fixed per-command ` +
1872
+ "budget, not a live outage, and raising it is not the fix. Either replace this gate with " +
1873
+ "a faster command (e.g. `wp lint`, or a narrow `wp audit <kind>`), or mark this Promotion " +
1874
+ 'Gates row `defer: "pre-implementation"` so it is recorded without being executed at gate ' +
1875
+ `time. ${resume}`);
1876
+ }
1877
+ if (failedCommand) {
1878
+ const exitDetail = failedCommand.exitCode === null ? "no exit code" : `exit ${failedCommand.exitCode}`;
1879
+ const logDetail = verification.logPath ? ` Log: ${verification.logPath}.` : "";
1880
+ return (`${preamble} Gate command \`${failedCommand.command}\` failed (${exitDetail}, ` +
1881
+ `outcome: ${failedCommand.outcome}).${logDetail} This is usually a missing dependency or ` +
1882
+ "stale install in the sandboxed checkout -- run the repo's install command " +
1883
+ `(e.g. \`wp install\`) and retry. ${resume}`);
1884
+ }
1885
+ const codeDetail = verification.failureCode ? ` (failureCode: ${verification.failureCode})` : "";
1886
+ return `${preamble} Verification failed${codeDetail} before any gate command ran. ${resume}`;
1887
+ }
1888
+ // Human-readable summary of why every candidate provider/reviewer is
1889
+ // currently suppressed in the project's availability.json cache, so a
1890
+ // zero-candidate gate result names the file and the reason instead of
1891
+ // looking like a silent no-op.
1892
+ function describeSuppressedCandidates(availability, candidates) {
1893
+ return candidates
1894
+ .map((provider) => {
1895
+ const record = availability.providers[provider];
1896
+ if (!record?.suppressed)
1897
+ return undefined;
1898
+ const reason = record.suppressionReason ?? record.terminalClassification ?? "unknown reason";
1899
+ const since = record.suppressedAt ? ` since ${record.suppressedAt}` : "";
1900
+ const retry = record.retryAfterMs === undefined ? "" : ` (retry window ${record.retryAfterMs}ms)`;
1901
+ return `${provider}: suppressed${since} (${reason}${retry})`;
1902
+ })
1903
+ .filter((value) => value !== undefined)
1904
+ .join("; ");
1905
+ }
1847
1906
  function safeArtifactPath(blueprintPath, relativeArtifact) {
1848
1907
  if (path.isAbsolute(relativeArtifact) || relativeArtifact.split(/[\\/]/u).includes("..")) {
1849
1908
  throw new Error(`Unsafe review artifact path: ${relativeArtifact}`);
@@ -2225,7 +2284,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2225
2284
  prompt = renderPrompt(summary);
2226
2285
  }
2227
2286
  if (verification?.status === "failed") {
2228
- const result = makeResult(currentApprovalGate(), "verification-failed");
2287
+ const result = makeResult(currentApprovalGate(), "verification-failed", describeVerificationFailure(verification, gateId));
2229
2288
  assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2230
2289
  persistGateState(projectRoot, result);
2231
2290
  cleanupSharedCheckout();
@@ -2297,6 +2356,23 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
2297
2356
  availability,
2298
2357
  ...(explicitProvider ? { explicitProvider } : {}),
2299
2358
  });
2359
+ if (providerQueue.length === 0) {
2360
+ const candidates = hostNeutralProviderOrder(explicitProvider);
2361
+ const detail = describeSuppressedCandidates(availability, candidates);
2362
+ const availabilityFile = path
2363
+ .relative(projectRoot, reviewAvailabilityPath(projectRoot))
2364
+ .replace(/\\/gu, "/");
2365
+ const result = makeResult(approvalGate, "needs-review", `No review provider was invoked: every candidate provider is suppressed by the ` +
2366
+ `cached availability record at ${availabilityFile}${detail ? ` (${detail})` : ""}. ` +
2367
+ "This is a stale-cache condition, not a live provider outage -- if the " +
2368
+ "account/provider has since recovered or been re-authenticated, delete the stale " +
2369
+ `entry (or the whole file) at ${availabilityFile}, or wait for its suppression ` +
2370
+ `window to elapse, then retry with --resume ${gateId}.`);
2371
+ assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
2372
+ persistGateState(projectRoot, result);
2373
+ cleanupSharedCheckout();
2374
+ return result;
2375
+ }
2300
2376
  const usedReviewers = new Set([
2301
2377
  ...attempts
2302
2378
  .filter((attempt) => !isRetryableGateAttempt(attempt))
@@ -1,16 +1,18 @@
1
1
  /**
2
- * `wp sync` — projects the canonical webpresso rule/skill catalog into the
3
- * supported host surfaces.
2
+ * `wp sync` — projects the canonical webpresso rule/skill/agent catalog into
3
+ * the supported host surfaces.
4
4
  *
5
- * Projects unified rule + skill content (catalog ∪ consumer) into per-IDE
6
- * surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
5
+ * Projects unified rule + skill + agent content (catalog ∪ consumer) into
6
+ * per-IDE surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
7
7
  *
8
8
  * Flags:
9
- * --kind rules|skills Filter to a single kind (default: both).
10
- * --check Dry-run; exit 1 on first drift, no writes.
9
+ * --kind rules|skills|agents Filter to a single kind (default: all).
10
+ * --check Dry-run; exit 1 on first drift, no writes.
11
11
  */
12
12
  import type { CAC } from "cac";
13
+ import type { ContentKind } from "#content/loader";
13
14
  import { type ConsumerContext } from "./init/detect-consumer.js";
15
+ export declare function parseKind(input: string | undefined): readonly ContentKind[] | undefined;
14
16
  export declare function computeSyncDriftCounts(input: {
15
17
  readonly surfaceFixCount: number;
16
18
  readonly agentsChanged: boolean;
@@ -1,13 +1,13 @@
1
1
  /**
2
- * `wp sync` — projects the canonical webpresso rule/skill catalog into the
3
- * supported host surfaces.
2
+ * `wp sync` — projects the canonical webpresso rule/skill/agent catalog into
3
+ * the supported host surfaces.
4
4
  *
5
- * Projects unified rule + skill content (catalog ∪ consumer) into per-IDE
6
- * surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
5
+ * Projects unified rule + skill + agent content (catalog ∪ consumer) into
6
+ * per-IDE surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
7
7
  *
8
8
  * Flags:
9
- * --kind rules|skills Filter to a single kind (default: both).
10
- * --check Dry-run; exit 1 on first drift, no writes.
9
+ * --kind rules|skills|agents Filter to a single kind (default: all).
10
+ * --check Dry-run; exit 1 on first drift, no writes.
11
11
  */
12
12
  import { existsSync } from "node:fs";
13
13
  import { join } from "node:path";
@@ -25,14 +25,16 @@ function commandError(message, exitCode = 1) {
25
25
  err.exitCode = exitCode;
26
26
  return err;
27
27
  }
28
- function parseKind(input) {
28
+ export function parseKind(input) {
29
29
  if (input === undefined)
30
30
  return undefined;
31
31
  if (input === "rules" || input === "rule")
32
32
  return ["rule"];
33
33
  if (input === "skills" || input === "skill")
34
34
  return ["skill"];
35
- throw commandError(`Invalid --kind: ${input}. Must be 'rules' or 'skills'.`);
35
+ if (input === "agents" || input === "agent")
36
+ return ["agent"];
37
+ throw commandError(`Invalid --kind: ${input}. Must be 'rules', 'skills', or 'agents'.`);
36
38
  }
37
39
  function formatMismatches(mismatches) {
38
40
  return mismatches.map((m) => ` - [${m.consumerId}] ${m.targetPath}: ${m.reason}`).join("\n");
@@ -90,7 +92,7 @@ export function isUnmaterializedAgentKitSourceWorktree(consumer) {
90
92
  export function registerSyncCommand(cli) {
91
93
  cli
92
94
  .command("sync", "Sync agent rules + skills across all supported host surfaces")
93
- .option("--kind <kind>", "Filter: rules | skills (default: both)")
95
+ .option("--kind <kind>", "Filter: rules | skills | agents (default: all)")
94
96
  .option("--check", "Exit 1 on drift; no writes")
95
97
  .action(async (options = {}) => {
96
98
  const kinds = parseKind(options.kind);
@@ -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.