@webpresso/agent-kit 3.3.4 → 3.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/esm/audit/agents.js +126 -26
  2. package/dist/esm/blueprint/trust/dossier.js +41 -1
  3. package/dist/esm/build/vendor-oxlint-plugins.d.ts +4 -1
  4. package/dist/esm/build/vendor-oxlint-plugins.js +9 -14
  5. package/dist/esm/cli/commands/init/index.js +3 -4
  6. package/dist/esm/cli/commands/review.d.ts +1 -0
  7. package/dist/esm/cli/commands/review.js +77 -1
  8. package/dist/esm/cli/commands/sync.d.ts +8 -6
  9. package/dist/esm/cli/commands/sync.js +11 -9
  10. package/dist/esm/config/oxlint/oxlintrc.d.ts +1 -1
  11. package/dist/esm/config/oxlint/oxlintrc.js +3 -12
  12. package/dist/esm/content/dispatch.js +4 -0
  13. package/dist/esm/content/loader.d.ts +1 -1
  14. package/dist/esm/content/loader.js +10 -6
  15. package/dist/esm/content/skill-policy.d.ts +11 -0
  16. package/dist/esm/content/skill-policy.js +33 -0
  17. package/dist/esm/hooks/pretool-guard/dev-routing.js +67 -4
  18. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  19. package/dist/esm/review/authority.js +56 -1
  20. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  21. package/dist/esm/review/delivery-verifier.js +9 -1
  22. package/dist/esm/symlinker/consumers.d.ts +3 -1
  23. package/dist/esm/symlinker/consumers.js +7 -1
  24. package/dist/esm/symlinker/unified-sync.d.ts +3 -3
  25. package/dist/esm/symlinker/unified-sync.js +88 -11
  26. package/dist/esm/test/shard-durations.json +0 -1
  27. package/package.json +12 -12
  28. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  29. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
@@ -21,10 +21,12 @@ const DEFAULT_KINDS = ["rule", "skill"];
21
21
  const CONSUMER_DIR_BY_KIND = {
22
22
  rule: "agent-rules",
23
23
  skill: "agent-skills",
24
+ agent: "agent-agents",
24
25
  };
25
26
  const CATALOG_DIR_BY_KIND = {
26
27
  rule: "rules",
27
28
  skill: "skills",
29
+ agent: "agents",
28
30
  };
