@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.

Potentially problematic release.


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

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,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
  },
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { existsSync, lstatSync, readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { evaluateReviewEvents, parseReviewEventLog } from "./events.js";
12
+ import { isPolicyApprovalVerdict } from "./verdict.js";
12
13
  import { createDeliverySubjectAtRef, createLegacyPlanSubjectAtRef, createPlanSubjectAtRef, createPreTagsStripPlanSubjectAtRef, resolveReviewCommit, } from "./subject.js";
13
14
  const GIT_TIMEOUT_MS = 5_000;
14
15
  const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
@@ -114,6 +115,54 @@ function markCompromisedArtifactEvents(cwd, entries, blueprintDirectory, events)
114
115
  }
115
116
  return { compromisedIds, issues };
116
117
  }
118
+ /** Bounded `git branch -a --contains <commit>` probe, tolerant of a pruned/unknown object. */
119
+ function branchesContainingCommit(cwd, commit) {
120
+ try {
121
+ const output = gitBuffer(cwd, ["branch", "-a", "--contains", commit]).toString("utf8");
122
+ return output
123
+ .split("\n")
124
+ .map((line) => line.trim())
125
+ .filter((line) => line.length > 0);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ // Diagnostic-only: names historical ledger approvals whose `reviewedCommit` is
132
+ // no longer reachable from any branch (typically because a rebase rewrote the
133
+ // history that commit lived on). Such an approval no longer matches the
134
+ // current subjectDigest, so it already contributes zero approvals under the
135
+ // existing policy in ./events.ts -- this only makes the reason visible instead
136
+ // of the operator seeing a bare "approvals does not satisfy the promotion
137
+ // reviewer mix" and mistaking a dead commit ref for a genuinely missing
138
+ // review. Never mutates `approvals`/`matching`; policy is unchanged.
139
+ function markOrphanedApprovalEvents(cwd, events, current) {
140
+ const issues = [];
141
+ const seen = new Set();
142
+ for (const event of events) {
143
+ if (event.purpose !== current.purpose ||
144
+ event.blueprintSlug !== current.blueprintSlug ||
145
+ event.subjectScheme !== current.subjectScheme ||
146
+ event.subjectDigest === current.subjectDigest ||
147
+ event.status !== "complete" ||
148
+ !isPolicyApprovalVerdict(event.verdict)) {
149
+ continue;
150
+ }
151
+ const key = `${event.reviewer}:${event.reviewedCommit}`;
152
+ if (seen.has(key))
153
+ continue;
154
+ seen.add(key);
155
+ const branches = branchesContainingCommit(cwd, event.reviewedCommit);
156
+ if (branches === null || branches.length === 0) {
157
+ issues.push(`Historical approval from ${event.reviewer} (event ${event.id}) reviewed commit ` +
158
+ `${event.reviewedCommit}, which is unreachable from any branch (git branch -a ` +
159
+ `--contains ${event.reviewedCommit} found nothing) -- likely orphaned by a rebase. ` +
160
+ "This approval does not count toward the current subject; rebase this worktree onto " +
161
+ "the latest base and re-run the gate.");
162
+ }
163
+ }
164
+ return issues;
165
+ }
117
166
  export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
118
167
  const commit = resolveReviewCommit(cwd, ref);
119
168
  const entries = parseRefTree(cwd, commit);
@@ -131,6 +180,12 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
131
180
  const subject = purpose === "plan"
132
181
  ? createPlanSubjectAtRef(cwd, commit, stableSlug, overview.path)
133
182
  : createDeliverySubjectAtRef(cwd, commit, stableSlug, overview.path);
183
+ const orphanIssues = markOrphanedApprovalEvents(cwd, events, {
184
+ purpose,
185
+ blueprintSlug: stableSlug,
186
+ subjectScheme: subject.scheme,
187
+ subjectDigest: subject.digest,
188
+ });
134
189
  let evaluation = evaluateReviewEvents(events, {
135
190
  purpose,
136
191
  blueprintSlug: stableSlug,
@@ -166,7 +221,7 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
166
221
  subject,
167
222
  events,
168
223
  ...evaluation,
169
- issues,
224
+ issues: [...issues, ...orphanIssues],
170
225
  };
171
226
  }
172
227
  export function readWorkingReviewEvents(blueprintPath, stableSlug) {
@@ -21,6 +21,10 @@ export interface ReviewGateVerification {
21
21
  readonly logPath?: string;
22
22
  readonly sandbox?: ReviewSandboxMode;
23
23
  readonly sandboxReason?: string;
24
+ readonly violations?: readonly {
25
+ readonly section: string;
26
+ readonly message: string;
27
+ }[];
24
28
  }
25
29
  /** Runs one gate command in the review checkout and returns its raw result. */
26
30
  export type RunSandboxedGate = (args: {
@@ -18,7 +18,15 @@ export async function verifyDeliveryPromotionGates(input) {
18
18
  parsed.violations[0]?.message === "missing Trust Dossier section") {
19
19
  return undefined;
20
20
  }
21
- return { status: "failed", commands: [], failureCode: "malformed-trust-dossier" };
21
+ return {
22
+ status: "failed",
23
+ commands: [],
24
+ failureCode: "malformed-trust-dossier",
25
+ violations: parsed.violations.map((violation) => ({
26
+ section: violation.section,
27
+ message: violation.message,
28
+ })),
29
+ };
22
30
  }
23
31
  const gates = parsed.dossier?.gates ?? [];
24
32
  if (gates.length === 0)
@@ -17,7 +17,7 @@
17
17
  * the canonical `.agent/{rules,skills}` SSOT always project.
18
18
  *
19
19
  * The `UNIFIED_CONSUMERS` registry below describes per-IDE projection of the
20
- * unified rule/skill content kinds (catalog ∪ consumer). Strategies:
20
+ * unified rule/skill/agent content kinds (catalog ∪ consumer). Strategies:
21
21
  * - 'symlink': create a relative symlink to the source (file or dir)
22
22
  * - 'copy': copy file or recursively copy dir tree
23
23
  * - 'transform': run a transform function over the body and write the
@@ -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.
@@ -116,6 +133,8 @@ export declare function unifiedRuleFilename(consumer: UnifiedConsumerConfig, slu
116
133
  * `selectUnifiedConsumers(hosts)`:
117
134
  * - `.agent/{rules,skills}/` (working dir, SSOT): symlink, always
118
135
  * - `.claude/rules/`: symlink, always (rules are not plugin-delivered)
136
+ * - `.claude/agents/`: symlink, always (canonical subagents; Claude has no
137
+ * native subagent delivery channel, so this projection is not plugin-owned)
119
138
  *
120
139
  * Project-local legacy skill roots are intentionally omitted. Codex has no
121
140
  * `.codex/agents/` consumer.
@@ -17,7 +17,7 @@
17
17
  * the canonical `.agent/{rules,skills}` SSOT always project.
18
18
  *
19
19
  * The `UNIFIED_CONSUMERS` registry below describes per-IDE projection of the
20
- * unified rule/skill content kinds (catalog ∪ consumer). Strategies:
20
+ * unified rule/skill/agent content kinds (catalog ∪ consumer). Strategies:
21
21
  * - 'symlink': create a relative symlink to the source (file or dir)
22
22
  * - 'copy': copy file or recursively copy dir tree
23
23
  * - 'transform': run a transform function over the body and write the
@@ -59,6 +59,8 @@ export function unifiedRuleFilename(consumer, slug) {
59
59
  * `selectUnifiedConsumers(hosts)`:
60
60
  * - `.agent/{rules,skills}/` (working dir, SSOT): symlink, always
61
61
  * - `.claude/rules/`: symlink, always (rules are not plugin-delivered)
62
+ * - `.claude/agents/`: symlink, always (canonical subagents; Claude has no
63
+ * native subagent delivery channel, so this projection is not plugin-owned)
62
64
  *
63
65
  * Project-local legacy skill roots are intentionally omitted. Codex has no
64
66
  * `.codex/agents/` consumer.
@@ -69,6 +71,18 @@ export const DEFAULT_UNIFIED_CONSUMERS = [
69
71
  { id: "agent-skills", dir: ".agent/skills", acceptsKind: "skill", strategy: "symlink" },
70
72
  // Claude: rules are scaffolded to .claude/rules; skills come from native channels.
71
73
  { id: "claude-rules", dir: ".claude/rules", acceptsKind: "rule", strategy: "symlink" },
74
+ // Claude: canonical subagents are scaffolded to .claude/agents. Host-agnostic
75
+ // (unconditional) because subagents were previously scaffolded unconditionally
76
+ // by the now-retired standalone scaffoldSubagents scaffolder.
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
+ },
72
86
  ];
73
87
  /**
74
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
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Unified rule + skill sync.
2
+ * Unified rule + skill + agent sync.
3
3
  *
4
- * Source of truth: catalog (`<pkg>/dist/catalog/agent/{rules,skills}/`) UNION
5
- * consumer (`<repo>/agent-{rules,skills}/`). The loader returns a
4
+ * Source of truth: catalog (`<pkg>/dist/catalog/agent/{rules,skills,agents}/`)
5
+ * UNION consumer (`<repo>/agent-{rules,skills,agents}/`). The loader returns a
6
6
  * source-tagged record list; this module projects that list into per-IDE
7
7
  * surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
8
8
  *
@@ -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
  /**