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

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 (183) 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 +6 -4
  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/companion/companion.ts +5 -1
  19. package/src/cli/commands/companion/link.ts +2 -2
  20. package/src/cli/commands/companion/sync.ts +92 -0
  21. package/src/cli/commands/doctor.ts +0 -3
  22. package/src/cli/commands/launch/shared.ts +23 -5
  23. package/src/cli/commands/report/collector.ts +72 -78
  24. package/src/cli/commands/report/contract.ts +40 -1
  25. package/src/cli/commands/report/highlight.ts +27 -0
  26. package/src/cli/commands/report/index.ts +11 -18
  27. package/src/cli/commands/report/renderer.ts +199 -2
  28. package/src/cli/commands/report/types.ts +26 -1
  29. package/src/cli/commands/shared/companion-selection.ts +107 -10
  30. package/src/cli/commands/studio/areas.ts +68 -0
  31. package/src/cli/commands/studio/index.ts +69 -0
  32. package/src/cli/commands/studio/inventory.ts +55 -0
  33. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  34. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  35. package/src/cli/commands/studio/payload.ts +184 -0
  36. package/src/cli/commands/studio/routes.ts +2 -0
  37. package/src/cli/commands/studio/selection.ts +61 -0
  38. package/src/cli/commands/studio/server.ts +201 -0
  39. package/src/cli/commands/studio/snapshot.ts +63 -0
  40. package/src/cli/commands/studio/topology.ts +199 -0
  41. package/src/cli/commands/studio/views/client.ts +197 -0
  42. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  43. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  44. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  45. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  46. package/src/cli/commands/studio/views/document.tsx +256 -0
  47. package/src/cli/commands/studio/views/error.tsx +20 -0
  48. package/src/cli/commands/studio/views/model.ts +34 -0
  49. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  50. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  51. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  52. package/src/cli/commands/studio/views/styles.ts +389 -0
  53. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  54. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  56. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  57. package/src/cli/commands/unwrap.ts +70 -0
  58. package/src/cli/commands/wrap.ts +164 -0
  59. package/src/cli/main.ts +68 -19
  60. package/src/cli/parse-flags.ts +36 -11
  61. package/src/cli/usage.ts +12 -3
  62. package/src/framework.ts +1 -7
  63. package/src/hooks/session-banner.ts +64 -11
  64. package/src/hooks/session-guidance.ts +40 -0
  65. package/src/hooks/validate-artifact-path.ts +108 -35
  66. package/src/lib/fs-utils.ts +9 -0
  67. package/src/lib/install.ts +33 -0
  68. package/src/lib/orchestrator/adapters/base.ts +14 -125
  69. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  70. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  71. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  72. package/src/lib/orchestrator/config-store.ts +2 -21
  73. package/src/lib/orchestrator/editor.ts +12 -22
  74. package/src/lib/orchestrator/framework-context.ts +17 -6
  75. package/src/lib/orchestrator/global-config-store.ts +1 -1
  76. package/src/lib/orchestrator/launcher.ts +97 -7
  77. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  78. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  79. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  80. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  81. package/src/lib/orchestrator/projection-entries.ts +377 -0
  82. package/src/lib/orchestrator/projection-record.ts +56 -0
  83. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  84. package/src/lib/orchestrator/projection-types.ts +169 -0
  85. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  86. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  87. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  88. package/src/lib/orchestrator/types.ts +1 -0
  89. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  90. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  91. package/src/lib/package-paths.ts +11 -1
  92. package/src/opencode/companion-hooks.ts +89 -245
  93. package/src/opencode/companion-policy.ts +35 -10
  94. package/src/opencode/index.ts +1 -0
  95. package/src/opencode/projected-guidance.ts +56 -0
  96. package/src/opencode/tui.tsx +13 -4
  97. package/src/playbooks/companion-guidance.ts +32 -116
  98. package/src/plugins.ts +0 -1
  99. package/src/runtime/companion-git-state.ts +156 -0
  100. package/src/runtime/companion-git.ts +203 -0
  101. package/src/runtime/companion-guidance.ts +222 -0
  102. package/src/runtime/companion-sync.ts +298 -0
  103. package/src/runtime/env-names.ts +30 -0
  104. package/src/runtime/env.ts +67 -35
  105. package/src/runtime/framework.ts +10 -0
  106. package/src/runtime/freshness.ts +58 -0
  107. package/src/runtime/index.ts +104 -0
  108. package/src/runtime/install.ts +30 -0
  109. package/src/runtime/policy.ts +66 -0
  110. package/src/runtime/projected-guidance.ts +45 -0
  111. package/src/runtime/projection.ts +224 -0
  112. package/src/runtime/repo-local.ts +64 -0
  113. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  114. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  115. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  116. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  117. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  118. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  119. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  122. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  123. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  124. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  125. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  126. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  127. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  128. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  129. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  130. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  131. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +147 -0
  132. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  133. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  134. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  135. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  136. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  137. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  138. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  139. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  140. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  141. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  142. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  143. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +147 -0
  144. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  145. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  146. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  147. package/src/templates/report-assets/README.md +32 -0
  148. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  149. package/src/templates/report-assets/mermaid.min.js +4376 -0
  150. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  151. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  152. package/src/tools/setup/capabilities/graphify.ts +16 -7
  153. package/src/tools/setup/capabilities/openspec.ts +63 -56
  154. package/src/tools/setup/capabilities/tokensave.ts +47 -0
  155. package/src/tools/setup/engine.ts +34 -6
  156. package/src/tools/setup/mate.ts +42 -13
  157. package/src/tools/setup/plugin.ts +9 -0
  158. package/src/tools/setup/plugins/guidance.ts +11 -1
  159. package/src/tools/setup/providers/claude-format.ts +49 -4
  160. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  161. package/src/tools/setup/providers/claude.ts +55 -220
  162. package/src/tools/setup/providers/opencode.ts +41 -14
  163. package/src/tools/setup/runtime-documents.ts +174 -0
  164. package/src/tools/setup/surface-target.ts +50 -0
  165. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  166. package/src/tools/setup/working-repo-local-state.ts +21 -1
  167. package/src/tools/setup.ts +25 -3
  168. package/wrappers/bin/graphify +57 -8
  169. package/wrappers/bin/openspec +50 -3
  170. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  171. package/src/cli/commands/cap/headroom.ts +0 -52
  172. package/src/cli/commands/workspace/list.ts +0 -25
  173. package/src/cli/commands/workspace/materialize.ts +0 -46
  174. package/src/cli/commands/workspace/workspace.ts +0 -22
  175. package/src/hooks/artifact-finish-nudge.ts +0 -244
  176. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  177. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  178. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  179. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  180. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  181. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  182. package/src/tools/setup/capabilities/headroom.ts +0 -57
  183. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -3,229 +3,23 @@ import path from "node:path";
