@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
@@ -0,0 +1,222 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "./framework";
4
+ import { GUIDANCE_FILE_VERSION, type MateGuidanceFile } from "./guidance";
5
+
6
+ /**
7
+ * The guidance builders, on the session-runtime side of core.
8
+ *
9
+ * They live here rather than under `playbooks/` because a hook shim and the
10
+ * OpenCode plugin both have to build guidance without a launch, and both may
11
+ * only reach `runtime/` — the import-isolation tests enforce that. What kept
12
+ * them out was a single resolved default, `getWrapperBinPath()`; taking the
13
+ * path as data instead makes the whole set pure. `playbooks/companion-guidance`
14
+ * still supplies that default for the framework-side callers.
15
+ */
16
+
17
+ /**
18
+ * What guidance actually reads off a launch context. Structurally satisfied by
19
+ * `AdapterContext`, so a launch passes its own context unchanged, and equally
20
+ * by a value composed from the projection.
21
+ */
22
+ export interface GuidanceCapability {
23
+ name: string;
24
+ }
25
+
26
+ export interface GuidanceContext {
27
+ companionPath: string;
28
+ repository: { id: string; path: string };
29
+ capabilities?: GuidanceCapability[];
30
+ }
31
+
32
+ export function hasGraphifyCapability(capabilities: GuidanceCapability[] = []): boolean {
33
+ return capabilities.some((capability) => capability.name === "graphify");
34
+ }
35
+
36
+ export function hasOpenspecCapability(capabilities: GuidanceCapability[] = []): boolean {
37
+ return capabilities.some((capability) => capability.name === "openspec");
38
+ }
39
+
40
+ export function hasTokensaveCapability(capabilities: GuidanceCapability[] = []): boolean {
41
+ return capabilities.some((capability) => capability.name === "tokensave");
42
+ }
43
+
44
+ export const GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT =
45
+ "$MATE_ARTIFACT_PATH/.graphify/$MATE_REPO_ID/graphify-out/";
46
+
47
+ export function buildCodebaseExplorationGuidanceSection(
48
+ options: {
49
+ useGraphify?: boolean;
50
+ useTokensave?: boolean;
51
+ } = {},
52
+ ): string {
53
+ const { useGraphify = false, useTokensave = false } = options;
54
+
55
+ if (!useGraphify && !useTokensave) {
56
+ return "";
57
+ }
58
+
59
+ if (useGraphify && useTokensave) {
60
+ return `<codebase-exploration-rules priority="mandatory">
61
+ <path role="graphify-out">${GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT}</path>
62
+ <trigger>Codebase-understanding: architecture, tracing, integrations, impact, "how does X work?"</trigger>
63
+ <order>tokensave -> graphify -> grep/glob/read. MUST NOT skip steps.
64
+ 1. tokensave_context first.
65
+ 2. If tokensave is empty/file-only/irrelevant, run graphify query "<question>"; use graphify path/explain to deepen.
66
+ 3. Use grep/glob/read only after tokensave and graphify were tried.</order>
67
+ <notes>Dirty graph files are expected. Use wiki/index.md for broad navigation; GRAPH_REPORT.md only if query/path/explain fall short.</notes>
68
+ <post-edit>After code changes, run ${FRAMEWORK_NAME} cap index.</post-edit>
69
+ </codebase-exploration-rules>`;
70
+ }
71
+
72
+ if (useGraphify) {
73
+ return `<codebase-exploration-rules priority="mandatory">
74
+ <path role="graphify-out">${GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT}</path>
75
+ <trigger>Codebase-understanding: architecture, tracing, integrations, impact, "how does X work?"</trigger>
76
+ <order>graphify -> grep/glob/read. MUST try graphify before raw source.
77
+ 1. graphify query "<question>" first.
78
+ 2. Use graphify path "<A>" "<B>" or graphify explain "<concept>" to deepen.
79
+ 3. Use grep/glob/read only after graphify was tried.</order>
80
+ <notes>Dirty graph files are expected. Use wiki/index.md for broad navigation; GRAPH_REPORT.md only if query/path/explain fall short.</notes>
81
+ <post-edit>After code changes, run ${FRAMEWORK_NAME} cap index --graphify.</post-edit>
82
+ </codebase-exploration-rules>`;
83
+ }
84
+
85
+ return `<codebase-exploration-rules priority="mandatory">
86
+ <trigger>Codebase-understanding: architecture, tracing, integrations, impact, "how does X work?"</trigger>
87
+ <order>tokensave -> grep/glob/read. MUST try tokensave before raw source.
88
+ 1. tokensave_context first.
89
+ 2. Use grep/glob/read only after tokensave is empty or irrelevant.</order>
90
+ <post-edit>After code changes, run ${FRAMEWORK_NAME} cap index --tokensave.</post-edit>
91
+ </codebase-exploration-rules>`;
92
+ }
93
+
94
+ /**
95
+ * Build just the `<companion-policy>` XML block: paths, CLI tools, and
96
+ * mandatory rules (including the capability-gated `openspec-publish` rule).
97
+ * Does not include codebase-exploration guidance — see
98
+ * `buildCompanionGuidance` for the merged single-string form, or call
99
+ * `buildCodebaseExplorationGuidanceSection` directly when that guidance is
100
+ * delivered through its own channel (as OpenCode's guidance contract does).
101
+ */
102
+ export function buildCompanionPolicyXml(
103
+ context: GuidanceContext,
104
+ options: { wrapperBinPath: string },
105
+ ): string {
106
+ const { wrapperBinPath } = options;
107
+ const lines = [
108
+ "## MANDATORY RULES - NON-NEGOTIABLE",
109
+ "",
110
+ `<companion-policy framework="${FRAMEWORK_NAME}" priority="mandatory">`,
111
+ ` <overview>You are operating inside the ${FRAMEWORK_NAME} companion repository.</overview>`,
112
+ " <context>",
113
+ " <paths>",
114
+ ` <path role="working-repository" env="MATE_REPO_PATH">${context.repository.path}</path>`,
115
+ ` <path role="companion-repository" env="MATE_ARTIFACT_PATH">${context.companionPath}</path>`,
116
+ ` <path role="package-wrapper-bin" env="MATE_WRAPPER_BIN_PATH">${wrapperBinPath}</path>`,
117
+ " </paths>",
118
+ " <cli-tools>",
119
+ ` <cli name="openspec" type="wrapper" invokeAs="${path.join(wrapperBinPath, "openspec")}" />`,
120
+ ` <cli name="graphify" type="wrapper" invokeAs="${path.join(wrapperBinPath, "graphify")}" />`,
121
+ ` <cli name="${FRAMEWORK_NAME}" type="global" invokeAs="${FRAMEWORK_NAME}" />`,
122
+ " </cli-tools>",
123
+ ` <linked-repository id="${context.repository.id}" />`,
124
+ " </context>",
125
+ " <mandatory-rules>",
126
+ ` <rule id="artifact-location" severity="critical">Agent artifacts MUST go to ${context.companionPath}, NEVER ${context.repository.path}. Artifacts include plans, specs, ADRs, todos, notes, handoffs, reasoning docs, and scratch files.</rule>`,
127
+ ` <rule id="pre-write-classification" severity="critical">Before ANY write, classify the target as product-code or agent-artifact. If unsure, treat it as agent-artifact.</rule>`,
128
+ ` <rule id="product-code-location" severity="critical">Product code (README, docs, source, tests) belongs in ${context.repository.path}. Agent-artifacts belong in ${context.companionPath}.</rule>`,
129
+ ` <rule id="local-artifact-exception" severity="critical">Only write artifacts in ${context.repository.path} when the exact path is gitignored AND intentionally local-only; otherwise use ${context.companionPath}.</rule>`,
130
+ ` <rule id="guardrail" severity="critical">Bad artifact writes to ${context.repository.path} are rejected. Classify correctly first.</rule>`,
131
+ ` <rule id="companion-multi-repository" severity="critical">This Companion Repository may serve multiple Working Repositories. The working-repository path above identifies this session's single primary Working Repository, not the companion's full repository set. The Companion Repository is the shared artifact and context plane; the primary Working Repository is the product-code plane.</rule>`,
132
+ ` <rule id="repository-area-scope" severity="critical">For domain modeling, identify the canonical Working Repository and its repository-relative Area first. A checkout basename or Area alone is not a repository identity. Shared context applies only when the Companion Repository's CONTEXT-MAP explicitly maps it to the current repository and Area.</rule>`,
133
+ ` <rule id="wrapper-only-cli-execution" severity="critical">For every CLI declared in cli-tools, invoke the exact path in its invokeAs attribute. Correct: ${path.join(wrapperBinPath, "openspec")} status ... . Incorrect: openspec status ... . Do not run bare openspec or graphify commands and do not rely on PATH, aliases, or shell functions. If the exact wrapper path is unavailable, stop and report it.</rule>`,
134
+ ];
135
+
136
+ if (hasOpenspecCapability(context.capabilities)) {
137
+ lines.push(
138
+ ` <rule id="openspec-scope" severity="critical">In OpenSpec, the frontmatter repository identifies the Working Repository. areas are repository-relative paths within that repository. Canonical specs use repository plus flat areas; change artifacts use paired scopes entries. In a monorepo, an Area normally stops at the package root. A cross-repository change may declare multiple scopes, but each resulting spec remains owned by one repository, and each requirement must bind its Area explicitly.</rule>`,
139
+ ` <rule id="openspec-publish" severity="critical">Archiving an OpenSpec change is a local operation and publishes nothing. Publish through the mate-artifact-publish skill: it lists candidates with ${FRAMEWORK_NAME} artifact pending --json, takes an explicit user selection, confirms the commit, tag, and push, then runs ${FRAMEWORK_NAME} artifact publish "<name>" --json per selected change. That command is the only sanctioned publication; never hand-commit or hand-tag one. Publishing only publishes an already-archived change: archiving is a precondition, and running publish on a still-active change is an error that names openspec archive as the missing step. Publish never archives and never applies delta specs itself, so archive first — by the archive workflow or an already-synced flow such as openspec-sync-specs — and then publish. Publish also refuses to run anywhere but the companion's default branch.</rule>`,
140
+ );
141
+ }
142
+
143
+ lines.push(" </mandatory-rules>");
144
+
145
+ lines.push("</companion-policy>");
146
+
147
+ return lines.join("\n");
148
+ }
149
+
150
+ /**
151
+ * Build the merged single-string guidance: the companion-policy XML plus
152
+ * codebase-exploration guidance appended when graphify or tokensave is
153
+ * enabled. Used by providers (e.g. Claude) that inject one combined prompt
154
+ * fragment rather than delivering exploration guidance separately.
155
+ */
156
+ export function buildCompanionGuidance(
157
+ context: GuidanceContext,
158
+ options: { wrapperBinPath: string },
159
+ ): string {
160
+ const lines = [buildCompanionPolicyXml(context, options)];
161
+
162
+ const graphifyEnabled = hasGraphifyCapability(context.capabilities);
163
+ const tokensaveEnabled = hasTokensaveCapability(context.capabilities);
164
+
165
+ if (graphifyEnabled || tokensaveEnabled) {
166
+ lines.push(
167
+ "",
168
+ buildCodebaseExplorationGuidanceSection({
169
+ useGraphify: graphifyEnabled,
170
+ useTokensave: tokensaveEnabled,
171
+ }),
172
+ );
173
+ }
174
+
175
+ return lines.join("\n");
176
+ }
177
+
178
+ /**
179
+ * Build the companion guidance payload the OpenCode plugin consumes, whether
180
+ * delivered through `MATE_GUIDANCE_JSON` by a launch or built from the
181
+ * Projection Root by the plugin itself. The text carries `$MATE_*`
182
+ * placeholders the plugin materializes from its own resolved context, so the
183
+ * same payload shape serves every companion and no path is resolved here.
184
+ *
185
+ * Real capabilities are passed through (not just the graphify/tokensave flags)
186
+ * so capability-gated companion-policy rules — e.g. openspec-publish — render
187
+ * exactly as they do for the Claude provider.
188
+ */
189
+ export function buildOpenCodeGuidance(capabilities: GuidanceCapability[]): MateGuidanceFile {
190
+ const companionGuidance = buildCompanionPolicyXml(
191
+ {
192
+ companionPath: "$MATE_ARTIFACT_PATH",
193
+ repository: { id: "$MATE_REPO_ID", path: "$MATE_REPO_PATH" },
194
+ capabilities,
195
+ },
196
+ { wrapperBinPath: "$MATE_WRAPPER_BIN_PATH" },
197
+ );
198
+ const graphifyEnabled = hasGraphifyCapability(capabilities);
199
+ const tokensaveEnabled = hasTokensaveCapability(capabilities);
200
+ const codebaseExplorationGuidance = buildCodebaseExplorationGuidanceSection({
201
+ useGraphify: graphifyEnabled,
202
+ useTokensave: tokensaveEnabled,
203
+ });
204
+ const errors: string[] = [];
205
+
206
+ if (!companionGuidance.includes("<companion-policy ")) {
207
+ errors.push("companion guidance was not injected");
208
+ }
209
+ if (
210
+ (graphifyEnabled || tokensaveEnabled) &&
211
+ !codebaseExplorationGuidance.includes("<codebase-exploration-rules ")
212
+ ) {
213
+ errors.push("codebase exploration guidance was not injected");
214
+ }
215
+
216
+ return {
217
+ version: GUIDANCE_FILE_VERSION,
218
+ companionGuidance,
219
+ codebaseExplorationGuidance,
220
+ errors,
221
+ };
222
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * The unattended half of companion Git synchronization: fetch, then
3
+ * fast-forward when the companion is strictly behind. Everything a human must
4
+ * answer — divergence, a dirty tree, credentials, an in-progress merge — is
5
+ * reported rather than attempted.
6
+ *
7
+ * Lives under `runtime/` because the session-start hooks need it and may not
8
+ * import `lib/orchestrator`. Synchronous for the same reason: the Claude hook
9
+ * that calls it is synchronous.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ import {
15
+ companionGitStatePath,
16
+ COMPANION_SYNC_TTL_MS,
17
+ FORK_VERDICT_TTL_MS,
18
+ isCompanionSyncDue,
19
+ readCachedForkRecord,
20
+ readCompanionGitRecord,
21
+ recordCompanionSync,
22
+ recordCompanionSyncUnfinished,
23
+ recordForkVerdict,
24
+ } from "./companion-git-state";
25
+ import {
26
+ companionForkState,
27
+ describeGitFailure,
28
+ forkStateAgainst,
29
+ GIT_QUERY_TIMEOUT_MS,
30
+ isAuthenticationFailure,
31
+ outputLines,
32
+ resolveUpstreamTargetSync,
33
+ runGitSync,
34
+ type CompanionForkState,
35
+ } from "./companion-git";
36
+ import { hasLaunchEnvironment } from "./env";
37
+ import { FRAMEWORK_NAME } from "./framework";
38
+ import { readCompanionPolicy } from "./policy";
39
+
40
+ /** The one command that finishes what the unattended half declines to do. */
41
+ export const COMPANION_SYNC_COMMAND = `${FRAMEWORK_NAME} companion sync`;
42
+
43
+ /** A cold link costs this once; the recorded completion then suppresses retries. */
44
+ export const COMPANION_SYNC_TIMEOUT_MS = 20_000;
45
+
46
+ export type UnattendedSyncStatus = "not-applicable" | "fresh" | "complete" | "unfinished";
47
+
48
+ export interface UnattendedSyncOutcome {
49
+ status: UnattendedSyncStatus;
50
+ changed: boolean;
51
+ companionPath: string;
52
+ /** Set for `unfinished`: what a human must resolve. */
53
+ reason?: string;
54
+ }
55
+
56
+ export interface UnattendedSyncOptions {
57
+ ttlMs?: number;
58
+ timeoutMs?: number;
59
+ now?: Date;
60
+ homeDir?: string;
61
+ env?: Record<string, string | undefined>;
62
+ }
63
+
64
+ const UNFINISHED_GIT_PATHS = [
65
+ "MERGE_HEAD",
66
+ "CHERRY_PICK_HEAD",
67
+ "REVERT_HEAD",
68
+ "rebase-merge",
69
+ "rebase-apply",
70
+ ];
71
+
72
+ function hasGitPath(companionPath: string, name: string, timeoutMs: number): boolean {
73
+ const gitPath = runGitSync(companionPath, ["rev-parse", "--git-path", name], timeoutMs);
74
+ if (gitPath.status !== 0) return false;
75
+ return fs.existsSync(path.resolve(companionPath, gitPath.stdout.trim()));
76
+ }
77
+
78
+ function notApplicable(companionPath: string): UnattendedSyncOutcome {
79
+ return { status: "not-applicable", changed: false, companionPath };
80
+ }
81
+
82
+ function unfinished(
83
+ companionPath: string,
84
+ reason: string,
85
+ homeDir: string | undefined,
86
+ ): UnattendedSyncOutcome {
87
+ /** Persisted so a reader that renders session state can surface it without re-running Git. */
88
+ recordCompanionSyncUnfinished(companionPath, reason, homeDir);
89
+ return { status: "unfinished", changed: false, companionPath, reason };
90
+ }
91
+
92
+ /**
93
+ * The consent gate is held here, before any process is spawned, so that
94
+ * reaching this function at all is enough to be safe — no caller can widen the
95
+ * operator's standing answer to "may Mate touch my Git".
96
+ */
97
+ export function syncCompanionUnattended(
98
+ companionPath: string,
99
+ options: UnattendedSyncOptions = {},
100
+ ): UnattendedSyncOutcome {
101
+ const {
102
+ ttlMs = COMPANION_SYNC_TTL_MS,
103
+ timeoutMs = COMPANION_SYNC_TIMEOUT_MS,
104
+ now = new Date(),
105
+ homeDir,
106
+ } = options;
107
+
108
+ if (!companionPath) return notApplicable(companionPath);
109
+ if (!readCompanionPolicy(companionPath).gitAutoMode) return notApplicable(companionPath);
110
+ if (!isCompanionSyncDue(companionPath, ttlMs, now, homeDir)) {
111
+ return { status: "fresh", changed: false, companionPath };
112
+ }
113
+
114
+ const deadline = now.getTime() + timeoutMs;
115
+ const remaining = () => Math.max(1, deadline - Date.now());
116
+
117
+ const target = resolveUpstreamTargetSync(
118
+ companionPath,
119
+ Math.min(GIT_QUERY_TIMEOUT_MS, remaining()),
120
+ );
121
+ if (!target) return notApplicable(companionPath);
122
+
123
+ const inProgress = UNFINISHED_GIT_PATHS.filter((name) =>
124
+ hasGitPath(companionPath, name, Math.min(GIT_QUERY_TIMEOUT_MS, remaining())),
125
+ );
126
+ if (inProgress.length > 0) {
127
+ return unfinished(
128
+ companionPath,
129
+ `an unfinished Git operation is in progress (${inProgress.join(", ")})`,
130
+ homeDir,
131
+ );
132
+ }
133
+
134
+ const fetch = runGitSync(
135
+ companionPath,
136
+ ["fetch", "--no-progress", target.remote, target.branch],
137
+ remaining(),
138
+ );
139
+ if (fetch.status !== 0) {
140
+ if (isAuthenticationFailure(fetch)) {
141
+ return unfinished(companionPath, `fetching ${target.ref} needs Git authentication`, homeDir);
142
+ }
143
+ return unfinished(
144
+ companionPath,
145
+ `unable to fetch ${target.ref}: ${describeGitFailure(fetch)}`,
146
+ homeDir,
147
+ );
148
+ }
149
+
150
+ const fork = forkStateAgainst(
151
+ companionPath,
152
+ target.ref,
153
+ Math.min(GIT_QUERY_TIMEOUT_MS, remaining()),
154
+ );
155
+ if (!fork) return notApplicable(companionPath);
156
+
157
+ if (fork.behind === 0) {
158
+ recordCompanionSync(companionPath, now, homeDir);
159
+ return { status: "complete", changed: false, companionPath };
160
+ }
161
+ if (fork.ahead > 0) {
162
+ return unfinished(
163
+ companionPath,
164
+ `history has diverged from ${target.ref} (${fork.ahead} ahead, ${fork.behind} behind)`,
165
+ homeDir,
166
+ );
167
+ }
168
+
169
+ const merge = runGitSync(
170
+ companionPath,
171
+ ["merge", "--ff-only", "--no-stat", "--no-progress", target.ref],
172
+ Math.min(GIT_QUERY_TIMEOUT_MS, remaining()),
173
+ );
174
+ if (merge.status !== 0) {
175
+ const dirty = runGitSync(
176
+ companionPath,
177
+ ["status", "--porcelain=v1", "--untracked-files=all"],
178
+ Math.min(GIT_QUERY_TIMEOUT_MS, remaining()),
179
+ );
180
+ if (dirty.status === 0 && outputLines(dirty.stdout).length > 0) {
181
+ return unfinished(
182
+ companionPath,
183
+ `local changes block the fast-forward to ${target.ref}`,
184
+ homeDir,
185
+ );
186
+ }
187
+ return unfinished(
188
+ companionPath,
189
+ `unable to fast-forward to ${target.ref}: ${describeGitFailure(merge)}`,
190
+ homeDir,
191
+ );
192
+ }
193
+
194
+ recordCompanionSync(companionPath, now, homeDir);
195
+ return { status: "complete", changed: true, companionPath };
196
+ }
197
+
198
+ /**
199
+ * Operator-facing only. This text must never reach a model-visible channel: a
200
+ * model told to run the command will run it unattended, with no terminal to
201
+ * answer a credential prompt and no way to tell a fast-forward from a
202
+ * conflicted rebase.
203
+ */
204
+ export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): string[] {
205
+ if (outcome.status !== "unfinished") return [];
206
+ return [
207
+ `companion Git synchronization unfinished: ${outcome.reason ?? "unknown reason"} — run \`${COMPANION_SYNC_COMMAND}\``,
208
+ ];
209
+ }
210
+
211
+ /**
212
+ * The note a session-state reader surfaces, taken from the persisted record so
213
+ * a render costs no Git. Operator-facing only, exactly as the live note is.
214
+ */
215
+ export function persistedCompanionGitStalenessLines(
216
+ companionPath: string,
217
+ homeDir?: string,
218
+ ): string[] {
219
+ if (!companionPath) return [];
220
+ const reason = readCompanionGitRecord(companionPath, homeDir).unfinishedReason;
221
+ if (!reason) return [];
222
+ return unattendedSyncStalenessLines({
223
+ status: "unfinished",
224
+ changed: false,
225
+ companionPath,
226
+ reason,
227
+ });
228
+ }
229
+
230
+ export interface ForkGuardOptions {
231
+ ttlMs?: number;
232
+ timeoutMs?: number;
233
+ now?: Date;
234
+ homeDir?: string;
235
+ }
236
+
237
+ /**
238
+ * The verdict both runtimes' artifact guards share, cached per interval so a
239
+ * retry loop cannot spawn Git per write. `null` means "no refusal" — including
240
+ * every case in which the check could not be completed, because the guard must
241
+ * never fail closed.
242
+ */
243
+ export function companionForkRefusal(
244
+ env: Record<string, string | undefined>,
245
+ companionPath: string,
246
+ options: ForkGuardOptions = {},
247
+ ): string | null {
248
+ if (!companionPath) return null;
249
+ /**
250
+ * A Managed Launch has already settled this session's Git question — by its
251
+ * preflight, by `--no-git`, or by a companion whose Git handling is not
252
+ * automatic. Refusing afterwards would revoke a documented bypass mid-session.
253
+ */
254
+ if (hasLaunchEnvironment(env)) return null;
255
+ /** The same consent gate the repair holds: Git handling off makes this inert. */
256
+ if (!readCompanionPolicy(companionPath).gitAutoMode) return null;
257
+
258
+ const fork = cachedCompanionForkState(companionPath, options);
259
+ if (!fork?.forked) return null;
260
+ return forkRefusalMessage(companionPath, fork);
261
+ }
262
+
263
+ export function cachedCompanionForkState(
264
+ companionPath: string,
265
+ options: ForkGuardOptions = {},
266
+ ): CompanionForkState | null {
267
+ const {
268
+ ttlMs = FORK_VERDICT_TTL_MS,
269
+ timeoutMs = GIT_QUERY_TIMEOUT_MS,
270
+ now = new Date(),
271
+ homeDir,
272
+ } = options;
273
+
274
+ const cached = readCachedForkRecord(companionPath, ttlMs, now, homeDir);
275
+ if (cached) {
276
+ return {
277
+ ahead: cached.ahead,
278
+ behind: cached.behind,
279
+ forked: cached.ahead > 0 && cached.behind > 0,
280
+ };
281
+ }
282
+
283
+ const fork = companionForkState(companionPath, timeoutMs);
284
+ if (!fork) return null;
285
+ recordForkVerdict(companionPath, { ahead: fork.ahead, behind: fork.behind }, now, homeDir);
286
+ return fork;
287
+ }
288
+
289
+ export function forkRefusalMessage(companionPath: string, fork: CompanionForkState): string {
290
+ return [
291
+ `${FRAMEWORK_NAME} guardrail: the companion's history has forked from its upstream.`,
292
+ ` companion: ${companionPath}`,
293
+ ` ${fork.ahead} local commit(s) ahead, ${fork.behind} upstream commit(s) behind`,
294
+ `Writing artifacts now risks losing work in the reconciliation. Run \`${COMPANION_SYNC_COMMAND}\` first.`,
295
+ ].join("\n");
296
+ }
297
+
298
+ export { companionGitStatePath };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stable Mate launch environment contract shared by the CLI (writer) and the
3
+ * OpenCode runtime plugins (readers). The CLI materializes these variables for
4
+ * every managed session; plugins must consume them through this module instead
5
+ * of hard-coding variable names.
6
+ *
7
+ * Every name is `MATE_`-prefixed, which is what lets a reader treat "any of
8
+ * these present" as "a Mate launch configured this process".
9
+ */
10
+ export const MATE_ENV = {
11
+ frameworkName: "MATE_NAME",
12
+ version: "MATE_VERSION",
13
+ companionPath: "MATE_ARTIFACT_PATH",
14
+ wrapperBinPath: "MATE_WRAPPER_BIN_PATH",
15
+ repositoryPath: "MATE_REPO_PATH",
16
+ repositoryId: "MATE_REPO_ID",
17
+ policyJson: "MATE_POLICY_JSON",
18
+ graphifyEnabled: "MATE_GRAPHIFY_ENABLED",
19
+ gitAutoMode: "MATE_GIT_AUTO_MODE",
20
+ reactDoctorEnabled: "MATE_REACT_DOCTOR_ENABLED",
21
+ reactDoctorBinPath: "MATE_REACT_DOCTOR_BIN_PATH",
22
+ /**
23
+ * Serialized `MateGuidanceFile` JSON built by the CLI per managed launch.
24
+ * Session-scoped: the plugin consumes it at startup and masks it from
25
+ * spawned shells so it never leaks into subprocess environments.
26
+ */
27
+ guidanceJson: "MATE_GUIDANCE_JSON",
28
+ } as const;
29
+
30
+ export type MateEnvVariable = (typeof MATE_ENV)[keyof typeof MATE_ENV];
@@ -1,35 +1,14 @@
1
- /**
2
- * Stable Mate launch environment contract shared by the CLI (writer) and the
3
- * OpenCode runtime plugins (readers). The CLI materializes these variables for
4
- * every managed session; plugins must consume them through this module instead
5
- * of hard-coding variable names.
6
- */
7
- export const MATE_ENV = {
8
- frameworkName: "MATE_NAME",
9
- version: "MATE_VERSION",
10
- companionPath: "MATE_ARTIFACT_PATH",
11
- wrapperBinPath: "MATE_WRAPPER_BIN_PATH",
12
- repositoryPath: "MATE_REPO_PATH",
13
- repositoryId: "MATE_REPO_ID",
14
- policyJson: "MATE_POLICY_JSON",
15
- graphifyEnabled: "MATE_GRAPHIFY_ENABLED",
16
- gitAutoMode: "MATE_GIT_AUTO_MODE",
17
- reactDoctorEnabled: "MATE_REACT_DOCTOR_ENABLED",
18
- reactDoctorBinPath: "MATE_REACT_DOCTOR_BIN_PATH",
19
- /**
20
- * Serialized `MateGuidanceFile` JSON built by the CLI per managed launch.
21
- * Session-scoped: the plugin consumes it at startup and masks it from
22
- * spawned shells so it never leaks into subprocess environments.
23
- */
24
- guidanceJson: "MATE_GUIDANCE_JSON",
25
- } as const;
1
+ import { MATE_ENV } from "./env-names";
2
+ import { FRAMEWORK_NAME } from "./framework";
3
+ import { isCapabilityEnabled, readCompanionPolicy } from "./policy";
4
+ import { resolveProjection, type ResolvedProjection } from "./projection";
26
5
 
