@webpresso/agent-kit 3.3.5 → 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.

Potentially problematic release.


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

@@ -110,6 +110,17 @@ export declare function readTaskVerification(markdown: string, taskId: string):
110
110
  */
111
111
  export declare function assertTaskHasCanonicalPassingEvidence(markdown: string, taskId: string): readonly Evidence[];
112
112
  /**
113
- * Assert that each supplied task id has task-local canonical passing evidence.
113
+ * Collect a failure message for every supplied task id that is missing
114
+ * task-local canonical passing evidence, instead of stopping at the first
115
+ * one. Callers that need to report every blocking condition in a single
116
+ * response (e.g. `wp_blueprint_finalize`) use this directly; callers that
117
+ * only need a single throw use {@link assertAllTasksHaveCanonicalPassingEvidence}.
118
+ */
119
+ export declare function collectMissingCanonicalPassingEvidence(markdown: string, taskIds: readonly string[]): string[];
120
+ /**
121
+ * Assert that each supplied task id has task-local canonical passing
122
+ * evidence. Throws once with every failing task's message joined, rather
123
+ * than stopping at the first failure — a caller fixing one issue at a time
124
+ * would otherwise need one failed round-trip per missing-evidence task.
114
125
  */
115
126
  export declare function assertAllTasksHaveCanonicalPassingEvidence(markdown: string, taskIds: readonly string[]): void;
@@ -173,11 +173,34 @@ export function assertTaskHasCanonicalPassingEvidence(markdown, taskId) {
173
173
  return evidence;
174
174
  }
175
175
  /**
176
- * Assert that each supplied task id has task-local canonical passing evidence.
176
+ * Collect a failure message for every supplied task id that is missing
177
+ * task-local canonical passing evidence, instead of stopping at the first
178
+ * one. Callers that need to report every blocking condition in a single
179
+ * response (e.g. `wp_blueprint_finalize`) use this directly; callers that
180
+ * only need a single throw use {@link assertAllTasksHaveCanonicalPassingEvidence}.
177
181
  */
