@coopcli/specplan 5.2.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/README.md +40 -0
- package/dist/cli/index.js +2650 -0
- package/dist/client/assets/index-DevSOQuK.css +1 -0
- package/dist/client/assets/index-GtBmBt6t.js +78 -0
- package/dist/client/index.html +13 -0
- package/package.json +67 -0
|
@@ -0,0 +1,2650 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// server/cli.ts
|
|
4
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "node:fs";
|
|
5
|
+
import { homedir as homedir2 } from "node:os";
|
|
6
|
+
import { dirname as dirname4, extname, join as join6, normalize as normalize2, resolve as resolve5 } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
|
+
import { serve } from "@hono/node-server";
|
|
10
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
11
|
+
|
|
12
|
+
// src/lib/schema.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var GENERATION_MODELS = [
|
|
15
|
+
"claude-opus-4-8",
|
|
16
|
+
"claude-sonnet-5",
|
|
17
|
+
"claude-fable-5"
|
|
18
|
+
];
|
|
19
|
+
var DEFAULT_MODEL = "claude-opus-4-8";
|
|
20
|
+
var GenerationModel = z.enum(GENERATION_MODELS);
|
|
21
|
+
var NodeType = z.enum(["spec", "user_story"]);
|
|
22
|
+
var SpecNode = z.object({
|
|
23
|
+
/** Server-minted `spec-<ulid>` (lowercase). */
|
|
24
|
+
id: z.string(),
|
|
25
|
+
type: z.literal("spec"),
|
|
26
|
+
title: z.string(),
|
|
27
|
+
/**
|
|
28
|
+
* Author's description/prompt for the spec — steering context for
|
|
29
|
+
* generation. May contain `@` file references, which expand at
|
|
30
|
+
* generation time like any other input.
|
|
31
|
+
*/
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
specFile: z.string(),
|
|
34
|
+
/** Sibling ordering hint — display/tie-break only, never affects edges. */
|
|
35
|
+
order: z.number()
|
|
36
|
+
});
|
|
37
|
+
var StoryNode = z.object({
|
|
38
|
+
/** Server-minted `us-<ulid>` (lowercase). */
|
|
39
|
+
id: z.string(),
|
|
40
|
+
type: z.literal("user_story"),
|
|
41
|
+
text: z.string(),
|
|
42
|
+
order: z.number()
|
|
43
|
+
});
|
|
44
|
+
var PlanNode = z.discriminatedUnion("type", [SpecNode, StoryNode]);
|
|
45
|
+
var EdgeType = z.enum(["dependency", "containment"]);
|
|
46
|
+
var PlanEdge = z.object({
|
|
47
|
+
from: z.string(),
|
|
48
|
+
to: z.string(),
|
|
49
|
+
type: EdgeType
|
|
50
|
+
});
|
|
51
|
+
var Plan = z.object({
|
|
52
|
+
version: z.number(),
|
|
53
|
+
nodes: z.array(PlanNode),
|
|
54
|
+
edges: z.array(PlanEdge)
|
|
55
|
+
});
|
|
56
|
+
var SPEC_STATUSES = ["draft", "active", "done", "deferred"];
|
|
57
|
+
var SpecStatus = z.enum(SPEC_STATUSES);
|
|
58
|
+
var SpecMeta = z.object({
|
|
59
|
+
specId: z.string(),
|
|
60
|
+
userStories: z.array(z.string()),
|
|
61
|
+
dependencies: z.array(z.string()),
|
|
62
|
+
/** Lifecycle status; absent = draft. */
|
|
63
|
+
status: SpecStatus.optional(),
|
|
64
|
+
generation: z.object({
|
|
65
|
+
/** sha256 of the last generated body files — detects hand edits. */
|
|
66
|
+
lastGeneratedBodyHash: z.string(),
|
|
67
|
+
lastGeneratedAt: z.string()
|
|
68
|
+
}).optional()
|
|
69
|
+
});
|
|
70
|
+
var ChatRole = z.enum(["user", "assistant", "summary"]);
|
|
71
|
+
var ChatMessage = z.object({
|
|
72
|
+
role: ChatRole,
|
|
73
|
+
text: z.string(),
|
|
74
|
+
/** ISO-8601 creation time. */
|
|
75
|
+
at: z.string()
|
|
76
|
+
});
|
|
77
|
+
var ExecutedToolCall = z.object({
|
|
78
|
+
tool: z.string(),
|
|
79
|
+
/** Human-readable outcome, e.g. `created spec-… "Auth floor"`. */
|
|
80
|
+
summary: z.string()
|
|
81
|
+
});
|
|
82
|
+
var ChatRequest = z.object({
|
|
83
|
+
/** The user's new message (may contain `@` references). */
|
|
84
|
+
message: z.string().min(1),
|
|
85
|
+
/** Conversation history preceding `message`, oldest first. */
|
|
86
|
+
conversation: z.array(ChatMessage).default([]),
|
|
87
|
+
model: GenerationModel.optional()
|
|
88
|
+
});
|
|
89
|
+
var UnresolvedRefInfo = z.object({ ref: z.string(), reason: z.string() });
|
|
90
|
+
var ChatReply = z.object({
|
|
91
|
+
reply: ChatMessage,
|
|
92
|
+
/** Tool calls the dispatch loop executed against the plan, in order. */
|
|
93
|
+
toolCalls: z.array(ExecutedToolCall),
|
|
94
|
+
/** The plan after any mutations (so the canvas refreshes in one hop). */
|
|
95
|
+
plan: Plan,
|
|
96
|
+
/** Workspace-relative paths whose contents joined the answer's context. */
|
|
97
|
+
resolvedRefs: z.array(z.string()),
|
|
98
|
+
/** References that soft-failed, with the reason shown to the user. */
|
|
99
|
+
unresolvedRefs: z.array(UnresolvedRefInfo)
|
|
100
|
+
});
|
|
101
|
+
var CapabilitySpec = z.object({
|
|
102
|
+
capability: z.string(),
|
|
103
|
+
spec: z.string()
|
|
104
|
+
});
|
|
105
|
+
var SpecBundle = z.object({
|
|
106
|
+
/** kebab-case change id — always overwritten with the card's specId. */
|
|
107
|
+
changeName: z.string(),
|
|
108
|
+
/** One-paragraph plain-language summary. */
|
|
109
|
+
summary: z.string(),
|
|
110
|
+
/** proposal.md — why + what is changing. */
|
|
111
|
+
proposal: z.string(),
|
|
112
|
+
/** design.md — technical approach, components, data flow, decisions. */
|
|
113
|
+
design: z.string(),
|
|
114
|
+
/** tasks.md — implementation checklist. */
|
|
115
|
+
tasks: z.string(),
|
|
116
|
+
/** One entry per capability the change introduces. */
|
|
117
|
+
specs: z.array(CapabilitySpec)
|
|
118
|
+
});
|
|
119
|
+
var GenerateRequest = z.object({
|
|
120
|
+
specId: z.string().min(1),
|
|
121
|
+
model: GenerationModel.optional()
|
|
122
|
+
});
|
|
123
|
+
var GenerateResponse = z.object({
|
|
124
|
+
specId: z.string(),
|
|
125
|
+
/**
|
|
126
|
+
* `generated` — bodies (re)written and validated; `metadata-only` — the
|
|
127
|
+
* body was hand-edited, so only the per-spec metadata file was updated.
|
|
128
|
+
*/
|
|
129
|
+
mode: z.enum(["generated", "metadata-only"]),
|
|
130
|
+
/** Change-dir-relative paths written by this generation. */
|
|
131
|
+
changedFiles: z.array(z.string())
|
|
132
|
+
});
|
|
133
|
+
var NodePosition = z.object({ x: z.number(), y: z.number() });
|
|
134
|
+
var SpecState = z.object({
|
|
135
|
+
/** Body files (proposal/design/tasks/specs) exist in the change dir. */
|
|
136
|
+
hasSpec: z.boolean(),
|
|
137
|
+
/**
|
|
138
|
+
* The body diverges from the recorded lastGeneratedBodyHash (or was never
|
|
139
|
+
* generated by specplan) — regeneration will be metadata-only.
|
|
140
|
+
*/
|
|
141
|
+
handEdited: z.boolean(),
|
|
142
|
+
/** Lifecycle status from the per-spec metadata file (absent = draft). */
|
|
143
|
+
status: SpecStatus
|
|
144
|
+
});
|
|
145
|
+
var SetStatusRequest = z.object({
|
|
146
|
+
specId: z.string(),
|
|
147
|
+
status: SpecStatus
|
|
148
|
+
});
|
|
149
|
+
var SessionSnapshot = z.object({
|
|
150
|
+
/** Display name of the plan root (the openspec directory). */
|
|
151
|
+
rootName: z.string(),
|
|
152
|
+
model: GenerationModel.optional(),
|
|
153
|
+
plan: Plan,
|
|
154
|
+
/** Canvas layout per node id (UI sidecar state, not plan data). */
|
|
155
|
+
positions: z.record(z.string(), NodePosition),
|
|
156
|
+
/** Per-spec-node on-disk change state, keyed by spec id. */
|
|
157
|
+
specStates: z.record(z.string(), SpecState),
|
|
158
|
+
/** Persisted assistant chat, oldest first. */
|
|
159
|
+
chat: z.array(ChatMessage).optional()
|
|
160
|
+
});
|
|
161
|
+
var SpecArtifacts = z.object({
|
|
162
|
+
specId: z.string(),
|
|
163
|
+
handEdited: z.boolean(),
|
|
164
|
+
/** Change-dir-relative body files in canonical order. */
|
|
165
|
+
files: z.array(z.object({ path: z.string(), content: z.string() }))
|
|
166
|
+
});
|
|
167
|
+
var SessionSave = z.object({
|
|
168
|
+
model: GenerationModel.optional(),
|
|
169
|
+
positions: z.record(z.string(), NodePosition).optional()
|
|
170
|
+
});
|
|
171
|
+
var CreateSpecRequest = z.object({
|
|
172
|
+
title: z.string().min(1),
|
|
173
|
+
/** Optional description/prompt used as generation steering context. */
|
|
174
|
+
description: z.string().optional()
|
|
175
|
+
});
|
|
176
|
+
var CreateStoryRequest = z.object({ text: z.string().min(1) });
|
|
177
|
+
var LinkRequest = z.object({ from: z.string(), to: z.string() });
|
|
178
|
+
var ReorderRequest = z.object({ nodeId: z.string(), order: z.number() });
|
|
179
|
+
var PromptSave = z.object({
|
|
180
|
+
system: z.string().min(1).nullable()
|
|
181
|
+
});
|
|
182
|
+
var PromptInfo = z.object({
|
|
183
|
+
/** The prompt the next generation will use (custom override or default). */
|
|
184
|
+
system: z.string(),
|
|
185
|
+
/** The built-in default, for the editor's Reset action. */
|
|
186
|
+
default: z.string(),
|
|
187
|
+
/** True when a custom override is active. */
|
|
188
|
+
custom: z.boolean()
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// server/app.ts
|
|
192
|
+
import { basename as basename3 } from "node:path";
|
|
193
|
+
import { Hono } from "hono";
|
|
194
|
+
|
|
195
|
+
// src/lib/dag.ts
|
|
196
|
+
function storyError(text) {
|
|
197
|
+
const lower = text.toLowerCase();
|
|
198
|
+
const find = (marker, from) => lower.indexOf(marker.toLowerCase(), from);
|
|
199
|
+
const asA = find("As a", 0);
|
|
200
|
+
if (asA === -1) return 'user story is missing the "As a" marker';
|
|
201
|
+
const iWantTo = find("I want to", asA + "As a".length);
|
|
202
|
+
if (iWantTo === -1) return 'user story is missing the "I want to" marker (after "As a")';
|
|
203
|
+
if (!text.slice(asA + "As a".length, iWantTo).trim()) {
|
|
204
|
+
return 'user story has no <user> text between "As a" and "I want to"';
|
|
205
|
+
}
|
|
206
|
+
const soThat = find("so that", iWantTo + "I want to".length);
|
|
207
|
+
if (soThat === -1) return 'user story is missing the "so that" marker (after "I want to")';
|
|
208
|
+
if (!text.slice(iWantTo + "I want to".length, soThat).trim()) {
|
|
209
|
+
return 'user story has no <feature> text between "I want to" and "so that"';
|
|
210
|
+
}
|
|
211
|
+
if (!text.slice(soThat + "so that".length).trim()) {
|
|
212
|
+
return 'user story has no <value proposition> text after "so that"';
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
var DagError = class extends Error {
|
|
217
|
+
};
|
|
218
|
+
function label(node) {
|
|
219
|
+
const name = node.type === "spec" ? node.title : node.text;
|
|
220
|
+
return `${node.id} ("${name.length > 40 ? `${name.slice(0, 40)}\u2026` : name}")`;
|
|
221
|
+
}
|
|
222
|
+
function dependencyAdjacency(edges) {
|
|
223
|
+
const adj = /* @__PURE__ */ new Map();
|
|
224
|
+
for (const e of edges) {
|
|
225
|
+
if (e.type !== "dependency") continue;
|
|
226
|
+
const next = adj.get(e.from) ?? [];
|
|
227
|
+
next.push(e.to);
|
|
228
|
+
adj.set(e.from, next);
|
|
229
|
+
}
|
|
230
|
+
return adj;
|
|
231
|
+
}
|
|
232
|
+
function dependencyPath(edges, start, goal) {
|
|
233
|
+
const adj = dependencyAdjacency(edges);
|
|
234
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
235
|
+
const walk = (at, path) => {
|
|
236
|
+
if (at === goal) return path;
|
|
237
|
+
for (const next of adj.get(at) ?? []) {
|
|
238
|
+
if (seen.has(next)) continue;
|
|
239
|
+
seen.add(next);
|
|
240
|
+
const found = walk(next, [...path, next]);
|
|
241
|
+
if (found) return found;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
};
|
|
245
|
+
return walk(start, [start]);
|
|
246
|
+
}
|
|
247
|
+
function deriveEdge(plan, from, to) {
|
|
248
|
+
const nodes = new Map(plan.nodes.map((n) => [n.id, n]));
|
|
249
|
+
const fromNode = nodes.get(from);
|
|
250
|
+
const toNode = nodes.get(to);
|
|
251
|
+
if (!fromNode) throw new DagError(`unknown node: ${from}`);
|
|
252
|
+
if (!toNode) throw new DagError(`unknown node: ${to}`);
|
|
253
|
+
if (fromNode.type === "user_story") {
|
|
254
|
+
throw new DagError(
|
|
255
|
+
`user stories carry no edges of their own \u2014 ${label(fromNode)} cannot be a link source; link from a spec instead`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (from === to) throw new DagError(`cannot link ${label(fromNode)} to itself`);
|
|
259
|
+
if (plan.edges.some((e) => e.from === from && e.to === to)) {
|
|
260
|
+
throw new DagError(`link already exists: ${from} -> ${to}`);
|
|
261
|
+
}
|
|
262
|
+
if (toNode.type === "user_story") return { from, to, type: "containment" };
|
|
263
|
+
const path = dependencyPath(plan.edges, to, from);
|
|
264
|
+
if (path) {
|
|
265
|
+
const cycle = [from, ...path].map((id) => nodes.get(id)).filter((n) => n !== void 0);
|
|
266
|
+
throw new DagError(`dependency cycle: ${cycle.map((n) => label(n)).join(" -> ")}`);
|
|
267
|
+
}
|
|
268
|
+
return { from, to, type: "dependency" };
|
|
269
|
+
}
|
|
270
|
+
function containedStories(plan, specId) {
|
|
271
|
+
return plan.edges.filter((e) => e.type === "containment" && e.from === specId).map((e) => e.to);
|
|
272
|
+
}
|
|
273
|
+
function specDependencies(plan, specId) {
|
|
274
|
+
return plan.edges.filter((e) => e.type === "dependency" && e.from === specId).map((e) => e.to);
|
|
275
|
+
}
|
|
276
|
+
function backlog(plan) {
|
|
277
|
+
const linked = new Set(plan.edges.flatMap((e) => [e.from, e.to]));
|
|
278
|
+
const free = plan.nodes.filter((n) => !linked.has(n.id));
|
|
279
|
+
return {
|
|
280
|
+
specs: sortSiblings(free.filter((n) => n.type === "spec")),
|
|
281
|
+
stories: sortSiblings(free.filter((n) => n.type === "user_story"))
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function sortSiblings(nodes) {
|
|
285
|
+
return [...nodes].sort((a, b) => a.order - b.order);
|
|
286
|
+
}
|
|
287
|
+
function planErrors(plan) {
|
|
288
|
+
const errors = [];
|
|
289
|
+
const byId = /* @__PURE__ */ new Map();
|
|
290
|
+
for (const n of plan.nodes) {
|
|
291
|
+
if (byId.has(n.id)) errors.push(`duplicate node id: ${n.id}`);
|
|
292
|
+
byId.set(n.id, n);
|
|
293
|
+
if (n.type === "user_story") {
|
|
294
|
+
const err = storyError(n.text);
|
|
295
|
+
if (err) errors.push(`${n.id}: ${err}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const seenEdges = /* @__PURE__ */ new Set();
|
|
299
|
+
for (const e of plan.edges) {
|
|
300
|
+
const fromNode = byId.get(e.from);
|
|
301
|
+
const toNode = byId.get(e.to);
|
|
302
|
+
if (!fromNode) errors.push(`edge references unknown node: ${e.from} (${e.from} -> ${e.to})`);
|
|
303
|
+
if (!toNode) errors.push(`edge references unknown node: ${e.to} (${e.from} -> ${e.to})`);
|
|
304
|
+
if (!fromNode || !toNode) continue;
|
|
305
|
+
if (fromNode.type === "user_story") {
|
|
306
|
+
errors.push(`user story ${e.from} carries an edge of its own (${e.from} -> ${e.to})`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const expected = toNode.type === "user_story" ? "containment" : "dependency";
|
|
310
|
+
if (e.type !== expected) {
|
|
311
|
+
errors.push(`edge ${e.from} -> ${e.to} must be type "${expected}", found "${e.type}"`);
|
|
312
|
+
}
|
|
313
|
+
const key = `${e.from}->${e.to}`;
|
|
314
|
+
if (seenEdges.has(key)) errors.push(`duplicate edge: ${e.from} -> ${e.to}`);
|
|
315
|
+
seenEdges.add(key);
|
|
316
|
+
}
|
|
317
|
+
const depEdges = plan.edges.filter(
|
|
318
|
+
(e) => e.type === "dependency" && byId.has(e.from) && byId.has(e.to)
|
|
319
|
+
);
|
|
320
|
+
for (const e of depEdges) {
|
|
321
|
+
const path = dependencyPath(depEdges, e.to, e.from);
|
|
322
|
+
if (path) {
|
|
323
|
+
errors.push(`dependency cycle: ${[e.from, ...path].join(" -> ")}`);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return errors;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// server/generate.ts
|
|
331
|
+
import { execFile } from "node:child_process";
|
|
332
|
+
import { createHash } from "node:crypto";
|
|
333
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
|
|
334
|
+
import { createRequire } from "node:module";
|
|
335
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
336
|
+
import { promisify } from "node:util";
|
|
337
|
+
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
338
|
+
|
|
339
|
+
// server/atomic.ts
|
|
340
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
341
|
+
import { dirname } from "node:path";
|
|
342
|
+
function writeAtomicFile(target, content) {
|
|
343
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
344
|
+
const tmp = `${target}.tmp`;
|
|
345
|
+
writeFileSync(tmp, content, "utf8");
|
|
346
|
+
renameSync(tmp, target);
|
|
347
|
+
}
|
|
348
|
+
function readIfExists(target) {
|
|
349
|
+
return existsSync(target) ? readFileSync(target, "utf8") : void 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// server/model.ts
|
|
353
|
+
var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
|
|
354
|
+
var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
|
|
355
|
+
async function createWithModelGate(anthropic, request) {
|
|
356
|
+
const msg = request.model === "claude-fable-5" ? await anthropic.beta.messages.create({
|
|
357
|
+
...request,
|
|
358
|
+
betas: FABLE_BETAS,
|
|
359
|
+
fallbacks: FABLE_FALLBACKS
|
|
360
|
+
}) : await anthropic.messages.create(request);
|
|
361
|
+
return msg;
|
|
362
|
+
}
|
|
363
|
+
function parseWithModelGate(anthropic, request) {
|
|
364
|
+
return request.model === "claude-fable-5" ? anthropic.beta.messages.parse({
|
|
365
|
+
...request,
|
|
366
|
+
betas: FABLE_BETAS,
|
|
367
|
+
fallbacks: FABLE_FALLBACKS
|
|
368
|
+
}) : anthropic.messages.parse(request);
|
|
369
|
+
}
|
|
370
|
+
function textOf(msg) {
|
|
371
|
+
return msg.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
372
|
+
}
|
|
373
|
+
function toolUsesOf(msg) {
|
|
374
|
+
return msg.content.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id ?? "", name: b.name ?? "", input: b.input }));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// server/references.ts
|
|
378
|
+
import { readFileSync as readFileSync2, readdirSync, realpathSync, statSync } from "node:fs";
|
|
379
|
+
import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
380
|
+
var REF_FILE_BYTE_LIMIT = 64 * 1024;
|
|
381
|
+
var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
|
|
382
|
+
var REF_DIR_FILE_LIMIT = 50;
|
|
383
|
+
var SEARCH_IGNORE = /* @__PURE__ */ new Set([".git", "node_modules", "dist", ".wrangler", ".DS_Store"]);
|
|
384
|
+
function parseReferences(text) {
|
|
385
|
+
const tokens = [];
|
|
386
|
+
const pattern = /(^|[\s([{'"`])@([^\s@]+)/g;
|
|
387
|
+
for (const match of text.matchAll(pattern)) {
|
|
388
|
+
const token = (match[2] ?? "").replace(/[.,;:!?)\]}'"`>]+$/, "");
|
|
389
|
+
if (!token || token.startsWith("<")) continue;
|
|
390
|
+
if (!tokens.includes(token)) tokens.push(token);
|
|
391
|
+
}
|
|
392
|
+
return tokens;
|
|
393
|
+
}
|
|
394
|
+
function insideRoot(rootReal, target) {
|
|
395
|
+
return target.startsWith(rootReal + sep);
|
|
396
|
+
}
|
|
397
|
+
var WALK_ENTRY_LIMIT = 2e4;
|
|
398
|
+
function collectEntries(root) {
|
|
399
|
+
const files = [];
|
|
400
|
+
const dirs = [];
|
|
401
|
+
const walk = (dir) => {
|
|
402
|
+
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
403
|
+
let entries;
|
|
404
|
+
try {
|
|
405
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
406
|
+
} catch {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
411
|
+
if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
412
|
+
const full = join(dir, entry.name);
|
|
413
|
+
if (entry.isDirectory()) {
|
|
414
|
+
dirs.push(relative(root, full));
|
|
415
|
+
walk(full);
|
|
416
|
+
} else if (entry.isFile()) files.push(relative(root, full));
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
walk(root);
|
|
420
|
+
return { files, dirs };
|
|
421
|
+
}
|
|
422
|
+
var collectFiles = (root) => collectEntries(root).files;
|
|
423
|
+
function findByName(root, name) {
|
|
424
|
+
return collectFiles(root).filter((p) => basename(p) === name).sort((a, b) => {
|
|
425
|
+
const depth = a.split(sep).length - b.split(sep).length;
|
|
426
|
+
return depth !== 0 ? depth : a.localeCompare(b);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
var FILE_SEARCH_LIMIT = 20;
|
|
430
|
+
function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
|
|
431
|
+
if (root === void 0) return [];
|
|
432
|
+
let rootReal;
|
|
433
|
+
try {
|
|
434
|
+
rootReal = realpathSync(resolve(root));
|
|
435
|
+
} catch {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
const q = query.trim().toLowerCase();
|
|
439
|
+
const { files, dirs } = collectEntries(rootReal);
|
|
440
|
+
const all = [...dirs.map((d) => `${d}/`), ...files];
|
|
441
|
+
const matches = q ? all.filter((p) => p.toLowerCase().includes(q)) : all;
|
|
442
|
+
const rank = (p) => {
|
|
443
|
+
const lower = p.toLowerCase();
|
|
444
|
+
return [
|
|
445
|
+
q && basename(lower.replace(/\/$/, "")).includes(q) ? 0 : 1,
|
|
446
|
+
q ? lower.indexOf(q) : 0,
|
|
447
|
+
p.length
|
|
448
|
+
];
|
|
449
|
+
};
|
|
450
|
+
return matches.map((p) => ({ p, r: rank(p) })).sort(
|
|
451
|
+
(a, b) => a.r[0] - b.r[0] || a.r[1] - b.r[1] || a.r[2] - b.r[2] || a.p.localeCompare(b.p)
|
|
452
|
+
).slice(0, limit).map(({ p }) => p);
|
|
453
|
+
}
|
|
454
|
+
function readClamped(target) {
|
|
455
|
+
const raw = readFileSync2(target);
|
|
456
|
+
if (raw.byteLength <= REF_FILE_BYTE_LIMIT) {
|
|
457
|
+
return { content: raw.toString("utf8"), truncated: false };
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
content: `${raw.subarray(0, REF_FILE_BYTE_LIMIT).toString("utf8")}
|
|
461
|
+
\u2026 [truncated at ${REF_FILE_BYTE_LIMIT} bytes]`,
|
|
462
|
+
truncated: true
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function expandReferences(text, root) {
|
|
466
|
+
const resolved = [];
|
|
467
|
+
const unresolved = [];
|
|
468
|
+
const refs = parseReferences(text);
|
|
469
|
+
if (refs.length === 0) return { resolved, unresolved };
|
|
470
|
+
let rootReal = null;
|
|
471
|
+
if (root !== void 0) {
|
|
472
|
+
try {
|
|
473
|
+
rootReal = realpathSync(resolve(root));
|
|
474
|
+
} catch {
|
|
475
|
+
rootReal = null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
let totalBytes = 0;
|
|
479
|
+
for (const ref of refs) {
|
|
480
|
+
if (rootReal === null) {
|
|
481
|
+
unresolved.push({ ref, reason: "file references are unavailable here" });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (totalBytes >= REF_TOTAL_BYTE_LIMIT) {
|
|
485
|
+
unresolved.push({ ref, reason: "reference budget exceeded \u2014 too much file content already included" });
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const outcome = resolveOne(rootReal, ref);
|
|
489
|
+
if ("reason" in outcome) {
|
|
490
|
+
unresolved.push({ ref, ...outcome });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if ("dirFiles" in outcome) {
|
|
494
|
+
let included = 0;
|
|
495
|
+
for (const relPath of outcome.dirFiles) {
|
|
496
|
+
if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
|
|
497
|
+
let body;
|
|
498
|
+
try {
|
|
499
|
+
body = readClamped(join(rootReal, relPath));
|
|
500
|
+
} catch {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
totalBytes += Buffer.byteLength(body.content, "utf8");
|
|
504
|
+
resolved.push({ ref, path: relPath, ...body });
|
|
505
|
+
included++;
|
|
506
|
+
}
|
|
507
|
+
const omitted = outcome.dirFiles.length - included;
|
|
508
|
+
if (included === 0) {
|
|
509
|
+
unresolved.push({
|
|
510
|
+
ref,
|
|
511
|
+
reason: "reference budget exceeded \u2014 too much file content already included"
|
|
512
|
+
});
|
|
513
|
+
} else if (omitted > 0) {
|
|
514
|
+
unresolved.push({
|
|
515
|
+
ref,
|
|
516
|
+
kind: "partial",
|
|
517
|
+
reason: `directory partially included \u2014 ${omitted} of ${outcome.dirFiles.length} files omitted by the size budget`
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
totalBytes += Buffer.byteLength(outcome.content, "utf8");
|
|
523
|
+
resolved.push({ ref, ...outcome });
|
|
524
|
+
}
|
|
525
|
+
return { resolved, unresolved };
|
|
526
|
+
}
|
|
527
|
+
function resolveOne(rootReal, ref) {
|
|
528
|
+
if (isAbsolute(ref)) return { reason: "absolute paths are not allowed" };
|
|
529
|
+
const rel = normalize(ref);
|
|
530
|
+
if (rel.split(sep).includes("..")) return { reason: "path escapes the working directory" };
|
|
531
|
+
const direct = resolve(rootReal, rel);
|
|
532
|
+
if (direct !== rootReal && !insideRoot(rootReal, direct)) {
|
|
533
|
+
return { reason: "path escapes the working directory" };
|
|
534
|
+
}
|
|
535
|
+
const stats = statOrNull(direct);
|
|
536
|
+
if (stats?.isDirectory()) {
|
|
537
|
+
let real;
|
|
538
|
+
try {
|
|
539
|
+
real = realpathSync(direct);
|
|
540
|
+
} catch {
|
|
541
|
+
return { reason: "not readable" };
|
|
542
|
+
}
|
|
543
|
+
if (real !== rootReal && !insideRoot(rootReal, real)) {
|
|
544
|
+
return { reason: "resolves outside the working directory" };
|
|
545
|
+
}
|
|
546
|
+
const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
|
|
547
|
+
const depth = a.split(sep).length - b.split(sep).length;
|
|
548
|
+
return depth !== 0 ? depth : a.localeCompare(b);
|
|
549
|
+
});
|
|
550
|
+
if (dirFiles.length === 0) return { reason: "directory contains no files" };
|
|
551
|
+
return { dirFiles };
|
|
552
|
+
}
|
|
553
|
+
if (stats?.isFile()) {
|
|
554
|
+
let real;
|
|
555
|
+
try {
|
|
556
|
+
real = realpathSync(direct);
|
|
557
|
+
} catch {
|
|
558
|
+
return { reason: "not readable" };
|
|
559
|
+
}
|
|
560
|
+
if (!insideRoot(rootReal, real)) return { reason: "resolves outside the working directory" };
|
|
561
|
+
try {
|
|
562
|
+
return { path: relative(rootReal, direct), ...readClamped(direct) };
|
|
563
|
+
} catch {
|
|
564
|
+
return { reason: "not readable" };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (rel.includes(sep)) return { reason: "not found" };
|
|
568
|
+
const matches = findByName(rootReal, rel);
|
|
569
|
+
if (matches.length === 0) return { reason: "not found" };
|
|
570
|
+
const best = matches[0];
|
|
571
|
+
try {
|
|
572
|
+
const body = readClamped(join(rootReal, best));
|
|
573
|
+
return {
|
|
574
|
+
path: best,
|
|
575
|
+
...body,
|
|
576
|
+
...matches.length > 1 ? {
|
|
577
|
+
note: `ambiguous \u2014 ${matches.length} files named "${rel}"; using ${best}, others: ${matches.slice(1, 5).join(", ")}${matches.length > 5 ? ", \u2026" : ""}`
|
|
578
|
+
} : {}
|
|
579
|
+
};
|
|
580
|
+
} catch {
|
|
581
|
+
return { reason: "not readable" };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function statOrNull(path) {
|
|
585
|
+
try {
|
|
586
|
+
return statSync(path);
|
|
587
|
+
} catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function renderReferenceContext(expansion) {
|
|
592
|
+
const parts = [];
|
|
593
|
+
for (const r of expansion.resolved) {
|
|
594
|
+
const note = r.note ? `(${r.note})
|
|
595
|
+
` : "";
|
|
596
|
+
parts.push(`=== @${r.path} ===
|
|
597
|
+
${note}${r.content}
|
|
598
|
+
=== end @${r.path} ===`);
|
|
599
|
+
}
|
|
600
|
+
for (const u of expansion.unresolved) {
|
|
601
|
+
parts.push(
|
|
602
|
+
u.kind === "partial" ? `@${u.ref}: ${u.reason}.` : `@${u.ref}: could not be resolved (${u.reason}) \u2014 do not invent its contents.`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return parts.join("\n\n");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// server/generate.ts
|
|
609
|
+
var SYSTEM = `You are a principal software architect and specification writer.
|
|
610
|
+
Given one planned spec card from a project roadmap \u2014 its title, the user stories it
|
|
611
|
+
contains, and the other specs it depends on \u2014 produce an OpenSpec change proposal for
|
|
612
|
+
exactly that spec.
|
|
613
|
+
|
|
614
|
+
Follow OpenSpec conventions:
|
|
615
|
+
- proposal: a "## Why" section (motivation) and a "## What Changes" section.
|
|
616
|
+
- design: components & responsibilities, data flow, key decisions/trade-offs, open questions.
|
|
617
|
+
- tasks: numbered "## <n>. <area>" sections of "- [ ] <n>.<m> \u2026" checklist items.
|
|
618
|
+
- Each capability the change introduces gets its own spec: a "## Purpose" paragraph, then
|
|
619
|
+
"## ADDED Requirements" containing "### Requirement: <name>" sections. Every requirement
|
|
620
|
+
states "The system SHALL ..." and has at least one "#### Scenario: <name>" block whose
|
|
621
|
+
body is "- WHEN ..." / "- THEN ..." bullet lines.
|
|
622
|
+
- EVERY user story contained by the spec MUST map to its own "#### Scenario:" block under
|
|
623
|
+
a requirement derived from it \u2014 no story may be dropped or merged away.
|
|
624
|
+
- changeName and capability ids are kebab-case.
|
|
625
|
+
- Dependencies on other specs are context: mention them in proposal/design where relevant,
|
|
626
|
+
but never restate their requirements.
|
|
627
|
+
|
|
628
|
+
Referenced files: the input may contain "@<path | filename>" references that were resolved
|
|
629
|
+
to real file contents, delimited as "=== @<path> === \u2026 === end @<path> ===" blocks. That
|
|
630
|
+
content is authoritative ground truth about the existing system \u2014 prefer it over inference.
|
|
631
|
+
A note like "@foo.ts: could not be resolved" marks a reference that failed to resolve;
|
|
632
|
+
acknowledge the gap where relevant, never invent the file's contents.
|
|
633
|
+
|
|
634
|
+
Ground every requirement in the stories and titles actually given; record ambiguity as an
|
|
635
|
+
assumption in design rather than inventing unrelated features. Prefer 1-3 focused
|
|
636
|
+
capabilities.`;
|
|
637
|
+
var GenerationFailed = class extends Error {
|
|
638
|
+
constructor(message, status) {
|
|
639
|
+
super(message);
|
|
640
|
+
this.status = status;
|
|
641
|
+
}
|
|
642
|
+
status;
|
|
643
|
+
};
|
|
644
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
645
|
+
function listBodyFiles(changeDir) {
|
|
646
|
+
const files = [];
|
|
647
|
+
for (const name of ["proposal.md", "design.md", "tasks.md"]) {
|
|
648
|
+
if (existsSync2(join2(changeDir, name))) files.push(name);
|
|
649
|
+
}
|
|
650
|
+
const specsDir = join2(changeDir, "specs");
|
|
651
|
+
if (existsSync2(specsDir)) {
|
|
652
|
+
for (const entry of readdirSync2(specsDir, { withFileTypes: true }).sort(
|
|
653
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
654
|
+
)) {
|
|
655
|
+
const rel = join2("specs", entry.name, "spec.md");
|
|
656
|
+
if (entry.isDirectory() && existsSync2(join2(changeDir, rel))) files.push(rel);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return files;
|
|
660
|
+
}
|
|
661
|
+
function bodyHash(changeDir) {
|
|
662
|
+
const files = listBodyFiles(changeDir);
|
|
663
|
+
if (files.length === 0) return null;
|
|
664
|
+
const parts = files.map((rel) => `${rel}
|
|
665
|
+
${sha256(readFileSync3(join2(changeDir, rel), "utf8"))}`);
|
|
666
|
+
return `sha256:${sha256(parts.join("\n"))}`;
|
|
667
|
+
}
|
|
668
|
+
function specState(store, specId) {
|
|
669
|
+
const changeDir = dirname2(store.specMetaPath(specId));
|
|
670
|
+
const currentHash = bodyHash(changeDir);
|
|
671
|
+
let meta = null;
|
|
672
|
+
try {
|
|
673
|
+
meta = store.readSpecMeta(specId);
|
|
674
|
+
} catch {
|
|
675
|
+
}
|
|
676
|
+
const status = meta?.status ?? "draft";
|
|
677
|
+
if (currentHash === null) return { hasSpec: false, handEdited: false, status };
|
|
678
|
+
return {
|
|
679
|
+
hasSpec: true,
|
|
680
|
+
handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
|
|
681
|
+
status
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function readBodyFiles(store, specId) {
|
|
685
|
+
const changeDir = dirname2(store.specMetaPath(specId));
|
|
686
|
+
return listBodyFiles(changeDir).map((rel) => ({ path: rel, content: readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0);
|
|
687
|
+
}
|
|
688
|
+
function resolveOpenspecBin() {
|
|
689
|
+
const require2 = createRequire(import.meta.url);
|
|
690
|
+
let dir = dirname2(require2.resolve("@fission-ai/openspec"));
|
|
691
|
+
for (; ; ) {
|
|
692
|
+
const pkgPath = join2(dir, "package.json");
|
|
693
|
+
if (existsSync2(pkgPath)) {
|
|
694
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
695
|
+
if (pkg.name === "@fission-ai/openspec" && pkg.bin) {
|
|
696
|
+
return join2(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const parent = dirname2(dir);
|
|
700
|
+
if (parent === dir) throw new Error("could not locate the @fission-ai/openspec bin");
|
|
701
|
+
dir = parent;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async function runOpenspecValidate(specId, rootDir) {
|
|
705
|
+
const bin = resolveOpenspecBin();
|
|
706
|
+
try {
|
|
707
|
+
const { stdout, stderr } = await promisify(execFile)(
|
|
708
|
+
process.execPath,
|
|
709
|
+
[bin, "validate", specId, "--strict"],
|
|
710
|
+
{ cwd: dirname2(resolve2(rootDir)) }
|
|
711
|
+
);
|
|
712
|
+
return { ok: true, output: `${stdout}${stderr}`.trim() };
|
|
713
|
+
} catch (err) {
|
|
714
|
+
const e = err;
|
|
715
|
+
return {
|
|
716
|
+
ok: false,
|
|
717
|
+
output: `${e.stdout ?? ""}${e.stderr ?? ""}`.trim() || (e.message ?? String(err))
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function renderSpecPrompt(spec, stories, deps) {
|
|
722
|
+
const storyLines = stories.length ? stories.map((s) => `- ${s.id}: ${s.text}`).join("\n") : "- (none \u2014 derive a minimal requirement set from the title and description alone)";
|
|
723
|
+
const depLines = deps.length ? deps.map((d) => `- ${d.id}: "${d.title}"`).join("\n") : "- (none)";
|
|
724
|
+
return `Planned spec card: "${spec.title}" (change id: ${spec.id})
|
|
725
|
+
|
|
726
|
+
` + (spec.description ? `Author's description/prompt (requirements and corrections that MUST steer this generation):
|
|
727
|
+
${spec.description}
|
|
728
|
+
|
|
729
|
+
` : "") + `Contained user stories (EVERY one must map to its own "#### Scenario:" block):
|
|
730
|
+
${storyLines}
|
|
731
|
+
|
|
732
|
+
Depends on specs (context only \u2014 do not restate their requirements):
|
|
733
|
+
${depLines}
|
|
734
|
+
|
|
735
|
+
Produce the OpenSpec change proposal for exactly this spec as structured output.
|
|
736
|
+
Use exactly "${spec.id}" as the changeName.`;
|
|
737
|
+
}
|
|
738
|
+
function renderBaseline(changeDir) {
|
|
739
|
+
const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
|
|
740
|
+
${f.content}`);
|
|
741
|
+
if (sections.length === 0) return "";
|
|
742
|
+
return `
|
|
743
|
+
|
|
744
|
+
The change directory already contains these artifacts (generated by a previous run). Treat them as the baseline: make the SMALLEST revision that brings them in line with the stories and dependencies above. Keep unaffected content verbatim; return the complete artifact set with those minimal edits applied.
|
|
745
|
+
|
|
746
|
+
${sections.join("\n\n")}`;
|
|
747
|
+
}
|
|
748
|
+
function writeBundle(changeDir, bundle) {
|
|
749
|
+
const files = {
|
|
750
|
+
"proposal.md": bundle.proposal,
|
|
751
|
+
"design.md": bundle.design,
|
|
752
|
+
"tasks.md": bundle.tasks
|
|
753
|
+
};
|
|
754
|
+
for (const s of bundle.specs) files[join2("specs", s.capability, "spec.md")] = s.spec;
|
|
755
|
+
const written = [];
|
|
756
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
757
|
+
if (readIfExists(join2(changeDir, rel)) === content) continue;
|
|
758
|
+
writeAtomicFile(join2(changeDir, rel), content);
|
|
759
|
+
written.push(rel);
|
|
760
|
+
}
|
|
761
|
+
return written;
|
|
762
|
+
}
|
|
763
|
+
async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
|
|
764
|
+
const { store } = deps;
|
|
765
|
+
const plan = store.load();
|
|
766
|
+
const node = plan.nodes.find((n) => n.id === specId);
|
|
767
|
+
if (!node || node.type !== "spec") {
|
|
768
|
+
throw new GenerationFailed(`unknown spec: ${specId}`, 400);
|
|
769
|
+
}
|
|
770
|
+
const stories = storyNodes(plan, specId);
|
|
771
|
+
const depSpecs = depNodes(plan, specId);
|
|
772
|
+
const changeDir = dirname2(store.specMetaPath(specId));
|
|
773
|
+
store.writeSpecMeta(
|
|
774
|
+
specId,
|
|
775
|
+
stories.map((s) => s.id),
|
|
776
|
+
depSpecs.map((d) => d.id)
|
|
777
|
+
);
|
|
778
|
+
const currentHash = bodyHash(changeDir);
|
|
779
|
+
if (currentHash !== null) {
|
|
780
|
+
const recorded = store.readSpecMeta(specId)?.generation?.lastGeneratedBodyHash;
|
|
781
|
+
if (recorded !== currentHash) {
|
|
782
|
+
return { specId, mode: "metadata-only", changedFiles: ["specplan.yaml"] };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const system = deps.systemPrompt ?? SYSTEM;
|
|
786
|
+
const refs = expandReferences(
|
|
787
|
+
[
|
|
788
|
+
...system === SYSTEM ? [] : [system],
|
|
789
|
+
node.title,
|
|
790
|
+
node.description ?? "",
|
|
791
|
+
...stories.map((s) => s.text)
|
|
792
|
+
].join("\n"),
|
|
793
|
+
deps.refRoot
|
|
794
|
+
);
|
|
795
|
+
const refContext = renderReferenceContext(refs);
|
|
796
|
+
const prompt = renderSpecPrompt(node, stories, depSpecs) + renderBaseline(changeDir) + (refContext ? `
|
|
797
|
+
|
|
798
|
+
Referenced files (resolved from @-references in the input):
|
|
799
|
+
|
|
800
|
+
${refContext}` : "");
|
|
801
|
+
const msg = await parseWithModelGate(deps.anthropic, {
|
|
802
|
+
model,
|
|
803
|
+
max_tokens: 16e3,
|
|
804
|
+
// non-streaming stays under the SDK HTTP timeout
|
|
805
|
+
system,
|
|
806
|
+
messages: [{ role: "user", content: prompt }],
|
|
807
|
+
output_config: { format: zodOutputFormat(SpecBundle) }
|
|
808
|
+
// No temperature/top_p or thinking config — both 400 on modern models.
|
|
809
|
+
});
|
|
810
|
+
if (msg.stop_reason === "refusal") {
|
|
811
|
+
throw new GenerationFailed(`the model refused this generation`, 422);
|
|
812
|
+
}
|
|
813
|
+
if (!msg.parsed_output) {
|
|
814
|
+
throw new GenerationFailed("model returned no structured output", 502);
|
|
815
|
+
}
|
|
816
|
+
const output = SpecBundle.parse(msg.parsed_output);
|
|
817
|
+
const bundle = { ...output, changeName: specId };
|
|
818
|
+
const changedFiles = writeBundle(changeDir, bundle);
|
|
819
|
+
store.writeGeneration(specId, bodyHash(changeDir) ?? "", (/* @__PURE__ */ new Date()).toISOString());
|
|
820
|
+
const verdict = await runOpenspecValidate(specId, store.dir);
|
|
821
|
+
if (!verdict.ok) {
|
|
822
|
+
throw new GenerationFailed(
|
|
823
|
+
`generated change failed \`openspec validate ${specId} --strict\`:
|
|
824
|
+
${verdict.output}`,
|
|
825
|
+
422
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return { specId, mode: "generated", changedFiles };
|
|
829
|
+
}
|
|
830
|
+
function storyNodes(plan, specId) {
|
|
831
|
+
const byId = new Map(plan.nodes.map((n) => [n.id, n]));
|
|
832
|
+
return containedStories(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "user_story");
|
|
833
|
+
}
|
|
834
|
+
function depNodes(plan, specId) {
|
|
835
|
+
const byId = new Map(plan.nodes.map((n) => [n.id, n]));
|
|
836
|
+
return specDependencies(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "spec");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// server/store.ts
|
|
840
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync3 } from "node:fs";
|
|
841
|
+
import { basename as basename2, join as join3, resolve as resolve3 } from "node:path";
|
|
842
|
+
import { Document, parseDocument } from "yaml";
|
|
843
|
+
|
|
844
|
+
// server/ulid.ts
|
|
845
|
+
var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
846
|
+
var RANDOM_BYTES = 10;
|
|
847
|
+
function encodeTime(ms) {
|
|
848
|
+
let str = "";
|
|
849
|
+
let t = ms;
|
|
850
|
+
for (let i = 9; i >= 0; i--) {
|
|
851
|
+
str = (ENCODING[t % 32] ?? "0") + str;
|
|
852
|
+
t = Math.floor(t / 32);
|
|
853
|
+
}
|
|
854
|
+
return str;
|
|
855
|
+
}
|
|
856
|
+
function encodeRandomBytes(bytes) {
|
|
857
|
+
let str = "";
|
|
858
|
+
let carry = 0;
|
|
859
|
+
let bits = 0;
|
|
860
|
+
for (const byte of bytes) {
|
|
861
|
+
carry = carry << 8 | byte;
|
|
862
|
+
bits += 8;
|
|
863
|
+
while (bits >= 5) {
|
|
864
|
+
bits -= 5;
|
|
865
|
+
str += ENCODING[carry >> bits & 31] ?? "0";
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (bits > 0) {
|
|
869
|
+
str += ENCODING[carry << 5 - bits & 31] ?? "0";
|
|
870
|
+
}
|
|
871
|
+
return str.slice(0, 16);
|
|
872
|
+
}
|
|
873
|
+
function incrementBytes(bytes) {
|
|
874
|
+
for (let i = bytes.length - 1; i >= 0; i--) {
|
|
875
|
+
if (bytes[i] < 255) {
|
|
876
|
+
bytes[i]++;
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
bytes[i] = 0;
|
|
880
|
+
}
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
var lastTime = 0;
|
|
884
|
+
var lastRandom = new Uint8Array(RANDOM_BYTES);
|
|
885
|
+
function generateUlid() {
|
|
886
|
+
const now = Date.now();
|
|
887
|
+
if (now > lastTime) {
|
|
888
|
+
lastTime = now;
|
|
889
|
+
lastRandom = crypto.getRandomValues(new Uint8Array(RANDOM_BYTES));
|
|
890
|
+
} else {
|
|
891
|
+
if (!incrementBytes(lastRandom)) {
|
|
892
|
+
lastTime += 1;
|
|
893
|
+
lastRandom = crypto.getRandomValues(new Uint8Array(RANDOM_BYTES));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return encodeTime(lastTime) + encodeRandomBytes(lastRandom);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// server/store.ts
|
|
900
|
+
var PlanFileError = class extends Error {
|
|
901
|
+
};
|
|
902
|
+
var ROOT_FILE = "specplan.yaml";
|
|
903
|
+
var META_FILE = "specplan.yaml";
|
|
904
|
+
var PlanStore = class {
|
|
905
|
+
/** Resolved openspec root directory. */
|
|
906
|
+
dir;
|
|
907
|
+
constructor(dir) {
|
|
908
|
+
this.dir = resolve3(dir);
|
|
909
|
+
mkdirSync2(this.dir, { recursive: true });
|
|
910
|
+
}
|
|
911
|
+
// ── Root file ────────────────────────────────────────────────────────────
|
|
912
|
+
rootPath() {
|
|
913
|
+
return join3(this.dir, ROOT_FILE);
|
|
914
|
+
}
|
|
915
|
+
loadDoc() {
|
|
916
|
+
const raw = readIfExists(this.rootPath());
|
|
917
|
+
if (raw === void 0) return new Document({ version: 1, nodes: [], edges: [] });
|
|
918
|
+
const doc = parseDocument(raw);
|
|
919
|
+
const fault = doc.errors[0];
|
|
920
|
+
if (fault) throw new PlanFileError(`${ROOT_FILE}: ${fault.message}`);
|
|
921
|
+
if (doc.getIn(["version"]) === void 0) doc.setIn(["version"], 1);
|
|
922
|
+
if (doc.getIn(["nodes"]) === void 0) doc.setIn(["nodes"], doc.createNode([]));
|
|
923
|
+
if (doc.getIn(["edges"]) === void 0) doc.setIn(["edges"], doc.createNode([]));
|
|
924
|
+
return doc;
|
|
925
|
+
}
|
|
926
|
+
planOf(doc) {
|
|
927
|
+
const parsed = Plan.safeParse(doc.toJS());
|
|
928
|
+
if (!parsed.success) {
|
|
929
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
930
|
+
throw new PlanFileError(`${ROOT_FILE}: ${issues}`);
|
|
931
|
+
}
|
|
932
|
+
return parsed.data;
|
|
933
|
+
}
|
|
934
|
+
/** The current plan, reloaded from disk. */
|
|
935
|
+
load() {
|
|
936
|
+
return this.planOf(this.loadDoc());
|
|
937
|
+
}
|
|
938
|
+
save(doc) {
|
|
939
|
+
writeAtomicFile(this.rootPath(), doc.toString());
|
|
940
|
+
}
|
|
941
|
+
// ── Ids ──────────────────────────────────────────────────────────────────
|
|
942
|
+
mintId(prefix, existing) {
|
|
943
|
+
for (; ; ) {
|
|
944
|
+
const id = `${prefix}-${generateUlid().toLowerCase()}`;
|
|
945
|
+
if (!existing.has(id)) return id;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
nextOrder(plan, type) {
|
|
949
|
+
const orders = plan.nodes.filter((n) => n.type === type).map((n) => n.order);
|
|
950
|
+
return orders.length === 0 ? 0 : Math.max(...orders) + 1;
|
|
951
|
+
}
|
|
952
|
+
// ── Mutations (canvas routes and chat tools share these) ─────────────────
|
|
953
|
+
createSpec(title, description) {
|
|
954
|
+
const trimmed = title.trim();
|
|
955
|
+
if (!trimmed) throw new DagError("spec title must not be empty");
|
|
956
|
+
const doc = this.loadDoc();
|
|
957
|
+
const plan = this.planOf(doc);
|
|
958
|
+
const id = this.mintId("spec", new Set(plan.nodes.map((n) => n.id)));
|
|
959
|
+
const node = {
|
|
960
|
+
id,
|
|
961
|
+
type: "spec",
|
|
962
|
+
title: trimmed,
|
|
963
|
+
...description?.trim() ? { description: description.trim() } : {},
|
|
964
|
+
specFile: `${basename2(this.dir)}/changes/${id}/${META_FILE}`,
|
|
965
|
+
order: this.nextOrder(plan, "spec")
|
|
966
|
+
};
|
|
967
|
+
doc.addIn(["nodes"], node);
|
|
968
|
+
this.save(doc);
|
|
969
|
+
this.initSpecMeta(id);
|
|
970
|
+
return node;
|
|
971
|
+
}
|
|
972
|
+
/** A fresh per-spec metadata file: empty relations, explicit draft status. */
|
|
973
|
+
initSpecMeta(specId) {
|
|
974
|
+
writeAtomicFile(
|
|
975
|
+
this.specMetaPath(specId),
|
|
976
|
+
new Document({
|
|
977
|
+
specId,
|
|
978
|
+
userStories: [],
|
|
979
|
+
dependencies: [],
|
|
980
|
+
status: "draft"
|
|
981
|
+
}).toString()
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
createStory(text) {
|
|
985
|
+
const trimmed = text.trim();
|
|
986
|
+
const fault = storyError(trimmed);
|
|
987
|
+
if (fault) throw new DagError(fault);
|
|
988
|
+
const doc = this.loadDoc();
|
|
989
|
+
const plan = this.planOf(doc);
|
|
990
|
+
const id = this.mintId("us", new Set(plan.nodes.map((n) => n.id)));
|
|
991
|
+
const node = {
|
|
992
|
+
id,
|
|
993
|
+
type: "user_story",
|
|
994
|
+
text: trimmed,
|
|
995
|
+
order: this.nextOrder(plan, "user_story")
|
|
996
|
+
};
|
|
997
|
+
doc.addIn(["nodes"], node);
|
|
998
|
+
this.save(doc);
|
|
999
|
+
return node;
|
|
1000
|
+
}
|
|
1001
|
+
/** Add an edge; the type is derived (spec→spec dependency, spec→story containment). */
|
|
1002
|
+
link(from, to) {
|
|
1003
|
+
const doc = this.loadDoc();
|
|
1004
|
+
const plan = this.planOf(doc);
|
|
1005
|
+
const edge = deriveEdge(plan, from, to);
|
|
1006
|
+
doc.addIn(["edges"], edge);
|
|
1007
|
+
this.save(doc);
|
|
1008
|
+
const next = { ...plan, edges: [...plan.edges, edge] };
|
|
1009
|
+
this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
|
|
1010
|
+
return edge;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Remove one edge (the canvas unlink gesture — a USER action; the chat
|
|
1014
|
+
* tool set deliberately has no destructive tool). Re-syncs the source
|
|
1015
|
+
* spec's metadata file so userStories/dependencies reflect the removal.
|
|
1016
|
+
*/
|
|
1017
|
+
unlink(from, to) {
|
|
1018
|
+
const doc = this.loadDoc();
|
|
1019
|
+
const plan = this.planOf(doc);
|
|
1020
|
+
const idx = plan.edges.findIndex((e) => e.from === from && e.to === to);
|
|
1021
|
+
const edge = plan.edges[idx];
|
|
1022
|
+
if (!edge) throw new DagError(`no link exists: ${from} -> ${to}`);
|
|
1023
|
+
doc.deleteIn(["edges", idx]);
|
|
1024
|
+
this.save(doc);
|
|
1025
|
+
const next = { ...plan, edges: plan.edges.filter((_, i) => i !== idx) };
|
|
1026
|
+
this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
|
|
1027
|
+
return edge;
|
|
1028
|
+
}
|
|
1029
|
+
/** Update one node's sibling `order` hint. Never touches edges. */
|
|
1030
|
+
reorder(nodeId, order) {
|
|
1031
|
+
const doc = this.loadDoc();
|
|
1032
|
+
const plan = this.planOf(doc);
|
|
1033
|
+
const idx = plan.nodes.findIndex((n) => n.id === nodeId);
|
|
1034
|
+
const node = plan.nodes[idx];
|
|
1035
|
+
if (!node) throw new DagError(`unknown node: ${nodeId}`);
|
|
1036
|
+
doc.setIn(["nodes", idx, "order"], order);
|
|
1037
|
+
this.save(doc);
|
|
1038
|
+
return { ...node, order };
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Adopt pre-existing OpenSpec change directories (layered-storage:
|
|
1042
|
+
* "Landing existing changes on the canvas"): every directory under
|
|
1043
|
+
* <root>/changes/ — except `archive` — that is not yet a node becomes a
|
|
1044
|
+
* spec node whose id IS the change-dir basename (the D5 rule), with its
|
|
1045
|
+
* per-spec metadata file initialized when missing. Idempotent; runs on
|
|
1046
|
+
* every session load so a change dir created mid-session lands on the
|
|
1047
|
+
* next refresh. Never touches a change's body files.
|
|
1048
|
+
*/
|
|
1049
|
+
adoptExistingChanges() {
|
|
1050
|
+
const changesDir = join3(this.dir, "changes");
|
|
1051
|
+
if (!existsSync3(changesDir)) return [];
|
|
1052
|
+
const doc = this.loadDoc();
|
|
1053
|
+
const plan = this.planOf(doc);
|
|
1054
|
+
const known = new Set(plan.nodes.map((n) => n.id));
|
|
1055
|
+
let order = this.nextOrder(plan, "spec");
|
|
1056
|
+
const adopted = [];
|
|
1057
|
+
for (const entry of readdirSync3(changesDir, { withFileTypes: true }).sort(
|
|
1058
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
1059
|
+
)) {
|
|
1060
|
+
if (!entry.isDirectory() || entry.name === "archive" || entry.name.startsWith(".")) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (known.has(entry.name)) continue;
|
|
1064
|
+
const node = {
|
|
1065
|
+
id: entry.name,
|
|
1066
|
+
type: "spec",
|
|
1067
|
+
title: entry.name,
|
|
1068
|
+
specFile: `${basename2(this.dir)}/changes/${entry.name}/${META_FILE}`,
|
|
1069
|
+
order: order++
|
|
1070
|
+
};
|
|
1071
|
+
doc.addIn(["nodes"], node);
|
|
1072
|
+
adopted.push(node);
|
|
1073
|
+
}
|
|
1074
|
+
if (adopted.length === 0) return [];
|
|
1075
|
+
this.save(doc);
|
|
1076
|
+
for (const node of adopted) {
|
|
1077
|
+
try {
|
|
1078
|
+
if (this.readSpecMeta(node.id) === null) this.initSpecMeta(node.id);
|
|
1079
|
+
} catch {
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return adopted;
|
|
1083
|
+
}
|
|
1084
|
+
// ── Per-spec metadata files ──────────────────────────────────────────────
|
|
1085
|
+
specMetaPath(specId) {
|
|
1086
|
+
return join3(this.dir, "changes", specId, META_FILE);
|
|
1087
|
+
}
|
|
1088
|
+
readSpecMeta(specId) {
|
|
1089
|
+
const raw = readIfExists(this.specMetaPath(specId));
|
|
1090
|
+
if (raw === void 0) return null;
|
|
1091
|
+
const doc = parseDocument(raw);
|
|
1092
|
+
const fault = doc.errors[0];
|
|
1093
|
+
if (fault) throw new PlanFileError(`changes/${specId}/${META_FILE}: ${fault.message}`);
|
|
1094
|
+
const parsed = SpecMeta.safeParse(doc.toJS());
|
|
1095
|
+
if (!parsed.success) {
|
|
1096
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
1097
|
+
throw new PlanFileError(`changes/${specId}/${META_FILE}: ${issues}`);
|
|
1098
|
+
}
|
|
1099
|
+
return parsed.data;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Write a spec's metadata file, preserving everything this write does not
|
|
1103
|
+
* own (comments, the generation block) by editing the existing document.
|
|
1104
|
+
*/
|
|
1105
|
+
writeSpecMeta(specId, userStories, dependencies) {
|
|
1106
|
+
const path = this.specMetaPath(specId);
|
|
1107
|
+
const raw = readIfExists(path);
|
|
1108
|
+
const doc = raw === void 0 ? new Document({}) : parseDocument(raw);
|
|
1109
|
+
doc.setIn(["specId"], specId);
|
|
1110
|
+
doc.setIn(["userStories"], userStories);
|
|
1111
|
+
doc.setIn(["dependencies"], dependencies);
|
|
1112
|
+
writeAtomicFile(path, doc.toString());
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Set a spec's lifecycle status (draft | active | done | deferred) in its
|
|
1116
|
+
* per-spec metadata file. User-owned, free transitions; everything else
|
|
1117
|
+
* in the file (stories, dependencies, generation, comments) is preserved.
|
|
1118
|
+
*/
|
|
1119
|
+
setStatus(specId, status) {
|
|
1120
|
+
const node = this.load().nodes.find((n) => n.id === specId);
|
|
1121
|
+
if (!node || node.type !== "spec") throw new DagError(`unknown spec: ${specId}`);
|
|
1122
|
+
const path = this.specMetaPath(specId);
|
|
1123
|
+
const raw = readIfExists(path);
|
|
1124
|
+
const doc = raw === void 0 ? new Document({ specId, userStories: [], dependencies: [] }) : parseDocument(raw);
|
|
1125
|
+
doc.setIn(["status"], status);
|
|
1126
|
+
writeAtomicFile(path, doc.toString());
|
|
1127
|
+
}
|
|
1128
|
+
/** Record a generation result in the spec's metadata file. */
|
|
1129
|
+
writeGeneration(specId, lastGeneratedBodyHash, lastGeneratedAt) {
|
|
1130
|
+
const path = this.specMetaPath(specId);
|
|
1131
|
+
const raw = readIfExists(path);
|
|
1132
|
+
const doc = raw === void 0 ? new Document({ specId }) : parseDocument(raw);
|
|
1133
|
+
doc.setIn(["generation", "lastGeneratedBodyHash"], lastGeneratedBodyHash);
|
|
1134
|
+
doc.setIn(["generation", "lastGeneratedAt"], lastGeneratedAt);
|
|
1135
|
+
writeAtomicFile(path, doc.toString());
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
// server/tools.ts
|
|
1140
|
+
import { z as z2 } from "zod";
|
|
1141
|
+
var CreateSpecInput = z2.object({ title: z2.string().min(1) });
|
|
1142
|
+
var CreateStoryInput = z2.object({ text: z2.string().min(1) });
|
|
1143
|
+
var LinkDependencyInput = z2.object({ fromSpecId: z2.string(), toSpecId: z2.string() });
|
|
1144
|
+
var ContainStoryInput = z2.object({ specId: z2.string(), storyId: z2.string() });
|
|
1145
|
+
var ReorderInput = z2.object({ nodeId: z2.string(), order: z2.number() });
|
|
1146
|
+
var CHAT_TOOLS = [
|
|
1147
|
+
{
|
|
1148
|
+
name: "create_spec",
|
|
1149
|
+
description: "Create a new spec card on the planning canvas. Returns the minted spec id.",
|
|
1150
|
+
input_schema: {
|
|
1151
|
+
type: "object",
|
|
1152
|
+
properties: { title: { type: "string", description: "Short spec title" } },
|
|
1153
|
+
required: ["title"]
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: "create_user_story",
|
|
1158
|
+
description: 'Create a new user story card. The text MUST follow "As a <user>, I want to <feature> so that <value proposition>". Returns the minted story id.',
|
|
1159
|
+
input_schema: {
|
|
1160
|
+
type: "object",
|
|
1161
|
+
properties: { text: { type: "string", description: "The full user-story sentence" } },
|
|
1162
|
+
required: ["text"]
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
name: "link_dependency",
|
|
1167
|
+
description: "Add a directed dependency edge between two existing SPECS (fromSpecId depends on toSpecId). Rejected if it would create a cycle.",
|
|
1168
|
+
input_schema: {
|
|
1169
|
+
type: "object",
|
|
1170
|
+
properties: {
|
|
1171
|
+
fromSpecId: { type: "string", description: "The dependent spec id (spec-\u2026)" },
|
|
1172
|
+
toSpecId: { type: "string", description: "The spec it depends on (spec-\u2026)" }
|
|
1173
|
+
},
|
|
1174
|
+
required: ["fromSpecId", "toSpecId"]
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
name: "contain_story",
|
|
1179
|
+
description: "Contain an existing user story in an existing spec (the coverage relation). Removes the story from the backlog.",
|
|
1180
|
+
input_schema: {
|
|
1181
|
+
type: "object",
|
|
1182
|
+
properties: {
|
|
1183
|
+
specId: { type: "string", description: "The containing spec id (spec-\u2026)" },
|
|
1184
|
+
storyId: { type: "string", description: "The user story id (us-\u2026)" }
|
|
1185
|
+
},
|
|
1186
|
+
required: ["specId", "storyId"]
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
name: "reorder",
|
|
1191
|
+
description: "Set a node's sibling `order` value (display ordering among siblings). Never changes any edges.",
|
|
1192
|
+
input_schema: {
|
|
1193
|
+
type: "object",
|
|
1194
|
+
properties: {
|
|
1195
|
+
nodeId: { type: "string", description: "The node to reorder" },
|
|
1196
|
+
order: { type: "number", description: "The new order value" }
|
|
1197
|
+
},
|
|
1198
|
+
required: ["nodeId", "order"]
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
];
|
|
1202
|
+
function parseInput(schema, input, tool) {
|
|
1203
|
+
const parsed = schema.safeParse(input);
|
|
1204
|
+
if (!parsed.success) {
|
|
1205
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
1206
|
+
throw new DagError(`${tool}: invalid input \u2014 ${issues}`);
|
|
1207
|
+
}
|
|
1208
|
+
return parsed.data;
|
|
1209
|
+
}
|
|
1210
|
+
function nodeType(store, id) {
|
|
1211
|
+
const node = store.load().nodes.find((n) => n.id === id);
|
|
1212
|
+
if (!node) throw new DagError(`unknown node: ${id}`);
|
|
1213
|
+
return node.type;
|
|
1214
|
+
}
|
|
1215
|
+
function executeToolCall(store, name, input) {
|
|
1216
|
+
switch (name) {
|
|
1217
|
+
case "create_spec": {
|
|
1218
|
+
const { title } = parseInput(CreateSpecInput, input, name);
|
|
1219
|
+
const node = store.createSpec(title);
|
|
1220
|
+
return { tool: name, summary: `created spec ${node.id} "${node.title}"` };
|
|
1221
|
+
}
|
|
1222
|
+
case "create_user_story": {
|
|
1223
|
+
const { text } = parseInput(CreateStoryInput, input, name);
|
|
1224
|
+
const node = store.createStory(text);
|
|
1225
|
+
return { tool: name, summary: `created user story ${node.id}` };
|
|
1226
|
+
}
|
|
1227
|
+
case "link_dependency": {
|
|
1228
|
+
const { fromSpecId, toSpecId } = parseInput(LinkDependencyInput, input, name);
|
|
1229
|
+
for (const id of [fromSpecId, toSpecId]) {
|
|
1230
|
+
if (nodeType(store, id) !== "spec") {
|
|
1231
|
+
throw new DagError(
|
|
1232
|
+
`link_dependency links specs only \u2014 ${id} is a user story (use contain_story)`
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const edge = store.link(fromSpecId, toSpecId);
|
|
1237
|
+
return { tool: name, summary: `linked dependency ${edge.from} -> ${edge.to}` };
|
|
1238
|
+
}
|
|
1239
|
+
case "contain_story": {
|
|
1240
|
+
const { specId, storyId } = parseInput(ContainStoryInput, input, name);
|
|
1241
|
+
if (nodeType(store, specId) !== "spec") {
|
|
1242
|
+
throw new DagError(`contain_story: ${specId} is not a spec`);
|
|
1243
|
+
}
|
|
1244
|
+
if (nodeType(store, storyId) !== "user_story") {
|
|
1245
|
+
throw new DagError(`contain_story: ${storyId} is not a user story`);
|
|
1246
|
+
}
|
|
1247
|
+
const edge = store.link(specId, storyId);
|
|
1248
|
+
return { tool: name, summary: `contained ${edge.to} in ${edge.from}` };
|
|
1249
|
+
}
|
|
1250
|
+
case "reorder": {
|
|
1251
|
+
const { nodeId, order } = parseInput(ReorderInput, input, name);
|
|
1252
|
+
const node = store.reorder(nodeId, order);
|
|
1253
|
+
return { tool: name, summary: `set order of ${node.id} to ${order}` };
|
|
1254
|
+
}
|
|
1255
|
+
default:
|
|
1256
|
+
throw new DagError(`unknown tool: ${name}`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// server/app.ts
|
|
1261
|
+
var CHAT_SYSTEM = `You are SpecPlan's planning assistant. The user is planning a project as a
|
|
1262
|
+
DAG of two node types \u2014 specs and user stories \u2014 with directed dependency edges between
|
|
1263
|
+
specs (kept acyclic) and containment edges from specs to the user stories they cover.
|
|
1264
|
+
|
|
1265
|
+
You have tools to CREATE specs and user stories, LINK spec dependencies, CONTAIN stories
|
|
1266
|
+
in specs, and REORDER siblings. Use them whenever the user asks to add, connect, cover, or
|
|
1267
|
+
reorder plan items, then confirm what you did. User stories MUST follow the formula
|
|
1268
|
+
"As a <user>, I want to <feature> so that <value proposition>" \u2014 compose conforming text
|
|
1269
|
+
when the user is informal. A failed tool call returns a named error; adjust or explain,
|
|
1270
|
+
never retry the identical call.
|
|
1271
|
+
|
|
1272
|
+
You CANNOT delete, remove, or rename anything \u2014 no such tool exists, by design. If asked
|
|
1273
|
+
to delete or remove an item, explain that SpecPlan's assistant is non-destructive (the
|
|
1274
|
+
user can edit specplan.yaml by hand) and make NO tool calls.
|
|
1275
|
+
|
|
1276
|
+
Ground answers in the current plan below. "=== @<path> ===" blocks are real file contents
|
|
1277
|
+
resolved from @-references; they are authoritative over your assumptions. Answers render
|
|
1278
|
+
as plain text in a narrow side pane: be concise, short paragraphs or simple "- " lists,
|
|
1279
|
+
no headings or heavy markdown.`;
|
|
1280
|
+
function renderPlanContext(plan, statuses) {
|
|
1281
|
+
if (plan.nodes.length === 0) return "Current plan: (empty \u2014 no nodes yet)";
|
|
1282
|
+
const stories = new Map(
|
|
1283
|
+
plan.nodes.filter((n) => n.type === "user_story").map((n) => [n.id, n])
|
|
1284
|
+
);
|
|
1285
|
+
const lines = ["Current plan:"];
|
|
1286
|
+
for (const node of plan.nodes) {
|
|
1287
|
+
if (node.type !== "spec") continue;
|
|
1288
|
+
lines.push(
|
|
1289
|
+
`- spec ${node.id} "${node.title}" (order ${node.order}, status ${statuses?.[node.id] ?? "draft"})`
|
|
1290
|
+
);
|
|
1291
|
+
for (const dep of specDependencies(plan, node.id)) lines.push(` depends on ${dep}`);
|
|
1292
|
+
for (const sid of containedStories(plan, node.id)) {
|
|
1293
|
+
const story = stories.get(sid);
|
|
1294
|
+
lines.push(` contains ${sid}: ${story?.type === "user_story" ? story.text : ""}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const free = backlog(plan);
|
|
1298
|
+
const backlogLines = [
|
|
1299
|
+
...free.specs.map((n) => `- ${n.id} (spec): ${n.type === "spec" ? n.title : ""}`),
|
|
1300
|
+
...free.stories.map((n) => `- ${n.id} (story): ${n.type === "user_story" ? n.text : ""}`)
|
|
1301
|
+
];
|
|
1302
|
+
lines.push("", "Backlog (not yet linked into the plan):");
|
|
1303
|
+
lines.push(...backlogLines.length > 0 ? backlogLines : ["- (empty)"]);
|
|
1304
|
+
return lines.join("\n");
|
|
1305
|
+
}
|
|
1306
|
+
var CHAT_TOOL_ROUNDS_LIMIT = 8;
|
|
1307
|
+
function anthropicErrorResponse(c, err, kind) {
|
|
1308
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1309
|
+
const status = err.status;
|
|
1310
|
+
if (status === 401 || /authentication|auth token|x-api-key/i.test(message)) {
|
|
1311
|
+
return c.json(
|
|
1312
|
+
{
|
|
1313
|
+
error: "anthropic-auth",
|
|
1314
|
+
hint: "No Anthropic credentials available to the server. Set ANTHROPIC_API_KEY in your environment, or install the Anthropic CLI and run `ant auth login` (macOS: brew install anthropics/tap/ant), then restart specplan.",
|
|
1315
|
+
detail: message
|
|
1316
|
+
},
|
|
1317
|
+
401
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
return c.json({ error: kind, detail: message }, 502);
|
|
1321
|
+
}
|
|
1322
|
+
function planErrorResponse(c, err) {
|
|
1323
|
+
if (err instanceof DagError) return c.json({ error: err.message }, 400);
|
|
1324
|
+
if (err instanceof PlanFileError) return c.json({ error: err.message }, 500);
|
|
1325
|
+
throw err;
|
|
1326
|
+
}
|
|
1327
|
+
function createApp(anthropic, workspace, store, options) {
|
|
1328
|
+
const refRoot = options === void 0 ? process.cwd() : options.refRoot;
|
|
1329
|
+
const app = new Hono();
|
|
1330
|
+
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
1331
|
+
app.get(
|
|
1332
|
+
"/api/config",
|
|
1333
|
+
(c) => c.json({
|
|
1334
|
+
workspace: Boolean(workspace),
|
|
1335
|
+
models: GENERATION_MODELS,
|
|
1336
|
+
defaultModel: DEFAULT_MODEL
|
|
1337
|
+
})
|
|
1338
|
+
);
|
|
1339
|
+
if (workspace && store) {
|
|
1340
|
+
app.get("/api/session", (c) => {
|
|
1341
|
+
try {
|
|
1342
|
+
store.adoptExistingChanges();
|
|
1343
|
+
const plan = store.load();
|
|
1344
|
+
const specStates = Object.fromEntries(
|
|
1345
|
+
plan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id)])
|
|
1346
|
+
);
|
|
1347
|
+
return c.json({
|
|
1348
|
+
rootName: basename3(workspace.dir),
|
|
1349
|
+
model: GENERATION_MODELS.find((m) => m === workspace.readModel()),
|
|
1350
|
+
plan,
|
|
1351
|
+
positions: workspace.readPositions(),
|
|
1352
|
+
specStates,
|
|
1353
|
+
chat: workspace.readChat()
|
|
1354
|
+
});
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
return planErrorResponse(c, err);
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
app.put("/api/session", async (c) => {
|
|
1360
|
+
const parsed = SessionSave.safeParse(await c.req.json().catch(() => null));
|
|
1361
|
+
if (!parsed.success) {
|
|
1362
|
+
return c.json({ error: "invalid session", detail: parsed.error.issues }, 400);
|
|
1363
|
+
}
|
|
1364
|
+
workspace.writeUiState(parsed.data);
|
|
1365
|
+
return c.json({ ok: true });
|
|
1366
|
+
});
|
|
1367
|
+
app.post("/api/plan/spec", async (c) => {
|
|
1368
|
+
const parsed = CreateSpecRequest.safeParse(await c.req.json().catch(() => null));
|
|
1369
|
+
if (!parsed.success) {
|
|
1370
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
return c.json(
|
|
1374
|
+
{ node: store.createSpec(parsed.data.title, parsed.data.description) },
|
|
1375
|
+
201
|
|
1376
|
+
);
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
return planErrorResponse(c, err);
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
app.post("/api/plan/story", async (c) => {
|
|
1382
|
+
const parsed = CreateStoryRequest.safeParse(await c.req.json().catch(() => null));
|
|
1383
|
+
if (!parsed.success) {
|
|
1384
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1385
|
+
}
|
|
1386
|
+
try {
|
|
1387
|
+
return c.json({ node: store.createStory(parsed.data.text) }, 201);
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
return planErrorResponse(c, err);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
app.post("/api/plan/link", async (c) => {
|
|
1393
|
+
const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
|
|
1394
|
+
if (!parsed.success) {
|
|
1395
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1396
|
+
}
|
|
1397
|
+
try {
|
|
1398
|
+
return c.json({ edge: store.link(parsed.data.from, parsed.data.to) }, 201);
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
return planErrorResponse(c, err);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
app.get("/api/spec/:specId", (c) => {
|
|
1404
|
+
const specId = c.req.param("specId");
|
|
1405
|
+
try {
|
|
1406
|
+
const node = store.load().nodes.find((n) => n.id === specId);
|
|
1407
|
+
if (!node || node.type !== "spec") {
|
|
1408
|
+
return c.json({ error: `unknown spec: ${specId}` }, 404);
|
|
1409
|
+
}
|
|
1410
|
+
return c.json({
|
|
1411
|
+
specId,
|
|
1412
|
+
handEdited: specState(store, specId).handEdited,
|
|
1413
|
+
files: readBodyFiles(store, specId)
|
|
1414
|
+
});
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
return planErrorResponse(c, err);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
app.post("/api/generate", async (c) => {
|
|
1420
|
+
const parsed = GenerateRequest.safeParse(await c.req.json().catch(() => null));
|
|
1421
|
+
if (!parsed.success) {
|
|
1422
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1423
|
+
}
|
|
1424
|
+
const model = parsed.data.model ?? GENERATION_MODELS.find((m) => m === workspace.readModel()) ?? DEFAULT_MODEL;
|
|
1425
|
+
try {
|
|
1426
|
+
const result = await generateForSpec(
|
|
1427
|
+
{
|
|
1428
|
+
anthropic,
|
|
1429
|
+
store,
|
|
1430
|
+
systemPrompt: workspace.readSystemPrompt(),
|
|
1431
|
+
refRoot
|
|
1432
|
+
},
|
|
1433
|
+
parsed.data.specId,
|
|
1434
|
+
model
|
|
1435
|
+
);
|
|
1436
|
+
return c.json(result);
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
if (err instanceof GenerationFailed) {
|
|
1439
|
+
return c.json({ error: "generation-failed", detail: err.message }, err.status);
|
|
1440
|
+
}
|
|
1441
|
+
return anthropicErrorResponse(c, err, "generation-failed");
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
app.post("/api/chat", async (c) => {
|
|
1445
|
+
const parsed = ChatRequest.safeParse(await c.req.json().catch(() => null));
|
|
1446
|
+
if (!parsed.success) {
|
|
1447
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1448
|
+
}
|
|
1449
|
+
const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
|
|
1450
|
+
const refs = expandReferences(message, refRoot);
|
|
1451
|
+
const refContext = renderReferenceContext(refs);
|
|
1452
|
+
const messages = [
|
|
1453
|
+
...conversation.map((m) => ({
|
|
1454
|
+
role: m.role === "user" ? "user" : "assistant",
|
|
1455
|
+
content: m.text
|
|
1456
|
+
})),
|
|
1457
|
+
{
|
|
1458
|
+
role: "user",
|
|
1459
|
+
content: message + (refContext ? `
|
|
1460
|
+
|
|
1461
|
+
Referenced files (resolved from @-references in the message):
|
|
1462
|
+
|
|
1463
|
+
${refContext}` : "")
|
|
1464
|
+
}
|
|
1465
|
+
];
|
|
1466
|
+
const chatPlan = store.load();
|
|
1467
|
+
const statuses = Object.fromEntries(
|
|
1468
|
+
chatPlan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id).status])
|
|
1469
|
+
);
|
|
1470
|
+
const system = `${CHAT_SYSTEM}
|
|
1471
|
+
|
|
1472
|
+
${renderPlanContext(chatPlan, statuses)}`;
|
|
1473
|
+
const executed = [];
|
|
1474
|
+
let text = "";
|
|
1475
|
+
try {
|
|
1476
|
+
for (let round = 0; round < CHAT_TOOL_ROUNDS_LIMIT; round++) {
|
|
1477
|
+
const msg = await createWithModelGate(anthropic, {
|
|
1478
|
+
model,
|
|
1479
|
+
max_tokens: 2e3,
|
|
1480
|
+
system,
|
|
1481
|
+
messages,
|
|
1482
|
+
tools: CHAT_TOOLS
|
|
1483
|
+
});
|
|
1484
|
+
if (msg.stop_reason === "refusal") {
|
|
1485
|
+
return c.json({ error: "refusal", detail: msg.stop_details }, 422);
|
|
1486
|
+
}
|
|
1487
|
+
const toolUses = toolUsesOf(msg);
|
|
1488
|
+
if (toolUses.length === 0 || msg.stop_reason !== "tool_use") {
|
|
1489
|
+
text = textOf(msg);
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
messages.push({ role: "assistant", content: msg.content });
|
|
1493
|
+
const results = toolUses.map((tu) => {
|
|
1494
|
+
try {
|
|
1495
|
+
const outcome = executeToolCall(store, tu.name, tu.input);
|
|
1496
|
+
executed.push(outcome);
|
|
1497
|
+
return { type: "tool_result", tool_use_id: tu.id, content: outcome.summary };
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
if (!(err instanceof DagError)) throw err;
|
|
1500
|
+
return {
|
|
1501
|
+
type: "tool_result",
|
|
1502
|
+
tool_use_id: tu.id,
|
|
1503
|
+
content: err.message,
|
|
1504
|
+
is_error: true
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
messages.push({ role: "user", content: results });
|
|
1509
|
+
}
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
return anthropicErrorResponse(c, err, "chat-failed");
|
|
1512
|
+
}
|
|
1513
|
+
if (!text) {
|
|
1514
|
+
text = executed.length > 0 ? `Applied ${executed.length} change${executed.length === 1 ? "" : "s"}: ${executed.map((e) => e.summary).join("; ")}.` : "The model returned no text.";
|
|
1515
|
+
}
|
|
1516
|
+
const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1517
|
+
workspace.writeChat([
|
|
1518
|
+
...conversation,
|
|
1519
|
+
{ role: "user", text: message, at: reply.at },
|
|
1520
|
+
reply
|
|
1521
|
+
]);
|
|
1522
|
+
return c.json({
|
|
1523
|
+
reply,
|
|
1524
|
+
toolCalls: executed,
|
|
1525
|
+
plan: store.load(),
|
|
1526
|
+
resolvedRefs: refs.resolved.map((r) => r.path),
|
|
1527
|
+
unresolvedRefs: refs.unresolved
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
app.delete("/api/chat", (c) => {
|
|
1531
|
+
workspace.writeChat([]);
|
|
1532
|
+
return c.json({ ok: true });
|
|
1533
|
+
});
|
|
1534
|
+
app.post("/api/plan/status", async (c) => {
|
|
1535
|
+
const parsed = SetStatusRequest.safeParse(await c.req.json().catch(() => null));
|
|
1536
|
+
if (!parsed.success) {
|
|
1537
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1538
|
+
}
|
|
1539
|
+
try {
|
|
1540
|
+
store.setStatus(parsed.data.specId, parsed.data.status);
|
|
1541
|
+
return c.json({ specId: parsed.data.specId, status: parsed.data.status });
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
return planErrorResponse(c, err);
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
app.post("/api/plan/unlink", async (c) => {
|
|
1547
|
+
const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
|
|
1548
|
+
if (!parsed.success) {
|
|
1549
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1550
|
+
}
|
|
1551
|
+
try {
|
|
1552
|
+
return c.json({ edge: store.unlink(parsed.data.from, parsed.data.to) });
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
return planErrorResponse(c, err);
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
app.post("/api/plan/reorder", async (c) => {
|
|
1558
|
+
const parsed = ReorderRequest.safeParse(await c.req.json().catch(() => null));
|
|
1559
|
+
if (!parsed.success) {
|
|
1560
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
return c.json({ node: store.reorder(parsed.data.nodeId, parsed.data.order) });
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
return planErrorResponse(c, err);
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
app.get(
|
|
1570
|
+
"/api/files",
|
|
1571
|
+
(c) => c.json({ files: searchFiles(refRoot, c.req.query("q") ?? "") })
|
|
1572
|
+
);
|
|
1573
|
+
app.get("/api/prompt", (c) => {
|
|
1574
|
+
const custom = workspace?.readSystemPrompt();
|
|
1575
|
+
return c.json({ system: custom ?? SYSTEM, default: SYSTEM, custom: custom !== void 0 });
|
|
1576
|
+
});
|
|
1577
|
+
app.put("/api/prompt", async (c) => {
|
|
1578
|
+
const parsed = PromptSave.safeParse(await c.req.json().catch(() => null));
|
|
1579
|
+
if (!parsed.success) {
|
|
1580
|
+
return c.json({ error: "invalid prompt", detail: parsed.error.issues }, 400);
|
|
1581
|
+
}
|
|
1582
|
+
workspace?.writeSystemPrompt(parsed.data.system ?? void 0);
|
|
1583
|
+
return c.json({ ok: true, persisted: Boolean(workspace) });
|
|
1584
|
+
});
|
|
1585
|
+
return app;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// server/login.ts
|
|
1589
|
+
import { parseArgs } from "node:util";
|
|
1590
|
+
|
|
1591
|
+
// ../shared/src/cli-config.ts
|
|
1592
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
1593
|
+
import { homedir, hostname } from "node:os";
|
|
1594
|
+
import { join as join4, dirname as dirname3 } from "node:path";
|
|
1595
|
+
|
|
1596
|
+
// ../shared/src/schemas.ts
|
|
1597
|
+
import { z as z3 } from "zod";
|
|
1598
|
+
var planTierSchema = z3.enum(["hobby", "squad", "org"]);
|
|
1599
|
+
var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
|
|
1600
|
+
var locationHintSchema = z3.enum([
|
|
1601
|
+
"wnam",
|
|
1602
|
+
"enam",
|
|
1603
|
+
"weur",
|
|
1604
|
+
"eeur",
|
|
1605
|
+
"apac",
|
|
1606
|
+
"oc",
|
|
1607
|
+
"afr",
|
|
1608
|
+
"me",
|
|
1609
|
+
"sam"
|
|
1610
|
+
]);
|
|
1611
|
+
var sessionNameSchema = z3.string().min(1).max(63).regex(
|
|
1612
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
1613
|
+
"Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
1614
|
+
);
|
|
1615
|
+
var teamNameSchema = z3.string().min(1).max(63).regex(
|
|
1616
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
1617
|
+
"Team name must be lowercase alphanumeric with hyphens"
|
|
1618
|
+
);
|
|
1619
|
+
var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
|
|
1620
|
+
var messageContentSchema = z3.object({
|
|
1621
|
+
kind: messageKindSchema,
|
|
1622
|
+
// No .max() here: the 10 000-char body limit is business logic owned by the
|
|
1623
|
+
// SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
|
|
1624
|
+
// both REST and WS. A schema cap would pre-empt it as a generic 400.
|
|
1625
|
+
body: z3.string().min(1),
|
|
1626
|
+
// Two-arg z.record: identical semantics in zod 3, required form in zod 4
|
|
1627
|
+
// (specsketch bundles this file against zod 4 — keep every construct dual-safe).
|
|
1628
|
+
metadata: z3.record(z3.string(), z3.unknown()).optional()
|
|
1629
|
+
});
|
|
1630
|
+
var createTeamInputSchema = z3.object({
|
|
1631
|
+
name: teamNameSchema
|
|
1632
|
+
});
|
|
1633
|
+
var createSessionInputSchema = z3.object({
|
|
1634
|
+
name: sessionNameSchema,
|
|
1635
|
+
teamId: z3.string().optional(),
|
|
1636
|
+
jurisdiction: jurisdictionSchema.optional(),
|
|
1637
|
+
locationHint: locationHintSchema.optional()
|
|
1638
|
+
});
|
|
1639
|
+
var sendMessageInputSchema = z3.object({
|
|
1640
|
+
content: messageContentSchema
|
|
1641
|
+
});
|
|
1642
|
+
var inviteInputSchema = z3.object({
|
|
1643
|
+
email: z3.string().email(),
|
|
1644
|
+
role: z3.enum(["admin", "member"]).default("member")
|
|
1645
|
+
});
|
|
1646
|
+
var joinTeamInputSchema = z3.object({
|
|
1647
|
+
inviteCode: z3.string().min(1)
|
|
1648
|
+
});
|
|
1649
|
+
var markReadInputSchema = z3.object({
|
|
1650
|
+
notificationIds: z3.array(z3.string().min(1))
|
|
1651
|
+
});
|
|
1652
|
+
var pollQuerySchema = z3.object({
|
|
1653
|
+
since: z3.string().optional(),
|
|
1654
|
+
limit: z3.coerce.number().int().min(1).max(100).default(50),
|
|
1655
|
+
session: z3.string().optional()
|
|
1656
|
+
});
|
|
1657
|
+
var messagesQuerySchema = z3.object({
|
|
1658
|
+
cursor: z3.string().optional(),
|
|
1659
|
+
limit: z3.coerce.number().int().min(1).max(100).default(50)
|
|
1660
|
+
});
|
|
1661
|
+
var teamTypeSchema = z3.enum(["personal", "shared"]);
|
|
1662
|
+
var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
|
|
1663
|
+
var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
|
|
1664
|
+
var accessGrantInputSchema = z3.object({
|
|
1665
|
+
userId: z3.string().min(1),
|
|
1666
|
+
sessionName: sessionNameSchema.nullable().default(null),
|
|
1667
|
+
// null = general
|
|
1668
|
+
permission: accessPermissionSchema
|
|
1669
|
+
});
|
|
1670
|
+
var machineIdSchema = z3.string().min(1).max(63).regex(
|
|
1671
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
1672
|
+
"Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
1673
|
+
);
|
|
1674
|
+
var clerkProfileInputSchema = z3.object({
|
|
1675
|
+
email: z3.string().email().nullable(),
|
|
1676
|
+
displayName: z3.string().min(1).max(100),
|
|
1677
|
+
avatarUrl: z3.string().url().nullable(),
|
|
1678
|
+
provider: z3.string().min(1).max(50),
|
|
1679
|
+
providerUserId: z3.string().min(1).max(100)
|
|
1680
|
+
});
|
|
1681
|
+
var sessionEnrollmentSchema = z3.object({
|
|
1682
|
+
sessionId: z3.string(),
|
|
1683
|
+
team: z3.string(),
|
|
1684
|
+
teamName: z3.string().optional(),
|
|
1685
|
+
key: z3.string().startsWith("api_"),
|
|
1686
|
+
enrolledAt: z3.string().datetime(),
|
|
1687
|
+
apiUrl: z3.string().url().optional(),
|
|
1688
|
+
machineId: z3.string().optional()
|
|
1689
|
+
});
|
|
1690
|
+
var authConfigSchema = z3.object({
|
|
1691
|
+
key: z3.string().startsWith("api_"),
|
|
1692
|
+
userId: z3.string().min(1),
|
|
1693
|
+
displayName: z3.string().nullable().optional().default(null),
|
|
1694
|
+
email: z3.string().email().nullable(),
|
|
1695
|
+
machineId: z3.string().nullable(),
|
|
1696
|
+
authenticatedAt: z3.string().datetime()
|
|
1697
|
+
});
|
|
1698
|
+
var configBaseFields = {
|
|
1699
|
+
apiUrl: z3.string().url(),
|
|
1700
|
+
machineId: z3.string().nullable(),
|
|
1701
|
+
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1702
|
+
defaults: z3.object({
|
|
1703
|
+
team: z3.string().nullable(),
|
|
1704
|
+
session: z3.string().nullable()
|
|
1705
|
+
}),
|
|
1706
|
+
poll: z3.object({
|
|
1707
|
+
lastPollAt: z3.string().datetime().nullable()
|
|
1708
|
+
})
|
|
1709
|
+
};
|
|
1710
|
+
var coopConfigV1Schema = z3.object({
|
|
1711
|
+
version: z3.literal(1),
|
|
1712
|
+
...configBaseFields
|
|
1713
|
+
});
|
|
1714
|
+
var coopConfigV2Schema = z3.object({
|
|
1715
|
+
version: z3.literal(2),
|
|
1716
|
+
...configBaseFields,
|
|
1717
|
+
auth: authConfigSchema.optional()
|
|
1718
|
+
});
|
|
1719
|
+
var profileNameSchema = z3.string().regex(
|
|
1720
|
+
/^[a-z0-9][a-z0-9_-]{0,63}$/,
|
|
1721
|
+
"Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
|
|
1722
|
+
);
|
|
1723
|
+
var coopProfileSchema = z3.object({
|
|
1724
|
+
apiUrl: z3.string().url(),
|
|
1725
|
+
auth: authConfigSchema.optional(),
|
|
1726
|
+
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1727
|
+
defaults: z3.object({
|
|
1728
|
+
team: z3.string().nullable(),
|
|
1729
|
+
session: z3.string().nullable()
|
|
1730
|
+
}),
|
|
1731
|
+
poll: z3.object({
|
|
1732
|
+
lastPollAt: z3.string().datetime().nullable()
|
|
1733
|
+
})
|
|
1734
|
+
});
|
|
1735
|
+
var coopConfigV3ObjectSchema = z3.object({
|
|
1736
|
+
version: z3.literal(3),
|
|
1737
|
+
machineId: z3.string().nullable(),
|
|
1738
|
+
defaultProfile: z3.string(),
|
|
1739
|
+
profiles: z3.record(profileNameSchema, coopProfileSchema)
|
|
1740
|
+
});
|
|
1741
|
+
function requireDefaultProfilePointer(config, ctx) {
|
|
1742
|
+
if (!(config.defaultProfile in config.profiles)) {
|
|
1743
|
+
ctx.addIssue({
|
|
1744
|
+
code: "custom",
|
|
1745
|
+
path: ["defaultProfile"],
|
|
1746
|
+
message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
|
|
1751
|
+
requireDefaultProfilePointer
|
|
1752
|
+
);
|
|
1753
|
+
var coopConfigSchema = z3.discriminatedUnion("version", [
|
|
1754
|
+
coopConfigV1Schema,
|
|
1755
|
+
coopConfigV2Schema,
|
|
1756
|
+
coopConfigV3ObjectSchema
|
|
1757
|
+
]).superRefine((config, ctx) => {
|
|
1758
|
+
if (config.version === 3) {
|
|
1759
|
+
requireDefaultProfilePointer(config, ctx);
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
var hookStdinSchema = z3.object({
|
|
1763
|
+
session_id: z3.string().optional(),
|
|
1764
|
+
cwd: z3.string().optional(),
|
|
1765
|
+
hook_event_name: z3.string().optional()
|
|
1766
|
+
}).passthrough();
|
|
1767
|
+
var wsClientMessageSchema = z3.discriminatedUnion("type", [
|
|
1768
|
+
z3.object({ type: z3.literal("message"), content: messageContentSchema }),
|
|
1769
|
+
z3.object({ type: z3.literal("presence_request") }),
|
|
1770
|
+
z3.object({ type: z3.literal("ping") })
|
|
1771
|
+
]);
|
|
1772
|
+
|
|
1773
|
+
// ../shared/src/cli-config.ts
|
|
1774
|
+
var DEFAULT_CONFIG_DIR = join4(homedir(), ".coopcli");
|
|
1775
|
+
var DEFAULT_CONFIG_PATH = join4(DEFAULT_CONFIG_DIR, "config.json");
|
|
1776
|
+
var PROD_API_URL = "https://api.coopcli.com";
|
|
1777
|
+
function emptyProfile(apiUrl) {
|
|
1778
|
+
return {
|
|
1779
|
+
apiUrl,
|
|
1780
|
+
sessions: {},
|
|
1781
|
+
defaults: { team: null, session: null },
|
|
1782
|
+
poll: { lastPollAt: null }
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
var DEFAULT_CONFIG = {
|
|
1786
|
+
version: 3,
|
|
1787
|
+
machineId: hostname(),
|
|
1788
|
+
defaultProfile: "default",
|
|
1789
|
+
profiles: { default: emptyProfile(PROD_API_URL) }
|
|
1790
|
+
};
|
|
1791
|
+
function upgradeToV3(parsed) {
|
|
1792
|
+
if (parsed.version === 3) return parsed;
|
|
1793
|
+
const profile = {
|
|
1794
|
+
apiUrl: parsed.apiUrl,
|
|
1795
|
+
...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
|
|
1796
|
+
sessions: parsed.sessions,
|
|
1797
|
+
defaults: parsed.defaults,
|
|
1798
|
+
poll: parsed.poll
|
|
1799
|
+
};
|
|
1800
|
+
return {
|
|
1801
|
+
version: 3,
|
|
1802
|
+
machineId: parsed.machineId,
|
|
1803
|
+
defaultProfile: "default",
|
|
1804
|
+
profiles: { default: profile }
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
function profileFromEnv(env, envVars) {
|
|
1808
|
+
for (const name of envVars) {
|
|
1809
|
+
const value = env[name];
|
|
1810
|
+
if (value) return value;
|
|
1811
|
+
}
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
var ConfigStore = class {
|
|
1815
|
+
config = null;
|
|
1816
|
+
selectedProfile = null;
|
|
1817
|
+
configPath;
|
|
1818
|
+
configDir;
|
|
1819
|
+
constructor(configPath) {
|
|
1820
|
+
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
|
1821
|
+
this.configDir = dirname3(this.configPath);
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Select the profile this store operates on for the rest of the process
|
|
1825
|
+
* (--profile flag / COOP_PROFILE env). Without a selection, the
|
|
1826
|
+
* defaultProfile pointer applies.
|
|
1827
|
+
*/
|
|
1828
|
+
selectProfile(name) {
|
|
1829
|
+
this.selectedProfile = name;
|
|
1830
|
+
}
|
|
1831
|
+
activeProfileName(config) {
|
|
1832
|
+
return this.selectedProfile ?? config.defaultProfile;
|
|
1833
|
+
}
|
|
1834
|
+
view(config) {
|
|
1835
|
+
const profileName = this.activeProfileName(config);
|
|
1836
|
+
const profile = config.profiles[profileName];
|
|
1837
|
+
if (!profile) {
|
|
1838
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
1839
|
+
throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
|
|
1840
|
+
}
|
|
1841
|
+
return { profileName, machineId: config.machineId, ...profile };
|
|
1842
|
+
}
|
|
1843
|
+
/** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
|
|
1844
|
+
cached() {
|
|
1845
|
+
return this.view(this.cachedRaw());
|
|
1846
|
+
}
|
|
1847
|
+
/** Returns the full previously loaded config. Throws if load() hasn't been called. */
|
|
1848
|
+
cachedRaw() {
|
|
1849
|
+
if (this.config === null) {
|
|
1850
|
+
throw new Error("Config not loaded yet. Call load() first.");
|
|
1851
|
+
}
|
|
1852
|
+
return this.config;
|
|
1853
|
+
}
|
|
1854
|
+
/** Load the config file and return the active profile's view. */
|
|
1855
|
+
async load() {
|
|
1856
|
+
return this.view(await this.loadRaw());
|
|
1857
|
+
}
|
|
1858
|
+
/** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
|
|
1859
|
+
async loadRaw() {
|
|
1860
|
+
if (this.config !== null) {
|
|
1861
|
+
return this.config;
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
const raw = await readFile(this.configPath, "utf8");
|
|
1865
|
+
const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
|
|
1866
|
+
if (!parsed.machineId) {
|
|
1867
|
+
parsed.machineId = hostname();
|
|
1868
|
+
}
|
|
1869
|
+
this.config = parsed;
|
|
1870
|
+
return parsed;
|
|
1871
|
+
} catch (err) {
|
|
1872
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
1873
|
+
this.config = structuredClone(DEFAULT_CONFIG);
|
|
1874
|
+
return this.config;
|
|
1875
|
+
}
|
|
1876
|
+
if (err instanceof SyntaxError || err?.name === "ZodError") {
|
|
1877
|
+
throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
|
|
1878
|
+
}
|
|
1879
|
+
throw err;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
async save(config) {
|
|
1883
|
+
await mkdir(this.configDir, { recursive: true, mode: 448 });
|
|
1884
|
+
const content = JSON.stringify(config, null, 2);
|
|
1885
|
+
const tmpPath = this.configPath + ".tmp";
|
|
1886
|
+
await writeFile(tmpPath, content, { mode: 384 });
|
|
1887
|
+
const { rename } = await import("node:fs/promises");
|
|
1888
|
+
await rename(tmpPath, this.configPath);
|
|
1889
|
+
try {
|
|
1890
|
+
await chmod(this.configPath, 384);
|
|
1891
|
+
} catch {
|
|
1892
|
+
}
|
|
1893
|
+
this.config = config;
|
|
1894
|
+
}
|
|
1895
|
+
/** Load, transform the active profile, and save — the single write path for profile-scoped state. */
|
|
1896
|
+
async saveProfile(mutate) {
|
|
1897
|
+
const config = await this.loadRaw();
|
|
1898
|
+
const name = this.activeProfileName(config);
|
|
1899
|
+
const profile = config.profiles[name];
|
|
1900
|
+
if (!profile) {
|
|
1901
|
+
throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
|
|
1902
|
+
}
|
|
1903
|
+
await this.save({
|
|
1904
|
+
...config,
|
|
1905
|
+
profiles: { ...config.profiles, [name]: mutate(profile) }
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
/** Validate config for common misconfigurations. Returns warning strings. */
|
|
1909
|
+
validate() {
|
|
1910
|
+
if (!this.config) return [];
|
|
1911
|
+
let view;
|
|
1912
|
+
try {
|
|
1913
|
+
view = this.view(this.config);
|
|
1914
|
+
} catch {
|
|
1915
|
+
return [];
|
|
1916
|
+
}
|
|
1917
|
+
const warnings = [];
|
|
1918
|
+
try {
|
|
1919
|
+
const url = new URL(view.apiUrl);
|
|
1920
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
1921
|
+
warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
|
|
1922
|
+
}
|
|
1923
|
+
} catch {
|
|
1924
|
+
warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
|
|
1925
|
+
}
|
|
1926
|
+
if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
|
|
1927
|
+
warnings.push(
|
|
1928
|
+
`profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
return warnings;
|
|
1932
|
+
}
|
|
1933
|
+
async enrollSession(name, enrollment) {
|
|
1934
|
+
await this.saveProfile((profile) => ({
|
|
1935
|
+
...profile,
|
|
1936
|
+
sessions: { ...profile.sessions, [name]: enrollment }
|
|
1937
|
+
}));
|
|
1938
|
+
}
|
|
1939
|
+
async unenrollSession(name) {
|
|
1940
|
+
await this.saveProfile((profile) => {
|
|
1941
|
+
const sessions = { ...profile.sessions };
|
|
1942
|
+
delete sessions[name];
|
|
1943
|
+
const defaults = { ...profile.defaults };
|
|
1944
|
+
if (defaults.session === name) {
|
|
1945
|
+
defaults.session = null;
|
|
1946
|
+
}
|
|
1947
|
+
return { ...profile, sessions, defaults };
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
async setDefault(key, value) {
|
|
1951
|
+
await this.saveProfile((profile) => ({
|
|
1952
|
+
...profile,
|
|
1953
|
+
defaults: { ...profile.defaults, [key]: value }
|
|
1954
|
+
}));
|
|
1955
|
+
}
|
|
1956
|
+
async getDefaultSession() {
|
|
1957
|
+
const view = await this.load();
|
|
1958
|
+
return view.defaults.session;
|
|
1959
|
+
}
|
|
1960
|
+
async getSessionKey(sessionName) {
|
|
1961
|
+
const view = await this.load();
|
|
1962
|
+
const enrollment = view.sessions[sessionName];
|
|
1963
|
+
if (!enrollment) {
|
|
1964
|
+
throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
|
|
1965
|
+
}
|
|
1966
|
+
return enrollment.key;
|
|
1967
|
+
}
|
|
1968
|
+
/** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
|
|
1969
|
+
async getApiUrl(sessionName) {
|
|
1970
|
+
const view = await this.load();
|
|
1971
|
+
if (sessionName) {
|
|
1972
|
+
const enrollment = view.sessions[sessionName];
|
|
1973
|
+
if (enrollment?.apiUrl) return enrollment.apiUrl;
|
|
1974
|
+
}
|
|
1975
|
+
return view.apiUrl;
|
|
1976
|
+
}
|
|
1977
|
+
/** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
|
|
1978
|
+
async getMachineId(sessionName) {
|
|
1979
|
+
const view = await this.load();
|
|
1980
|
+
if (sessionName) {
|
|
1981
|
+
const enrollment = view.sessions[sessionName];
|
|
1982
|
+
if (enrollment?.machineId) return enrollment.machineId;
|
|
1983
|
+
}
|
|
1984
|
+
if (view.machineId) return view.machineId;
|
|
1985
|
+
if (view.auth?.machineId) return view.auth.machineId;
|
|
1986
|
+
return null;
|
|
1987
|
+
}
|
|
1988
|
+
async setMachineId(machineId) {
|
|
1989
|
+
const config = await this.loadRaw();
|
|
1990
|
+
await this.save({ ...config, machineId });
|
|
1991
|
+
}
|
|
1992
|
+
/** Set the active profile's API base URL. */
|
|
1993
|
+
async setApiUrl(apiUrl) {
|
|
1994
|
+
await this.saveProfile((profile) => ({ ...profile, apiUrl }));
|
|
1995
|
+
}
|
|
1996
|
+
/** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
|
|
1997
|
+
async ensureActiveProfile(apiUrl) {
|
|
1998
|
+
const config = await this.loadRaw();
|
|
1999
|
+
const name = this.activeProfileName(config);
|
|
2000
|
+
if (config.profiles[name]) return;
|
|
2001
|
+
await this.save({
|
|
2002
|
+
...config,
|
|
2003
|
+
profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
/** Re-point the defaultProfile fallback. The target must exist. */
|
|
2007
|
+
async setDefaultProfile(name) {
|
|
2008
|
+
const config = await this.loadRaw();
|
|
2009
|
+
if (!config.profiles[name]) {
|
|
2010
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
2011
|
+
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
2012
|
+
}
|
|
2013
|
+
await this.save({ ...config, defaultProfile: name });
|
|
2014
|
+
}
|
|
2015
|
+
/** True if any profile holds an auth block (used to detect the first login). */
|
|
2016
|
+
async hasAnyAuth() {
|
|
2017
|
+
const config = await this.loadRaw();
|
|
2018
|
+
return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Remove a profile including its credentials and enrollments. The
|
|
2022
|
+
* defaultProfile pointer target cannot be deleted — re-point first.
|
|
2023
|
+
*/
|
|
2024
|
+
async deleteProfile(name) {
|
|
2025
|
+
const config = await this.loadRaw();
|
|
2026
|
+
if (!config.profiles[name]) {
|
|
2027
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
2028
|
+
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
2029
|
+
}
|
|
2030
|
+
if (config.defaultProfile === name) {
|
|
2031
|
+
throw new Error(
|
|
2032
|
+
`Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
const profiles = { ...config.profiles };
|
|
2036
|
+
delete profiles[name];
|
|
2037
|
+
await this.save({ ...config, profiles });
|
|
2038
|
+
}
|
|
2039
|
+
async updateLastPoll() {
|
|
2040
|
+
await this.saveProfile((profile) => ({
|
|
2041
|
+
...profile,
|
|
2042
|
+
poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
2043
|
+
}));
|
|
2044
|
+
}
|
|
2045
|
+
/** Returns true if the active profile has an auth block. */
|
|
2046
|
+
isAuthenticated() {
|
|
2047
|
+
if (this.config === null) return false;
|
|
2048
|
+
return this.view(this.config).auth !== void 0;
|
|
2049
|
+
}
|
|
2050
|
+
/** Returns the user-scoped API key, or throws if not authenticated. */
|
|
2051
|
+
getAuthKey() {
|
|
2052
|
+
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
2053
|
+
if (!auth) {
|
|
2054
|
+
throw new Error("Not authenticated. Run `coop login` first.");
|
|
2055
|
+
}
|
|
2056
|
+
return auth.key;
|
|
2057
|
+
}
|
|
2058
|
+
/** Returns the user ID, or throws if not authenticated. */
|
|
2059
|
+
getUserId() {
|
|
2060
|
+
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
2061
|
+
if (!auth) {
|
|
2062
|
+
throw new Error("Not authenticated. Run `coop login` first.");
|
|
2063
|
+
}
|
|
2064
|
+
return auth.userId;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Saves the auth block into the active profile. Legacy v1/v2 files are
|
|
2068
|
+
* normalized to v3 by load(), so the write always persists v3.
|
|
2069
|
+
*/
|
|
2070
|
+
async setAuth(auth) {
|
|
2071
|
+
await this.saveProfile((profile) => ({ ...profile, auth }));
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Returns true if the user should be nudged to run `coop login`:
|
|
2075
|
+
* the active profile has no auth block.
|
|
2076
|
+
*/
|
|
2077
|
+
needsLoginNudge() {
|
|
2078
|
+
if (this.config === null) return true;
|
|
2079
|
+
return this.view(this.config).auth === void 0;
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Removes the auth block from the active profile (for `coop logout`).
|
|
2083
|
+
* Sessions are preserved.
|
|
2084
|
+
*/
|
|
2085
|
+
async clearAuth() {
|
|
2086
|
+
await this.saveProfile((profile) => {
|
|
2087
|
+
const { auth: _removed, ...rest } = profile;
|
|
2088
|
+
return rest;
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
function isNodeError(err) {
|
|
2093
|
+
return err instanceof Error && "code" in err;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// ../shared/src/cli-auth.ts
|
|
2097
|
+
import { exec } from "node:child_process";
|
|
2098
|
+
import { randomBytes } from "node:crypto";
|
|
2099
|
+
import { createServer } from "node:http";
|
|
2100
|
+
import { hostname as hostname2 } from "node:os";
|
|
2101
|
+
import { URL as URL2 } from "node:url";
|
|
2102
|
+
function resolveDisplayName(name, email, userId) {
|
|
2103
|
+
if (name && !name.startsWith("user_")) return name;
|
|
2104
|
+
if (email) return email.split("@")[0];
|
|
2105
|
+
if (userId) return userId.slice(0, 12);
|
|
2106
|
+
if (name) return name.slice(0, 12);
|
|
2107
|
+
return "unknown";
|
|
2108
|
+
}
|
|
2109
|
+
async function openBrowser(url) {
|
|
2110
|
+
const escaped = url.replace(/"/g, '\\"');
|
|
2111
|
+
const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
|
|
2112
|
+
return new Promise((resolve6) => {
|
|
2113
|
+
exec(cmd, () => resolve6());
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
|
|
2117
|
+
function htmlPage(title, body) {
|
|
2118
|
+
return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
|
|
2119
|
+
}
|
|
2120
|
+
function startLocalhostServer(options) {
|
|
2121
|
+
const timeout = options.timeout ?? 12e4;
|
|
2122
|
+
const loginCommand = options.loginCommand ?? "coop login";
|
|
2123
|
+
return new Promise((resolveServer, rejectServer) => {
|
|
2124
|
+
let resolveCallback;
|
|
2125
|
+
let rejectCallback;
|
|
2126
|
+
const callbackPromise = new Promise((res, rej) => {
|
|
2127
|
+
resolveCallback = res;
|
|
2128
|
+
rejectCallback = rej;
|
|
2129
|
+
});
|
|
2130
|
+
const timer = setTimeout(() => {
|
|
2131
|
+
rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
|
|
2132
|
+
server.close();
|
|
2133
|
+
}, timeout);
|
|
2134
|
+
const server = createServer((req, res) => {
|
|
2135
|
+
const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
|
|
2136
|
+
if (url.pathname === "/") {
|
|
2137
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2138
|
+
res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (url.pathname === "/callback") {
|
|
2142
|
+
const state = url.searchParams.get("state") ?? "";
|
|
2143
|
+
if (state !== options.expectedState) {
|
|
2144
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2145
|
+
res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
const key = url.searchParams.get("key") ?? "";
|
|
2149
|
+
if (!key) {
|
|
2150
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2151
|
+
res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2155
|
+
res.end(htmlPage("Authentication Complete", "You can close this tab."));
|
|
2156
|
+
clearTimeout(timer);
|
|
2157
|
+
resolveCallback({
|
|
2158
|
+
key,
|
|
2159
|
+
userId: url.searchParams.get("userId") ?? "",
|
|
2160
|
+
email: url.searchParams.get("email") ?? "",
|
|
2161
|
+
displayName: url.searchParams.get("displayName") ?? "",
|
|
2162
|
+
state,
|
|
2163
|
+
apiUrl: url.searchParams.get("apiUrl") ?? ""
|
|
2164
|
+
});
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
2168
|
+
res.end("Not Found");
|
|
2169
|
+
});
|
|
2170
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2171
|
+
const addr = server.address();
|
|
2172
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
2173
|
+
resolveServer({
|
|
2174
|
+
port,
|
|
2175
|
+
close: () => {
|
|
2176
|
+
clearTimeout(timer);
|
|
2177
|
+
server.close();
|
|
2178
|
+
},
|
|
2179
|
+
waitForCallback: () => callbackPromise
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
server.on("error", (err) => {
|
|
2183
|
+
clearTimeout(timer);
|
|
2184
|
+
rejectServer(err);
|
|
2185
|
+
});
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
function deriveApiUrl(webUrl) {
|
|
2189
|
+
const url = new URL2(webUrl);
|
|
2190
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
2191
|
+
return "http://localhost:8788";
|
|
2192
|
+
}
|
|
2193
|
+
return `https://api.${url.hostname}`;
|
|
2194
|
+
}
|
|
2195
|
+
async function login(input, deps) {
|
|
2196
|
+
const state = randomBytes(16).toString("hex");
|
|
2197
|
+
const machineId = input.machine ?? hostname2();
|
|
2198
|
+
const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
|
|
2199
|
+
try {
|
|
2200
|
+
const webUrl = input.webUrl ?? "https://coopcli.com";
|
|
2201
|
+
const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
|
|
2202
|
+
process.stderr.write(`Opening browser for authentication...
|
|
2203
|
+
`);
|
|
2204
|
+
await deps.openBrowser(authUrl);
|
|
2205
|
+
process.stderr.write(`Waiting for callback on port ${server.port}...
|
|
2206
|
+
`);
|
|
2207
|
+
const params = await server.waitForCallback();
|
|
2208
|
+
const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
|
|
2209
|
+
const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
|
|
2210
|
+
const isFirstLogin = !await deps.config.hasAnyAuth();
|
|
2211
|
+
await deps.config.ensureActiveProfile(resolvedApiUrl);
|
|
2212
|
+
await deps.config.setAuth({
|
|
2213
|
+
key: params.key,
|
|
2214
|
+
userId: params.userId,
|
|
2215
|
+
displayName,
|
|
2216
|
+
email: params.email || null,
|
|
2217
|
+
machineId,
|
|
2218
|
+
authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2219
|
+
});
|
|
2220
|
+
const config = await deps.config.load();
|
|
2221
|
+
if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
|
|
2222
|
+
process.stderr.write(
|
|
2223
|
+
`
|
|
2224
|
+
Backend mismatch: you authenticated against ${params.apiUrl}
|
|
2225
|
+
but your config points to ${config.apiUrl}
|
|
2226
|
+
Updating config to ${resolvedApiUrl}
|
|
2227
|
+
|
|
2228
|
+
`
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
if (resolvedApiUrl !== config.apiUrl) {
|
|
2232
|
+
await deps.config.setApiUrl(resolvedApiUrl);
|
|
2233
|
+
}
|
|
2234
|
+
if (isFirstLogin) {
|
|
2235
|
+
await deps.config.setDefaultProfile(config.profileName);
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
userId: params.userId,
|
|
2239
|
+
email: params.email || null,
|
|
2240
|
+
displayName
|
|
2241
|
+
};
|
|
2242
|
+
} finally {
|
|
2243
|
+
server.close();
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// server/login.ts
|
|
2248
|
+
var LOGIN_USAGE = `Usage: specplan login [--profile <name>] [--web-url <url>]
|
|
2249
|
+
|
|
2250
|
+
Opens your browser to sign in to CoopCLI and saves credentials to
|
|
2251
|
+
~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
|
|
2252
|
+
|
|
2253
|
+
--profile config profile to log into (default: SPECPLAN_COOP_PROFILE,
|
|
2254
|
+
then COOP_PROFILE, then the config's default profile). A profile
|
|
2255
|
+
that doesn't exist yet is created.
|
|
2256
|
+
--web-url web app URL for browser auth (default: https://coopcli.com)`;
|
|
2257
|
+
var LoginUsageError = class extends Error {
|
|
2258
|
+
};
|
|
2259
|
+
function parseLoginArgs(argv) {
|
|
2260
|
+
let parsed;
|
|
2261
|
+
try {
|
|
2262
|
+
parsed = parseArgs({
|
|
2263
|
+
args: argv,
|
|
2264
|
+
options: { profile: { type: "string" }, "web-url": { type: "string" } },
|
|
2265
|
+
allowPositionals: true
|
|
2266
|
+
});
|
|
2267
|
+
} catch (err) {
|
|
2268
|
+
throw new LoginUsageError(err instanceof Error ? err.message : String(err));
|
|
2269
|
+
}
|
|
2270
|
+
if (parsed.positionals.length > 0) {
|
|
2271
|
+
throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
|
|
2272
|
+
}
|
|
2273
|
+
return {
|
|
2274
|
+
profile: parsed.values.profile ?? null,
|
|
2275
|
+
...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
async function runLogin(args, deps = {}) {
|
|
2279
|
+
const env = deps.env ?? process.env;
|
|
2280
|
+
const config = deps.config ?? new ConfigStore();
|
|
2281
|
+
const profile = args.profile ?? profileFromEnv(env, ["SPECPLAN_COOP_PROFILE", "COOP_PROFILE"]);
|
|
2282
|
+
if (profile) config.selectProfile(profile);
|
|
2283
|
+
const result = await login(
|
|
2284
|
+
{ webUrl: args.webUrl, loginCommand: "specplan login" },
|
|
2285
|
+
{ config, openBrowser: deps.openBrowser ?? openBrowser }
|
|
2286
|
+
);
|
|
2287
|
+
(deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// server/validate.ts
|
|
2291
|
+
function collectTreeErrors(rootDir) {
|
|
2292
|
+
const store = new PlanStore(rootDir);
|
|
2293
|
+
let plan;
|
|
2294
|
+
try {
|
|
2295
|
+
plan = store.load();
|
|
2296
|
+
} catch (err) {
|
|
2297
|
+
return [err instanceof Error ? err.message : String(err)];
|
|
2298
|
+
}
|
|
2299
|
+
const errors = planErrors(plan);
|
|
2300
|
+
const nodeIds = new Set(plan.nodes.map((n) => n.id));
|
|
2301
|
+
for (const node of plan.nodes) {
|
|
2302
|
+
if (node.type !== "spec") continue;
|
|
2303
|
+
let meta;
|
|
2304
|
+
try {
|
|
2305
|
+
meta = store.readSpecMeta(node.id);
|
|
2306
|
+
} catch (err) {
|
|
2307
|
+
errors.push(err instanceof PlanFileError ? err.message : String(err));
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
if (meta === null) {
|
|
2311
|
+
errors.push(`${node.id}: per-spec file missing (${node.specFile})`);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
if (meta.specId !== node.id) {
|
|
2315
|
+
errors.push(
|
|
2316
|
+
`changes/${node.id}/specplan.yaml: specId "${meta.specId}" does not match its change directory "${node.id}"`
|
|
2317
|
+
);
|
|
2318
|
+
}
|
|
2319
|
+
for (const ref of [...meta.userStories, ...meta.dependencies]) {
|
|
2320
|
+
if (!nodeIds.has(ref)) {
|
|
2321
|
+
errors.push(`changes/${node.id}/specplan.yaml: references unknown node ${ref}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
return errors;
|
|
2326
|
+
}
|
|
2327
|
+
function validateCommand(rootDir, log = console.log, logError = console.error) {
|
|
2328
|
+
const errors = collectTreeErrors(rootDir);
|
|
2329
|
+
if (errors.length > 0) {
|
|
2330
|
+
for (const e of errors) logError(`\u2717 ${e}`);
|
|
2331
|
+
logError(`specplan: plan is invalid \u2014 ${errors.length} error${errors.length === 1 ? "" : "s"}`);
|
|
2332
|
+
return 1;
|
|
2333
|
+
}
|
|
2334
|
+
const plan = new PlanStore(rootDir).load();
|
|
2335
|
+
log(`plan is valid \u2014 ${plan.nodes.length} nodes, ${plan.edges.length} edges`);
|
|
2336
|
+
return 0;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// server/workspace.ts
|
|
2340
|
+
import { join as join5, resolve as resolve4 } from "node:path";
|
|
2341
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
2342
|
+
var SIDECAR_FILE = "specplan-ui.json";
|
|
2343
|
+
var PlanWorkspace = class {
|
|
2344
|
+
/** Resolved openspec root directory. */
|
|
2345
|
+
dir;
|
|
2346
|
+
constructor(dir) {
|
|
2347
|
+
this.dir = resolve4(dir);
|
|
2348
|
+
mkdirSync3(this.dir, { recursive: true });
|
|
2349
|
+
}
|
|
2350
|
+
// ── Sidecar ──────────────────────────────────────────────────────────────
|
|
2351
|
+
readSidecar() {
|
|
2352
|
+
const raw = readIfExists(join5(this.dir, SIDECAR_FILE));
|
|
2353
|
+
if (!raw) return {};
|
|
2354
|
+
try {
|
|
2355
|
+
return JSON.parse(raw);
|
|
2356
|
+
} catch {
|
|
2357
|
+
return {};
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
updateSidecar(update) {
|
|
2361
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2362
|
+
const current = this.readSidecar();
|
|
2363
|
+
const next = update(current);
|
|
2364
|
+
next.createdAt ??= now;
|
|
2365
|
+
next.updatedAt = now;
|
|
2366
|
+
writeAtomicFile(join5(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
|
|
2367
|
+
}
|
|
2368
|
+
readModel() {
|
|
2369
|
+
return this.readSidecar().model;
|
|
2370
|
+
}
|
|
2371
|
+
readPositions() {
|
|
2372
|
+
return this.readSidecar().positions ?? {};
|
|
2373
|
+
}
|
|
2374
|
+
/** One sidecar write for the debounced UI autosave (model + positions). */
|
|
2375
|
+
writeUiState(state) {
|
|
2376
|
+
this.updateSidecar((s) => ({
|
|
2377
|
+
...s,
|
|
2378
|
+
...state.model !== void 0 ? { model: state.model } : {},
|
|
2379
|
+
...state.positions !== void 0 ? { positions: state.positions } : {}
|
|
2380
|
+
}));
|
|
2381
|
+
}
|
|
2382
|
+
// ── Assistant chat ───────────────────────────────────────────────────────
|
|
2383
|
+
readChat() {
|
|
2384
|
+
return this.readSidecar().chat ?? [];
|
|
2385
|
+
}
|
|
2386
|
+
writeChat(chat) {
|
|
2387
|
+
this.updateSidecar((s) => ({ ...s, chat }));
|
|
2388
|
+
}
|
|
2389
|
+
// ── Generation system prompt override ────────────────────────────────────
|
|
2390
|
+
readSystemPrompt() {
|
|
2391
|
+
return this.readSidecar().systemPrompt;
|
|
2392
|
+
}
|
|
2393
|
+
/** `undefined` clears the override (reset to the built-in default). */
|
|
2394
|
+
writeSystemPrompt(systemPrompt) {
|
|
2395
|
+
this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
|
|
2396
|
+
...s,
|
|
2397
|
+
...systemPrompt !== void 0 ? { systemPrompt } : {}
|
|
2398
|
+
}));
|
|
2399
|
+
}
|
|
2400
|
+
};
|
|
2401
|
+
|
|
2402
|
+
// server/cli.ts
|
|
2403
|
+
var USAGE = `Usage: specplan [rootDir] [--port <n>]
|
|
2404
|
+
specplan generate <specId> [rootDir]
|
|
2405
|
+
specplan validate [rootDir]
|
|
2406
|
+
specplan login [--profile <name>] [--web-url <url>]
|
|
2407
|
+
|
|
2408
|
+
specplan # plan ./openspec in the browser
|
|
2409
|
+
specplan ../other-repo/openspec --port 9000
|
|
2410
|
+
|
|
2411
|
+
rootDir is the OpenSpec root to plan (default ./openspec, created if
|
|
2412
|
+
missing). The plan autosaves to specplan.yaml inside it, plus one
|
|
2413
|
+
specplan.yaml per openspec/changes/<specId>/ directory.
|
|
2414
|
+
|
|
2415
|
+
--port sets the preferred port (default 8789); if it's already in use the
|
|
2416
|
+
server moves to the next available port and prints the actual URL.
|
|
2417
|
+
|
|
2418
|
+
generate <specId> runs OpenSpec generation for one spec card headlessly;
|
|
2419
|
+
exits non-zero when the output fails \`openspec validate --strict\`.
|
|
2420
|
+
|
|
2421
|
+
validate checks the plan files (schema, acyclicity, containment, story
|
|
2422
|
+
formula, id references) and exits non-zero with named errors.
|
|
2423
|
+
|
|
2424
|
+
login authenticates this machine against your CoopCLI account (opens a
|
|
2425
|
+
browser, saves credentials to ~/.coopcli/config.json).`;
|
|
2426
|
+
var UsageError = class extends Error {
|
|
2427
|
+
};
|
|
2428
|
+
var DEFAULT_ROOT_DIR = "./openspec";
|
|
2429
|
+
var DEFAULT_PORT = 8789;
|
|
2430
|
+
function parseCliArgs(argv) {
|
|
2431
|
+
let parsed;
|
|
2432
|
+
try {
|
|
2433
|
+
parsed = parseArgs2({
|
|
2434
|
+
args: argv,
|
|
2435
|
+
options: { port: { type: "string" } },
|
|
2436
|
+
allowPositionals: true
|
|
2437
|
+
});
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
2440
|
+
}
|
|
2441
|
+
const [rootDir = DEFAULT_ROOT_DIR, ...rest] = parsed.positionals;
|
|
2442
|
+
if (rest.length > 0) throw new UsageError("expected at most one root directory");
|
|
2443
|
+
const port = parsed.values.port === void 0 ? DEFAULT_PORT : Number(parsed.values.port);
|
|
2444
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2445
|
+
throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
|
|
2446
|
+
}
|
|
2447
|
+
return { rootDir, port };
|
|
2448
|
+
}
|
|
2449
|
+
var PORT_SCAN_LIMIT = 100;
|
|
2450
|
+
async function bindAvailablePort(start, tryBind) {
|
|
2451
|
+
const end = Math.min(start + PORT_SCAN_LIMIT - 1, 65535);
|
|
2452
|
+
for (let port = start; port <= end; port++) {
|
|
2453
|
+
try {
|
|
2454
|
+
return { port: await tryBind(port), moved: port !== start };
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
if (err.code !== "EADDRINUSE") throw err;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
throw new Error(`no free port between ${start} and ${end} \u2014 free one up or pass --port`);
|
|
2460
|
+
}
|
|
2461
|
+
var MIME = {
|
|
2462
|
+
".html": "text/html; charset=utf-8",
|
|
2463
|
+
".js": "text/javascript; charset=utf-8",
|
|
2464
|
+
".css": "text/css; charset=utf-8",
|
|
2465
|
+
".svg": "image/svg+xml",
|
|
2466
|
+
".png": "image/png",
|
|
2467
|
+
".ico": "image/x-icon",
|
|
2468
|
+
".json": "application/json",
|
|
2469
|
+
".map": "application/json",
|
|
2470
|
+
".woff2": "font/woff2",
|
|
2471
|
+
".woff": "font/woff"
|
|
2472
|
+
};
|
|
2473
|
+
function resolveClientDir(moduleUrl) {
|
|
2474
|
+
const here = dirname4(fileURLToPath(moduleUrl));
|
|
2475
|
+
const candidates = [
|
|
2476
|
+
join6(here, "..", "client"),
|
|
2477
|
+
// packed: dist/cli -> dist/client
|
|
2478
|
+
join6(here, "..", "dist", "client")
|
|
2479
|
+
// repo: server/ -> dist/client
|
|
2480
|
+
];
|
|
2481
|
+
for (const dir of candidates) {
|
|
2482
|
+
if (existsSync4(join6(dir, "index.html"))) return dir;
|
|
2483
|
+
}
|
|
2484
|
+
return null;
|
|
2485
|
+
}
|
|
2486
|
+
function staticResponse(clientDir, pathname) {
|
|
2487
|
+
const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
|
|
2488
|
+
const target = resolve5(clientDir, rel === "" ? "index.html" : rel);
|
|
2489
|
+
if (!target.startsWith(resolve5(clientDir))) return null;
|
|
2490
|
+
const file = existsSync4(target) && extname(target) ? target : join6(clientDir, "index.html");
|
|
2491
|
+
if (!existsSync4(file)) return null;
|
|
2492
|
+
return new Response(readFileSync4(file), {
|
|
2493
|
+
headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
var CREDENTIALS_HINT = `No Anthropic credentials detected. The canvas works without them,
|
|
2497
|
+
but chat and "Generate OpenSpec" need one of:
|
|
2498
|
+
|
|
2499
|
+
\u2022 ANTHROPIC_API_KEY set in your environment
|
|
2500
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
2501
|
+
\u2022 or the Anthropic CLI's login profile (no key handling needed):
|
|
2502
|
+
brew install anthropics/tap/ant # macOS
|
|
2503
|
+
ant auth login`;
|
|
2504
|
+
function detectCredentialSource(env = process.env, home = homedir2()) {
|
|
2505
|
+
if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
|
|
2506
|
+
if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
|
|
2507
|
+
try {
|
|
2508
|
+
const dir = join6(home, ".config", "anthropic", "credentials");
|
|
2509
|
+
if (readdirSync4(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
|
|
2510
|
+
} catch {
|
|
2511
|
+
}
|
|
2512
|
+
return null;
|
|
2513
|
+
}
|
|
2514
|
+
function preflight(log = console.log) {
|
|
2515
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
2516
|
+
if (major < 22) {
|
|
2517
|
+
log(`\u26A0 Node ${process.versions.node} detected \u2014 specplan needs Node >= 22.`);
|
|
2518
|
+
log(" Install a current Node (https://nodejs.org) and rerun.");
|
|
2519
|
+
process.exit(1);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
async function main(argv) {
|
|
2523
|
+
preflight();
|
|
2524
|
+
if (argv[0] === "login") {
|
|
2525
|
+
let loginArgs;
|
|
2526
|
+
try {
|
|
2527
|
+
loginArgs = parseLoginArgs(argv.slice(1));
|
|
2528
|
+
} catch (err) {
|
|
2529
|
+
console.error(
|
|
2530
|
+
err instanceof LoginUsageError ? `specplan: ${err.message}
|
|
2531
|
+
|
|
2532
|
+
${LOGIN_USAGE}` : err
|
|
2533
|
+
);
|
|
2534
|
+
process.exit(1);
|
|
2535
|
+
}
|
|
2536
|
+
try {
|
|
2537
|
+
await runLogin(loginArgs);
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
console.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
|
|
2540
|
+
process.exit(1);
|
|
2541
|
+
}
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (argv[0] === "validate") {
|
|
2545
|
+
const [rootDir = DEFAULT_ROOT_DIR, ...rest] = argv.slice(1);
|
|
2546
|
+
if (rest.length > 0 || rootDir.startsWith("-")) {
|
|
2547
|
+
console.error(`specplan: validate takes at most one root directory
|
|
2548
|
+
|
|
2549
|
+
${USAGE}`);
|
|
2550
|
+
process.exit(1);
|
|
2551
|
+
}
|
|
2552
|
+
process.exit(validateCommand(rootDir));
|
|
2553
|
+
}
|
|
2554
|
+
if (argv[0] === "generate") {
|
|
2555
|
+
const [specId, rootDir = DEFAULT_ROOT_DIR, ...rest] = argv.slice(1);
|
|
2556
|
+
if (!specId || specId.startsWith("-") || rest.length > 0) {
|
|
2557
|
+
console.error(`specplan: generate takes a spec id and at most one root directory
|
|
2558
|
+
|
|
2559
|
+
${USAGE}`);
|
|
2560
|
+
process.exit(1);
|
|
2561
|
+
}
|
|
2562
|
+
const workspace2 = new PlanWorkspace(rootDir);
|
|
2563
|
+
const store2 = new PlanStore(rootDir);
|
|
2564
|
+
const model = GENERATION_MODELS.find((m) => m === workspace2.readModel()) ?? DEFAULT_MODEL;
|
|
2565
|
+
try {
|
|
2566
|
+
const result = await generateForSpec(
|
|
2567
|
+
{
|
|
2568
|
+
anthropic: new Anthropic(),
|
|
2569
|
+
store: store2,
|
|
2570
|
+
systemPrompt: workspace2.readSystemPrompt(),
|
|
2571
|
+
refRoot: process.cwd()
|
|
2572
|
+
},
|
|
2573
|
+
specId,
|
|
2574
|
+
model
|
|
2575
|
+
);
|
|
2576
|
+
if (result.mode === "metadata-only") {
|
|
2577
|
+
console.log(
|
|
2578
|
+
`specplan: ${specId} body is hand-edited \u2014 updated metadata only (changes/${specId}/specplan.yaml)`
|
|
2579
|
+
);
|
|
2580
|
+
} else {
|
|
2581
|
+
console.log(
|
|
2582
|
+
`specplan: generated ${specId} (${result.changedFiles.length} file${result.changedFiles.length === 1 ? "" : "s"} changed) \u2014 openspec validate --strict passed`
|
|
2583
|
+
);
|
|
2584
|
+
for (const f of result.changedFiles) console.log(` ${f}`);
|
|
2585
|
+
}
|
|
2586
|
+
} catch (err) {
|
|
2587
|
+
console.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
|
|
2588
|
+
process.exit(1);
|
|
2589
|
+
}
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
let args;
|
|
2593
|
+
try {
|
|
2594
|
+
args = parseCliArgs(argv);
|
|
2595
|
+
} catch (err) {
|
|
2596
|
+
console.error(err instanceof UsageError ? `specplan: ${err.message}
|
|
2597
|
+
|
|
2598
|
+
${USAGE}` : err);
|
|
2599
|
+
process.exit(1);
|
|
2600
|
+
}
|
|
2601
|
+
const clientDir = resolveClientDir(import.meta.url);
|
|
2602
|
+
if (!clientDir) {
|
|
2603
|
+
console.error("specplan: built UI assets not found (dist/client missing).");
|
|
2604
|
+
process.exit(1);
|
|
2605
|
+
}
|
|
2606
|
+
const workspace = new PlanWorkspace(args.rootDir);
|
|
2607
|
+
const store = new PlanStore(args.rootDir);
|
|
2608
|
+
const app = createApp(new Anthropic(), workspace, store);
|
|
2609
|
+
app.get("*", (c) => {
|
|
2610
|
+
const res = staticResponse(clientDir, new URL(c.req.url).pathname);
|
|
2611
|
+
return res ?? c.notFound();
|
|
2612
|
+
});
|
|
2613
|
+
const tryBind = (port2) => new Promise((bound, failed) => {
|
|
2614
|
+
const server = serve({ fetch: app.fetch, port: port2, hostname: "127.0.0.1" }, (info) => {
|
|
2615
|
+
bound(info.port);
|
|
2616
|
+
});
|
|
2617
|
+
server.once("error", (err) => {
|
|
2618
|
+
try {
|
|
2619
|
+
server.close();
|
|
2620
|
+
} catch {
|
|
2621
|
+
}
|
|
2622
|
+
failed(err);
|
|
2623
|
+
});
|
|
2624
|
+
});
|
|
2625
|
+
let port;
|
|
2626
|
+
let moved;
|
|
2627
|
+
try {
|
|
2628
|
+
({ port, moved } = await bindAvailablePort(args.port, tryBind));
|
|
2629
|
+
} catch (err) {
|
|
2630
|
+
console.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
|
|
2631
|
+
process.exit(1);
|
|
2632
|
+
}
|
|
2633
|
+
const source = detectCredentialSource();
|
|
2634
|
+
console.log(`coopcli specplan`);
|
|
2635
|
+
console.log(` plan root: ${workspace.dir}`);
|
|
2636
|
+
console.log(` open: http://127.0.0.1:${port}`);
|
|
2637
|
+
if (moved) {
|
|
2638
|
+
console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
|
|
2639
|
+
}
|
|
2640
|
+
if (source) {
|
|
2641
|
+
console.log(` auth: ${source}`);
|
|
2642
|
+
} else {
|
|
2643
|
+
console.log(`
|
|
2644
|
+
\u26A0 ${CREDENTIALS_HINT}
|
|
2645
|
+
`);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// server/cli-entry.ts
|
|
2650
|
+
void main(process.argv.slice(2));
|