3
3
 
4
4
  import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
5
5
 
6
+ import {
7
+ companionForkRefusal,
8
+ syncCompanionUnattended,
9
+ unattendedSyncStalenessLines,
10
+ } from "../runtime/companion-sync";
11
+ import { hasLaunchEnvironment } from "../runtime/env";
6
12
  import {
7
13
  buildArtifactError,
8
14
  extractPatchPaths,
15
+ isArtifactPath,
16
+ normalizeTargetPath,
9
17
  readContext,
10
18
  shouldBlockArtifactWrite,
11
19
  type CompanionContext,
12
20
  } from "./companion-policy";
13
21
 
14
- const ARCHIVE_ENTRY_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
15
- const ARCHIVE_PATH_PATTERN =
16
- /(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
17
-
18
- function readArchiveEntries(archiveDir: string): Set<string> {
19
- try {
20
- const entries = new Set<string>();
21
- for (const entry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
22
- if (entry.isDirectory() && ARCHIVE_ENTRY_PATTERN.test(entry.name)) entries.add(entry.name);
23
- }
24
- return entries;
25
- } catch {
26
- return new Set();
27
- }
28
- }
29
-
30
- function shellSplit(command: string): string[] {
31
- const parts: string[] = [];
32
- let current = "";
33
- let quote: "'" | '"' | null = null;
34
- let escaping = false;
35
-
36
- for (let index = 0; index < command.length; index += 1) {
37
- const char = command[index];
38
- if (escaping) {
39
- current += char;
40
- escaping = false;
41
- continue;
42
- }
43
- if (char === "\\" && quote !== "'") {
44
- const next = command[index + 1] ?? "";
45
- if (/^[\\\s'"]$/.test(next)) {
46
- escaping = true;
47
- continue;
48
- }
49
- current += char;
50
- continue;
51
- }
52
- if ((char === "'" || char === '"') && !quote) {
53
- quote = char;
54
- continue;
55
- }
56
- if (quote === char) {
57
- quote = null;
58
- continue;
59
- }
60
- if (!quote && /\s/.test(char)) {
61
- if (current) {
62
- parts.push(current);
63
- current = "";
64
- }
65
- continue;
66
- }
67
- current += char;
68
- }
69
-
70
- if (current) parts.push(current);
71
- return parts;
72
- }
73
-
74
- function extractArchivedChangeFromPath(value: string): string | null {
75
- const normalized = value.replace(/\\/g, "/");
76
- const match = normalized.match(ARCHIVE_PATH_PATTERN);
77
- return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
78
- }
79
-
80
- function extractArchiveCommandChange(command: string): string | null {
81
- const parts = shellSplit(command);
82
- const archiveIndex = parts.findIndex(
83
- (part, index) =>
84
- part === "archive" &&
85
- index > 0 &&
86
- (parts[index - 1] === "openspec" || parts[index - 1].endsWith("/openspec")),
87
- );
88
- if (archiveIndex < 0) return null;
89
-
90
- for (let index = archiveIndex + 1; index < parts.length; index += 1) {
91
- const part = parts[index];
92
- if (!part || part === "--") continue;
93
- if (part.startsWith("-")) {
94
- if (part === "--store") index += 1;
95
- continue;
96
- }
97
- return part;
98
- }
99
- return null;
100
- }
101
-
102
- function extractMoveArchiveChange(commandText: string): string | null {
103
- const parts = shellSplit(commandText);
104
- const moveCommands = new Set([
105
- "mv",
106
- "move",
107
- "move-item",
108
- "cmd",
109
- "cmd.exe",
110
- "powershell",
111
- "powershell.exe",
112
- "pwsh",
113
- "pwsh.exe",
114
- ]);
115
- const isPython = (part: string) =>
116
- /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() ?? part);
117
-
118
- for (let index = 0; index < parts.length; index += 1) {
119
- const command = parts[index].toLowerCase();
120
- if (moveCommands.has(command)) {
121
- for (const part of parts.slice(index + 1)) {
122
- const change = extractArchivedChangeFromPath(part);
123
- if (change) return change;
124
- }
125
- }
126
- if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(commandText)) {
127
- return extractArchivedChangeFromPath(commandText);
128
- }
129
- }
130
- return null;
131
- }
132
-
133
- function collectCommands(value: unknown): string[] {
134
- if (!value || typeof value !== "object") return [];
135
- const record = value as Record<string, unknown>;
136
- const commands: string[] = [];
137
- if (typeof record.command === "string") commands.push(record.command);
138
- for (const key of ["args", "tool_input"] as const) {
139
- const nested = record[key];
140
- if (nested && typeof nested === "object") {
141
- const command = (nested as Record<string, unknown>).command;
142
- if (typeof command === "string") commands.push(command);
143
- }
144
- }
145
- return commands;
146
- }
147
-
148
- function commandSucceeded(value: unknown): boolean {
149
- if (!value || typeof value !== "object") return true;
150
- const record = value as Record<string, unknown>;
151
- const candidates = [
152
- record,
153
- record.result,
154
- record.output,
155
- record.tool_output,
156
- record.tool_response,
157
- ];
158
- for (const candidate of candidates) {
159
- if (!candidate || typeof candidate !== "object") continue;
160
- const result = candidate as Record<string, unknown>;
161
- if (typeof result.success === "boolean") return result.success;
162
- for (const key of ["exitCode", "exit_code", "status", "statusCode", "status_code"] as const) {
163
- const status = result[key];
164
- if (typeof status === "number") return status === 0;
165
- if (typeof status === "string" && status.trim()) return Number(status) === 0;
166
- }
167
- }
168
- return true;
169
- }
170
-
171
- function detectCommandArchive(input: unknown, output: unknown): string | null {
172
- if (!commandSucceeded(input) || !commandSucceeded(output)) return null;
173
- for (const command of [...collectCommands(input), ...collectCommands(output)]) {
174
- const change = extractArchiveCommandChange(command) ?? extractMoveArchiveChange(command);
175
- if (change) return change;
176
- }
177
- return null;
178
- }
179
-
180
- function detectNewlyArchivedChanges(archiveDir: string, snapshot: Set<string>): string[] {
181
- const current = readArchiveEntries(archiveDir);
182
- return [...current].filter((entry) => !snapshot.has(entry)).toSorted();
183
- }
184
-
185
- function appendOpenSpecFinishNudge(
186
- context: CompanionContext,
187
- archiveSnapshot: Set<string> | undefined,
188
- nudgedCommandChanges: Set<string>,
189
- input: { tool?: unknown },
190
- output: { output?: string },
191
- ): void {
192
- if (!context.gitAutoModeEnabled) return;
193
-
194
- const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
195
- const changes: string[] = [];
196
- if (archiveSnapshot) {
197
- for (const entry of detectNewlyArchivedChanges(archiveDir, archiveSnapshot)) {
198
- changes.push(entry.slice("YYYY-MM-DD-".length));
199
- }
200
- }
201
- const commandChange =
202
- String(input.tool ?? "").toLowerCase() === "bash" ? detectCommandArchive(input, output) : null;
203
- if (commandChange && !nudgedCommandChanges.has(commandChange)) {
204
- changes.push(commandChange);
205
- nudgedCommandChanges.add(commandChange);
206
- }
207
-
208
- const uniqueChanges = [...new Set(changes)];
209
- if (uniqueChanges.length === 0) return;
210
- const nudges = uniqueChanges.map(
211
- (change) =>
212
- `ACTION REQUIRED: An openspec change (${change}) was just archived. ` +
213
- `Immediately invoke the mate-artifact-finish skill to complete the finish pipeline ` +
214
- "(invoke the CLI as `mate`, never through a companion-local wrapper path or as `" +
215
- "$MATE_COMPANION_BIN_PATH/mate`). " +
216
- "`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
217
- "do not hand-commit, do not hand-tag, and do not re-apply delta specs manually. " +
218
- `Follow the skill's guardrails, completing the non-interactive happy path without asking for confirmation.`,
219
- );
220
- output.output = [output.output, ...nudges]
221
- .flatMap((part) => {
222
- const trimmed = (part ?? "").trim();
223
- return trimmed ? [trimmed] : [];
224
- })
225
- .join("\n\n");
226
- }
227
-
228
- const REACT_DOCTOR_VERSION = "0.8.1";
22
+ const REACT_DOCTOR_VERSION = "0.9.13";
229
23
 
