@webpresso/agent-kit 3.1.28 → 3.1.30

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 (98) hide show
  1. package/catalog/AGENTS.md.tpl +4 -4
  2. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  3. package/catalog/agent/rules/ci-test-perf.md +5 -1
  4. package/catalog/agent/rules/cmd-execution.md +9 -0
  5. package/catalog/agent/rules/pre-implementation.md +2 -1
  6. package/catalog/agent/rules/test-scan-perf.md +80 -0
  7. package/catalog/agent/rules/workflow-skills-routing.md +4 -2
  8. package/catalog/agent/skills/fix/SKILL.md +3 -0
  9. package/catalog/agent/skills/verify/SKILL.md +18 -0
  10. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +3 -0
  13. package/dist/esm/audit/roadmap-links.js +6 -0
  14. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  15. package/dist/esm/audit/test-scan-perf.js +275 -0
  16. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  17. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  18. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  20. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  21. package/dist/esm/ci/act-replay.js +110 -7
  22. package/dist/esm/ci/act-runner.d.ts +4 -0
  23. package/dist/esm/ci/act-runner.js +85 -26
  24. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  25. package/dist/esm/ci/act-worktree-git.js +219 -0
  26. package/dist/esm/cli/commands/audit.js +3 -0
  27. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  28. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  29. package/dist/esm/cli/commands/ci.d.ts +8 -1
  30. package/dist/esm/cli/commands/ci.js +80 -21
  31. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  32. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  33. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  34. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  35. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  36. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  37. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  38. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  39. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  40. package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
  41. package/dist/esm/cli/commands/dash/plan-once.js +41 -4
  42. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  43. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  44. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  45. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  46. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  47. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  48. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  49. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  50. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  51. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  52. package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
  53. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  54. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  55. package/dist/esm/cli/commands/format.js +0 -2
  56. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  57. package/dist/esm/cli/commands/init/config.js +1 -4
  58. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  59. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  60. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  63. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  64. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  65. package/dist/esm/cli/commands/logs.d.ts +15 -0
  66. package/dist/esm/cli/commands/logs.js +192 -3
  67. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  68. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  69. package/dist/esm/cli/commands/review.js +5 -8
  70. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  71. package/dist/esm/cli/commands/secrets.js +4 -1
  72. package/dist/esm/cli/direct-provider-launch.js +25 -1
  73. package/dist/esm/hooks/doctor.d.ts +15 -0
  74. package/dist/esm/hooks/doctor.js +56 -0
  75. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  76. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  77. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  78. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  79. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  80. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  81. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  82. package/dist/esm/mcp/tools/audit.js +10 -0
  83. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  84. package/dist/esm/mcp/tools/ci-act.js +9 -11
  85. package/dist/esm/paths/state-root.js +3 -0
  86. package/dist/esm/review/authority.js +17 -2
  87. package/dist/esm/review/availability.js +18 -5
  88. package/dist/esm/review/subject.d.ts +15 -0
  89. package/dist/esm/review/subject.js +105 -10
  90. package/dist/esm/runtime/executor.d.ts +1 -0
  91. package/dist/esm/runtime/executor.js +2 -1
  92. package/dist/esm/secret-gate/runner.d.ts +1 -0
  93. package/dist/esm/secret-gate/runner.js +4 -1
  94. package/dist/esm/worktrees/current.d.ts +1 -0
  95. package/dist/esm/worktrees/current.js +1 -0
  96. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  97. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  98. package/package.json +14 -14
@@ -7,6 +7,7 @@ import { CONTROL_PLANE_HOSTS } from "#cli/commands/dash/tui/hosts.js";
7
7
  import { isWizardProvider } from "#cli/commands/dash/session-wizard.js";
8
8
  import { browserOverlayLines, createBlueprintBrowser, reduceBlueprintBrowserKey, } from "#cli/commands/dash/tui/browser.js";
9
9
  import { filterWorktreePickerItems } from "#cli/commands/dash/worktree-picker.js";
