@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
@@ -93,13 +93,38 @@ export interface ReportTextSection extends ReportSectionBase {
93
93
  content: string;
94
94
  }
95
95
 
96
+ export interface ReportDiagramSectionBase extends ReportSectionBase {
97
+ type: "diagram";
98
+ }
99
+
100
+ export interface ReportMermaidDiagramSection extends ReportDiagramSectionBase {
101
+ mermaid: string;
102
+ text?: never;
103
+ }
104
+
105
+ export interface ReportTextDiagramSection extends ReportDiagramSectionBase {
106
+ text: string;
107
+ mermaid?: never;
108
+ }
109
+
110
+ /** Exactly one payload: `mermaid` source drawn by the runtime, or `text` kept as a monospace block. */
111
+ export type ReportDiagramSection = ReportMermaidDiagramSection | ReportTextDiagramSection;
112
+
113
+ export interface ReportDiffSection extends ReportSectionBase {
114
+ type: "diff";
115
+ /** Unified diff as produced by `git diff`. */
116
+ patch: string;
117
+ }
118
+
96
119
  export type ReportSection =
97
120
  | ReportMetadataSection
98
121
  | ReportMetricsSection
99
122
  | ReportKeyValueSection
100
123
  | ReportTableSection
101
124
  | ReportStatusesSection
102
- | ReportTextSection;
125
+ | ReportTextSection
126
+ | ReportDiagramSection
127
+ | ReportDiffSection;
103
128
 