230
24
  const REACT_DOCTOR_NON_LINT_FAILURES = [
231
25
  /No React dependency found/i,
@@ -311,37 +105,97 @@ async function runReactDoctorScan(
311
105
  }
312
106
  }
313
107
 
108
+ /**
109
+ * Keyed by companion so a double-loaded plugin repairs at most once per
110
+ * session, which is the guarantee the spec asks for.
111
+ */
112
+ const repairedCompanions = new Set<string>();
113
+
114
+ /**
115
+ * Session start is the only point at which the companion may move without
116
+ * invalidating a read the session has already taken. The launch-environment
117
+ * gate comes first so a Managed Session does no Git work at all; the consent
118
+ * gate is the operation's own.
119
+ */
120
+ export async function repairCompanionGitOnce(
121
+ context: CompanionContext,
122
+ client: PluginInput["client"] | undefined,
123
+ env: Record<string, string | undefined> = process.env,
124
+ ): Promise<string[]> {
125
+ if (!context.companionPath || hasLaunchEnvironment(env)) return [];
126
+ if (repairedCompanions.has(context.companionPath)) return [];
127
+ repairedCompanions.add(context.companionPath);
128
+
129
+ const notes = unattendedSyncStalenessLines(syncCompanionUnattended(context.companionPath));
130
+ if (notes.length === 0) return [];
131
+
132
+ /**
133
+ * Operator-facing only: a model told to run the command would run it
134
+ * unattended. Delivery is best-effort in both directions — a client that
135
+ * throws rather than rejecting must not cost the caller its notes.
136
+ */
137
+ try {
138
+ await client?.tui?.showToast({
139
+ query: { directory: context.repositoryPath },
140
+ body: {
141
+ title: `${context.frameworkName} companion`,
142
+ message: notes.join("\n"),
143
+ variant: "warning",
144
+ },
145
+ });
146
+ } catch {
147
+ /** The note survives in the persisted record and the TUI's staleness lines. */
148
+ }
149
+ return notes;
150
+ }
151
+
152
+ /** Test seam: the once-per-session guard is process-wide by design. */
153
+ export function resetCompanionGitRepairGuard(): void {
154
+ repairedCompanions.clear();
155
+ }
156
+
157
+ /**
158
+ * Writing artifacts on top of a forked companion is the state in which the
159
+ * eventual reconciliation destroys work. The verdict comes from `runtime/`, so
160
+ * the Claude hook and this middleware refuse the same writes for the same
161
+ * reason — including the launch-environment gate that keeps a Managed
162
+ * Launch's Git decision authoritative for the session it started.
163
+ */
164
+ export function refuseForkedCompanionWrite(
165
+ context: CompanionContext,
166
+ filePath: string,
167
+ env: Record<string, string | undefined> = process.env,
168
+ ): void {
169
+ if (!filePath || !isArtifactPath(filePath)) return;
170
+ if (!normalizeTargetPath(context, filePath).startsWith(path.normalize(context.companionPath))) {
171
+ return;
172
+ }
173
+ const refusal = companionForkRefusal(env, context.companionPath);
174
+ if (refusal) throw new Error(refusal);
175
+ }
176
+
314
177
  type PluginEventInput = Parameters<NonNullable<Hooks["event"]>>[0];