29
31
  export function loadContent(options) {
30
32
  const { catalogDir, consumerRoot, kinds = DEFAULT_KINDS } = options;
@@ -83,9 +85,11 @@ function readKind(args) {
83
85
  const stat = statSync(root);
84
86
  if (!stat.isDirectory())
85
87
  return [];
86
- return kind === "rule" ? readRules(root, source) : readSkills(root, source);
88
+ // agent kind shares the rule shape: flat `<slug>.md` files, README.md
89
+ // excluded. Only `skill` is directory-shaped (`<slug>/SKILL.md`).
90
+ return kind === "skill" ? readSkills(root, source) : readFlatMarkdown(kind, root, source);
87
91
  }
88
- function readRules(root, source) {
92
+ function readFlatMarkdown(kind, root, source) {
89
93
  const out = [];
90
94
  for (const entry of readdirSync(root, { withFileTypes: true })) {
91
95
  if (!entry.isFile())
@@ -93,9 +97,9 @@ function readRules(root, source) {
93
97
  if (!entry.name.endsWith(".md"))
94
98
  continue;
95
99
  // Skip directory-documentation files. README.md exists in both the
96
- // canonical catalog and the consumer-owned agent-rules/ scaffolder
97
- // surface; treating it as a rule would produce a spurious slug
98
- // collision and would also try to project it into per-IDE surfaces.
100
+ // canonical catalog and the consumer-owned agent-rules/ (or agent-agents/)
101
+ // scaffolder surface; treating it as content would produce a spurious
102
+ // slug collision and would also try to project it into per-IDE surfaces.
99
103
  if (entry.name === "README.md")
100
104
  continue;
101
105
  const filePath = realpathSync(join(root, entry.name));
@@ -103,7 +107,7 @@ function readRules(root, source) {
103
107
  const parsed = matter(readFileSync(filePath, "utf8"));
104
108
  const raw = { ...parsed.data };
105
109
  out.push({
106
- kind: "rule",
110
+ kind,
107
111
  slug,
108
112
  source,
109
113
  filePath,
@@ -1,6 +1,17 @@
1
1
  export declare const DEFAULT_PACKAGED_SKILL_SLUGS: readonly ["verify", "fix", "investigate", "ai-deslop", "plan-refine", "ralplan", "best-practice-research", "testing-philosophy", "tph", "claude", "codex", "grok", "opencode-go", "plan-eng-review", "plan-ceo-review", "plan-design-review", "plan-devex-review", "browse", "devex-review", "design-review", "hooks-doctor", "tech-debt", "lore-protocol", "deep-interview", "deep-research", "autoresearch", "ultragoal", "autopilot", "team"];
2
2
  export type DefaultPackagedSkillSlug = (typeof DEFAULT_PACKAGED_SKILL_SLUGS)[number];
3
3
  export declare const DEFAULT_PACKAGED_SKILL_SLUG_SET: ReadonlySet<string>;
4
+ /**
5
+ * Catalog skills that are deliberately NOT in DEFAULT_PACKAGED_SKILL_SLUGS,
6
+ * with a one-line reason each. Every slug under `catalog/agent/skills/` must
7
+ * appear in either that list or this one (enforced by
8
+ * `checkCatalogSkillClassification` in `src/audit/agents.ts`) — an
9
+ * unclassified slug means a new catalog skill shipped without a deliberate
10
+ * packaging decision.
11
+ */
12
+ export declare const UNPACKAGED_SKILL_SLUGS: Readonly<Record<string, string>>;
13
+ export declare const UNPACKAGED_SKILL_SLUG_SET: ReadonlySet<string>;
14
+ export declare function isUnpackagedSkillSlug(slug: string): boolean;
4
15
  export declare const MERGED_DELETED_SKILL_SLUGS: readonly ["autoplan", "health", "systematic-debugging", "test-driven-development"];
5
16
  export declare const MERGED_DELETED_SKILL_SLUG_SET: ReadonlySet<string>;
6
17
  /**
@@ -30,6 +30,39 @@ export const DEFAULT_PACKAGED_SKILL_SLUGS = [
30
30
  "team",
31
31
  ];
32
32
  export const DEFAULT_PACKAGED_SKILL_SLUG_SET = new Set(DEFAULT_PACKAGED_SKILL_SLUGS);
33
+ /**
34
+ * Catalog skills that are deliberately NOT in DEFAULT_PACKAGED_SKILL_SLUGS,
35
+ * with a one-line reason each. Every slug under `catalog/agent/skills/` must
36
+ * appear in either that list or this one (enforced by
37
+ * `checkCatalogSkillClassification` in `src/audit/agents.ts`) — an
38
+ * unclassified slug means a new catalog skill shipped without a deliberate
39
+ * packaging decision.
40
+ */
41
+ export const UNPACKAGED_SKILL_SLUGS = {
42
+ qa: "source-catalog QA skill, not package-visible in the default plugin skill set today (see workflow-skills-routing.md)",
43
+ "qa-only": "source-catalog QA skill, not package-visible in the default plugin skill set today (see workflow-skills-routing.md)",
44
+ review: "listed as a default workflow skill in workflow-skills-routing.md but not yet added to DEFAULT_PACKAGED_SKILL_SLUGS",
45
+ pll: "ultragoal's parallel-execute companion (ready-queue/worktrees driver); not independently default-packaged",
46
+ "frontend-design": "opt-in design-guidance skill, not part of the default packaged workflow set",
47
+ "monorepo-navigation": "tier-3 opt-in preset (wp setup --with monorepo-navigation), not default-packaged",
48
+ "tanstack-query": "tier-3 opt-in preset (wp setup --with tanstack-query), not default-packaged",
49
+ "react-doctor": "opt-in framework best-practice skill, not default-packaged",
50
+ "better-auth-best-practices": "opt-in best-practice reference skill, not default-packaged",
51
+ "logging-best-practices": "opt-in best-practice reference skill, not default-packaged",
52
+ "vercel-react-best-practices": "opt-in best-practice reference skill, not default-packaged",
53
+ "web-design-guidelines": "opt-in best-practice reference skill, not default-packaged",
54
+ deepseek: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
55
+ glm: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
56
+ hy3: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
57
+ kimi: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
58
+ mimo: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
59
+ minimax: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
60
+ qwen: "individual OpenCode-Go model reviewer skill; only the aggregate opencode-go skill is default-packaged",
61
+ };
62
+ export const UNPACKAGED_SKILL_SLUG_SET = new Set(Object.keys(UNPACKAGED_SKILL_SLUGS));
63
+ export function isUnpackagedSkillSlug(slug) {
64
+ return UNPACKAGED_SKILL_SLUG_SET.has(slug);
65
+ }
33
66
  export const MERGED_DELETED_SKILL_SLUGS = [
34
67
  "autoplan",
35
68
  "health",
@@ -296,11 +296,11 @@ const SAFE_PASSTHROUGH_PREFIXES = [
296
296
  const SANDBOX_PREFIXES = [
297
297
  {
298
298
  prefix: "grep",
299
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
299
+ guidance: "Recursive/directory-wide grep is unbounded; narrow to explicit file target(s) (`grep -n pattern file`) to pass through directly. Otherwise use wp_session_batch_execute for bounded search so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use a bounded `rg -n ... | sed -n '1,120p'` excerpt instead of recursive raw grep output",
300
300
  },
301
301
  {
302
302
  prefix: "find",
303
- guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
303
+ guidance: "Use wp_session_batch_execute for bounded shell gathering/search commands so output is capped, indexed, and recallable instead of flooding the transcript — note wp_session_batch_execute runs argv directly (no shell) and cannot express `|`/`>`. If MCP is unavailable, use `rg --files ... | sed -n '1,120p'` or another explicit bounded listing",
304
304
  },
305
305
  {
306
306
  prefix: "cat",
@@ -324,7 +324,7 @@ const SANDBOX_PREFIXES = [
324
324
  },
325
325
  {
326
326
  prefix: "git log",
327
- guidance: "Use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use explicit bounds such as `git log --oneline -n 50`",
327
+ guidance: "Add an explicit count bound (`-N`, `-n N`, or `--max-count=N`) to pass through directly — e.g. `git log --oneline -50`. Otherwise use wp_session_batch_execute for bounded git log shell gathering so output is capped, indexed, and recallable. If MCP is unavailable, use an explicit bound directly, e.g. `git log --oneline -n 50`",
328
328
  },
329
329
  {
330
330
  prefix: "git diff",
@@ -332,7 +332,7 @@ const SANDBOX_PREFIXES = [
332
332
  },
333
333
  {
334
334
  prefix: "git show",
335
- guidance: "Use wp_session_batch_execute or wp_session_execute for bounded git show gathering instead of raw large output. If MCP is unavailable, use `git show --stat` or a targeted excerpt",
335
+ guidance: "Add `--stat`/`--name-only`/`--name-status`/`--shortstat` to pass through directly — e.g. `git show --stat HEAD` or use wp_session_batch_execute or wp_session_execute for bounded gathering of the full patch",
336
336
  },
337
337
  {
338
338
  prefix: "vp run build",
@@ -782,6 +782,63 @@ const BOUNDED_GIT_DIFF = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTIO
782
782
  function isBoundedGitDiffRead(command) {
783
783
  return BOUNDED_GIT_DIFF.test(command);
784
784
  }
785
+ // `git log`/`git show` reads are bounded (small, predictable output) exactly when
786
+ // they carry an explicit count/summary bound the same way BOUNDED_GIT_DIFF already
787
+ // recognizes `--stat`/`--name-only`/etc. as bounded. Unbounded forms (bare `git log`,
788
+ // full-patch `git show <ref>`) still fall through to the SANDBOX_PREFIXES rules below.
789
+ const SIMPLE_GIT_LOG_PREFIX = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}log\b`, "u");
790
+ const GIT_LOG_COUNT_BOUND = /(?:^|\s)(?:-\d+|-n[=\s]?\d+|--max-count(?:=|\s+)\d+)(?:\s|$)/u;
791
+ function isBoundedGitLogRead(command) {
792
+ return SIMPLE_GIT_LOG_PREFIX.test(command) && GIT_LOG_COUNT_BOUND.test(command);
793
+ }
794
+ const BOUNDED_GIT_SHOW = new RegExp(String.raw `^git\s+${SIMPLE_GIT_GLOBAL_OPTION}show\b(?=[\s\S]*(?:--stat\b|--shortstat\b|--name-only\b|--name-status\b))(?![\s\S]*(?:--patch\b|-p\b))`, "u");
795
+ function isBoundedGitShowRead(command) {
796
+ return BOUNDED_GIT_SHOW.test(command);
797
+ }
798
+ // A grep read is bounded when it targets explicit file argument(s) (not a whole
799
+ // directory tree via -r/-R/--recursive/--include/--exclude*) and supplies at least
800
+ // one non-flag file target beyond the pattern itself. Recursive/directory-wide grep
801
+ // stays sandboxed since its output size is unpredictable.
802
+ const GREP_RECURSIVE_LONG_FLAGS = new Set([
803
+ "--recursive",
804
+ "--include",
805
+ "--exclude",
806
+ "--exclude-dir",
807
+ "--exclude-from",
808
+ ]);
809
+ function isBoundedGrepRead(command) {
810
+ const tokens = tokenizeCommand(command);
811
+ if (tokens[0] !== "grep")
812
+ return false;
813
+ const positionals = [];
814
+ let optionsEnded = false;
815
+ for (let index = 1; index < tokens.length; index += 1) {
816
+ const token = tokens[index];
817
+ if (!token)
818
+ continue;
819
+ if (!optionsEnded && token === "--") {
820
+ optionsEnded = true;
821
+ continue;
822
+ }
823
+ if (!optionsEnded && token !== "-" && token.startsWith("-")) {
824
+ const longFlag = token.split("=")[0] ?? token;
825
+ if (GREP_RECURSIVE_LONG_FLAGS.has(longFlag))
826
+ return false;
827
+ // Strip a trailing bundled numeric argument (e.g. the `2` in `-rA2` for
828
+ // `-r -A 2`) before checking for a bundled `r`/`R` — otherwise a
829
+ // recursive short flag bundled with a value-taking flag like `-A`/`-B`/
830
+ // `-C`/`-m` slips past undetected.
831
+ const shortCluster = token.slice(1).replace(/\d+$/u, "");
832
+ if (/^[A-Za-z]+$/u.test(shortCluster) && /[rR]/u.test(shortCluster))
833
+ return false;
834
+ continue;
835
+ }
836
+ positionals.push(token);
837
+ }
838
+ // First positional is the search pattern; at least one more explicit file
839
+ // target is required to count as a bounded (non-stdin, non-directory) read.
840
+ return positionals.length >= 2;
841
+ }
785
842
  function escapedRegexToken(value) {
786
843
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
787
844
  }
@@ -982,6 +1039,12 @@ export function routeCommand(command, _sessionId) {
982
1039
  }
983
1040
  if (isBoundedGitDiffRead(trimmed))
984
1041
  return { action: { action: "passthrough" } };
1042
+ if (isBoundedGitLogRead(trimmed))
1043
+ return { action: { action: "passthrough" } };
1044
+ if (isBoundedGitShowRead(trimmed))
1045
+ return { action: { action: "passthrough" } };
1046
+ if (isBoundedGrepRead(trimmed))
1047
+ return { action: { action: "passthrough" } };
985
1048
  // Explicit passthroughs (audits, safe git/nav commands)
986
1049
  for (const prefix of PASSTHROUGH_PREFIXES) {
987
1050
  if (matchesPrefix(trimmed, prefix))
@@ -850,7 +850,9 @@ const GIT_GLOBAL_RUN = `(?:-C\\s+${QUOTED_ARG}\\s+|-c\\s+${QUOTED_ARG}\\s+|--git
850
850
  // branch flags are allowed.
851
851
  const FORBIDDEN_SUBCOMMAND = "(commit\\b|switch\\b(?!\\s+(?:-h|--help)\\b)|checkout\\s+(?!--(?:\\s|$))(?:(?:\\S+\\s+)*)\\S|branch\\s+(?:(?!-|--)\\S|--track\\b|--no-track\\b|--create-reflog\\b|--force\\b|-f\\b|-c\\b|-C\\b|-m\\b|-M\\b))";
852
852
  const FORBIDDEN_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})${FORBIDDEN_SUBCOMMAND}`, "gu");
853
- const MAIN_OWNERSHIP_OP = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b[^;&|(){}]*|checkout\\b[^;&|(){}]*|branch\\b[^;&|(){}]*|worktree\\s+add\\b[^;&|(){}]*)`, "gu");
853
+ // Verb-only: deliberately does NOT slurp trailing arguments itself (see
854
+ // mainOwnershipGitOps below for why a naive `[^;&|(){}]*` capture is unsafe).
855
+ const MAIN_OWNERSHIP_VERB = new RegExp(`\\bgit\\s+(${GIT_GLOBAL_RUN})(switch\\b|checkout\\b|branch\\b|worktree\\s+add\\b)`, "gu");
854
856
  function labelFor(subcommand) {
855
857
  if (subcommand.startsWith("commit"))
856
858
  return "git commit";
@@ -958,12 +960,69 @@ function mainOwnershipLabel(words) {
958
960
  return "git worktree add main";
959
961
  return null;
960
962
  }
963
+ /**
964
+ * Quote-aware segment bounds for `command`, split on the same unquoted
965
+ * `;&|(){}` control characters as {@link splitShellSegments} (kept in sync
966
+ * intentionally), but returning OFFSETS instead of trimmed substrings so a
967
+ * caller can slice the original command text for a match that falls inside a
968
+ * given segment.
969
+ */
970
+ function segmentBounds(command) {
971
+ const bounds = [];
972
+ let start = 0;
973
+ let quote = null;
974
+ let escaped = false;
975
+ for (let i = 0; i < command.length; i += 1) {
976
+ const ch = command[i];
977
+ if (ch === undefined)
978
+ continue;
979
+ if (escaped) {
980
+ escaped = false;
981
+ continue;
982
+ }
983
+ if (quote !== "'" && ch === "\\") {
984
+ escaped = true;
985
+ continue;
986
+ }
987
+ if ((ch === '"' || ch === "'") && (quote === null || quote === ch)) {
988
+ quote = quote === ch ? null : ch;
989
+ continue;
990
+ }
991
+ if (!quote && /[;&|(){}]/u.test(ch)) {
992
+ bounds.push({ start, end: i });
993
+ start = i + 1;
994
+ }
995
+ }
996
+ bounds.push({ start, end: command.length });
997
+ return bounds;
998
+ }
999
+ /**
1000
+ * Main-ownership candidate ops (`git switch|checkout|branch|worktree add`).
1001
+ *
1002
+ * MAIN_OWNERSHIP_VERB intentionally does NOT slurp trailing arguments itself
1003
+ * (unlike the pre-fix implementation) — a naive `[^;&|(){}]*` character class
1004
+ * is not quote-aware, so a read-only `git branch --format='%(refname:short)'`
1005
+ * has its captured subcommand text truncated mid-quote at the `(` inside the
1006
+ * quoted format string, producing an unterminated quote that shellWords()
1007
+ * reports as "ambiguous" and gets fail-closed blocked — purely because of a
1008
+ * paren inside its OWN quoted argument, not because it mutates anything.
1009
+ * Bounding the subcommand text to the enclosing quote-aware shell segment
1010
+ * (segmentBounds, sharing splitShellSegments' control-char set) fixes that
1011
+ * without weakening real-mutation detection: genuinely ambiguous syntax
1012
+ * (nested shells, unresolved substitutions, unterminated quotes) still
1013
+ * produces "ambiguous" and fails closed exactly as before.
1014
+ */
961
1015
  function mainOwnershipGitOps(command) {
962
1016
  if (!/\bgit\b/u.test(command))
963
1017
  return [];
964
1018
  const ops = [];
965
- for (const m of command.matchAll(MAIN_OWNERSHIP_OP)) {
966
- const subcommand = m[2] ?? "";
1019
+ const segments = segmentBounds(command);
1020
+ for (const m of command.matchAll(MAIN_OWNERSHIP_VERB)) {
1021
+ const verb = m[2] ?? "";
1022
+ const verbStart = (m.index ?? 0) + m[0].length - verb.length;
1023
+ const segment = segments.find((s) => verbStart >= s.start && verbStart < s.end) ??
1024
+ { start: verbStart, end: command.length };
1025
+ const subcommand = command.slice(verbStart, segment.end);
967
1026
  const words = shellWords(subcommand);
968
1027
  if (words === "ambiguous") {
969
1028
  ops.push({ label: "git main", globals: m[1] ?? "", index: m.index ?? 0 });
@@ -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
@@ -116,6 +116,8 @@ export declare function unifiedRuleFilename(consumer: UnifiedConsumerConfig, slu
116
116
  * `selectUnifiedConsumers(hosts)`:
117
117
  * - `.agent/{rules,skills}/` (working dir, SSOT): symlink, always
118
118
  * - `.claude/rules/`: symlink, always (rules are not plugin-delivered)
119
+ * - `.claude/agents/`: symlink, always (canonical subagents; Claude has no
120
+ * native subagent delivery channel, so this projection is not plugin-owned)
119
121
  *
120
122
  * Project-local legacy skill roots are intentionally omitted. Codex has no
121
123
  * `.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,10 @@ 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
+ { id: "claude-agents", dir: ".claude/agents", acceptsKind: "agent", strategy: "symlink" },
72
78
  ];
73
79
  /**
74
80
  * Projection surfaces retired by a newer agent-kit release.
@@ -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
  *
@@ -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
  *
@@ -86,7 +86,9 @@ function planTargets(records, consumers, consumerRoot) {
86
86
  const consumerDirAbs = join(consumerRoot, consumer.dir);
87
87
  let entryName;
88
88
  let targetPath;
89
- if (record.kind === "rule") {
89
+ if (record.kind === "rule" || record.kind === "agent") {
90
+ // agent records are file-shaped like rules (a single <slug>.md),
91
+ // not dir-shaped like skills.
90
92
  entryName = unifiedRuleFilename(consumer, record.slug);
91
93
  targetPath = join(consumerDirAbs, entryName);
92
94
  }
@@ -248,13 +250,40 @@ function applyTarget(plan, check) {
248
250
  }
249
251
  }
250
252
  }
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
+ }
251
280
  /**
252
281
  * Remove entries under each consumer dir that no longer correspond to any
253
282
  * planned record. Only removes entries whose shape matches the consumer's
254
283
  * managed pattern (rule files: `<slug><ext>`; skill dirs: subdirectories).
255
284
  * Real files outside the managed pattern (README.md, .gitkeep) are left alone.
256
285
  */
257
- function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs) {
286
+ function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs, configuredAgentSourceDirs) {
258
287
  const expectedByConsumerDir = new Map();
259
288
  for (const plan of plans) {
260
289
  const key = plan.consumer.dir;
@@ -265,6 +294,25 @@ function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs) {
265
294
  }
266
295
  set.add(plan.entryName);
267
296
  }
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.
310
+ const agentSourceDirs = new Set([
311
+ ...configuredAgentSourceDirs,
312
+ ...plans
313
+ .filter((plan) => plan.record.kind === "agent")
314
+ .map((plan) => dirname(plan.record.filePath)),
315
+ ]);
268
316
  let removed = 0;
269
317
  const mismatches = [];
270
318
  // Each unique dir gets a single sweep — multiple consumers can share a dir
@@ -292,15 +340,28 @@ function pruneStale(plans, consumers, consumerRoot, check, preserveSkillSlugs) {
292
340
  preserveSkillSlugs.has(name) &&
293
341
  entry.isDirectory())
294
342
  continue;
295
- // Only prune entries that match a managed shape: rule extension OR a
296
- // directory (skill).
297
- const matchesRulePattern = consumersForDir.some((c) => c.acceptsKind === "rule" && name.endsWith(c.ruleExtension ?? ".md"));
343
+ // Only prune entries that match a managed shape: rule/agent extension
344
+ // OR a directory (skill). Agent records are file-shaped like rules.
345
+ const matchesRulePattern = consumersForDir.some((c) => (c.acceptsKind === "rule" || c.acceptsKind === "agent") &&
346
+ name.endsWith(c.ruleExtension ?? ".md"));
298
347
  const matchesSkillPattern = consumersForDir.some((c) => c.acceptsKind === "skill") && entry.isDirectory();
299
348
  // Also handle stale symlinks of either shape.
300
349
  const isLink = entry.isSymbolicLink();
301
350
  if (!matchesRulePattern && !matchesSkillPattern && !isLink)
302
351
  continue;
303
352
  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)) {
363
+ continue;
364
+ }
304
365
  if (check) {
305
366
  mismatches.push({
306
367
  consumerId: consumersForDir[0]?.id ?? dir,
@@ -348,7 +409,7 @@ function pruneEmptyRetiredConsumerDirs(consumers, consumerRoot, check) {
348
409
  * Main entrypoint. See module docstring.
349
410
  */
350
411
  export function runUnifiedSync(options) {
351
- const kinds = options.kinds ?? ["rule", "skill"];
412
+ const kinds = options.kinds ?? ["rule", "skill", "agent"];
352
413
  const kindSet = new Set(kinds);
353
414
  const consumers = (options.consumers ?? selectUnifiedConsumers(options.hosts)).filter((consumer) => kindSet.has(consumer.acceptsKind));
354
415
  const activeDirs = new Set(consumers.map((consumer) => consumer.dir));
@@ -394,10 +455,26 @@ export function runUnifiedSync(options) {
394
455
  }
395
456
  }
396
457
  }
397
- const prune = pruneStale(plans, consumers, consumerRoot, options.check === true, options.preserveSkillSlugs);
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
+ const prune = pruneStale(plans, consumers, consumerRoot, options.check === true, options.preserveSkillSlugs, configuredAgentSourceDirs);
398
475
  fixCount += prune.removed;
399
476
  mismatches.push(...prune.mismatches);
400
- const retiredPrune = pruneStale([], retiredConsumers, consumerRoot, options.check === true, undefined);
477
+ const retiredPrune = pruneStale([], retiredConsumers, consumerRoot, options.check === true, undefined, configuredAgentSourceDirs);
401
478
  fixCount += retiredPrune.removed;
402
479
  mismatches.push(...retiredPrune.mismatches);
403
480
  const retiredDirectories = pruneEmptyRetiredConsumerDirs(retiredConsumers, consumerRoot, options.check === true);
@@ -296,7 +296,6 @@
296
296
  "src/hooks/dispatch/index.test.ts": 11.771874999999994,
297
297
  "src/hooks/shared/hook-bootstrap.test.ts": 58.217375000000004,
298
298
  "src/cli/commands/init/scaffolders/agent-hooks/emitters/grok.test.ts": 67.57654199999999,
299
- "src/cli/commands/init/scaffolders/subagents/index.test.ts": 49.99270800000005,
300
299
  "src/blueprint/core/validation/criteria.test.ts": 9.635125000000016,
301
300
  "src/cli/commands/session.test.ts": 662.8378339999999,
302
301
  "src/cli/commands/dash/run-registry.test.ts": 793.002083,