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

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 (186) 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 +7 -5
  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/cap/tokensave.ts +4 -4
  19. package/src/cli/commands/companion/companion.ts +5 -1
  20. package/src/cli/commands/companion/link.ts +2 -2
  21. package/src/cli/commands/companion/sync.ts +92 -0
  22. package/src/cli/commands/doctor.ts +0 -3
  23. package/src/cli/commands/launch/shared.ts +23 -5
  24. package/src/cli/commands/report/collector.ts +72 -78
  25. package/src/cli/commands/report/contract.ts +40 -1
  26. package/src/cli/commands/report/highlight.ts +27 -0
  27. package/src/cli/commands/report/index.ts +11 -18
  28. package/src/cli/commands/report/renderer.ts +199 -2
  29. package/src/cli/commands/report/types.ts +26 -1
  30. package/src/cli/commands/shared/companion-selection.ts +107 -10
  31. package/src/cli/commands/studio/areas.ts +68 -0
  32. package/src/cli/commands/studio/index.ts +69 -0
  33. package/src/cli/commands/studio/inventory.ts +55 -0
  34. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  35. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  36. package/src/cli/commands/studio/payload.ts +184 -0
  37. package/src/cli/commands/studio/routes.ts +2 -0
  38. package/src/cli/commands/studio/selection.ts +61 -0
  39. package/src/cli/commands/studio/server.ts +201 -0
  40. package/src/cli/commands/studio/snapshot.ts +63 -0
  41. package/src/cli/commands/studio/topology.ts +199 -0
  42. package/src/cli/commands/studio/views/client.ts +197 -0
  43. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  44. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  46. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  47. package/src/cli/commands/studio/views/document.tsx +256 -0
  48. package/src/cli/commands/studio/views/error.tsx +20 -0
  49. package/src/cli/commands/studio/views/model.ts +34 -0
  50. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  51. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  52. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  53. package/src/cli/commands/studio/views/styles.ts +389 -0
  54. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  56. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  57. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  58. package/src/cli/commands/unwrap.ts +70 -0
  59. package/src/cli/commands/wrap.ts +164 -0
  60. package/src/cli/main.ts +68 -19
  61. package/src/cli/parse-flags.ts +36 -11
  62. package/src/cli/usage.ts +12 -3
  63. package/src/framework.ts +1 -7
  64. package/src/hooks/session-banner.ts +64 -11
  65. package/src/hooks/session-guidance.ts +40 -0
  66. package/src/hooks/validate-artifact-path.ts +108 -35
  67. package/src/lib/fs-utils.ts +9 -0
  68. package/src/lib/install.ts +33 -0
  69. package/src/lib/orchestrator/adapters/base.ts +14 -125
  70. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  71. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  72. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  73. package/src/lib/orchestrator/config-store.ts +2 -21
  74. package/src/lib/orchestrator/editor.ts +12 -22
  75. package/src/lib/orchestrator/framework-context.ts +17 -6
  76. package/src/lib/orchestrator/global-config-store.ts +1 -1
  77. package/src/lib/orchestrator/launcher.ts +97 -7
  78. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  79. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  80. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  81. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  82. package/src/lib/orchestrator/projection-entries.ts +377 -0
  83. package/src/lib/orchestrator/projection-record.ts +56 -0
  84. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  85. package/src/lib/orchestrator/projection-types.ts +169 -0
  86. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  87. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  88. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  89. package/src/lib/orchestrator/types.ts +1 -0
  90. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  91. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  92. package/src/lib/package-paths.ts +11 -1
  93. package/src/lib/public-npm.ts +2 -1
  94. package/src/lib/update-checker.ts +15 -9
  95. package/src/opencode/companion-hooks.ts +89 -245
  96. package/src/opencode/companion-policy.ts +35 -10
  97. package/src/opencode/index.ts +1 -0
  98. package/src/opencode/projected-guidance.ts +56 -0
  99. package/src/opencode/tui.tsx +13 -4
  100. package/src/playbooks/companion-guidance.ts +32 -116
  101. package/src/plugins.ts +0 -1
  102. package/src/runtime/companion-git-state.ts +156 -0
  103. package/src/runtime/companion-git.ts +203 -0
  104. package/src/runtime/companion-guidance.ts +222 -0
  105. package/src/runtime/companion-sync.ts +298 -0
  106. package/src/runtime/env-names.ts +30 -0
  107. package/src/runtime/env.ts +67 -35
  108. package/src/runtime/framework.ts +10 -0
  109. package/src/runtime/freshness.ts +58 -0
  110. package/src/runtime/index.ts +104 -0
  111. package/src/runtime/install.ts +30 -0
  112. package/src/runtime/policy.ts +66 -0
  113. package/src/runtime/projected-guidance.ts +45 -0
  114. package/src/runtime/projection.ts +224 -0
  115. package/src/runtime/repo-local.ts +64 -0
  116. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  117. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  118. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  119. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  122. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  123. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  124. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  125. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  126. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  127. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  128. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  129. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  130. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  131. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  132. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  133. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  134. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
  135. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  136. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  137. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  138. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  139. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  140. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  141. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  142. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  143. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  144. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  145. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  146. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
  147. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  148. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  149. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  150. package/src/templates/report-assets/README.md +32 -0
  151. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  152. package/src/templates/report-assets/mermaid.min.js +4376 -0
  153. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  154. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  155. package/src/tools/setup/capabilities/graphify.ts +16 -7
  156. package/src/tools/setup/capabilities/openspec.ts +63 -56
  157. package/src/tools/setup/capabilities/tokensave.ts +116 -2
  158. package/src/tools/setup/engine.ts +34 -6
  159. package/src/tools/setup/mate.ts +42 -13
  160. package/src/tools/setup/plugin.ts +9 -0
  161. package/src/tools/setup/plugins/guidance.ts +11 -1
  162. package/src/tools/setup/providers/claude-format.ts +49 -4
  163. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  164. package/src/tools/setup/providers/claude.ts +55 -220
  165. package/src/tools/setup/providers/opencode.ts +41 -14
  166. package/src/tools/setup/runtime-documents.ts +174 -0
  167. package/src/tools/setup/surface-target.ts +50 -0
  168. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  169. package/src/tools/setup/working-repo-local-state.ts +21 -1
  170. package/src/tools/setup.ts +25 -3
  171. package/wrappers/bin/graphify +57 -8
  172. package/wrappers/bin/openspec +50 -3
  173. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  174. package/src/cli/commands/cap/headroom.ts +0 -52
  175. package/src/cli/commands/workspace/list.ts +0 -25
  176. package/src/cli/commands/workspace/materialize.ts +0 -46
  177. package/src/cli/commands/workspace/workspace.ts +0 -22
  178. package/src/hooks/artifact-finish-nudge.ts +0 -244
  179. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  180. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  181. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  182. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  183. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  184. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  185. package/src/tools/setup/capabilities/headroom.ts +0 -57
  186. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -0,0 +1,184 @@