315
178
  type ToolBeforeInput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[0];
316
179
  type ToolBeforeOutput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[1];
317
180
  type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
318
- type ToolAfterOutput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[1];
319
-
320
- interface ArchiveCallSnapshot {
321
- sessionID: string;
322
- entries: Set<string>;
323
- }
324
181
 
325
182
  export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
326
183
  const { client, $ } = pluginInput;
327
- const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
184
+ const context = readContext();
328
185
  if (!context.companionPath || !context.repositoryPath) return {};
329
186
 
330
- const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
331
- const archiveCallSnapshots = new Map<string, ArchiveCallSnapshot>();
332
- const nudgedCommandChanges = new Set<string>();
187
+ /**
188
+ * The repair sits above the returned hooks, so anything escaping it would
189
+ * cost the session every guardrail below — the artifact guard, the React
190
+ * Doctor scan — to save a synchronization that is optional by design.
191
+ */
192
+ await repairCompanionGitOnce(context, client).catch(() => []);
193
+
333
194
  const dirtyReactDoctorSessions = new Set<string>();
334
195
  const reactDoctorScansInFlight = new Set<string>();
335
196
 
336
197
  return {
337
198
  event: async ({ event }: PluginEventInput) => {
338
- if (event.type === "session.deleted") {
339
- const sessionID = event.properties.info.id;
340
- for (const [callID, snapshot] of archiveCallSnapshots) {
341
- if (snapshot.sessionID === sessionID) archiveCallSnapshots.delete(callID);
342
- }
343
- return;
344
- }
345
199
  if (event.type !== "session.idle") return;
346
200
  const sessionID = event.properties.sessionID;
347
201
  if (
@@ -361,31 +215,21 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
361
215
  if (filePath && shouldBlockArtifactWrite(context, filePath)) {
362
216
  throw new Error(buildArtifactError(context, filePath));
363
217
  }
218
+ refuseForkedCompanionWrite(context, filePath);
364
219
  }
365
220
  if (toolName === "apply_patch") {
366
221
  for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
367
222
  if (shouldBlockArtifactWrite(context, filePath)) {
368
223
  throw new Error(buildArtifactError(context, filePath));
369
224
  }
225
+ refuseForkedCompanionWrite(context, filePath);
370
226
  }
371
227
  }
372
- if (context.gitAutoModeEnabled) {
373
- archiveCallSnapshots.set(input.callID, {
374
- sessionID: input.sessionID,
375
- entries: readArchiveEntries(archiveDir),
376
- });
377
- }
378
228
  },