178
- export function assertAllTasksHaveCanonicalPassingEvidence(markdown, taskIds) {
182
+ export function collectMissingCanonicalPassingEvidence(markdown, taskIds) {
183
+ const failures = [];
179
184
  for (const taskId of taskIds) {
180
- assertTaskHasCanonicalPassingEvidence(markdown, taskId);
185
+ try {
186
+ assertTaskHasCanonicalPassingEvidence(markdown, taskId);
187
+ }
188
+ catch (error) {
189
+ failures.push(error instanceof Error ? error.message : String(error));
190
+ }
191
+ }
192
+ return failures;
193
+ }
194
+ /**
195
+ * Assert that each supplied task id has task-local canonical passing
196
+ * evidence. Throws once with every failing task's message joined, rather
197
+ * than stopping at the first failure — a caller fixing one issue at a time
198
+ * would otherwise need one failed round-trip per missing-evidence task.
199
+ */
200
+ export function assertAllTasksHaveCanonicalPassingEvidence(markdown, taskIds) {
201
+ const failures = collectMissingCanonicalPassingEvidence(markdown, taskIds);
202
+ if (failures.length > 0) {
203
+ throw new Error(failures.join("; "));
181
204
  }
182
205
  }
183
206
  // ---------------------------------------------------------------------------
@@ -209,7 +209,11 @@ export function ensureGuardrailsCiStep(content, repoAlreadyWired = false) {
209
209
  // Safe: anchorStepStart is an index returned by findLiveTestRunAnchor over
210
210
  // this same content.split("\n"), so it is always within lines' bounds here.
211
211
  const indent = lines[anchorStepStart].match(/^(\s*)/u)?.[1] ?? "";
212
- lines.splice(anchorStepStart, 0, `${indent}- name: Run wp audit guardrails`, `${indent} run: wp audit guardrails`);
212
+ // continue-on-error: true matches framework quality.yml and keeps
213
+ // ci-guardrails-wiring green without hard-failing thin consumers on
214
+ // agent-kit dogfood audits (command-surface, etc.) or known suite debt.
215
+ // Explicit hard-fail audits remain separate workflow steps.
216
+ lines.splice(anchorStepStart, 0, `${indent}- name: Run wp audit guardrails`, `${indent} continue-on-error: true`, `${indent} run: wp audit guardrails`);
213
217
  return lines.join("\n");
214
218
  }
215
219
  function migrateConsumerWorkflows(repoRoot, actionRef, version, options) {
@@ -6,6 +6,8 @@ export const MCP_COMMAND_HELP = [
6
6
  "by a Claude Code plugin manifest entry (`mcpServers.webpresso`) or any MCP",
7
7
  "client. Tools are auto-discovered from `dist/esm/mcp/tools/*.js`.",
8
8
  "",
9
+ "No subcommands are supported; use `wp review gate` for review workflows.",
10
+ "",
9
11
  "Examples:",
10
12
  " wp mcp",
11
13
  ].join("\n");
@@ -142,6 +142,14 @@ export function registerSyncCommand(cli) {
142
142
  }
143
143
  throw error;
144
144
  }
145
+ // Operator-owned paths agent-kit refused to replace. A warning, never
146
+ // drift: treating it as a mismatch would red `wp sync --check` on every
147
+ // run for someone who legitimately customized a subagent.
148
+ for (const entry of result.preserved) {
149
+ console.warn(`wp sync: preserved ${entry.targetPath} — it is not an agent-kit projection, ` +
150
+ `so the catalog '${entry.slug}' agent was not written there. ` +
151
+ `Rename yours, or move it to agent-agents/ to have it managed.`);
152
+ }
145
153
  const retiredMcp = cleanupRetiredMcpFanouts(repoRoot, { check });
146
154
  for (const targetPath of retiredMcp.preserved) {
147
155
  console.warn(`wp sync: preserved divergent retired MCP target ${targetPath}; remove it manually if it is no longer needed.`);
@@ -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
@@ -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;
@@ -1,5 +1,80 @@
1
+ import { z } from "zod";
1
2
  import type { ProjectResolver } from "#project-resolver.js";
2
3
  import type { ToolHandlerResult } from "#mcp/auto-discover.js";
4
+ declare const putDocumentSchema: z.ZodObject<{
5
+ type: z.ZodDefault<z.ZodLiteral<"blueprint">>;
6
+ title: z.ZodString;
7
+ status: z.ZodEnum<{
8
+ archived: "archived";
9
+ completed: "completed";
10
+ draft: "draft";
11
+ "in-progress": "in-progress";
12
+ parked: "parked";
13
+ planned: "planned";
14
+ }>;
15
+ complexity: z.ZodEnum<{
16
+ L: "L";
17
+ M: "M";
18
+ S: "S";
19
+ XL: "XL";
20
+ XS: "XS";
21
+ }>;
22
+ owner: z.ZodString;
23
+ created: z.ZodString;
24
+ last_updated: z.ZodString;
25
+ progress: z.ZodOptional<z.ZodString>;
26
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
+ product_wedge_anchor: z.ZodObject<{
28
+ stage_outcome: z.ZodString;
29
+ consuming_surface: z.ZodString;
30
+ new_user_visible_capability: z.ZodString;
31
+ }, z.core.$strip>;
32
+ summary: z.ZodString;
33
+ tasks: z.ZodArray<z.ZodObject<{
34
+ id: z.ZodString;
35
+ title: z.ZodString;
36
+ status: z.ZodDefault<z.ZodEnum<{
37
+ blocked: "blocked";
38
+ done: "done";
39
+ dropped: "dropped";
40
+ "in-progress": "in-progress";
41
+ todo: "todo";
42
+ }>>;
43
+ wave: z.ZodOptional<z.ZodString>;
44
+ lane: z.ZodOptional<z.ZodString>;
45
+ description: z.ZodOptional<z.ZodString>;
46
+ acceptance: z.ZodArray<z.ZodString>;
47
+ }, z.core.$strip>>;
48
+ trust_dossier: z.ZodOptional<z.ZodObject<{
49
+ readiness: z.ZodObject<{
50
+ promotion_ready: z.ZodBoolean;
51
+ unresolved_count: z.ZodNumber;
52
+ verified_at: z.ZodString;
53
+ verified_head: z.ZodString;
54
+ trust_gate_version: z.ZodString;
55
+ }, z.core.$strip>;
56
+ material_claims: z.ZodArray<z.ZodObject<{
57
+ id: z.ZodString;
58
+ claim: z.ZodString;
59
+ evidence: z.ZodString;
60
+ }, z.core.$strip>>;
61
+ material_decisions: z.ZodArray<z.ZodObject<{
62
+ id: z.ZodString;
63
+ decision: z.ZodString;
64
+ chosen_option: z.ZodString;
65
+ rejected_alternatives: z.ZodString;
66
+ rationale: z.ZodString;
67
+ }, z.core.$strip>>;
68
+ promotion_gates: z.ZodArray<z.ZodObject<{
69
+ gate: z.ZodString;
70
+ command: z.ZodString;
71
+ expected_outcome: z.ZodString;
72
+ last_result: z.ZodString;
73
+ defer: z.ZodOptional<z.ZodLiteral<"pre-implementation">>;
74
+ }, z.core.$strip>>;
75
+ residual_unknowns: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
76
+ }, z.core.$strip>>;
77
+ }, z.core.$strip>;
3
78
  export declare const putInputJsonSchema: {
4
79
  type: string;
5
80
  properties: {
@@ -233,6 +308,28 @@ export declare const putInputJsonSchema: {
233
308
  };
234
309
  required: string[];
235
310
  };
311
+ /**
312
+ * Promotion-grade dossier completeness (promotion-ready: true,
313
+ * unresolved-count: 0, Residual Unknowns exactly `None.`, non-empty material
314
+ * claims/decisions/gates, passing gate results) is a PROMOTION-time
315
+ * requirement, not a draft-authoring one. `wp_blueprint_put` only ever writes
316
+ * `status: draft` documents (see the refusal in `handleBlueprintPut` above);
317
+ * gating every intermediate draft save on full promotion readiness forced
318
+ * authors to either fabricate readiness fields or keep genuinely-finished
319
+ * tasks marked `todo` to dodge the "checked box implies unreviewed
320
+ * completion" ambiguity scan -- inverting the draft -> planned lifecycle.
321
+ *
322
+ * `promotionCandidate` is therefore derived from the document's OWN target
323
+ * `status`, not hardcoded: a `draft` target skips promotion-grade dossier
324
+ * completeness entirely (an incomplete/in-progress dossier round-trips
325
+ * as-is), while any non-draft target keeps today's full strictness
326
+ * unchanged. Real promotion strictness is enforced separately and
327
+ * unconditionally at `wp_blueprint_transition`'s `crossesDraftApprovalBoundary`
328
+ * gate (`applyPromotionTrustGate` in `#trust/promotion.ts`), which still
329
+ * hardcodes `promotionCandidate: true` and is untouched by this function.
330
+ */
331
+ export declare function validatePutTrustDossier(repoRoot: string, file: string, markdown: string, targetStatus: z.infer<typeof putDocumentSchema>["status"]): string | null;
236
332
  export declare function handleBlueprintPut(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
237
333
  export declare function handleBlueprintTransition(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
238
334
  export declare function handleBlueprintCreate(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
335
+ export {};
@@ -427,13 +427,33 @@ function describeEvidenceLossRefusal(taskId, submittedStatus) {
427
427
  `that evidence. Keep task ${taskId} present with status "done" in the payload; put will carry ` +
428
428
  `the recorded evidence forward automatically.`);
429
429
  }
430
- function validatePutTrustDossier(repoRoot, file, markdown) {
430
+ /**
431
+ * Promotion-grade dossier completeness (promotion-ready: true,
432
+ * unresolved-count: 0, Residual Unknowns exactly `None.`, non-empty material
433
+ * claims/decisions/gates, passing gate results) is a PROMOTION-time
434
+ * requirement, not a draft-authoring one. `wp_blueprint_put` only ever writes
435
+ * `status: draft` documents (see the refusal in `handleBlueprintPut` above);
436
+ * gating every intermediate draft save on full promotion readiness forced
437
+ * authors to either fabricate readiness fields or keep genuinely-finished
438
+ * tasks marked `todo` to dodge the "checked box implies unreviewed
439
+ * completion" ambiguity scan -- inverting the draft -> planned lifecycle.
440
+ *
441
+ * `promotionCandidate` is therefore derived from the document's OWN target
442
+ * `status`, not hardcoded: a `draft` target skips promotion-grade dossier
443
+ * completeness entirely (an incomplete/in-progress dossier round-trips
444
+ * as-is), while any non-draft target keeps today's full strictness
445
+ * unchanged. Real promotion strictness is enforced separately and
446
+ * unconditionally at `wp_blueprint_transition`'s `crossesDraftApprovalBoundary`
447
+ * gate (`applyPromotionTrustGate` in `#trust/promotion.ts`), which still
448
+ * hardcodes `promotionCandidate: true` and is untouched by this function.
449
+ */
450
+ export function validatePutTrustDossier(repoRoot, file, markdown, targetStatus) {
431
451
  const validated = validateBlueprintTrust({
432
452
  repoRoot,
433
453
  file,
434
454
  status: "draft",
435
455
  markdown,
436
- promotionCandidate: true,
456
+ promotionCandidate: targetStatus !== "draft",
437
457
  requirePassingGates: false,
438
458
  scanTaskAmbiguity: true,
439
459
  });
@@ -509,7 +529,7 @@ export async function handleBlueprintPut(projectResolver, cwd, raw) {
509
529
  markdown = preserved.markdown;
510
530
  }
511
531
  if (document.trust_dossier) {
512
- const trustDossierFailure = validatePutTrustDossier(writeCwd, overviewPath, markdown);
532
+ const trustDossierFailure = validatePutTrustDossier(writeCwd, overviewPath, markdown, document.status);
513
533
  if (trustDossierFailure) {
514
534
  return err("wp_blueprint_put validation error", trustDossierFailure);
515
535
  }
@@ -7,7 +7,7 @@ import { ensureProjectionReady } from "#projection-ready.js";
7
7
  import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
8
8
  import { dbPath } from "#mcp/blueprint/_shared/db";
9
9
  import { err, finishPayload, jsonContent } from "#mcp/blueprint/_shared/errors";
10
- import { applyLocalBlueprintTransition, assertBlueprintCanComplete, NON_COMPLETED_BLUEPRINT_STATES, } from "#mcp/blueprint/_shared/lifecycle";
10
+ import { applyLocalBlueprintTransition, collectBlueprintCompletionBlockers, NON_COMPLETED_BLUEPRINT_STATES, } from "#mcp/blueprint/_shared/lifecycle";
11
11
  import { toStr } from "#mcp/blueprint/_shared/payload";
12
12
  import { findBlueprintDir, resolveToolProject } from "#mcp/blueprint/_shared/project";
13
13
  import { resolveSyncAdapter, runPlatformMutationSync } from "#mcp/blueprint/_shared/sync";
@@ -53,8 +53,6 @@ export async function handleFinalize(projectResolver, cwd, raw) {
53
53
  finally {
54
54
  conn.close();
55
55
  }
56
- if (openTasks.length > 0)
57
- return err("wp_blueprint_finalize refused", `Blueprint "${slug}" has open tasks: ${openTasks.map((t) => `${t.task_id} (${t.status})`).join(", ")}`);
58
56
  const root = resolveBlueprintRoot(projectCwd);
59
57
  const found = findBlueprintDir(root, slug, NON_COMPLETED_BLUEPRINT_STATES);
60
58
  if (!found) {
@@ -69,11 +67,24 @@ export async function handleFinalize(projectResolver, cwd, raw) {
69
67
  });
70
68
  return err("wp_blueprint_finalize failed", `Blueprint "${slug}" not found`);
71
69
  }
72
- try {
73
- assertBlueprintCanComplete(found.path, slug);
70
+ // Gather EVERY blocking condition up front, from every check this handler
71
+ // (and applyLocalBlueprintTransition, invoked later) is capable of raising
72
+ // — open tasks per the DB projection, zero-task/unfinished-task/missing-
73
+ // evidence per the raw markdown, doc-validate gaps, and the completed
74
+ // approval/digest gate. Refusing once per condition costs one failed
75
+ // round-trip per fix; a single combined refusal costs one. This also
76
+ // fixes atomicity at the handler level: neither the platform-sync side
77
+ // effect below nor the local move in applyLocalBlueprintTransition is
78
+ // ever attempted while any blocker remains, so a failed finalize call
79
+ // can never partially move — or platform-sync without locally moving —
80
+ // the blueprint.
81
+ const blockers = [];
82
+ if (openTasks.length > 0) {
83
+ blockers.push(`Blueprint "${slug}" has open tasks: ${openTasks.map((t) => `${t.task_id} (${t.status})`).join(", ")}`);
74
84
  }
75
- catch (error) {
76
- return err("wp_blueprint_finalize refused", toStr(error));
85
+ blockers.push(...collectBlueprintCompletionBlockers(found.path, slug));
86
+ if (blockers.length > 0) {
87
+ return err("wp_blueprint_finalize refused", blockers.join("; "));
77
88
  }
78
89
  // Platform-first path: push event + pull fresh replica before local move.
79
90
  // Iron rule: resolveSyncAdapter() returns null when WP_BLUEPRINT_PLATFORM_DISABLED=1.
@@ -255,7 +255,7 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
255
255
  "next_action",
256
256
  ],
257
257
  }, (r) => handleBlueprintCreate(projectResolver, cwd, r), { title: "Blueprint Create", destructiveHint: false, openWorldHint: false });
258
- registrar.registerTool("wp_blueprint_task_verify", "Mark a task done with an Evidence Contract. Requires at least one pass evidence item. Accepts optional request_id for idempotent retries, optional head_at_ingest from wp_blueprint_get/wp_blueprint_context to reject stale writes, and re-ingests DB on success. Returns { status, idempotent, next_action? }.", {
258
+ registrar.registerTool("wp_blueprint_task_verify", "Mark a task done with an Evidence Contract. Requires at least one pass evidence item. Accepts optional request_id for idempotent retries, optional head_at_ingest from wp_blueprint_get/wp_blueprint_context to reject stale writes, and re-ingests DB on success. Returns { status, idempotent, next_action? }. Each evidence item's required fields depend on its `kind`: 'test' requires `command` + `exit_code` (must be 0 when result='pass'); 'integration' additionally requires a non-empty `target_files`; 'audit' requires `audit_kind` + `passed:true` when result='pass'; 'manual' requires `actor`, `description`, the literal `allow_manual:true` (an explicit anti-shortcut opt-in), and a non-empty `log_excerpt` (<=4096 bytes) — it does NOT accept a `note` field.", {
259
259
  type: "object",
260
260
  properties: {
261
261
  project_id: { type: "string" },
@@ -271,7 +271,36 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
271
271
  properties: {
272
272
  kind: { type: "string", enum: ["test", "integration", "audit", "manual"] },
273
273
  result: { type: "string", enum: ["pass", "fail"] },
274
- ts: { type: "string" },
274
+ ts: { type: "string", description: "ISO 8601 date-time string" },
275
+ agent: { type: "string" },
276
+ command: {
277
+ type: "string",
278
+ description: "Required for kind='test'/'integration'",
279
+ },
280
+ exit_code: {
281
+ type: "number",
282
+ description: "Required for kind='test'/'integration'; must be 0 when result='pass'",
283
+ },
284
+ target_files: {
285
+ type: "array",
286
+ items: { type: "string" },
287
+ description: "Required non-empty array for kind='integration'",
288
+ },
289
+ audit_kind: { type: "string", description: "Required for kind='audit'" },
290
+ passed: {
291
+ type: "boolean",
292
+ description: "Required for kind='audit'; must be true when result='pass'",
293
+ },
294
+ actor: { type: "string", description: "Required for kind='manual'" },
295
+ description: { type: "string", description: "Required for kind='manual'" },
296
+ allow_manual: {
297
+ type: "boolean",
298
+ description: "Required for kind='manual'; must be the literal boolean true (anti-shortcut opt-in)",
299
+ },
300
+ log_excerpt: {
301
+ type: "string",
302
+ description: "Required for kind='manual'; <=4096 bytes",
303
+ },
275
304
  },
276
305
  required: ["kind", "result", "ts"],
277
306
  },
@@ -78,6 +78,23 @@ export interface UnifiedConsumerConfig {
78
78
  * always project.
79
79
  */
80
80
  readonly host?: AgentHostName;
81
+ /**
82
+ * Who owns the target directory, and therefore what agent-kit may destroy in
83
+ * it. Defaults to `"exclusive"`, preserving the historical sweep behavior.
84
+ *
85
+ * - `"exclusive"` — agent-kit owns the directory. It is generated and
86
+ * gitignored, has no hand-authoring workflow, and unexpected entries
87
+ * matching the managed shape are stale projections, safe to remove.
88
+ * - `"shared"` — a host-native authoring surface co-owned with the user
89
+ * (`.claude/agents` is Claude Code's own subagent authoring location).
90
+ * agent-kit may destroy only paths it can prove it created; anything else
91
+ * is preserved, because the surface is gitignored and loss is permanent.
92
+ *
93
+ * Declared rather than inferred on purpose: the stance used to be derived
94
+ * from `acceptsKind === "agent"` at each use site, which meant one code path
95
+ * had the guard and another silently did not.
96
+ */
97
+ readonly ownership?: "exclusive" | "shared";
81
98
  /**
82
99
  * Legacy flag retained for shape compatibility. Plugin hosts no longer
83
100
  * reactivate project-local skill projections under skip envs.
@@ -74,7 +74,15 @@ export const DEFAULT_UNIFIED_CONSUMERS = [
74
74
  // Claude: canonical subagents are scaffolded to .claude/agents. Host-agnostic
75
75
  // (unconditional) because subagents were previously scaffolded unconditionally
76
76
  // by the now-retired standalone scaffoldSubagents scaffolder.
77
- { id: "claude-agents", dir: ".claude/agents", acceptsKind: "agent", strategy: "symlink" },
77
+ {
78
+ id: "claude-agents",
79
+ dir: ".claude/agents",
80
+ acceptsKind: "agent",
81
+ strategy: "symlink",
82
+ // Claude Code's own subagent authoring location: the user may hand-author
83
+ // here, and agent-kit gitignores it, so a wrong deletion is unrecoverable.
84
+ ownership: "shared",
85
+ },
78
86
  ];
79
87
  /**
80
88
  * Projection surfaces retired by a newer agent-kit release.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Who may destroy a path under a consumer directory.
3
+ *
4
+ * This is the single owner of that question. It exists because the answer was
5
+ * previously implemented twice — conservatively in `pruneStale`, not at all in
6
+ * `applyTarget` — and the two drifted, which is how a consumer-authored file
7
+ * whose name collided with a catalog slug came to be deleted silently.
8
+ *
9
+ * The predicate is deliberately narrow: agent-kit may destroy a path only when
10
+ * it can PROVE it created that path, i.e. the path is a symlink resolving into
11
+ * a directory agent-kit projects from. A real file, a real directory, or a
12
+ * symlink pointing anywhere else is somebody else's and is never destroyed on a
13
+ * `shared` surface.
14
+ */
15
+ export interface ManagedSourceDirsInput {
16
+ /** Catalog root passed to `runUnifiedSync` (may itself be a symlink alias). */
17
+ readonly catalogDir: string;
18
+ /** Consumer repo root, already realpath-resolved by the caller. */
19
+ readonly consumerRoot: string;
20
+ }
21
+ /**
22
+ * Directories agent-kit projects agent records from.
23
+ *
24
+ * Derived from CONFIGURATION, never from the planned record set, so the result
25
+ * is non-empty even when the catalog agents directory is empty or entirely
26
+ * absent. That matters: with a plans-derived set, a broken or partial install
27
+ * produces zero plans, an empty managed set, and a sweep that can no longer
28
+ * tell agent-kit's own relics from the operator's files.
29
+ *
30
+ * The catalog child is derived from `realpathSync(catalogDir)` rather than by
31
+ * realpathing the child itself. `loadContent` throws when `catalogDir` is
32
+ * missing, so the parent always resolves — whereas the child may not exist yet
33
+ * (or any more), and a non-existent child silently contributes only its
34
+ * unresolved alias form, which then fails to match a symlink recorded against
35
+ * the real path.
36
+ */
37
+ export declare function resolveManagedSourceDirs(input: ManagedSourceDirsInput): ReadonlySet<string>;
38
+ /**
39
+ * True when `targetPath` is a symlink agent-kit projected — the only shape it
40
+ * is allowed to destroy on a `shared` surface.
41
+ *
42
+ * The resolved target need not still exist: a dangling link left behind by a
43
+ * removed catalog record is agent-kit's own relic and must stay prunable.
44
+ * Containment is delegated to `isPathContained`, which canonicalizes both sides
45
+ * and rejects `..` escapes, so a sibling directory such as `<managed>-evil`
46
+ * cannot masquerade as managed.
47
+ */
48
+ export declare function isAgentKitOwned(targetPath: string, managedSourceDirs: ReadonlySet<string>): boolean;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Who may destroy a path under a consumer directory.
3
+ *
4
+ * This is the single owner of that question. It exists because the answer was
5
+ * previously implemented twice — conservatively in `pruneStale`, not at all in
6
+ * `applyTarget` — and the two drifted, which is how a consumer-authored file
7
+ * whose name collided with a catalog slug came to be deleted silently.
8
+ *
9
+ * The predicate is deliberately narrow: agent-kit may destroy a path only when
10
+ * it can PROVE it created that path, i.e. the path is a symlink resolving into
11
+ * a directory agent-kit projects from. A real file, a real directory, or a
12
+ * symlink pointing anywhere else is somebody else's and is never destroyed on a
13
+ * `shared` surface.
14
+ */
15
+ import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { isPathContained } from "#worktrees/identity.js";
18
+ /**
19
+ * Directories agent-kit projects agent records from.
20
+ *
21
+ * Derived from CONFIGURATION, never from the planned record set, so the result
22
+ * is non-empty even when the catalog agents directory is empty or entirely
23
+ * absent. That matters: with a plans-derived set, a broken or partial install
24
+ * produces zero plans, an empty managed set, and a sweep that can no longer
25
+ * tell agent-kit's own relics from the operator's files.
26
+ *
27
+ * The catalog child is derived from `realpathSync(catalogDir)` rather than by
28
+ * realpathing the child itself. `loadContent` throws when `catalogDir` is
29
+ * missing, so the parent always resolves — whereas the child may not exist yet
30
+ * (or any more), and a non-existent child silently contributes only its
31
+ * unresolved alias form, which then fails to match a symlink recorded against
32
+ * the real path.
33
+ */
34
+ export function resolveManagedSourceDirs(input) {
35
+ const dirs = new Set();
36
+ const catalogRoots = new Set([resolve(input.catalogDir)]);
37
+ if (existsSync(input.catalogDir))
38
+ catalogRoots.add(realpathSync(input.catalogDir));
39
+ for (const catalogRoot of catalogRoots)
40
+ dirs.add(join(catalogRoot, "agents"));
41
+ // Consumer-side override dir. It is frequently absent (no scaffolder creates
42
+ // it), so it gets the same parent-anchored treatment.
43
+ const consumerRoots = new Set([resolve(input.consumerRoot)]);
44
+ if (existsSync(input.consumerRoot))
45
+ consumerRoots.add(realpathSync(input.consumerRoot));
46
+ for (const consumerRootForm of consumerRoots)
47
+ dirs.add(join(consumerRootForm, "agent-agents"));
48
+ return dirs;
49
+ }
50
+ /**
51
+ * True when `targetPath` is a symlink agent-kit projected — the only shape it
52
+ * is allowed to destroy on a `shared` surface.
53
+ *
54
+ * The resolved target need not still exist: a dangling link left behind by a
55
+ * removed catalog record is agent-kit's own relic and must stay prunable.
56
+ * Containment is delegated to `isPathContained`, which canonicalizes both sides
57
+ * and rejects `..` escapes, so a sibling directory such as `<managed>-evil`
58
+ * cannot masquerade as managed.
59
+ */
60
+ export function isAgentKitOwned(targetPath, managedSourceDirs) {
61
+ let stat;
62
+ try {
63
+ stat = lstatSync(targetPath);
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ if (!stat.isSymbolicLink())
69
+ return false;
70
+ let resolved;
71
+ try {
72
+ resolved = resolve(dirname(targetPath), readlinkSync(targetPath));
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ const linkParent = dirname(resolved);
78
+ for (const managedDir of managedSourceDirs) {
79
+ if (isPathContained(managedDir, linkParent))
80
+ return true;
81
+ }
82
+ return false;
83
+ }
@@ -16,6 +16,22 @@
16
16
  * filename pattern but is not in the expected set is removed. This propagates
17
17
  * deletions in `agent-rules/` and `agent-skills/` to per-IDE cleanup.
18
18
  *
19
+ * Ownership: a consumer declares `ownership` (see `UnifiedConsumerConfig`).
20
+ * On a `shared` surface agent-kit destroys only what it can prove it created,
21
+ * via the single predicate in `ownership.ts`. Both write paths consult that one
22
+ * predicate but take DIFFERENT actions, and the asymmetry is deliberate:
23
+ *
24
+ * | path | situation | action |
25
+ * |-------------|------------------------------------|-------------------------|
26
+ * | pruneStale | nothing in the catalog claims this | leave untouched |
27
+ * | | name | |
28
+ * | applyTarget | a catalog record DOES claim it | preserve + skip + warn |
29
+ *
30
+ * Quarantining or deleting an unclaimed file would be gratuitous — it is in
31
+ * nobody's way. Silently replacing a claimed one is the data-loss bug this
32
+ * guard exists to prevent, because `.claude/agents` is gitignored and the loss
33
+ * is unrecoverable.
34
+ *
19
35
  * `--check` mode (dry-run): produce a list of (target, status) pairs and
20
36
  * return the count of mismatches; perform no writes.
21
37
  */
@@ -68,6 +84,22 @@ export interface UnifiedSyncResult {
68
84
  readonly fixCount: number;
69
85
  /** Mismatches surfaced in check mode. Empty in non-check mode. */
70
86
  readonly mismatches: readonly UnifiedSyncMismatch[];
87
+ /**
88
+ * Paths on a `shared` surface that agent-kit refused to replace because it
89
+ * could not prove it created them.
90
+ *
91
+ * Deliberately NOT mismatches: a mismatch means drift the tool should fix,
92
+ * and `wp sync --check` exits non-zero on drift. A user who legitimately
93
+ * customized a subagent would then red every CI run forever, which is the
94
+ * pressure that makes people delete their own file. These are warnings.
95
+ */
96
+ readonly preserved: readonly UnifiedSyncPreserved[];
97
+ }
98
+ export interface UnifiedSyncPreserved {
99
+ readonly consumerId: string;
100
+ readonly targetPath: string;
101
+ /** Slug the catalog wanted to project there. */
102
+ readonly slug: string;
71
103
  }
72
104
  export declare function isSymlinkPointingTo(linkPath: string, expectedAbs: string): boolean;
73
105
  /**
@@ -16,6 +16,22 @@
16
16
  * filename pattern but is not in the expected set is removed. This propagates
17
17
  * deletions in `agent-rules/` and `agent-skills/` to per-IDE cleanup.
18
18
  *
19
+ * Ownership: a consumer declares `ownership` (see `UnifiedConsumerConfig`).
20
+ * On a `shared` surface agent-kit destroys only what it can prove it created,
21
+ * via the single predicate in `ownership.ts`. Both write paths consult that one
22
+ * predicate but take DIFFERENT actions, and the asymmetry is deliberate:
23
+ *
24
+ * | path | situation | action |
25
+ * |-------------|------------------------------------|-------------------------|
26
+ * | pruneStale | nothing in the catalog claims this | leave untouched |
27
+ * | | name | |
28
+ * | applyTarget | a catalog record DOES claim it | preserve + skip + warn |
29
+ *
30
+ * Quarantining or deleting an unclaimed file would be gratuitous — it is in
31
+ * nobody's way. Silently replacing a claimed one is the data-loss bug this
32
+ * guard exists to prevent, because `.claude/agents` is gitignored and the loss
33
+ * is unrecoverable.
34
+ *
19
35
  * `--check` mode (dry-run): produce a list of (target, status) pairs and
20
36
  * return the count of mismatches; perform no writes.
21
37
  */
@@ -24,6 +40,7 @@ import { dirname, join, relative, resolve } from "node:path";
24
40
  import { loadContent } from "#content/loader";
25
41
  import { DEFAULT_PACKAGED_SKILL_SLUG_SET } from "#content/skill-policy";
26
42
  import { RETIRED_UNIFIED_CONSUMERS, selectUnifiedConsumers, unifiedRuleFilename, } from "./consumers.js";
43
+ import { isAgentKitOwned, resolveManagedSourceDirs } from "./ownership.js";
27
44
  import { pathHasSymlinkComponent } from "./retired-surfaces.js";
28
45
  /**
29
46
  * Create a symlink with explicit Windows type hint. POSIX ignores the type;
@@ -170,12 +187,49 @@ function dirsEqual(srcDir, destDir) {
170
187
  }
171
188
  return true;
172
189
  }
190
+ /**
191
+ * True when applying `plan` would destroy something agent-kit cannot prove it
192
+ * created, on a surface the user co-owns.
193
+ *
194
+ * This is the apply-side half of the ownership question. The prune side asks
195
+ * the same question via the same predicate but takes a different action, and
196
+ * the asymmetry is deliberate — see the module docstring.
197
+ *
198
+ * An `exclusive` consumer is unaffected: its directory is generated and
199
+ * gitignored, so replacing an unexpected entry is the intended behavior.
200
+ */
201
+ function wouldDestroyForeignPath(plan, managedSourceDirs) {
202
+ if ((plan.consumer.ownership ?? "exclusive") !== "shared")
203
+ return false;
204
+ if (!existsSync(plan.targetPath) && !isSymlinkLike(plan.targetPath))
205
+ return false;
206
+ return !isAgentKitOwned(plan.targetPath, managedSourceDirs);
207
+ }
208
+ /** `existsSync` follows symlinks, so a dangling link needs its own probe. */
209
+ function isSymlinkLike(path) {
210
+ try {
211
+ return lstatSync(path).isSymbolicLink();
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
173
217
  /**
174
218
  * Apply a single planned target. Returns 1 on write, 0 if already correct.
219
+ *
220
+ * `preserved` is a third outcome, distinct from both: the projection was
221
+ * skipped because the target belongs to the operator. It is reported as a
222
+ * warning, never as drift — see `UnifiedSyncResult`.
175
223
  */
176
- function applyTarget(plan, check) {
224
+ function applyTarget(plan, check, managedSourceDirs) {
177
225
  const { consumer, record, targetPath } = plan;
178
226
  const sourcePath = record.kind === "skill" ? dirname(record.filePath) : record.filePath;
227
+ // Gate EVERY strategy, not just the symlink branch: each one below uses its
228
+ // "is it already what we'd write?" check as the destruction gate, and none of
229
+ // those checks can distinguish "stale projection" from "operator's file".
230
+ if (wouldDestroyForeignPath(plan, managedSourceDirs)) {
231
+ return { wrote: 0, preserved: targetPath };
232
+ }
179
233
  switch (consumer.strategy) {
180
234
  case "symlink": {
181
235
  // Use the resolved (real) source path so links survive pnpm version churn.
@@ -250,33 +304,6 @@ function applyTarget(plan, check) {
250
304
  }
251
305
  }
252
306
  }
253
- /**
254
- * True when `fullPath` is a symlink whose resolved target's directory is one
255
- * of `managedSourceDirs`. Used to distinguish an agent-kit-managed symlink
256
- * (safe to prune when stale/renamed) from a real file or a foreign symlink
257
- * (never agent-kit-managed, must survive `wp sync`). The resolved target
258
- * itself need not currently exist — a dangling symlink to a since-removed
259
- * catalog agent still resolves into the managed source dir and is prunable.
260
- */
261
- function isManagedAgentSymlink(fullPath, managedSourceDirs) {
262
- let stat;
263
- try {
264
- stat = lstatSync(fullPath);
265
- }
266
- catch {
267
- return false;
268
- }
269
- if (!stat.isSymbolicLink())
270
- return false;
271
- try {
272
- const linkTarget = readlinkSync(fullPath);
273
- const resolved = resolve(dirname(fullPath), linkTarget);
274
- return managedSourceDirs.has(dirname(resolved));
275
- }
276
- catch {
277
- return false;
278
- }
279
- }
280
307
  /**
281
308
  * Remove entries under each consumer dir that no longer correspond to any
282
309
  * planned record. Only removes entries whose shape matches the consumer's
@@ -294,19 +321,10 @@ function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs, c
294
321
  }
295
322
  set.add(plan.entryName);
296
323
  }
297
- // Directories that agent-kind sources resolve from (the catalog `agents/`
298
- // dir, and a consumer `agent-agents/` override dir if present). Unlike
299
- // `.claude/rules` and `.claude/skills` surfaces with no legitimate
300
- // hand-authoring workflow `.claude/agents` is Claude Code's own native
301
- // subagent authoring location. A consumer-authored `.claude/agents/foo.md`
302
- // that was never agent-kit-managed must survive `wp sync`; only symlinks
303
- // that resolve into a known managed source dir are eligible for pruning.
304
- //
305
- // The set unions plan-derived dirs with the CONFIGURED source dirs so it is
306
- // never empty: with an empty or missing catalog agents dir there are no
307
- // plans, and a plans-only set would leave nothing provably managed — the
308
- // sweep must then delete nothing, not everything. Dangling managed symlinks
309
- // (pointing into a configured dir whose file is gone) stay prunable.
324
+ // Plan-derived source dirs are folded in alongside the configuration-derived
325
+ // set. The configured set already guarantees non-emptiness (see
326
+ // `resolveManagedSourceDirs`); the plan-derived entries additionally cover a
327
+ // consumer override dir the configuration cannot know about.
310
328
  const agentSourceDirs = new Set([
311
329
  ...configuredAgentSourceDirs,
312
330
  ...plans
@@ -350,16 +368,18 @@ function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs, c
350
368
  if (!matchesRulePattern && !matchesSkillPattern && !isLink)
351
369
  continue;
352
370
  const fullPath = join(dirAbs, name);
353
- // Conservative prune for agent-only dirs: skip real files and foreign
354
- // symlinks (anything not resolving into a known agent-kit managed
355
- // source dir). This guard is unconditional the source-dir set is
356
- // configuration-derived and never empty, so a broken install (empty or
357
- // missing catalog agents dir) cannot degrade the sweep into deleting
358
- // hand-authored files. Rules/skills dirs have no legitimate
359
- // hand-authoring workflow, so their existing
360
- // sweep-everything-unexpected behavior is unchanged.
361
- const isAgentOnlyDir = consumersForDir.every((c) => c.acceptsKind === "agent");
362
- if (isAgentOnlyDir && !isManagedAgentSymlink(fullPath, agentSourceDirs)) {
371
+ // Conservative prune on a shared surface: skip real files, real dirs, and
372
+ // foreign symlinks (anything agent-kit cannot prove it created). The
373
+ // stance is read from the consumer's DECLARED ownership rather than
374
+ // inferred from its kind inference by `.every(c => acceptsKind ===
375
+ // "agent")` would silently switch the guard off for the whole directory
376
+ // the moment any non-agent consumer was registered on it.
377
+ //
378
+ // Exclusive dirs (rules, skills) are generated and gitignored with no
379
+ // hand-authoring workflow, so their sweep-everything behavior is
380
+ // deliberately unchanged.
381
+ const isSharedDir = consumersForDir.some((c) => (c.ownership ?? "exclusive") === "shared");
382
+ if (isSharedDir && !isAgentKitOwned(fullPath, agentSourceDirs)) {
363
383
  continue;
364
384
  }
365
385
  if (check) {
@@ -440,10 +460,27 @@ export function runUnifiedSync(options) {
440
460
  throw new Error(`wp sync: slug collisions between catalog and consumer (rename consumer copy):\n${lines.join("\n")}`);
441
461
  }
442
462
  const plans = planTargets(filteredRecords, consumers, consumerRoot);
463
+ // The directories agent-kit projects agent records from. Configuration-
464
+ // derived and therefore never empty, so a broken install cannot collapse
465
+ // ownership into "everything is ours". Owned by ownership.ts, shared by the
466
+ // apply and prune paths so they cannot answer the question differently.
467
+ const configuredAgentSourceDirs = resolveManagedSourceDirs({
468
+ catalogDir: options.catalogDir,
469
+ consumerRoot,
470
+ });
443
471
  let fixCount = 0;
444
472
  const mismatches = [];
473
+ const preserved = [];
445
474
  for (const plan of plans) {
446
- const result = applyTarget(plan, options.check === true);
475
+ const result = applyTarget(plan, options.check === true, configuredAgentSourceDirs);
476
+ if (result.preserved !== undefined) {
477
+ preserved.push({
478
+ consumerId: plan.consumer.id,
479
+ targetPath: result.preserved,
480
+ slug: plan.record.slug,
481
+ });
482
+ continue;
483
+ }
447
484
  if (result.wrote > 0) {
448
485
  fixCount += result.wrote;
449
486
  if (options.check === true && result.mismatch !== undefined) {
@@ -455,22 +492,6 @@ export function runUnifiedSync(options) {
455
492
  }
456
493
  }
457
494
  }
458
- // Configuration-derived agent source dirs (catalog `agents/` plus the
459
- // consumer `agent-agents/` override). Added in both raw-resolved and
460
- // realpath forms so set membership survives the /var → /private/var realm
461
- // split on macOS. Present even when the dirs are empty or absent — that is
462
- // the point: prune eligibility must not collapse to "everything" when a
463
- // broken install yields zero planned agent records.
464
- const configuredAgentSourceDirs = new Set();
465
- for (const candidate of [
466
- join(options.catalogDir, "agents"),
467
- join(consumerRoot, "agent-agents"),
468
- ]) {
469
- configuredAgentSourceDirs.add(resolve(candidate));
470
- if (existsSync(candidate)) {
471
- configuredAgentSourceDirs.add(realpathSync(candidate));
472
- }
473
- }
474
495
  const prune = pruneStale(plans, consumers, consumerRoot, options.check === true, options.preserveSkillSlugs, configuredAgentSourceDirs);
475
496
  fixCount += prune.removed;
476
497
  mismatches.push(...prune.mismatches);
@@ -480,5 +501,5 @@ export function runUnifiedSync(options) {
480
501
  const retiredDirectories = pruneEmptyRetiredConsumerDirs(retiredConsumers, consumerRoot, options.check === true);
481
502
  fixCount += retiredDirectories.removed;
482
503
  mismatches.push(...retiredDirectories.mismatches);
483
- return { fixCount, mismatches };
504
+ return { fixCount, mismatches, preserved };
484
505
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpresso/agent-kit",
3
- "version": "3.3.5",
3
+ "version": "3.3.6",
4
4
  "private": false,
5
5
  "description": "TypeScript-first agent harness for guarded develop/deploy workflows: wp CLI gates, MCP tools, hooks, memory, worktrees, secrets, audits, and evidence checks.",
6
6
  "keywords": [
@@ -514,16 +514,16 @@
514
514
  "setupWpActionRef": "c2c71a7a4be446fc6858e6b57bf55a11ccfa2d88"
515
515
  },
516
516
  "optionalDependencies": {
517
- "@webpresso/agent-kit-runtime-darwin-arm64": "3.3.5",
518
- "@webpresso/agent-kit-runtime-darwin-x64": "3.3.5",
519
- "@webpresso/agent-kit-runtime-linux-x64": "3.3.5",
520
- "@webpresso/agent-kit-runtime-linux-arm64": "3.3.5",
521
- "@webpresso/agent-kit-runtime-windows-x64": "3.3.5",
522
- "@webpresso/agent-kit-session-memory-darwin-x64": "3.3.5",
523
- "@webpresso/agent-kit-session-memory-darwin-arm64": "3.3.5",
524
- "@webpresso/agent-kit-session-memory-linux-x64": "3.3.5",
525
- "@webpresso/agent-kit-session-memory-linux-arm64": "3.3.5",
526
- "@webpresso/agent-kit-session-memory-win32-x64": "3.3.5",
527
- "@webpresso/agent-kit-session-memory-win32-arm64": "3.3.5"
517
+ "@webpresso/agent-kit-runtime-darwin-arm64": "3.3.6",
518
+ "@webpresso/agent-kit-runtime-darwin-x64": "3.3.6",
519
+ "@webpresso/agent-kit-runtime-linux-x64": "3.3.6",
520
+ "@webpresso/agent-kit-runtime-linux-arm64": "3.3.6",
521
+ "@webpresso/agent-kit-runtime-windows-x64": "3.3.6",
522
+ "@webpresso/agent-kit-session-memory-darwin-x64": "3.3.6",
523
+ "@webpresso/agent-kit-session-memory-darwin-arm64": "3.3.6",
524
+ "@webpresso/agent-kit-session-memory-linux-x64": "3.3.6",
525
+ "@webpresso/agent-kit-session-memory-linux-arm64": "3.3.6",
526
+ "@webpresso/agent-kit-session-memory-win32-x64": "3.3.6",
527
+ "@webpresso/agent-kit-session-memory-win32-arm64": "3.3.6"
528
528
  }
529
529
  }