@coopcli/specsketch 5.5.6 → 5.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +863 -252
  2. package/package.json +77 -74
package/dist/cli/index.js CHANGED
@@ -1,18 +1,343 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3 } from "node:fs";
5
- import { homedir as homedir3 } from "node:os";
6
- import { dirname as dirname3, extname, join as join5, normalize as normalize2, resolve as resolve3 } from "node:path";
4
+ import { existsSync as existsSync6, readFileSync as readFileSync4, readdirSync as readdirSync5 } from "node:fs";
5
+ import { homedir as homedir4 } from "node:os";
6
+ import { dirname as dirname4, extname, join as join7, normalize as normalize2, resolve as resolve3 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs as parseArgs2 } from "node:util";
9
9
  import { serve } from "@hono/node-server";
10
10
  import Anthropic from "@anthropic-ai/sdk";
11
+
12
+ // ../agent-harness/src/model-auth.ts
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ var ModelAuthError = class extends Error {
17
+ };
18
+ var API_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
19
+ function hasAntAuthProfile(home) {
20
+ try {
21
+ return readdirSync(join(home, ".config", "anthropic", "credentials")).some(
22
+ (f) => f.endsWith(".json")
23
+ );
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ function resolveModelCredential(opts = {}) {
29
+ const env = opts.env ?? process.env;
30
+ const home = opts.home ?? homedir();
31
+ const useApi = Boolean(opts.useApi) || Boolean(env.COOP_USE_API);
32
+ const hasApiKey = Boolean(env.ANTHROPIC_API_KEY);
33
+ const hasAntAuth = Boolean(env.ANTHROPIC_AUTH_TOKEN) || hasAntAuthProfile(home);
34
+ const hasSubscription = existsSync(join(home, ".claude", ".credentials.json"));
35
+ if (useApi) {
36
+ if (hasApiKey || hasAntAuth) return apiResult(env, hasApiKey ? "api-key" : "ant-auth");
37
+ throw new ModelAuthError(
38
+ "--api / COOP_USE_API is set but no API credential was found. Set ANTHROPIC_API_KEY=sk-ant-\u2026 or run `ant auth login`."
39
+ );
40
+ }
41
+ if (hasSubscription) {
42
+ const subprocessEnv = { ...env };
43
+ for (const k of API_ENV_KEYS) delete subprocessEnv[k];
44
+ return {
45
+ path: "subscription",
46
+ billsApiCredits: false,
47
+ notice: "Model auth: Max/Pro subscription login \u2014 billing your subscription.",
48
+ subprocessEnv
49
+ };
50
+ }
51
+ if (hasApiKey) return apiResult(env, "api-key");
52
+ if (hasAntAuth) return apiResult(env, "ant-auth");
53
+ throw new ModelAuthError(
54
+ "No Anthropic model credential found. Either:\n \u2022 log in your own Claude Code for subscription billing: claude login\n \u2022 or use API credits: export ANTHROPIC_API_KEY=sk-ant-\u2026 (or: ant auth login)"
55
+ );
56
+ }
57
+ function apiResult(env, path) {
58
+ return {
59
+ path,
60
+ billsApiCredits: true,
61
+ notice: `Model auth: ${path === "api-key" ? "ANTHROPIC_API_KEY" : "ant auth OAuth token"} \u2014 billing API credits, NOT your Max subscription.`,
62
+ subprocessEnv: { ...env }
63
+ };
64
+ }
65
+
66
+ // ../agent-harness/src/claude.ts
67
+ function makeGate(profile) {
68
+ return async (toolName, input) => {
69
+ if (!profile.allowedTools.includes(toolName)) {
70
+ return {
71
+ behavior: "deny",
72
+ message: `Tool "${toolName}" is not in the headless allowlist for agent "${profile.identity}".`
73
+ };
74
+ }
75
+ if (toolName === "Bash") {
76
+ const command = typeof input["command"] === "string" ? input["command"] : "";
77
+ for (const pattern of profile.blockedBashPatterns) {
78
+ if (pattern.test(command)) {
79
+ return {
80
+ behavior: "deny",
81
+ message: `Bash command blocked by headless profile pattern ${String(pattern)} for agent "${profile.identity}".`
82
+ };
83
+ }
84
+ }
85
+ }
86
+ if (profile.gatedTools.includes(toolName) && !profile.allowGated) {
87
+ return {
88
+ behavior: "deny",
89
+ message: `Tool "${toolName}" is a gated side-effect tool and this profile does not allow gated tools (allowGated: false).`
90
+ };
91
+ }
92
+ return { behavior: "allow", updatedInput: input };
93
+ };
94
+ }
95
+ function destructiveBashHook(profile) {
96
+ return {
97
+ matcher: "Bash",
98
+ hooks: [
99
+ async (input) => {
100
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
101
+ const ti = input.tool_input;
102
+ const command = typeof ti === "object" && ti !== null ? String(ti.command ?? "") : "";
103
+ for (const pattern of profile.blockedBashPatterns) {
104
+ if (pattern.test(command)) {
105
+ return {
106
+ continue: true,
107
+ hookSpecificOutput: {
108
+ hookEventName: "PreToolUse",
109
+ permissionDecision: "deny",
110
+ permissionDecisionReason: `Destructive command blocked by headless profile (${String(pattern)}) for agent "${profile.identity}".`
111
+ }
112
+ };
113
+ }
114
+ }
115
+ return { continue: true };
116
+ }
117
+ ]
118
+ };
119
+ }
120
+ function* mapMessage(message, expectStructuredOutput) {
121
+ switch (message.type) {
122
+ case "system": {
123
+ if (message.subtype === "init") {
124
+ yield { type: "start", sessionId: message.session_id };
125
+ }
126
+ return;
127
+ }
128
+ case "assistant": {
129
+ const assistant = message;
130
+ for (const block of assistant.message.content) {
131
+ if (block.type === "text") {
132
+ yield { type: "text", text: block.text };
133
+ } else if (block.type === "thinking") {
134
+ yield { type: "thinking", text: block.thinking };
135
+ } else if (block.type === "tool_use") {
136
+ yield { type: "tool_use", id: block.id, name: block.name, input: block.input };
137
+ }
138
+ }
139
+ return;
140
+ }
141
+ case "user": {
142
+ const user = message;
143
+ const content = user.message.content;
144
+ if (typeof content === "string") return;
145
+ for (const block of content) {
146
+ if (block.type === "tool_result") {
147
+ yield {
148
+ type: "tool_result",
149
+ id: block.tool_use_id,
150
+ isError: block.is_error ?? false,
151
+ content: block.content
152
+ };
153
+ }
154
+ }
155
+ return;
156
+ }
157
+ case "result": {
158
+ if (message.subtype === "success") {
159
+ const missingStructured = expectStructuredOutput && message.structured_output === void 0;
160
+ yield {
161
+ type: "result",
162
+ text: message.result,
163
+ costUsd: message.total_cost_usd,
164
+ isError: message.is_error || missingStructured,
165
+ ...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
166
+ };
167
+ } else {
168
+ yield {
169
+ type: "result",
170
+ text: message.errors.join("\n"),
171
+ costUsd: message.total_cost_usd,
172
+ isError: true
173
+ };
174
+ }
175
+ return;
176
+ }
177
+ default:
178
+ return;
179
+ }
180
+ }
181
+ function buildOptions(input, resolved) {
182
+ const profile = input.profile;
183
+ const options = {
184
+ cwd: input.cwd,
185
+ // CRITICAL: an EXPLICIT settingSources: [] loads NOTHING. (The SDK's own
186
+ // default — when the option is omitted — loads ALL sources; we always
187
+ // pass the profile's list so inheritance is an explicit choice, and the
188
+ // profile's sources are what yield LifeOS inheritance.)
189
+ settingSources: profile.settingSources,
190
+ // Never 'bypassPermissions' / 'acceptEdits' — PermissionProfile's type
191
+ // only admits 'default' | 'plan' | 'dontAsk'.
192
+ permissionMode: profile.permissionMode,
193
+ allowedTools: profile.allowedTools,
194
+ canUseTool: makeGate(profile),
195
+ // Containment: destructive-Bash denial must survive a bare allowlisted
196
+ // `Bash` (allow rules short-circuit canUseTool), so it is enforced by a
197
+ // PreToolUse hook, which the SDK evaluates before allow rules.
198
+ hooks: { PreToolUse: [destructiveBashHook(profile)] },
199
+ // Options.env REPLACES the subprocess environment (no merge). The
200
+ // resolver hands back the FULL inherited env (HOME/PATH preserved) with
201
+ // ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN stripped on the subscription
202
+ // path, so the subprocess cannot prefer an API key over the login.
203
+ env: resolved.subprocessEnv
204
+ };
205
+ if (profile.disallowedTools) {
206
+ options.disallowedTools = profile.disallowedTools;
207
+ }
208
+ if (profile.mcpServers) {
209
+ options.mcpServers = profile.mcpServers;
210
+ }
211
+ if (profile.strictMcp !== void 0) {
212
+ options.strictMcpConfig = profile.strictMcp;
213
+ }
214
+ if (input.outputSchema) {
215
+ options.outputFormat = { type: "json_schema", schema: input.outputSchema };
216
+ }
217
+ return options;
218
+ }
219
+ function verifyBilling(resolved, apiKeySource, notify) {
220
+ if (apiKeySource === void 0) return;
221
+ const billedViaApiKey = apiKeySource !== "none";
222
+ if (resolved.path === "subscription" && billedViaApiKey) {
223
+ notify(
224
+ `Warning: the subscription path was selected but the run authenticated from an API-key source ('${apiKeySource}') \u2014 this run bills API credits, not your subscription.`
225
+ );
226
+ } else if (resolved.path !== "subscription" && !billedViaApiKey) {
227
+ notify(
228
+ `Warning: the ${resolved.path} (API credits) path was selected but the run authenticated as first-party (no API key) \u2014 this run bills the subscription.`
229
+ );
230
+ }
231
+ }
232
+ var SDK_UNAVAILABLE_MESSAGE = "Claude Agent SDK not available \u2014 reinstall the CLI (do not use --omit=optional). Global install is required for the agent path; pnpm dlx/npx re-download the ~268 MB native binary.";
233
+ function isSdkAbsence(error) {
234
+ const code = error?.code;
235
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
236
+ const message = error instanceof Error ? error.message : String(error);
237
+ return /Native CLI binary.*not found/i.test(message);
238
+ }
239
+ async function loadSdkQuery(loader) {
240
+ try {
241
+ const mod = await (loader ?? (() => import("@anthropic-ai/claude-agent-sdk")))();
242
+ return mod.query;
243
+ } catch (error) {
244
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
245
+ throw error;
246
+ }
247
+ }
248
+ function createClaudeHarness(deps) {
249
+ return {
250
+ id: "claude",
251
+ async *run(input) {
252
+ const resolved = resolveModelCredential({
253
+ env: deps?.env,
254
+ home: deps?.home,
255
+ useApi: input.useApi
256
+ });
257
+ const notify = input.onNotice ?? ((message) => console.error(message));
258
+ notify(resolved.notice);
259
+ const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
260
+ const expectStructuredOutput = input.outputSchema !== void 0;
261
+ try {
262
+ const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
263
+ for await (const message of stream) {
264
+ if (message.type === "system" && message.subtype === "init") {
265
+ verifyBilling(resolved, message.apiKeySource, notify);
266
+ }
267
+ yield* mapMessage(message, expectStructuredOutput);
268
+ }
269
+ } catch (error) {
270
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
271
+ throw error;
272
+ }
273
+ }
274
+ };
275
+ }
276
+
277
+ // ../agent-harness/src/profiles.ts
278
+ var SIDE_EFFECT_GATED_TOOLS = ["SendMessage", "Send", "Deploy", "Publish"];
279
+ var BLOCKED_BASH_PATTERNS = [
280
+ /\bgit\s+push\b/,
281
+ /\bwrangler\s+deploy\b/,
282
+ /\brm\s+-rf\b/,
283
+ /\bnpm\s+publish\b/
284
+ ];
285
+ function blockedBashPatterns() {
286
+ return [...BLOCKED_BASH_PATTERNS];
287
+ }
288
+ function defaultHeadlessProfile(identity) {
289
+ return {
290
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch"],
291
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
292
+ allowGated: false,
293
+ blockedBashPatterns: blockedBashPatterns(),
294
+ permissionMode: "default",
295
+ settingSources: [],
296
+ identity
297
+ };
298
+ }
299
+ function lifeOSProfile(identity, mcpServers) {
300
+ const profile = {
301
+ allowedTools: [
302
+ "Read",
303
+ "Grep",
304
+ "Glob",
305
+ "WebFetch",
306
+ "WebSearch",
307
+ "Write",
308
+ "Edit",
309
+ "NotebookEdit",
310
+ // NOTE: a bare 'Bash' allow rule auto-approves the tool and
311
+ // short-circuits canUseTool — blockedBashPatterns stay enforced only
312
+ // because the harness installs them as a PreToolUse hook (claude.ts
313
+ // destructiveBashHook), which the SDK evaluates before allow rules.
314
+ "Bash",
315
+ "Agent",
316
+ "Task",
317
+ "Skill",
318
+ "TodoWrite"
319
+ ],
320
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
321
+ allowGated: false,
322
+ blockedBashPatterns: blockedBashPatterns(),
323
+ permissionMode: "default",
324
+ settingSources: ["user", "project", "local"],
325
+ identity
326
+ };
327
+ if (mcpServers) {
328
+ profile.mcpServers = mcpServers;
329
+ profile.strictMcp = true;
330
+ }
331
+ return profile;
332
+ }
333
+
334
+ // server/cli.ts
11
335
  import { WebSocketServer } from "ws";
12
336
 
13
337
  // server/app.ts
14
338
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
15
339
  import { Hono } from "hono";
340
+ import { z as z2 } from "zod";
16
341
 
17
342
  // src/lib/schema.ts
18
343
  import { z } from "zod";
@@ -137,8 +462,8 @@ var SessionSnapshot = z.object({
137
462
  });
138
463
 
139
464
  // server/references.ts
140
- import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
141
- import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
465
+ import { readFileSync, readdirSync as readdirSync2, realpathSync, statSync } from "node:fs";
466
+ import { basename, isAbsolute, join as join2, normalize, relative, resolve, sep } from "node:path";
142
467
  var REF_FILE_BYTE_LIMIT = 64 * 1024;
143
468
  var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
144
469
  var REF_DIR_FILE_LIMIT = 50;
@@ -164,14 +489,14 @@ function collectEntries(root) {
164
489
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
165
490
  let entries;
166
491
  try {
167
- entries = readdirSync(dir, { withFileTypes: true });
492
+ entries = readdirSync2(dir, { withFileTypes: true });
168
493
  } catch {
169
494
  return;
170
495
  }
171
496
  for (const entry of entries) {
172
497
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
173
498
  if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
174
- const full = join(dir, entry.name);
499
+ const full = join2(dir, entry.name);
175
500
  if (entry.isDirectory()) {
176
501
  dirs.push(relative(root, full));
177
502
  walk(full);
@@ -258,7 +583,7 @@ function expandReferences(text, root) {
258
583
  if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
259
584
  let body;
260
585
  try {
261
- body = readClamped(join(rootReal, relPath));
586
+ body = readClamped(join2(rootReal, relPath));
262
587
  } catch {
263
588
  continue;
264
589
  }
@@ -305,7 +630,7 @@ function resolveOne(rootReal, ref) {
305
630
  if (real !== rootReal && !insideRoot(rootReal, real)) {
306
631
  return { reason: "resolves outside the working directory" };
307
632
  }
308
- const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
633
+ const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join2(direct, p))).sort((a, b) => {
309
634
  const depth = a.split(sep).length - b.split(sep).length;
310
635
  return depth !== 0 ? depth : a.localeCompare(b);
311
636
  });
@@ -331,7 +656,7 @@ function resolveOne(rootReal, ref) {
331
656
  if (matches.length === 0) return { reason: "not found" };
332
657
  const best = matches[0];
333
658
  try {
334
- const body = readClamped(join(rootReal, best));
659
+ const body = readClamped(join2(rootReal, best));
335
660
  return {
336
661
  path: best,
337
662
  ...body,
@@ -513,6 +838,7 @@ function bundleFilePaths(b) {
513
838
  ...b.specs.map((s) => `specs/${s.capability}/spec.md`)
514
839
  ];
515
840
  }
841
+ var SPEC_BUNDLE_JSON_SCHEMA = z2.toJSONSchema(SpecBundle, { target: "draft-7" });
516
842
  var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
517
843
  var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
518
844
  function parseWithModelGate(anthropic, request) {
@@ -546,6 +872,9 @@ function anthropicErrorResponse(c, err, kind) {
546
872
  }
547
873
  function createApp(anthropic, workspace, getShare, options) {
548
874
  const refRoot = options === void 0 ? process.cwd() : options.refRoot;
875
+ const harness = options?.harness;
876
+ const reviewProfile = lifeOSProfile("specsketch-review");
877
+ const generateProfile = defaultHeadlessProfile("specsketch-generate");
549
878
  const app = new Hono();
550
879
  app.get("/api/health", (c) => c.json({ ok: true }));
551
880
  app.get(
@@ -624,25 +953,65 @@ Produce the OpenSpec change proposal for this architecture as structured output.
624
953
  Referenced files (resolved from @-references in the input):
625
954
 
626
955
  ${refContext}` : "");
627
- let msg;
628
- try {
629
- msg = await parseWithModelGate(anthropic, {
630
- model,
631
- max_tokens: 16e3,
632
- // non-streaming stays under the SDK HTTP timeout
633
- system,
634
- messages: [{ role: "user", content: prompt }],
635
- output_config: { format: zodOutputFormat(SpecBundle) }
636
- // No temperature/top_p or thinking config — both 400 on modern models.
637
- });
638
- } catch (err) {
639
- return anthropicErrorResponse(c, err, "generation-failed");
640
- }
641
- if (msg.stop_reason === "refusal") {
642
- return c.json({ error: "refusal", detail: msg.stop_details }, 422);
956
+ let output;
957
+ if (harness) {
958
+ let structured;
959
+ let resultText = "";
960
+ let resultError = false;
961
+ try {
962
+ for await (const ev of harness.run({
963
+ prompt: `${system}
964
+
965
+ ${prompt}`,
966
+ cwd: refRoot ?? process.cwd(),
967
+ session: workspace?.changeName ?? "specsketch-generate",
968
+ profile: generateProfile,
969
+ outputSchema: SPEC_BUNDLE_JSON_SCHEMA
970
+ })) {
971
+ if (ev.type === "result") {
972
+ structured = ev.structuredOutput;
973
+ resultText = ev.text;
974
+ resultError = ev.isError;
975
+ }
976
+ }
977
+ } catch (err) {
978
+ return anthropicErrorResponse(c, err, "generation-failed");
979
+ }
980
+ if (structured === void 0) {
981
+ return resultError ? c.json(
982
+ { error: "generation-failed", detail: resultText || "the agent run failed" },
983
+ 502
984
+ ) : c.json({ error: "model returned no structured output" }, 502);
985
+ }
986
+ const checked = SpecBundle.safeParse(structured);
987
+ if (!checked.success) {
988
+ return c.json(
989
+ { error: "model returned invalid structured output", detail: checked.error.issues },
990
+ 502
991
+ );
992
+ }
993
+ output = checked.data;
994
+ } else {
995
+ let msg;
996
+ try {
997
+ msg = await parseWithModelGate(anthropic, {
998
+ model,
999
+ max_tokens: 16e3,
1000
+ // non-streaming stays under the SDK HTTP timeout
1001
+ system,
1002
+ messages: [{ role: "user", content: prompt }],
1003
+ output_config: { format: zodOutputFormat(SpecBundle) }
1004
+ // No temperature/top_p or thinking config — both 400 on modern models.
1005
+ });
1006
+ } catch (err) {
1007
+ return anthropicErrorResponse(c, err, "generation-failed");
1008
+ }
1009
+ if (msg.stop_reason === "refusal") {
1010
+ return c.json({ error: "refusal", detail: msg.stop_details }, 422);
1011
+ }
1012
+ if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
1013
+ output = SpecBundle.parse(msg.parsed_output);
643
1014
  }
644
- if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
645
- const output = SpecBundle.parse(msg.parsed_output);
646
1015
  const bundle = workspace ? { ...output, changeName: workspace.changeName } : output;
647
1016
  const changedFiles = workspace ? workspace.writeBundle(bundle) : bundleFilePaths(bundle);
648
1017
  const seed = await makeSeed(anthropic, model, conversation, changedFiles, bundle);
@@ -654,43 +1023,56 @@ ${refContext}` : "");
654
1023
  if (!parsed.success) {
655
1024
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
656
1025
  }
657
- const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
1026
+ const { message, conversation } = parsed.data;
658
1027
  const bundle = parsed.data.bundle ?? workspace?.toBundle() ?? null;
659
1028
  if (!bundle) return c.json({ error: "no spec to chat about \u2014 generate one first" }, 400);
1029
+ if (!harness) {
1030
+ return c.json(
1031
+ { error: "chat-unavailable", detail: "no agent harness configured for this server" },
1032
+ 500
1033
+ );
1034
+ }
660
1035
  const refs = expandReferences(message, refRoot);
661
1036
  const refContext = renderReferenceContext(refs);
662
- const history = conversation.map((m) => ({
663
- role: m.role === "user" ? "user" : "assistant",
664
- content: m.text
665
- }));
666
- let msg;
667
- try {
668
- msg = await createWithModelGate(anthropic, {
669
- model,
670
- max_tokens: 2e3,
671
- system: `${CHAT_SYSTEM}
672
-
673
- ${renderBundleContext(bundle)}`,
674
- messages: [
675
- ...history,
676
- {
677
- role: "user",
678
- content: message + (refContext ? `
1037
+ const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
1038
+ ${m.text}`).join("\n\n");
1039
+ const prompt = `${CHAT_SYSTEM}
1040
+
1041
+ ${renderBundleContext(bundle)}` + (history ? `
1042
+
1043
+ Conversation so far:
1044
+
1045
+ ${history}` : "") + `
1046
+
1047
+ [user]
1048
+ ${message}` + (refContext ? `
679
1049
 
680
1050
  Referenced files (resolved from @-references in the message):
681
1051
 
682
- ${refContext}` : "")
683
- }
684
- ]
685
- });
1052
+ ${refContext}` : "");
1053
+ let answer = "";
1054
+ let resultText = "";
1055
+ let resultError = false;
1056
+ try {
1057
+ for await (const ev of harness.run({
1058
+ prompt,
1059
+ cwd: refRoot ?? process.cwd(),
1060
+ session: workspace?.changeName ?? bundle.changeName ?? "specsketch-review",
1061
+ profile: reviewProfile
1062
+ })) {
1063
+ if (ev.type === "text") answer += ev.text;
1064
+ else if (ev.type === "result") {
1065
+ resultText = ev.text;
1066
+ resultError = ev.isError;
1067
+ }
1068
+ }
686
1069
  } catch (err) {
687
1070
  return anthropicErrorResponse(c, err, "chat-failed");
688
1071
  }
689
- if (msg.stop_reason === "refusal") {
690
- return c.json({ error: "refusal", detail: msg.stop_details }, 422);
1072
+ const text = answer.trim() || resultText.trim();
1073
+ if (!text) {
1074
+ return resultError ? c.json({ error: "chat-failed", detail: resultText || "the agent run failed" }, 502) : c.json({ error: "model returned no text" }, 502);
691
1075
  }
692
- const text = textOf(msg);
693
- if (!text) return c.json({ error: "model returned no text" }, 502);
694
1076
  const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
695
1077
  workspace?.writeChat([
696
1078
  ...conversation,
@@ -711,27 +1093,27 @@ ${refContext}` : "")
711
1093
  }
712
1094
 
713
1095
  // server/coop-auth.ts
714
- import { homedir as homedir2 } from "node:os";
715
- import { join as join3 } from "node:path";
1096
+ import { homedir as homedir3 } from "node:os";
1097
+ import { join as join4 } from "node:path";
716
1098
 
717
1099
  // ../shared/src/cli-auth.ts
718
1100
  import { exec } from "node:child_process";
719
1101
  import { randomBytes } from "node:crypto";
720
- import { existsSync } from "node:fs";
1102
+ import { existsSync as existsSync2 } from "node:fs";
721
1103
  import { createServer } from "node:http";
722
1104
  import { hostname as hostname2 } from "node:os";
723
1105
  import { URL as URL2 } from "node:url";
724
1106
 
725
1107
  // ../shared/src/cli-config.ts
726
1108
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
727
- import { homedir, hostname } from "node:os";
728
- import { join as join2, dirname } from "node:path";
1109
+ import { homedir as homedir2, hostname } from "node:os";
1110
+ import { join as join3, dirname } from "node:path";
729
1111
 
730
1112
  // ../shared/src/schemas.ts
731
- import { z as z2 } from "zod";
732
- var planTierSchema = z2.enum(["hobby", "squad", "org"]);
733
- var jurisdictionSchema = z2.enum(["eu", "fedramp"]);
734
- var locationHintSchema = z2.enum([
1113
+ import { z as z3 } from "zod";
1114
+ var planTierSchema = z3.enum(["hobby", "squad", "org"]);
1115
+ var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
1116
+ var locationHintSchema = z3.enum([
735
1117
  "wnam",
736
1118
  "enam",
737
1119
  "weur",
@@ -742,135 +1124,135 @@ var locationHintSchema = z2.enum([
742
1124
  "me",
743
1125
  "sam"
744
1126
  ]);
745
- var sessionNameSchema = z2.string().min(1).max(63).regex(
1127
+ var sessionNameSchema = z3.string().min(1).max(63).regex(
746
1128
  /^[a-z0-9][a-z0-9-]*$/,
747
1129
  "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
748
1130
  );
749
- var teamNameSchema = z2.string().min(1).max(63).regex(
1131
+ var teamNameSchema = z3.string().min(1).max(63).regex(
750
1132
  /^[a-z0-9][a-z0-9-]*$/,
751
1133
  "Team name must be lowercase alphanumeric with hyphens"
752
1134
  );
753
- var messageKindSchema = z2.enum(["text", "context", "request", "system"]);
754
- var messageContentSchema = z2.object({
1135
+ var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
1136
+ var messageContentSchema = z3.object({
755
1137
  kind: messageKindSchema,
756
1138
  // No .max() here: the 10 000-char body limit is business logic owned by the
757
1139
  // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
758
1140
  // both REST and WS. A schema cap would pre-empt it as a generic 400.
759
- body: z2.string().min(1),
1141
+ body: z3.string().min(1),
760
1142
  // Two-arg z.record: identical semantics in zod 3, required form in zod 4
761
1143
  // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
762
- metadata: z2.record(z2.string(), z2.unknown()).optional()
1144
+ metadata: z3.record(z3.string(), z3.unknown()).optional()
763
1145
  });
764
- var createTeamInputSchema = z2.object({
1146
+ var createTeamInputSchema = z3.object({
765
1147
  name: teamNameSchema
766
1148
  });
767
- var createSessionInputSchema = z2.object({
1149
+ var createSessionInputSchema = z3.object({
768
1150
  name: sessionNameSchema,
769
- teamId: z2.string().optional(),
1151
+ teamId: z3.string().optional(),
770
1152
  jurisdiction: jurisdictionSchema.optional(),
771
1153
  locationHint: locationHintSchema.optional()
772
1154
  });
773
- var sendMessageInputSchema = z2.object({
1155
+ var sendMessageInputSchema = z3.object({
774
1156
  content: messageContentSchema
775
1157
  });
776
- var inviteInputSchema = z2.object({
777
- email: z2.string().email(),
778
- role: z2.enum(["admin", "member"]).default("member")
1158
+ var inviteInputSchema = z3.object({
1159
+ email: z3.string().email(),
1160
+ role: z3.enum(["admin", "member"]).default("member")
779
1161
  });
780
- var joinTeamInputSchema = z2.object({
781
- inviteCode: z2.string().min(1)
1162
+ var joinTeamInputSchema = z3.object({
1163
+ inviteCode: z3.string().min(1)
782
1164
  });
783
- var markReadInputSchema = z2.object({
784
- notificationIds: z2.array(z2.string().min(1))
1165
+ var markReadInputSchema = z3.object({
1166
+ notificationIds: z3.array(z3.string().min(1))
785
1167
  });
786
- var pollQuerySchema = z2.object({
787
- since: z2.string().optional(),
788
- limit: z2.coerce.number().int().min(1).max(100).default(50),
789
- session: z2.string().optional()
1168
+ var pollQuerySchema = z3.object({
1169
+ since: z3.string().optional(),
1170
+ limit: z3.coerce.number().int().min(1).max(100).default(50),
1171
+ session: z3.string().optional()
790
1172
  });
791
- var messagesQuerySchema = z2.object({
792
- cursor: z2.string().optional(),
793
- limit: z2.coerce.number().int().min(1).max(100).default(50)
1173
+ var messagesQuerySchema = z3.object({
1174
+ cursor: z3.string().optional(),
1175
+ limit: z3.coerce.number().int().min(1).max(100).default(50)
794
1176
  });
795
- var teamTypeSchema = z2.enum(["personal", "shared"]);
796
- var teamRoleSchema = z2.enum(["owner", "admin", "member"]);
797
- var accessPermissionSchema = z2.enum(["read", "write", "admin"]);
798
- var accessGrantInputSchema = z2.object({
799
- userId: z2.string().min(1),
1177
+ var teamTypeSchema = z3.enum(["personal", "shared"]);
1178
+ var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
1179
+ var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
1180
+ var accessGrantInputSchema = z3.object({
1181
+ userId: z3.string().min(1),
800
1182
  sessionName: sessionNameSchema.nullable().default(null),
801
1183
  // null = general
802
1184
  permission: accessPermissionSchema
803
1185
  });
804
- var machineIdSchema = z2.string().min(1).max(63).regex(
1186
+ var machineIdSchema = z3.string().min(1).max(63).regex(
805
1187
  /^[a-z0-9][a-z0-9-]*$/,
806
1188
  "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
807
1189
  );
808
- var clerkProfileInputSchema = z2.object({
809
- email: z2.string().email().nullable(),
810
- displayName: z2.string().min(1).max(100),
811
- avatarUrl: z2.string().url().nullable(),
812
- provider: z2.string().min(1).max(50),
813
- providerUserId: z2.string().min(1).max(100)
1190
+ var clerkProfileInputSchema = z3.object({
1191
+ email: z3.string().email().nullable(),
1192
+ displayName: z3.string().min(1).max(100),
1193
+ avatarUrl: z3.string().url().nullable(),
1194
+ provider: z3.string().min(1).max(50),
1195
+ providerUserId: z3.string().min(1).max(100)
814
1196
  });
815
- var sessionEnrollmentSchema = z2.object({
816
- sessionId: z2.string(),
817
- team: z2.string(),
818
- teamName: z2.string().optional(),
819
- key: z2.string().startsWith("api_"),
820
- enrolledAt: z2.string().datetime(),
821
- apiUrl: z2.string().url().optional(),
822
- machineId: z2.string().optional()
1197
+ var sessionEnrollmentSchema = z3.object({
1198
+ sessionId: z3.string(),
1199
+ team: z3.string(),
1200
+ teamName: z3.string().optional(),
1201
+ key: z3.string().startsWith("api_"),
1202
+ enrolledAt: z3.string().datetime(),
1203
+ apiUrl: z3.string().url().optional(),
1204
+ machineId: z3.string().optional()
823
1205
  });
824
- var authConfigSchema = z2.object({
825
- key: z2.string().startsWith("api_"),
826
- userId: z2.string().min(1),
827
- displayName: z2.string().nullable().optional().default(null),
828
- email: z2.string().email().nullable(),
829
- machineId: z2.string().nullable(),
830
- authenticatedAt: z2.string().datetime()
1206
+ var authConfigSchema = z3.object({
1207
+ key: z3.string().startsWith("api_"),
1208
+ userId: z3.string().min(1),
1209
+ displayName: z3.string().nullable().optional().default(null),
1210
+ email: z3.string().email().nullable(),
1211
+ machineId: z3.string().nullable(),
1212
+ authenticatedAt: z3.string().datetime()
831
1213
  });
832
1214
  var configBaseFields = {
833
- apiUrl: z2.string().url(),
834
- machineId: z2.string().nullable(),
835
- sessions: z2.record(z2.string(), sessionEnrollmentSchema),
836
- defaults: z2.object({
837
- team: z2.string().nullable(),
838
- session: z2.string().nullable()
1215
+ apiUrl: z3.string().url(),
1216
+ machineId: z3.string().nullable(),
1217
+ sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1218
+ defaults: z3.object({
1219
+ team: z3.string().nullable(),
1220
+ session: z3.string().nullable()
839
1221
  }),
840
- poll: z2.object({
841
- lastPollAt: z2.string().datetime().nullable()
1222
+ poll: z3.object({
1223
+ lastPollAt: z3.string().datetime().nullable()
842
1224
  })
843
1225
  };
844
- var coopConfigV1Schema = z2.object({
845
- version: z2.literal(1),
1226
+ var coopConfigV1Schema = z3.object({
1227
+ version: z3.literal(1),
846
1228
  ...configBaseFields
847
1229
  });
848
- var coopConfigV2Schema = z2.object({
849
- version: z2.literal(2),
1230
+ var coopConfigV2Schema = z3.object({
1231
+ version: z3.literal(2),
850
1232
  ...configBaseFields,
851
1233
  auth: authConfigSchema.optional()
852
1234
  });
853
- var profileNameSchema = z2.string().regex(
1235
+ var profileNameSchema = z3.string().regex(
854
1236
  /^[a-z0-9][a-z0-9_-]{0,63}$/,
855
1237
  "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
856
1238
  );
857
- var coopProfileSchema = z2.object({
858
- apiUrl: z2.string().url(),
1239
+ var coopProfileSchema = z3.object({
1240
+ apiUrl: z3.string().url(),
859
1241
  auth: authConfigSchema.optional(),
860
- sessions: z2.record(z2.string(), sessionEnrollmentSchema),
861
- defaults: z2.object({
862
- team: z2.string().nullable(),
863
- session: z2.string().nullable()
1242
+ sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1243
+ defaults: z3.object({
1244
+ team: z3.string().nullable(),
1245
+ session: z3.string().nullable()
864
1246
  }),
865
- poll: z2.object({
866
- lastPollAt: z2.string().datetime().nullable()
1247
+ poll: z3.object({
1248
+ lastPollAt: z3.string().datetime().nullable()
867
1249
  })
868
1250
  });
869
- var coopConfigV3ObjectSchema = z2.object({
870
- version: z2.literal(3),
871
- machineId: z2.string().nullable(),
872
- defaultProfile: z2.string(),
873
- profiles: z2.record(profileNameSchema, coopProfileSchema)
1251
+ var coopConfigV3ObjectSchema = z3.object({
1252
+ version: z3.literal(3),
1253
+ machineId: z3.string().nullable(),
1254
+ defaultProfile: z3.string(),
1255
+ profiles: z3.record(profileNameSchema, coopProfileSchema)
874
1256
  });
875
1257
  function requireDefaultProfilePointer(config, ctx) {
876
1258
  if (!(config.defaultProfile in config.profiles)) {
@@ -884,7 +1266,7 @@ function requireDefaultProfilePointer(config, ctx) {
884
1266
  var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
885
1267
  requireDefaultProfilePointer
886
1268
  );
887
- var coopConfigSchema = z2.discriminatedUnion("version", [
1269
+ var coopConfigSchema = z3.discriminatedUnion("version", [
888
1270
  coopConfigV1Schema,
889
1271
  coopConfigV2Schema,
890
1272
  coopConfigV3ObjectSchema
@@ -893,20 +1275,39 @@ var coopConfigSchema = z2.discriminatedUnion("version", [
893
1275
  requireDefaultProfilePointer(config, ctx);
894
1276
  }
895
1277
  });
896
- var hookStdinSchema = z2.object({
897
- session_id: z2.string().optional(),
898
- cwd: z2.string().optional(),
899
- hook_event_name: z2.string().optional()
1278
+ var hookStdinSchema = z3.object({
1279
+ session_id: z3.string().optional(),
1280
+ cwd: z3.string().optional(),
1281
+ hook_event_name: z3.string().optional()
900
1282
  }).passthrough();
901
- var wsClientMessageSchema = z2.discriminatedUnion("type", [
902
- z2.object({ type: z2.literal("message"), content: messageContentSchema }),
903
- z2.object({ type: z2.literal("presence_request") }),
904
- z2.object({ type: z2.literal("ping") })
1283
+ var wsClientMessageSchema = z3.discriminatedUnion("type", [
1284
+ z3.object({ type: z3.literal("message"), content: messageContentSchema }),
1285
+ z3.object({ type: z3.literal("ack"), data: z3.object({ messageId: z3.string() }) }),
1286
+ z3.object({ type: z3.literal("presence_request") }),
1287
+ z3.object({ type: z3.literal("ping") })
905
1288
  ]);
1289
+ var workKindSchema = z3.enum(["implement", "generate"]);
1290
+ var workCompletionStatusSchema = z3.enum(["succeeded", "failed"]);
1291
+ var poolJoinInputSchema = z3.object({}).passthrough();
1292
+ var poolCheckInInputSchema = z3.object({}).passthrough();
1293
+ var poolCompleteInputSchema = z3.object({
1294
+ workId: z3.string().min(1),
1295
+ status: workCompletionStatusSchema,
1296
+ resultRef: z3.string().max(512),
1297
+ assignmentSeq: z3.number().int().nonnegative()
1298
+ });
1299
+ var poolEnqueueInputSchema = z3.object({
1300
+ kind: workKindSchema,
1301
+ source: z3.string().min(1),
1302
+ dedupKey: z3.string().min(1).max(512).optional()
1303
+ });
1304
+ var poolCancelInputSchema = z3.object({
1305
+ workId: z3.string().min(1)
1306
+ });
906
1307
 
907
1308
  // ../shared/src/cli-config.ts
908
- var DEFAULT_CONFIG_DIR = join2(homedir(), ".coopcli");
909
- var DEFAULT_CONFIG_PATH = join2(DEFAULT_CONFIG_DIR, "config.json");
1309
+ var DEFAULT_CONFIG_DIR = join3(homedir2(), ".coopcli");
1310
+ var DEFAULT_CONFIG_PATH = join3(DEFAULT_CONFIG_DIR, "config.json");
910
1311
  var PROD_API_URL = "https://api.coopcli.com";
911
1312
  function emptyProfile(apiUrl) {
912
1313
  return {
@@ -1383,7 +1784,7 @@ var CliAuthError = class extends Error {
1383
1784
  async function resolveCoopCredentials(opts) {
1384
1785
  const env = opts.env ?? process.env;
1385
1786
  const { configPath, loginHint } = opts;
1386
- if (!existsSync(configPath)) {
1787
+ if (!existsSync2(configPath)) {
1387
1788
  throw new CliAuthError(`No CoopCLI config found at ${configPath}.
1388
1789
  ${loginHint}`, "missing-config");
1389
1790
  }
@@ -1425,7 +1826,7 @@ ${loginHint}`, "no-login");
1425
1826
  var CoopAuthError = class extends Error {
1426
1827
  };
1427
1828
  var LOGIN_HINT = "--share needs a CoopCLI account. Run `specsketch login` (it opens your browser and saves credentials to ~/.coopcli/config.json), then rerun with --share.";
1428
- async function readCoopAuth(configPath = join3(homedir2(), ".coopcli", "config.json"), env = process.env) {
1829
+ async function readCoopAuth(configPath = join4(homedir3(), ".coopcli", "config.json"), env = process.env) {
1429
1830
  try {
1430
1831
  return await resolveCoopCredentials({
1431
1832
  configPath,
@@ -1688,14 +2089,6 @@ var ApiClient = class _ApiClient {
1688
2089
  latest: count
1689
2090
  });
1690
2091
  }
1691
- async queryAIContext(teamId, sessionName, input) {
1692
- return this.request(
1693
- "POST",
1694
- this.userPath(`/sessions/${encodeURIComponent(sessionName)}/ai-context`),
1695
- input,
1696
- { team: teamId }
1697
- );
1698
- }
1699
2092
  async listSessionFiles(teamId, sessionName) {
1700
2093
  const result = await this.request(
1701
2094
  "GET",
@@ -1783,6 +2176,72 @@ var ApiClient = class _ApiClient {
1783
2176
  const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
1784
2177
  return { filename, body: res.body, size };
1785
2178
  }
2179
+ // ── Agent pool (specsesh-agent-pool) — user-scoped routes ──────────────
2180
+ /** Enroll this machine in its org's execute pool. Identity comes from the key. */
2181
+ async poolJoin() {
2182
+ return this.request("POST", this.userPath("/pool/join"), {});
2183
+ }
2184
+ /** Check in; may return one assigned work item in the same round-trip (FIFO). */
2185
+ async poolCheckIn() {
2186
+ return this.request("POST", this.userPath("/pool/check-in"), {});
2187
+ }
2188
+ /**
2189
+ * The org pool's roster. `specsesh dispatch` reads it to surface a live-agent
2190
+ * count so a dispatch into a pool with zero live agents is a visible no-signal
2191
+ * enqueue, not a silent success (specsesh-work-dispatch §4.4).
2192
+ */
2193
+ async poolRoster() {
2194
+ const body = await this.request("GET", this.userPath("/pool/roster"));
2195
+ return body.agents;
2196
+ }
2197
+ /**
2198
+ * Report completion of an assigned work item. `assignmentSeq` is REQUIRED
2199
+ * (specsesh-task-lifecycle §5) — echo back the epoch from the `WorkItem`
2200
+ * this caller was handed at check-in, so the DO's ABA fence can reject a
2201
+ * stale completion from a since-superseded assignment.
2202
+ */
2203
+ async poolComplete(workId, status, resultRef, assignmentSeq) {
2204
+ await this.request("POST", this.userPath("/pool/complete"), { workId, status, resultRef, assignmentSeq });
2205
+ }
2206
+ /**
2207
+ * Dispatch a task record into the org pool as one work item (idempotent on
2208
+ * the qualified `dedupKey`). CLI producer of specsesh-work-dispatch; returns
2209
+ * the (possibly already-existing) work id.
2210
+ */
2211
+ async poolEnqueue(kind, source, dedupKey) {
2212
+ return this.request("POST", this.userPath("/pool/enqueue"), { kind, source, dedupKey });
2213
+ }
2214
+ /**
2215
+ * Org-scoped cancel (specsesh-task-lifecycle §2) — any authenticated member
2216
+ * of the org may cancel any work item in its pool, regardless of which
2217
+ * agent (if any) holds it. `queued` cancels directly; `assigned`/`running`
2218
+ * cancels and frees the holding agent (no redelivery); an already-terminal
2219
+ * item rejects.
2220
+ */
2221
+ async poolCancel(workId) {
2222
+ await this.request("POST", this.userPath("/pool/cancel"), { workId });
2223
+ }
2224
+ /**
2225
+ * Query a single work item's current status (specsesh-task-lifecycle §4) —
2226
+ * tenant-scoped, independent of whether the caller is the agent (if any)
2227
+ * holding it. Throws `CoopApiError` (`POOL_WORK_NOT_FOUND`, 404) if the
2228
+ * item does not exist in this organization's pool.
2229
+ */
2230
+ async poolWork(workId) {
2231
+ const body = await this.request(
2232
+ "GET",
2233
+ this.userPath(`/pool/work/${encodeURIComponent(workId)}`)
2234
+ );
2235
+ return body.work;
2236
+ }
2237
+ /**
2238
+ * List every work item currently in the organization's pool, regardless of
2239
+ * status (specsesh-task-lifecycle §4).
2240
+ */
2241
+ async poolWorkList() {
2242
+ const body = await this.request("GET", this.userPath("/pool/work"));
2243
+ return body.items;
2244
+ }
1786
2245
  getWebSocketUrl(session) {
1787
2246
  const wsBase = this.apiUrl.replace(
1788
2247
  /^https?:\/\//,
@@ -1796,98 +2255,98 @@ var ApiClient = class _ApiClient {
1796
2255
  };
1797
2256
 
1798
2257
  // ../shared/src/canvas-protocol.ts
1799
- import { z as z3 } from "zod";
2258
+ import { z as z4 } from "zod";
1800
2259
  var CANVAS_PROTOCOL_VERSION = 1;
1801
- var canvasElementSchema = z3.object({
1802
- id: z3.string().min(1),
1803
- version: z3.number().int().nonnegative(),
1804
- versionNonce: z3.number().int(),
1805
- isDeleted: z3.boolean().optional()
2260
+ var canvasElementSchema = z4.object({
2261
+ id: z4.string().min(1),
2262
+ version: z4.number().int().nonnegative(),
2263
+ versionNonce: z4.number().int(),
2264
+ isDeleted: z4.boolean().optional()
1806
2265
  }).passthrough();
1807
- var canvasFilesSchema = z3.record(z3.string(), z3.unknown());
1808
- var canvasSceneSchema = z3.object({
1809
- elements: z3.array(canvasElementSchema),
2266
+ var canvasFilesSchema = z4.record(z4.string(), z4.unknown());
2267
+ var canvasSceneSchema = z4.object({
2268
+ elements: z4.array(canvasElementSchema),
1810
2269
  files: canvasFilesSchema.optional()
1811
2270
  });
1812
- var canvasParticipantSchema = z3.object({
1813
- id: z3.string().min(1),
1814
- role: z3.enum(["host", "guest"]),
1815
- name: z3.string().optional(),
1816
- color: z3.string().optional()
2271
+ var canvasParticipantSchema = z4.object({
2272
+ id: z4.string().min(1),
2273
+ role: z4.enum(["host", "guest"]),
2274
+ name: z4.string().optional(),
2275
+ color: z4.string().optional()
1817
2276
  });
1818
- var canvasHelloSchema = z3.object({
1819
- type: z3.literal("hello"),
1820
- protocolVersion: z3.number().int().positive(),
1821
- role: z3.enum(["host", "guest"]),
2277
+ var canvasHelloSchema = z4.object({
2278
+ type: z4.literal("hello"),
2279
+ protocolVersion: z4.number().int().positive(),
2280
+ role: z4.enum(["host", "guest"]),
1822
2281
  /** Stable id across reconnects, if the client has one. */
1823
- participantId: z3.string().min(1).max(64).optional(),
1824
- name: z3.string().max(120).optional()
2282
+ participantId: z4.string().min(1).max(64).optional(),
2283
+ name: z4.string().max(120).optional()
1825
2284
  });
1826
- var canvasClientOpSchema = z3.object({
1827
- type: z3.literal("op"),
2285
+ var canvasClientOpSchema = z4.object({
2286
+ type: z4.literal("op"),
1828
2287
  /** Changed or soft-deleted elements only — never the full scene. */
1829
- elements: z3.array(canvasElementSchema).min(1),
2288
+ elements: z4.array(canvasElementSchema).min(1),
1830
2289
  /** New binary files referenced by the changed elements, if any. */
1831
2290
  files: canvasFilesSchema.optional(),
1832
2291
  /** Client-local monotonic sequence, used by the sender for echo dedupe. */
1833
- clientSeq: z3.number().int().nonnegative()
2292
+ clientSeq: z4.number().int().nonnegative()
1834
2293
  });
1835
- var canvasClientMessageSchema = z3.discriminatedUnion("type", [
2294
+ var canvasClientMessageSchema = z4.discriminatedUnion("type", [
1836
2295
  canvasHelloSchema,
1837
2296
  canvasClientOpSchema
1838
2297
  ]);
1839
- var canvasSnapshotSchema = z3.object({
1840
- type: z3.literal("snapshot"),
2298
+ var canvasSnapshotSchema = z4.object({
2299
+ type: z4.literal("snapshot"),
1841
2300
  scene: canvasSceneSchema,
1842
- revision: z3.number().int().nonnegative(),
1843
- participants: z3.array(canvasParticipantSchema),
2301
+ revision: z4.number().int().nonnegative(),
2302
+ participants: z4.array(canvasParticipantSchema),
1844
2303
  /** Echoes the server's protocol version so clients can detect skew. */
1845
- protocolVersion: z3.number().int().positive(),
2304
+ protocolVersion: z4.number().int().positive(),
1846
2305
  /** The participant id the server assigned to (or kept for) this client. */
1847
- participantId: z3.string()
2306
+ participantId: z4.string()
1848
2307
  });
1849
- var canvasServerOpSchema = z3.object({
1850
- type: z3.literal("op"),
2308
+ var canvasServerOpSchema = z4.object({
2309
+ type: z4.literal("op"),
1851
2310
  /** The elements that won reconciliation (never echoed to the sender). */
1852
- elements: z3.array(canvasElementSchema),
2311
+ elements: z4.array(canvasElementSchema),
1853
2312
  files: canvasFilesSchema.optional(),
1854
- revision: z3.number().int().positive(),
2313
+ revision: z4.number().int().positive(),
1855
2314
  /** Participant id of the sender. */
1856
- from: z3.string()
2315
+ from: z4.string()
1857
2316
  });
1858
- var canvasPresenceSchema = z3.object({
1859
- type: z3.literal("presence"),
1860
- participants: z3.array(canvasParticipantSchema)
2317
+ var canvasPresenceSchema = z4.object({
2318
+ type: z4.literal("presence"),
2319
+ participants: z4.array(canvasParticipantSchema)
1861
2320
  });
1862
- var canvasEndSchema = z3.object({
1863
- type: z3.literal("end"),
1864
- reason: z3.string()
2321
+ var canvasEndSchema = z4.object({
2322
+ type: z4.literal("end"),
2323
+ reason: z4.string()
1865
2324
  });
1866
- var canvasErrorSchema = z3.object({
1867
- type: z3.literal("error"),
1868
- code: z3.string(),
1869
- message: z3.string()
2325
+ var canvasErrorSchema = z4.object({
2326
+ type: z4.literal("error"),
2327
+ code: z4.string(),
2328
+ message: z4.string()
1870
2329
  });
1871
- var canvasServerMessageSchema = z3.discriminatedUnion("type", [
2330
+ var canvasServerMessageSchema = z4.discriminatedUnion("type", [
1872
2331
  canvasSnapshotSchema,
1873
2332
  canvasServerOpSchema,
1874
2333
  canvasPresenceSchema,
1875
2334
  canvasEndSchema,
1876
2335
  canvasErrorSchema
1877
2336
  ]);
1878
- var canvasSessionCreatedSchema = z3.object({
1879
- sessionId: z3.string().min(1),
2337
+ var canvasSessionCreatedSchema = z4.object({
2338
+ sessionId: z4.string().min(1),
1880
2339
  /** Capability token — shown once; the server stores only its hash. */
1881
- token: z3.string().min(1),
1882
- shareUrl: z3.string().min(1),
1883
- wsUrl: z3.string().min(1)
2340
+ token: z4.string().min(1),
2341
+ shareUrl: z4.string().min(1),
2342
+ wsUrl: z4.string().min(1)
1884
2343
  });
1885
- var canvasSessionSummarySchema = z3.object({
1886
- sessionId: z3.string(),
1887
- title: z3.string().nullable(),
1888
- status: z3.enum(["active", "ended"]),
1889
- createdAt: z3.number(),
1890
- endedAt: z3.number().nullable()
2344
+ var canvasSessionSummarySchema = z4.object({
2345
+ sessionId: z4.string(),
2346
+ title: z4.string().nullable(),
2347
+ status: z4.enum(["active", "ended"]),
2348
+ createdAt: z4.number(),
2349
+ endedAt: z4.number().nullable()
1891
2350
  });
1892
2351
  function elementWins(local, incoming) {
1893
2352
  if (incoming.version !== local.version) return incoming.version > local.version;
@@ -2171,37 +2630,178 @@ var RemoteSession = class _RemoteSession {
2171
2630
  };
2172
2631
 
2173
2632
  // server/workspace.ts
2174
- import { createHash } from "node:crypto";
2633
+ import { createHash as createHash2 } from "node:crypto";
2175
2634
  import {
2176
- existsSync as existsSync2,
2177
- mkdirSync,
2178
- readFileSync as readFileSync2,
2179
- readdirSync as readdirSync2,
2180
- renameSync,
2181
- writeFileSync
2635
+ existsSync as existsSync5,
2636
+ mkdirSync as mkdirSync2,
2637
+ readFileSync as readFileSync3,
2638
+ readdirSync as readdirSync4,
2639
+ renameSync as renameSync2,
2640
+ writeFileSync as writeFileSync2
2182
2641
  } from "node:fs";
2183
- import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
2642
+ import { basename as basename2, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
2643
+
2644
+ // ../shared/src/openspec-change-meta.ts
2645
+ import { createHash } from "node:crypto";
2646
+ import { existsSync as existsSync4, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
2647
+ import { join as join5 } from "node:path";
2648
+ import { Document, parseDocument } from "yaml";
2649
+ import { z as z5 } from "zod";
2650
+
2651
+ // ../shared/src/fs-atomic.ts
2652
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
2653
+ import { dirname as dirname2 } from "node:path";
2654
+ function writeAtomicFile(target, content) {
2655
+ mkdirSync(dirname2(target), { recursive: true });
2656
+ const tmp = `${target}.tmp`;
2657
+ writeFileSync(tmp, content, "utf8");
2658
+ renameSync(tmp, target);
2659
+ }
2660
+ function readIfExists(target) {
2661
+ return existsSync3(target) ? readFileSync2(target, "utf8") : void 0;
2662
+ }
2663
+
2664
+ // ../shared/src/openspec-change-meta.ts
2665
+ var CHANGE_META_FILE = "specplan.yaml";
2666
+ var nodeChangeMetaFs = {
2667
+ readIfExists(path) {
2668
+ return readIfExists(path);
2669
+ },
2670
+ writeAtomicFile(path, content) {
2671
+ writeAtomicFile(path, content);
2672
+ },
2673
+ exists(path) {
2674
+ return existsSync4(path);
2675
+ },
2676
+ readdir(path) {
2677
+ if (!existsSync4(path)) return [];
2678
+ return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
2679
+ name: entry.name,
2680
+ directory: entry.isDirectory()
2681
+ }));
2682
+ },
2683
+ realpath(path) {
2684
+ return realpathSync2(path);
2685
+ }
2686
+ };
2687
+ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
2688
+ function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
2689
+ const files = [];
2690
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
2691
+ if (fsx.exists(join5(changeDir, name))) files.push(name);
2692
+ }
2693
+ const specsDir = join5(changeDir, "specs");
2694
+ for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
2695
+ const rel = join5("specs", entry.name, "spec.md");
2696
+ if (entry.directory && fsx.exists(join5(changeDir, rel))) files.push(rel);
2697
+ }
2698
+ return files;
2699
+ }
2700
+ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
2701
+ const files = listBodyFiles(changeDir, fsx);
2702
+ if (files.length === 0) return null;
2703
+ const parts = files.map(
2704
+ (rel) => `${rel}
2705
+ ${sha256(fsx.readIfExists(join5(changeDir, rel)) ?? "")}`
2706
+ );
2707
+ return `sha256:${sha256(parts.join("\n"))}`;
2708
+ }
2709
+ var SPEC_STATUSES = ["draft", "in-progress"];
2710
+ var SpecStatus = z5.enum(SPEC_STATUSES);
2711
+ var LEGACY_STATUS_COERCION = {
2712
+ active: "in-progress",
2713
+ done: "in-progress",
2714
+ deferred: "draft"
2715
+ };
2716
+ var StoredSpecStatus = z5.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
2717
+ (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
2718
+ );
2719
+ var SpecMetaSchema = z5.object({
2720
+ specId: z5.string(),
2721
+ userStories: z5.array(z5.string()),
2722
+ dependencies: z5.array(z5.string()),
2723
+ /** Working state (retired values coerce on read); absent = draft. */
2724
+ status: StoredSpecStatus.optional(),
2725
+ /**
2726
+ * Approved axis (formerly `applied`) — fold-driven, SEPARATE from the
2727
+ * working status above. True when the proposal's delta has been folded
2728
+ * into specs/ and archived (apply-first: the fold happens at approval).
2729
+ * Absent = not-approved.
2730
+ */
2731
+ approved: z5.boolean().optional(),
2732
+ /**
2733
+ * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
2734
+ * compatibility: readers treat `applied: true` as `approved: true`; no
2735
+ * writer ever sets this field again.
2736
+ */
2737
+ applied: z5.boolean().optional(),
2738
+ /**
2739
+ * Build flag — user-set, independent of the fold: true once the code has
2740
+ * been built to match the approved diff. Absent = not-implemented.
2741
+ */
2742
+ implemented: z5.boolean().optional(),
2743
+ /**
2744
+ * Minimal implementation-task record written on fold (specplan-timeline-apply
2745
+ * "Implementation task record on fold"): the creation time and a reference to
2746
+ * the archived change's own id/path (from which the delta artifact is
2747
+ * materialized on demand). Deliberately carries NO status and NO dispatch
2748
+ * state — task execution/dispatch is a separate, not-yet-defined concern. A
2749
+ * field addition to this sidecar, not a redefinition of its format.
2750
+ */
2751
+ task: z5.object({
2752
+ createdAt: z5.string(),
2753
+ source: z5.string()
2754
+ }).optional(),
2755
+ generation: z5.object({
2756
+ /** sha256 of the last generated body files — detects hand edits. */
2757
+ lastGeneratedBodyHash: z5.string(),
2758
+ lastGeneratedAt: z5.string()
2759
+ }).optional()
2760
+ });
2761
+ function writeChangeMeta(fsx, changeDir, meta) {
2762
+ const path = join5(changeDir, CHANGE_META_FILE);
2763
+ const raw = fsx.readIfExists(path);
2764
+ const doc = raw === void 0 ? new Document({
2765
+ specId: meta.specId,
2766
+ userStories: meta.userStories ?? [],
2767
+ dependencies: meta.dependencies ?? [],
2768
+ status: meta.status ?? "draft"
2769
+ }) : parseDocument(raw);
2770
+ if (meta.approved !== void 0) doc.setIn(["approved"], meta.approved);
2771
+ if (meta.implemented !== void 0) doc.setIn(["implemented"], meta.implemented);
2772
+ if (meta.task !== void 0) {
2773
+ doc.setIn(["task", "createdAt"], meta.task.createdAt);
2774
+ doc.setIn(["task", "source"], meta.task.source);
2775
+ }
2776
+ if (meta.generation !== void 0) {
2777
+ doc.setIn(["generation", "lastGeneratedBodyHash"], meta.generation.lastGeneratedBodyHash);
2778
+ doc.setIn(["generation", "lastGeneratedAt"], meta.generation.lastGeneratedAt);
2779
+ }
2780
+ fsx.writeAtomicFile(path, doc.toString());
2781
+ }
2782
+
2783
+ // server/workspace.ts
2184
2784
  var DIAGRAM_FILE = "diagram.excalidraw";
2185
2785
  var SIDECAR_FILE = "specsketch.json";
2186
- var sha256 = (s) => createHash("sha256").update(s).digest("hex");
2786
+ var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
2187
2787
  var ChangeWorkspace = class {
2188
2788
  dir;
2189
2789
  changeName;
2190
2790
  constructor(dir) {
2191
2791
  this.dir = resolve2(dir);
2192
2792
  this.changeName = basename2(this.dir);
2193
- mkdirSync(this.dir, { recursive: true });
2793
+ mkdirSync2(this.dir, { recursive: true });
2194
2794
  }
2195
2795
  writeAtomic(relPath, content) {
2196
- const target = join4(this.dir, relPath);
2197
- mkdirSync(dirname2(target), { recursive: true });
2796
+ const target = join6(this.dir, relPath);
2797
+ mkdirSync2(dirname3(target), { recursive: true });
2198
2798
  const tmp = `${target}.tmp`;
2199
- writeFileSync(tmp, content, "utf8");
2200
- renameSync(tmp, target);
2799
+ writeFileSync2(tmp, content, "utf8");
2800
+ renameSync2(tmp, target);
2201
2801
  }
2202
2802
  readIfExists(relPath) {
2203
- const target = join4(this.dir, relPath);
2204
- return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
2803
+ const target = join6(this.dir, relPath);
2804
+ return existsSync5(target) ? readFileSync3(target, "utf8") : void 0;
2205
2805
  }
2206
2806
  // ── Scene ────────────────────────────────────────────────────────────────
2207
2807
  loadScene() {
@@ -2262,11 +2862,11 @@ var ChangeWorkspace = class {
2262
2862
  /** Current artifact files, however they were authored. */
2263
2863
  readArtifacts() {
2264
2864
  const specs = [];
2265
- const specsDir = join4(this.dir, "specs");
2266
- if (existsSync2(specsDir)) {
2267
- for (const entry of readdirSync2(specsDir, { withFileTypes: true })) {
2865
+ const specsDir = join6(this.dir, "specs");
2866
+ if (existsSync5(specsDir)) {
2867
+ for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
2268
2868
  if (!entry.isDirectory()) continue;
2269
- const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
2869
+ const spec = this.readIfExists(join6("specs", entry.name, "spec.md"));
2270
2870
  if (spec !== void 0) specs.push({ capability: entry.name, spec });
2271
2871
  }
2272
2872
  }
@@ -2300,11 +2900,11 @@ var ChangeWorkspace = class {
2300
2900
  "design.md": bundle.design,
2301
2901
  "tasks.md": bundle.tasks
2302
2902
  };
2303
- for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
2903
+ for (const s of bundle.specs) files[join6("specs", s.capability, "spec.md")] = s.spec;
2304
2904
  const written = [];
2305
2905
  const manifest = {};
2306
2906
  for (const [relPath, content] of Object.entries(files)) {
2307
- manifest[relPath] = sha256(content);
2907
+ manifest[relPath] = sha2562(content);
2308
2908
  if (this.readIfExists(relPath) === content) continue;
2309
2909
  this.writeAtomic(relPath, content);
2310
2910
  written.push(relPath);
@@ -2317,6 +2917,16 @@ var ChangeWorkspace = class {
2317
2917
  // post-generation chat seed (generation-chat-reset-summary).
2318
2918
  lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
2319
2919
  }));
2920
+ const lastGeneratedBodyHash = bodyHash(this.dir, nodeChangeMetaFs);
2921
+ if (lastGeneratedBodyHash) {
2922
+ writeChangeMeta(nodeChangeMetaFs, this.dir, {
2923
+ specId: this.changeName,
2924
+ userStories: [],
2925
+ dependencies: [],
2926
+ status: "draft",
2927
+ generation: { lastGeneratedBodyHash, lastGeneratedAt: (/* @__PURE__ */ new Date()).toISOString() }
2928
+ });
2929
+ }
2320
2930
  return written;
2321
2931
  }
2322
2932
  // ── Spec review chat ─────────────────────────────────────────────────────
@@ -2405,15 +3015,15 @@ var MIME = {
2405
3015
  ".woff": "font/woff"
2406
3016
  };
2407
3017
  function resolveClientDir(moduleUrl) {
2408
- const here = dirname3(fileURLToPath(moduleUrl));
3018
+ const here = dirname4(fileURLToPath(moduleUrl));
2409
3019
  const candidates = [
2410
- join5(here, "..", "client"),
3020
+ join7(here, "..", "client"),
2411
3021
  // packed: dist/cli -> dist/client
2412
- join5(here, "..", "dist", "client")
3022
+ join7(here, "..", "dist", "client")
2413
3023
  // repo: server/ -> dist/client
2414
3024
  ];
2415
3025
  for (const dir of candidates) {
2416
- if (existsSync3(join5(dir, "index.html"))) return dir;
3026
+ if (existsSync6(join7(dir, "index.html"))) return dir;
2417
3027
  }
2418
3028
  return null;
2419
3029
  }
@@ -2421,9 +3031,9 @@ function staticResponse(clientDir, pathname) {
2421
3031
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
2422
3032
  const target = resolve3(clientDir, rel === "" ? "index.html" : rel);
2423
3033
  if (!target.startsWith(resolve3(clientDir))) return null;
2424
- const file = existsSync3(target) && extname(target) ? target : join5(clientDir, "index.html");
2425
- if (!existsSync3(file)) return null;
2426
- return new Response(readFileSync3(file), {
3034
+ const file = existsSync6(target) && extname(target) ? target : join7(clientDir, "index.html");
3035
+ if (!existsSync6(file)) return null;
3036
+ return new Response(readFileSync4(file), {
2427
3037
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
2428
3038
  });
2429
3039
  }
@@ -2435,12 +3045,12 @@ but "Generate Spec" needs one of:
2435
3045
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
2436
3046
  brew install anthropics/tap/ant # macOS
2437
3047
  ant auth login`;
2438
- function detectCredentialSource(env = process.env, home = homedir3()) {
3048
+ function detectCredentialSource(env = process.env, home = homedir4()) {
2439
3049
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
2440
3050
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
2441
3051
  try {
2442
- const dir = join5(home, ".config", "anthropic", "credentials");
2443
- if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
3052
+ const dir = join7(home, ".config", "anthropic", "credentials");
3053
+ if (readdirSync5(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2444
3054
  } catch {
2445
3055
  }
2446
3056
  return null;
@@ -2507,7 +3117,8 @@ ${USAGE}` : err);
2507
3117
  shareUrl: remote.shareUrl,
2508
3118
  status: remote.status,
2509
3119
  participants: remote.participants.length
2510
- } : { active: false }
3120
+ } : { active: false },
3121
+ { refRoot: process.cwd(), harness: createClaudeHarness() }
2511
3122
  );
2512
3123
  app.get("*", (c) => {
2513
3124
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);