379
- "tool.execute.after": async (input: ToolAfterInput, output: ToolAfterOutput) => {
229
+ "tool.execute.after": async (input: ToolAfterInput) => {
380
230
  if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
381
231
  dirtyReactDoctorSessions.add(input.sessionID);
382
232
  }
383
- const archiveSnapshot = archiveCallSnapshots.get(input.callID)?.entries;
384
- archiveCallSnapshots.delete(input.callID);
385
- appendOpenSpecFinishNudge(context, archiveSnapshot, nudgedCommandChanges, input, output);
386
- },
387
- dispose: async () => {
388
- archiveCallSnapshots.clear();
389
233
  },
390
234
  };
391
235
  };
@@ -2,10 +2,18 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
- import { readCompanionRuntimeContext, type CompanionRuntimeContext } from "../runtime/env";
5
+ import { persistedCompanionGitStalenessLines } from "../runtime/companion-sync";
6
+ import { resolveCompanionRuntime, type CompanionRuntimeContext } from "../runtime/env";
7
+ import { projectionFreshness, projectionStalenessLines } from "../runtime/freshness";
6
8
 
7
9
  export type CompanionContext = CompanionRuntimeContext & {
8
10
  agentsMd: string;
11
+ /**
12
+ * Operator-facing session state: a stale projection, and an unfinished
13
+ * companion Git synchronization. Never reaches a model payload — the TUI is
14
+ * its only reader.
15
+ */
16
+ stalenessLines: string[];
9
17
  };
