@uniqbit/mate-core 0.18.3-canary.1 → 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 (95) 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 +9 -0
  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/working-repo-projection.ts +71 -159
  48. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  49. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  50. package/src/lib/runtime-directory-refresh.ts +186 -0
  51. package/src/lib/update-checker.ts +6 -0
  52. package/src/opencode/companion-hooks.ts +24 -132
  53. package/src/opencode/companion-policy.ts +26 -10
  54. package/src/opencode/index.ts +1 -7
  55. package/src/opencode/session-notes.ts +76 -0
  56. package/src/opencode/session-prepare.ts +139 -0
  57. package/src/opencode/tui.tsx +56 -9
  58. package/src/runtime/claude-delivery-plan.ts +73 -0
  59. package/src/runtime/claude-hook-group.ts +21 -0
  60. package/src/runtime/companion-lock.ts +121 -0
  61. package/src/runtime/companion-sync.ts +66 -9
  62. package/src/runtime/runtime-directory.ts +43 -0
  63. package/src/runtime/runtime-document-manifest.ts +335 -0
  64. package/src/runtime/session-prepare-runner.ts +148 -0
  65. package/src/runtime/wrap-state.ts +90 -0
  66. package/src/runtime/write-atomic.ts +27 -0
  67. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  68. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  70. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  71. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  72. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  73. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  74. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  75. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  76. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  77. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  78. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  79. package/src/tools/setup/engine.ts +15 -4
  80. package/src/tools/setup/mate.ts +1 -1
  81. package/src/tools/setup/providers/claude-format.ts +11 -18
  82. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  83. package/src/tools/setup/providers/claude.ts +23 -6
  84. package/src/tools/setup/runtime-documents.ts +152 -32
  85. package/src/tools/setup/working-repo-local-state.ts +82 -13
  86. package/src/tools/setup.ts +2 -2
  87. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  88. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  89. package/src/lib/orchestrator/companion-store.ts +0 -94
  90. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  91. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  92. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  93. package/src/templates/report-assets/README.md +0 -32
  94. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  95. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Mate hooks — PreToolUse artifact-path guardrail, SessionStart banner and companion guidance",
2
+ "description": "Mate hooks \u2014 PreToolUse artifact-path guardrail, SessionStart banner, companion guidance and session prepare",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -28,6 +28,15 @@
28
28
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-guidance.mjs\""
29
29
  }
30
30
  ]
31
+ },
32
+ {
33
+ "hooks": [
34
+ {
35
+ "type": "command",
36
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-prepare.mjs\"",
37
+ "timeout": 45
38
+ }
39
+ ]
31
40
  }
32
41
  ]
33
42
  }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/session-banner.ts, loaded through
3
- // ts-loader.mjs because the installed plugin sits inside node_modules where
4
- // node's native type stripping is disabled.
2
+ /**
3
+ * Thin shim for src/hooks/session-banner.ts, loaded through ts-loader.mjs: the installed
4
+ * plugin sits inside node_modules, where node's native type stripping is off.
5
+ */
5
6
  import "./ts-loader.mjs";
6
7
 
7
8
  const { run } = await import("../../src/hooks/session-banner.ts");
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin shim for src/hooks/session-prepare.ts, loaded through ts-loader.mjs: the installed
4
+ * plugin sits inside node_modules, where node's native type stripping is off.
5
+ */
6
+ import "./ts-loader.mjs";
7
+
8
+ const { run } = await import("../../src/hooks/session-prepare.ts");
9
+ process.exitCode = await run();
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import type { Context } from "@opencode/plugin/promise/plugin";
@@ -6,14 +7,36 @@ import { COMPANION_POLICY_MARKER, type MateGuidanceFile } from "../../src/runtim
6
7
 
7
8
  import type { CompanionContext } from "../../src/opencode/companion-policy";
8
9
  import { resolveOpenCodeGuidance } from "../../src/opencode/projected-guidance";
10
+ import {
11
+ runtimeDirectoryPath,
12
+ runtimeMateBin,
13
+ runtimeWrapperBinPath,
14
+ } from "../../src/runtime/runtime-directory";
9
15
 
