@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,10 @@
1
+ /**
2
+ * Framework identity: names everything identity-shaped — state directories
3
+ * (`~/.mate`, `.mate/`), managed-block markers, `MATE_*` env values, usage
4
+ * output, command hints, error prefixes, agent guidance, and permission
5
+ * entries. The distribution is always invoked as `mate`.
6
+ *
7
+ * Lives under `runtime/` because the import-isolated runtime subpath derives
8
+ * repo-local `.mate/` paths from it; `../framework` re-exports it.
9
+ */
10
+ export const FRAMEWORK_NAME = "mate";
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+
3
+ import { mateVersion } from "./install";
4
+ import { computeProjectionStamp, type ResolvedProjection } from "./projection";
5
+ import { repoLocalRegistryPath } from "./repo-local";
6
+
7
+ /**
8
+ * Two independent axes. A stamp answers only "was this written by a mate whose
9
+ * version, or whose view of the repo-local registry, differs from mine"; it
10
+ * cannot see a moved or deleted companion, because `companionPath` is not a
11
+ * stamp input.
12
+ */
13
+ export interface ProjectionFreshness {
14
+ stampCurrent: boolean;
15
+ companionExists: boolean;
16
+ isCurrent: boolean;
17
+ }
18
+
19
+ export function projectionFreshness(projection: ResolvedProjection): ProjectionFreshness {
20
+ const registryContent = (() => {
21
+ try {
22
+ return fs.readFileSync(repoLocalRegistryPath(projection.repoRoot), "utf8");
23
+ } catch {
24
+ return "";
25
+ }
26
+ })();
27
+
28
+ const stampCurrent =
29
+ projection.stamp === computeProjectionStamp({ version: mateVersion(), registryContent });
30
+
31
+ const companionExists = (() => {
32
+ try {
33
+ return fs.statSync(projection.companionPath).isDirectory();
34
+ } catch {
35
+ return false;
36
+ }
37
+ })();
38
+
39
+ return { stampCurrent, companionExists, isCurrent: stampCurrent && companionExists };
40
+ }
41
+
42
+ /**
43
+ * Advisory, never fatal: a reader surfaces these and carries on. Stale is the
44
+ * steady state under wrap-once, so the repair is named rather than enforced.
45
+ */
46
+ export function projectionStalenessLines(
47
+ projection: ResolvedProjection,
48
+ freshness: ProjectionFreshness,
49
+ ): string[] {
50
+ const lines: string[] = [];
51
+ if (!freshness.stampCurrent) {
52
+ lines.push("projection was written by a different mate install — run `mate wrap` to refresh");
53
+ }
54
+ if (!freshness.companionExists) {
55
+ lines.push(`projected companion is missing: ${projection.companionPath} — run \`mate wrap\``);
56
+ }
57
+ return lines;
58
+ }
@@ -1,10 +1,75 @@
1
+ export {
2
+ GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT,
3
+ buildCodebaseExplorationGuidanceSection,
4
+ buildCompanionGuidance,
5
+ buildCompanionPolicyXml,
6
+ buildOpenCodeGuidance,
7
+ hasGraphifyCapability,
8
+ hasOpenspecCapability,
9
+ hasTokensaveCapability,
10
+ type GuidanceCapability,
11
+ type GuidanceContext,
12
+ } from "./companion-guidance";
13
+ export {
14
+ companionForkState,
15
+ describeGitFailure,
16
+ forkStateAgainst,
17
+ gitEnvironment,
18
+ GIT_QUERY_TIMEOUT_MS,
19
+ isAuthenticationFailure,
20
+ outputLines,
21
+ resolveUpstreamTargetSync,
22
+ resolveUpstreamTargetWith,
23
+ runGitSync,
24
+ stripGitProgress,
25
+ upstreamTargetSteps,
26
+ type CompanionForkState,
27
+ type GitResult,
28
+ type UpstreamTarget,
29
+ } from "./companion-git";
30
+ export {
31
+ companionGitStatePath,
32
+ companionGitStateRoot,
33
+ COMPANION_SYNC_TTL_MS,
34
+ FORK_VERDICT_TTL_MS,
35
+ isCompanionSyncDue,
36
+ readCachedForkRecord,
37
+ readCompanionGitRecord,
38
+ recordCompanionSync,
39
+ recordForkVerdict,
40
+ writeCompanionGitRecord,
41
+ type CompanionForkRecord,
42
+ type CompanionGitRecord,
43
+ } from "./companion-git-state";
44
+ export {
45
+ cachedCompanionForkState,
46
+ companionForkRefusal,
47
+ COMPANION_SYNC_COMMAND,
48
+ COMPANION_SYNC_TIMEOUT_MS,
49
+ forkRefusalMessage,
50
+ syncCompanionUnattended,
51
+ unattendedSyncStalenessLines,
52
+ type ForkGuardOptions,
53
+ type UnattendedSyncOptions,
54
+ type UnattendedSyncOutcome,
55
+ type UnattendedSyncStatus,
56
+ } from "./companion-sync";
57
+ export { FRAMEWORK_NAME } from "./framework";
1
58
  export {
2
59
  MATE_ENV,
60
+ hasLaunchEnvironment,
3
61
  isManagedCompanionContext,
4
62
  readCompanionRuntimeContext,
63
+ resolveCompanionRuntime,
5
64
  type CompanionRuntimeContext,
65
+ type CompanionRuntimeResolution,
6
66
  type MateEnvVariable,
7
67
  } from "./env";
