@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1

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 (186) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +7 -5
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/cap/tokensave.ts +4 -4
  19. package/src/cli/commands/companion/companion.ts +5 -1
  20. package/src/cli/commands/companion/link.ts +2 -2
  21. package/src/cli/commands/companion/sync.ts +92 -0
  22. package/src/cli/commands/doctor.ts +0 -3
  23. package/src/cli/commands/launch/shared.ts +23 -5
  24. package/src/cli/commands/report/collector.ts +72 -78
  25. package/src/cli/commands/report/contract.ts +40 -1
  26. package/src/cli/commands/report/highlight.ts +27 -0
  27. package/src/cli/commands/report/index.ts +11 -18
  28. package/src/cli/commands/report/renderer.ts +199 -2
  29. package/src/cli/commands/report/types.ts +26 -1
  30. package/src/cli/commands/shared/companion-selection.ts +107 -10
  31. package/src/cli/commands/studio/areas.ts +68 -0
  32. package/src/cli/commands/studio/index.ts +69 -0
  33. package/src/cli/commands/studio/inventory.ts +55 -0
  34. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  35. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  36. package/src/cli/commands/studio/payload.ts +184 -0
  37. package/src/cli/commands/studio/routes.ts +2 -0
  38. package/src/cli/commands/studio/selection.ts +61 -0
  39. package/src/cli/commands/studio/server.ts +201 -0
  40. package/src/cli/commands/studio/snapshot.ts +63 -0
  41. package/src/cli/commands/studio/topology.ts +199 -0
  42. package/src/cli/commands/studio/views/client.ts +197 -0
  43. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  44. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  46. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  47. package/src/cli/commands/studio/views/document.tsx +256 -0
  48. package/src/cli/commands/studio/views/error.tsx +20 -0
  49. package/src/cli/commands/studio/views/model.ts +34 -0
  50. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  51. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  52. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  53. package/src/cli/commands/studio/views/styles.ts +389 -0
  54. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  56. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  57. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  58. package/src/cli/commands/unwrap.ts +70 -0
  59. package/src/cli/commands/wrap.ts +164 -0
  60. package/src/cli/main.ts +68 -19
  61. package/src/cli/parse-flags.ts +36 -11
  62. package/src/cli/usage.ts +12 -3
  63. package/src/framework.ts +1 -7
  64. package/src/hooks/session-banner.ts +64 -11
  65. package/src/hooks/session-guidance.ts +40 -0
  66. package/src/hooks/validate-artifact-path.ts +108 -35
  67. package/src/lib/fs-utils.ts +9 -0
  68. package/src/lib/install.ts +33 -0
  69. package/src/lib/orchestrator/adapters/base.ts +14 -125
  70. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  71. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  72. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  73. package/src/lib/orchestrator/config-store.ts +2 -21
  74. package/src/lib/orchestrator/editor.ts +12 -22
  75. package/src/lib/orchestrator/framework-context.ts +17 -6
  76. package/src/lib/orchestrator/global-config-store.ts +1 -1
  77. package/src/lib/orchestrator/launcher.ts +97 -7
  78. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  79. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  80. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  81. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  82. package/src/lib/orchestrator/projection-entries.ts +377 -0
  83. package/src/lib/orchestrator/projection-record.ts +56 -0
  84. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  85. package/src/lib/orchestrator/projection-types.ts +169 -0
  86. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  87. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  88. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  89. package/src/lib/orchestrator/types.ts +1 -0
  90. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  91. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  92. package/src/lib/package-paths.ts +11 -1
  93. package/src/lib/public-npm.ts +2 -1
  94. package/src/lib/update-checker.ts +15 -9
  95. package/src/opencode/companion-hooks.ts +89 -245
  96. package/src/opencode/companion-policy.ts +35 -10
  97. package/src/opencode/index.ts +1 -0
  98. package/src/opencode/projected-guidance.ts +56 -0
  99. package/src/opencode/tui.tsx +13 -4
  100. package/src/playbooks/companion-guidance.ts +32 -116
  101. package/src/plugins.ts +0 -1
  102. package/src/runtime/companion-git-state.ts +156 -0
  103. package/src/runtime/companion-git.ts +203 -0
  104. package/src/runtime/companion-guidance.ts +222 -0
  105. package/src/runtime/companion-sync.ts +298 -0
  106. package/src/runtime/env-names.ts +30 -0
  107. package/src/runtime/env.ts +67 -35
  108. package/src/runtime/framework.ts +10 -0
  109. package/src/runtime/freshness.ts +58 -0
  110. package/src/runtime/index.ts +104 -0
  111. package/src/runtime/install.ts +30 -0
  112. package/src/runtime/policy.ts +66 -0
  113. package/src/runtime/projected-guidance.ts +45 -0
  114. package/src/runtime/projection.ts +224 -0
  115. package/src/runtime/repo-local.ts +64 -0
  116. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  117. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  118. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  119. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  122. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  123. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  124. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  125. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  126. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  127. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  128. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  129. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  130. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  131. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  132. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  133. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  134. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
  135. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  136. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  137. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  138. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  139. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  140. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  141. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  142. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  143. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  144. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  145. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  146. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
  147. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  148. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  149. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  150. package/src/templates/report-assets/README.md +32 -0
  151. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  152. package/src/templates/report-assets/mermaid.min.js +4376 -0
  153. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  154. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  155. package/src/tools/setup/capabilities/graphify.ts +16 -7
  156. package/src/tools/setup/capabilities/openspec.ts +63 -56
  157. package/src/tools/setup/capabilities/tokensave.ts +116 -2
  158. package/src/tools/setup/engine.ts +34 -6
  159. package/src/tools/setup/mate.ts +42 -13
  160. package/src/tools/setup/plugin.ts +9 -0
  161. package/src/tools/setup/plugins/guidance.ts +11 -1
  162. package/src/tools/setup/providers/claude-format.ts +49 -4
  163. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  164. package/src/tools/setup/providers/claude.ts +55 -220
  165. package/src/tools/setup/providers/opencode.ts +41 -14
  166. package/src/tools/setup/runtime-documents.ts +174 -0
  167. package/src/tools/setup/surface-target.ts +50 -0
  168. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  169. package/src/tools/setup/working-repo-local-state.ts +21 -1
  170. package/src/tools/setup.ts +25 -3
  171. package/wrappers/bin/graphify +57 -8
  172. package/wrappers/bin/openspec +50 -3
  173. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  174. package/src/cli/commands/cap/headroom.ts +0 -52
  175. package/src/cli/commands/workspace/list.ts +0 -25
  176. package/src/cli/commands/workspace/materialize.ts +0 -46
  177. package/src/cli/commands/workspace/workspace.ts +0 -22
  178. package/src/hooks/artifact-finish-nudge.ts +0 -244
  179. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  180. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  181. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  182. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  183. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  184. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  185. package/src/tools/setup/capabilities/headroom.ts +0 -57
  186. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  upsertManagedBlock,