10
16
  function prependPathEntry(pathValue: string | undefined, entry: string): string {
11
17
  const entries = (pathValue ?? "").split(path.delimiter).filter(Boolean);
12
18
  return [entry, ...entries.filter((value) => value !== entry)].join(path.delimiter);
13
19
  }
14
20
 
15
- function resolveWrapperBinPath(): string {
16
- return process.env.MATE_WRAPPER_BIN_PATH ?? "$MATE_WRAPPER_BIN_PATH";
21
+ /**
22
+ * The launch environment first, then the runtime directory every `mate` run
23
+ * keeps current. `null` when neither exists: guidance naming an unresolved
24
+ * placeholder would send the model to a path that is not there.
25
+ */
26
+ function resolveWrapperBinPath(): string | null {
27
+ if (process.env.MATE_WRAPPER_BIN_PATH) return process.env.MATE_WRAPPER_BIN_PATH;
28
+ const runtime = runtimeWrapperBinPath();
29
+ return fs.existsSync(runtime) ? runtime : null;
30
+ }
31
+
32
+ /** The runtime-directory launcher for the Mate CLI; `null` until a `mate` run wrote it. */
33
+ export function resolveMateBin(): string | null {
34
+ const launcher = runtimeMateBin();
35
+ return fs.existsSync(launcher) ? launcher : null;
36
+ }
37
+
38
+ export function missingRuntimeDirectoryMessage(): string {
39
+ return `Mate runtime directory ${runtimeDirectoryPath()} is missing; run \`mate\` once to repair it.`;
17
40
  }
18
41
 
19
42
  function buildStartupError(details: string[]): Error {
@@ -37,8 +60,11 @@ function loadGuidance(directory: string): MateGuidanceFile | null {
37
60
  return guidance;
38
61
  }
39
62
 
40
- function materializeCompanionGuidance(guidance: string, context: CompanionContext): string {
41
- const wrapperBinPath = resolveWrapperBinPath();
63
+ function materializeCompanionGuidance(
64
+ guidance: string,
65
+ context: CompanionContext,
66
+ wrapperBinPath: string,
67
+ ): string {
42
68
  return guidance
43
69
  .replaceAll("$MATE_REPO_PATH", context.repositoryPath)
44
70
  .replaceAll("$MATE_ARTIFACT_PATH", context.companionPath)
@@ -46,8 +72,12 @@ function materializeCompanionGuidance(guidance: string, context: CompanionContex
46
72
  .replaceAll("$MATE_REPO_ID", context.repositoryId);
47
73
  }
48
74
 
49
- function buildSystemPrompt(context: CompanionContext, guidance: MateGuidanceFile): string[] {
50
- const lines = [materializeCompanionGuidance(guidance.companionGuidance, context)];
75
+ function buildSystemPrompt(
76
+ context: CompanionContext,
77
+ guidance: MateGuidanceFile,
78
+ wrapperBinPath: string,
79
+ ): string[] {
80
+ const lines = [materializeCompanionGuidance(guidance.companionGuidance, context, wrapperBinPath)];
51
81
 
52
82
  if (guidance.codebaseExplorationGuidance.trim()) {
53
83
  lines.push("", guidance.codebaseExplorationGuidance);
@@ -70,8 +100,8 @@ function readSystemText(part: unknown): string {
70
100
  function applyCompanionShellEnv(
71
101
  context: CompanionContext,
72
102
  env: Record<string, string | undefined>,
103
+ wrapperBinPath: string,
73
104
  ): void {
74
- const wrapperBinPath = resolveWrapperBinPath();
75
105
  env.MATE_NAME = context.frameworkName;
76
106
  env.MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
77
107
  env.MATE_ARTIFACT_PATH = context.companionPath;
@@ -95,8 +125,7 @@ function applyCompanionShellEnv(
95
125
  env.PATH = prependPathEntry(process.env.PATH, wrapperBinPath);
96
126
  }
97
127
 
98
- function companionPathsResult(context: CompanionContext) {
99
- const wrapperBinPath = resolveWrapperBinPath();
128
+ function companionPathsResult(context: CompanionContext, wrapperBinPath: string) {
100
129
  return {
101
130
  content: JSON.stringify(
102
131
  {
@@ -146,8 +175,14 @@ function mergeCompanionSystem(system: unknown[], companion: string): string[] {
146
175
  export async function registerCompanion(api: Context, context: CompanionContext): Promise<void> {
147
176
  const guidance = loadGuidance(api.location.directory);
148
177
  if (!guidance) return;
178
+ /** Reported, not thrown: the guardrails registered before this must stay live. */
179
+ const wrapperBinPath = resolveWrapperBinPath();
180
+ if (!wrapperBinPath) {
181
+ console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
182
+ return;
183
+ }
149
184
 
150
- const companion = buildSystemPrompt(context, guidance).join("\n");
185
+ const companion = buildSystemPrompt(context, guidance, wrapperBinPath).join("\n");
151
186
 
152
187
  await api.session.hook("context", (event) => {
153
188
  const merged = mergeCompanionSystem(event.system, companion);
@@ -164,7 +199,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
164
199
  });
165
200
 
166
201
  await api.shell.hook("create.before", (event) => {
167
- applyCompanionShellEnv(context, event.env);
202
+ applyCompanionShellEnv(context, event.env, wrapperBinPath);
168
203
  });
169
204
 
170
205
  await api.tool.transform((editor) => {
@@ -173,7 +208,7 @@ export async function registerCompanion(api: Context, context: CompanionContext)
173
208
  description: `Return active ${context.frameworkName} companion framework and working repository paths.`,
174
209
  input: { type: "object", properties: {}, additionalProperties: false },
175
210
  async execute() {
176
- return companionPathsResult(context);
211
+ return companionPathsResult(context, wrapperBinPath);
177
212
  },
178
213
  });
179
214
  });
@@ -1,9 +1,40 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
- import { registerCompanionHooks } from "../../src/opencode/companion-hooks";
2
+ import type { Context } from "@opencode/plugin/promise/plugin";
3
+ import {
4
+ registerCompanionHooks,
5
+ repositoryWrappedForOpenCode,
6
+ } from "../../src/opencode/companion-hooks";
3
7
  import { readContext } from "../../src/opencode/companion-policy";
8
+ import { registerSessionPrepare } from "../../src/opencode/session-prepare";
9
+ import { hasLaunchEnvironment } from "../../src/runtime/env";
10
+ import { spawnPrepareRunner } from "../../src/runtime/session-prepare-runner";
11
+ import { readWrapStateSync } from "../../src/runtime/wrap-state";
4
12
 
5
13
  import { registerCompanionAccess } from "./add-dir";
6
- import { registerCompanion } from "./companion";
14
+ import { missingRuntimeDirectoryMessage, registerCompanion, resolveMateBin } from "./companion";
15
+
16
+ /**
17
+ * Only a location in a repository wrapped for OpenCode prepares, and only
18
+ * without a launch environment: `mate opencode` has already prepared the
19
+ * session it starts.
20
+ */
21
+ async function registerPrepare(api: Context, companionPath: string) {
22
+ if (hasLaunchEnvironment(process.env)) return undefined;
23
+ const repoRoot = repositoryWrappedForOpenCode(api.location.directory);
24
+ if (!repoRoot) return undefined;
25
+ const mateBin = resolveMateBin();
26
+ if (!mateBin) {
27
+ console.error(`mate: ${missingRuntimeDirectoryMessage()}`);
28
+ return undefined;
29
+ }
30
+ return registerSessionPrepare(api, {
31
+ directory: api.location.directory,
32
+ repoPath: repoRoot,
33
+ companionPath,
34
+ mateServers: readWrapStateSync(repoRoot).openCodeMcpServers,
35
+ run: spawnPrepareRunner(mateBin, repoRoot),
36
+ });
37
+ }
7
38
 
8
39
  /**
9
40
  * Mate OpenCode server plugin (OpenCode 2.x). Loaded through the package's
@@ -17,9 +48,13 @@ export const MateOpenCodePlugin = Plugin.define({
17
48
  if (!context.companionPath) return;
18
49
 
19
50
  await registerCompanionAccess(api, context.companionPath);
20
- const cleanup = await registerCompanionHooks(api, context);
51
+ await registerCompanionHooks(api, context);
21
52
  await registerCompanion(api, context);
22
- return cleanup;
53
+ /** Last: the guardrails above are live while the local phase holds `setup`. */
54
+ const prepareCleanup = await registerPrepare(api, context.companionPath);
55
+ return async () => {
56
+ await prepareCleanup?.();
57
+ };
23
58
  },
24
59
  });
25
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.3-canary.1",
3
+ "version": "0.18.3",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,8 +35,8 @@
35
35
  "typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
36
36
  "lint": "oxlint src/ opencode-plugin/",
37
37
  "lint:fix": "oxlint src/ opencode-plugin/ --fix",
38
- "format": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\"",
39
- "format:check": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
38
+ "format": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\"",
39
+ "format:check": "oxfmt src/ opencode-plugin/ \"!**/*.generated.*\" --check"
40
40
  },
41
41
  "dependencies": {
42
42
  "@opencode/plugin": "^2.0.23",
@@ -356,7 +356,7 @@ export async function resolveDriftedSpecs(
356
356
  message: `mate: not a canonical spec: ${outside.join(", ")}. Only specs under ${SPECS_RELATIVE_DIR}/ are spec publication targets.`,
357
357
  };
358
358
  }
359
- const driftedPaths = new Set(drifted);
359
+ const driftedPaths = new Set(drifted.map(withoutTrailingSlash));
360
360
  const notDrifted = normalized.filter((candidate) => !driftedPaths.has(candidate));
361
361
  if (notDrifted.length > 0) {
362
362
  return {
@@ -109,28 +109,32 @@ function changeKindFor(
109
109
  );
110
110
  }
111
111
 
112
+ /**
113
+ * Every file beneath `directory`, as POSIX paths under `relativeRoot`. Reads directory
114
+ * entry names only, never file contents. Throws when `directory` cannot be read.
115
+ */
116
+ async function filesUnder(directory: string, relativeRoot: string): Promise<string[]> {
117
+ const entries = await fs.readdir(directory, { withFileTypes: true });
118
+ const nested = await Promise.all(
119
+ entries.map(async (entry) => {
120
+ const entryRelative = path.posix.join(relativeRoot, entry.name);
121
+ if (entry.isDirectory()) return filesUnder(path.join(directory, entry.name), entryRelative);
122
+ return entry.isFile() ? [entryRelative] : [];
123
+ }),
124
+ );
125
+ return nested.flat();
126
+ }
127
+
112
128
  /**
113
129
  * Canonical specs the change's delta specs applied to. Only directory entry names under
114
130
  * the archive's `specs/` tree are read — never any file contents — so archived prose
115
131
  * cannot influence discovery.
116
132
  */
117
133
  async function canonicalSpecsFor(companionPath: string, anchor: string): Promise<string[]> {
118
- const collectDeltaSpecFiles = async (directory: string, relative = ""): Promise<string[]> => {
119
- const entries = await fs.readdir(directory, { withFileTypes: true });
120
- const nested = await Promise.all(
121
- entries.map(async (entry) => {
122
- const entryRelative = path.posix.join(relative, entry.name);
123
- if (entry.isDirectory()) {
124
- return collectDeltaSpecFiles(path.join(directory, entry.name), entryRelative);
125
- }
126
- return entry.isFile() ? [path.posix.join(SPECS_RELATIVE_DIR, entryRelative)] : [];
127
- }),
128
- );
129
- return nested.flat();
130
- };
131
134
  try {
132
- const specs = await collectDeltaSpecFiles(
135
+ const specs = await filesUnder(
133
136
  path.join(companionPath, ARCHIVE_RELATIVE_DIR, anchor, "specs"),
137
+ SPECS_RELATIVE_DIR,
134
138
  );
135
139
  return specs.toSorted();
136
140
  } catch {
@@ -216,6 +220,32 @@ async function attributionIndex(
216
220
  );
217
221
  }
218
222
 
223
+ /**
224
+ * The canonical spec files behind one uncommitted path. Porcelain collapses an untracked
225
+ * directory to `dir/`, so a collapsed path at or above a capability directory is expanded
226
+ * into the files beneath it, every one of them new; publish narrows by file, never by
227
+ * directory. Expansion reads directory entry names only, so spec prose cannot influence
228
+ * discovery.
229
+ */
230
+ async function specFilesFor(
231
+ companionPath: string,
232
+ worktreePath: string,
233
+ changeKinds: Readonly<Record<string, WorkingTreeChange>>,
234
+ ): Promise<Array<{ path: string; kind: WorkingTreeChange }>> {
235
+ if (!worktreePath.endsWith("/")) {
236
+ return [{ path: worktreePath, kind: changeKindFor(worktreePath, changeKinds) }];
237
+ }
238
+ const collapsed = withoutTrailingSlash(worktreePath);
239
+ const root = collapsed.startsWith(`${SPECS_RELATIVE_DIR}/`) ? collapsed : SPECS_RELATIVE_DIR;
240
+ try {
241
+ const files = await filesUnder(path.join(companionPath, root), root);
242
+ return files.map((file) => ({ path: file, kind: "new" as const }));
243
+ } catch {
244
+ // A collapsed ancestor such as `openspec/` need not contain a spec tree at all.
245
+ return [];
246
+ }
247
+ }
248
+
219
249
  /**
220
250
  * Uncommitted canonical specs that no pending change accounts for, each attributed to
221
251
  * every archive whose delta specs name it. An attributed spec belongs to a change whose
@@ -230,18 +260,27 @@ export async function unattributedSpecs(
230
260
  archives: ArchiveEntry[],
231
261
  changeKinds: Readonly<Record<string, WorkingTreeChange>> = {},
232
262
  ): Promise<UnattributedSpec[]> {
233
- const claimed = new Set(pendingArchives(archives).flatMap((entry) => entry.uncommittedSpecs));
234
- const specPaths = uncommittedPaths
235
- .filter(
236
- (worktreePath) => touches(worktreePath, SPECS_RELATIVE_DIR) && !claimed.has(worktreePath),
237
- )
238
- .toSorted();
263
+ const claimed = pendingArchives(archives).flatMap((entry) => entry.uncommittedSpecs);
264
+ const reported = await Promise.all(
265
+ uncommittedPaths.flatMap((worktreePath) =>
266
+ touches(worktreePath, SPECS_RELATIVE_DIR)
267
+ ? [specFilesFor(companionPath, worktreePath, changeKinds)]
268
+ : [],
269
+ ),
270
+ );
271
+ const driftKinds = new Map<string, WorkingTreeChange>();
272
+ for (const spec of reported.flat()) {
273
+ if (!claimed.some((claimedPath) => touches(claimedPath, spec.path))) {
274
+ driftKinds.set(spec.path, spec.kind);
275
+ }
276
+ }
277
+ const specPaths = [...driftKinds.keys()].toSorted();
239
278
  if (specPaths.length === 0) return [];
240
279
 
241
280
  const index = await attributionIndex(companionPath, archives);
242
281
  return specPaths.map((specPath) => ({
243
282
  path: specPath,
244
- kind: changeKindFor(specPath, changeKinds),
283
+ kind: driftKinds.get(specPath) ?? "modified",
245
284
  touchedByArchives: index.flatMap(({ anchor, state, specs }) =>
246
285
  specs.some((spec) => touches(specPath, spec)) ? [{ anchor, state }] : [],
247
286
  ),
@@ -10,10 +10,12 @@ import {
10
10
  } from "../../companion-link-wizard";
11
11
  import { extractRepoName } from "../artifact/extract-repo-name";
12
12
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
13
- import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
14
- import { companionRootedStore } from "../../../lib/orchestrator/companion-store";
13
+ import {
14
+ CompanionResolver,
15
+ companionRootedStore,
16
+ findDescendantRepoLocalRegistries,
17
+ } from "../../../lib/orchestrator/repository-link";
15
18
  import { inspectSetupPreflight } from "../../../lib/orchestrator/setup-preflight";
16
- import { findDescendantRepoLocalRegistries } from "../../../lib/orchestrator/repo-local-registry";
17
19
  import { runSetupFlowAtPath } from "../setup";
18
20
  import { runInstallCommand } from "../install";
19
21
  import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
@@ -164,14 +166,15 @@ export async function runCompanionLinkCommandWithDeps(
164
166
  if (!inputs.companionPaths?.length) {
165
167
  exitWithError("mate: no existing companion was selected.");
166
168
  }
167
- companionPaths = [];
168
- for (const rawCompanionPath of inputs.companionPaths) {
169
- const resolvedCompanionPath = path.resolve(rawCompanionPath);
170
- const existingTarget = await stat(resolvedCompanionPath).catch(() => null);
171
- if (!existingTarget?.isDirectory()) {
172
- exitWithError(`mate: existing companion must be a directory: ${resolvedCompanionPath}`);
173
- }
174
- companionPaths.push(resolvedCompanionPath);
169
+ companionPaths = inputs.companionPaths.map((rawCompanionPath) =>
170
+ path.resolve(rawCompanionPath),
171
+ );
172
+ const targets = await Promise.all(
173
+ companionPaths.map((companionPath) => stat(companionPath).catch(() => null)),
174
+ );
175
+ const notADirectory = companionPaths.find((_, index) => !targets[index]?.isDirectory());
176
+ if (notADirectory) {
177
+ exitWithError(`mate: existing companion must be a directory: ${notADirectory}`);
175
178
  }
176
179
  } else {
177
180
  const rawCompanionPath = inputs.localPath?.trim();
@@ -205,6 +208,7 @@ export async function runCompanionLinkCommandWithDeps(
205
208
  // Managed companions are git-backed; explicitly pasted paths retain local provenance.
206
209
  const persistedSource: CompanionSource = companionSource === "local" ? "local" : "git";
207
210
 
211
+ /* oxlint-disable no-await-in-loop -- each companion is set up, linked and installed in turn; a declined install stops the rest */
208
212
  for (const companionPath of companionPaths) {
209
213
  const preflight = await inspectPreflight(companionPath);
210
214
  if (companionSource === "git" && preflight.kind !== "existing-companion") {
@@ -216,6 +220,7 @@ export async function runCompanionLinkCommandWithDeps(
216
220
  await invalidateInstallState({ kind: "companion", companionPath });
217
221
  if ((await installCompanion(companionPath)) === false) return;
218
222
  }
223
+ /* oxlint-enable no-await-in-loop */
219
224
 
220
225
  let shadowedPaths: string[] = [];
221
226
  try {
@@ -1,4 +1,4 @@
1
- import { CompanionStore } from "../../../lib/orchestrator/companion-store";
1
+ import { CompanionStore } from "../../../lib/orchestrator/repository-link";
2
2
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
3
3
  import { renderRepoListTable } from "../../repo-list-table";
4
4
  import { writeJsonStdout } from "../../write-json-stdout";
@@ -13,14 +13,13 @@ import { writeJsonStdout } from "../../write-json-stdout";
13
13
  * TTY (e.g. piped into another command).
14
14
  */
15
15
  export async function runCompanionListCommand(argv: string[] = []): Promise<void> {
16
- const { configStore, workingRepoStore, repository, contextKind } = await resolveFrameworkContext(
17
- process.cwd(),
18
- );
16
+ const { companionPath, workingRepoStore, repository, contextKind } =
17
+ await resolveFrameworkContext(process.cwd());
19
18
  const repositories = repository
20
19
  ? [repository]
21
20
  : contextKind === "companion-root"
22
21
  ? []
23
- : await new CompanionStore(configStore, workingRepoStore).listRepositories();
22
+ : await new CompanionStore(companionPath, workingRepoStore).listRepositories();
24
23
  const forceJson = argv.includes("--json");
25
24
 
26
25
  if (forceJson || !process.stdout.isTTY) {
@@ -1,4 +1,4 @@
1
- import { CompanionStore } from "../../../lib/orchestrator/companion-store";
1
+ import { CompanionStore } from "../../../lib/orchestrator/repository-link";
2
2
  import { resolveForLaunch } from "../../../lib/orchestrator/framework-context";
3
3
  import { injectEditorFolder, getPreferredEditorCli } from "../../../lib/orchestrator/editor";
4
4
  import { ensureUnambiguousCompanion } from "../launch/shared";
@@ -7,9 +7,9 @@ export const workspaceOpenCommandDeps = {
7
7
  ensureUnambiguousCompanion: () => ensureUnambiguousCompanion(),
8
8
  resolveLaunchContext: () => resolveForLaunch(process.cwd()),
9
9
  createStore: (
10
- configStore: ConstructorParameters<typeof CompanionStore>[0],
10
+ companionPath: ConstructorParameters<typeof CompanionStore>[0],
11
11
  workingRepoStore: ConstructorParameters<typeof CompanionStore>[1],
12
- ) => new CompanionStore(configStore, workingRepoStore),
12
+ ) => new CompanionStore(companionPath, workingRepoStore),
13
13
  getPreferredEditorCli: () => getPreferredEditorCli(),
14
14
  injectEditorFolder,
15
15
  };
@@ -35,13 +35,12 @@ export async function runWorkspaceOpenCommand(): Promise<void> {
35
35
  companionPath,
36
36
  repositoryId,
37
37
  repository: localRepository,
38
- configStore,
39
38
  workingRepoStore,
40
39
  } = await workspaceOpenCommandDeps.resolveLaunchContext();
41
40
  const repository =
42
41
  localRepository ??
43
42
  (await workspaceOpenCommandDeps
44
- .createStore(configStore, workingRepoStore)
43
+ .createStore(companionPath, workingRepoStore)
45
44
  .getRepository(repositoryId));
46
45
 
47
46
  if (!repository) {
@@ -4,7 +4,7 @@ import { accessSync } from "node:fs";
4
4
  import { getActiveDistribution } from "../../../distribution";
5
5
  import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
7
- import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repo-local-registry";
7
+ import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repository-link";
8
8
  import { ensureUnambiguousCompanion } from "../launch/shared";
9
9
 
10
10
  export function renderBanner(repoPath: string, companionPath: string): string {
@@ -6,7 +6,7 @@ import { gitEnvironment } from "../../runtime/companion-git";
6
6
  import { getActiveDistribution } from "../../distribution";
7
7
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
8
8
  import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
9
- import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
9
+ import { pathIsDirectory } from "../../lib/fs-utils";
10
10
  import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
11
11
  import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
12
12
  import {
@@ -1,6 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
1
  import { reportDataToDocument } from "./adapter";
5
2
  import { validateReportDocument } from "./contract";
6
3
  import { highlightInline, highlightStyleBlock } from "./highlight";
@@ -14,26 +11,19 @@ import type {
14
11
  ReportValue,
15
12
  } from "./types";
16
13
 
17
- const MERMAID_RUNTIME_PATH = join(
18
- import.meta.dirname,
19
- "../../../templates/report-assets/mermaid.min.js",
20
- );
14
+ /**
15
+ * Pinned and integrity-checked, so a CDN cannot serve other code. Offline, the
16
+ * script fails to load and each diagram stays readable as its escaped source.
17
+ */
18
+ export const MERMAID_RUNTIME_URL =
19
+ "https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js";
20
+ const MERMAID_RUNTIME_INTEGRITY =
21
+ "sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI";
21
22
 
22
23
  const MERMAID_RUNTIME_ID = "mermaid-runtime";
23
24
 
24
25
  const CODE_BLOCK_SELECTOR = "pre.report-code";
25
26
 
26
- let mermaidRuntime: string | undefined;
27
-
28
- /** A literal `</script` inside the bundle would close the inline element, so it is neutralised. */
29
- function readMermaidRuntime(): string {
30
- mermaidRuntime ??= readFileSync(MERMAID_RUNTIME_PATH, "utf8").replaceAll(
31
- "</script",
32
- String.raw`<\/script`,
33
- );
34
- return mermaidRuntime;
35
- }
36
-
37
27
  const carriesMermaid = (sections: ReportSection[]): boolean =>
38
28
  sections.some((section) => section.type === "diagram" && section.mermaid !== undefined);
39
29
 
@@ -384,7 +374,7 @@ export function renderHTML(input: RenderInput): string {
384
374
  ? `\n <style>${highlightStyleBlock(CODE_BLOCK_SELECTOR)}${VISUAL_SECTION_CSS}</style>`
385
375
  : "";
386
376
  const diagramRuntime = carriesMermaid(data.sections)
387
- ? `\n <script id="${MERMAID_RUNTIME_ID}">${readMermaidRuntime()}</script>\n <script>mermaid.initialize({ startOnLoad: true, securityLevel: "strict" });</script>`
377
+ ? `\n <script id="${MERMAID_RUNTIME_ID}" src="${MERMAID_RUNTIME_URL}" integrity="${MERMAID_RUNTIME_INTEGRITY}" crossorigin="anonymous"></script>\n <script>window.mermaid?.initialize({ startOnLoad: true, securityLevel: "strict" });</script>`
388
378
  : "";
389
379
 
390
380
  return `<!doctype html>