10
+ import { DEFAULT_CONTROL_PLANE_CHAT_MODE, reduceChatModeSwitch, toggleControlPlaneChatMode, } from "#cli/commands/dash/tui/chat-mode.js";
10
11
  export function createInitialTuiState(input) {
11
12
  const menu = {
12
13
  selectedKey: input.agents[0] ? `group:${input.agents[0].key ?? "claude"}` : null,
@@ -16,6 +17,10 @@ export function createInitialTuiState(input) {
16
17
  menu: reconcileSelection(menu, input.agents),
17
18
  agents: input.agents,
18
19
  permissionMode: input.permissionMode,
20
+ chatMode: input.chatMode ?? DEFAULT_CONTROL_PLANE_CHAT_MODE,
21
+ chatSession: null,
22
+ chatDraft: "",
23
+ chatOpenGeneration: 0,
19
24
  overlay: { kind: "none" },
20
25
  status: null,
21
26
  warnings: [],
@@ -53,7 +58,7 @@ export function reduceTuiKey(state, key) {
53
58
  if (state.overlay.kind === "help") {
54
59
  if (key.kind === "escape" ||
55
60
  (key.kind === "char" && (key.value === "?" || key.value === "q"))) {
56
- return { state: { ...state, overlay: { kind: "none" } }, effects: [] };
61
+ return { state: dismissTransientOverlay(state), effects: [] };
57
62
  }
58
63
  return { state, effects: [] };
59
64
  }
@@ -63,6 +68,10 @@ export function reduceTuiKey(state, key) {
63
68
  if (state.overlay.kind === "browser" && state.browser) {
64
69
  return reduceBrowserKey(state, key);
65
70
  }
71
+ // Integrated-chat overlay uses browser chrome without blueprint browser state.
72
+ if (state.overlay.kind === "browser" && !state.browser && state.chatMode === "integrated-chat") {
73
+ return reduceIntegratedChatKey(state, key);
74
+ }
66
75
  if (state.overlay.kind === "wizard" && state.wizard) {
67
76
  return reduceWizardKey(state, key);
68
77
  }
@@ -110,6 +119,19 @@ export function reduceTuiKey(state, key) {
110
119
  effects: [{ kind: "launch", command: "wp fleet watch", provider: "fleet", newRun: true }],
111
120
  };
112
121
  }
122
+ if (key.kind === "char" && key.value === "m") {
123
+ return {
124
+ state: {
125
+ ...state,
126
+ browser: null,
127
+ overlay: { kind: "browser", title: "Plan-once merge", lines: ["loading…"] },
128
+ },
129
+ effects: [{ kind: "palette-command", id: "merge.status" }],
130
+ };
131
+ }
132
+ if (key.kind === "char" && key.value === "c") {
133
+ return toggleChatMode(state);
134
+ }
113
135
  if (key.kind === "char" && key.value === "n") {
114
136
  const provider = wizardProviderFromSelection(state) ?? "claude";
115
137
  const blocked = hostBlockedReason(state, provider);
@@ -269,7 +291,7 @@ function reducePaletteKey(state, key) {
269
291
  const { query, cursor } = state.overlay;
270
292
  const matches = filterPaletteCommands(query);
271
293
  if (key.kind === "escape" || (key.kind === "ctrl" && key.value === "c")) {
272
- return { state: { ...state, overlay: { kind: "none" } }, effects: [] };
294
+ return { state: dismissTransientOverlay(state), effects: [] };
273
295
  }
274
296
  if (key.kind === "backspace") {
275
297
  const nextQuery = query.slice(0, -1);
@@ -310,8 +332,9 @@ function reducePaletteKey(state, key) {
310
332
  if (key.kind === "enter") {
311
333
  const command = matches[cursor];
312
334
  if (!command)
313
- return { state: { ...state, overlay: { kind: "none" } }, effects: [] };
314
- return applyPaletteCommand({ ...state, overlay: { kind: "none" } }, command.id);
335
+ return { state: dismissTransientOverlay(state), effects: [] };
336
+ // Dismiss palette first; integrated-chat restores compose overlay when still active.
337
+ return applyPaletteCommand(dismissTransientOverlay(state), command.id);
315
338
  }
316
339
  if (key.kind === "char") {
317
340
  const nextQuery = query + key.value;
@@ -433,6 +456,21 @@ export function applyPaletteCommand(state, id) {
433
456
  return reduceTuiKey(state, { kind: "char", value: "b" });
434
457
  case "fleet.open":
435
458
  return reduceTuiKey(state, { kind: "char", value: "f" });
459
+ case "merge.status":
460
+ return reduceTuiKey(state, { kind: "char", value: "m" });
461
+ case "chat.mode.toggle":
462
+ return toggleChatMode(state);
463
+ case "chat.mode.open":
464
+ // Runtime-owned open; pure path only re-emits when already requesting open.
465
+ return {
466
+ state,
467
+ effects: [{ kind: "palette-command", id: "chat.mode.open" }],
468
+ };
469
+ case "chat.mode.send":
470
+ return {
471
+ state,
472
+ effects: [{ kind: "palette-command", id: "chat.mode.send" }],
473
+ };
436
474
  case "permission.toggle":
437
475
  return reduceTuiKey(state, { kind: "char", value: "d" });
438
476
  case "help.open":
@@ -445,6 +483,221 @@ export function applyPaletteCommand(state, id) {
445
483
  return { state, effects: [] };
446
484
  }
447
485
  }
486
+ /**
487
+ * Toggle host-pane ↔ integrated-chat. Integrated chat requires an ACP-capable
488
+ * selected host and an existing dash run identity (no second registry).
489
+ */
490
+ export function toggleChatMode(state) {
491
+ if (state.chatMode === "integrated-chat") {
492
+ return leaveIntegratedChat(state);
493
+ }
494
+ const host = selectedDashProvider(state);
495
+ const target = toggleControlPlaneChatMode(state.chatMode);
496
+ const decision = reduceChatModeSwitch({
497
+ mode: state.chatMode,
498
+ target,
499
+ host,
500
+ });
501
+ if (!decision.ok) {
502
+ // Refused: no persist effect.
503
+ return { state: { ...state, status: decision.error }, effects: [] };
504
+ }
505
+ const run = closeTargetFromState(state);
506
+ if (!run?.runId) {
507
+ return {
508
+ state: {
509
+ ...state,
510
+ status: "Select a host session (run) before switching to integrated chat",
511
+ },
512
+ effects: [],
513
+ };
514
+ }
515
+ const nextGen = state.chatOpenGeneration + 1;
516
+ return {
517
+ state: {
518
+ ...state,
519
+ chatMode: "integrated-chat",
520
+ chatDraft: "",
521
+ chatOpenGeneration: nextGen,
522
+ browser: null,
523
+ wizard: null,
524
+ status: "mode:integrated-chat (opening…)",
525
+ overlay: {
526
+ kind: "browser",
527
+ title: "Integrated chat",
528
+ lines: ["opening chat bridge…", "type to compose · ⏎ send · esc → host-pane"],
529
+ },
530
+ },
531
+ // Persist after bridge open succeeds in runtime; open effect only here.
532
+ effects: [{ kind: "palette-command", id: "chat.mode.open" }],
533
+ };
534
+ }
535
+ /**
536
+ * After agents load: if preferred is integrated-chat and gates pass, apply the
537
+ * normal toggle path once. Never forces integrated-chat chrome without host/run.
538
+ */
539
+ export function applyPreferredChatModeSeed(state, preferred) {
540
+ if (preferred === "host-pane") {
541
+ if (state.chatMode === "host-pane")
542
+ return { state, effects: [] };
543
+ return leaveIntegratedChat(state);
544
+ }
545
+ // preferred integrated-chat
546
+ if (state.chatMode === "integrated-chat")
547
+ return { state, effects: [] };
548
+ return toggleChatMode(state);
549
+ }
550
+ function leaveIntegratedChat(state) {
551
+ return {
552
+ state: {
553
+ ...state,
554
+ chatMode: "host-pane",
555
+ chatSession: null,
556
+ chatDraft: "",
557
+ // Bump generation so in-flight open/send cannot re-enter chat mode.
558
+ chatOpenGeneration: state.chatOpenGeneration + 1,
559
+ overlay: { kind: "none" },
560
+ browser: null,
561
+ status: "mode:host-pane",
562
+ },
563
+ effects: [{ kind: "persist-chat-mode", mode: "host-pane" }],
564
+ };
565
+ }
566
+ function reduceIntegratedChatKey(state, key) {
567
+ // Escape (or Ctrl+C with empty draft) returns to host-pane. Printable `c` is
568
+ // a normal composer character so operators can type freely.
569
+ if (key.kind === "escape") {
570
+ return leaveIntegratedChat(state);
571
+ }
572
+ // Empty draft: pass through menu global chords (?, q, palette, help).
573
+ if (state.chatDraft.length === 0) {
574
+ if (key.kind === "ctrl" && (key.value === "p" || key.value === "k")) {
575
+ return {
576
+ state: { ...state, overlay: { kind: "palette", query: "", cursor: 0 } },
577
+ effects: [],
578
+ };
579
+ }
580
+ if (key.kind === "ctrl" && key.value === "x") {
581
+ return { state: { ...state, overlay: { kind: "help" } }, effects: [] };
582
+ }
583
+ if (key.kind === "char" && key.value === "q") {
584
+ return { state: { ...state, quit: true }, effects: [{ kind: "quit" }] };
585
+ }
586
+ if (key.kind === "char" && key.value === "?") {
587
+ return { state: { ...state, overlay: { kind: "help" } }, effects: [] };
588
+ }
589
+ }
590
+ if (key.kind === "ctrl" && key.value === "c") {
591
+ if (state.chatDraft.length === 0)
592
+ return leaveIntegratedChat(state);
593
+ return {
594
+ state: withChatOverlay({ ...state, chatDraft: "" }),
595
+ effects: [],
596
+ };
597
+ }
598
+ if (key.kind === "backspace") {
599
+ if (state.chatDraft.length === 0)
600
+ return { state, effects: [] };
601
+ return {
602
+ state: withChatOverlay({ ...state, chatDraft: state.chatDraft.slice(0, -1) }),
603
+ effects: [],
604
+ };
605
+ }
606
+ if (key.kind === "enter") {
607
+ const text = state.chatDraft.trim();
608
+ if (!text) {
609
+ return { state: { ...state, status: "type a message before send" }, effects: [] };
610
+ }
611
+ if (!state.chatSession) {
612
+ return {
613
+ state: { ...state, status: "chat session not open yet" },
614
+ effects: [],
615
+ };
616
+ }
617
+ // Keep draft until runtime drains it after send.
618
+ return {
619
+ state: {
620
+ ...withChatOverlay({
621
+ ...state,
622
+ chatDraft: text,
623
+ status: "sending…",
624
+ }),
625
+ },
626
+ effects: [{ kind: "palette-command", id: "chat.mode.send" }],
627
+ };
628
+ }
629
+ if (key.kind === "char") {
630
+ const nextDraft = state.chatDraft + key.value;
631
+ return {
632
+ state: withChatOverlay({ ...state, chatDraft: nextDraft }),
633
+ effects: [],
634
+ };
635
+ }
636
+ return { state, effects: [] };
637
+ }
638
+ function withChatOverlay(state) {
639
+ return {
640
+ ...state,
641
+ overlay: {
642
+ kind: "browser",
643
+ title: "Integrated chat",
644
+ lines: formatIntegratedChatLines(state),
645
+ },
646
+ };
647
+ }
648
+ /** Dismiss help/palette without stranding integrated-chat (restore browser compose overlay). */
649
+ function dismissTransientOverlay(state) {
650
+ if (state.chatMode === "integrated-chat") {
651
+ return withChatOverlay(state);
652
+ }
653
+ return { ...state, overlay: { kind: "none" } };
654
+ }
655
+ function formatIntegratedChatLines(state) {
656
+ const session = state.chatSession;
657
+ const body = session
658
+ ? [
659
+ `chat · ${session.host} · run ${session.runId.slice(0, 8)}`,
660
+ session.label ? `label · ${session.label}` : "label · —",
661
+ "scope · integrated-chat (⏎ send · esc → host-pane)",
662
+ "─".repeat(32),
663
+ ...session.messages.map((msg) => {
664
+ const tag = msg.role === "user" ? "you" : msg.role === "assistant" ? "host" : "sys";
665
+ return `${tag} · ${msg.text}`;
666
+ }),
667
+ ]
668
+ : ["opening chat bridge…"];
669
+ return [...body, "", `compose> ${state.chatDraft}█`];
670
+ }
671
+ function selectedDashProvider(state) {
672
+ const host = wizardProviderFromSelection(state);
673
+ return host && isDashProvider(host) ? host : null;
674
+ }
675
+ /** Runtime applies hermetic/real bridge result into state + overlay. */
676
+ export function applyChatBridgeSession(state, session, error, options = {}) {
677
+ // Stale completion: operator already left or re-entered chat.
678
+ if (options.expectedGeneration !== undefined &&
679
+ options.expectedGeneration !== state.chatOpenGeneration) {
680
+ return state;
681
+ }
682
+ if (error || !session) {
683
+ return {
684
+ ...state,
685
+ chatMode: "host-pane",
686
+ chatSession: null,
687
+ chatDraft: "",
688
+ overlay: { kind: "none" },
689
+ status: error ?? "chat bridge failed",
690
+ };
691
+ }
692
+ const next = {
693
+ ...state,
694
+ chatMode: "integrated-chat",
695
+ chatSession: session,
696
+ browser: null,
697
+ status: `mode:integrated-chat · ${session.host}`,
698
+ };
699
+ return withChatOverlay(next);
700
+ }
448
701
  /** Open the worktree/permission wizard with a real wizard object (not overlay-only). */
449
702
  export function openLaunchWizard(state, provider) {
450
703
  return {
@@ -19,8 +19,6 @@ const OXFMT_EXTENSIONS = new Set([
19
19
  ".json",
20
20
  ".md",
21
21
  ".mdx",
22
- ".sh",
23
- ".tmpl",
24
22
  ".yaml",
25
23
  ".yml",
26
24
  ]);
@@ -27,7 +27,6 @@ export interface AgentkitConfig {
27
27
  visibility?: Record<string, Record<string, VisibilityStatus>>;
28
28
  };
29
29
  mcp?: {
30
- serverName?: string;
31
30
  toolPrefix?: string;
32
31
  };
33
32
  /** Pretool-guard routing policy. `mechanism` lives in agent-kit; this is the
@@ -105,11 +105,8 @@ function parseConfigFile(path) {
105
105
  const overrides = Array.isArray(rules?.overrides) ? rules.overrides : [];
106
106
  const durablePlanningRoot = readOptionalString(parsed.durablePlanningRoot);
107
107
  const blueprintsDir = readOptionalString(parsed.blueprintsDir);
108
- const serverName = readOptionalString(mcp?.serverName);
109
108
  const toolPrefix = readOptionalString(mcp?.toolPrefix);
110
- const normalizedMcp = serverName || toolPrefix
111
- ? { ...(serverName ? { serverName } : {}), ...(toolPrefix ? { toolPrefix } : {}) }
112
- : undefined;
109
+ const normalizedMcp = toolPrefix ? { toolPrefix } : undefined;
113
110
  const guard = parsed.guard;
114
111
  const packageManager = guard?.packageManager === "vp-only" ? "vp-only" : undefined;
115
112
  const rawScriptRoutes = guard?.scriptRoutes && typeof guard.scriptRoutes === "object"
@@ -12,12 +12,11 @@ import type { HooksMap, MatcherSet } from "#cli/commands/init/scaffolders/agent-
12
12
  * event key. Specs with a `matcher` key reference the corresponding field
13
13
  * in the provided MatcherSet.
14
14
  *
15
- * PreToolUse (pretool-guard) is special-cased for spawn reduction:
16
- * - Non-Bash security tools keep a full always-on group (no `if`).
17
- * - Bash is emitted as a git-scoped group with `if: Bash(git *)` for git-hot
18
- * paths (commit-author / git discipline).
19
- * - Bash security catch-all without `if` preserves catastrophic command checks
20
- * (e.g. `rm -rf`) for non-git Bash (residual: git Bash may hit both groups).
15
+ * PreToolUse (pretool-guard) keeps the Bash matcher in its own group but emits
16
+ * exactly one guard entry per tool, so the guard spawns once per call. A prior
17
+ * git-scoped `if: Bash(git *)` group was removed: its command was byte-identical
18
+ * to the Bash catch-all, so it added no enforcement, doubled guard spawns on
19
+ * git commands, and tripped the hook-surface duplicate-managed-hook audit.
21
20
  */
22
21
  export declare function buildClaudeHookGroups(input: {
23
22
  resolveBin: (name: string) => string;
@@ -5,7 +5,7 @@
5
5
  * Extracted from buildWebpressoHookGroups in index.ts. The function
6
6
  * buildWebpressoHookGroups in index.ts now delegates here for backward compat.
7
7
  */
8
- import { CLAUDE_PRETOOL_GIT_IF, WP_HOOK_SPECS, } from "#cli/commands/init/scaffolders/agent-hooks/ir.js";
8
+ import { WP_HOOK_SPECS } from "#cli/commands/init/scaffolders/agent-hooks/ir.js";
9
9
  function pushGroup(result, event, group) {
10
10
  const existing = result[event] ?? [];
11
11
  result[event] = [...existing, group];
@@ -28,12 +28,11 @@ function splitPreToolMatchers(matcher) {
28
28
  * event key. Specs with a `matcher` key reference the corresponding field
29
29
  * in the provided MatcherSet.
30
30
  *
31
- * PreToolUse (pretool-guard) is special-cased for spawn reduction:
32
- * - Non-Bash security tools keep a full always-on group (no `if`).
33
- * - Bash is emitted as a git-scoped group with `if: Bash(git *)` for git-hot
34
- * paths (commit-author / git discipline).
35
- * - Bash security catch-all without `if` preserves catastrophic command checks
36
- * (e.g. `rm -rf`) for non-git Bash (residual: git Bash may hit both groups).
31
+ * PreToolUse (pretool-guard) keeps the Bash matcher in its own group but emits
32
+ * exactly one guard entry per tool, so the guard spawns once per call. A prior
33
+ * git-scoped `if: Bash(git *)` group was removed: its command was byte-identical
34
+ * to the Bash catch-all, so it added no enforcement, doubled guard spawns on
35
+ * git commands, and tripped the hook-surface duplicate-managed-hook audit.
37
36
  */
38
37
  export function buildClaudeHookGroups(input) {
39
38
  const { resolveBin, matchers } = input;
@@ -54,12 +53,6 @@ export function buildClaudeHookGroups(input) {
54
53
  });
55
54
  }
56
55
  if (hasBash) {
57
- // Git-hot path: only spawn when Bash looks like git (spawn reduction).
58
- pushGroup(result, "PreToolUse", {
59
- matcher: "Bash",
60
- hooks: [{ ...baseHook, if: CLAUDE_PRETOOL_GIT_IF }],
61
- });
62
- // Security catch-all for non-git Bash (catastrophic commands, etc.).
63
56
  pushGroup(result, "PreToolUse", {
64
57
  matcher: "Bash",
65
58
  hooks: [baseHook],
@@ -22,8 +22,6 @@ export type HookGroup = {
22
22
  hooks: HookEntry[];
23
23
  };
24
24
  export type HooksMap = Record<string, HookGroup[]>;
25
- /** Claude-only PreToolUse `if` for git-hot Bash paths (commit-author / git discipline). */
26
- export declare const CLAUDE_PRETOOL_GIT_IF = "Bash(git *)";
27
25
  export declare const HOOK_EVENT_NAMES: readonly ["SessionStart", "PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit", "Stop", "PermissionRequest", "SubagentStart", "SubagentStop", "SessionEnd", "PreCompact", "PostCompact"];
28
26
  export type HookEventName = (typeof HOOK_EVENT_NAMES)[number];
29
27
  export type MatcherSet = {
@@ -7,8 +7,6 @@
7
7
  * output formats.
8
8
  */
9
9
  import { assertValidHookSpecs } from "./contract.js";
10
- /** Claude-only PreToolUse `if` for git-hot Bash paths (commit-author / git discipline). */
11
- export const CLAUDE_PRETOOL_GIT_IF = "Bash(git *)";
12
10
  // Known hook event names across the documented Claude/Codex lifecycle. Used by
13
11
  // migration/validation paths to identify legacy flat-form keys and by CLI
14
12
  // helpers like `wp hooks dispatch` / `wp hooks demo` to accept a wider event
@@ -6,9 +6,12 @@
6
6
  */
7
7
  import type { HookGroup, HooksMap } from "./ir.js";
8
8
  /**
9
- * Ensure `group` is present in `groups`. If a group already contains a hook
10
- * with the same managed identity (command + matcher + if), update its metadata
11
- * (matcher, timeout, if) but preserve the consumer's materialized command form.
9
+ * Ensure `group` is present in `groups`. The first hook with the same managed
10
+ * identity (command target + matcher) is updated to mirror the incoming spec
11
+ * metadata (matcher, timeout, `if` dropped when the spec no longer sets it)
12
+ * while preserving the consumer's materialized command form. Any further hooks
13
+ * with the same identity are stale spec revisions (e.g. a legacy `if`-gated
14
+ * duplicate) and are pruned so repair converges to one entry per identity.
12
15
  * If no matching hook is found, append the group.
13
16
  */
14
17
  export declare function ensureGroup(groups: HookGroup[], group: HookGroup): HookGroup[];
@@ -36,18 +36,16 @@ function commandMatches(left, right) {
36
36
  return leftTarget !== null && extractCommandTarget(right) === leftTarget;
37
37
  }
38
38
  /**
39
- * Managed hooks can legitimately emit multiple groups that share the same
40
- * command target but differ by matcher and/or permission-rule `if` (e.g. Claude
41
- * PreToolUse: non-Bash group + Bash(git *) + Bash catch-all). Identity is
42
- * (command target, matcher, if) not command alone.
39
+ * Managed hooks may emit multiple groups that share the same command target
40
+ * only when their matchers differ (e.g. Claude PreToolUse: non-Bash group +
41
+ * Bash group). Identity is (command target, matcher). The permission-rule `if`
42
+ * is spec-owned metadata, not identity: treating it as identity let a spec
43
+ * revision that changed `if` strand the old entry as a duplicate that
44
+ * `wp audit hook-surface` then flagged (duplicate-managed-hook).
43
45
  */
44
46
  function sameManagedHookIdentity(existingGroup, existingHook, incomingGroup, incomingHook) {
45
47
  if (!commandMatches(existingHook.command, incomingHook.command))
46
48
  return false;
47
- const existingIf = typeof existingHook.if === "string" ? existingHook.if : "";
48
- const incomingIf = typeof incomingHook.if === "string" ? incomingHook.if : "";
49
- if (existingIf !== incomingIf)
50
- return false;
51
49
  const existingMatcher = existingGroup.matcher ?? "";
52
50
  const incomingMatcher = incomingGroup.matcher ?? "";
53
51
  if (existingMatcher === incomingMatcher)
@@ -57,38 +55,53 @@ function sameManagedHookIdentity(existingGroup, existingHook, incomingGroup, inc
57
55
  return existingMatcher === "" || incomingMatcher === "";
58
56
  }
59
57
  /**
60
- * Ensure `group` is present in `groups`. If a group already contains a hook
61
- * with the same managed identity (command + matcher + if), update its metadata
62
- * (matcher, timeout, if) but preserve the consumer's materialized command form.
58
+ * Ensure `group` is present in `groups`. The first hook with the same managed
59
+ * identity (command target + matcher) is updated to mirror the incoming spec
60
+ * metadata (matcher, timeout, `if` dropped when the spec no longer sets it)
61
+ * while preserving the consumer's materialized command form. Any further hooks
62
+ * with the same identity are stale spec revisions (e.g. a legacy `if`-gated
63
+ * duplicate) and are pruned so repair converges to one entry per identity.
63
64
  * If no matching hook is found, append the group.
64
65
  */
65
66
  export function ensureGroup(groups, group) {
66
67
  const incomingHook = group.hooks[0];
67
68
  if (!incomingHook)
68
69
  return groups;
69
- let changed = false;
70
- const nextGroups = groups.map((existingGroup) => {
71
- const hookIndex = existingGroup.hooks.findIndex((hook) => sameManagedHookIdentity(existingGroup, hook, group, incomingHook));
72
- if (hookIndex === -1)
73
- return existingGroup;
74
- changed = true;
75
- const hooks = existingGroup.hooks.map((hook, index) => index === hookIndex
76
- ? {
70
+ let updated = false;
71
+ const nextGroups = [];
72
+ for (const existingGroup of groups) {
73
+ const hooks = [];
74
+ let matchedHere = false;
75
+ for (const hook of existingGroup.hooks) {
76
+ if (!sameManagedHookIdentity(existingGroup, hook, group, incomingHook)) {
77
+ hooks.push(hook);
78
+ continue;
79
+ }
80
+ if (updated)
81
+ continue; // stale duplicate of an already-updated identity
82
+ updated = true;
83
+ matchedHere = true;
84
+ const merged = {
77
85
  ...hook,
78
86
  ...incomingHook,
79
87
  // Preserve the consumer's already-materialized command form when
80
88
  // only the matcher/timeout/if changed. Codex command path migration is
81
89
  // handled by normalizeCodexAgentKitCommands before this merge.
82
90
  command: hook.command,
83
- }
84
- : hook);
85
- return {
91
+ };
92
+ if (incomingHook.if === undefined)
93
+ delete merged.if;
94
+ hooks.push(merged);
95
+ }
96
+ if (hooks.length === 0)
97
+ continue; // group held only stale duplicates
98
+ nextGroups.push({
86
99
  ...existingGroup,
87
- ...(group.matcher !== undefined ? { matcher: group.matcher } : {}),
100
+ ...(matchedHere && group.matcher !== undefined ? { matcher: group.matcher } : {}),
88
101
  hooks,
89
- };
90
- });
91
- if (changed)
102
+ });
103
+ }
104
+ if (updated)
92
105
  return nextGroups;
93
106
  return [...groups, group];
94
107
  }