@uniqbit/mate-core 0.18.3-canary.0 → 0.18.3

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 (96) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +56 -2
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/types.ts +4 -1
  48. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  49. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  50. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  51. package/src/lib/runtime-directory-refresh.ts +186 -0
  52. package/src/lib/update-checker.ts +6 -0
  53. package/src/opencode/companion-hooks.ts +24 -132
  54. package/src/opencode/companion-policy.ts +26 -10
  55. package/src/opencode/index.ts +1 -7
  56. package/src/opencode/session-notes.ts +76 -0
  57. package/src/opencode/session-prepare.ts +139 -0
  58. package/src/opencode/tui.tsx +56 -9
  59. package/src/runtime/claude-delivery-plan.ts +73 -0
  60. package/src/runtime/claude-hook-group.ts +21 -0
  61. package/src/runtime/companion-lock.ts +121 -0
  62. package/src/runtime/companion-sync.ts +66 -9
  63. package/src/runtime/runtime-directory.ts +43 -0
  64. package/src/runtime/runtime-document-manifest.ts +335 -0
  65. package/src/runtime/session-prepare-runner.ts +148 -0
  66. package/src/runtime/wrap-state.ts +90 -0
  67. package/src/runtime/write-atomic.ts +27 -0
  68. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  70. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  71. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  72. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  73. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  74. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  75. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  76. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  77. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  78. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  79. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  80. package/src/tools/setup/engine.ts +15 -4
  81. package/src/tools/setup/mate.ts +1 -1
  82. package/src/tools/setup/providers/claude-format.ts +11 -18
  83. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  84. package/src/tools/setup/providers/claude.ts +23 -6
  85. package/src/tools/setup/runtime-documents.ts +152 -32
  86. package/src/tools/setup/working-repo-local-state.ts +82 -13
  87. package/src/tools/setup.ts +2 -2
  88. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  89. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  90. package/src/lib/orchestrator/companion-store.ts +0 -94
  91. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  92. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  93. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  94. package/src/templates/report-assets/README.md +0 -32
  95. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  96. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,88 +1,72 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { getReactDoctorBinPath } from "../../../lib/package-paths";
4
5
  import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
5
- import { removeLegacyOpenCodeSkills } from "../providers/agents-skill-root";
6
+ import { removeLegacyOpenCodeSkills, removeSharedSkills } from "../providers/agents-skill-root";
6
7
  import { pruneEmptyAncestors } from "../utils";
7
8
 
8
- const SKILL_NAME = "react-doctor";
9
+ const SKILL_NAME = "mate-react-doctor";
10
+ // The upstream skill earlier releases installed under its own name.
11
+ const LEGACY_SKILL_NAME = "react-doctor";
9
12
 
10
13
  const SKILL_SRC = path.join(
11
14
  import.meta.dirname,
12
- "../../../templates/capabilities/react-doctor/skill",
13
- );
14
- export const CLAUDE_HOOK_SRC = path.join(
15
- import.meta.dirname,
16
- "../../../templates/capabilities/react-doctor/claude/hooks/react-doctor.sh",
15
+ "../../../templates/capabilities/react-doctor/mate-react-doctor",
17
16
  );
18
17
 