68
+ export {
69
+ projectionFreshness,
70
+ projectionStalenessLines,
71
+ type ProjectionFreshness,
72
+ } from "./freshness";
8
73
  export {
9
74
  CODEBASE_EXPLORATION_MARKER,
10
75
  COMPANION_POLICY_MARKER,
@@ -14,3 +79,42 @@ export {
14
79
  type GuidanceValidation,
15
80
  type MateGuidanceFile,
16
81
  } from "./guidance";
82
+ export { mateInstallPath, mateVersion } from "./install";
83
+ export { buildProjectedGuidance } from "./projected-guidance";
84
+ export {
85
+ companionFrameworkConfigPath,
86
+ emptyCompanionPolicy,
87
+ isCapabilityEnabled,
88
+ readCompanionPolicy,
89
+ type CompanionPolicy,
90
+ } from "./policy";
91
+ export {
92
+ PROJECTION_ENV_FILE,
93
+ PROJECTION_ENV_NAMES,
94
+ PROJECTION_FIELDS,
95
+ PROJECTION_STAMP_ENV_NAME,
96
+ PROJECTION_YAML_FILE,
97
+ computeProjectionStamp,
98
+ parseProjectionEnv,
99
+ parseProjectionYaml,
100
+ projectionEnvPath,
101
+ projectionYamlPath,
102
+ readProjectionFile,
103
+ renderProjectionEnv,
104
+ renderProjectionYaml,
105
+ resolveProjection,
106
+ writeProjectionPair,
107
+ type MateProjection,
108
+ type ProjectionFile,
109
+ type ProjectionStampInputs,
110
+ type ResolvedProjection,
111
+ } from "./projection";
112
+ export {
113
+ ancestorDirectories,
114
+ findRepoLocalRegistryFileSync,
115
+ repoLocalDirName,
116
+ repoLocalDirPath,
117
+ repoLocalFrameworkPath,
118
+ repoLocalRegistryPath,
119
+ type RepoLocalRegistryFile,
120
+ } from "./repo-local";
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Identity of the running mate install, as the projection stamp records it.
6
+ * It lives inside `runtime/` because the readers that judge a projection's
7
+ * freshness — hooks and OpenCode plugins — may import nothing else from core;
8
+ * `lib/package-paths` delegates here so the install has one definition.
9
+ *
10
+ * The manifest is read rather than imported: a static `package.json` import
11
+ * would put a non-`runtime/` module in the isolation walk.
12
+ */
13
+ export function mateInstallPath(): string {
14
+ return path.resolve(import.meta.dirname, "..", "..");
15
+ }
16
+
17
+ export function mateVersion(): string {
18
+ try {
19
+ const parsed: unknown = JSON.parse(
20
+ fs.readFileSync(path.join(mateInstallPath(), "package.json"), "utf8"),
21
+ );
22
+ if (parsed && typeof parsed === "object") {
23
+ const value = (parsed as { version?: unknown }).version;
24
+ if (typeof value === "string" && value) return value;
25
+ }
26
+ } catch {
27
+ // fall through to the unknown marker
28
+ }
29
+ return "unknown";
30
+ }
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { parse } from "yaml";
5
+
6
+ import { FRAMEWORK_NAME } from "./framework";
7
+
8
+ /**
9
+ * Predicates that are never projected: they are read live from the companion
10
+ * so toggling a Capability takes effect without a projection refresh.
11
+ */
12
+ export interface CompanionPolicy {
13
+ allowedAgents: string[];
14
+ /** Names of the Capabilities the companion has enabled. */
15
+ enabledCapabilities: string[];
16
+ gitAutoMode: boolean;
17
+ }
18
+
19
+ export const emptyCompanionPolicy = (): CompanionPolicy => ({
20
+ allowedAgents: [],
21
+ enabledCapabilities: [],
22
+ gitAutoMode: false,
23
+ });
24
+
25
+ export function companionFrameworkConfigPath(companionPath: string): string {
26
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
27
+ }
28
+
29
+ function stringList(value: unknown): string[] {
30
+ if (!Array.isArray(value)) return [];
31
+ return value.filter((entry): entry is string => typeof entry === "string");
32
+ }
33
+
34
+ function capabilityNames(value: unknown): string[] {
35
+ if (!Array.isArray(value)) return [];
36
+ return value
37
+ .map((entry) =>
38
+ entry && typeof entry === "object" ? (entry as { name?: unknown }).name : undefined,
39
+ )
40
+ .filter((name): name is string => typeof name === "string");
41
+ }
42
+
43
+ /**
44
+ * Synchronous so it composes into `readCompanionRuntimeContext`. A missing or
45
+ * unparseable companion config yields an inert policy rather than throwing.
46
+ */
47
+ export function readCompanionPolicy(companionPath: string): CompanionPolicy {
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = parse(fs.readFileSync(companionFrameworkConfigPath(companionPath), "utf8"));
51
+ } catch {
52
+ return emptyCompanionPolicy();
53
+ }
54
+ if (!parsed || typeof parsed !== "object") return emptyCompanionPolicy();
55
+
56
+ const config = parsed as Record<string, unknown>;
57
+ return {
58
+ allowedAgents: stringList(config.allowedAgents),
59
+ enabledCapabilities: capabilityNames(config.capabilities),
60
+ gitAutoMode: config.git === "auto",
61
+ };
62
+ }
63
+
64
+ export function isCapabilityEnabled(policy: CompanionPolicy, name: string): boolean {
65
+ return policy.enabledCapabilities.includes(name);
66
+ }
@@ -0,0 +1,45 @@
1
+ import { buildCompanionGuidance } from "./companion-guidance";
2
+ import { hasLaunchEnvironment } from "./env";
3
+ import { readCompanionPolicy } from "./policy";
4
+ import { resolveProjection } from "./projection";
5
+
6
+ /**
7
+ * The companion guidance for an Unmanaged Session, built from the Projection
8
+ * Root instead of from a launch.
9
+ *
10
+ * Guidance was the one companion concept with no reader form: it is built from
11
+ * an `AdapterContext`, which exists only inside a `LaunchAdapter`, so it could
12
+ * not cross into a session Mate did not start. Every field it needs is
13
+ * available without one — paths from the projection, predicates read live from
14
+ * the companion — which is what this composes.
15
+ *
16
+ * Reading the predicates live rather than projecting the rendered text is what
17
+ * makes a Capability toggled after a wrap change the guidance with no re-wrap.
18
+ * A rendered string in the Projection Root would be a snapshot, and rule 2 —
19
+ * paths only, never predicates — exists to keep exactly that out of it.
20
+ *
21
+ * Returns null for a managed session: the launch already injects the guidance
22
+ * through its own channel, and rule 1 says the environment wins. That is what
23
+ * lets the same declaration be loaded by both a plugin and a projected
24
+ * document without emitting twice.
25
+ */
26
+ export function buildProjectedGuidance(
27
+ env: Record<string, string | undefined> = process.env,
28
+ cwd: string = process.cwd(),
29
+ ): string | null {
30
+ if (hasLaunchEnvironment(env)) return null;
31
+
32
+ const projection = resolveProjection(cwd);
33
+ if (!projection) return null;
34
+
35
+ const policy = readCompanionPolicy(projection.companionPath);
36
+ return buildCompanionGuidance(
37
+ {
38
+ companionPath: projection.companionPath,
39
+ repository: { id: projection.repositoryId, path: projection.repositoryPath },
40
+ capabilities: policy.enabledCapabilities.map((name) => ({ name })),
41
+ },
42
+ /** The projected path, not the running install's: a wrap may be another mate's. */
43
+ { wrapperBinPath: projection.wrapperBinPath },
44
+ );
45
+ }
@@ -0,0 +1,224 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { parse, stringify } from "yaml";
6
+
7
+ import { MATE_ENV } from "./env-names";
8
+ import { FRAMEWORK_NAME } from "./framework";
9
+ import { findRepoLocalRegistryFileSync, repoLocalDirPath } from "./repo-local";
10
+
11
+ /** Generated projection files, directly under the repo-local `.mate/`. */
12
+ export const PROJECTION_YAML_FILE = "projection.yaml";
13
+ export const PROJECTION_ENV_FILE = "projection.env";
14
+
15
+ /**
16
+ * Paths a session needs, projected to disk at the Projection Root. Paths only:
17
+ * a predicate would go stale between refreshes, so predicates are read live
18
+ * from the companion instead. No predicate field is expressible here.
19
+ */
20
+ export interface MateProjection {
21
+ version: string;
22
+ companionPath: string;
23
+ repositoryPath: string;
24
+ repositoryId: string;
25
+ wrapperBinPath: string;
26
+ reactDoctorBinPath: string;
27
+ graphifyOut: string;
28
+ }
29
+
30
+ /**
31
+ * Env variable each projected field materializes as. `GRAPHIFY_OUT` is
32
+ * graphify's own variable rather than part of the `MATE_*` contract, so it is
33
+ * spelled here instead of in `MATE_ENV`.
34
+ */
35
+ export const PROJECTION_ENV_NAMES: Record<keyof MateProjection, string> = {
36
+ version: MATE_ENV.version,
37
+ companionPath: MATE_ENV.companionPath,
38
+ repositoryPath: MATE_ENV.repositoryPath,
39
+ repositoryId: MATE_ENV.repositoryId,
40
+ wrapperBinPath: MATE_ENV.wrapperBinPath,
41
+ reactDoctorBinPath: MATE_ENV.reactDoctorBinPath,
42
+ graphifyOut: "GRAPHIFY_OUT",
43
+ };
44
+
45
+ /** Stable field order; both renderers iterate it so the forms cannot diverge. */
46
+ export const PROJECTION_FIELDS = Object.keys(PROJECTION_ENV_NAMES) as Array<keyof MateProjection>;
47
+
48
+ export const PROJECTION_STAMP_ENV_NAME = "MATE_PROJECTION_STAMP";
49
+
50
+ /**
51
+ * Stamp inputs. The companion's `framework.yaml` is deliberately absent: no
52
+ * predicate is projected, so nothing derived from it can go stale.
53
+ *
54
+ * The running install's path is absent for a stronger reason: the readers
55
+ * cannot agree on it. Every input has to be computable identically by the
56
+ * writer and by each reader, and several copies of one Mate version coexist by
57
+ * design — the global CLI writes the stamp, the OpenCode plugin runs from
58
+ * OpenCode's own npm cache, and each resolves its own location. Including the
59
+ * path made the plugin report every projection as another install's, session
60
+ * after session, with `mate wrap` unable to fix what it had just written. The
61
+ * version alone answers what the stamp is for.
62
+ */
63
+ export interface ProjectionStampInputs {
64
+ version: string;
65
+ registryContent: string;
66
+ }
67
+
68
+ /** Both generated files carry the same stamp; a reader regenerates on mismatch. */
69
+ export interface ProjectionFile {
70
+ stamp: string;
71
+ projection: MateProjection;
72
+ }
73
+
74
+ export function computeProjectionStamp(inputs: ProjectionStampInputs): string {
75
+ return crypto
76
+ .createHash("sha256")
77
+ .update(inputs.version)
78
+ .update("\0")
79
+ .update(inputs.registryContent)
80
+ .digest("hex");
81
+ }
82
+
83
+ export function projectionYamlPath(repoRoot: string): string {
84
+ return path.join(repoLocalDirPath(repoRoot), PROJECTION_YAML_FILE);
85
+ }
86
+
87
+ export function projectionEnvPath(repoRoot: string): string {
88
+ return path.join(repoLocalDirPath(repoRoot), PROJECTION_ENV_FILE);
89
+ }
90
+
91
+ const generatedHeader = () => `# generated by ${FRAMEWORK_NAME} — do not edit`;
92
+
93
+ export function renderProjectionYaml(file: ProjectionFile): string {
94
+ const document: Record<string, string> = { stamp: file.stamp };
95
+ for (const field of PROJECTION_FIELDS) document[field] = file.projection[field];
96
+ return `${generatedHeader()}\n${stringify(document)}`;
97
+ }
98
+
99
+ export function parseProjectionYaml(raw: string): ProjectionFile | null {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = parse(raw);
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (!parsed || typeof parsed !== "object") return null;
107
+ return readProjectionRecord(parsed as Record<string, unknown>, (field) => field);
108
+ }
109
+
110
+ /**
111
+ * Single-quoted for `sh`: inside single quotes nothing expands or word-splits,
112
+ * so a path containing spaces survives sourcing intact.
113
+ */
114
+ function quoteShellValue(value: string): string {
115
+ return `'${value.replaceAll("'", `'\\''`)}'`;
116
+ }
117
+
118
+ function unquoteShellValue(value: string): string | null {
119
+ if (value.length < 2 || !value.startsWith("'") || !value.endsWith("'")) return null;
120
+ return value.slice(1, -1).replaceAll(`'\\''`, "'");
121
+ }
122
+
123
+ export function renderProjectionEnv(file: ProjectionFile): string {
124
+ const lines = [generatedHeader(), `${PROJECTION_STAMP_ENV_NAME}=${quoteShellValue(file.stamp)}`];
125
+ for (const field of PROJECTION_FIELDS) {
126
+ lines.push(`${PROJECTION_ENV_NAMES[field]}=${quoteShellValue(file.projection[field])}`);
127
+ }
128
+ return `${lines.join("\n")}\n`;
129
+ }
130
+
131
+ export function parseProjectionEnv(raw: string): ProjectionFile | null {
132
+ const values: Record<string, unknown> = {};
133
+ for (const line of raw.split("\n")) {
134
+ const trimmed = line.trim();
135
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
136
+ const separator = trimmed.indexOf("=");
137
+ if (separator <= 0) return null;
138
+ const unquoted = unquoteShellValue(trimmed.slice(separator + 1));
139
+ if (unquoted === null) return null;
140
+ values[trimmed.slice(0, separator)] = unquoted;
141
+ }
142
+
143
+ const stamp = values[PROJECTION_STAMP_ENV_NAME];
144
+ if (typeof stamp !== "string") return null;
145
+ return readProjectionRecord({ ...values, stamp }, (field) => PROJECTION_ENV_NAMES[field]);
146
+ }
147
+
148
+ function readProjectionRecord(
149
+ record: Record<string, unknown>,
150
+ keyOf: (field: keyof MateProjection) => string,
151
+ ): ProjectionFile | null {
152
+ const stamp = record.stamp;
153
+ if (typeof stamp !== "string") return null;
154
+
155
+ const projection = {} as MateProjection;
156
+ for (const field of PROJECTION_FIELDS) {
157
+ const value = record[keyOf(field)];
158
+ if (typeof value !== "string") return null;
159
+ projection[field] = value;
160
+ }
161
+ return { stamp, projection };
162
+ }
163
+
164
+ /**
165
+ * Writes both forms as one unit: rendered in memory, written to temporary
166
+ * siblings, then renamed into place, so a reader never observes a fresh
167
+ * `.yaml` beside a stale `.env`. Default file mode — the projection holds only
168
+ * paths already visible in the working tree's config, so it is not restricted.
169
+ */
170
+ export function writeProjectionPair(repoRoot: string, file: ProjectionFile): void {
171
+ const yamlPath = projectionYamlPath(repoRoot);
172
+ const envPath = projectionEnvPath(repoRoot);
173
+ const suffix = `.${crypto.randomUUID()}.tmp`;
174
+ const yamlTempPath = `${yamlPath}${suffix}`;
175
+ const envTempPath = `${envPath}${suffix}`;
176
+
177
+ fs.mkdirSync(path.dirname(yamlPath), { recursive: true });
178
+ try {
179
+ fs.writeFileSync(yamlTempPath, renderProjectionYaml(file), "utf8");
180
+ fs.writeFileSync(envTempPath, renderProjectionEnv(file), "utf8");
181
+ fs.renameSync(yamlTempPath, yamlPath);
182
+ fs.renameSync(envTempPath, envPath);
183
+ } catch (error) {
184
+ for (const tempPath of [yamlTempPath, envTempPath]) {
185
+ try {
186
+ fs.rmSync(tempPath, { force: true });
187
+ } catch {
188
+ // best-effort cleanup
189
+ }
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ export function readProjectionFile(repoRoot: string): ProjectionFile | null {
196
+ try {
197
+ return parseProjectionYaml(fs.readFileSync(projectionYamlPath(repoRoot), "utf8"));
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ export interface ResolvedProjection extends MateProjection {
204
+ repoRoot: string;
205
+ /** Recorded, never judged here: the verdict needs inputs this module lacks. */
206
+ stamp: string;
207
+ }
208
+
209
+ /**
210
+ * Synchronous, paths only, so a hook or shell that cannot await can use it.
211
+ * Anchored to the directory holding the repo-local `registry.yaml` rather than
212
+ * walking independently for `projection.yaml`, so it cannot resolve a
213
+ * projection belonging to a different ancestor than the registry does. The
214
+ * projected `companionPath` is not stat-ed: a reader that needs the companion
215
+ * to exist fails in its own terms.
216
+ */
217
+ export function resolveProjection(cwd: string): ResolvedProjection | null {
218
+ const found = findRepoLocalRegistryFileSync(cwd);
219
+ if (!found) return null;
220
+
221
+ const file = readProjectionFile(found.repoRoot);
222
+ if (!file) return null;
223
+ return { repoRoot: found.repoRoot, stamp: file.stamp, ...file.projection };
224
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { FRAMEWORK_NAME } from "./framework";
5
+
6
+ export const repoLocalDirName = () => `.${FRAMEWORK_NAME}`;
7
+
8
+ export function repoLocalDirPath(repoPath: string): string {
9
+ return path.join(path.resolve(repoPath), repoLocalDirName());
10
+ }
11
+
12
+ export function repoLocalRegistryPath(repoPath: string): string {
13
+ return path.join(repoLocalDirPath(repoPath), "config", "registry.yaml");
14
+ }
15
+
16
+ export function repoLocalFrameworkPath(repoPath: string): string {
17
+ return path.join(repoLocalDirPath(repoPath), "config", "framework.yaml");
18
+ }
19
+
20
+ /**
21
+ * Where the Companion Repository is reachable from inside the Working
22
+ * Repository by ordinary path traversal. Spelled here rather than beside the
23
+ * writer so a synchronous reader — a hook, a shell — names it without loading
24
+ * the projection owner.
25
+ */
26
+ export function companionLinkPath(repoPath: string): string {
27
+ return path.join(repoLocalDirPath(repoPath), "companion");
28
+ }
29
+
30
+ /**
31
+ * `cwd` and each of its ancestors, nearest first, ending at the filesystem
32
+ * root. Shared by the async and sync registry walks so the two cannot land on
33
+ * different ancestors.
34
+ */
35
+ export function* ancestorDirectories(cwd: string): Generator<string> {
36
+ let dir = path.resolve(cwd);
37
+ for (;;) {
38
+ yield dir;
39
+ const parent = path.dirname(dir);
40
+ if (parent === dir) return;
41
+ dir = parent;
42
+ }
43
+ }
44
+
45
+ export interface RepoLocalRegistryFile {
46
+ repoRoot: string;
47
+ registryPath: string;
48
+ }
49
+
50
+ /**
51
+ * Synchronous sibling of `findRepoLocalRegistryFile`. Differs only in I/O;
52
+ * hooks and shells that cannot await resolve their Projection Root through it.
53
+ */
54
+ export function findRepoLocalRegistryFileSync(cwd: string): RepoLocalRegistryFile | null {
55
+ for (const dir of ancestorDirectories(cwd)) {
56
+ const candidate = repoLocalRegistryPath(dir);
57
+ try {
58
+ if (fs.statSync(candidate).isFile()) return { repoRoot: dir, registryPath: candidate };
59
+ } catch {
60
+ // keep walking up
61
+ }
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,56 @@
1
+ name: mate-minimal
2
+ version: 2
3
+ description: Mate's short OpenSpec workflow for one-Area, low-risk changes - specs → tasks
4
+ artifacts:
5
+ - id: specs
6
+ generates: specs/**/*.md
7
+ description: Delta specification answering what behavior must change through a concise user story
8
+ template: spec.md
9
+ instruction: |
10
+ This schema is a deliberate shortcut, not an optional subset of mate-v1. Use it only when
11
+ the change passes the documented gate: one repository and one Area; no new dependency,
12
+ data migration, public API change, breaking behavior, or cross-cutting design decision.
13
+ Use mate-v1 if any gate item is false or uncertain.
14
+
15
+ Read `openspec/mate-conventions.yaml` before writing the artifact. Create one delta spec
16
+ per affected capability under `specs/<capability>/spec.md`, using a kebab-case capability
17
+ name. Never place specs under an Area folder.
18
+
19
+ Write requirements as user stories: `As a <role>, I want <capability>, so that <benefit>.`
20
+ Keep the OpenSpec parser contract around each story:
21
+ - Use `## ADDED Requirements`, `## MODIFIED Requirements`, and `## REMOVED Requirements`.
22
+ - Start every story with `### Requirement: <story title>`. `### User Story:` is ignored.
23
+ - Follow the story with one SHALL/MUST sentence so `openspec validate --strict` can check it.
24
+ - Give every story an `#### Acceptance Criteria` block using `- **Given**`, `- **When**`,
25
+ `- **Then**` bullets. This is the minimal profile's readable form of a scenario.
26
+ - MODIFIED entries MUST include the full updated story and criteria. REMOVED entries MUST
27
+ include **Reason** and **Migration**.
28
+
29
+ Every user story carries an **Area:** marker naming the Areas it binds, using values from
30
+ the frontmatter scopes - always, even when there is only one Area.
31
+
32
+ **Obsidian**: Emit YAML frontmatter with `type: delta-spec`, the actual change name, the
33
+ capability, the `openspec/change`, `openspec/spec`, and `openspec/delta` tags, and paired
34
+ `scopes` metadata.
35
+ requires: []
36
+ - id: tasks
37
+ generates: tasks.md
38
+ description: Short task list answering how to complete and verify the delta
39
+ template: tasks.md
40
+ instruction: |
41
+ Create a flat implementation checklist that satisfies the delta specs. Keep it short: this
42
+ workflow intentionally has no explore, proposal, or design artifact.
43
+
44
+ Number tasks `N.M` and group them under `## N. <group>` headers so apply tracking can address them individually.
45
+ Read `openspec/mate-conventions.yaml` before writing the artifact and preserve its scope rules.
46
+
47
+ **Obsidian**: Emit YAML frontmatter with `type: change-tasks`, the actual change name, `schema: mate-minimal`, and the `openspec/change` and `openspec/tasks` tags. At the end of the file, add a `## References` section with `[[wiki-links]]` for each affected spec area.
48
+ requires:
49
+ - specs
50
+ apply:
51
+ requires:
52
+ - tasks
53
+ tracks: tasks.md
54
+ instruction: |
55
+ Read the delta specs and work through pending tasks only within the affected Areas. Mark complete as you go.
56
+ Pause if you hit blockers or need clarification.