@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.
- package/claude-plugin/.claude-plugin/plugin.json +2 -2
- package/claude-plugin/hooks/hooks.json +3 -6
- package/claude-plugin/hooks/session-guidance.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +19 -0
- package/package.json +6 -4
- package/src/cli/commands/artifact/artifact.ts +19 -3
- package/src/cli/commands/artifact/finish/command.ts +183 -57
- package/src/cli/commands/artifact/finish/engine.ts +80 -91
- package/src/cli/commands/artifact/finish/finisher.ts +26 -33
- package/src/cli/commands/artifact/finish/git.ts +34 -23
- package/src/cli/commands/artifact/finish/index.ts +9 -3
- package/src/cli/commands/artifact/finish/openspec.ts +225 -97
- package/src/cli/commands/artifact/pending/command.ts +175 -0
- package/src/cli/commands/artifact/pending/discovery.ts +249 -0
- package/src/cli/commands/artifact/pending/index.ts +17 -0
- package/src/cli/commands/cap/index-cmd.ts +9 -1
- package/src/cli/commands/cap/index.ts +2 -6
- package/src/cli/commands/companion/companion.ts +5 -1
- package/src/cli/commands/companion/link.ts +2 -2
- package/src/cli/commands/companion/sync.ts +92 -0
- package/src/cli/commands/doctor.ts +0 -3
- package/src/cli/commands/launch/shared.ts +23 -5
- package/src/cli/commands/report/collector.ts +72 -78
- package/src/cli/commands/report/contract.ts +40 -1
- package/src/cli/commands/report/highlight.ts +27 -0
- package/src/cli/commands/report/index.ts +11 -18
- package/src/cli/commands/report/renderer.ts +199 -2
- package/src/cli/commands/report/types.ts +26 -1
- package/src/cli/commands/shared/companion-selection.ts +107 -10
- package/src/cli/commands/studio/areas.ts +68 -0
- package/src/cli/commands/studio/index.ts +69 -0
- package/src/cli/commands/studio/inventory.ts +55 -0
- package/src/cli/commands/studio/mate-inventory.ts +43 -0
- package/src/cli/commands/studio/openspec-cli.ts +198 -0
- package/src/cli/commands/studio/payload.ts +184 -0
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +61 -0
- package/src/cli/commands/studio/server.ts +201 -0
- package/src/cli/commands/studio/snapshot.ts +63 -0
- package/src/cli/commands/studio/topology.ts +199 -0
- package/src/cli/commands/studio/views/client.ts +197 -0
- package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
- package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
- package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
- package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
- package/src/cli/commands/studio/views/document.tsx +256 -0
- package/src/cli/commands/studio/views/error.tsx +20 -0
- package/src/cli/commands/studio/views/model.ts +34 -0
- package/src/cli/commands/studio/views/pairings.tsx +38 -0
- package/src/cli/commands/studio/views/skills/index.tsx +87 -0
- package/src/cli/commands/studio/views/specs/index.tsx +101 -0
- package/src/cli/commands/studio/views/styles.ts +389 -0
- package/src/cli/commands/studio/views/warnings.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
- package/src/cli/commands/unwrap.ts +70 -0
- package/src/cli/commands/wrap.ts +164 -0
- package/src/cli/main.ts +68 -19
- package/src/cli/parse-flags.ts +36 -11
- package/src/cli/usage.ts +12 -3
- package/src/framework.ts +1 -7
- package/src/hooks/session-banner.ts +64 -11
- package/src/hooks/session-guidance.ts +40 -0
- package/src/hooks/validate-artifact-path.ts +108 -35
- package/src/lib/fs-utils.ts +9 -0
- package/src/lib/install.ts +33 -0
- package/src/lib/orchestrator/adapters/base.ts +14 -125
- package/src/lib/orchestrator/adapters/claude.ts +0 -11
- package/src/lib/orchestrator/adapters/opencode.ts +2 -32
- package/src/lib/orchestrator/companion-git-sync.ts +94 -84
- package/src/lib/orchestrator/config-store.ts +2 -21
- package/src/lib/orchestrator/editor.ts +12 -22
- package/src/lib/orchestrator/framework-context.ts +17 -6
- package/src/lib/orchestrator/global-config-store.ts +1 -1
- package/src/lib/orchestrator/launcher.ts +97 -7
- package/src/lib/orchestrator/opencode-guidance.ts +4 -56
- package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
- package/src/lib/orchestrator/projection-companion-link.ts +62 -0
- package/src/lib/orchestrator/projection-entries.ts +377 -0
- package/src/lib/orchestrator/projection-record.ts +56 -0
- package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
- package/src/lib/orchestrator/projection-types.ts +169 -0
- package/src/lib/orchestrator/repo-local-registry.ts +37 -133
- package/src/lib/orchestrator/repo-local-store.ts +96 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/lib/orchestrator/working-repo-projection.ts +366 -0
- package/src/lib/orchestrator/workspace-inventory.ts +1 -1
- package/src/lib/package-paths.ts +11 -1
- package/src/opencode/companion-hooks.ts +89 -245
- package/src/opencode/companion-policy.ts +35 -10
- package/src/opencode/index.ts +1 -0
- package/src/opencode/projected-guidance.ts +56 -0
- package/src/opencode/tui.tsx +13 -4
- package/src/playbooks/companion-guidance.ts +32 -116
- package/src/plugins.ts +0 -1
- package/src/runtime/companion-git-state.ts +156 -0
- package/src/runtime/companion-git.ts +203 -0
- package/src/runtime/companion-guidance.ts +222 -0
- package/src/runtime/companion-sync.ts +298 -0
- package/src/runtime/env-names.ts +30 -0
- package/src/runtime/env.ts +67 -35
- package/src/runtime/framework.ts +10 -0
- package/src/runtime/freshness.ts +58 -0
- package/src/runtime/index.ts +104 -0
- package/src/runtime/install.ts +30 -0
- package/src/runtime/policy.ts +66 -0
- package/src/runtime/projected-guidance.ts +45 -0
- package/src/runtime/projection.ts +224 -0
- package/src/runtime/repo-local.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
- package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +147 -0
- package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +147 -0
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/report-assets/README.md +32 -0
- package/src/templates/report-assets/mermaid.LICENSE +21 -0
- package/src/templates/report-assets/mermaid.min.js +4376 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
- package/src/tools/setup/capabilities/graphify.ts +16 -7
- package/src/tools/setup/capabilities/openspec.ts +63 -56
- package/src/tools/setup/capabilities/tokensave.ts +47 -0
- package/src/tools/setup/engine.ts +34 -6
- package/src/tools/setup/mate.ts +42 -13
- package/src/tools/setup/plugin.ts +9 -0
- package/src/tools/setup/plugins/guidance.ts +11 -1
- package/src/tools/setup/providers/claude-format.ts +49 -4
- package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
- package/src/tools/setup/providers/claude.ts +55 -220
- package/src/tools/setup/providers/opencode.ts +41 -14
- package/src/tools/setup/runtime-documents.ts +174 -0
- package/src/tools/setup/surface-target.ts +50 -0
- package/src/tools/setup/working-repo-cleanup.ts +33 -26
- package/src/tools/setup/working-repo-local-state.ts +21 -1
- package/src/tools/setup.ts +25 -3
- package/wrappers/bin/graphify +57 -8
- package/wrappers/bin/openspec +50 -3
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
- package/src/cli/commands/cap/headroom.ts +0 -52
- package/src/cli/commands/workspace/list.ts +0 -25
- package/src/cli/commands/workspace/materialize.ts +0 -46
- package/src/cli/commands/workspace/workspace.ts +0 -22
- package/src/hooks/artifact-finish-nudge.ts +0 -244
- package/src/lib/orchestrator/headroom/proxy.ts +0 -116
- package/src/lib/orchestrator/workspace-materialize.ts +0 -80
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
- package/src/tools/setup/capabilities/headroom.ts +0 -57
- /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,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
|
+
}
|