19
19
  } from "../context-services";
20
20
  import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
21
+ import { surfaceRoot } from "../surface-target";
21
22
  import { mergeDir, pruneEmptyAncestors } from "../utils";
22
23
  import {
23
24
  getOpenCodePluginReferences,
@@ -424,22 +425,23 @@ export async function reconcileOpenCodeContributions(
424
425
  ctx: SetupContext,
425
426
  inputs: CapabilityContributionInput[],
426
427
  ): Promise<void> {
427
- const { companionPath } = ctx;
428
+ /** Resolved once per pass; every write site below is relative to it. */
429
+ const root = surfaceRoot(ctx);
428
430
 
429
431
  if (ctx.scope === "hub") {
430
- await reconcileOpenCodeMcpContributions(ctx, inputs);
431
- await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
432
+ await reconcileOpenCodeMcpContributions(root, inputs);
433
+ await reconcileOpenCodeAgentDefinitionContributions(root, inputs);
432
434
  return;
433
435
  }
434
436
 
435
- await reconcileOpenCodeMcpContributions(ctx, inputs);
436
- await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
437
+ await reconcileOpenCodeMcpContributions(root, inputs);
438
+ await reconcileOpenCodeAgentDefinitionContributions(root, inputs);
437
439
 
438
440
  for (const input of inputs) {
439
441
  for (const pluginReference of input.contributions.pluginReferences ?? []) {
440
442
  const configFiles = pluginReference.configFiles ?? OPENCODE_CONTRIBUTION_CONFIG_FILES;
441
443
  for (const name of configFiles) {
442
- const configPath = path.join(companionPath, ".opencode", name);
444
+ const configPath = path.join(root, ".opencode", name);
443
445
  const { present, config } = await readOpenCodeConfig(configPath);
444
446
  if (!input.enabled && !present) continue;
445
447
  const preserved = getOpenCodePluginReferences(config).filter(
@@ -456,7 +458,7 @@ export async function reconcileOpenCodeContributions(
456
458
  // Guidance sections are managed blocks in AGENTS.md. Capability disable
457
459
  // strips them here; runtime teardown of the shared AGENTS.md is guarded in
458
460
  // the provider teardown paths (kept while another active runtime uses it).
459
- const guidancePath = path.join(companionPath, "AGENTS.md");
461
+ const guidancePath = path.join(root, "AGENTS.md");
460
462
  const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
461
463
  const keepKeys = new Set<string>();
462
464
  for (const section of sections) {
@@ -469,12 +471,12 @@ export async function reconcileOpenCodeContributions(
469
471
  }
470
472
 
471
473
  for (const skillTree of input.contributions.skillTrees ?? []) {
472
- const skillDir = path.join(companionPath, ".opencode", "skills", skillTree.name);
474
+ const skillDir = path.join(root, ".opencode", "skills", skillTree.name);
473
475
  if (input.enabled) {
474
476
  await mergeDir(skillTree.sourceDir, skillDir);
475
477
  } else {
476
478
  await fs.rm(skillDir, { recursive: true, force: true });
477
- await pruneEmptyAncestors(path.join(companionPath, ".opencode", "skills"), companionPath);
479
+ await pruneEmptyAncestors(path.join(root, ".opencode", "skills"), root);
478
480
  }
479
481
  }
480
482
  }
@@ -482,13 +484,13 @@ export async function reconcileOpenCodeContributions(
482
484
 
483
485
  /** Reconcile only MCP entries without touching OpenCode plugins or guidance. */
484
486
  async function reconcileOpenCodeMcpContributions(
485
- ctx: SetupContext,
487
+ root: string,
486
488
  inputs: CapabilityContributionInput[],
487
489
  ): Promise<void> {
488
490
  for (const input of inputs) {
489
491
  for (const descriptor of input.contributions.mcpServers ?? []) {
490
492
  await updateOpenCodeMcpServer(
491
- getCompanionOpenCodeConfigPath(ctx.companionPath),
493
+ getCompanionOpenCodeConfigPath(root),
492
494
  descriptor.name,
493
495
  input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
494
496
  );
@@ -496,6 +498,31 @@ async function reconcileOpenCodeMcpContributions(
496
498
  }
497
499
  }
498
500
 
501
+ /** The Mate-managed `mcp` map of one pass, as a value. */
502
+ export function renderManagedOpenCodeMcpServers(
503
+ contributions: CapabilityContributionInput[] = [],
504
+ ): Record<string, unknown> {
505
+ const servers: Record<string, unknown> = {};
506
+ for (const input of contributions) {
507
+ if (!input.enabled) continue;
508
+ for (const descriptor of input.contributions.mcpServers ?? []) {
509
+ servers[descriptor.name] = toOpenCodeMcpEntry(descriptor);
510
+ }
511
+ }
512
+ return servers;
513
+ }
514
+
515
+ /**
516
+ * The Mate-managed `permission.external_directory` map, as a value. Rendered
517
+ * once for both destinations a session can reach it through — the projected
518
+ * document and the launch environment — so the two cannot drift.
519
+ */
520
+ export function renderCompanionExternalDirectoryPermissions(
521
+ companionPath: string,
522
+ ): Record<string, string> {
523
+ return { [companionPath]: "allow", [`${companionPath}/**`]: "allow" };
524
+ }
525
+
499
526
  /**
500
527
  * Reconcile declared agent definition files under `.opencode/agents/`. Runs
501
528
  * in both companion and hub scope — an agent definition is fully
@@ -503,10 +530,10 @@ async function reconcileOpenCodeMcpContributions(
503
530
  * surface.
504
531
  */
505
532
  async function reconcileOpenCodeAgentDefinitionContributions(
506
- ctx: SetupContext,
533
+ root: string,
507
534
  inputs: CapabilityContributionInput[],
508
535
  ): Promise<void> {
509
- const agentsDir = path.join(ctx.companionPath, ".opencode", "agents");
536
+ const agentsDir = path.join(root, ".opencode", "agents");
510
537
  for (const input of inputs) {
511
538
  for (const agent of input.contributions.agentDefinitions ?? []) {
512
539
  const agentPath = path.join(agentsDir, `${agent.name}.md`);
@@ -515,7 +542,7 @@ async function reconcileOpenCodeAgentDefinitionContributions(
515
542
  await fs.writeFile(agentPath, agent.content, "utf8");
516
543
  } else {
517
544
  await fs.rm(agentPath, { force: true });
518
- await pruneEmptyAncestors(agentsDir, ctx.companionPath);
545
+ await pruneEmptyAncestors(agentsDir, root);
519
546
  }
520
547
  }
521
548
  }
@@ -0,0 +1,174 @@
1
+ import {
2
+ CLAUDE_LOCAL_CONFIG_DOCUMENT,
3
+ CLAUDE_MCP_DOCUMENT,
4
+ CLAUDE_SETTINGS_DOCUMENT,
5
+ OPENCODE_CONFIG_DOCUMENT,
6
+ } from "../../lib/orchestrator/projection-runtime-documents";
7
+ import type {
8
+ ManagedRegion,
9
+ RenderedRuntimeDocument,
10
+ } from "../../lib/orchestrator/projection-types";
11
+ import { getOpenCodePluginPackageReference } from "../../lib/opencode-plugin-package";
12
+ import type { CapabilityContributionInput, SetupContext } from "./plugin";
13
+ import {
14
+ mateBannerHookGroups,
15
+ mateGuardHookGroups,
16
+ mateGuidanceHookGroups,
17
+ } from "./providers/claude-plugin-hooks";
18
+ import {
19
+ renderManagedClaudeMcpServers,
20
+ renderManagedClaudePermissionEntries,
21
+ } from "./providers/claude";
22
+ import {
23
+ renderCompanionExternalDirectoryPermissions,
24
+ renderManagedOpenCodeMcpServers,
25
+ } from "./providers/opencode";
26
+ import { projectionRepoRoot } from "./surface-target";
27
+
28
+ /**
29
+ * The working target's documents, rendered as values. Each is a document the
30
+ * runtime finds by walking up from the current directory, and every path inside
31
+ * one still names the Companion Repository — only where the document is placed
32
+ * differs from the companion target.
33
+ */
34
+
35
+ export {
36
+ CLAUDE_LOCAL_CONFIG_DOCUMENT,
37
+ CLAUDE_MCP_DOCUMENT,
38
+ CLAUDE_SETTINGS_DOCUMENT,
39
+ OPENCODE_CONFIG_DOCUMENT,
40
+ };
41
+
42
+ function nonEmpty(region: ManagedRegion): boolean {
43
+ if (region.kind === "list") return region.values.length > 0;
44
+ if (region.kind === "map") return Object.keys(region.entries).length > 0;
45
+ return true;
46
+ }
47
+
48
+ function document(documentPath: string, regions: ManagedRegion[]): RenderedRuntimeDocument[] {
49
+ const kept = regions.filter(nonEmpty);
50
+ return kept.length > 0 ? [{ path: documentPath, regions: kept }] : [];
51
+ }
52
+
53
+ /**
54
+ * The Mate plugin reference is what makes an Unmanaged OpenCode session load
55
+ * the companion guidance at all: OpenCode has no config channel for a generated
56
+ * system prompt, so the plugin is the only injection point, and a bare session
57
+ * loads no companion config to find it in.
58
+ *
59
+ * A managed launch does load the plugin twice — its `OPENCODE_CONFIG_DIR` names
60
+ * the companion, whose config carries the same reference. That is inert rather
61
+ * than doubled: the plugin skips its own emission when the companion-policy
62
+ * marker is already in the prompt.
63
+ */
64
+
65
+ /**
66
+ * Capability hook groups stay companion-only. A managed session loads this
67
+ * document as its `local` source *alongside* the companion's, so anything here
68
+ * that the launch also delivers runs twice; the guard, the guidance hook and
69
+ * the banner are the contributions whose absence is a correctness hole and
70
+ * whose double run is inert — the guard because its verdict depends only on the
71
+ * tool input, the guidance hook because it yields nothing under a launch
72
+ * environment, and the banner because the copy carried here is flagged
73
+ * `--projected` and defers to the one the launch's own plugin prints.
74
+ *
75
+ * `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` is what a managed launch sets
76
+ * beside `--add-dir`. Without it the companion directories this document
77
+ * already permits are reachable but their `CLAUDE.md` is not loaded, so the
78
+ * permission alone delivers no guidance.
79
+ */
80
+ function claudeSettingsRegions(
81
+ ctx: SetupContext,
82
+ inputs: CapabilityContributionInput[],
83
+ ): ManagedRegion[] {
84
+ return [
85
+ { at: ["hooks", "PreToolUse"], kind: "list", values: mateGuardHookGroups() },
86
+ {
87
+ at: ["hooks", "SessionStart"],
88
+ kind: "list",
89
+ values: [...mateBannerHookGroups(), ...mateGuidanceHookGroups()],
90
+ },
91
+ /**
92
+ * `stripPrefix` heals a repository an older Mate wrapped. That version wrote
93
+ * the whole launch environment here, and Claude Code applies `env` to the
94
+ * session, so every hook in a bare session inherited `MATE_*` and read the
95
+ * repository as a Managed Session — silently, since looking managed is
96
+ * exactly what makes the guidance hook emit nothing. Guidance must be the
97
+ * projection's to give in an Unmanaged Session, so no `MATE_*` key may
98
+ * survive here.
99
+ */
100
+ {
101
+ at: ["env"],
102
+ kind: "map",
103
+ entries: { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" },
104
+ stripPrefix: "MATE_",
105
+ },
106
+ {
107
+ at: ["permissions", "allow"],
108
+ kind: "list",
109
+ values: renderManagedClaudePermissionEntries(ctx.companionPath, inputs),
110
+ },
111
+ ];
112
+ }
113
+
114
+ export function renderDocumentsForTarget(
115
+ ctx: SetupContext,
116
+ inputsByRuntime: Map<string, CapabilityContributionInput[]>,
117
+ ): RenderedRuntimeDocument[] {
118
+ const repoRoot = projectionRepoRoot(ctx);
119
+
120
+ const documents: RenderedRuntimeDocument[] = [];
121
+ if (ctx.activeProviders.includes("claude")) {
122
+ const inputs = inputsByRuntime.get("claude") ?? [];
123
+ documents.push(...document(CLAUDE_SETTINGS_DOCUMENT, claudeSettingsRegions(ctx, inputs)));
124
+ /**
125
+ * Local scope, not `.mcp.json`: a project-scoped server is inert until a
126
+ * human approves it in a session, which an Unmanaged Session is exactly the
127
+ * case that cannot rely on. The repo path is the key Claude Code files a
128
+ * project under, resolved so a symlinked working directory still matches.
129
+ *
130
+ * Confirmed against a real `~/.claude.json`: the keys there are the
131
+ * directories sessions started in, not repository roots — sibling
132
+ * directories of one checkout each hold their own entry, as do temporary
133
+ * directories. So this reaches exactly one session: the one started at the
134
+ * repository root. A session started in a subdirectory of a wrapped
135
+ * repository has its own key, finds no `mcpServers` under it, and sees none
136
+ * of these servers — the Unmanaged Session case this document exists for,
137
+ * missed. That limit is deliberate. The directories a session might start
138
+ * in are unbounded, and every one of them would be a fresh entry in the
139
+ * user's global configuration that nothing later reclaims; keying the
140
+ * repository root only keeps the projection's footprint there to a single
141
+ * key. A subdirectory session still gets the rest of the projection — the
142
+ * documents inside the repository are found by the runtime's own upward
143
+ * walk — it loses only the MCP servers.
144
+ */
145
+ documents.push(
146
+ ...document(CLAUDE_LOCAL_CONFIG_DOCUMENT, [
147
+ {
148
+ at: ["projects", repoRoot, "mcpServers"],
149
+ kind: "map",
150
+ entries: renderManagedClaudeMcpServers(inputs),
151
+ },
152
+ ]),
153
+ );
154
+ }
155
+ if (ctx.activeProviders.includes("opencode")) {
156
+ const inputs = inputsByRuntime.get("opencode") ?? [];
157
+ documents.push(
158
+ ...document(OPENCODE_CONFIG_DOCUMENT, [
159
+ {
160
+ at: ["plugin"],
161
+ kind: "list",
162
+ values: [getOpenCodePluginPackageReference()],
163
+ },
164
+ { at: ["mcp"], kind: "map", entries: renderManagedOpenCodeMcpServers(inputs) },
165
+ {
166
+ at: ["permission", "external_directory"],
167
+ kind: "map",
168
+ entries: renderCompanionExternalDirectoryPermissions(ctx.companionPath),
169
+ },
170
+ ]),
171
+ );
172
+ }
173
+ return documents;
174
+ }
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+
3
+ import type { ProjectionTarget, SetupContext } from "./plugin";
4
+
5
+ /**
6
+ * The projection target of a reconcile pass, resolved once. A `working` pass
7
+ * without a Working Repository in scope refuses rather than falling back to the
8
+ * companion: writing the other root is the one outcome the caller cannot detect.
9
+ */
10
+ export function projectionTarget(ctx: SetupContext): ProjectionTarget {
11
+ const target = ctx.target ?? "companion";
12
+ if (target === "working" && !ctx.repoPath) {
13
+ throw new Error(
14
+ "a working-target reconcile needs a Working Repository in scope; none was supplied",
15
+ );
16
+ }
17
+ return target;
18
+ }
19
+
20
+ /**
21
+ * The Working Repository a rendered document is addressed by, resolved so a
22
+ * symlinked working directory still matches what the runtime reports. Every
23
+ * document a reconcile pass renders is for a Working Repository, so a pass with
24
+ * none in scope refuses here rather than resolving an absent path: `path.resolve`
25
+ * answers the current working directory for an empty one, and a document keyed
26
+ * by wherever the process happened to start would file a Runtime Surface's MCP
27
+ * servers under a stranger's project in the user's global Claude config — a
28
+ * write no caller asked for and none would notice.
29
+ */
30
+ export function projectionRepoRoot(ctx: SetupContext): string {
31
+ const target = projectionTarget(ctx);
32
+ if (!ctx.repoPath) {
33
+ throw new Error(
34
+ `a ${target}-target render needs a Working Repository in scope to address its documents by; none was supplied`,
35
+ );
36
+ }
37
+ return path.resolve(ctx.repoPath);
38
+ }
39
+
40
+ /**
41
+ * The root a Runtime Surface writes into. Defined for the companion target
42
+ * only: a working-target pass renders its documents for the Projection Root to
43
+ * place, so no Runtime Surface ever computes a path inside a Working Repository.
44
+ */
45
+ export function surfaceRoot(ctx: SetupContext): string {
46
+ if (projectionTarget(ctx) === "working") {
47
+ throw new Error("a working-target reconcile renders its documents; it writes no surface root");
48
+ }
49
+ return ctx.companionPath;
50
+ }
@@ -1,40 +1,47 @@
1
- import fs from "node:fs/promises";
2
-
3
- import { repoLocalDirPath } from "../../lib/orchestrator/repo-local-registry";
4
- import { cleanupWorkingRepoClaudeSettings } from "./providers/claude";
5
- import { removeWorkingRepoLocalExcludes } from "./working-repo-local-state";
1
+ import { unproject } from "../../lib/orchestrator/working-repo-projection";
2
+ import type { ProjectionEntryId } from "../../lib/orchestrator/projection-types";
6
3
 
7
4
  export interface WorkingRepoCleanupResult {
8
5
  changed: boolean;
9
6
  removed: string[];
10
7
  updated: string[];
8
+ /** Entries `unproject` deliberately left in place, named rather than omitted. */
9
+ retained: string[];
11
10
  }
12
11
 
13
- async function pathExists(candidatePath: string): Promise<boolean> {
14
- try {
15
- await fs.access(candidatePath);
16
- return true;
17
- } catch {
18
- return false;
19
- }
20
- }
12
+ /**
13
+ * How an entry is reported. Presentation only — cleanup holds no list of paths,
14
+ * so a newly declared entry is removed without touching this map.
15
+ */
16
+ const REPORTED_AS: Partial<
17
+ Record<ProjectionEntryId, { as: "removed" | "updated"; label: string }>
18
+ > = {
19
+ "projection-root": { as: "removed", label: ".mate" },
20
+ "git-excludes": { as: "removed", label: "git-excludes" },
21
+ "claude-working-settings": { as: "updated", label: "claude-settings" },
22
+ "legacy-tokensave-claude-md": { as: "updated", label: "claude-settings" },
23
+ };
21
24
 
22
25
  export async function cleanupWorkingRepository(
23
26
  repoPath: string,
24
- registeredCompanionPaths: string[],
27
+ registeredCompanionPaths: string[] = [],
25
28
  ): Promise<WorkingRepoCleanupResult> {
26
- const updated: string[] = [];
29
+ const { outcomes } = await unproject({ repoPath, registeredCompanionPaths });
27
30
  const removed: string[] = [];
28
- if (await cleanupWorkingRepoClaudeSettings(repoPath, registeredCompanionPaths)) {
29
- updated.push("claude-settings");
30
- }
31
- if (await removeWorkingRepoLocalExcludes(repoPath)) {
32
- removed.push("git-excludes");
33
- }
34
- const localDir = repoLocalDirPath(repoPath);
35
- if (await pathExists(localDir)) {
36
- await fs.rm(localDir, { recursive: true, force: true });
37
- removed.unshift(".mate");
31
+ const updated: string[] = [];
32
+ const retained: string[] = [];
33
+
34
+ for (const entry of outcomes) {
35
+ if (entry.state === "retained") {
36
+ retained.push(entry.id);
37
+ continue;
38
+ }
39
+ if (entry.state !== "removed") continue;
40
+ const reported = REPORTED_AS[entry.id];
41
+ if (!reported) continue;
42
+ const bucket = reported.as === "removed" ? removed : updated;
43
+ if (!bucket.includes(reported.label)) bucket.push(reported.label);
38
44
  }
39
- return { changed: removed.length > 0 || updated.length > 0, removed, updated };
45
+
46
+ return { changed: removed.length > 0 || updated.length > 0, removed, updated, retained };
40
47
  }
@@ -5,7 +5,8 @@ import { resolveGitInfoExcludePath } from "./git-utils";
5
5
 
6
6
  const MANAGED_BLOCK_START = "# mate managed: start";
7
7
  const MANAGED_BLOCK_END = "# mate managed: end";
8
- const CORE_EXCLUDE_ENTRIES = ["/.mate/", "/.claude/", "/.opencode/", "/.agents/"];
8
+ /** `.mcp.json` is the one working-target document no directory entry covers. */
9
+ const CORE_EXCLUDE_ENTRIES = ["/.mate/", "/.claude/", "/.opencode/", "/.agents/", "/.mcp.json"];
9
10
  const LEGACY_CORE_EXCLUDE_ENTRIES = new Set([".mate/", ".claude/settings.local.json"]);
10
11
 
11
12
  async function readExclude(excludePath: string): Promise<string> {
@@ -90,6 +91,25 @@ export async function reconcileWorkingRepoCapabilityExcludes(
90
91
  return writeExclude(excludePath, existing, lines);
91
92
  }
92
93
 
94
+ /** Whether the managed block is on disk; the block, not the exclude file. */
95
+ export async function managedWorkingRepoExcludesPresent(repoPath: string): Promise<boolean> {
96
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
97
+ if (!excludePath) return false;
98
+ const lines = contentLines(await readExclude(excludePath));
99
+ return lines.some((line) => line.trim() === MANAGED_BLOCK_START);
100
+ }
101
+
102
+ /** Whether any Capability-owned exclusion is present outside the managed block. */
103
+ export async function workingRepoCapabilityExcludesPresent(
104
+ repoPath: string,
105
+ candidateEntries: string[],
106
+ ): Promise<boolean> {
107
+ const excludePath = await resolveGitInfoExcludePath(repoPath);
108
+ if (!excludePath) return false;
109
+ const lines = withoutManagedBlocks(contentLines(await readExclude(excludePath)));
110
+ return candidateEntries.some((entry) => lines.some((line) => line.trim() === entry));
111
+ }
112
+
93
113
  export async function removeWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
94
114
  const excludePath = await resolveGitInfoExcludePath(repoPath);
95
115
  if (!excludePath) return false;
@@ -19,7 +19,6 @@ import {
19
19
  type CapabilityConfig,
20
20
  type FrameworkConfig,
21
21
  } from "../lib/orchestrator/types";
22
- import { syncWorkingRepoClaudeSettings } from "./setup/providers/claude";
23
22
  import {
24
23
  collectManagedGitignoreEntries,
25
24
  writeManagedGitignoreBlock,
@@ -35,10 +34,10 @@ import { installDeclaredPlugins } from "./setup/dynamic-plugins/install";
35
34
  import {
36
35
  buildSetupInstallationPlan,
37
36
  executeSetupInstallationPlan,
37
+ renderRuntimeDocuments,
38
38
  type SetupInstallationOutcome,
39
39
  } from "./setup/engine";
40
-
41
- export { syncWorkingRepoClaudeSettings };
40
+ import type { RenderedRuntimeDocument } from "../lib/orchestrator/projection-types";
42
41
 
43
42
  export interface SetupInput {
44
43
  allowedAgents?: string[];
@@ -67,6 +66,29 @@ export async function applySetupCompatibilities(
67
66
  return executeSetupInstallationPlan(ctx, plugins, plan);
68
67
  }
69
68
 
69
+ /**
70
+ * The active Agent Runtimes' documents for a Working Repository, rendered
71
+ * without a write. Runs no plugin phase: the working target reconciles
72
+ * declarations only, so nothing is written to the companion on its behalf.
73
+ */
74
+ export async function renderWorkingRuntimeDocuments(
75
+ companionPath: string,
76
+ config: FrameworkConfig,
77
+ repoPath: string,
78
+ plugins: PluginRegistration[] = getActiveDistribution().registry.getAll(),
79
+ ): Promise<RenderedRuntimeDocument[]> {
80
+ const plan = buildSetupInstallationPlan(config, plugins);
81
+ const ctx: SetupContext = {
82
+ companionPath,
83
+ config,
84
+ mode: "sync",
85
+ activeProviders: plan.activeProviders,
86
+ repoPath,
87
+ target: "working",
88
+ };
89
+ return renderRuntimeDocuments(ctx, plugins, plan);
90
+ }
91
+
70
92
  export async function updateProjectGitignore(
71
93
  companionPath: string,
72
94
  config: FrameworkConfig,
@@ -1,8 +1,59 @@
1
1
  #!/usr/bin/env bash
2
- [ -n "$MATE_ARTIFACT_PATH" ] || { echo "graphify: must be run via mate claude or mate opencode (MATE_ARTIFACT_PATH not set)" >&2; exit 1; }
3
- [ -n "$MATE_REPO_PATH" ] || { echo "graphify: must be run via mate claude or mate opencode (MATE_REPO_PATH not set)" >&2; exit 1; }
4
-
5
2
  _this_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
3
+
4
+ # Mate context: the launch environment first, the Working Repository's
5
+ # Projection Root second. Projected values stay in private shell variables and
6
+ # no MATE_-prefixed variable is exported: the projection carries paths and
7
+ # never predicates, so a partial MATE_* environment would read to any child as
8
+ # authoritative with every predicate disabled.
9
+ _mate_projection_file() {
10
+ local dir="$PWD"
11
+ while :; do
12
+ if [ -f "$dir/.mate/config/registry.yaml" ]; then
13
+ [ -f "$dir/.mate/projection.env" ] && printf '%s' "$dir/.mate/projection.env"
14
+ return
15
+ fi
16
+ [ "$dir" = "/" ] && return
17
+ dir="$(dirname "$dir")"
18
+ done
19
+ }
20
+
21
+ _mate_projected() {
22
+ sed -n "s/^$2='\\(.*\\)'\$/\\1/p" "$1" | head -n 1 | sed "s/'\\\\''/'/g"
23
+ }
24
+
25
+ _mate_same_dir() {
26
+ local left="$1" right="$2"
27
+ [ -d "$left" ] && left="$(cd "$left" && pwd)"
28
+ [ -d "$right" ] && right="$(cd "$right" && pwd)"
29
+ [ "$left" = "$right" ]
30
+ }
31
+
32
+ _companion="$MATE_ARTIFACT_PATH"
33
+ _repo_path="$MATE_REPO_PATH"
34
+ _graphify_out="$GRAPHIFY_OUT"
35
+ if [ -z "$_companion" ]; then
36
+ _projection="$(_mate_projection_file)"
37
+ if [ -n "$_projection" ]; then
38
+ _companion="$(_mate_projected "$_projection" MATE_ARTIFACT_PATH)"
39
+ _repo_path="$(_mate_projected "$_projection" MATE_REPO_PATH)"
40
+ _graphify_out="$(_mate_projected "$_projection" GRAPHIFY_OUT)"
41
+ _projected_wrapper_dir="$(_mate_projected "$_projection" MATE_WRAPPER_BIN_PATH)"
42
+ if [ -n "$_companion" ] && ! _mate_same_dir "$_projected_wrapper_dir" "$_this_dir"; then
43
+ {
44
+ echo "graphify: this repository was wrapped by a different mate install."
45
+ echo " projected wrappers: $_projected_wrapper_dir"
46
+ echo " running wrapper: $_this_dir"
47
+ echo " re-run \`mate wrap\` with this install."
48
+ } >&2
49
+ exit 1
50
+ fi
51
+ fi
52
+ fi
53
+ [ -n "$_companion" ] || { echo "graphify: no mate context here — run \`mate wrap\` in this repository" >&2; exit 1; }
54
+ [ -n "$_repo_path" ] || { echo "graphify: no working repository in the mate context — run \`mate wrap\` in this repository" >&2; exit 1; }
55
+ [ -n "$_graphify_out" ] || { echo "graphify: no graphify output path in the mate context — run \`mate wrap\` in this repository" >&2; exit 1; }
56
+
6
57
  _real=""
7
58
  IFS=:
8
59
  for _dir in $PATH; do
@@ -14,8 +65,6 @@ done
14
65
  unset IFS
15
66
  [ -n "$_real" ] || { echo "graphify wrapper: real graphify binary not found in PATH" >&2; exit 1; }
16
67
 
17
- _repo_id="${MATE_REPO_ID:-unknown}"
18
- _graphify_out="$MATE_ARTIFACT_PATH/.graphify/$_repo_id/graphify-out"
19
68
  _graphify_root="$(dirname "$_graphify_out")"
20
69
  _graph_json="$_graphify_out/graph.json"
21
70
  export GRAPHIFY_OUT="$_graphify_out"
@@ -32,18 +81,18 @@ case "$_cmd" in
32
81
  *) _rest+=("$_a") ;;
33
82
  esac
34
83
  done
35
- exec "$_real" extract "$MATE_REPO_PATH" --out "$_graphify_root" "${_rest[@]}"
84
+ exec "$_real" extract "$_repo_path" --out "$_graphify_root" "${_rest[@]}"
36
85
  ;;
37
86
  update)
38
87
  if [ "$#" -eq 1 ] || [[ "$2" == -* ]]; then
39
- exec "$_real" "$MATE_REPO_PATH" --update "${@:2}"
88
+ exec "$_real" "$_repo_path" --update "${@:2}"
40
89
  else
41
90
  exec "$_real" "$2" --update "${@:3}"
42
91
  fi
43
92
  ;;
44
93
  extract|watch|cluster-only|label|check-update)
45
94
  if [ "$#" -eq 1 ] || [[ "$2" == -* ]]; then
46
- exec "$_real" "$_cmd" "$MATE_REPO_PATH" "${@:2}"
95
+ exec "$_real" "$_cmd" "$_repo_path" "${@:2}"
47
96
  else
48
97
  exec "$_real" "$@"
49
98
  fi