@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4

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 (127) 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/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,22 +1,19 @@
1
- import { execFile } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { promisify } from "node:util";
5
3
 
6
4
  import { FRAMEWORK_NAME } from "../../framework";
7
- import { gitEnvironment } from "../../runtime/companion-git";
8
5
  import { readProjectionFile } from "../../runtime/projection";
6
+ import { GlobalConfigStore } from "./global-config-store";
9
7
  import { repoLocalDirPath } from "../../runtime/repo-local";
10
- import type { GlobalConfigStore } from "./global-config-store";
11
8
  import { projectionEntries } from "./projection-entries";
12
9
  import {
13
- isExternalDocument,
14
- recordedRuntimeDocuments,
15
- removeRuntimeDocuments,
16
- } from "./projection-runtime-documents";
10
+ copies as recordedCopies,
11
+ documents as recordedDocuments,
12
+ readManifest,
13
+ } from "../../runtime/runtime-document-manifest";
14
+ import { removeRuntimeDocuments } from "./projection-runtime-documents";
17
15
  import {
18
16
  firstFailure,
19
- type RenderedRuntimeDocument,
20
17
  type ProjectionDescription,
21
18
  type ProjectionEntry,
22
19
  type ProjectionEntryOutcome,
@@ -26,10 +23,10 @@ import {
26
23
  type ProjectionRemovalResult,
27
24
  type ProjectionResult,
28
25
  type ProjectionScope,
26
+ type ProjectionWriteReport,
27
+ type ProjectionWriteState,
29
28
  } from "./projection-types";
30
- import type { FrameworkConfig, LinkedRepository } from "./types";
31
-
32
- const execFileAsync = promisify(execFile);
29
+ import type { LinkedRepository } from "./types";
33
30
 
34
31
  /**
35
32
  * The owner of the Managed Projection: every path Mate places inside a Working
@@ -60,6 +57,22 @@ function outcome(
60
57
  state,
61
58
  ...(error ? { error } : {}),
62
59
  ...(entry.degradable ? { degradable: true } : {}),
60
+ ...(entry.runtime ? { runtime: entry.runtime } : {}),
61
+ };
62
+ }
63
+
64
+ /** One outcome shape for both forms a write may return; empty reports are omitted. */
65
+ function writeOutcome(
66
+ entry: ProjectionEntry,
67
+ written: ProjectionWriteState | ProjectionWriteReport,
68
+ ): ProjectionEntryOutcome {
69
+ if (typeof written === "string") return outcome(entry, written);
70
+ const { state, warnings = [], changed } = written;
71
+ const anyChanged = changed && (changed.written.length > 0 || changed.removed.length > 0);
72
+ return {
73
+ ...outcome(entry, state),
74
+ ...(warnings.length > 0 ? { warnings } : {}),
75
+ ...(anyChanged ? { changed } : {}),
63
76
  };
64
77
  }
65
78
 
@@ -74,11 +87,13 @@ export async function project(
74
87
  input: ProjectionInput,
75
88
  ): Promise<ProjectionResult> {
76
89
  const outcomes: ProjectionEntryOutcome[] = [];
90
+ const runtimes = input.runtimes ? new Set(input.runtimes) : null;
77
91
  for (const entry of projectionEntries()) {
78
92
  if (!entry.scopes.includes(scope)) continue;
93
+ if (entry.runtime && runtimes && !runtimes.has(entry.runtime)) continue;
79
94
  try {
80
95
  // oxlint-disable-next-line no-await-in-loop -- catalogue order is the contract
81
- outcomes.push(outcome(entry, await entry.write(input)));
96
+ outcomes.push(writeOutcome(entry, await entry.write(input))); // react-doctor-disable-line react-doctor/async-await-in-loop
82
97
  } catch (error) {
83
98
  outcomes.push(outcome(entry, "failed", toError(error)));
84
99
  }
@@ -93,7 +108,7 @@ export async function project(
93
108
  */
94
109
  export async function unproject(input: ProjectionRemovalInput): Promise<ProjectionRemovalResult> {
95
110
  const outcomes: ProjectionEntryOutcome[] = [];
96
- for (const entry of [...projectionEntries()].reverse()) {
111
+ for (const entry of projectionEntries().toReversed()) {
97
112
  const { removal } = entry;
98
113
  if (removal.by === "entry") {
99
114
  outcomes.push(outcome(entry, "covered"));
@@ -105,7 +120,8 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
105
120
  }
106
121
  try {
107
122
  // oxlint-disable-next-line no-await-in-loop -- reverse catalogue order is the contract
108
- outcomes.push(outcome(entry, await removal.remove(input)));
123
+ const removed = outcome(entry, await removal.remove(input));
124
+ outcomes.push(removal.retains ? { ...removed, retains: removal.retains } : removed);
109
125
  } catch (error) {
110
126
  outcomes.push(outcome(entry, "failed", toError(error)));
111
127
  }
@@ -113,39 +129,42 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
113
129
  return { root: repoLocalDirPath(input.repoPath), outcomes };
114
130
  }
115
131
 
116
- /** Removes entries that wrap owns but an unwrap must withdraw independently. */
132
+ /**
133
+ * Removes entries that wrap owns but an unwrap must withdraw independently.
134
+ * Sequential: several of them rewrite the manifest, and concurrent rewrites
135
+ * would restore each other's withdrawn records.
136
+ */
117
137
  async function removeWorkingRepositoryUnwrapEntries(
118
138
  repoPath: string,
139
+ registeredCompanionPaths: string[],
119
140
  ): Promise<ProjectionEntryOutcome[]> {
120
- const entries = projectionEntries().filter(
121
- (entry) => entry.removeOnUnwrap && entry.removal.by === "self",
122
- );
123
- return Promise.all(
124
- entries.map(async (entry) => {
125
- const removal = entry.removal;
126
- if (removal.by !== "self") return outcome(entry, "failed", new Error("invalid removal"));
127
- try {
128
- return outcome(entry, await removal.remove({ repoPath }));
129
- } catch (error) {
130
- return outcome(entry, "failed", toError(error));
131
- }
132
- }),
133
- );
141
+ const outcomes: ProjectionEntryOutcome[] = [];
142
+ for (const entry of projectionEntries()) {
143
+ const removal = entry.removal;
144
+ if (!entry.removeOnUnwrap || removal.by !== "self") continue;
145
+ try {
146
+ // oxlint-disable-next-line no-await-in-loop -- each removal rewrites the manifest
147
+ outcomes.push(outcome(entry, await removal.remove({ repoPath, registeredCompanionPaths }))); // react-doctor-disable-line react-doctor/async-await-in-loop
148
+ } catch (error) {
149
+ outcomes.push(outcome(entry, "failed", toError(error)));
150
+ }
151
+ }
152
+ return outcomes;
134
153
  }
135
154
 
136
155
  /** Reads and reports; writes nothing. Freshness is a verdict above this module. */
137
156
  export async function describe(repoPath: string): Promise<ProjectionDescription> {
138
- const entries: ProjectionEntryPresence[] = [];
139
- for (const entry of projectionEntries()) {
140
- const present = entry.present
141
- ? // oxlint-disable-next-line no-await-in-loop -- reported in catalogue order
142
- await entry.present(repoPath)
143
- : await fs
144
- .access(path.join(path.resolve(repoPath), entry.path))
145
- .then(() => true)
146
- .catch(() => false);
147
- entries.push({ id: entry.id, path: entry.path, kind: entry.kind, present });
148
- }
157
+ const entries: ProjectionEntryPresence[] = await Promise.all(
158
+ projectionEntries().map(async (entry) => {
159
+ const present = await (entry.present
160
+ ? entry.present(repoPath)
161
+ : fs
162
+ .access(path.join(path.resolve(repoPath), entry.path))
163
+ .then(() => true)
164
+ .catch(() => false));
165
+ return { id: entry.id, path: entry.path, kind: entry.kind, present };
166
+ }),
167
+ );
149
168
  return {
150
169
  root: repoLocalDirPath(repoPath),
151
170
  entries,
@@ -183,123 +202,12 @@ export async function projectWorkingRepository(
183
202
  };
184
203
  }
185
204
 
186
- /**
187
- * The rendered runtime documents, placed. Reported per document because the
188
- * operator asked for these by name: a failure has to say which one failed while
189
- * the projection already written stays where it is.
190
- */
191
- export type RuntimeDocumentsResult =
192
- | { kind: "written" | "current"; documents: string[] }
193
- | { kind: "failed"; document: string; error: Error };
194
-
195
- /**
196
- * Which of the given repo-relative paths Git already has in its index.
197
- * Best-effort by construction: a directory that is no repository, a Git that is
198
- * not installed, and a `ls-files` that fails for any other reason all answer
199
- * "nothing tracked" — this feeds a warning, and a warning may not fail a wrap.
200
- */
201
- async function trackedDocuments(repoPath: string, documentPaths: string[]): Promise<string[]> {
202
- if (documentPaths.length === 0) return [];
203
- try {
204
- const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", ...documentPaths], {
205
- cwd: path.resolve(repoPath),
206
- encoding: "utf8",
207
- env: gitEnvironment(),
208
- maxBuffer: 64 * 1024 * 1024,
209
- });
210
- return stdout.split("\0").filter(Boolean);
211
- } catch {
212
- return [];
213
- }
214
- }
215
-
216
- /**
217
- * The managed exclude block only hides a file Git does not already track, so a
218
- * repository that commits one of these documents keeps it in the index and the
219
- * regions land in a tracked file. Those regions carry machine-absolute
220
- * companion paths — keys naming directories under this user's home — so a
221
- * routine `git add -A` pushes one machine's layout to everyone else. Warn and
222
- * write anyway: refusing would silently drop the projection the repository was
223
- * wrapped for.
224
- *
225
- * Asked here rather than while rendering because the render also runs on every
226
- * launch; the wrap pass is the one that is a deliberate act, so this is the
227
- * scope where saying it once is information rather than noise.
228
- */
229
- async function warnAboutTrackedDocuments(
230
- repository: LinkedRepository,
231
- runtimeDocuments: readonly RenderedRuntimeDocument[],
232
- ): Promise<void> {
233
- const tracked = await trackedDocuments(
234
- repository.path,
235
- runtimeDocuments
236
- .map((document) => document.path)
237
- .filter((documentPath) => !isExternalDocument(documentPath)),
238
- );
239
- for (const documentPath of tracked) {
240
- console.error(
241
- `${FRAMEWORK_NAME}: warning: ${documentPath} is tracked by Git in ${repository.id}; ${FRAMEWORK_NAME} writes machine-absolute companion paths into it, so committing it would push this machine's paths to everyone else on the repository.`,
242
- );
243
- console.error(
244
- `Untrack it with \`git rm --cached ${documentPath}\` if those paths should stay local.`,
245
- );
246
- }
247
- }
248
-
249
- export async function projectWorkingRuntimeDocuments(
250
- companionPath: string,
251
- repository: LinkedRepository,
252
- config: FrameworkConfig,
253
- runtimeDocuments: readonly RenderedRuntimeDocument[],
254
- globalConfigStore?: GlobalConfigStore,
255
- ): Promise<RuntimeDocumentsResult> {
256
- await warnAboutTrackedDocuments(repository, runtimeDocuments);
257
- const result = await project("wrap", {
258
- repoPath: repository.path,
259
- companionPath: path.resolve(companionPath),
260
- repository,
261
- config,
262
- runtimeDocuments,
263
- globalConfigStore,
264
- });
265
-
266
- const failure = firstFailure(result.outcomes);
267
- if (failure) {
268
- return {
269
- kind: "failed",
270
- document: failure.path,
271
- error: failure.error ?? new Error("runtime document write failed"),
272
- };
273
- }
274
-
275
- /** The destinations the caller handed over; the rest of the pass is scaffolding. */
276
- const destinations = new Set(
277
- runtimeDocuments.map((document) => document.path.split("/").join(path.sep)),
278
- );
279
- const outcomes = result.outcomes.filter((outcome) => destinations.has(outcome.path));
280
- return {
281
- kind: outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
282
- documents: [...new Set(outcomes.map((outcome) => outcome.path))],
283
- };
284
- }
285
-
286
- /**
287
- * Whether `mate wrap` configured this Working Repository. Asked of the runtime
288
- * document manifest rather than of the Projection Root, because the root is not
289
- * evidence of a wrap: a launch and every Capability command write the
290
- * projection pair too. The manifest is written only by a pass that placed a
291
- * runtime document, and wrapping is the only pass that places one.
292
- */
293
- export async function isWorkingRepositoryWrapped(repoPath: string): Promise<boolean> {
294
- return (await recordedRuntimeDocuments(repoPath)).length > 0;
295
- }
296
-
297
205
  export type UnwrapResult =
298
- | { kind: "unwrapped" | "absent"; documents: string[] }
206
+ | { kind: "unwrapped" | "absent"; documents: string[]; copies: string[] }
299
207
  | { kind: "failed"; document: string; error: Error };
300
208
 
301
209
  /**
302
- * The inverse of {@link projectWorkingRuntimeDocuments}: runtime documents and
210
+ * The inverse of a wrap's runtime documents: runtime documents and
303
211
  * projected Claude skill links are withdrawn while the Projection Root, the
304
212
  * Repository Link and the companion link are left exactly as they were. That
305
213
  * asymmetry with `mate working cleanup` is the point — cleanup removes Mate's
@@ -311,10 +219,15 @@ export type UnwrapResult =
311
219
  * Driven by the manifest, so it withdraws whatever the wrap that ran recorded,
312
220
  * including a destination this release no longer renders.
313
221
  */
314
- export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<UnwrapResult> {
315
- const documents = await recordedRuntimeDocuments(repoPath);
222
+ export async function unwrapWorkingRuntimeDocuments(
223
+ repoPath: string,
224
+ globalConfigStore: GlobalConfigStore = new GlobalConfigStore(),
225
+ ): Promise<UnwrapResult> {
226
+ const manifest = await readManifest(repoPath);
227
+ const documents = recordedDocuments(manifest);
228
+ const copies = Object.keys(recordedCopies(manifest));
316
229
  const removeWrapEntries = async (): Promise<ProjectionEntryOutcome[]> =>
317
- removeWorkingRepositoryUnwrapEntries(repoPath);
230
+ removeWorkingRepositoryUnwrapEntries(repoPath, await globalConfigStore.list());
318
231
  if (documents.length === 0) {
319
232
  const outcomes = await removeWrapEntries();
320
233
  const failure = firstFailure(outcomes);
@@ -326,8 +239,8 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
326
239
  };
327
240
  }
328
241
  return outcomes.some((entry) => entry.state === "removed")
329
- ? { kind: "unwrapped", documents: [] }
330
- : { kind: "absent", documents: [] };
242
+ ? { kind: "unwrapped", documents: [], copies }
243
+ : { kind: "absent", documents: [], copies: [] };
331
244
  }
332
245
 
333
246
  /**
@@ -349,7 +262,7 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
349
262
  error: failure.error ?? new Error("wrap entry removal failed"),
350
263
  };
351
264
  }
352
- return { kind: "unwrapped", documents: removed };
265
+ return { kind: "unwrapped", documents: removed, copies };
353
266
  }
354
267
 
355
268
  /**
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
- import { pathIsDirectory } from "./repo-local-registry";
4
- import { readCompanionRegistry } from "./companion-registry-reader";
3
+ import { pathIsDirectory } from "../fs-utils";
4
+ import { readCompanionRegistry } from "./repository-link";
5
5
  import { GlobalConfigStore } from "./global-config-store";
6
6
  import type { LinkedRepository } from "./types";
7
7
 
@@ -0,0 +1,222 @@
1
+ import path from "node:path";
2
+
3
+ import { renderWorkingRuntimeDocuments } from "../../tools/setup";
4
+ import { renderAudienceLayer } from "../../tools/setup/audience-layer";
5
+ import { loadAudienceRegistrations } from "../../tools/setup/audience-registrations";
6
+ import { declaredAudiences } from "../../tools/setup/dynamic-plugins/audiences";
7
+ import { buildSetupInstallationPlan } from "../../tools/setup/engine";
8
+ import { getActiveDistribution } from "../../distribution";
9
+ import { audienceLayerDir } from "../../runtime/audience-layer";
10
+ import {
11
+ readManifest,
12
+ recordedAudience,
13
+ updateManifest,
14
+ withAudience,
15
+ } from "../../runtime/runtime-document-manifest";
16
+ import { readCompanionConfig } from "./config-store";
17
+ import { GlobalConfigStore } from "./global-config-store";
18
+ import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
19
+ import type { WrapRuntime } from "../../runtime/runtime-document-manifest";
20
+ import { readWrapState, type WrapState } from "../../runtime/wrap-state";
21
+ import type { NormalizedRegistration } from "../../tools/setup/registry";
22
+ import type { FrameworkConfig, LinkedRepository } from "./types";
23
+ import { project } from "./working-repo-projection";
24
+
25
+ export interface WrapPassDependencies {
26
+ renderWorkingRuntimeDocuments: typeof renderWorkingRuntimeDocuments;
27
+ readCompanionConfig: typeof readCompanionConfig;
28
+ project: typeof project;
29
+ loadAudienceRegistrations: typeof loadAudienceRegistrations;
30
+ renderAudienceLayer: typeof renderAudienceLayer;
31
+ /** The registered companions, whose stale skill links a re-pin withdraws; the user's registry when absent. */
32
+ globalConfigStore?: GlobalConfigStore;
33
+ }
34
+
35
+ export const wrapPassDeps: WrapPassDependencies = {
36
+ renderWorkingRuntimeDocuments,
37
+ readCompanionConfig,
38
+ project,
39
+ loadAudienceRegistrations,
40
+ renderAudienceLayer,
41
+ };
42
+
43
+ export interface WrapPassOptions {
44
+ /** Reconcile only these runtimes; every active runtime when absent. */
45
+ runtimes?: readonly WrapRuntime[];
46
+ /** The companion config the caller already read; read from files otherwise. */
47
+ config?: FrameworkConfig;
48
+ /**
49
+ * The audience to wrap the checkout for: a name records it, `null` withdraws
50
+ * it, absent keeps the one the manifest records. Never read from the
51
+ * environment: it is the only source of a bare session's audience.
52
+ */
53
+ audience?: string | null;
54
+ }
55
+
56
+ export type WrapPassResult =
57
+ | {
58
+ kind: "written" | "current";
59
+ projectionRoot: string;
60
+ companionPath: string;
61
+ /** The runtime documents this pass answered for, written or current. */
62
+ documents: string[];
63
+ /** The subset of `documents` this pass wrote. */
64
+ writtenDocuments: string[];
65
+ /** Every path an entry reported writing or withdrawing one by one. */
66
+ changed: ProjectionChangedPaths;
67
+ /** The render's warnings, then every entry's, for the caller to present. */
68
+ warnings: string[];
69
+ /** Active runtimes whose tagged entries this pass ran; never inferred from paths. */
70
+ runtimes: ReadonlySet<WrapRuntime>;
71
+ /** The wrap state as this pass left it. */
72
+ wrapState: WrapState;
73
+ /** The audience the checkout is wrapped for after this pass. */
74
+ audience: string | null;
75
+ }
76
+ | { kind: "failed"; message: string };
77
+
78
+ /** An audience a checkout may be wrapped for: declared by the companion, never the reserved base. */
79
+ function wrappableAudience(config: FrameworkConfig, audience: string): boolean {
80
+ return audience !== "base" && declaredAudiences(config).includes(audience);
81
+ }
82
+
83
+ function describeUnwrappable(config: FrameworkConfig, audience: string): string {
84
+ const declared = declaredAudiences(config).filter((name) => name !== "base");
85
+ return `audience "${audience}" cannot be wrapped: the companion declares ${declared.length > 0 ? declared.join(", ") : "no audiences"}.`;
86
+ }
87
+
88
+ /**
89
+ * One pass of the `wrap` scope, restricted to the requested runtimes by each
90
+ * entry's runtime tag. Best-effort per entry like every projection: a failed
91
+ * entry fails the pass while whatever the other entries wrote stays in place.
92
+ */
93
+ export async function runWrapPass(
94
+ companionPath: string,
95
+ repository: LinkedRepository,
96
+ options: WrapPassOptions = {},
97
+ deps: WrapPassDependencies = wrapPassDeps,
98
+ ): Promise<WrapPassResult> {
99
+ const resolvedCompanionPath = path.resolve(companionPath);
100
+ const config = options.config ?? (await deps.readCompanionConfig(resolvedCompanionPath));
101
+
102
+ const audienceWarnings: string[] = [];
103
+ let audience: string | null;
104
+ if (options.audience === undefined) {
105
+ const recorded = recordedAudience(await readManifest(repository.path));
106
+ /** A recorded audience the companion no longer declares falls back to base, never fails a prepare. */
107
+ if (recorded && !wrappableAudience(config, recorded)) {
108
+ audienceWarnings.push(
109
+ `${describeUnwrappable(config, recorded)} ${repository.id} is wrapped for base only.`,
110
+ );
111
+ audience = null;
112
+ } else {
113
+ audience = recorded;
114
+ }
115
+ } else {
116
+ audience = options.audience;
117
+ if (audience && !wrappableAudience(config, audience)) {
118
+ return { kind: "failed", message: describeUnwrappable(config, audience) };
119
+ }
120
+ }
121
+
122
+ let wrapped: { audience: string; registrations: NormalizedRegistration[] } | undefined;
123
+ if (audience) {
124
+ const loaded = await deps.loadAudienceRegistrations(resolvedCompanionPath, config, audience);
125
+ audienceWarnings.push(...loaded.warnings);
126
+ await deps.renderAudienceLayer({
127
+ companionPath: resolvedCompanionPath,
128
+ config,
129
+ audience,
130
+ activeProviders: buildSetupInstallationPlan(
131
+ config,
132
+ getActiveDistribution().registry.baseRegistrations(),
133
+ ).activeProviders,
134
+ registrations: loaded.registrations,
135
+ });
136
+ wrapped = { audience, registrations: loaded.registrations };
137
+ }
138
+
139
+ const rendered = await deps.renderWorkingRuntimeDocuments(
140
+ resolvedCompanionPath,
141
+ config,
142
+ repository.path,
143
+ undefined,
144
+ wrapped,
145
+ );
146
+ const result = await deps.project("wrap", {
147
+ repoPath: repository.path,
148
+ companionPath: resolvedCompanionPath,
149
+ repository,
150
+ config,
151
+ runtimeDocuments: rendered.documents,
152
+ audienceLayerDir: audience ? audienceLayerDir(resolvedCompanionPath, audience) : null,
153
+ globalConfigStore: deps.globalConfigStore ?? new GlobalConfigStore(),
154
+ ...(options.runtimes ? { runtimes: options.runtimes } : {}),
155
+ });
156
+
157
+ const failure = firstFailure(result.outcomes);
158
+ if (failure) return { kind: "failed", message: entryFailureMessage(failure, repository.id) };
159
+
160
+ const before = recordedAudience(await readManifest(repository.path));
161
+ const audienceChanged = before !== audience;
162
+ if (audienceChanged) {
163
+ await updateManifest(repository.path, (manifest) => withAudience(manifest, audience));
164
+ }
165
+
166
+ /** Read once under the caller's lock, so it is exactly what the pass recorded. */
167
+ let wrapState: WrapState;
168
+ try {
169
+ wrapState = await readWrapState(repository.path);
170
+ } catch (error) {
171
+ return {
172
+ kind: "failed",
173
+ message: `could not read the runtime document manifest for ${repository.id}: ${(error as Error).message}`,
174
+ };
175
+ }
176
+ /** Inactive runtimes' entries run too, to withdraw; that is not covering them. */
177
+ const active = new Set(config.allowedAgents ?? []);
178
+ const runtimes = new Set(
179
+ result.outcomes.flatMap((outcome) =>
180
+ outcome.runtime && outcome.state !== "skipped" && active.has(outcome.runtime)
181
+ ? [outcome.runtime]
182
+ : [],
183
+ ),
184
+ );
185
+
186
+ /** The destinations the surfaces rendered; the rest of the pass is scaffolding. */
187
+ const destinations = new Set(
188
+ rendered.documents.map((document) => document.path.split("/").join(path.sep)),
189
+ );
190
+ const placed = result.outcomes.filter((outcome) => destinations.has(outcome.path));
191
+ const documents = placed.map((outcome) => outcome.path);
192
+ const writtenDocuments = placed
193
+ .filter((outcome) => outcome.state === "written")
194
+ .map((outcome) => outcome.path);
195
+
196
+ return {
197
+ kind:
198
+ audienceChanged || result.outcomes.some((outcome) => outcome.state === "written")
199
+ ? "written"
200
+ : "current",
201
+ projectionRoot: result.root,
202
+ companionPath: resolvedCompanionPath,
203
+ documents: [...new Set(documents)],
204
+ writtenDocuments: [...new Set(writtenDocuments)],
205
+ changed: {
206
+ written: result.outcomes.flatMap((outcome) => outcome.changed?.written ?? []),
207
+ removed: result.outcomes.flatMap((outcome) => outcome.changed?.removed ?? []),
208
+ },
209
+ warnings: [
210
+ ...audienceWarnings,
211
+ ...rendered.warnings,
212
+ ...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
213
+ /** A degradable failure does not fail the pass, but it is never silent. */
214
+ ...result.outcomes
215
+ .filter((outcome) => outcome.state === "failed" && outcome.degradable)
216
+ .map((outcome) => entryFailureMessage(outcome, repository.id)),
217
+ ],
218
+ runtimes,
219
+ wrapState,
220
+ audience,
221
+ };
222
+ }