10
18
 
11
19
  const ARTIFACT_BASENAMES = new Set([
@@ -158,20 +166,37 @@ export function buildArtifactError(context: CompanionContext, filePath: string):
158
166
  ].join("\n");
159
167
  }
160
168
 
161
- export function readContext(companionPath: string): CompanionContext {
162
- const agentsMdPath = path.join(companionPath, "AGENTS.md");
169
+ /**
170
+ * Resolves its own companion — launch environment first, Projection Root
171
+ * second — rather than taking one from the caller. `AGENTS.md` follows the
172
+ * resolved companion so the two cannot name different repositories.
173
+ */
174
+ export function readContext(
175
+ env: Record<string, string | undefined> = process.env,
176
+ cwd: string = process.cwd(),
177
+ ): CompanionContext {
178
+ const { context, projection } = resolveCompanionRuntime(env, cwd);
179
+
163
180
  let agentsMd = "";
164
- try {
165
- if (fs.existsSync(agentsMdPath)) {
166
- agentsMd = fs.readFileSync(agentsMdPath, "utf8");
181
+ if (context.companionPath) {
182
+ const agentsMdPath = path.join(context.companionPath, "AGENTS.md");
183
+ try {
184
+ if (fs.existsSync(agentsMdPath)) {
185
+ agentsMd = fs.readFileSync(agentsMdPath, "utf8");
186
+ }
187
+ } catch {
188
+ // Ignore read errors; AGENTS.md is optional
167
189
  }
168
- } catch {
169
- // Ignore read errors; AGENTS.md is optional
170
190
  }
171
191
 
172
192
  return {
173
- ...readCompanionRuntimeContext(process.env),
174
- companionPath,
193
+ ...context,
175
194
  agentsMd,
195
+ stalenessLines: projection
196
+ ? [
197
+ ...projectionStalenessLines(projection, projectionFreshness(projection)),
198
+ ...persistedCompanionGitStalenessLines(context.companionPath),
199
+ ]
200
+ : [],
176
201
  };
177
202
  }
@@ -6,4 +6,5 @@
6
6
  */
7
7
  export { default as tuiPlugin } from "./tui";
8
8
  export { CompanionHooksPlugin } from "./companion-hooks";
9
+ export { resolveOpenCodeGuidance, type OpenCodeGuidanceResolution } from "./projected-guidance";
9
10
  export * from "./companion-policy";
@@ -0,0 +1,56 @@
1
+ import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
2
+ import { MATE_ENV } from "../runtime/env-names";
3
+ import { hasLaunchEnvironment } from "../runtime/env";
4
+ import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
5
+ import { readCompanionPolicy } from "../runtime/policy";
6
+ import { resolveProjection } from "../runtime/projection";
7
+
8
+ /**
9
+ * `null` guidance with no errors is the inert case — no companion resolves, so
10
+ * the plugin contributes nothing. Errors are startup failures: a managed launch
11
+ * that failed to inject its payload is broken and must say so.
12
+ */
13
+ export interface OpenCodeGuidanceResolution {
14
+ guidance: MateGuidanceFile | null;
15
+ errors: string[];
16
+ }
17
+
18
+ /**
19
+ * The OpenCode guidance payload, from the launch environment when Mate started
20
+ * the session and from the Projection Root otherwise — the same env-first,
21
+ * projection-second contract `readCompanionRuntimeContext` already follows.
22
+ *
23
+ * The projected branch needs nothing but the enabled Capability names: the
24
+ * payload carries `$MATE_*` placeholders that the plugin materializes from its
25
+ * own resolved context, so no path is resolved here. Reading the names live
26
+ * means a Capability toggled after a wrap changes the guidance on the next
27
+ * session with no re-wrap.
28
+ *
29
+ * A missing payload while a launch environment *is* present stays a hard error.
30
+ * That combination means a managed launch failed to inject, which the projection
31
+ * must not paper over.
32
+ */
33
+ export function resolveOpenCodeGuidance(
34
+ env: Record<string, string | undefined> = process.env,
35
+ cwd: string = process.cwd(),
36
+ ): OpenCodeGuidanceResolution {
37
+ const raw = env[MATE_ENV.guidanceJson];
38
+ if (raw?.trim()) {
39
+ const { guidance, errors } = parseGuidanceContent(raw);
40
+ return { guidance: errors.length > 0 ? null : guidance, errors };
41
+ }
42
+
43
+ if (hasLaunchEnvironment(env)) {
44
+ return {
45
+ guidance: null,
46
+ errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
47
+ };
48
+ }
49
+
50
+ const projection = resolveProjection(cwd);
51
+ if (!projection) return { guidance: null, errors: [] };
52
+
53
+ const policy = readCompanionPolicy(projection.companionPath);
54
+ const guidance = buildOpenCodeGuidance(policy.enabledCapabilities.map((name) => ({ name })));
55
+ return { guidance: guidance.errors.length > 0 ? null : guidance, errors: guidance.errors };
56
+ }
@@ -1,12 +1,18 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- /* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope */
2
+ /* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope, react/jsx-key, react-doctor/jsx-key */
3
+ /**
4
+ * Solid JSX, not React: `key` is not a reconciliation prop here and `TextProps`
5
+ * does not accept it, so the jsx-key rules cannot be satisfied — only suppressed.
6
+ * Static arrays render through `.map`; a reactive one would need `<For>`.
7
+ */
3
8
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui";
4
9
 
10
+ import { mateVersion } from "../runtime/install";
5
11
  import { readContext } from "./companion-policy";
6
12
 
7
13
  const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
8
14
  const NARROW_TERMINAL_WIDTH = 80;
9
- const MATE_VERSION = process.env.MATE_VERSION ?? "unknown";
15
+ const MATE_VERSION = process.env.MATE_VERSION ?? mateVersion();
10
16
 
11
17
  function SessionContext({
12
18
  api,
@@ -17,7 +23,7 @@ function SessionContext({
17
23
  compact?: boolean;
18
24
  sidebar?: boolean;
19
25
  }) {
20
- const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
26
+ const context = readContext();
21
27
  const theme = api.theme.current;
22
28
 
23
29
  if (compact) {
@@ -35,12 +41,15 @@ function SessionContext({
35
41
  <text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
36
42
  <text fg={theme.textMuted}>repo: {context.repositoryPath}</text>
37
43
  <text fg={theme.textMuted}>mate: {context.companionPath}</text>
44
+ {context.stalenessLines.map((note) => (
45
+ <text fg={theme.warning ?? theme.textMuted}>{note}</text>
46
+ ))}
38
47
  </box>
39
48
  );
40
49
  }
41
50
 
42
51
  const tui: TuiPlugin = async (api) => {
43
- const context = readContext(process.env.MATE_ARTIFACT_PATH ?? "");
52
+ const context = readContext();
44
53
  if (!context.companionPath || !context.repositoryPath) {
45
54
  return;
46
55
  }