27
- export type MateEnvVariable = (typeof MATE_ENV)[keyof typeof MATE_ENV];
6
+ export { MATE_ENV, type MateEnvVariable } from "./env-names";
28
7
 
29
8
  /**
30
- * Normalized companion runtime context derived from the Mate launch
31
- * environment. Contains no repository-specific defaults; every value comes
32
- * from the active session environment.
9
+ * Normalized companion runtime context. Values come from the Mate launch
10
+ * environment when it is present, and from the durable projection at the
11
+ * Projection Root otherwise.
33
12
  */
34
13
  export type CompanionRuntimeContext = {
35
14
  frameworkName: string;
@@ -42,11 +21,17 @@ export type CompanionRuntimeContext = {
42
21
  reactDoctorEnabled: boolean;
43
22
  };
44
23
 
45
- export function readCompanionRuntimeContext(
46
- env: Record<string, string | undefined> = process.env,
47
- ): CompanionRuntimeContext {
24
+ /**
25
+ * Any `MATE_*` variable present means a Mate launch configured this process,
26
+ * so the environment is authoritative and the projection is not read.
27
+ */
28
+ export function hasLaunchEnvironment(env: Record<string, string | undefined>): boolean {
29
+ return Object.values(MATE_ENV).some((name) => env[name] !== undefined);
30
+ }
31
+
32
+ function fromEnvironment(env: Record<string, string | undefined>): CompanionRuntimeContext {
48
33
  return {
49
- frameworkName: env[MATE_ENV.frameworkName] ?? "mate",
34
+ frameworkName: env[MATE_ENV.frameworkName] ?? FRAMEWORK_NAME,
50
35
  companionPath: env[MATE_ENV.companionPath] ?? "",
51
36
  repositoryPath: env[MATE_ENV.repositoryPath] ?? "",
52
37
  repositoryId: env[MATE_ENV.repositoryId] ?? "",
@@ -58,8 +43,55 @@ export function readCompanionRuntimeContext(
58
43
  }
59
44
 
60
45
  /**
61
- * A session is Mate-managed only when the CLI exported both the companion
62
- * path and the working repository path. Plugins must stay inert otherwise.
46
+ * The resolved context together with the projection it came from, so a reader
47
+ * that surfaces session state can judge that projection's freshness without a
48
+ * second upward walk. `projection` is null for a managed session.
49
+ */
50
+ export interface CompanionRuntimeResolution {
51
+ context: CompanionRuntimeContext;
52
+ projection: ResolvedProjection | null;
53
+ }
54
+
55
+ /**
56
+ * The environment always wins: the projection is consulted only when no
57
+ * `MATE_*` variable is set, so a managed session never reads it and no stale
58
+ * or malformed projection can degrade a session a Mate launch configured.
59
+ */
60
+ export function resolveCompanionRuntime(
61
+ env: Record<string, string | undefined> = process.env,
62
+ cwd: string = process.cwd(),
63
+ ): CompanionRuntimeResolution {
64
+ if (hasLaunchEnvironment(env)) return { context: fromEnvironment(env), projection: null };
65
+
66
+ const projection = resolveProjection(cwd);
67
+ if (!projection) return { context: fromEnvironment(env), projection: null };
68
+
69
+ const policy = readCompanionPolicy(projection.companionPath);
70
+ return {
71
+ projection,
72
+ context: {
73
+ frameworkName: FRAMEWORK_NAME,
74
+ companionPath: projection.companionPath,
75
+ repositoryPath: projection.repositoryPath,
76
+ repositoryId: projection.repositoryId,
77
+ policyJson: JSON.stringify({ allowedAgents: policy.allowedAgents }),
78
+ graphifyEnabled: isCapabilityEnabled(policy, "graphify"),
79
+ gitAutoModeEnabled: policy.gitAutoMode,
80
+ reactDoctorEnabled: isCapabilityEnabled(policy, "react-doctor"),
81
+ },
82
+ };
83
+ }
84
+
85
+ export function readCompanionRuntimeContext(
86
+ env: Record<string, string | undefined> = process.env,
87
+ cwd: string = process.cwd(),
88
+ ): CompanionRuntimeContext {
89
+ return resolveCompanionRuntime(env, cwd).context;
90
+ }
91
+
92
+ /**
93
+ * A session is Mate-managed only when both the companion path and the working
94
+ * repository path resolved. Plugins must stay inert otherwise.
63
95
  */
64
96
  export function isManagedCompanionContext(context: CompanionRuntimeContext): boolean {
65
97
  return Boolean(context.companionPath && context.repositoryPath);