18
+ /** Removes the skill copies earlier releases installed under the upstream name. */
19
+ async function removeLegacySkills(companionPath: string): Promise<void> {
20
+ const claudeSkillsDir = path.join(companionPath, ".claude", "skills");
21
+ await fs.rm(path.join(claudeSkillsDir, LEGACY_SKILL_NAME), { recursive: true, force: true });
22
+ await pruneEmptyAncestors(claudeSkillsDir, companionPath);
23
+ await removeSharedSkills(companionPath, [LEGACY_SKILL_NAME]);
24
+ await removeLegacyOpenCodeSkills(companionPath, [LEGACY_SKILL_NAME]);
25
+ }
26
+
27
+ /** Removes the post-edit hook script earlier releases copied into the companion. */
28
+ async function removeLegacyHookScript(companionPath: string): Promise<void> {
29
+ const hooksDir = path.join(companionPath, ".claude", "hooks");
30
+ await fs.rm(path.join(hooksDir, "react-doctor.sh"), { force: true });
31
+ await pruneEmptyAncestors(hooksDir, companionPath);
32
+ }
33
+
19
34
  export function createReactDoctorPlugin(): CapabilityPlugin {
20
35
  return {
21
36
  id: "react-doctor",
22
37
  kind: "capability",
23
38
  label: "React Doctor",
24
- description: "Install React Doctor skills and Claude post-edit scans.",
39
+ description: "Install the mate-react-doctor skill that verifies React changes during apply.",
25
40
  defaultSelected: true,
26
41
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "react-doctor"),
27
- // Skills, session hooks, and permission pre-seeds are declared and
28
- // reconciled by each runtime's Runtime Surface. Only the hook script file
29
- // itself is copied imperatively below (script files are not a config
30
- // format the surface owns).
31
- getRuntimeContributions(ctx: SetupContext): RuntimeContributionsByRuntime {
32
- const hookCommand = `sh "${ctx.companionPath}/.claude/hooks/react-doctor.sh"`;
42
+ // React Doctor runs only when an agent invokes the skill (the shared Mate
43
+ // verification rule adds it to OpenSpec task lists), so the capability
44
+ // contributes no hooks: just the skill and its permission pre-seeds.
45
+ getRuntimeContributions(): RuntimeContributionsByRuntime {
33
46
  const skillTrees = [{ name: SKILL_NAME, sourceDir: SKILL_SRC }];
34
47
  return {
35
48
  claude: {
36
- hookGroups: [
37
- // Record edits cheaply, then scan once when the edited turn finishes.
38
- {
39
- event: "PostToolUse",
40
- marker: "react-doctor.sh",
41
- group: {
42
- matcher: "Write|Edit|MultiEdit|NotebookEdit|ApplyPatch",
43
- hooks: [{ type: "command", command: hookCommand, timeout: 5 }],
44
- },
45
- },
46
- {
47
- event: "Stop",
48
- marker: "react-doctor.sh",
49
- group: { hooks: [{ type: "command", command: hookCommand, timeout: 45 }] },
50
- },
51
- ],
52
- permissionEntries: [
53
- "Skill(react-doctor)",
54
- "Bash(npx react-doctor:*)",
55
- "Bash(npx react-doctor@latest *)",
56
- ],
49
+ permissionEntries: [`Skill(${SKILL_NAME})`, `Bash(${getReactDoctorBinPath()}:*)`],
57
50
  skillTrees,
58
51
  },
59
52
  opencode: { skillTrees },
60
53
  };
61
54
  },
62
55
  async apply(ctx: SetupContext) {
63
- await removeLegacyOpenCodeSkills(ctx.companionPath, [SKILL_NAME]);
56
+ await removeLegacySkills(ctx.companionPath);
64
57
  },
65
58
  async teardown(ctx: SetupContext) {
66
- await removeLegacyOpenCodeSkills(ctx.companionPath, [SKILL_NAME]);
59
+ await removeLegacySkills(ctx.companionPath);
67
60
  },
61
+ // Cleanup only: stale hook groups are stripped by settings-sync through the
62
+ // `react-doctor.sh` managed marker; the script file itself is removed here.
68
63
  forProvider: {
69
64
  claude: {
70
65
  async apply(ctx: SetupContext) {
71
- const hookDest = path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh");
72
- await fs.mkdir(path.dirname(hookDest), { recursive: true });
73
- await fs.copyFile(CLAUDE_HOOK_SRC, hookDest);
74
- await fs.chmod(hookDest, 0o755);
66
+ await removeLegacyHookScript(ctx.companionPath);
75
67
  },
76
68
  async teardown(ctx: SetupContext) {
77
- try {
78
- await fs.unlink(path.join(ctx.companionPath, ".claude", "hooks", "react-doctor.sh"));
79
- } catch {
80
- /* not present */
81
- }
82
- await pruneEmptyAncestors(
83
- path.join(ctx.companionPath, ".claude", "hooks"),
84
- ctx.companionPath,
85
- );
69
+ await removeLegacyHookScript(ctx.companionPath);
86
70
  },
87
71
  },
88
72
  },
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { getActiveDistribution } from "../../../distribution";
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
- import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
6
+ import { CompanionResolver } from "../../../lib/orchestrator/repository-link";
7
7
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
8
8
  import type { Plugin } from "../plugin";
9
9
  import type { PluginRegistry } from "../registry";
@@ -1,8 +1,8 @@
1
1
  // oxlint-disable no-await-in-loop
2
2
  import { getActiveDistribution } from "../../distribution";
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
- import type { RenderedRuntimeDocument } from "../../lib/orchestrator/projection-types";
5
4
  import type { FrameworkConfig } from "../../lib/orchestrator/types";
5
+ import type { RenderedRuntimeDocument } from "../../runtime/runtime-document-manifest";
6
6
  import { collectHostingProviders, ContextServiceMediator } from "./context-services";
7
7
  import type {
8
8
  CapabilityContributionInput,
@@ -13,7 +13,7 @@ import type {
13
13
  } from "./plugin";
14
14
  import { reconcileClaudeContributions } from "./providers/claude";
15
15
  import { reconcileOpenCodeContributions } from "./providers/opencode";
16
- import { renderDocumentsForTarget } from "./runtime-documents";
16
+ import { mergeCompanionOpenCodeConfig, renderDocumentsForTarget } from "./runtime-documents";
17
17
  import { normalizeRegistration, type NormalizedRegistration } from "./registry";
18
18
 
19
19
  export interface SetupInstallationPlanAction {
@@ -252,6 +252,12 @@ async function reconcileCapabilityContributions(
252
252
  }
253
253
  }
254
254
 
255
+ /** What a render returns: its warnings are data for the caller, never printed here. */
256
+ export interface RenderedRuntimeDocuments {
257
+ documents: RenderedRuntimeDocument[];
258
+ warnings: string[];
259
+ }
260
+
255
261
  /**
256
262
  * The same declarations, rendered rather than written: a working-target pass
257
263
  * produces the documents and the Projection Root places them, so no Runtime
@@ -261,7 +267,12 @@ export async function renderRuntimeDocuments(
261
267
  ctx: SetupContext,
262
268
  registrations: PluginRegistration[],
263
269
  plan: SetupInstallationPlan,
264
- ): Promise<RenderedRuntimeDocument[]> {
270
+ ): Promise<RenderedRuntimeDocuments> {
265
271
  const plugins = registrations.map((entry) => normalizeRegistration(entry).plugin);
266
- return renderDocumentsForTarget(ctx, await collectContributionInputs(ctx, plugins, plan));
272
+ const documents = renderDocumentsForTarget(
273
+ ctx,
274
+ await collectContributionInputs(ctx, plugins, plan),
275
+ );
276
+ if (ctx.target !== "working") return { documents, warnings: [] };
277
+ return mergeCompanionOpenCodeConfig(ctx.companionPath, documents);
267
278
  }
@@ -81,7 +81,7 @@ A \`diff\` section carries a unified diff patch as produced by \`git diff\`. The
81
81
  { "id": "changes", "title": "Changes", "type": "diff", "patch": "diff --git a/src/acme.ts b/src/acme.ts\\n..." }
82
82
  \`\`\`
83
83
 
84
- A report carrying a mermaid payload inlines the diagram runtime; a report without one does not. Either way the page stays a single self-contained file that makes no network request.
84
+ A report carrying a mermaid payload loads the pinned, integrity-checked diagram runtime from a CDN; a report without one does not. Offline, each diagram stays readable as its source. Otherwise the page stays a single self-contained file.
85
85
 
86
86
  ## Invocation
87
87
 
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { hookGroupKey, type ClaudeHookGroup } from "../../../runtime/claude-hook-group";
5
+
4
6
  // Structurally matches `McpServerDescriptor` from ../plugin; declared here so
5
7
  // the format module stays dependency-free of the plugin contract (which
6
8
  // imports hook types from this module).
@@ -17,17 +19,11 @@ export interface McpEntryDescriptor {
17
19
  // groups are Mate-managed lives here too, because the companion-side reconcile
18
20
  // and the working-repo projection entry must strip the same set.
19
21
 
20
- export interface ClaudeHookCommand {
21
- type?: string;
22
- command?: string;
23
- args?: string[];
24
- timeout?: number;
25
- }
26
-
27
- export interface ClaudeHookGroup {
28
- matcher?: string;
29
- hooks?: ClaudeHookCommand[];
30
- }
22
+ export {
23
+ hookGroupKey,
24
+ type ClaudeHookCommand,
25
+ type ClaudeHookGroup,
26
+ } from "../../../runtime/claude-hook-group";
31
27
 
32
28
  export type ClaudeHookMap = Record<string, ClaudeHookGroup[]>;
33
29
 
@@ -43,10 +39,6 @@ export interface ClaudeMcpConfig {
43
39
  [key: string]: unknown;
44
40
  }
45
41
 
46
- function hookGroupKey(group: ClaudeHookGroup): string {
47
- return JSON.stringify(group);
48
- }
49
-
50
42
  /** Append incoming hook groups per event, skipping structurally identical ones. */
51
43
  export function mergeClaudeHookGroups(
52
44
  existingHooks: ClaudeHookMap,
@@ -84,9 +76,10 @@ export function filterClaudeHookGroups(
84
76
 
85
77
  // Command substrings that mark a hook group as Mate-managed. The mate plugin
86
78
  // hooks (validate-artifact-path, mate-session-banner) now ship in the bundled
87
- // Claude plugin, and the retired mate-artifact-finish.sh archive hook is gone
88
- // entirely; their markers are retained migration-only so stale managed groups
89
- // written by earlier releases keep being stripped, and are never re-added.
79
+ // Claude plugin, and the retired mate-artifact-finish.sh archive hook and
80
+ // react-doctor.sh post-edit scan are gone entirely; their markers are retained
81
+ // migration-only so stale managed groups written by earlier releases keep being
82
+ // stripped, and are never re-added.
90
83
  const MANAGED_HOOK_MARKERS = [
91
84
  "validate-artifact-path",
92
85
  "mate-session-banner",
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
 
4
4
  import { PROJECTED_BANNER_FLAG } from "../../../hooks/session-banner";
5
5
  import { getClaudePluginRoot } from "../../../lib/package-paths";
6
- import type { ClaudeHookGroup } from "./claude-format";
6
+ import { runtimeClaudePluginRoot } from "../../../runtime/runtime-directory";
7
+ import { hookGroupKey, type ClaudeHookGroup } from "./claude-format";
7
8
 
8
9
  /**
9
10
  * The bundled Claude plugin's hook wiring, read as a value. A managed launch
@@ -17,11 +18,16 @@ import type { ClaudeHookGroup } from "./claude-format";
17
18
  * environment is present and so cannot double the prompt the launch already
18
19
  * appended; and the banner, whose carried copy is marked so it defers to the
19
20
  * launch's.
21
+ *
22
+ * The wiring is read from the installed plugin, but every command names
23
+ * `<mate home>/runtime/claude-plugin`, so a reinstall or Node switch does not
24
+ * strand a wrapped repository.
20
25
  */
21
26
 
22
27
  const GUARD_HOOK_SHIM = "validate-artifact-path.mjs";
23
28
  const GUIDANCE_HOOK_SHIM = "session-guidance.mjs";
24
29
  const BANNER_HOOK_SHIM = "session-banner.mjs";
30
+ const PREPARE_HOOK_SHIM = "session-prepare.mjs";
25
31
  const PLUGIN_ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
26
32
 
27
33
  interface PluginHookWiring {
@@ -50,26 +56,24 @@ function resolveCommands(group: ClaudeHookGroup, pluginRoot: string): ClaudeHook
50
56
  };
51
57
  }
52
58
 
53
- function runsShim(shim: string): (group: ClaudeHookGroup) => boolean {
54
- return (group) => (group.hooks ?? []).some((hook) => (hook.command ?? "").includes(shim));
59
+ function runsShim(group: ClaudeHookGroup, shim: string): boolean {
60
+ return (group.hooks ?? []).some((hook) => (hook.command ?? "").includes(shim));
55
61
  }
56
62
 
57
- function groupsFor(
58
- event: string,
59
- shim: string,
60
- pluginRoot: string = getClaudePluginRoot(),
61
- ): ClaudeHookGroup[] {
62
- return (readPluginHookWiring(pluginRoot).hooks?.[event] ?? [])
63
- .filter(runsShim(shim))
64
- .map((group) => resolveCommands(group, pluginRoot));
63
+ function groupsFor(event: string, shim: string, commandRoot: string): ClaudeHookGroup[] {
64
+ return (readPluginHookWiring(getClaudePluginRoot()).hooks?.[event] ?? []).flatMap((group) =>
65
+ runsShim(group, shim) ? [resolveCommands(group, commandRoot)] : [],
66
+ );
65
67
  }
66
68
 
67
69
  /**
68
70
  * The guard's `PreToolUse` groups with `${CLAUDE_PLUGIN_ROOT}` resolved against
69
- * the running installation, so the command works with no launch environment.
71
+ * the runtime link, so the command works with no launch environment.
70
72
  */
71
- export function mateGuardHookGroups(pluginRoot: string = getClaudePluginRoot()): ClaudeHookGroup[] {
72
- return groupsFor("PreToolUse", GUARD_HOOK_SHIM, pluginRoot);
73
+ export function mateGuardHookGroups(
74
+ commandRoot: string = runtimeClaudePluginRoot(),
75
+ ): ClaudeHookGroup[] {
76
+ return groupsFor("PreToolUse", GUARD_HOOK_SHIM, commandRoot);
73
77
  }
74
78
 
75
79
  /**
@@ -79,9 +83,9 @@ export function mateGuardHookGroups(pluginRoot: string = getClaudePluginRoot()):
79
83
  * flag.
80
84
  */
81
85
  export function mateGuidanceHookGroups(
82
- pluginRoot: string = getClaudePluginRoot(),
86
+ commandRoot: string = runtimeClaudePluginRoot(),
83
87
  ): ClaudeHookGroup[] {
84
- return groupsFor("SessionStart", GUIDANCE_HOOK_SHIM, pluginRoot);
88
+ return groupsFor("SessionStart", GUIDANCE_HOOK_SHIM, commandRoot);
85
89
  }
86
90
 
87
91
  /**
@@ -91,9 +95,9 @@ export function mateGuidanceHookGroups(
91
95
  * would print the banner twice.
92
96
  */
93
97
  export function mateBannerHookGroups(
94
- pluginRoot: string = getClaudePluginRoot(),
98
+ commandRoot: string = runtimeClaudePluginRoot(),
95
99
  ): ClaudeHookGroup[] {
96
- return groupsFor("SessionStart", BANNER_HOOK_SHIM, pluginRoot).map((group) => ({
100
+ return groupsFor("SessionStart", BANNER_HOOK_SHIM, commandRoot).map((group) => ({
97
101
  ...group,
98
102
  hooks: (group.hooks ?? []).map((hook) => ({
99
103
  ...hook,
@@ -102,16 +106,49 @@ export function mateBannerHookGroups(
102
106
  }));
103
107
  }
104
108
 
109
+ /**
110
+ * The session-prepare `SessionStart` group, resolved the same way. Its declared
111
+ * `timeout` sits above the shim's own bound, so Claude Code never kills a
112
+ * prepare the shim has not given up on. Inert under a launch environment.
113
+ */
114
+ export function mateSessionPrepareHookGroups(
115
+ commandRoot: string = runtimeClaudePluginRoot(),
116
+ ): ClaudeHookGroup[] {
117
+ return groupsFor("SessionStart", PREPARE_HOOK_SHIM, commandRoot);
118
+ }
119
+
120
+ function mateOwnHookGroups(commandRoot: string): ClaudeHookGroup[] {
121
+ return [
122
+ ...mateGuardHookGroups(commandRoot),
123
+ ...mateGuidanceHookGroups(commandRoot),
124
+ ...mateBannerHookGroups(commandRoot),
125
+ ...mateSessionPrepareHookGroups(commandRoot),
126
+ ];
127
+ }
128
+
129
+ const ownGroupKeys = new Map<string, ReadonlySet<string>>();
130
+
131
+ /** Memoized per plugin-root pair; hooks are short-lived, so a refresh mid-process is not tracked. */
132
+ function mateOwnHookGroupKeys(runtimeRoot: string, installRoot: string): ReadonlySet<string> {
133
+ const memoKey = `${runtimeRoot}\0${installRoot}`;
134
+ let keys = ownGroupKeys.get(memoKey);
135
+ if (!keys) {
136
+ keys = new Set(
137
+ [...mateOwnHookGroups(runtimeRoot), ...mateOwnHookGroups(installRoot)].map(hookGroupKey),
138
+ );
139
+ ownGroupKeys.set(memoKey, keys);
140
+ }
141
+ return keys;
142
+ }
143
+
105
144
  /**
106
145
  * Whether a hook group is one the working target placed. It matches a managed
107
146
  * marker, so the entry that strips managed groups from a Working Repository has
108
- * to be told to leave these alone.
147
+ * to be told to leave these alone. Both the runtime-link form and the
148
+ * install-path form an older release wrote are recognized.
109
149
  */
110
150
  export function isMateGuardHookGroup(group: ClaudeHookGroup): boolean {
111
- const own = new Set(
112
- [...mateGuardHookGroups(), ...mateGuidanceHookGroups(), ...mateBannerHookGroups()].map(
113
- (candidate) => JSON.stringify(candidate),
114
- ),
151
+ return mateOwnHookGroupKeys(runtimeClaudePluginRoot(), getClaudePluginRoot()).has(
152
+ hookGroupKey(group),
115
153
  );
116
- return own.has(JSON.stringify(group));
117
154
  }
@@ -89,6 +89,10 @@ function getBaseManagedPermissionEntries(companionPath: string): string[] {
89
89
  const LEGACY_MANAGED_PERMISSION_ENTRIES = [
90
90
  "Bash($MATE_COMPANION_BIN_PATH/openspec:*)",
91
91
  "Bash($MATE_COMPANION_BIN_PATH/graphify:*)",
92
+ // React Doctor's pre-seeds before it moved to the pinned mate-react-doctor skill.
93
+ "Skill(react-doctor)",
94
+ "Bash(npx react-doctor:*)",
95
+ "Bash(npx react-doctor@latest *)",
92
96
  ];
93
97
 
94
98
  function getAllManagedPermissionEntries(companionPath: string): Set<string> {
@@ -102,16 +106,12 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
102
106
  ]);
103
107
  }
104
108
 
105
- // Resolved path of the companion-owned Claude settings document. This is the
106
- // single source of truth for Mate-managed Claude hooks, permissions, and MCP
107
- // servers; it is loaded at launch via `claude --settings`.
109
+ /** Companion-owned Claude settings: sole source of Mate-managed hooks, permissions and MCP servers. */
108
110
  export function getCompanionClaudeSettingsPath(companionPath: string): string {
109
111
  return path.join(companionPath, ".claude", "settings.local.json");
110
112
  }
111
113
 
112
- // Resolved path of the companion-owned `.mcp.json`. Mate writes Mate-managed
113
- // MCP servers here and loads it at launch via `claude --mcp-config`, keeping
114
- // the MCP surface out of the working repo and out of settings.local.json.
114
+ /** Companion-owned `.mcp.json`; loaded via `--mcp-config` only by a companion-scoped launch. */
115
115
  export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
116
116
  return path.join(companionPath, ".mcp.json");
117
117
  }
@@ -147,6 +147,23 @@ export function renderManagedClaudeMcpServers(
147
147
  return servers;
148
148
  }
149
149
 
150
+ /**
151
+ * Enabled Capabilities' Claude hook groups per event, in declared order, as a
152
+ * value: what a Claude wrap carries into the Working Repository.
153
+ */
154
+ export function renderManagedClaudeHookGroups(
155
+ contributions: CapabilityContributionInput[] = [],
156
+ ): Record<string, ClaudeHookGroup[]> {
157
+ const hooks: Record<string, ClaudeHookGroup[]> = {};
158
+ for (const input of contributions) {
159
+ if (!input.enabled) continue;
160
+ for (const hook of input.contributions.hookGroups ?? []) {
161
+ (hooks[hook.event] ??= []).push(hook.group);
162
+ }
163
+ }
164
+ return hooks;
165
+ }
166
+
150
167
  // Reconcile the Mate-managed Claude settings shape (hooks, permissions, MCP
151
168
  // servers) on top of whatever already exists in the settings document. Managed
152
169
  // groups/entries always lead their arrays so the emitted shape stays stable