1
+ import path from "node:path";
2
+
3
+ import { readSpecAreas } from "./areas";
4
+ import { collectSkillInventory, type StudioSkillInventory } from "./mate-inventory";
5
+ import {
6
+ listChanges,
7
+ listSpecs,
8
+ readAllChangeStatus,
9
+ validateAll,
10
+ type OpenSpecFailure,
11
+ type OpenSpecValidationItem,
12
+ } from "./openspec-cli";
13
+ import { readWorkflowTopology, type WorkflowTopology } from "./topology";
14
+
15
+ export interface StudioChangeArtifact {
16
+ id: string;
17
+ status?: string;
18
+ }
19
+
20
+ export interface StudioChange {
21
+ name: string;
22
+ completedTasks?: number;
23
+ totalTasks?: number;
24
+ status?: string;
25
+ lastModified?: string;
26
+ schemaName?: string;
27
+ artifacts: StudioChangeArtifact[];
28
+ valid?: boolean;
29
+ issueCount?: number;
30
+ }
31
+
32
+ export interface StudioSpec {
33
+ capability: string;
34
+ requirementCount?: number;
35
+ areas: string[];
36
+ valid?: boolean;
37
+ issueCount?: number;
38
+ }
39
+
40
+ export interface StudioCompanionPayload {
41
+ companionPath: string;
42
+ changes: StudioChange[];
43
+ specs: StudioSpec[];
44
+ /** Flattened names retained for the workflow's skill-availability check. */
45
+ skills?: string[];
46
+ skillInventory?: StudioSkillInventory;
47
+ topology: WorkflowTopology | null;
48
+ warnings: string[];
49
+ }
50
+
51
+ export interface StudioCompanionError {
52
+ error: { companionPath: string; reason: string };
53
+ }
54
+
55
+ export type StudioCompanionResponse = StudioCompanionPayload | StudioCompanionError;
56
+
57
+ export interface CompanionPayloadDeps {
58
+ listChanges?: typeof listChanges;
59
+ listSpecs?: typeof listSpecs;
60
+ readAllChangeStatus?: typeof readAllChangeStatus;
61
+ validateAll?: typeof validateAll;
62
+ readWorkflowTopology?: typeof readWorkflowTopology;
63
+ readSpecAreas?: (specsRoot: string, specId: string) => Promise<string[]>;
64
+ collectSkillInventory?: typeof collectSkillInventory;
65
+ }
66
+
67
+ function describe(failure: OpenSpecFailure): string {
68
+ return `${failure.command}: ${failure.reason}`;
69
+ }
70
+
71
+ function validationOf(
72
+ items: OpenSpecValidationItem[] | undefined,
73
+ id: string,
74
+ type: string,
75
+ ): { valid?: boolean; issueCount?: number } {
76
+ const item = items?.find((entry) => entry.id === id && (entry.type ?? type) === type);
77
+ if (!item || item.valid === undefined) return {};
78
+ return { valid: item.valid, issueCount: item.issues?.length ?? 0 };
79
+ }
80
+
81
+ /**
82
+ * One Companion Repository's studio payload. Change and spec collection is
83
+ * load-bearing — its failure becomes the error payload — while topology,
84
+ * validation, and skills degrade to warnings so a companion missing one of them
85
+ * still renders.
86
+ */
87
+ export async function assembleCompanionPayload(
88
+ companionPath: string,
89
+ deps: CompanionPayloadDeps = {},
90
+ ): Promise<StudioCompanionResponse> {
91
+ const collectChanges = deps.listChanges ?? listChanges;
92
+ const collectSpecs = deps.listSpecs ?? listSpecs;
93
+ const collectStatus = deps.readAllChangeStatus ?? readAllChangeStatus;
94
+ const collectValidation = deps.validateAll ?? validateAll;
95
+ const collectTopology = deps.readWorkflowTopology ?? readWorkflowTopology;
96
+ const collectAreas = deps.readSpecAreas ?? readSpecAreas;
97
+ const collectSkills = deps.collectSkillInventory ?? collectSkillInventory;
98
+
99
+ const [changeList, specList, status, validation, topology, skillInventory] = await Promise.all([
100
+ collectChanges(companionPath),
101
+ collectSpecs(companionPath),
102
+ collectStatus(companionPath),
103
+ collectValidation(companionPath),
104
+ collectTopology(companionPath),
105
+ collectSkills(companionPath).then(
106
+ (skills) => ({ skills }),
107
+ (error: unknown) => ({ error }),
108
+ ),
109
+ ]);
110
+
111
+ for (const required of [changeList, specList, status]) {
112
+ if (!required.ok) {
113
+ return { error: { companionPath, reason: describe(required.failure) } };
114
+ }
115
+ }
116
+ if (!changeList.ok || !specList.ok || !status.ok) {
117
+ return { error: { companionPath, reason: "companion state could not be collected" } };
118
+ }
119
+
120
+ const warnings: string[] = [];
121
+ if (!topology.ok) warnings.push(describe(topology.failure));
122
+ if (!validation.ok) warnings.push(describe(validation.failure));
123
+ if ("error" in skillInventory) {
124
+ warnings.push(
125
+ `Mate skill inventory: ${skillInventory.error instanceof Error ? skillInventory.error.message : String(skillInventory.error)}`,
126
+ );
127
+ }
128
+ const validationItems = validation.ok ? validation.value.items : undefined;
129
+
130
+ const statusByChange = new Map(
131
+ (status.value.changes ?? []).map((entry) => [entry.changeName, entry]),
132
+ );
133
+
134
+ const changes: StudioChange[] = (changeList.value.changes ?? []).map((change) => {
135
+ const changeStatus = statusByChange.get(change.name);
136
+ return {
137
+ name: change.name,
138
+ ...(change.completedTasks === undefined ? {} : { completedTasks: change.completedTasks }),
139
+ ...(change.totalTasks === undefined ? {} : { totalTasks: change.totalTasks }),
140
+ ...(change.status === undefined ? {} : { status: change.status }),
141
+ ...(change.lastModified === undefined ? {} : { lastModified: change.lastModified }),
142
+ ...(changeStatus?.schemaName ? { schemaName: changeStatus.schemaName } : {}),
143
+ artifacts: (changeStatus?.artifacts ?? []).map((artifact) => ({
144
+ id: artifact.id,
145
+ ...(artifact.status === undefined ? {} : { status: artifact.status }),
146
+ })),
147
+ ...validationOf(validationItems, change.name, "change"),
148
+ };
149
+ });
150
+
151
+ const planningRoot =
152
+ (status.value.changes ?? []).find((entry) => entry.planningHome?.root)?.planningHome?.root ??
153
+ companionPath;
154
+ const specsRoot = path.join(planningRoot, "openspec", "specs");
155
+
156
+ const specs: StudioSpec[] = await Promise.all(
157
+ (specList.value.specs ?? []).map(async (spec) => ({
158
+ capability: spec.id,
159
+ ...(spec.requirementCount === undefined ? {} : { requirementCount: spec.requirementCount }),
160
+ areas: await collectAreas(specsRoot, spec.id),
161
+ ...validationOf(validationItems, spec.id, "spec"),
162
+ })),
163
+ );
164
+
165
+ return {
166
+ companionPath,
167
+ changes,
168
+ specs,
169
+ skills:
170
+ "skills" in skillInventory
171
+ ? [
172
+ ...new Set([
173
+ ...skillInventory.skills.claude,
174
+ ...skillInventory.skills.opencode,
175
+ ...skillInventory.skills.agents,
176
+ ]),
177
+ ].toSorted()
178
+ : [],
179
+ skillInventory:
180
+ "skills" in skillInventory ? skillInventory.skills : { claude: [], opencode: [], agents: [] },
181
+ topology: topology.ok ? topology.value : null,
182
+ warnings,
183
+ };
184
+ }
@@ -0,0 +1,2 @@
1
+ /** Loopback only: a payload carries absolute companion paths and change names. */
2
+ export const STUDIO_HOSTNAME = "127.0.0.1";
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
4
+
5
+ export type StudioView = "dashboard" | "workflow" | "specs" | "skills";
6
+
7
+ export const STUDIO_VIEWS: readonly StudioView[] = ["dashboard", "workflow", "specs", "skills"];
8
+
9
+ export const COMPANION_PARAM = "companion";
10
+ export const VIEW_PARAM = "view";
11
+ export const REFRESH_PARAM = "refresh";
12
+
13
+ /** Filled into a prompt when no change is named, so a prompt is never half-written. */
14
+ export const CHANGE_PLACEHOLDER = "<change-name>";
15
+
16
+ const DIGEST_LENGTH = 10;
17
+
18
+ export interface StudioSelection {
19
+ companionDigest: string | null;
20
+ view: StudioView;
21
+ /**
22
+ * One-shot: the refresh control asks for it, and it is never carried forward
23
+ * into the URL another control builds. Switching a view is not a refresh.
24
+ */
25
+ refresh: boolean;
26
+ }
27
+
28
+ /**
29
+ * Derived from the companion path alone: stable enough to bookmark, and short
30
+ * enough that no absolute Companion Repository path is written into browser
31
+ * history, autocomplete, or a shared URL.
32
+ */
33
+ /** The shape a digest must have to be trusted from outside the server: a URL, a browser store. */
34
+ export const COMPANION_DIGEST_PATTERN = new RegExp(`^[0-9a-f]{${DIGEST_LENGTH}}$`);
35
+
36
+ export function companionDigest(companionPath: string): string {
37
+ return createHash("sha256").update(companionPath).digest("hex").slice(0, DIGEST_LENGTH);
38
+ }
39
+
40
+ /** An unresolvable digest selects nothing rather than failing the request. */
41
+ export function resolveCompanion(
42
+ inventory: StudioInventory,
43
+ digest: string | null,
44
+ ): StudioInventoryCompanion | null {
45
+ if (!digest) return null;
46
+ return (
47
+ inventory.companions.find((companion) => companionDigest(companion.path) === digest) ?? null
48
+ );
49
+ }
50
+
51
+ function readView(value: string | null): StudioView {
52
+ return STUDIO_VIEWS.includes(value as StudioView) ? (value as StudioView) : "dashboard";
53
+ }
54
+
55
+ export function parseStudioSelection(url: URL): StudioSelection {
56
+ return {
57
+ companionDigest: url.searchParams.get(COMPANION_PARAM)?.trim() || null,
58
+ view: readView(url.searchParams.get(VIEW_PARAM)),
59
+ refresh: url.searchParams.get(REFRESH_PARAM) === "1",
60
+ };
61
+ }
@@ -0,0 +1,201 @@
1
+ import { collectStudioInventory, type StudioInventory } from "./inventory";
2
+ import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
3
+ import { STUDIO_HOSTNAME } from "./routes";
4
+ import { parseStudioSelection, resolveCompanion } from "./selection";
5
+ import { createStudioSnapshotCache, type StudioSnapshotCache } from "./snapshot";
6
+ import type { StudioPage } from "./views/model";
7
+
8
+ export { STUDIO_HOSTNAME } from "./routes";
9
+
10
+ /** Read methods; every other method is refused before any collection runs. */
11
+ const READ_METHODS = new Set(["GET", "HEAD"]);
12
+
13
+ export interface StudioServeOptions {
14
+ port: number;
15
+ hostname: string;
16
+ fetch: (request: Request) => Promise<Response>;
17
+ }
18
+
19
+ export interface StudioBoundServer {
20
+ port: number;
21
+ stop(closeActiveConnections?: boolean): void | Promise<void>;
22
+ }
23
+
24
+ export interface StudioServerHandle {
25
+ url: string;
26
+ port: number;
27
+ hostname: string;
28
+ stop(): void | Promise<void>;
29
+ }
30
+
31
+ export interface StudioServerDeps {
32
+ collectStudioInventory?: () => Promise<StudioInventory>;
33
+ assembleCompanionPayload?: (companionPath: string) => Promise<StudioCompanionResponse>;
34
+ renderDocument?: (page: StudioPage) => string | Promise<string>;
35
+ serve?: (options: StudioServeOptions) => StudioBoundServer;
36
+ snapshots?: StudioSnapshotCache;
37
+ }
38
+
39
+ /**
40
+ * Loaded at the point of use so no other command pays for the renderer: `hono/jsx`
41
+ * is reachable from the studio path alone.
42
+ */
43
+ async function renderStudioDocument(page: StudioPage): Promise<string> {
44
+ const views = await import("./views/document");
45
+ return views.renderStudioDocument(page);
46
+ }
47
+
48
+ function html(body: string): Response {
49
+ return new Response(body, {
50
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
51
+ });
52
+ }
53
+
54
+ /**
55
+ * The studio request handler. Read-only by construction: a non-read method is
56
+ * refused before any collection runs, and no route writes. The response is
57
+ * already correct for the URL that asked for it — the companion, the change,
58
+ * and the view are read from the request rather than reconciled in the browser.
59
+ * Collection is held in one snapshot cache for the life of the handler, so a
60
+ * navigation that only names another view of already-collected state serves it
61
+ * without spawning OpenSpec again.
62
+ */
63
+ export function createStudioFetch(
64
+ deps: StudioServerDeps = {},
65
+ ): (request: Request) => Promise<Response> {
66
+ const inventory = deps.collectStudioInventory ?? collectStudioInventory;
67
+ const companion = deps.assembleCompanionPayload ?? assembleCompanionPayload;
68
+ const render = deps.renderDocument ?? renderStudioDocument;
69
+ const snapshots =
70
+ deps.snapshots ?? createStudioSnapshotCache({ assembleCompanionPayload: companion });
71
+
72
+ return async (request: Request): Promise<Response> => {
73
+ if (!READ_METHODS.has(request.method)) {
74
+ return new Response("studio serves read requests only", {
75
+ status: 405,
76
+ headers: { allow: "GET, HEAD" },
77
+ });
78
+ }
79
+
80
+ const url = new URL(request.url);
81
+ const respond = (response: Response) =>
82
+ request.method === "HEAD"
83
+ ? new Response(null, { status: response.status, headers: response.headers })
84
+ : response;
85
+
86
+ if (url.pathname !== "/") return respond(new Response("not found", { status: 404 }));
87
+
88
+ return respond(html(await render(await collectStudioPage(url, inventory, snapshots))));
89
+ };
90
+ }
91
+
92
+ /**
93
+ * One rendered document's worth of state. An absent or unresolvable companion
94
+ * is not a failure: the page renders the selector and the server keeps serving.
95
+ */
96
+ async function collectStudioPage(
97
+ url: URL,
98
+ collectInventory: () => Promise<StudioInventory>,
99
+ snapshots: StudioSnapshotCache,
100
+ ): Promise<StudioPage> {
101
+ const selection = parseStudioSelection(url);
102
+ const inventory = await collectInventory();
103
+ const companion = resolveCompanion(inventory, selection.companionDigest);
104
+ const page: StudioPage = {
105
+ inventory,
106
+ selection,
107
+ companion,
108
+ payload: null,
109
+ error: null,
110
+ collectedAt: null,
111
+ };
112
+
113
+ if (!companion) return page;
114
+
115
+ try {
116
+ const snapshot = await snapshots.read(companion.path, selection.refresh);
117
+ const collected = { ...page, collectedAt: snapshot.collectedAt };
118
+ if ("error" in snapshot.response) return { ...collected, error: snapshot.response.error };
119
+ return { ...collected, payload: snapshot.response };
120
+ } catch (error) {
121
+ return {
122
+ ...page,
123
+ error: {
124
+ companionPath: companion.path,
125
+ reason: error instanceof Error ? error.message : String(error),
126
+ },
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Bun's server, reached through `globalThis` rather than an ambient global: the
133
+ * repository typechecks against Node types only, and Studio's dependence on the
134
+ * Bun runtime is a deliberate constraint worth naming here.
135
+ */
136
+ interface BunRuntime {
137
+ serve(options: StudioServeOptions): StudioBoundServer;
138
+ }
139
+
140
+ function bunServe(options: StudioServeOptions): StudioBoundServer {
141
+ const runtime = (globalThis as unknown as { Bun?: BunRuntime }).Bun;
142
+ if (!runtime) throw new Error("studio requires the Bun runtime to serve");
143
+ return runtime.serve(options);
144
+ }
145
+
146
+ /**
147
+ * Binds the studio server to an operating-system-assigned loopback port. A
148
+ * bind failure propagates: the caller reports it and exits rather than opening
149
+ * a browser at a URL nothing answers.
150
+ */
151
+ export function startStudioServer(deps: StudioServerDeps = {}): StudioServerHandle {
152
+ const serve = deps.serve ?? bunServe;
153
+ const server = serve({ port: 0, hostname: STUDIO_HOSTNAME, fetch: createStudioFetch(deps) });
154
+
155
+ return {
156
+ url: `http://localhost:${server.port}`,
157
+ port: server.port,
158
+ hostname: STUDIO_HOSTNAME,
159
+ stop: () => server.stop(true),
160
+ };
161
+ }
162
+
163
+ export interface ServeUntilInterruptedDeps {
164
+ onSignal?: (signal: string, handler: () => void) => () => void;
165
+ }
166
+
167
+ const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"] as const;
168
+
169
+ function subscribeToProcessSignal(signal: string, handler: () => void): () => void {
170
+ process.on(signal as NodeJS.Signals, handler);
171
+ return () => process.off(signal as NodeJS.Signals, handler);
172
+ }
173
+
174
+ /**
175
+ * Holds the invocation open while the server serves, and stops it on the first
176
+ * interrupt. Nothing outlives the invocation: no detached process, no
177
+ * process-identifier file, no registration, and the signal handlers are removed
178
+ * before the promise settles.
179
+ */
180
+ export function serveUntilInterrupted(
181
+ server: StudioServerHandle,
182
+ deps: ServeUntilInterruptedDeps = {},
183
+ ): Promise<void> {
184
+ const onSignal = deps.onSignal ?? subscribeToProcessSignal;
185
+
186
+ return new Promise<void>((resolve) => {
187
+ const unsubscribes: (() => void)[] = [];
188
+ let settled = false;
189
+
190
+ const finish = () => {
191
+ if (settled) return;
192
+ settled = true;
193
+ for (const unsubscribe of unsubscribes) unsubscribe();
194
+ void Promise.resolve(server.stop()).then(resolve, resolve);
195
+ };
196
+
197
+ for (const signal of INTERRUPT_SIGNALS) {
198
+ unsubscribes.push(onSignal(signal, finish));
199
+ }
200
+ });
201
+ }
@@ -0,0 +1,63 @@
1
+ import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
2
+
3
+ /** One companion's collected state, with the moment it was collected. */
4
+ export interface StudioSnapshot {
5
+ response: StudioCompanionResponse;
6
+ collectedAt: number;
7
+ }
8
+
9
+ export interface StudioSnapshotCache {
10
+ read(companionPath: string, refresh: boolean): Promise<StudioSnapshot>;
11
+ }
12
+
13
+ export interface StudioSnapshotCacheDeps {
14
+ assembleCompanionPayload?: (companionPath: string) => Promise<StudioCompanionResponse>;
15
+ now?: () => number;
16
+ }
17
+
18
+ /**
19
+ * Holds each Companion Repository's collected state for the life of the server
20
+ * process. Collection costs one OpenSpec subprocess per command, so re-running
21
+ * it for a view switch would charge the reader a second of process startup to
22
+ * see the same state twice — and the page already promises that its data moves
23
+ * only on load or on an explicit refresh. A refresh collects again and replaces
24
+ * the snapshot; nothing else does.
25
+ *
26
+ * Concurrent reads of one companion share a single collection: a second request
27
+ * arriving mid-collection awaits the first rather than spawning its own.
28
+ */
29
+ export function createStudioSnapshotCache(deps: StudioSnapshotCacheDeps = {}): StudioSnapshotCache {
30
+ const assemble = deps.assembleCompanionPayload ?? assembleCompanionPayload;
31
+ const now = deps.now ?? Date.now;
32
+ const snapshots = new Map<string, StudioSnapshot>();
33
+ const inFlight = new Map<string, Promise<StudioSnapshot>>();
34
+
35
+ const collect = (companionPath: string): Promise<StudioSnapshot> => {
36
+ const startedAt = now();
37
+ const pending = assemble(companionPath).then((response) => {
38
+ const snapshot: StudioSnapshot = { response, collectedAt: startedAt };
39
+ /** Two overlapping collections must not leave the older one's state behind. */
40
+ const held = snapshots.get(companionPath);
41
+ if (!held || held.collectedAt <= startedAt) snapshots.set(companionPath, snapshot);
42
+ return snapshot;
43
+ });
44
+
45
+ /** A failed collection leaves no snapshot, so the next read collects again. */
46
+ inFlight.set(companionPath, pending);
47
+ return pending.finally(() => {
48
+ if (inFlight.get(companionPath) === pending) inFlight.delete(companionPath);
49
+ });
50
+ };
51
+
52
+ return {
53
+ read(companionPath: string, refresh: boolean): Promise<StudioSnapshot> {
54
+ if (!refresh) {
55
+ const cached = snapshots.get(companionPath);
56
+ if (cached) return Promise.resolve(cached);
57
+ const pending = inFlight.get(companionPath);
58
+ if (pending) return pending;
59
+ }
60
+ return collect(companionPath);
61
+ },
62
+ };
63
+ }