104
129
  export interface ReportDocument {
105
130
  version: typeof REPORT_DOCUMENT_VERSION;
@@ -1,35 +1,132 @@
1
- import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import {
5
+ CompanionResolver,
6
+ type CompanionMatch,
7
+ } from "../../../lib/orchestrator/companion-resolver";
2
8
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
9
+ import { findRepoLocalLinkedRepository } from "../../../lib/orchestrator/repo-local-registry";
10
+ import { projectWorkingRepositoryBestEffort } from "../../../lib/orchestrator/working-repo-projection";
11
+ import { resolveProjection } from "../../../runtime/projection";
3
12
  import { selectCompanion } from "../../companion-selector";
4
13
 
5
14
  export const launchAmbiguityDeps = {
6
15
  resolveCompanionMatches: async (cwd: string) =>
7
16
  (await new CompanionResolver(new GlobalConfigStore()).resolveWithDiagnostics(cwd))
8
17
  .ambiguousMatches,
18
+ /**
19
+ * The resolver reports `ambiguousMatches` as `[]` for an unambiguous link, so
20
+ * the Linked Companions are that list when populated and the single match
21
+ * otherwise.
22
+ */
23
+ resolveLinkedCompanions: async (cwd: string): Promise<CompanionMatch[]> => {
24
+ const { match, ambiguousMatches } = await new CompanionResolver(
25
+ new GlobalConfigStore(),
26
+ ).resolveWithDiagnostics(cwd);
27
+ if (ambiguousMatches.length > 0) return ambiguousMatches;
28
+ return match ? [match] : [];
29
+ },
9
30
  selectCompanion,
31
+ findRepoLocalLinkedRepository,
32
+ resolveProjection,
33
+ projectWorkingRepository: projectWorkingRepositoryBestEffort,
10
34
  };
11
35
 
36
+ export interface CompanionSelectionOptions {
37
+ /** Ignore both recorded answers — the launch environment and the projection. */
38
+ reselect?: boolean;
39
+ /** Ignore only the projected answer, so an ambiguous repository is asked again. */
40
+ ignoreProjection?: boolean;
41
+ /** Outranks the environment, the projection, and the picker. */
42
+ companion?: string;
43
+ }
44
+
45
+ function pin(match: Pick<CompanionMatch, "companionPath" | "repositoryId">): void {
46
+ process.env.MATE_ARTIFACT_PATH = match.companionPath;
47
+ process.env.MATE_REPO_ID = match.repositoryId;
48
+ }
49
+
50
+ function reportLinkedCompanions(message: string, matches: CompanionMatch[]): void {
51
+ process.stderr.write(message);
52
+ for (const match of matches) {
53
+ process.stderr.write(` - ${match.companionPath}\n`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Rank 1. Validated against the Linked Companions so a wrap can never record a
59
+ * companion that does not link the Working Repository.
60
+ */
61
+ async function pinExplicitCompanion(cwd: string, companion: string): Promise<boolean> {
62
+ const resolved = path.resolve(companion);
63
+ const linked = await launchAmbiguityDeps.resolveLinkedCompanions(cwd);
64
+ const chosen = linked.find((match) => path.resolve(match.companionPath) === resolved);
65
+ if (!chosen) {
66
+ reportLinkedCompanions(
67
+ `${FRAMEWORK_NAME}: ${resolved} does not link this working repo; linked companions are:\n`,
68
+ linked,
69
+ );
70
+ return false;
71
+ }
72
+
73
+ pin(chosen);
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Rank 3. Only reached when the link is ambiguous, so `matches` is the full set
79
+ * of Linked Companions — a projected companion absent from it no longer links
80
+ * the repository and is ignored rather than pinning a dead path.
81
+ */
82
+ function projectedCompanion(cwd: string, matches: CompanionMatch[]): CompanionMatch | null {
83
+ const projected = launchAmbiguityDeps.resolveProjection(cwd);
84
+ if (!projected) return null;
85
+
86
+ const resolved = path.resolve(projected.companionPath);
87
+ return matches.find((match) => path.resolve(match.companionPath) === resolved) ?? null;
88
+ }
89
+
90
+ /** Best-effort: a read-only Working Repository warns and the command proceeds. */
91
+ async function recordCompanion(cwd: string, chosen: CompanionMatch): Promise<void> {
92
+ const repository = await launchAmbiguityDeps.findRepoLocalLinkedRepository(cwd);
93
+ if (!repository) return;
94
+
95
+ await launchAmbiguityDeps.projectWorkingRepository(chosen.companionPath, repository);
96
+ }
97
+
12
98
  /**
13
99
  * Pins a companion selection into env vars when the current working repo is
14
100
  * linked from multiple companions. Commands that should respect an already
15
- * selected companion can call this before any context resolution.
101
+ * selected companion can call this before any context resolution. Resolution
102
+ * order: explicit companion, launch environment, Projection Root, picker — the
103
+ * environment above the projection, so no projection can degrade a session Mate
104
+ * configured. A picked companion is recorded at the Projection Root, so the
105
+ * question is asked once rather than once per command.
16
106
  */
17
107
  export async function ensureUnambiguousCompanion(
18
108
  cwd: string = process.cwd(),
19
- options: { reselect?: boolean } = {},
109
+ options: CompanionSelectionOptions = {},
20
110
  ): Promise<boolean> {
111
+ if (options.companion) return pinExplicitCompanion(cwd, options.companion);
21
112
  if (process.env.MATE_ARTIFACT_PATH && !options.reselect) return true;
22
113
 
23
114
  const ambiguousMatches = await launchAmbiguityDeps.resolveCompanionMatches(cwd);
24
115
  if (ambiguousMatches.length <= 1) return true;
25
116
 
117
+ if (!options.reselect && !options.ignoreProjection) {
118
+ const projected = projectedCompanion(cwd, ambiguousMatches);
119
+ if (projected) {
120
+ pin(projected);
121
+ return true;
122
+ }
123
+ }
124
+
26
125
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
27
- process.stderr.write(
28
- "mate: this working repo is linked from multiple companions; re-run in a TTY to choose one, or set MATE_ARTIFACT_PATH.\n",
126
+ reportLinkedCompanions(
127
+ `${FRAMEWORK_NAME}: this working repo is linked from multiple companions; re-run in a TTY to choose one, run \`${FRAMEWORK_NAME} wrap --companion <path>\`, or set MATE_ARTIFACT_PATH.\n`,
128
+ ambiguousMatches,
29
129
  );
30
- for (const match of ambiguousMatches) {
31
- process.stderr.write(` - ${match.companionPath}\n`);
32
- }
33
130
  return false;
34
131
  }
35
132
 
@@ -39,7 +136,7 @@ export async function ensureUnambiguousCompanion(
39
136
  return false;
40
137
  }
41
138
 
42
- process.env.MATE_ARTIFACT_PATH = chosen.companionPath;
43
- process.env.MATE_REPO_ID = chosen.repositoryId;
139
+ pin(chosen);
140
+ await recordCompanion(cwd, chosen);
44
141
  return true;
45
142
  }
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { parse } from "yaml";
5
+
6
+ /**
7
+ * Inline Area binding a requirement carries in a spec body. Anchored to the
8
+ * start of its own line, which is where the marker is written: a spec that
9
+ * merely mentions the marker inside prose or a code span binds no Area by
10
+ * talking about one.
11
+ */
12
+ const INLINE_AREA = /^\*\*Area:\*\*[ \t]*`([^`\n]+)`/gm;
13
+
14
+ function frontmatterAreas(content: string): string[] {
15
+ if (!content.startsWith("---")) return [];
16
+ const end = content.indexOf("\n---", 3);
17
+ if (end < 0) return [];
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = parse(content.slice(3, end));
21
+ } catch {
22
+ return [];
23
+ }
24
+ if (!parsed || typeof parsed !== "object") return [];
25
+
26
+ const document = parsed as { areas?: unknown; scopes?: unknown };
27
+ const areas = Array.isArray(document.areas)
28
+ ? document.areas.filter((entry): entry is string => typeof entry === "string")
29
+ : [];
30
+ const scoped = Array.isArray(document.scopes)
31
+ ? document.scopes
32
+ .map((entry) =>
33
+ entry && typeof entry === "object" ? (entry as { area?: unknown }).area : undefined,
34
+ )
35
+ .filter((area): area is string => typeof area === "string")
36
+ : [];
37
+ return [...areas, ...scoped];
38
+ }
39
+
40
+ /**
41
+ * Areas one spec binds. No OpenSpec JSON command reports them in bulk — a
42
+ * per-spec `show --json` would cost one process per spec — so the resolved
43
+ * spec document is read directly: frontmatter `areas`/`scopes` unioned with the
44
+ * inline `**Area:**` bindings its requirements carry.
45
+ */
46
+ export async function readSpecAreas(
47
+ specsRoot: string,
48
+ specId: string,
49
+ readFile: (filePath: string) => Promise<string> = (filePath) => fs.readFile(filePath, "utf8"),
50
+ ): Promise<string[]> {
51
+ let content: string;
52
+ try {
53
+ content = await readFile(path.join(specsRoot, specId, "spec.md"));
54
+ } catch {
55
+ return [];
56
+ }
57
+
58
+ const areas = new Set(
59
+ frontmatterAreas(content)
60
+ .map((area) => area.trim())
61
+ .filter(Boolean),
62
+ );
63
+ for (const match of content.matchAll(INLINE_AREA)) {
64
+ const area = match[1]?.trim();
65
+ if (area) areas.add(area);
66
+ }
67
+ return [...areas].toSorted((a, b) => a.localeCompare(b));
68
+ }
@@ -0,0 +1,69 @@
1
+ import { FRAMEWORK_NAME } from "../../../framework";
2
+ import { openReportInBrowser } from "../report/delivery";
3
+ import { serveUntilInterrupted, startStudioServer } from "./server";
4
+
5
+ export interface StudioCommandDeps {
6
+ startStudioServer?: typeof startStudioServer;
7
+ serveUntilInterrupted?: typeof serveUntilInterrupted;
8
+ openInBrowser?: (url: string) => Promise<void>;
9
+ log?: (message: string) => void;
10
+ warn?: (message: string) => void;
11
+ }
12
+
13
+ /**
14
+ * @command mate studio
15
+ * @description Serves a local, read-only page over the Companion Repositories
16
+ * registered on this machine — the workflow drawn from each companion's
17
+ * resolved schema with its OpenSpec state on it — and opens the platform
18
+ * browser at it. Runs in the foreground on an operating-system-assigned
19
+ * loopback port and dies with the process: no daemon, no stop command, and no
20
+ * state written anywhere.
21
+ * @remarks Takes no arguments and resolves no Repository Link, so it runs from
22
+ * any directory.
23
+ */
24
+ export async function runStudioCommand(
25
+ argv: string[] = [],
26
+ deps: StudioCommandDeps = {},
27
+ ): Promise<void> {
28
+ const log = deps.log ?? ((message: string) => process.stdout.write(`${message}\n`));
29
+ const warn = deps.warn ?? ((message: string) => process.stderr.write(`${message}\n`));
30
+
31
+ if (argv.length > 0) {
32
+ warn(`${FRAMEWORK_NAME}: \`studio\` takes no arguments; unrecognized: ${argv.join(" ")}`);
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+
37
+ const start = deps.startStudioServer ?? startStudioServer;
38
+ const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
39
+ const open = deps.openInBrowser ?? openReportInBrowser;
40
+
41
+ let server;
42
+ try {
43
+ server = start();
44
+ } catch (error) {
45
+ warn(
46
+ `${FRAMEWORK_NAME}: studio could not bind a port: ${
47
+ error instanceof Error ? error.message : String(error)
48
+ }`,
49
+ );
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ log(server.url);
55
+ log("Press Ctrl+C to stop.");
56
+
57
+ try {
58
+ await open(server.url);
59
+ } catch (error) {
60
+ warn(
61
+ `${FRAMEWORK_NAME}: studio could not open a browser: ${
62
+ error instanceof Error ? error.message : String(error)
63
+ }`,
64
+ );
65
+ warn(`${FRAMEWORK_NAME}: open ${server.url} manually; the server keeps serving.`);
66
+ }
67
+
68
+ await serve(server);
69
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ collectWorkspaceInventory,
3
+ type CompanionInventoryHealth,
4
+ type PairingInventoryHealth,
5
+ } from "../../../lib/orchestrator/workspace-inventory";
6
+
7
+ export interface StudioInventoryPairing {
8
+ repositoryId: string;
9
+ repositoryPath: string;
10
+ health: PairingInventoryHealth;
11
+ ambiguous: boolean;
12
+ }
13
+
14
+ export interface StudioInventoryCompanion {
15
+ path: string;
16
+ health: CompanionInventoryHealth;
17
+ diagnostic?: string;
18
+ pairings: StudioInventoryPairing[];
19
+ }
20
+
21
+ export interface StudioInventory {
22
+ companions: StudioInventoryCompanion[];
23
+ }
24
+
25
+ export interface StudioInventoryDeps {
26
+ collectWorkspaceInventory?: typeof collectWorkspaceInventory;
27
+ }
28
+
29
+ /**
30
+ * Companion-first projection of the machine-wide workspace inventory. Reads
31
+ * only the aggregate the workspace surface already resolves — health,
32
+ * ambiguity, and duplicate collapsing stay owned there.
33
+ */
34
+ export async function collectStudioInventory(
35
+ deps: StudioInventoryDeps = {},
36
+ ): Promise<StudioInventory> {
37
+ const collect = deps.collectWorkspaceInventory ?? collectWorkspaceInventory;
38
+ const inventory = await collect();
39
+
40
+ return {
41
+ companions: inventory.companions.map((companion) => ({
42
+ path: companion.path,
43
+ health: companion.health,
44
+ ...(companion.diagnostic ? { diagnostic: companion.diagnostic } : {}),
45
+ pairings: inventory.pairings
46
+ .filter((pairing) => pairing.companionPath === companion.path)
47
+ .map((pairing) => ({
48
+ repositoryId: pairing.repository.id,
49
+ repositoryPath: pairing.repository.path,
50
+ health: pairing.health,
51
+ ambiguous: pairing.ambiguous,
52
+ })),
53
+ })),
54
+ };
55
+ }
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs/promises";
2
+ import type { Dirent } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export interface StudioSkillInventory {
6
+ claude: string[];
7
+ opencode: string[];
8
+ agents: string[];
9
+ }
10
+
11
+ const SKILL_DIRS = {
12
+ claude: ".claude",
13
+ opencode: ".opencode",
14
+ agents: ".agents",
15
+ } as const;
16
+
17
+ async function skillNamesAt(companionPath: string, runtimeDir: string): Promise<string[]> {
18
+ let entries: Dirent[];
19
+ try {
20
+ entries = await fs.readdir(path.join(companionPath, runtimeDir, "skills"), {
21
+ withFileTypes: true,
22
+ });
23
+ } catch {
24
+ return [];
25
+ }
26
+
27
+ return entries
28
+ .filter((entry) => entry.isDirectory())
29
+ .map((entry) => entry.name)
30
+ .toSorted();
31
+ }
32
+
33
+ /**
34
+ * Reports every skill directory in each Agent Runtime and the shared Agents tree.
35
+ */
36
+ export async function collectSkillInventory(companionPath: string): Promise<StudioSkillInventory> {
37
+ const [claude, opencode, agents] = await Promise.all([
38
+ skillNamesAt(companionPath, SKILL_DIRS.claude),
39
+ skillNamesAt(companionPath, SKILL_DIRS.opencode),
40
+ skillNamesAt(companionPath, SKILL_DIRS.agents),
41
+ ]);
42
+ return { claude, opencode, agents };
43
+ }
@@ -0,0 +1,198 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ import { getWrapperBinPath } from "../../../lib/package-paths";
5
+
6
+ export interface OpenSpecFailure {
7
+ command: string;
8
+ reason: string;
9
+ }
10
+
11
+ export type OpenSpecResult<T> = { ok: true; value: T } | { ok: false; failure: OpenSpecFailure };
12
+
13
+ export interface OpenSpecRunOutput {
14
+ stdout: string;
15
+ stderr: string;
16
+ code: number | null;
17
+ }
18
+
19
+ export interface OpenSpecCliDeps {
20
+ wrapperPath?: () => string;
21
+ run?: (bin: string, args: string[], companionPath: string) => Promise<OpenSpecRunOutput>;
22
+ }
23
+
24
+ /** The Capability whose CLI produces the workflow a companion's changes move through. */
25
+ export const WORKFLOW_CAPABILITY_ID = "openspec";
26
+
27
+ /** Upper bound on one collection call; a hung wrapper must not hold a request open. */
28
+ const OPENSPEC_TIMEOUT_MS = 60_000;
29
+
30
+ export function openSpecWrapperPath(): string {
31
+ return path.join(getWrapperBinPath(), "openspec");
32
+ }
33
+
34
+ /**
35
+ * The wrapper resolves its OpenSpec root from `MATE_ARTIFACT_PATH`, so the
36
+ * companion is selected by environment rather than by cwd — the studio server
37
+ * has no Repository Link to walk up from.
38
+ */
39
+ function runOpenSpecWrapper(
40
+ bin: string,
41
+ args: string[],
42
+ companionPath: string,
43
+ ): Promise<OpenSpecRunOutput> {
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn(bin, args, {
46
+ cwd: companionPath,
47
+ env: { ...process.env, MATE_ARTIFACT_PATH: companionPath },
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ timeout: OPENSPEC_TIMEOUT_MS,
50
+ });
51
+
52
+ let stdout = "";
53
+ let stderr = "";
54
+ child.stdout?.setEncoding("utf8");
55
+ child.stderr?.setEncoding("utf8");
56
+ child.stdout?.on("data", (chunk: string) => {
57
+ stdout += chunk;
58
+ });
59
+ child.stderr?.on("data", (chunk: string) => {
60
+ stderr += chunk;
61
+ });
62
+ child.once("error", reject);
63
+ child.once("close", (code) => resolve({ stdout, stderr, code }));
64
+ });
65
+ }
66
+
67
+ function firstJsonValue(stdout: string): unknown {
68
+ const start = stdout.search(/[[{]/);
69
+ if (start < 0) return undefined;
70
+ try {
71
+ return JSON.parse(stdout.slice(start));
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ function reasonFor(output: OpenSpecRunOutput): string {
78
+ const detail = (output.stderr.trim() || output.stdout.trim()).split("\n").slice(-3).join("; ");
79
+ const status = `exit code ${output.code ?? "unknown"}`;
80
+ return detail ? `${status}: ${detail}` : status;
81
+ }
82
+
83
+ /**
84
+ * Reads one OpenSpec JSON command for a Companion Repository. Never throws:
85
+ * every failure — spawn, exit status, unparseable output — becomes a typed
86
+ * failure so one unreadable companion cannot take the server down.
87
+ */
88
+ export async function readOpenSpecJson<T>(
89
+ companionPath: string,
90
+ args: string[],
91
+ deps: OpenSpecCliDeps = {},
92
+ ): Promise<OpenSpecResult<T>> {
93
+ const bin = (deps.wrapperPath ?? openSpecWrapperPath)();
94
+ const command = `openspec ${args.join(" ")}`;
95
+ const run = deps.run ?? runOpenSpecWrapper;
96
+
97
+ let output: OpenSpecRunOutput;
98
+ try {
99
+ output = await run(bin, args, companionPath);
100
+ } catch (error) {
101
+ return {
102
+ ok: false,
103
+ failure: { command, reason: error instanceof Error ? error.message : String(error) },
104
+ };
105
+ }
106
+
107
+ // `validate` exits non-zero when it finds issues, and its JSON report is
108
+ // exactly what Studio wants in that case — parseable stdout wins over status.
109
+ const parsed = firstJsonValue(output.stdout);
110
+ if (parsed !== undefined) return { ok: true, value: parsed as T };
111
+
112
+ if (output.code !== 0) return { ok: false, failure: { command, reason: reasonFor(output) } };
113
+ return { ok: false, failure: { command, reason: `${command} did not return JSON` } };
114
+ }
115
+
116
+ export interface OpenSpecChangeListEntry {
117
+ name: string;
118
+ completedTasks?: number;
119
+ totalTasks?: number;
120
+ status?: string;
121
+ lastModified?: string;
122
+ }
123
+
124
+ export interface OpenSpecChangeList {
125
+ changes?: OpenSpecChangeListEntry[];
126
+ }
127
+
128
+ export interface OpenSpecSpecListEntry {
129
+ id: string;
130
+ requirementCount?: number;
131
+ }
132
+
133
+ export interface OpenSpecSpecList {
134
+ specs?: OpenSpecSpecListEntry[];
135
+ }
136
+
137
+ export interface OpenSpecArtifactStatus {
138
+ id: string;
139
+ status?: string;
140
+ outputPath?: string;
141
+ requires?: string[];
142
+ }
143
+
144
+ export interface OpenSpecChangeStatusEntry {
145
+ changeName: string;
146
+ schemaName?: string;
147
+ isComplete?: boolean;
148
+ isPlanningComplete?: boolean;
149
+ planningHome?: { root?: string; defaultSchema?: string };
150
+ artifacts?: OpenSpecArtifactStatus[];
151
+ }
152
+
153
+ export interface OpenSpecAllChangeStatus {
154
+ changes?: OpenSpecChangeStatusEntry[];
155
+ }
156
+
157
+ export interface OpenSpecValidationItem {
158
+ id: string;
159
+ type?: string;
160
+ valid?: boolean;
161
+ issues?: { level?: string; path?: string; message?: string }[];
162
+ }
163
+
164
+ export interface OpenSpecValidationReport {
165
+ items?: OpenSpecValidationItem[];
166
+ }
167
+
168
+ export function listChanges(
169
+ companionPath: string,
170
+ deps: OpenSpecCliDeps = {},
171
+ ): Promise<OpenSpecResult<OpenSpecChangeList>> {
172
+ return readOpenSpecJson(companionPath, ["list", "--json"], deps);
173
+ }
174
+
175
+ export function listSpecs(
176
+ companionPath: string,
177
+ deps: OpenSpecCliDeps = {},
178
+ ): Promise<OpenSpecResult<OpenSpecSpecList>> {
179
+ return readOpenSpecJson(companionPath, ["list", "--specs", "--json"], deps);
180
+ }
181
+
182
+ export function readAllChangeStatus(
183
+ companionPath: string,
184
+ deps: OpenSpecCliDeps = {},
185
+ ): Promise<OpenSpecResult<OpenSpecAllChangeStatus>> {
186
+ return readOpenSpecJson(companionPath, ["status", "--all", "--json"], deps);
187
+ }
188
+
189
+ /**
190
+ * `validate --json` without a selector prints help and exits non-zero, so the
191
+ * whole-root report is the `--all` form.
192
+ */
193
+ export function validateAll(
194
+ companionPath: string,
195
+ deps: OpenSpecCliDeps = {},
196
+ ): Promise<OpenSpecResult<OpenSpecValidationReport>> {
197
+ return readOpenSpecJson(companionPath, ["validate", "--all", "--json"], deps);
198
+ }