@coopcli/specsketch 5.5.6 → 2026.817.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/cli/index.js +2753 -1124
  2. package/dist/client/assets/index-DyUTkivM.css +1 -0
  3. package/dist/client/assets/index-Tyf33hWt.js +196 -0
  4. package/dist/client/index.html +3 -4
  5. package/package.json +80 -74
  6. package/dist/client/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  7. package/dist/client/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  8. package/dist/client/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  9. package/dist/client/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  10. package/dist/client/assets/abnfDiagram-VRR7QNED-CTNJVYti.js +0 -1
  11. package/dist/client/assets/ar-SA-G6X2FPQ2-Dx5RkIpf.js +0 -10
  12. package/dist/client/assets/arc-CS-8wk7Q.js +0 -1
  13. package/dist/client/assets/architecture-TIHT7OUA-b9_tu6da.js +0 -1
  14. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-C7zIp5ev.js +0 -36
  15. package/dist/client/assets/array-BifhSqXX.js +0 -1
  16. package/dist/client/assets/az-AZ-76LH7QW2-lKEr0lki.js +0 -1
  17. package/dist/client/assets/bg-BG-XCXSNQG7-BrueLcBf.js +0 -5
  18. package/dist/client/assets/blockDiagram-677ZJIJ3-B3Y6utF7.js +0 -132
  19. package/dist/client/assets/bn-BD-2XOGV67Q-BVnHj_Zk.js +0 -5
  20. package/dist/client/assets/c4Diagram-LMCZKHZV-Dt_cruyV.js +0 -10
  21. package/dist/client/assets/ca-ES-6MX7JW3Y-BM6SppAL.js +0 -8
  22. package/dist/client/assets/channel-Dz1KOO7f.js +0 -1
  23. package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
  24. package/dist/client/assets/chunk-32BRIVSS-Ca80Axke.js +0 -1
  25. package/dist/client/assets/chunk-52WLFC77-CtSrQbPt.js +0 -10
  26. package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
  27. package/dist/client/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
  28. package/dist/client/assets/chunk-C7G6YPKG-B2LFv6gm.js +0 -1
  29. package/dist/client/assets/chunk-EIO257PC-BAdMCi3M.js +0 -22
  30. package/dist/client/assets/chunk-EX3LRPZG-BJ4MtZ6_.js +0 -231
  31. package/dist/client/assets/chunk-FWX5IMBZ-Oeex84SP.js +0 -2
  32. package/dist/client/assets/chunk-HOUHSVGY-CwXgFjr1.js +0 -1
  33. package/dist/client/assets/chunk-ICXQ74PX-CNjPZw9f.js +0 -2
  34. package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
  35. package/dist/client/assets/chunk-KEIR6QF5-BfrZ3jm6.js +0 -161
  36. package/dist/client/assets/chunk-MOJQB5TN-CH9qxz0s.js +0 -88
  37. package/dist/client/assets/chunk-OGEWGWER-CXz-hiqH.js +0 -1
  38. package/dist/client/assets/chunk-PUDLZKDR-DeKKZ45n.js +0 -156
  39. package/dist/client/assets/chunk-Q4XR5HBZ-BfA-corw.js +0 -70
  40. package/dist/client/assets/chunk-RYQCIY6F-CmGX33Ed.js +0 -1
  41. package/dist/client/assets/chunk-V7JOEXUC-DBArf2HI.js +0 -206
  42. package/dist/client/assets/chunk-VAUOI2AC-Ce5MjSpg.js +0 -1
  43. package/dist/client/assets/chunk-VR4S4FIN-DUkeWBHv.js +0 -1
  44. package/dist/client/assets/chunk-WYO6CB5R-5IIfqwS1.js +0 -127
  45. package/dist/client/assets/chunk-XXDRQBXY-B_lJn00H.js +0 -1
  46. package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
  47. package/dist/client/assets/chunk-ZGVPDNZ5-BVFUMmyX.js +0 -62
  48. package/dist/client/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
  49. package/dist/client/assets/classDiagram-OUVF2IWQ-5dxJ_ejB.js +0 -1
  50. package/dist/client/assets/classDiagram-v2-EOCWNBFH-5dxJ_ejB.js +0 -1
  51. package/dist/client/assets/cose-bilkent-JH36ORCC-pqulBXGU.js +0 -1
  52. package/dist/client/assets/cs-CZ-2BRQDIVT-BeWSN4Lg.js +0 -11
  53. package/dist/client/assets/cynefin-VYW2F7L2-BPoEGt5P.js +0 -1
  54. package/dist/client/assets/cynefinDiagram-TSTJHNR4-D-HGa5eC.js +0 -62
  55. package/dist/client/assets/cytoscape.esm-B3I8pqwA.js +0 -321
  56. package/dist/client/assets/da-DK-5WZEPLOC-C7Kj8cSr.js +0 -5
  57. package/dist/client/assets/dagre-CXRCoUWR.js +0 -1
  58. package/dist/client/assets/dagre-VKFMJZFB-DYJas6XO.js +0 -4
  59. package/dist/client/assets/de-DE-XR44H4JA-CGONhJum.js +0 -8
  60. package/dist/client/assets/defaultLocale-C8Fc0cco.js +0 -1
  61. package/dist/client/assets/diagram-FQU43EPY-CovScBBf.js +0 -3
  62. package/dist/client/assets/diagram-G47NLZAW-6_UOQ1JD.js +0 -24
  63. package/dist/client/assets/diagram-NH7WQ7WH-DfM8Ux2D.js +0 -24
  64. package/dist/client/assets/diagram-OA4YK3LP-BFJDUTm_.js +0 -30
  65. package/dist/client/assets/diagram-WEI45ONY-8OKtOSaq.js +0 -41
  66. package/dist/client/assets/directory-open-01563666-D4xXyWb_.js +0 -1
  67. package/dist/client/assets/directory-open-4ed118d0-1i309Asm.js +0 -1
  68. package/dist/client/assets/dist-C8c_sMtm.js +0 -1
  69. package/dist/client/assets/dist-CBoHd3nF.js +0 -29
  70. package/dist/client/assets/ebnfDiagram-CCIWWBDH-TBneTBs-.js +0 -1
  71. package/dist/client/assets/el-GR-BZB4AONW-C9Q6-3JL.js +0 -10
  72. package/dist/client/assets/en-B4ZKOASM-DooiP2-J.js +0 -1
  73. package/dist/client/assets/erDiagram-Q63AITRT-Dy5QBJCT.js +0 -85
  74. package/dist/client/assets/es-ES-U4NZUMDT-DKxT2wWX.js +0 -9
  75. package/dist/client/assets/eu-ES-A7QVB2H4-DrKnZ3gY.js +0 -11
  76. package/dist/client/assets/eventmodeling-45OFAUF4-DP9O5S3e.js +0 -1
  77. package/dist/client/assets/fa-IR-HGAKTJCU-DjA2Px-T.js +0 -8
  78. package/dist/client/assets/fi-FI-Z5N7JZ37-NUghXbpa.js +0 -6
  79. package/dist/client/assets/file-open-002ab408-BHUWm0Sh.js +0 -1
  80. package/dist/client/assets/file-open-7c801643-DZtJT5zp.js +0 -1
  81. package/dist/client/assets/file-save-3189631c-CO9S4HFW.js +0 -1
  82. package/dist/client/assets/file-save-745eba88-Bwdfz6OZ.js +0 -1
  83. package/dist/client/assets/flowDiagram-23GEKE2U-CUBp7I9C.js +0 -1
  84. package/dist/client/assets/fr-FR-RHASNOE6-9Vmv2nhB.js +0 -9
  85. package/dist/client/assets/ganttDiagram-NO4QXBWP-DNoDTMaW.js +0 -292
  86. package/dist/client/assets/gitGraph-TEB2WS4Q-BdzEwYG8.js +0 -1
  87. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-Coi-sbuV.js +0 -106
  88. package/dist/client/assets/gl-ES-HMX3MZ6V-CrTqlGTr.js +0 -10
  89. package/dist/client/assets/graphlib-B8gBHxth.js +0 -1
  90. package/dist/client/assets/he-IL-6SHJWFNN-BDoFv8vR.js +0 -10
  91. package/dist/client/assets/hi-IN-IWLTKZ5I-DwqsXYOg.js +0 -4
  92. package/dist/client/assets/hu-HU-A5ZG7DT2-Bfb8Cu1b.js +0 -7
  93. package/dist/client/assets/id-ID-SAP4L64H-DOW7LO7-.js +0 -10
  94. package/dist/client/assets/image-GAAHSSAO-BGgn-PVN.js +0 -1
  95. package/dist/client/assets/image-blob-reduce.esm-DLFdRAwc.js +0 -2
  96. package/dist/client/assets/index-oPO4_QxD.js +0 -270
  97. package/dist/client/assets/index-pqEtQj4K.css +0 -1
  98. package/dist/client/assets/info-DKCQHKI2-DrYXVDvR.js +0 -1
  99. package/dist/client/assets/infoDiagram-FWYZ7A6U-lZtFhAXP.js +0 -2
  100. package/dist/client/assets/init-D6jRqBbL.js +0 -1
  101. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-BREj5_gD.js +0 -70
  102. package/dist/client/assets/it-IT-JPQ66NNP-Bgr9Cu8C.js +0 -11
  103. package/dist/client/assets/ja-JP-DBVTYXUO-BGNdWvdH.js +0 -8
  104. package/dist/client/assets/journeyDiagram-5HDEW3XC-CMBikQPO.js +0 -139
  105. package/dist/client/assets/kaa-6HZHGXH3-CRDlnQ9T.js +0 -1
  106. package/dist/client/assets/kab-KAB-ZGHBKWFO-C4Lqev4p.js +0 -8
  107. package/dist/client/assets/kanban-definition-HUTT4EX6-BWBx3j_A.js +0 -89
  108. package/dist/client/assets/katex-CddkPoXu.js +0 -257
  109. package/dist/client/assets/kk-KZ-P5N5QNE5-DgFr17Od.js +0 -1
  110. package/dist/client/assets/km-KH-HSX4SM5Z-BDvXuwGA.js +0 -11
  111. package/dist/client/assets/ko-KR-MTYHY66A-BNhHcL3I.js +0 -9
  112. package/dist/client/assets/ku-TR-6OUDTVRD-DNktxnYd.js +0 -9
  113. package/dist/client/assets/line-Dt0OF9ws.js +0 -1
  114. package/dist/client/assets/linear-Dcedcc10.js +0 -1
  115. package/dist/client/assets/lt-LT-XHIRWOB4-aRviZ0MH.js +0 -3
  116. package/dist/client/assets/lv-LV-5QDEKY6T-CoVYcDKN.js +0 -7
  117. package/dist/client/assets/map-DsCK-0Cs.js +0 -1
  118. package/dist/client/assets/mermaid-parser.core-PdKOsWiX.js +0 -7
  119. package/dist/client/assets/mindmap-definition-LN4V7U3C-CQ5v-EBF.js +0 -96
  120. package/dist/client/assets/mr-IN-CRQNXWMA-BxQJIhtJ.js +0 -13
  121. package/dist/client/assets/my-MM-5M5IBNSE-FhM1FFVw.js +0 -1
  122. package/dist/client/assets/nb-NO-T6EIAALU-CU26LQ4M.js +0 -10
  123. package/dist/client/assets/nl-NL-IS3SIHDZ-DEbkQpIZ.js +0 -8
  124. package/dist/client/assets/nn-NO-6E72VCQL-DTXzh0og.js +0 -8
  125. package/dist/client/assets/oc-FR-POXYY2M6-BMWu-GuX.js +0 -8
  126. package/dist/client/assets/ordinal-hYBb2elL.js +0 -1
  127. package/dist/client/assets/pa-IN-N4M65BXN-BvvaU4wr.js +0 -4
  128. package/dist/client/assets/packet-7NZHBO7P-D3npuWOU.js +0 -1
  129. package/dist/client/assets/path-BWPyau1x.js +0 -1
  130. package/dist/client/assets/pegDiagram-2B236MQR-BRo6DMPk.js +0 -1
  131. package/dist/client/assets/percentages-BXMCSKIN-EXL0wbvf.js +0 -1
  132. package/dist/client/assets/pica-LQ2hHv1b.js +0 -2
  133. package/dist/client/assets/pie-RZYD4A2V-CNkV5sKG.js +0 -1
  134. package/dist/client/assets/pieDiagram-ENE6RG2P-DjyEljFW.js +0 -39
  135. package/dist/client/assets/pl-PL-T2D74RX3-Q4-sfWh2.js +0 -9
  136. package/dist/client/assets/pt-BR-5N22H2LF-D3I0MlSt.js +0 -9
  137. package/dist/client/assets/pt-PT-UZXXM6DQ-CjvJgWuJ.js +0 -9
  138. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-DwPjmUqM.js +0 -7
  139. package/dist/client/assets/radar-I7S5WNFK-BUDPffFb.js +0 -1
  140. package/dist/client/assets/railroad-3IZDKUUU-Cdo3kV8g.js +0 -1
  141. package/dist/client/assets/railroad-abnf-AHOZXSZD-DhwVR0O6.js +0 -1
  142. package/dist/client/assets/railroad-ebnf-EBAXGLYW-BH-sR6KG.js +0 -1
  143. package/dist/client/assets/railroad-peg-LSFZ7HO6-DNVw0v5I.js +0 -1
  144. package/dist/client/assets/railroadDiagram-RFXS5EU6-DM4LPeTp.js +0 -1
  145. package/dist/client/assets/requirementDiagram-TGXJPOKE-BgufYhbt.js +0 -84
  146. package/dist/client/assets/ro-RO-JPDTUUEW-DTqaPOB6.js +0 -11
  147. package/dist/client/assets/rolldown-runtime-BHe-jwch.js +0 -1
  148. package/dist/client/assets/rough.esm-CSKSodPl.js +0 -1
  149. package/dist/client/assets/roundRect-D01gJrlt.js +0 -1
  150. package/dist/client/assets/ru-RU-B4JR7IUQ-DGLVQl1a.js +0 -9
  151. package/dist/client/assets/sankeyDiagram-HTMAVEWB-B1Sjq8RS.js +0 -40
  152. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D5SV4V4W.js +0 -162
  153. package/dist/client/assets/si-LK-N5RQ5JYF-C3gSdqxC.js +0 -1
  154. package/dist/client/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
  155. package/dist/client/assets/sk-SK-C5VTKIMK-Bg1sgW2y.js +0 -6
  156. package/dist/client/assets/sl-SI-NN7IZMDC-5tRgzcvI.js +0 -6
  157. package/dist/client/assets/src-DpN7Pt3R.js +0 -1
  158. package/dist/client/assets/stateDiagram-2N3HPSRC-BDd2Qyke.js +0 -1
  159. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc9mjKnL.js +0 -1
  160. package/dist/client/assets/subset-shared.chunk-CBqF3lLT.js +0 -1
  161. package/dist/client/assets/subset-worker.chunk-kFoGWzk5.js +0 -1
  162. package/dist/client/assets/sv-SE-XGPEYMSR-BOzuSEwa.js +0 -10
  163. package/dist/client/assets/swimlanes-5IMT3BWC-DjFw-CEo.js +0 -2
  164. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DxdVR5MZ.js +0 -8
  165. package/dist/client/assets/ta-IN-2NMHFXQM-C7WRODt_.js +0 -9
  166. package/dist/client/assets/th-TH-HPSO5L25-WFuWcN58.js +0 -2
  167. package/dist/client/assets/timeline-definition-FHXFAJF6-BItaRl8g.js +0 -120
  168. package/dist/client/assets/tr-TR-DEFEU3FU-DtSAkhUR.js +0 -7
  169. package/dist/client/assets/treeView-QDETBFTQ-BsqpkMak.js +0 -1
  170. package/dist/client/assets/treemap-6X3UGDF4-CF2ZChjy.js +0 -1
  171. package/dist/client/assets/uk-UA-QMV73CPH-BLNYCDf-.js +0 -6
  172. package/dist/client/assets/vennDiagram-L72KCM5P-DkVOQsDM.js +0 -34
  173. package/dist/client/assets/vi-VN-M7AON7JQ-CC-oykOI.js +0 -5
  174. package/dist/client/assets/wardley-OPB4EBWU-BfqiNa2m.js +0 -1
  175. package/dist/client/assets/wardleyDiagram-EHGQE667-C7jTfbZa.js +0 -78
  176. package/dist/client/assets/xychartDiagram-FW5EYKEG-KJVcpo2q.js +0 -7
  177. package/dist/client/assets/zh-CN-LNUGB5OW-BpxSN9jo.js +0 -10
  178. package/dist/client/assets/zh-HK-E62DVLB3-BVqdbOUX.js +0 -1
  179. package/dist/client/assets/zh-TW-RAJ6MFWO-c__UZRJ7.js +0 -9
package/dist/cli/index.js CHANGED
@@ -1,18 +1,347 @@
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 existsSync8, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
5
+ import { homedir as homedir4 } from "node:os";
6
+ import { dirname as dirname6, extname, join as join11, normalize as normalize2, resolve as resolve4 } 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
- import { WebSocketServer } from "ws";
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 {
125
+ type: "start",
126
+ sessionId: message.session_id,
127
+ ...message.apiKeySource !== void 0 ? { apiKeySource: message.apiKeySource } : {}
128
+ };
129
+ }
130
+ return;
131
+ }
132
+ case "assistant": {
133
+ const assistant = message;
134
+ for (const block of assistant.message.content) {
135
+ if (block.type === "text") {
136
+ yield { type: "text", text: block.text };
137
+ } else if (block.type === "thinking") {
138
+ yield { type: "thinking", text: block.thinking };
139
+ } else if (block.type === "tool_use") {
140
+ yield { type: "tool_use", id: block.id, name: block.name, input: block.input };
141
+ }
142
+ }
143
+ return;
144
+ }
145
+ case "user": {
146
+ const user = message;
147
+ const content = user.message.content;
148
+ if (typeof content === "string") return;
149
+ for (const block of content) {
150
+ if (block.type === "tool_result") {
151
+ yield {
152
+ type: "tool_result",
153
+ id: block.tool_use_id,
154
+ isError: block.is_error ?? false,
155
+ content: block.content
156
+ };
157
+ }
158
+ }
159
+ return;
160
+ }
161
+ case "result": {
162
+ if (message.subtype === "success") {
163
+ const missingStructured = expectStructuredOutput && message.structured_output === void 0;
164
+ yield {
165
+ type: "result",
166
+ text: message.result,
167
+ costUsd: message.total_cost_usd,
168
+ isError: message.is_error || missingStructured,
169
+ ...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
170
+ };
171
+ } else {
172
+ yield {
173
+ type: "result",
174
+ text: message.errors.join("\n"),
175
+ costUsd: message.total_cost_usd,
176
+ isError: true
177
+ };
178
+ }
179
+ return;
180
+ }
181
+ default:
182
+ return;
183
+ }
184
+ }
185
+ function buildOptions(input, resolved) {
186
+ const profile = input.profile;
187
+ const options = {
188
+ cwd: input.cwd,
189
+ // CRITICAL: an EXPLICIT settingSources: [] loads NOTHING. (The SDK's own
190
+ // default — when the option is omitted — loads ALL sources; we always
191
+ // pass the profile's list so inheritance is an explicit choice, and the
192
+ // profile's sources are what yield LifeOS inheritance.)
193
+ settingSources: profile.settingSources,
194
+ // Never 'bypassPermissions' / 'acceptEdits' — PermissionProfile's type
195
+ // only admits 'default' | 'plan' | 'dontAsk'.
196
+ permissionMode: profile.permissionMode,
197
+ allowedTools: profile.allowedTools,
198
+ canUseTool: makeGate(profile),
199
+ // Containment: destructive-Bash denial must survive a bare allowlisted
200
+ // `Bash` (allow rules short-circuit canUseTool), so it is enforced by a
201
+ // PreToolUse hook, which the SDK evaluates before allow rules.
202
+ hooks: { PreToolUse: [destructiveBashHook(profile)] },
203
+ // Options.env REPLACES the subprocess environment (no merge). The
204
+ // resolver hands back the FULL inherited env (HOME/PATH preserved) with
205
+ // ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN stripped on the subscription
206
+ // path, so the subprocess cannot prefer an API key over the login.
207
+ env: resolved.subprocessEnv
208
+ };
209
+ if (profile.disallowedTools) {
210
+ options.disallowedTools = profile.disallowedTools;
211
+ }
212
+ if (profile.mcpServers) {
213
+ options.mcpServers = profile.mcpServers;
214
+ }
215
+ if (profile.strictMcp !== void 0) {
216
+ options.strictMcpConfig = profile.strictMcp;
217
+ }
218
+ if (input.outputSchema) {
219
+ options.outputFormat = { type: "json_schema", schema: input.outputSchema };
220
+ }
221
+ return options;
222
+ }
223
+ function verifyBilling(resolved, apiKeySource, notify) {
224
+ if (apiKeySource === void 0) return;
225
+ const billedViaApiKey = apiKeySource !== "none";
226
+ if (resolved.path === "subscription" && billedViaApiKey) {
227
+ notify(
228
+ `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.`
229
+ );
230
+ } else if (resolved.path !== "subscription" && !billedViaApiKey) {
231
+ notify(
232
+ `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.`
233
+ );
234
+ }
235
+ }
236
+ 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.";
237
+ function isSdkAbsence(error) {
238
+ const code = error?.code;
239
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
240
+ const message = error instanceof Error ? error.message : String(error);
241
+ return /Native CLI binary.*not found/i.test(message);
242
+ }
243
+ async function loadSdkQuery(loader) {
244
+ try {
245
+ const mod = await (loader ?? (() => import("@anthropic-ai/claude-agent-sdk")))();
246
+ return mod.query;
247
+ } catch (error) {
248
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
249
+ throw error;
250
+ }
251
+ }
252
+ function createClaudeHarness(deps) {
253
+ return {
254
+ id: "claude",
255
+ async *run(input) {
256
+ const resolved = resolveModelCredential({
257
+ env: deps?.env,
258
+ home: deps?.home,
259
+ useApi: input.useApi
260
+ });
261
+ const notify = input.onNotice ?? ((message) => console.error(message));
262
+ notify(resolved.notice);
263
+ const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
264
+ const expectStructuredOutput = input.outputSchema !== void 0;
265
+ try {
266
+ const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
267
+ for await (const message of stream) {
268
+ if (message.type === "system" && message.subtype === "init") {
269
+ verifyBilling(resolved, message.apiKeySource, notify);
270
+ }
271
+ yield* mapMessage(message, expectStructuredOutput);
272
+ }
273
+ } catch (error) {
274
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
275
+ throw error;
276
+ }
277
+ }
278
+ };
279
+ }
280
+
281
+ // ../agent-harness/src/profiles.ts
282
+ var SIDE_EFFECT_GATED_TOOLS = ["SendMessage", "Send", "Deploy", "Publish"];
283
+ var BLOCKED_BASH_PATTERNS = [
284
+ /\bgit\s+push\b/,
285
+ /\bwrangler\s+deploy\b/,
286
+ /\brm\s+-rf\b/,
287
+ /\bnpm\s+publish\b/
288
+ ];
289
+ function blockedBashPatterns() {
290
+ return [...BLOCKED_BASH_PATTERNS];
291
+ }
292
+ function defaultHeadlessProfile(identity) {
293
+ return {
294
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch"],
295
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
296
+ allowGated: false,
297
+ blockedBashPatterns: blockedBashPatterns(),
298
+ permissionMode: "default",
299
+ settingSources: [],
300
+ identity
301
+ };
302
+ }
303
+ function lifeOSProfile(identity, mcpServers) {
304
+ const profile = {
305
+ allowedTools: [
306
+ "Read",
307
+ "Grep",
308
+ "Glob",
309
+ "WebFetch",
310
+ "WebSearch",
311
+ "Write",
312
+ "Edit",
313
+ "NotebookEdit",
314
+ // NOTE: a bare 'Bash' allow rule auto-approves the tool and
315
+ // short-circuits canUseTool — blockedBashPatterns stay enforced only
316
+ // because the harness installs them as a PreToolUse hook (claude.ts
317
+ // destructiveBashHook), which the SDK evaluates before allow rules.
318
+ "Bash",
319
+ "Agent",
320
+ "Task",
321
+ "Skill",
322
+ "TodoWrite"
323
+ ],
324
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
325
+ allowGated: false,
326
+ blockedBashPatterns: blockedBashPatterns(),
327
+ permissionMode: "default",
328
+ settingSources: ["user", "project", "local"],
329
+ identity
330
+ };
331
+ if (mcpServers) {
332
+ profile.mcpServers = mcpServers;
333
+ profile.strictMcp = true;
334
+ }
335
+ return profile;
336
+ }
337
+
338
+ // server/cli.ts
339
+ import "ws";
12
340
 
13
341
  // server/app.ts
14
342
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
15
343
  import { Hono } from "hono";
344
+ import { z as z2 } from "zod";
16
345
 
17
346
  // src/lib/schema.ts
18
347
  import { z } from "zod";
@@ -137,8 +466,8 @@ var SessionSnapshot = z.object({
137
466
  });
138
467
 
139
468
  // server/references.ts
140
- import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
141
- import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
469
+ import { readFileSync, readdirSync as readdirSync2, realpathSync, statSync } from "node:fs";
470
+ import { basename, isAbsolute, join as join2, normalize, relative, resolve, sep } from "node:path";
142
471
  var REF_FILE_BYTE_LIMIT = 64 * 1024;
143
472
  var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
144
473
  var REF_DIR_FILE_LIMIT = 50;
@@ -164,14 +493,14 @@ function collectEntries(root) {
164
493
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
165
494
  let entries;
166
495
  try {
167
- entries = readdirSync(dir, { withFileTypes: true });
496
+ entries = readdirSync2(dir, { withFileTypes: true });
168
497
  } catch {
169
498
  return;
170
499
  }
171
500
  for (const entry of entries) {
172
501
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
173
502
  if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
174
- const full = join(dir, entry.name);
503
+ const full = join2(dir, entry.name);
175
504
  if (entry.isDirectory()) {
176
505
  dirs.push(relative(root, full));
177
506
  walk(full);
@@ -258,7 +587,7 @@ function expandReferences(text, root) {
258
587
  if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
259
588
  let body;
260
589
  try {
261
- body = readClamped(join(rootReal, relPath));
590
+ body = readClamped(join2(rootReal, relPath));
262
591
  } catch {
263
592
  continue;
264
593
  }
@@ -305,7 +634,7 @@ function resolveOne(rootReal, ref) {
305
634
  if (real !== rootReal && !insideRoot(rootReal, real)) {
306
635
  return { reason: "resolves outside the working directory" };
307
636
  }
308
- const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
637
+ const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join2(direct, p))).sort((a, b) => {
309
638
  const depth = a.split(sep).length - b.split(sep).length;
310
639
  return depth !== 0 ? depth : a.localeCompare(b);
311
640
  });
@@ -331,7 +660,7 @@ function resolveOne(rootReal, ref) {
331
660
  if (matches.length === 0) return { reason: "not found" };
332
661
  const best = matches[0];
333
662
  try {
334
- const body = readClamped(join(rootReal, best));
663
+ const body = readClamped(join2(rootReal, best));
335
664
  return {
336
665
  path: best,
337
666
  ...body,
@@ -513,6 +842,7 @@ function bundleFilePaths(b) {
513
842
  ...b.specs.map((s) => `specs/${s.capability}/spec.md`)
514
843
  ];
515
844
  }
845
+ var SPEC_BUNDLE_JSON_SCHEMA = z2.toJSONSchema(SpecBundle, { target: "draft-7" });
516
846
  var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
517
847
  var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
518
848
  function parseWithModelGate(anthropic, request) {
@@ -546,6 +876,9 @@ function anthropicErrorResponse(c, err, kind) {
546
876
  }
547
877
  function createApp(anthropic, workspace, getShare, options) {
548
878
  const refRoot = options === void 0 ? process.cwd() : options.refRoot;
879
+ const harness = options?.harness;
880
+ const reviewProfile = lifeOSProfile("specsketch-review");
881
+ const generateProfile = defaultHeadlessProfile("specsketch-generate");
549
882
  const app = new Hono();
550
883
  app.get("/api/health", (c) => c.json({ ok: true }));
551
884
  app.get(
@@ -624,25 +957,65 @@ Produce the OpenSpec change proposal for this architecture as structured output.
624
957
  Referenced files (resolved from @-references in the input):
625
958
 
626
959
  ${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);
960
+ let output;
961
+ if (harness) {
962
+ let structured;
963
+ let resultText = "";
964
+ let resultError = false;
965
+ try {
966
+ for await (const ev of harness.run({
967
+ prompt: `${system}
968
+
969
+ ${prompt}`,
970
+ cwd: refRoot ?? process.cwd(),
971
+ session: workspace?.changeName ?? "specsketch-generate",
972
+ profile: generateProfile,
973
+ outputSchema: SPEC_BUNDLE_JSON_SCHEMA
974
+ })) {
975
+ if (ev.type === "result") {
976
+ structured = ev.structuredOutput;
977
+ resultText = ev.text;
978
+ resultError = ev.isError;
979
+ }
980
+ }
981
+ } catch (err) {
982
+ return anthropicErrorResponse(c, err, "generation-failed");
983
+ }
984
+ if (structured === void 0) {
985
+ return resultError ? c.json(
986
+ { error: "generation-failed", detail: resultText || "the agent run failed" },
987
+ 502
988
+ ) : c.json({ error: "model returned no structured output" }, 502);
989
+ }
990
+ const checked = SpecBundle.safeParse(structured);
991
+ if (!checked.success) {
992
+ return c.json(
993
+ { error: "model returned invalid structured output", detail: checked.error.issues },
994
+ 502
995
+ );
996
+ }
997
+ output = checked.data;
998
+ } else {
999
+ let msg;
1000
+ try {
1001
+ msg = await parseWithModelGate(anthropic, {
1002
+ model,
1003
+ max_tokens: 16e3,
1004
+ // non-streaming stays under the SDK HTTP timeout
1005
+ system,
1006
+ messages: [{ role: "user", content: prompt }],
1007
+ output_config: { format: zodOutputFormat(SpecBundle) }
1008
+ // No temperature/top_p or thinking config — both 400 on modern models.
1009
+ });
1010
+ } catch (err) {
1011
+ return anthropicErrorResponse(c, err, "generation-failed");
1012
+ }
1013
+ if (msg.stop_reason === "refusal") {
1014
+ return c.json({ error: "refusal", detail: msg.stop_details }, 422);
1015
+ }
1016
+ if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
1017
+ output = SpecBundle.parse(msg.parsed_output);
643
1018
  }
644
- if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
645
- const output = SpecBundle.parse(msg.parsed_output);
646
1019
  const bundle = workspace ? { ...output, changeName: workspace.changeName } : output;
647
1020
  const changedFiles = workspace ? workspace.writeBundle(bundle) : bundleFilePaths(bundle);
648
1021
  const seed = await makeSeed(anthropic, model, conversation, changedFiles, bundle);
@@ -654,43 +1027,56 @@ ${refContext}` : "");
654
1027
  if (!parsed.success) {
655
1028
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
656
1029
  }
657
- const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
1030
+ const { message, conversation } = parsed.data;
658
1031
  const bundle = parsed.data.bundle ?? workspace?.toBundle() ?? null;
659
1032
  if (!bundle) return c.json({ error: "no spec to chat about \u2014 generate one first" }, 400);
1033
+ if (!harness) {
1034
+ return c.json(
1035
+ { error: "chat-unavailable", detail: "no agent harness configured for this server" },
1036
+ 500
1037
+ );
1038
+ }
660
1039
  const refs = expandReferences(message, refRoot);
661
1040
  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 ? `
1041
+ const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
1042
+ ${m.text}`).join("\n\n");
1043
+ const prompt = `${CHAT_SYSTEM}
1044
+
1045
+ ${renderBundleContext(bundle)}` + (history ? `
1046
+
1047
+ Conversation so far:
1048
+
1049
+ ${history}` : "") + `
1050
+
1051
+ [user]
1052
+ ${message}` + (refContext ? `
679
1053
 
680
1054
  Referenced files (resolved from @-references in the message):
681
1055
 
682
- ${refContext}` : "")
683
- }
684
- ]
685
- });
1056
+ ${refContext}` : "");
1057
+ let answer = "";
1058
+ let resultText = "";
1059
+ let resultError = false;
1060
+ try {
1061
+ for await (const ev of harness.run({
1062
+ prompt,
1063
+ cwd: refRoot ?? process.cwd(),
1064
+ session: workspace?.changeName ?? bundle.changeName ?? "specsketch-review",
1065
+ profile: reviewProfile
1066
+ })) {
1067
+ if (ev.type === "text") answer += ev.text;
1068
+ else if (ev.type === "result") {
1069
+ resultText = ev.text;
1070
+ resultError = ev.isError;
1071
+ }
1072
+ }
686
1073
  } catch (err) {
687
1074
  return anthropicErrorResponse(c, err, "chat-failed");
688
1075
  }
689
- if (msg.stop_reason === "refusal") {
690
- return c.json({ error: "refusal", detail: msg.stop_details }, 422);
1076
+ const text = answer.trim() || resultText.trim();
1077
+ if (!text) {
1078
+ return resultError ? c.json({ error: "chat-failed", detail: resultText || "the agent run failed" }, 502) : c.json({ error: "model returned no text" }, 502);
691
1079
  }
692
- const text = textOf(msg);
693
- if (!text) return c.json({ error: "model returned no text" }, 502);
694
1080
  const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
695
1081
  workspace?.writeChat([
696
1082
  ...conversation,
@@ -710,28 +1096,23 @@ ${refContext}` : "")
710
1096
  return app;
711
1097
  }
712
1098
 
713
- // server/coop-auth.ts
714
- import { homedir as homedir2 } from "node:os";
715
- import { join as join3 } from "node:path";
716
-
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";
721
1102
  import { createServer } from "node:http";
722
1103
  import { hostname as hostname2 } from "node:os";
723
1104
  import { URL as URL2 } from "node:url";
724
1105
 
725
1106
  // ../shared/src/cli-config.ts
726
1107
  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";
1108
+ import { homedir as homedir2, hostname } from "node:os";
1109
+ import { join as join3, dirname } from "node:path";
729
1110
 
730
1111
  // ../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([
1112
+ import { z as z3 } from "zod";
1113
+ var planTierSchema = z3.enum(["hobby", "squad", "org"]);
1114
+ var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
1115
+ var locationHintSchema = z3.enum([
735
1116
  "wnam",
736
1117
  "enam",
737
1118
  "weur",
@@ -742,135 +1123,135 @@ var locationHintSchema = z2.enum([
742
1123
  "me",
743
1124
  "sam"
744
1125
  ]);
745
- var sessionNameSchema = z2.string().min(1).max(63).regex(
1126
+ var sessionNameSchema = z3.string().min(1).max(63).regex(
746
1127
  /^[a-z0-9][a-z0-9-]*$/,
747
1128
  "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
748
1129
  );
749
- var teamNameSchema = z2.string().min(1).max(63).regex(
1130
+ var teamNameSchema = z3.string().min(1).max(63).regex(
750
1131
  /^[a-z0-9][a-z0-9-]*$/,
751
1132
  "Team name must be lowercase alphanumeric with hyphens"
752
1133
  );
753
- var messageKindSchema = z2.enum(["text", "context", "request", "system"]);
754
- var messageContentSchema = z2.object({
1134
+ var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
1135
+ var messageContentSchema = z3.object({
755
1136
  kind: messageKindSchema,
756
1137
  // No .max() here: the 10 000-char body limit is business logic owned by the
757
1138
  // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
758
1139
  // both REST and WS. A schema cap would pre-empt it as a generic 400.
759
- body: z2.string().min(1),
1140
+ body: z3.string().min(1),
760
1141
  // Two-arg z.record: identical semantics in zod 3, required form in zod 4
761
1142
  // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
762
- metadata: z2.record(z2.string(), z2.unknown()).optional()
1143
+ metadata: z3.record(z3.string(), z3.unknown()).optional()
763
1144
  });
764
- var createTeamInputSchema = z2.object({
1145
+ var createTeamInputSchema = z3.object({
765
1146
  name: teamNameSchema
766
1147
  });
767
- var createSessionInputSchema = z2.object({
1148
+ var createSessionInputSchema = z3.object({
768
1149
  name: sessionNameSchema,
769
- teamId: z2.string().optional(),
1150
+ teamId: z3.string().optional(),
770
1151
  jurisdiction: jurisdictionSchema.optional(),
771
1152
  locationHint: locationHintSchema.optional()
772
1153
  });
773
- var sendMessageInputSchema = z2.object({
1154
+ var sendMessageInputSchema = z3.object({
774
1155
  content: messageContentSchema
775
1156
  });
776
- var inviteInputSchema = z2.object({
777
- email: z2.string().email(),
778
- role: z2.enum(["admin", "member"]).default("member")
1157
+ var inviteInputSchema = z3.object({
1158
+ email: z3.string().email(),
1159
+ role: z3.enum(["admin", "member"]).default("member")
779
1160
  });
780
- var joinTeamInputSchema = z2.object({
781
- inviteCode: z2.string().min(1)
1161
+ var joinTeamInputSchema = z3.object({
1162
+ inviteCode: z3.string().min(1)
782
1163
  });
783
- var markReadInputSchema = z2.object({
784
- notificationIds: z2.array(z2.string().min(1))
1164
+ var markReadInputSchema = z3.object({
1165
+ notificationIds: z3.array(z3.string().min(1))
785
1166
  });
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()
1167
+ var pollQuerySchema = z3.object({
1168
+ since: z3.string().optional(),
1169
+ limit: z3.coerce.number().int().min(1).max(100).default(50),
1170
+ session: z3.string().optional()
790
1171
  });
791
- var messagesQuerySchema = z2.object({
792
- cursor: z2.string().optional(),
793
- limit: z2.coerce.number().int().min(1).max(100).default(50)
1172
+ var messagesQuerySchema = z3.object({
1173
+ cursor: z3.string().optional(),
1174
+ limit: z3.coerce.number().int().min(1).max(100).default(50)
794
1175
  });
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),
1176
+ var teamTypeSchema = z3.enum(["personal", "shared"]);
1177
+ var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
1178
+ var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
1179
+ var accessGrantInputSchema = z3.object({
1180
+ userId: z3.string().min(1),
800
1181
  sessionName: sessionNameSchema.nullable().default(null),
801
1182
  // null = general
802
1183
  permission: accessPermissionSchema
803
1184
  });
804
- var machineIdSchema = z2.string().min(1).max(63).regex(
1185
+ var machineIdSchema = z3.string().min(1).max(63).regex(
805
1186
  /^[a-z0-9][a-z0-9-]*$/,
806
1187
  "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
807
1188
  );
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)
1189
+ var clerkProfileInputSchema = z3.object({
1190
+ email: z3.string().email().nullable(),
1191
+ displayName: z3.string().min(1).max(100),
1192
+ avatarUrl: z3.string().url().nullable(),
1193
+ provider: z3.string().min(1).max(50),
1194
+ providerUserId: z3.string().min(1).max(100)
814
1195
  });
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()
1196
+ var sessionEnrollmentSchema = z3.object({
1197
+ sessionId: z3.string(),
1198
+ team: z3.string(),
1199
+ teamName: z3.string().optional(),
1200
+ key: z3.string().startsWith("api_"),
1201
+ enrolledAt: z3.string().datetime(),
1202
+ apiUrl: z3.string().url().optional(),
1203
+ machineId: z3.string().optional()
823
1204
  });
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()
1205
+ var authConfigSchema = z3.object({
1206
+ key: z3.string().startsWith("api_"),
1207
+ userId: z3.string().min(1),
1208
+ displayName: z3.string().nullable().optional().default(null),
1209
+ email: z3.string().email().nullable(),
1210
+ machineId: z3.string().nullable(),
1211
+ authenticatedAt: z3.string().datetime()
831
1212
  });
832
1213
  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()
1214
+ apiUrl: z3.string().url(),
1215
+ machineId: z3.string().nullable(),
1216
+ sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1217
+ defaults: z3.object({
1218
+ team: z3.string().nullable(),
1219
+ session: z3.string().nullable()
839
1220
  }),
840
- poll: z2.object({
841
- lastPollAt: z2.string().datetime().nullable()
1221
+ poll: z3.object({
1222
+ lastPollAt: z3.string().datetime().nullable()
842
1223
  })
843
1224
  };
844
- var coopConfigV1Schema = z2.object({
845
- version: z2.literal(1),
1225
+ var coopConfigV1Schema = z3.object({
1226
+ version: z3.literal(1),
846
1227
  ...configBaseFields
847
1228
  });
848
- var coopConfigV2Schema = z2.object({
849
- version: z2.literal(2),
1229
+ var coopConfigV2Schema = z3.object({
1230
+ version: z3.literal(2),
850
1231
  ...configBaseFields,
851
1232
  auth: authConfigSchema.optional()
852
1233
  });
853
- var profileNameSchema = z2.string().regex(
1234
+ var profileNameSchema = z3.string().regex(
854
1235
  /^[a-z0-9][a-z0-9_-]{0,63}$/,
855
1236
  "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
856
1237
  );
857
- var coopProfileSchema = z2.object({
858
- apiUrl: z2.string().url(),
1238
+ var coopProfileSchema = z3.object({
1239
+ apiUrl: z3.string().url(),
859
1240
  auth: authConfigSchema.optional(),
860
- sessions: z2.record(z2.string(), sessionEnrollmentSchema),
861
- defaults: z2.object({
862
- team: z2.string().nullable(),
863
- session: z2.string().nullable()
1241
+ sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1242
+ defaults: z3.object({
1243
+ team: z3.string().nullable(),
1244
+ session: z3.string().nullable()
864
1245
  }),
865
- poll: z2.object({
866
- lastPollAt: z2.string().datetime().nullable()
1246
+ poll: z3.object({
1247
+ lastPollAt: z3.string().datetime().nullable()
867
1248
  })
868
1249
  });
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)
1250
+ var coopConfigV3ObjectSchema = z3.object({
1251
+ version: z3.literal(3),
1252
+ machineId: z3.string().nullable(),
1253
+ defaultProfile: z3.string(),
1254
+ profiles: z3.record(profileNameSchema, coopProfileSchema)
874
1255
  });
875
1256
  function requireDefaultProfilePointer(config, ctx) {
876
1257
  if (!(config.defaultProfile in config.profiles)) {
@@ -884,7 +1265,7 @@ function requireDefaultProfilePointer(config, ctx) {
884
1265
  var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
885
1266
  requireDefaultProfilePointer
886
1267
  );
887
- var coopConfigSchema = z2.discriminatedUnion("version", [
1268
+ var coopConfigSchema = z3.discriminatedUnion("version", [
888
1269
  coopConfigV1Schema,
889
1270
  coopConfigV2Schema,
890
1271
  coopConfigV3ObjectSchema
@@ -893,20 +1274,39 @@ var coopConfigSchema = z2.discriminatedUnion("version", [
893
1274
  requireDefaultProfilePointer(config, ctx);
894
1275
  }
895
1276
  });
896
- var hookStdinSchema = z2.object({
897
- session_id: z2.string().optional(),
898
- cwd: z2.string().optional(),
899
- hook_event_name: z2.string().optional()
1277
+ var hookStdinSchema = z3.object({
1278
+ session_id: z3.string().optional(),
1279
+ cwd: z3.string().optional(),
1280
+ hook_event_name: z3.string().optional()
900
1281
  }).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") })
1282
+ var wsClientMessageSchema = z3.discriminatedUnion("type", [
1283
+ z3.object({ type: z3.literal("message"), content: messageContentSchema }),
1284
+ z3.object({ type: z3.literal("ack"), data: z3.object({ messageId: z3.string() }) }),
1285
+ z3.object({ type: z3.literal("presence_request") }),
1286
+ z3.object({ type: z3.literal("ping") })
905
1287
  ]);
1288
+ var workKindSchema = z3.enum(["implement", "generate"]);
1289
+ var workCompletionStatusSchema = z3.enum(["succeeded", "failed"]);
1290
+ var poolJoinInputSchema = z3.object({}).passthrough();
1291
+ var poolCheckInInputSchema = z3.object({}).passthrough();
1292
+ var poolCompleteInputSchema = z3.object({
1293
+ workId: z3.string().min(1),
1294
+ status: workCompletionStatusSchema,
1295
+ resultRef: z3.string().max(512),
1296
+ assignmentSeq: z3.number().int().nonnegative()
1297
+ });
1298
+ var poolEnqueueInputSchema = z3.object({
1299
+ kind: workKindSchema,
1300
+ source: z3.string().min(1),
1301
+ dedupKey: z3.string().min(1).max(512).optional()
1302
+ });
1303
+ var poolCancelInputSchema = z3.object({
1304
+ workId: z3.string().min(1)
1305
+ });
906
1306
 
907
1307
  // ../shared/src/cli-config.ts
908
- var DEFAULT_CONFIG_DIR = join2(homedir(), ".coopcli");
909
- var DEFAULT_CONFIG_PATH = join2(DEFAULT_CONFIG_DIR, "config.json");
1308
+ var DEFAULT_CONFIG_DIR = join3(homedir2(), ".coopcli");
1309
+ var DEFAULT_CONFIG_PATH = join3(DEFAULT_CONFIG_DIR, "config.json");
910
1310
  var PROD_API_URL = "https://api.coopcli.com";
911
1311
  function emptyProfile(apiUrl) {
912
1312
  return {
@@ -1238,8 +1638,8 @@ function resolveDisplayName(name, email, userId) {
1238
1638
  async function openBrowser(url) {
1239
1639
  const escaped = url.replace(/"/g, '\\"');
1240
1640
  const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
1241
- return new Promise((resolve4) => {
1242
- exec(cmd, () => resolve4());
1641
+ return new Promise((resolve5) => {
1642
+ exec(cmd, () => resolve5());
1243
1643
  });
1244
1644
  }
1245
1645
  var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
@@ -1324,14 +1724,15 @@ function deriveApiUrl(webUrl) {
1324
1724
  async function login(input, deps) {
1325
1725
  const state = randomBytes(16).toString("hex");
1326
1726
  const machineId = input.machine ?? hostname2();
1727
+ const log = deps.log ?? ((message) => void process.stderr.write(message));
1327
1728
  const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
1328
1729
  try {
1329
1730
  const webUrl = input.webUrl ?? "https://coopcli.com";
1330
1731
  const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
1331
- process.stderr.write(`Opening browser for authentication...
1732
+ log(`Opening browser for authentication...
1332
1733
  `);
1333
1734
  await deps.openBrowser(authUrl);
1334
- process.stderr.write(`Waiting for callback on port ${server.port}...
1735
+ log(`Waiting for callback on port ${server.port}...
1335
1736
  `);
1336
1737
  const params = await server.waitForCallback();
1337
1738
  const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
@@ -1348,7 +1749,7 @@ async function login(input, deps) {
1348
1749
  });
1349
1750
  const config = await deps.config.load();
1350
1751
  if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
1351
- process.stderr.write(
1752
+ log(
1352
1753
  `
1353
1754
  Backend mismatch: you authenticated against ${params.apiUrl}
1354
1755
  but your config points to ${config.apiUrl}
@@ -1372,73 +1773,6 @@ Backend mismatch: you authenticated against ${params.apiUrl}
1372
1773
  server.close();
1373
1774
  }
1374
1775
  }
1375
- var CliAuthError = class extends Error {
1376
- constructor(message, kind) {
1377
- super(message);
1378
- this.kind = kind;
1379
- this.name = "CliAuthError";
1380
- }
1381
- kind;
1382
- };
1383
- async function resolveCoopCredentials(opts) {
1384
- const env = opts.env ?? process.env;
1385
- const { configPath, loginHint } = opts;
1386
- if (!existsSync(configPath)) {
1387
- throw new CliAuthError(`No CoopCLI config found at ${configPath}.
1388
- ${loginHint}`, "missing-config");
1389
- }
1390
- const store = new ConfigStore(configPath);
1391
- let raw;
1392
- try {
1393
- raw = await store.loadRaw();
1394
- } catch {
1395
- throw new CliAuthError(
1396
- `Could not read CoopCLI credentials from ${configPath}.
1397
- ${loginHint}`,
1398
- "unreadable-config"
1399
- );
1400
- }
1401
- const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
1402
- const profile = raw.profiles[profileName];
1403
- if (!profile) {
1404
- const available = Object.keys(raw.profiles).join(", ");
1405
- throw new CliAuthError(
1406
- `CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
1407
- ${loginHint}`,
1408
- "unknown-profile"
1409
- );
1410
- }
1411
- if (!profile.auth) {
1412
- throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
1413
- ${loginHint}`, "no-login");
1414
- }
1415
- const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
1416
- return {
1417
- apiUrl,
1418
- key: profile.auth.key,
1419
- userId: profile.auth.userId,
1420
- displayName: profile.auth.displayName ?? null
1421
- };
1422
- }
1423
-
1424
- // server/coop-auth.ts
1425
- var CoopAuthError = class extends Error {
1426
- };
1427
- 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) {
1429
- try {
1430
- return await resolveCoopCredentials({
1431
- configPath,
1432
- env,
1433
- profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
1434
- apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
1435
- loginHint: LOGIN_HINT
1436
- });
1437
- } catch (err) {
1438
- if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
1439
- throw err;
1440
- }
1441
- }
1442
1776
 
1443
1777
  // server/login.ts
1444
1778
  import { parseArgs } from "node:util";
@@ -1479,886 +1813,2229 @@ async function runLogin(args, deps = {}) {
1479
1813
  if (profile) config.selectProfile(profile);
1480
1814
  const result = await login(
1481
1815
  { webUrl: args.webUrl, loginCommand: "specsketch login" },
1482
- { config, openBrowser: deps.openBrowser ?? openBrowser }
1816
+ // Forward the writer too, so a caller supplying one gets ALL of login's output
1817
+ // rather than just this command's closing line (issue #1).
1818
+ { config, openBrowser: deps.openBrowser ?? openBrowser, log: deps.log }
1483
1819
  );
1484
1820
  (deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
1485
1821
  }
1486
1822
 
1487
- // server/remote-session.ts
1488
- import { WebSocket } from "ws";
1823
+ // server/workspace.ts
1824
+ import { createHash as createHash2 } from "node:crypto";
1825
+ import {
1826
+ existsSync as existsSync4,
1827
+ mkdirSync as mkdirSync2,
1828
+ readFileSync as readFileSync3,
1829
+ readdirSync as readdirSync4,
1830
+ renameSync as renameSync2,
1831
+ writeFileSync as writeFileSync2
1832
+ } from "node:fs";
1833
+ import { basename as basename2, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
1834
+
1835
+ // ../shared/src/openspec-change-meta.ts
1836
+ import { createHash } from "node:crypto";
1837
+ import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
1838
+ import { join as join4 } from "node:path";
1839
+ import { Document, parseDocument } from "yaml";
1840
+ import { z as z4 } from "zod";
1489
1841
 
1490
- // ../shared/src/errors.ts
1491
- function isApiErrorBody(value) {
1492
- if (typeof value !== "object" || value === null) return false;
1493
- const v = value;
1494
- return typeof v.category === "string" && typeof v.code === "string" && typeof v.message === "string";
1842
+ // ../shared/src/fs-atomic.ts
1843
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
1844
+ import { dirname as dirname2 } from "node:path";
1845
+ function writeAtomicFile(target, content) {
1846
+ mkdirSync(dirname2(target), { recursive: true });
1847
+ const tmp = `${target}.tmp`;
1848
+ writeFileSync(tmp, content, "utf8");
1849
+ renameSync(tmp, target);
1850
+ }
1851
+ function readIfExists(target) {
1852
+ return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
1495
1853
  }
1496
1854
 
1497
- // ../shared/src/api-client.ts
1498
- var CoopApiError = class extends Error {
1499
- category;
1500
- code;
1501
- status;
1502
- details;
1503
- constructor(status, body) {
1504
- super(body.message);
1505
- this.name = "CoopApiError";
1506
- this.category = body.category;
1507
- this.code = body.code;
1508
- this.status = status;
1509
- this.details = body.details;
1855
+ // ../shared/src/openspec-change-meta.ts
1856
+ var CHANGE_META_FILE = "specplan.yaml";
1857
+ var nodeChangeMetaFs = {
1858
+ readIfExists(path) {
1859
+ return readIfExists(path);
1860
+ },
1861
+ writeAtomicFile(path, content) {
1862
+ writeAtomicFile(path, content);
1863
+ },
1864
+ exists(path) {
1865
+ return existsSync3(path);
1866
+ },
1867
+ readdir(path) {
1868
+ if (!existsSync3(path)) return [];
1869
+ return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
1870
+ name: entry.name,
1871
+ directory: entry.isDirectory()
1872
+ }));
1873
+ },
1874
+ realpath(path) {
1875
+ return realpathSync2(path);
1510
1876
  }
1511
1877
  };
1512
- function isCoopApiError(err) {
1513
- return err instanceof CoopApiError;
1514
- }
1515
- async function streamToBuffer(stream) {
1516
- const reader = stream.getReader();
1517
- const chunks = [];
1518
- for (; ; ) {
1519
- const { done, value } = await reader.read();
1520
- if (done) break;
1521
- chunks.push(value);
1522
- }
1523
- const total = chunks.reduce((sum, c) => sum + c.length, 0);
1524
- const result = new Uint8Array(total);
1525
- let offset = 0;
1526
- for (const chunk of chunks) {
1527
- result.set(chunk, offset);
1528
- offset += chunk.length;
1529
- }
1530
- return result;
1531
- }
1532
- var verbose = false;
1533
- var ApiClient = class _ApiClient {
1534
- constructor(apiUrl, token, userId) {
1535
- this.apiUrl = apiUrl;
1536
- this.token = token;
1537
- this.userId = userId;
1538
- }
1539
- apiUrl;
1540
- token;
1541
- userId;
1542
- /** Build a user-scoped path. Only callable when userId is set. */
1543
- userPath(sub) {
1544
- return `/api/u/${this.userId}${sub}`;
1545
- }
1546
- async request(method, path, body, params) {
1547
- const url = new URL(path, this.apiUrl);
1548
- if (params) {
1549
- for (const [key, val] of Object.entries(params)) {
1550
- if (val !== void 0) {
1551
- url.searchParams.set(key, String(val));
1552
- }
1553
- }
1554
- }
1555
- if (verbose) {
1556
- process.stderr.write(`[verbose] ${method} ${url.toString()}
1557
- `);
1558
- if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
1559
- `);
1560
- }
1561
- const res = await fetch(url.toString(), {
1562
- method,
1563
- headers: {
1564
- Authorization: `Bearer ${this.token}`,
1565
- "Content-Type": "application/json"
1566
- },
1567
- body: body !== void 0 ? JSON.stringify(body) : void 0
1568
- });
1569
- if (verbose) {
1570
- process.stderr.write(`[verbose] ${res.status} ${res.statusText}
1571
- `);
1572
- }
1573
- if (!res.ok) {
1574
- let parsed = null;
1575
- try {
1576
- const json = await res.json();
1577
- if (isApiErrorBody(json)) {
1578
- parsed = json;
1579
- } else {
1580
- const legacy = json;
1581
- parsed = {
1582
- category: res.status >= 500 ? "server_error" : "user_action",
1583
- code: "UNKNOWN",
1584
- message: legacy.error ?? legacy.message ?? res.statusText
1585
- };
1586
- }
1587
- } catch {
1588
- parsed = {
1589
- category: res.status >= 500 ? "server_error" : "user_action",
1590
- code: "UNKNOWN",
1591
- message: res.statusText
1592
- };
1593
- }
1594
- if (verbose) {
1595
- process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
1596
- `);
1597
- }
1598
- throw new CoopApiError(res.status, parsed);
1599
- }
1600
- return res.json();
1601
- }
1602
- async getMe() {
1603
- const path = this.userId ? this.userPath("/me") : "/api/me";
1604
- return this.request("GET", path);
1605
- }
1606
- async createTeam(input) {
1607
- if (this.userId) {
1608
- }
1609
- return this.request("POST", "/api/teams", input);
1610
- }
1611
- async listTeams() {
1612
- if (this.userId) {
1613
- const me = await this.request("GET", this.userPath("/me"));
1614
- return (me.teams ?? []).map((m) => ({
1615
- id: m.teamId,
1616
- name: m.teamName,
1617
- type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
1618
- plan: "hobby",
1619
- createdBy: "",
1620
- createdAt: m.joinedAt
1621
- }));
1622
- }
1623
- return this.request("GET", "/api/teams");
1624
- }
1625
- async inviteToTeam(team, input) {
1626
- return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
1627
- }
1628
- async joinTeam(team, inviteCode) {
1629
- await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
1630
- }
1631
- async listTeamMembers(team) {
1632
- return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
1633
- }
1634
- async createSession(input) {
1635
- const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
1636
- return this.request("POST", path, input);
1637
- }
1638
- async listSessions() {
1639
- const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
1640
- const result = await this.request("GET", path);
1641
- return result.sessions;
1642
- }
1643
- async joinSession(name) {
1644
- await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
1645
- }
1646
- async leaveSession(name) {
1647
- await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
1648
- }
1649
- async deleteSession(name) {
1650
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
1651
- return this.request("DELETE", path);
1652
- }
1653
- async sendMessage(teamId, session, input) {
1654
- if (this.userId) {
1655
- return this.request(
1656
- "POST",
1657
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1658
- input,
1659
- { team: teamId }
1660
- );
1661
- }
1662
- return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
1663
- }
1664
- async getMessages(teamId, session, cursor, limit) {
1665
- if (this.userId) {
1666
- return this.request(
1667
- "GET",
1668
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1669
- void 0,
1670
- { team: teamId, cursor, limit }
1671
- );
1672
- }
1673
- return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
1674
- cursor,
1675
- limit
1676
- });
1677
- }
1678
- async getLatestMessages(teamId, session, count = 5) {
1679
- if (this.userId) {
1680
- return this.request(
1681
- "GET",
1682
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1683
- void 0,
1684
- { team: teamId, latest: count }
1685
- );
1686
- }
1687
- return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
1688
- latest: count
1689
- });
1690
- }
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
- async listSessionFiles(teamId, sessionName) {
1700
- const result = await this.request(
1701
- "GET",
1702
- this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
1703
- void 0,
1704
- { team: teamId }
1705
- );
1706
- return result.files;
1707
- }
1708
- async getNotifications(params) {
1709
- const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
1710
- return this.request("GET", path, void 0, params);
1711
- }
1712
- async markRead(ids) {
1713
- const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
1714
- await this.request("POST", path, { notificationIds: ids });
1715
- }
1716
- /** List the caller's user-scoped API keys. Requires user-level auth. */
1717
- async listUserApiKeys() {
1718
- const body = await this.request("GET", this.userPath("/keys"));
1719
- return body.keys;
1878
+ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
1879
+ function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
1880
+ const files = [];
1881
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
1882
+ if (fsx.exists(join4(changeDir, name))) files.push(name);
1720
1883
  }
1721
- /** Revoke a user-scoped API key by id. Requires user-level auth. */
1722
- async revokeUserApiKey(id) {
1723
- await this.request("DELETE", this.userPath(`/keys/${encodeURIComponent(id)}`));
1884
+ const specsDir = join4(changeDir, "specs");
1885
+ for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
1886
+ const rel = join4("specs", entry.name, "spec.md");
1887
+ if (entry.directory && fsx.exists(join4(changeDir, rel))) files.push(rel);
1724
1888
  }
1889
+ return files;
1890
+ }
1891
+ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
1892
+ const files = listBodyFiles(changeDir, fsx);
1893
+ if (files.length === 0) return null;
1894
+ const parts = files.map(
1895
+ (rel) => `${rel}
1896
+ ${sha256(fsx.readIfExists(join4(changeDir, rel)) ?? "")}`
1897
+ );
1898
+ return `sha256:${sha256(parts.join("\n"))}`;
1899
+ }
1900
+ var SPEC_STATUSES = ["draft", "in-progress"];
1901
+ var SpecStatus = z4.enum(SPEC_STATUSES);
1902
+ var LEGACY_STATUS_COERCION = {
1903
+ active: "in-progress",
1904
+ done: "in-progress",
1905
+ deferred: "draft"
1906
+ };
1907
+ var StoredSpecStatus = z4.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
1908
+ (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
1909
+ );
1910
+ var SpecMetaSchema = z4.object({
1911
+ specId: z4.string(),
1912
+ userStories: z4.array(z4.string()),
1913
+ dependencies: z4.array(z4.string()),
1914
+ /** Working state (retired values coerce on read); absent = draft. */
1915
+ status: StoredSpecStatus.optional(),
1725
1916
  /**
1726
- * Create a live canvas session (specsketch --share host). User-scoped
1727
- * route; the caller validates the response shape against
1728
- * canvasSessionCreatedSchema.
1917
+ * Approved axis (formerly `applied`) fold-driven, SEPARATE from the
1918
+ * working status above. True when the proposal's delta has been folded
1919
+ * into specs/ and archived (apply-first: the fold happens at approval).
1920
+ * Absent = not-approved.
1729
1921
  */
1730
- async createCanvasSession(input) {
1731
- return this.request("POST", this.userPath("/canvas/sessions"), input);
1732
- }
1733
- /** End a canvas session server-side (invalidates the share link). */
1734
- async endCanvasSession(sessionId) {
1735
- await this.request("DELETE", this.userPath(`/canvas/sessions/${encodeURIComponent(sessionId)}`));
1736
- }
1737
- async uploadFile(teamId, session, filename, body, size) {
1738
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/upload`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/upload`;
1739
- const url = new URL(path, this.apiUrl);
1740
- if (this.userId) url.searchParams.set("team", teamId);
1741
- const form = new FormData();
1742
- const bytes = new Uint8Array(
1743
- body instanceof Buffer || body instanceof Uint8Array ? body : await streamToBuffer(body)
1744
- );
1745
- form.append("file", new Blob([bytes]), filename);
1746
- form.append("filename", filename);
1747
- const res = await fetch(url.toString(), {
1748
- method: "POST",
1749
- headers: { Authorization: `Bearer ${this.token}` },
1750
- body: form
1751
- });
1752
- if (!res.ok) {
1753
- const text = await res.text();
1754
- let msg = res.statusText;
1755
- try {
1756
- msg = JSON.parse(text).message ?? msg;
1757
- } catch {
1758
- }
1759
- throw new CoopApiError(res.status, { category: "user_action", code: "UPLOAD_FAILED", message: msg });
1760
- }
1761
- return res.json();
1922
+ approved: z4.boolean().optional(),
1923
+ /**
1924
+ * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
1925
+ * compatibility: readers treat `applied: true` as `approved: true`; no
1926
+ * writer ever sets this field again.
1927
+ */
1928
+ applied: z4.boolean().optional(),
1929
+ /**
1930
+ * Build flag user-set, independent of the fold: true once the code has
1931
+ * been built to match the approved diff. Absent = not-implemented.
1932
+ */
1933
+ implemented: z4.boolean().optional(),
1934
+ /**
1935
+ * Minimal implementation-task record written on fold (specplan-timeline-apply
1936
+ * "Implementation task record on fold"): the creation time and a reference to
1937
+ * the archived change's own id/path (from which the delta artifact is
1938
+ * materialized on demand). Deliberately carries NO status and NO dispatch
1939
+ * state task execution/dispatch is a separate, not-yet-defined concern. A
1940
+ * field addition to this sidecar, not a redefinition of its format.
1941
+ */
1942
+ task: z4.object({
1943
+ createdAt: z4.string(),
1944
+ source: z4.string()
1945
+ }).optional(),
1946
+ generation: z4.object({
1947
+ /** sha256 of the last generated body files — detects hand edits. */
1948
+ lastGeneratedBodyHash: z4.string(),
1949
+ lastGeneratedAt: z4.string()
1950
+ }).optional()
1951
+ });
1952
+ function writeChangeMeta(fsx, changeDir, meta) {
1953
+ const path = join4(changeDir, CHANGE_META_FILE);
1954
+ const raw = fsx.readIfExists(path);
1955
+ const doc = raw === void 0 ? new Document({
1956
+ specId: meta.specId,
1957
+ userStories: meta.userStories ?? [],
1958
+ dependencies: meta.dependencies ?? [],
1959
+ status: meta.status ?? "draft"
1960
+ }) : parseDocument(raw);
1961
+ if (meta.approved !== void 0) doc.setIn(["approved"], meta.approved);
1962
+ if (meta.implemented !== void 0) doc.setIn(["implemented"], meta.implemented);
1963
+ if (meta.task !== void 0) {
1964
+ doc.setIn(["task", "createdAt"], meta.task.createdAt);
1965
+ doc.setIn(["task", "source"], meta.task.source);
1966
+ }
1967
+ if (meta.generation !== void 0) {
1968
+ doc.setIn(["generation", "lastGeneratedBodyHash"], meta.generation.lastGeneratedBodyHash);
1969
+ doc.setIn(["generation", "lastGeneratedAt"], meta.generation.lastGeneratedAt);
1970
+ }
1971
+ fsx.writeAtomicFile(path, doc.toString());
1972
+ }
1973
+
1974
+ // server/workspace.ts
1975
+ var DIAGRAM_FILE = "diagram.excalidraw";
1976
+ var SIDECAR_FILE = "specsketch.json";
1977
+ var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
1978
+ var ChangeWorkspace = class {
1979
+ dir;
1980
+ changeName;
1981
+ /**
1982
+ * `create` defaults to true, which is the canvas-era contract: constructing a
1983
+ * workspace makes its directory. The workbench passes `false`, because opening a
1984
+ * change id that does not exist must not put an empty directory in the repository —
1985
+ * the next run would report `resuming` for a change nobody started, and
1986
+ * `openspec:check` WARNs about a change carrying no delta specs. `writeAtomic`
1987
+ * creates the directory when something is actually written, which is the first
1988
+ * moment the change is real.
1989
+ */
1990
+ constructor(dir, options = {}) {
1991
+ this.dir = resolve2(dir);
1992
+ this.changeName = basename2(this.dir);
1993
+ if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
1762
1994
  }
1763
- async downloadFile(teamId, session, fileId) {
1764
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`;
1765
- const url = new URL(path, this.apiUrl);
1766
- if (this.userId) url.searchParams.set("team", teamId);
1767
- const res = await fetch(url.toString(), {
1768
- method: "GET",
1769
- headers: { Authorization: `Bearer ${this.token}` }
1770
- });
1771
- if (!res.ok) {
1772
- const text = await res.text();
1773
- let msg = res.statusText;
1774
- try {
1775
- msg = JSON.parse(text).message ?? msg;
1776
- } catch {
1777
- }
1778
- throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
1779
- }
1780
- const disposition = res.headers.get("Content-Disposition") ?? "";
1781
- const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
1782
- const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
1783
- const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
1784
- return { filename, body: res.body, size };
1785
- }
1786
- getWebSocketUrl(session) {
1787
- const wsBase = this.apiUrl.replace(
1788
- /^https?:\/\//,
1789
- (match) => match === "https://" ? "wss://" : "ws://"
1790
- );
1791
- return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
1995
+ writeAtomic(relPath, content) {
1996
+ const target = join5(this.dir, relPath);
1997
+ mkdirSync2(dirname3(target), { recursive: true });
1998
+ const tmp = `${target}.tmp`;
1999
+ writeFileSync2(tmp, content, "utf8");
2000
+ renameSync2(tmp, target);
1792
2001
  }
1793
- withToken(token) {
1794
- return new _ApiClient(this.apiUrl, token, this.userId);
2002
+ readIfExists(relPath) {
2003
+ const target = join5(this.dir, relPath);
2004
+ return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
1795
2005
  }
1796
- };
1797
-
1798
- // ../shared/src/canvas-protocol.ts
1799
- import { z as z3 } from "zod";
1800
- 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()
1806
- }).passthrough();
1807
- var canvasFilesSchema = z3.record(z3.string(), z3.unknown());
1808
- var canvasSceneSchema = z3.object({
1809
- elements: z3.array(canvasElementSchema),
1810
- files: canvasFilesSchema.optional()
1811
- });
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()
1817
- });
1818
- var canvasHelloSchema = z3.object({
1819
- type: z3.literal("hello"),
1820
- protocolVersion: z3.number().int().positive(),
1821
- role: z3.enum(["host", "guest"]),
1822
- /** 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()
1825
- });
1826
- var canvasClientOpSchema = z3.object({
1827
- type: z3.literal("op"),
1828
- /** Changed or soft-deleted elements only — never the full scene. */
1829
- elements: z3.array(canvasElementSchema).min(1),
1830
- /** New binary files referenced by the changed elements, if any. */
1831
- files: canvasFilesSchema.optional(),
1832
- /** Client-local monotonic sequence, used by the sender for echo dedupe. */
1833
- clientSeq: z3.number().int().nonnegative()
1834
- });
1835
- var canvasClientMessageSchema = z3.discriminatedUnion("type", [
1836
- canvasHelloSchema,
1837
- canvasClientOpSchema
1838
- ]);
1839
- var canvasSnapshotSchema = z3.object({
1840
- type: z3.literal("snapshot"),
1841
- scene: canvasSceneSchema,
1842
- revision: z3.number().int().nonnegative(),
1843
- participants: z3.array(canvasParticipantSchema),
1844
- /** Echoes the server's protocol version so clients can detect skew. */
1845
- protocolVersion: z3.number().int().positive(),
1846
- /** The participant id the server assigned to (or kept for) this client. */
1847
- participantId: z3.string()
1848
- });
1849
- var canvasServerOpSchema = z3.object({
1850
- type: z3.literal("op"),
1851
- /** The elements that won reconciliation (never echoed to the sender). */
1852
- elements: z3.array(canvasElementSchema),
1853
- files: canvasFilesSchema.optional(),
1854
- revision: z3.number().int().positive(),
1855
- /** Participant id of the sender. */
1856
- from: z3.string()
1857
- });
1858
- var canvasPresenceSchema = z3.object({
1859
- type: z3.literal("presence"),
1860
- participants: z3.array(canvasParticipantSchema)
1861
- });
1862
- var canvasEndSchema = z3.object({
1863
- type: z3.literal("end"),
1864
- reason: z3.string()
1865
- });
1866
- var canvasErrorSchema = z3.object({
1867
- type: z3.literal("error"),
1868
- code: z3.string(),
1869
- message: z3.string()
1870
- });
1871
- var canvasServerMessageSchema = z3.discriminatedUnion("type", [
1872
- canvasSnapshotSchema,
1873
- canvasServerOpSchema,
1874
- canvasPresenceSchema,
1875
- canvasEndSchema,
1876
- canvasErrorSchema
2006
+ // ── Scene ────────────────────────────────────────────────────────────────
2007
+ loadScene() {
2008
+ const raw = this.readIfExists(DIAGRAM_FILE);
2009
+ if (!raw) return null;
2010
+ try {
2011
+ const parsed = JSON.parse(raw);
2012
+ return {
2013
+ elements: Array.isArray(parsed.elements) ? parsed.elements : [],
2014
+ appState: parsed.appState ?? void 0,
2015
+ files: parsed.files ?? void 0
2016
+ };
2017
+ } catch {
2018
+ return null;
2019
+ }
2020
+ }
2021
+ saveScene(scene, title, model) {
2022
+ this.writeAtomic(
2023
+ DIAGRAM_FILE,
2024
+ JSON.stringify(
2025
+ {
2026
+ type: "excalidraw",
2027
+ version: 2,
2028
+ source: "coopcli-specsketch",
2029
+ elements: scene.elements,
2030
+ appState: scene.appState ?? {},
2031
+ files: scene.files ?? {}
2032
+ },
2033
+ null,
2034
+ 2
2035
+ )
2036
+ );
2037
+ this.updateSidecar((s) => ({
2038
+ ...s,
2039
+ ...title !== void 0 ? { title } : {},
2040
+ ...model !== void 0 ? { model } : {}
2041
+ }));
2042
+ }
2043
+ // ── Sidecar ──────────────────────────────────────────────────────────────
2044
+ readSidecar() {
2045
+ const raw = this.readIfExists(SIDECAR_FILE);
2046
+ if (!raw) return {};
2047
+ try {
2048
+ return JSON.parse(raw);
2049
+ } catch {
2050
+ return {};
2051
+ }
2052
+ }
2053
+ updateSidecar(update) {
2054
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2055
+ const current = this.readSidecar();
2056
+ const next = update(current);
2057
+ next.createdAt ??= now;
2058
+ next.updatedAt = now;
2059
+ this.writeAtomic(SIDECAR_FILE, JSON.stringify(next, null, 2));
2060
+ }
2061
+ // ── Artifacts ────────────────────────────────────────────────────────────
2062
+ /** Current artifact files, however they were authored. */
2063
+ readArtifacts() {
2064
+ const specs = [];
2065
+ const specsDir = join5(this.dir, "specs");
2066
+ if (existsSync4(specsDir)) {
2067
+ for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
2068
+ if (!entry.isDirectory()) continue;
2069
+ const spec = this.readIfExists(join5("specs", entry.name, "spec.md"));
2070
+ if (spec !== void 0) specs.push({ capability: entry.name, spec });
2071
+ }
2072
+ }
2073
+ return {
2074
+ proposal: this.readIfExists("proposal.md"),
2075
+ design: this.readIfExists("design.md"),
2076
+ tasks: this.readIfExists("tasks.md"),
2077
+ specs
2078
+ };
2079
+ }
2080
+ /** Reconstruct a SpecBundle view from the files on disk, or null if none. */
2081
+ toBundle() {
2082
+ const a = this.readArtifacts();
2083
+ if (!a.proposal && !a.design && !a.tasks && a.specs.length === 0) return null;
2084
+ return {
2085
+ changeName: this.changeName,
2086
+ summary: this.readSidecar().summary ?? "",
2087
+ proposal: a.proposal ?? "",
2088
+ design: a.design ?? "",
2089
+ tasks: a.tasks ?? "",
2090
+ specs: a.specs
2091
+ };
2092
+ }
2093
+ /**
2094
+ * Materialize a generated bundle in OpenSpec layout, writing only files
2095
+ * whose content actually changed. Returns the relative paths written.
2096
+ */
2097
+ writeBundle(bundle) {
2098
+ const files = {
2099
+ "proposal.md": bundle.proposal,
2100
+ "design.md": bundle.design,
2101
+ "tasks.md": bundle.tasks
2102
+ };
2103
+ for (const s of bundle.specs) files[join5("specs", s.capability, "spec.md")] = s.spec;
2104
+ const written = [];
2105
+ const manifest = {};
2106
+ for (const [relPath, content] of Object.entries(files)) {
2107
+ manifest[relPath] = sha2562(content);
2108
+ if (this.readIfExists(relPath) === content) continue;
2109
+ this.writeAtomic(relPath, content);
2110
+ written.push(relPath);
2111
+ }
2112
+ this.updateSidecar((s) => ({
2113
+ ...s,
2114
+ summary: bundle.summary,
2115
+ generated: manifest,
2116
+ // The diff-before-write result IS the changed-file signal for the
2117
+ // post-generation chat seed (generation-chat-reset-summary).
2118
+ lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
2119
+ }));
2120
+ const lastGeneratedBodyHash = bodyHash(this.dir, nodeChangeMetaFs);
2121
+ if (lastGeneratedBodyHash) {
2122
+ writeChangeMeta(nodeChangeMetaFs, this.dir, {
2123
+ specId: this.changeName,
2124
+ userStories: [],
2125
+ dependencies: [],
2126
+ status: "draft",
2127
+ generation: { lastGeneratedBodyHash, lastGeneratedAt: (/* @__PURE__ */ new Date()).toISOString() }
2128
+ });
2129
+ }
2130
+ return written;
2131
+ }
2132
+ // ── Spec review chat ─────────────────────────────────────────────────────
2133
+ readChat() {
2134
+ return this.readSidecar().chat ?? [];
2135
+ }
2136
+ writeChat(chat) {
2137
+ this.updateSidecar((s) => ({ ...s, chat }));
2138
+ }
2139
+ // ── Generation system prompt override ────────────────────────────────────
2140
+ readSystemPrompt() {
2141
+ return this.readSidecar().systemPrompt;
2142
+ }
2143
+ /** `undefined` clears the override (reset to the built-in default). */
2144
+ writeSystemPrompt(systemPrompt) {
2145
+ this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
2146
+ ...s,
2147
+ ...systemPrompt !== void 0 ? { systemPrompt } : {}
2148
+ }));
2149
+ }
2150
+ };
2151
+
2152
+ // server/workbench-host.ts
2153
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
2154
+ import { homedir as homedir3 } from "node:os";
2155
+ import { dirname as dirname5, join as join10, resolve as resolve3 } from "node:path";
2156
+
2157
+ // src/workbench/core/capability-store.ts
2158
+ import { readFileSync as readFileSync4, readdirSync as readdirSync5, statSync as statSync2 } from "node:fs";
2159
+ import { join as join6 } from "node:path";
2160
+
2161
+ // src/workbench/core/spec-parse.ts
2162
+ var REQUIREMENT_HEADING = /^###[ \t]+Requirement:[ \t]*(.*)$/;
2163
+ var TERMINATING_HEADING = /^#{1,3}(?!#)(?:[ \t]|$)/;
2164
+ var HIGHER_LEVEL_HEADING = /^#{1,2}(?!#)(?:[ \t]|$)/;
2165
+ var FENCE_DELIMITER = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
2166
+ function probe(line) {
2167
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
2168
+ }
2169
+ function isBlank(line) {
2170
+ return probe(line).trim() === "";
2171
+ }
2172
+ function advanceFence(open, probed, lineNumber, rawLine) {
2173
+ const delimiter2 = FENCE_DELIMITER.exec(probed);
2174
+ if (!delimiter2) return open;
2175
+ const marker = delimiter2[1];
2176
+ const info = delimiter2[2] ?? "";
2177
+ const char = marker[0];
2178
+ if (open === null) {
2179
+ if (char === "`" && info.includes("`")) return null;
2180
+ return { char, length: marker.length, openLine: lineNumber, openText: rawLine };
2181
+ }
2182
+ if (char === open.char && marker.length >= open.length && info.trim() === "") return null;
2183
+ return open;
2184
+ }
2185
+ function closeBlock(open) {
2186
+ const lines = open.lines.slice();
2187
+ while (lines.length > 1 && isBlank(lines[lines.length - 1])) lines.pop();
2188
+ const bodyLines = lines.slice(1);
2189
+ return {
2190
+ index: open.index,
2191
+ name: open.name,
2192
+ headingLine: open.headingLine,
2193
+ lines,
2194
+ text: lines.join("\n"),
2195
+ bodyLines,
2196
+ bodyText: bodyLines.join("\n"),
2197
+ startLine: open.startLine,
2198
+ endLine: open.startLine + lines.length,
2199
+ higherLevelHeadings: open.higherLevelHeadings.filter(
2200
+ (h) => h.line < open.startLine + lines.length
2201
+ )
2202
+ };
2203
+ }
2204
+ function parseSpecFile(src) {
2205
+ const lines = src.split("\n");
2206
+ const head = [];
2207
+ const requirements = [];
2208
+ const outside = [];
2209
+ let open = null;
2210
+ let outsideStart = -1;
2211
+ let outsideLines = [];
2212
+ const fence = { open: null };
2213
+ const flushOutside = () => {
2214
+ if (outsideStart >= 0) {
2215
+ outside.push({ startLine: outsideStart, lines: outsideLines });
2216
+ outsideStart = -1;
2217
+ outsideLines = [];
2218
+ }
2219
+ };
2220
+ lines.forEach((line, i) => {
2221
+ const p = probe(line);
2222
+ const wasFenced = fence.open !== null;
2223
+ fence.open = advanceFence(fence.open, p, i, line);
2224
+ const fenced = wasFenced || fence.open !== null;
2225
+ if (!fenced) {
2226
+ const requirementHeading = REQUIREMENT_HEADING.exec(p);
2227
+ if (requirementHeading) {
2228
+ if (open) requirements.push(closeBlock(open));
2229
+ flushOutside();
2230
+ open = {
2231
+ index: requirements.length,
2232
+ name: (requirementHeading[1] ?? "").trim(),
2233
+ headingLine: line,
2234
+ startLine: i,
2235
+ lines: [line],
2236
+ higherLevelHeadings: []
2237
+ };
2238
+ return;
2239
+ }
2240
+ if (TERMINATING_HEADING.test(p)) {
2241
+ if (open) {
2242
+ requirements.push(closeBlock(open));
2243
+ open = null;
2244
+ }
2245
+ if (requirements.length === 0) {
2246
+ head.push(line);
2247
+ return;
2248
+ }
2249
+ if (outsideStart < 0) outsideStart = i;
2250
+ outsideLines.push(line);
2251
+ return;
2252
+ }
2253
+ }
2254
+ if (open) {
2255
+ const block = open;
2256
+ if (HIGHER_LEVEL_HEADING.test(p)) block.higherLevelHeadings.push({ line: i, text: line });
2257
+ block.lines.push(line);
2258
+ return;
2259
+ }
2260
+ if (requirements.length === 0) {
2261
+ head.push(line);
2262
+ return;
2263
+ }
2264
+ if (outsideStart < 0) outsideStart = i;
2265
+ outsideLines.push(line);
2266
+ });
2267
+ if (open) requirements.push(closeBlock(open));
2268
+ flushOutside();
2269
+ const unterminatedFence = fence.open === null ? null : { openLine: fence.open.openLine, openText: fence.open.openText };
2270
+ return {
2271
+ head,
2272
+ headText: head.join("\n"),
2273
+ requirements,
2274
+ outside,
2275
+ unterminatedFence
2276
+ };
2277
+ }
2278
+ function countRequirements(src) {
2279
+ return parseSpecFile(src).requirements.length;
2280
+ }
2281
+
2282
+ // src/workbench/core/capability-store.ts
2283
+ var CAPABILITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2284
+ var SPEC_FILE = "spec.md";
2285
+ function validateCapabilityName(name) {
2286
+ if (name.length === 0) return "a capability name cannot be empty";
2287
+ if (!CAPABILITY_NAME.test(name)) {
2288
+ return `\`${name}\` is not a capability name: expected lowercase kebab-case, e.g. \`specsketch-delta-derivation\``;
2289
+ }
2290
+ return null;
2291
+ }
2292
+ function readIfPresent(path) {
2293
+ try {
2294
+ if (!statSync2(path).isFile()) return null;
2295
+ } catch {
2296
+ return null;
2297
+ }
2298
+ return readFileSync4(path, "utf8");
2299
+ }
2300
+ function createFoldedSpecReader(repoRoot) {
2301
+ const specsDir = join6(repoRoot, "openspec", "specs");
2302
+ const specPath = (name) => join6(specsDir, name, SPEC_FILE);
2303
+ const readCapability = (name) => {
2304
+ const invalid = validateCapabilityName(name);
2305
+ if (invalid) throw new RangeError(invalid);
2306
+ return readIfPresent(specPath(name));
2307
+ };
2308
+ const listCapabilities = (openCapabilities = []) => {
2309
+ const open = new Set(openCapabilities);
2310
+ const listings = [];
2311
+ for (const entry of readdirSync5(specsDir, { withFileTypes: true })) {
2312
+ if (!entry.isDirectory()) continue;
2313
+ const folded = readIfPresent(specPath(entry.name));
2314
+ if (folded === null) continue;
2315
+ listings.push({
2316
+ name: entry.name,
2317
+ requirementCount: countRequirements(folded),
2318
+ alreadyOpen: open.has(entry.name)
2319
+ });
2320
+ }
2321
+ listings.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2322
+ return listings;
2323
+ };
2324
+ const openCapability = (name, openCapabilities = []) => {
2325
+ const invalid = validateCapabilityName(name);
2326
+ if (invalid) return { outcome: "invalid-name", capability: name, reason: invalid };
2327
+ if (new Set(openCapabilities).has(name)) return { outcome: "already-open", capability: name };
2328
+ const folded = readIfPresent(specPath(name));
2329
+ if (folded === null) return { outcome: "no-folded-counterpart", capability: name };
2330
+ return { outcome: "opened", capability: name, draft: folded, folded };
2331
+ };
2332
+ return Object.freeze({ specsDir, listCapabilities, readCapability, openCapability });
2333
+ }
2334
+
2335
+ // src/workbench/approve/approve.ts
2336
+ import { execFileSync } from "node:child_process";
2337
+ import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync5, rmSync, statSync as statSync3 } from "node:fs";
2338
+ import { tmpdir } from "node:os";
2339
+ import { join as join7 } from "node:path";
2340
+
2341
+ // src/workbench/core/emit.ts
2342
+ var PROPOSAL_SECTIONS = Object.freeze([
2343
+ { id: "why", title: "Why", level: 2, required: true, skippable: false },
2344
+ { id: "what-changes", title: "What Changes", level: 2, required: true, skippable: false },
2345
+ { id: "system-context", title: "System context", level: 3, required: true, skippable: true },
2346
+ { id: "containers", title: "Containers", level: 3, required: true, skippable: true },
2347
+ { id: "components", title: "Components", level: 3, required: true, skippable: true },
2348
+ { id: "code", title: "Code", level: 3, required: false, skippable: true },
2349
+ { id: "impact", title: "Impact", level: 2, required: true, skippable: false }
1877
2350
  ]);
1878
- var canvasSessionCreatedSchema = z3.object({
1879
- sessionId: z3.string().min(1),
1880
- /** 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)
1884
- });
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()
2351
+ function emptyProposal() {
2352
+ return {
2353
+ why: { state: "empty" },
2354
+ "what-changes": { state: "empty" },
2355
+ "system-context": { state: "empty" },
2356
+ containers: { state: "empty" },
2357
+ components: { state: "empty" },
2358
+ code: { state: "empty" },
2359
+ impact: { state: "empty" }
2360
+ };
2361
+ }
2362
+ var DESIGN_FENCES = Object.freeze({
2363
+ mermaid: "mermaid",
2364
+ sketch: "specsketch-sketch"
1891
2365
  });
1892
- function elementWins(local, incoming) {
1893
- if (incoming.version !== local.version) return incoming.version > local.version;
1894
- return incoming.versionNonce < local.versionNonce;
1895
- }
1896
- function reconcileCanvasElements(local, incoming) {
1897
- const byId = new Map(local.map((el) => [el.id, el]));
1898
- const accepted = [];
1899
- for (const el of incoming) {
1900
- const existing = byId.get(el.id);
1901
- if (!existing || elementWins(existing, el)) {
1902
- byId.set(el.id, el);
1903
- accepted.push(el);
1904
- }
1905
- }
1906
- return { elements: [...byId.values()], accepted };
1907
- }
1908
-
1909
- // server/remote-session.ts
1910
- var HEARTBEAT_INTERVAL_MS = 3e4;
1911
- var HEARTBEAT_TIMEOUT_MS = 75e3;
1912
- var BACKOFF_MIN_MS = 1e3;
1913
- var BACKOFF_MAX_MS = 3e4;
1914
- var PENDING_OP_LIMIT = 200;
1915
- function asCanvasElements(elements) {
1916
- if (!elements) return [];
1917
- return elements.filter(
1918
- (el) => typeof el === "object" && el !== null && typeof el.id === "string" && typeof el.version === "number" && typeof el.versionNonce === "number"
1919
- );
2366
+ function numberTasks(groups) {
2367
+ return groups.map((group, g) => ({
2368
+ ...group,
2369
+ number: `${g + 1}`,
2370
+ title: group.title,
2371
+ tasks: group.tasks.map((task, t) => ({ ...task, number: `${g + 1}.${t + 1}` }))
2372
+ }));
2373
+ }
2374
+ var SCENARIO_HEADING = /^####[ \t]+Scenario:[ \t]*(.*)$/;
2375
+ var ANY_HEADING = /^#{1,6}[ \t]/;
2376
+ function probe2(line) {
2377
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
2378
+ }
2379
+ function parseScenarios(block) {
2380
+ const scenarios = [];
2381
+ let open = null;
2382
+ for (const line of block.bodyLines) {
2383
+ const p = probe2(line);
2384
+ const heading = SCENARIO_HEADING.exec(p);
2385
+ if (heading) {
2386
+ if (open) scenarios.push(open);
2387
+ open = { name: (heading[1] ?? "").trim(), steps: [] };
2388
+ continue;
2389
+ }
2390
+ if (ANY_HEADING.test(p)) {
2391
+ if (open) scenarios.push(open);
2392
+ open = null;
2393
+ continue;
2394
+ }
2395
+ if (open && p.trim() !== "") open.steps.push(p);
2396
+ }
2397
+ if (open) scenarios.push(open);
2398
+ return scenarios;
2399
+ }
2400
+ function requirementProse(block) {
2401
+ const prose = [];
2402
+ for (const line of block.bodyLines) {
2403
+ const p = probe2(line);
2404
+ if (SCENARIO_HEADING.test(p) || ANY_HEADING.test(p)) break;
2405
+ prose.push(p);
2406
+ }
2407
+ return prose.join("\n").trim();
2408
+ }
2409
+ function draftBlockOf(delta) {
2410
+ return delta.operation === "REMOVED" ? null : delta.block;
2411
+ }
2412
+ function deltasOf(entry) {
2413
+ return entry.derivation.outcome === "derived" ? entry.derivation.deltas : [];
2414
+ }
2415
+ function emitsDeltaSpec(entry) {
2416
+ return deltasOf(entry).length > 0;
2417
+ }
2418
+ function testingRows(capabilities) {
2419
+ const rows = [];
2420
+ for (const entry of capabilities) {
2421
+ for (const delta of deltasOf(entry)) {
2422
+ const block = draftBlockOf(delta);
2423
+ if (!block) continue;
2424
+ for (const scenario of parseScenarios(block)) {
2425
+ rows.push({
2426
+ index: rows.length + 1,
2427
+ capability: entry.capability,
2428
+ requirement: block.name,
2429
+ scenario: scenario.name
2430
+ });
2431
+ }
2432
+ }
2433
+ }
2434
+ return rows;
2435
+ }
2436
+ function citableRequirements(capabilities) {
2437
+ const citable = [];
2438
+ for (const entry of capabilities) {
2439
+ for (const delta of deltasOf(entry)) {
2440
+ citable.push({ capability: entry.capability, key: delta.key });
2441
+ }
2442
+ }
2443
+ return citable;
2444
+ }
2445
+ function purposeOf(entry) {
2446
+ if (entry.derivation.outcome !== "derived") return null;
2447
+ if (entry.derivation.foldedComparison.kind !== "no-folded-counterpart") return null;
2448
+ return readPurposeBody(entry);
2449
+ }
2450
+ var PURPOSE_HEADING = /^##[ \t]+Purpose[ \t]*$/;
2451
+ function readPurposeBody(entry) {
2452
+ const body = [];
2453
+ let inPurpose = false;
2454
+ for (const line of entry.draft.split("\n")) {
2455
+ const p = probe2(line);
2456
+ if (PURPOSE_HEADING.test(p)) {
2457
+ inPurpose = true;
2458
+ continue;
2459
+ }
2460
+ if (inPurpose && ANY_HEADING.test(p)) break;
2461
+ if (inPurpose) body.push(p);
2462
+ }
2463
+ const text = body.join("\n").trim();
2464
+ return text === "" ? null : text;
2465
+ }
2466
+ function deltaSpecDocument(entry) {
2467
+ const deltas = deltasOf(entry);
2468
+ return {
2469
+ capability: entry.capability,
2470
+ purpose: purposeOf(entry),
2471
+ added: deltas.filter((d) => d.operation === "ADDED").map((d) => blockText(d)),
2472
+ // A declared rename whose body also changed emits RENAMED *and* MODIFIED: the
2473
+ // rename names the heading change, the modification carries the body change
2474
+ // with the folded block as its base. Emitting only the rename would discard a
2475
+ // body edit the author made; refusing would make a legitimate edit and a
2476
+ // legitimate rename mutually exclusive. See `docs/specsketch-workbench-issues.md` #8.
2477
+ modified: deltas.filter((d) => d.operation === "MODIFIED" || d.operation === "RENAMED" && d.bodyChanged).map((d) => blockText(d)),
2478
+ removed: deltas.filter((d) => d.operation === "REMOVED").map((d) => d.base.name),
2479
+ renamed: deltas.filter((d) => d.operation === "RENAMED").map((d) => ({ from: d.base.name, to: d.block.name }))
2480
+ };
2481
+ }
2482
+ function blockText(delta) {
2483
+ const block = draftBlockOf(delta);
2484
+ if (!block) throw new RangeError(`no draft block for ${delta.operation} delta \`${delta.key}\``);
2485
+ return block.text;
2486
+ }
2487
+ function changeDocument(change) {
2488
+ return {
2489
+ id: change.id,
2490
+ proposal: change.proposal,
2491
+ design: change.design,
2492
+ groups: numberTasks(change.tasks),
2493
+ testing: testingRows(change.capabilities),
2494
+ deltaSpecs: change.capabilities.filter(emitsDeltaSpec).map(deltaSpecDocument).sort((a, b) => a.capability < b.capability ? -1 : a.capability > b.capability ? 1 : 0)
2495
+ };
2496
+ }
2497
+ var SKIPPED_MARKER = "_Skipped._";
2498
+ var UNSATISFIED_MARKER = "_Unsatisfied \u2014 this section has no body and no recorded skip._";
2499
+ function renderProposalSection(spec, section2) {
2500
+ const heading = `${"#".repeat(spec.level)} ${spec.title}`;
2501
+ switch (section2.state) {
2502
+ case "authored":
2503
+ return `${heading}
2504
+
2505
+ ${section2.body}
2506
+ `;
2507
+ case "skipped":
2508
+ return `${heading}
2509
+
2510
+ ${SKIPPED_MARKER} ${section2.reason.trim()}
2511
+ `;
2512
+ case "empty":
2513
+ return `${heading}
2514
+
2515
+ ${UNSATISFIED_MARKER}
2516
+ `;
2517
+ }
2518
+ }
2519
+ function renderProposal(doc) {
2520
+ const parts = [`# ${doc.id}
2521
+ `];
2522
+ for (const spec of PROPOSAL_SECTIONS) {
2523
+ parts.push(renderProposalSection(spec, doc.proposal[spec.id]));
2524
+ }
2525
+ return `${parts.join("\n")}`;
2526
+ }
2527
+ function renderDesign(doc) {
2528
+ const parts = [`# Design \u2014 ${doc.id}
2529
+ `];
2530
+ for (const block of doc.design) {
2531
+ const title = (block.title ?? "").trim();
2532
+ const heading = title === "" ? "" : `## ${title}
2533
+
2534
+ `;
2535
+ if (block.kind === "markdown") {
2536
+ parts.push(`${heading}${block.text.trim()}
2537
+ `);
2538
+ continue;
2539
+ }
2540
+ const info = block.kind === "mermaid" ? DESIGN_FENCES.mermaid : DESIGN_FENCES.sketch;
2541
+ parts.push(`${heading}\`\`\`${info}
2542
+ ${block.text.trim()}
2543
+ \`\`\`
2544
+ `);
2545
+ }
2546
+ return parts.join("\n");
2547
+ }
2548
+ function cell(text) {
2549
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
2550
+ }
2551
+ function uncell(text) {
2552
+ return text.replace(/\\\|/g, "|").replace(/\\\\/g, "\\");
2553
+ }
2554
+ function renderTasks(doc) {
2555
+ const parts = [`# Tasks \u2014 ${doc.id}
2556
+ `];
2557
+ for (const group of doc.groups) {
2558
+ const citation = group.cites ? ` \u2014 implements \`${group.cites.capability}\`: \`${group.cites.key}\`` : "";
2559
+ const lines = [`## ${group.number}. ${group.title}${citation}
2560
+ `];
2561
+ for (const task of group.tasks) {
2562
+ const box = task.done ? "x" : " ";
2563
+ lines.push(`- [${box}] ${task.number} ${task.title}`);
2564
+ }
2565
+ parts.push(`${lines.join("\n")}
2566
+ `);
2567
+ }
2568
+ parts.push(renderTestingTable(doc.testing));
2569
+ return parts.join("\n");
2570
+ }
2571
+ function renderTestingTable(rows) {
2572
+ const lines = [
2573
+ "## Testing",
2574
+ "",
2575
+ "One test per scenario. This table is derived from the change's deltas \u2014 it is not",
2576
+ "editable text, and its row count is the change's scenario count.",
2577
+ "",
2578
+ "| # | Capability | Requirement | Scenario |",
2579
+ "|---:|---|---|---|"
2580
+ ];
2581
+ for (const row of rows) {
2582
+ lines.push(
2583
+ `| ${row.index} | \`${cell(row.capability)}\` | ${cell(row.requirement)} | ${cell(row.scenario)} |`
2584
+ );
2585
+ }
2586
+ if (rows.length === 0) lines.push("| \u2014 | \u2014 | \u2014 | \u2014 |");
2587
+ return `${lines.join("\n")}
2588
+ `;
2589
+ }
2590
+ function renderDeltaSpec(doc, changeId) {
2591
+ const parts = [`# ${doc.capability} (delta) \u2014 ${changeId}
2592
+ `];
2593
+ if (doc.purpose !== null) parts.push(`## Purpose
2594
+
2595
+ ${doc.purpose}
2596
+ `);
2597
+ if (doc.added.length > 0) parts.push(section("## ADDED Requirements", doc.added));
2598
+ if (doc.modified.length > 0) parts.push(section("## MODIFIED Requirements", doc.modified));
2599
+ if (doc.removed.length > 0) {
2600
+ parts.push(
2601
+ section(
2602
+ "## REMOVED Requirements",
2603
+ doc.removed.map((name) => `### Requirement: ${name}`)
2604
+ )
2605
+ );
2606
+ }
2607
+ if (doc.renamed.length > 0) {
2608
+ const pairs = doc.renamed.map(
2609
+ (pair) => `- FROM: \`### Requirement: ${pair.from}\`
2610
+ - TO: \`### Requirement: ${pair.to}\``
2611
+ );
2612
+ parts.push(`## RENAMED Requirements
2613
+
2614
+ ${pairs.join("\n\n")}
2615
+ `);
2616
+ }
2617
+ return parts.join("\n");
2618
+ }
2619
+ function section(heading, blocks) {
2620
+ return `${heading}
2621
+
2622
+ ${blocks.map((b) => b.trimEnd()).join("\n\n")}
2623
+ `;
2624
+ }
2625
+ function renderChangeDocument(doc) {
2626
+ const files = [
2627
+ { path: "proposal.md", content: renderProposal(doc) },
2628
+ { path: "design.md", content: renderDesign(doc) },
2629
+ { path: "tasks.md", content: renderTasks(doc) }
2630
+ ];
2631
+ for (const spec of doc.deltaSpecs) {
2632
+ files.push({
2633
+ path: `specs/${spec.capability}/spec.md`,
2634
+ content: renderDeltaSpec(spec, doc.id)
2635
+ });
2636
+ }
2637
+ return { id: doc.id, files };
2638
+ }
2639
+ function emitChange(change) {
2640
+ return renderChangeDocument(changeDocument(change));
2641
+ }
2642
+ var TASK_LINE = /^- \[([ xX])\] (\d+\.\d+) (.*)$/;
2643
+ var GROUP_CITATION = /^(.*) — implements `([^`]+)`: `([^`]+)`$/;
2644
+ var GROUP_HEADING = /^## (\d+)\. (.*)$/;
2645
+ var FENCE = /^```(\S*)\s*$/;
2646
+ function parseChangeDocument(emitted) {
2647
+ const byPath = new Map(emitted.files.map((f) => [f.path, f.content]));
2648
+ const proposalSrc = byPath.get("proposal.md") ?? "";
2649
+ const designSrc = byPath.get("design.md") ?? "";
2650
+ const tasksSrc = byPath.get("tasks.md") ?? "";
2651
+ const groups = parseTasksFile(tasksSrc);
2652
+ const deltaSpecs = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => parseDeltaSpecFile(capabilityOfPath(f.path), f.content));
2653
+ return {
2654
+ id: emitted.id,
2655
+ proposal: parseProposalFile(proposalSrc),
2656
+ design: parseDesignFile(designSrc),
2657
+ groups: numberTasks(groups),
2658
+ testing: parseTestingTable(tasksSrc),
2659
+ deltaSpecs
2660
+ };
2661
+ }
2662
+ function capabilityOfPath(path) {
2663
+ const parts = path.split("/");
2664
+ return parts[1] ?? "";
2665
+ }
2666
+ function parseProposalFile(src) {
2667
+ const lines = src.split("\n");
2668
+ const bodies = /* @__PURE__ */ new Map();
2669
+ let current = null;
2670
+ for (const line of lines) {
2671
+ const p = probe2(line);
2672
+ const spec = PROPOSAL_SECTIONS.find(
2673
+ (s) => p === `${"#".repeat(s.level)} ${s.title}`
2674
+ );
2675
+ if (spec) {
2676
+ current = spec.id;
2677
+ bodies.set(spec.id, []);
2678
+ continue;
2679
+ }
2680
+ if (ANY_HEADING.test(p)) {
2681
+ current = null;
2682
+ continue;
2683
+ }
2684
+ if (current) bodies.get(current).push(p);
2685
+ }
2686
+ const draft = emptyProposal();
2687
+ for (const spec of PROPOSAL_SECTIONS) {
2688
+ const body = (bodies.get(spec.id) ?? []).join("\n").trim();
2689
+ if (body === "" || body === UNSATISFIED_MARKER) {
2690
+ draft[spec.id] = { state: "empty" };
2691
+ continue;
2692
+ }
2693
+ if (body.startsWith(SKIPPED_MARKER)) {
2694
+ draft[spec.id] = { state: "skipped", reason: body.slice(SKIPPED_MARKER.length).trim() };
2695
+ continue;
2696
+ }
2697
+ draft[spec.id] = { state: "authored", body };
2698
+ }
2699
+ return draft;
1920
2700
  }
1921
- var RemoteSession = class _RemoteSession {
1922
- sessionId;
1923
- shareUrl;
1924
- token;
1925
- wsUrl;
1926
- opts;
1927
- status = "connecting";
1928
- revision = 0;
1929
- participants = [];
1930
- ws = null;
1931
- closed = false;
1932
- clientSeq = 0;
1933
- backoffMs;
1934
- lastTrafficAt = 0;
1935
- heartbeatTimer = null;
1936
- reconnectTimer = null;
1937
- pendingOps = [];
1938
- api;
1939
- constructor(opts, api, created) {
1940
- this.opts = opts;
1941
- this.api = api;
1942
- this.sessionId = created.sessionId;
1943
- this.token = created.token;
1944
- this.shareUrl = created.shareUrl;
1945
- this.wsUrl = created.wsUrl;
1946
- this.backoffMs = opts.backoffMinMs ?? BACKOFF_MIN_MS;
1947
- }
1948
- /** Create the session server-side and open the host connection. */
1949
- static async start(opts) {
1950
- const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
1951
- let response;
1952
- try {
1953
- response = await api.createCanvasSession({ title: opts.title });
1954
- } catch (err) {
1955
- if (isCoopApiError(err)) {
1956
- if (err.status === 401 || err.status === 403) {
1957
- throw new Error(
1958
- "CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
1959
- );
1960
- }
1961
- throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
2701
+ var DESIGN_HEADING = /^##[ \t]+(.*)$/;
2702
+ function parseDesignFile(src) {
2703
+ const lines = src.split("\n");
2704
+ const blocks = [];
2705
+ let markdown = [];
2706
+ let title = null;
2707
+ let fence = null;
2708
+ const withTitle = (block) => {
2709
+ if (title === null) return block;
2710
+ const titled = { ...block, title };
2711
+ title = null;
2712
+ return titled;
2713
+ };
2714
+ const flushMarkdown = () => {
2715
+ const text = markdown.join("\n").trim();
2716
+ if (text !== "" || title !== null) blocks.push(withTitle({ kind: "markdown", text }));
2717
+ markdown = [];
2718
+ };
2719
+ for (const [i, line] of lines.entries()) {
2720
+ const p = probe2(line);
2721
+ const fenceMatch = FENCE.exec(p);
2722
+ if (fence) {
2723
+ if (p === "```") {
2724
+ const kind = fence.info === DESIGN_FENCES.mermaid ? "mermaid" : "sketch";
2725
+ blocks.push(withTitle({ kind, text: fence.lines.join("\n").trim() }));
2726
+ fence = null;
2727
+ continue;
1962
2728
  }
1963
- throw err;
2729
+ fence.lines.push(p);
2730
+ continue;
1964
2731
  }
1965
- const created = canvasSessionCreatedSchema.parse(response);
1966
- const session = new _RemoteSession(opts, api, created);
1967
- session.connect();
1968
- return session;
1969
- }
1970
- // ── Upstream connection ──
1971
- connect() {
1972
- if (this.closed) return;
1973
- const factory = this.opts.wsFactory ?? ((url, headers) => new WebSocket(url, { headers }));
1974
- const ws = factory(this.wsUrl, { Authorization: `Bearer ${this.opts.key}` });
1975
- this.ws = ws;
1976
- this.lastTrafficAt = Date.now();
1977
- ws.on("open", () => {
1978
- this.backoffMs = this.opts.backoffMinMs ?? BACKOFF_MIN_MS;
1979
- this.status = "connected";
1980
- ws.send(
1981
- JSON.stringify({
1982
- type: "hello",
1983
- protocolVersion: CANVAS_PROTOCOL_VERSION,
1984
- role: "host",
1985
- name: this.opts.hostName ?? void 0
1986
- })
1987
- );
1988
- for (const frame of this.pendingOps.splice(0)) ws.send(frame);
1989
- });
1990
- ws.on("message", (data) => this.handleUpstreamFrame(String(data)));
1991
- ws.on("close", (code) => {
1992
- if (ws !== this.ws) return;
1993
- this.stopHeartbeat();
1994
- if (this.closed) return;
1995
- this.status = "reconnecting";
1996
- this.opts.log?.(`share: connection lost (code ${code}) \u2014 reconnecting`);
1997
- this.scheduleReconnect();
1998
- });
1999
- ws.on("error", () => {
2732
+ if (fenceMatch && isDesignFence(fenceMatch[1] ?? "")) {
2733
+ if (markdown.join("\n").trim() !== "") flushMarkdown();
2734
+ markdown = [];
2735
+ fence = { info: fenceMatch[1], lines: [] };
2736
+ continue;
2737
+ }
2738
+ const heading = DESIGN_HEADING.exec(p);
2739
+ if (heading) {
2740
+ flushMarkdown();
2741
+ title = (heading[1] ?? "").trim();
2742
+ continue;
2743
+ }
2744
+ if (i === 0 && p.startsWith("# Design \u2014 ")) continue;
2745
+ markdown.push(p);
2746
+ }
2747
+ flushMarkdown();
2748
+ return blocks;
2749
+ }
2750
+ function isDesignFence(info) {
2751
+ return info === DESIGN_FENCES.mermaid || info === DESIGN_FENCES.sketch;
2752
+ }
2753
+ function parseTasksFile(src) {
2754
+ const groups = [];
2755
+ let open = null;
2756
+ for (const line of src.split("\n")) {
2757
+ const p = probe2(line);
2758
+ const heading = GROUP_HEADING.exec(p);
2759
+ if (heading) {
2760
+ if (open) groups.push(open);
2761
+ const text = (heading[2] ?? "").trim();
2762
+ const cited = GROUP_CITATION.exec(text);
2763
+ open = cited ? {
2764
+ title: (cited[1] ?? "").trim(),
2765
+ tasks: [],
2766
+ cites: { capability: cited[2], key: cited[3] }
2767
+ } : { title: text, tasks: [] };
2768
+ continue;
2769
+ }
2770
+ if (/^##[ \t]/.test(p)) {
2771
+ if (open) groups.push(open);
2772
+ open = null;
2773
+ continue;
2774
+ }
2775
+ const task = TASK_LINE.exec(p);
2776
+ if (task && open) {
2777
+ const done = (task[1] ?? " ").toLowerCase() === "x";
2778
+ open.tasks.push({ title: (task[3] ?? "").trim(), done });
2779
+ }
2780
+ }
2781
+ if (open) groups.push(open);
2782
+ return groups;
2783
+ }
2784
+ function parseTestingTable(src) {
2785
+ const rows = [];
2786
+ let inTable = false;
2787
+ for (const line of src.split("\n")) {
2788
+ const p = probe2(line);
2789
+ if (p.startsWith("| # | Capability |")) {
2790
+ inTable = true;
2791
+ continue;
2792
+ }
2793
+ if (!inTable) continue;
2794
+ if (!p.startsWith("|")) break;
2795
+ if (/^\|[-: |]+\|$/.test(p)) continue;
2796
+ const cells = splitRow(p);
2797
+ if (cells.length !== 4) continue;
2798
+ const index = Number.parseInt(cells[0], 10);
2799
+ if (!Number.isFinite(index)) continue;
2800
+ rows.push({
2801
+ index,
2802
+ capability: cells[1].replace(/^`|`$/g, ""),
2803
+ requirement: cells[2],
2804
+ scenario: cells[3]
2000
2805
  });
2001
- this.startHeartbeat();
2002
- }
2003
- scheduleReconnect() {
2004
- if (this.closed || this.reconnectTimer) return;
2005
- const jitter = Math.random() * 0.3 + 0.85;
2006
- const delay = Math.round(this.backoffMs * jitter);
2007
- this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
2008
- this.reconnectTimer = setTimeout(() => {
2009
- this.reconnectTimer = null;
2010
- this.connect();
2011
- }, delay);
2012
- }
2013
- startHeartbeat() {
2014
- this.stopHeartbeat();
2015
- this.heartbeatTimer = setInterval(() => {
2016
- const ws = this.ws;
2017
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2018
- if (Date.now() - this.lastTrafficAt > HEARTBEAT_TIMEOUT_MS) {
2019
- ws.terminate();
2020
- return;
2806
+ }
2807
+ return rows;
2808
+ }
2809
+ function splitRow(line) {
2810
+ const cells = [];
2811
+ let buffer = "";
2812
+ let escaped = false;
2813
+ for (const char of line.slice(1, line.lastIndexOf("|"))) {
2814
+ if (escaped) {
2815
+ buffer += `\\${char}`;
2816
+ escaped = false;
2817
+ continue;
2818
+ }
2819
+ if (char === "\\") {
2820
+ escaped = true;
2821
+ continue;
2822
+ }
2823
+ if (char === "|") {
2824
+ cells.push(uncell(buffer.trim()));
2825
+ buffer = "";
2826
+ continue;
2827
+ }
2828
+ buffer += char;
2829
+ }
2830
+ cells.push(uncell(buffer.trim()));
2831
+ return cells;
2832
+ }
2833
+ var DELTA_SECTION = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements[ \t]*$/;
2834
+ var REQUIREMENT_HEADING2 = /^###[ \t]+Requirement:[ \t]*(.*)$/;
2835
+ var RENAME_FROM = /^-[ \t]+FROM:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
2836
+ var RENAME_TO = /^-[ \t]+TO:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
2837
+ function parseDeltaSpecFile(capability, src) {
2838
+ const added = [];
2839
+ const modified = [];
2840
+ const removed = [];
2841
+ const renamed = [];
2842
+ const purposeLines = [];
2843
+ let operation = null;
2844
+ let block = null;
2845
+ let pendingFrom = null;
2846
+ const flushBlock = () => {
2847
+ if (!block) return;
2848
+ const text = block.join("\n").trimEnd();
2849
+ if (operation === "ADDED") added.push(text);
2850
+ if (operation === "MODIFIED") modified.push(text);
2851
+ block = null;
2852
+ };
2853
+ for (const line of src.split("\n")) {
2854
+ const p = probe2(line);
2855
+ const sectionHeading = DELTA_SECTION.exec(p);
2856
+ if (sectionHeading) {
2857
+ flushBlock();
2858
+ operation = sectionHeading[1];
2859
+ continue;
2860
+ }
2861
+ if (PURPOSE_HEADING.test(p)) {
2862
+ flushBlock();
2863
+ operation = "PURPOSE";
2864
+ continue;
2865
+ }
2866
+ if (/^##(?!#)[ \t]/.test(p)) {
2867
+ flushBlock();
2868
+ operation = null;
2869
+ continue;
2870
+ }
2871
+ if (operation === "PURPOSE") {
2872
+ purposeLines.push(p);
2873
+ continue;
2874
+ }
2875
+ const requirementHeading = REQUIREMENT_HEADING2.exec(p);
2876
+ if (requirementHeading) {
2877
+ flushBlock();
2878
+ if (operation === "REMOVED") {
2879
+ removed.push((requirementHeading[1] ?? "").trim());
2880
+ continue;
2021
2881
  }
2022
- ws.send("ping");
2023
- }, HEARTBEAT_INTERVAL_MS);
2024
- this.heartbeatTimer.unref?.();
2025
- }
2026
- stopHeartbeat() {
2027
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2028
- this.heartbeatTimer = null;
2029
- }
2030
- // ── Frames from the DO ──
2031
- handleUpstreamFrame(text) {
2032
- this.lastTrafficAt = Date.now();
2033
- if (text === "pong") return;
2034
- const parsed = canvasServerMessageSchema.safeParse(
2035
- (() => {
2036
- try {
2037
- return JSON.parse(text);
2038
- } catch {
2039
- return null;
2040
- }
2041
- })()
2042
- );
2043
- if (!parsed.success) return;
2044
- const frame = parsed.data;
2045
- switch (frame.type) {
2046
- case "snapshot": {
2047
- const local = this.opts.workspace.loadScene();
2048
- const { elements, accepted } = reconcileCanvasElements(
2049
- asCanvasElements(frame.scene.elements),
2050
- asCanvasElements(local?.elements)
2051
- );
2052
- this.revision = frame.revision;
2053
- this.participants = frame.participants;
2054
- this.opts.workspace.saveScene({
2055
- elements,
2056
- appState: local?.appState,
2057
- files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
2058
- });
2059
- if (accepted.length > 0) {
2060
- this.sendUpstream(
2061
- JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
2062
- );
2063
- }
2064
- this.opts.onLocalBroadcast?.(text);
2065
- return;
2882
+ if (operation === "ADDED" || operation === "MODIFIED") block = [p];
2883
+ continue;
2884
+ }
2885
+ if (operation === "RENAMED") {
2886
+ const from = RENAME_FROM.exec(p);
2887
+ if (from) {
2888
+ pendingFrom = (from[1] ?? "").trim();
2889
+ continue;
2066
2890
  }
2067
- case "op": {
2068
- if (frame.revision > this.revision + 1) {
2069
- this.ws?.terminate();
2070
- return;
2071
- }
2072
- this.revision = frame.revision;
2073
- this.applyRemoteElements(frame.elements, frame.files);
2074
- this.opts.onLocalBroadcast?.(text);
2075
- return;
2891
+ const to = RENAME_TO.exec(p);
2892
+ if (to && pendingFrom !== null) {
2893
+ renamed.push({ from: pendingFrom, to: (to[1] ?? "").trim() });
2894
+ pendingFrom = null;
2076
2895
  }
2077
- case "presence": {
2078
- this.participants = frame.participants;
2079
- this.opts.onLocalBroadcast?.(text);
2080
- return;
2896
+ continue;
2897
+ }
2898
+ if (block) block.push(p);
2899
+ }
2900
+ flushBlock();
2901
+ const purpose = purposeLines.join("\n").trim();
2902
+ return {
2903
+ capability,
2904
+ purpose: purpose === "" ? null : purpose,
2905
+ added,
2906
+ modified,
2907
+ removed,
2908
+ renamed
2909
+ };
2910
+ }
2911
+
2912
+ // src/workbench/core/validate.ts
2913
+ var SHALL_OR_MUST = /\b(SHALL|MUST)\b/;
2914
+ function validateChange(change) {
2915
+ const findings = [
2916
+ ...validateProposal(change),
2917
+ ...validateDesign(change),
2918
+ ...validateCapabilities(change),
2919
+ ...validateTasks(change)
2920
+ ];
2921
+ return report(findings);
2922
+ }
2923
+ function report(findings) {
2924
+ const errors = findings.filter((f) => f.severity === "error");
2925
+ const warnings = findings.filter((f) => f.severity === "warning");
2926
+ return { findings, errors, warnings, valid: errors.length === 0 };
2927
+ }
2928
+ function validateProposal(change) {
2929
+ const findings = [];
2930
+ for (const spec of PROPOSAL_SECTIONS) {
2931
+ const section2 = change.proposal[spec.id];
2932
+ const subject = {
2933
+ kind: "proposal-section",
2934
+ section: spec.id,
2935
+ title: spec.title
2936
+ };
2937
+ if (section2.state === "skipped") {
2938
+ findings.push(...validateSkip(spec, section2.reason, subject));
2939
+ continue;
2940
+ }
2941
+ if (section2.state === "empty" && spec.required) {
2942
+ findings.push({
2943
+ severity: "error",
2944
+ kind: "proposal-section-unsatisfied",
2945
+ subject,
2946
+ message: `\`${spec.title}\` has no body and no recorded skip, and is required.`
2947
+ });
2948
+ }
2949
+ }
2950
+ return findings;
2951
+ }
2952
+ function validateSkip(spec, reason, subject) {
2953
+ if (!spec.skippable) {
2954
+ return [
2955
+ {
2956
+ severity: "error",
2957
+ kind: "proposal-section-not-skippable",
2958
+ subject,
2959
+ message: `\`${spec.title}\` is not skippable \u2014 only the C4 levels are.`
2081
2960
  }
2082
- case "end": {
2083
- this.opts.log?.(`share: session ended (${frame.reason})`);
2084
- this.opts.onLocalBroadcast?.(text);
2085
- this.closed = true;
2086
- this.status = "ended";
2087
- this.teardown();
2088
- return;
2961
+ ];
2962
+ }
2963
+ if (reason.trim() === "") {
2964
+ return [
2965
+ {
2966
+ severity: "error",
2967
+ kind: "proposal-skip-without-reason",
2968
+ subject,
2969
+ message: `\`${spec.title}\` is skipped with no recorded reason; a skip requires one.`
2089
2970
  }
2090
- case "error": {
2091
- this.opts.log?.(`share: server error ${frame.code}: ${frame.message}`);
2092
- return;
2971
+ ];
2972
+ }
2973
+ return [];
2974
+ }
2975
+ function validateDesign(change) {
2976
+ if (change.design.length === 0) {
2977
+ return [
2978
+ {
2979
+ severity: "warning",
2980
+ kind: "design-empty",
2981
+ subject: { kind: "change", change: change.id },
2982
+ message: "`design.md` carries no blocks; the change will emit a title and nothing else."
2093
2983
  }
2094
- }
2984
+ ];
2095
2985
  }
2096
- /** Persist remote edits into the change directory — the durable copy. */
2097
- applyRemoteElements(incoming, files) {
2098
- const local = this.opts.workspace.loadScene();
2099
- const { elements } = reconcileCanvasElements(asCanvasElements(local?.elements), incoming);
2100
- this.opts.workspace.saveScene({
2101
- elements,
2102
- appState: local?.appState,
2103
- files: { ...local?.files ?? {}, ...files ?? {} }
2986
+ const findings = [];
2987
+ change.design.forEach((block, index) => {
2988
+ if ((block.title ?? "").trim() !== "") return;
2989
+ findings.push({
2990
+ severity: "error",
2991
+ kind: "design-block-untitled",
2992
+ // An untitled block has no name to be reported under, so it is named by where
2993
+ // it is and what it is the two things the author can find it by.
2994
+ subject: { kind: "design-block", index, blockKind: block.kind },
2995
+ message: `Design block ${index + 1} (\`${block.kind}\`) carries no title, and a block's title is emitted as its \`##\` heading.`
2996
+ });
2997
+ });
2998
+ return findings;
2999
+ }
3000
+ function validateCapabilities(change) {
3001
+ const findings = [];
3002
+ let derivedDeltas = 0;
3003
+ for (const entry of change.capabilities) {
3004
+ if (entry.derivation.outcome === "refused") {
3005
+ findings.push(...refusalFindings(entry));
3006
+ continue;
3007
+ }
3008
+ const deltas = deltasOf(entry);
3009
+ derivedDeltas += deltas.length;
3010
+ findings.push(...emptyDerivationFindings(entry));
3011
+ findings.push(...conditionFindings(entry));
3012
+ findings.push(...requirementFindings(entry));
3013
+ findings.push(...duplicateIdentityFindings(entry));
3014
+ }
3015
+ if (derivedDeltas === 0) {
3016
+ findings.push({
3017
+ severity: "error",
3018
+ kind: "no-delta-derived",
3019
+ subject: { kind: "change", change: change.id },
3020
+ message: "the change derives no delta at all, so it alters nothing \u2014 there is no operation to fold."
2104
3021
  });
2105
3022
  }
2106
- // ── Frames from the host browser (via /ws/local) ──
2107
- /**
2108
- * Handle one message from a local browser socket. Ops are relayed upstream
2109
- * verbatim (the browser already persists via its autosave PUT). A `hello`
2110
- * is answered locally with a synthetic snapshot so the browser can seed
2111
- * its revision/participant state without a round-trip to the DO.
2112
- */
2113
- handleLocalFrame(text, reply) {
2114
- const parsed = canvasClientMessageSchema.safeParse(
2115
- (() => {
2116
- try {
2117
- return JSON.parse(text);
2118
- } catch {
2119
- return null;
3023
+ return findings;
3024
+ }
3025
+ function refusalFindings(entry) {
3026
+ if (entry.derivation.outcome !== "refused") return [];
3027
+ return entry.derivation.refusals.map((refusal) => {
3028
+ if (refusal.kind === "duplicate-identity") {
3029
+ const keys = refusal.collisions.map((c) => `\`${c.key}\``).join(", ");
3030
+ return {
3031
+ severity: "error",
3032
+ kind: "duplicate-requirement-identity",
3033
+ subject: { kind: "capability", capability: entry.capability },
3034
+ message: `two requirements in the ${refusal.side} \`${entry.capability}\` claim one identity: ${keys}. One of them would derive nothing.`
3035
+ };
3036
+ }
3037
+ if (refusal.kind === "unterminated-fence") {
3038
+ return {
3039
+ severity: "error",
3040
+ kind: "capability-derivation-refused",
3041
+ subject: { kind: "capability", capability: entry.capability },
3042
+ message: `the ${refusal.side} \`${entry.capability}\` opens a fence on line ${refusal.openLine + 1} (\`${refusal.openText.trim()}\`) and never closes it, so everything after it sits inside the requirement above.`
3043
+ };
3044
+ }
3045
+ return {
3046
+ severity: "error",
3047
+ kind: "capability-derivation-refused",
3048
+ subject: { kind: "capability", capability: entry.capability },
3049
+ message: `\`${entry.capability}\` derives nothing: ${refusal.kind}.`
3050
+ };
3051
+ });
3052
+ }
3053
+ function emptyDerivationFindings(entry) {
3054
+ if (entry.derivation.outcome !== "derived") return [];
3055
+ if (entry.derivation.deltas.length > 0) return [];
3056
+ const subject = { kind: "capability", capability: entry.capability };
3057
+ switch (entry.derivation.foldedComparison.kind) {
3058
+ case "identical":
3059
+ return [
3060
+ {
3061
+ severity: "warning",
3062
+ kind: "capability-unchanged",
3063
+ subject,
3064
+ message: `\`${entry.capability}\` is open but byte-identical to its folded file; no delta spec is emitted for it.`
2120
3065
  }
2121
- })()
2122
- );
2123
- if (!parsed.success) return;
2124
- if (parsed.data.type === "hello") {
2125
- const scene = this.opts.workspace.loadScene();
2126
- reply(
2127
- JSON.stringify({
2128
- type: "snapshot",
2129
- scene: { elements: scene?.elements ?? [], files: scene?.files ?? void 0 },
2130
- revision: this.revision,
2131
- participants: this.participants,
2132
- protocolVersion: CANVAS_PROTOCOL_VERSION,
2133
- participantId: "host"
2134
- })
2135
- );
2136
- return;
3066
+ ];
3067
+ case "differs-outside-requirements":
3068
+ return [
3069
+ {
3070
+ severity: "error",
3071
+ kind: "capability-changed-outside-requirements",
3072
+ subject,
3073
+ message: `\`${entry.capability}\` differs from its folded file only outside its requirements (its \`## Purpose\` or head). OpenSpec has no operation for that edit \u2014 a delta file carrying no operation cannot fold. Edit \`openspec/specs/${entry.capability}/spec.md\` directly, or revert the edit.`
3074
+ }
3075
+ ];
3076
+ case "differs-in-requirement-order":
3077
+ return [
3078
+ {
3079
+ severity: "error",
3080
+ kind: "capability-requirements-reordered",
3081
+ subject,
3082
+ message: `\`${entry.capability}\`'s requirements are reordered and otherwise unchanged. OpenSpec has no move operation, so the reordering cannot be folded and would be lost.`
3083
+ }
3084
+ ];
3085
+ case "no-folded-counterpart":
3086
+ return [
3087
+ {
3088
+ severity: "error",
3089
+ kind: "new-capability-declares-no-requirement",
3090
+ subject,
3091
+ message: `\`${entry.capability}\` is a new capability that declares no requirement; there is nothing to add.`
3092
+ }
3093
+ ];
3094
+ case "differs":
3095
+ return [
3096
+ {
3097
+ severity: "error",
3098
+ kind: "capability-differs-without-delta",
3099
+ subject,
3100
+ message: `\`${entry.capability}\` differs from its folded file but derives no delta; the derivation and the comparison disagree about the same two documents.`
3101
+ }
3102
+ ];
3103
+ }
3104
+ }
3105
+ function conditionFindings(entry) {
3106
+ if (entry.derivation.outcome !== "derived") return [];
3107
+ const findings = [];
3108
+ const isNew = entry.derivation.foldedComparison.kind === "no-folded-counterpart";
3109
+ const subject = { kind: "capability", capability: entry.capability };
3110
+ for (const condition of entry.derivation.conditions) {
3111
+ if (isNew && (condition.kind === "purpose-empty" || condition.kind === "purpose-missing")) {
3112
+ findings.push({
3113
+ severity: "error",
3114
+ kind: "new-capability-purpose-empty",
3115
+ subject,
3116
+ message: `new capability \`${entry.capability}\` has ${condition.kind === "purpose-missing" ? "no `## Purpose` heading" : "an empty `## Purpose`"}; it seeds the folded spec's Purpose, which cannot be left blank.`
3117
+ });
3118
+ }
3119
+ if (condition.kind === "identity-scheme-changed") {
3120
+ findings.push({
3121
+ severity: "warning",
3122
+ kind: "identity-scheme-changed",
3123
+ subject,
3124
+ message: `\`${entry.capability}\`'s folded file resolves on ${condition.foldedScheme} identity and its draft on ${condition.draftScheme}, so no key matches across them: every requirement derives as a removal plus an addition. Declare the renames if that is not what you meant.`
3125
+ });
2137
3126
  }
2138
- this.sendUpstream(text);
2139
3127
  }
2140
- sendUpstream(frameText) {
2141
- const ws = this.ws;
2142
- if (ws && ws.readyState === WebSocket.OPEN) {
2143
- ws.send(frameText);
2144
- return;
3128
+ return findings;
3129
+ }
3130
+ function requirementFindings(entry) {
3131
+ const findings = [];
3132
+ for (const delta of deltasOf(entry)) {
3133
+ const block = draftBlockOf(delta);
3134
+ if (!block) continue;
3135
+ const subject = {
3136
+ kind: "requirement",
3137
+ capability: entry.capability,
3138
+ requirement: block.name
3139
+ };
3140
+ if (!SHALL_OR_MUST.test(requirementProse(block))) {
3141
+ findings.push({
3142
+ severity: "error",
3143
+ kind: "requirement-without-shall",
3144
+ subject,
3145
+ message: `\`${block.name}\` in \`${entry.capability}\` states no obligation: its text carries neither SHALL nor MUST.`
3146
+ });
3147
+ }
3148
+ const scenarios = parseScenarios(block);
3149
+ if (scenarios.length === 0) {
3150
+ findings.push({
3151
+ severity: "error",
3152
+ kind: "requirement-without-scenario",
3153
+ subject,
3154
+ message: `\`${block.name}\` in \`${entry.capability}\` has no \`#### Scenario:\`; a requirement is not testable without one.`
3155
+ });
3156
+ }
3157
+ for (const scenario of scenarios) {
3158
+ if (scenario.steps.length > 0) continue;
3159
+ findings.push({
3160
+ severity: "error",
3161
+ kind: "scenario-without-steps",
3162
+ subject: {
3163
+ kind: "scenario",
3164
+ capability: entry.capability,
3165
+ requirement: block.name,
3166
+ scenario: scenario.name
3167
+ },
3168
+ message: `scenario \`${scenario.name}\` of \`${block.name}\` in \`${entry.capability}\` has no steps.`
3169
+ });
2145
3170
  }
2146
- this.pendingOps.push(frameText);
2147
- if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
2148
3171
  }
2149
- // ── Teardown ──
2150
- /** End the session server-side (invalidates the share link) and close. */
2151
- async end() {
2152
- if (this.status === "ended") return;
2153
- this.closed = true;
2154
- this.status = "ended";
2155
- this.teardown();
2156
- try {
2157
- await this.api.endCanvasSession(this.sessionId);
2158
- } catch {
3172
+ return findings;
3173
+ }
3174
+ function duplicateIdentityFindings(entry) {
3175
+ const findings = [];
3176
+ const seen = /* @__PURE__ */ new Map();
3177
+ for (const delta of deltasOf(entry)) {
3178
+ const block = draftBlockOf(delta);
3179
+ const name = block ? block.name : delta.operation === "REMOVED" ? delta.base.name : delta.key;
3180
+ const section2 = delta.operation === "RENAMED" ? "MODIFIED" : delta.operation;
3181
+ const slot = `${section2} ${name}`;
3182
+ seen.set(slot, { name, count: (seen.get(slot)?.count ?? 0) + 1 });
3183
+ }
3184
+ for (const { name, count } of seen.values()) {
3185
+ if (count < 2) continue;
3186
+ findings.push({
3187
+ severity: "error",
3188
+ kind: "duplicate-requirement-identity",
3189
+ subject: { kind: "requirement", capability: entry.capability, requirement: name },
3190
+ message: `\`${name}\` appears ${count} times in \`${entry.capability}\`'s deltas; one of them would be invisible after the fold.`
3191
+ });
3192
+ }
3193
+ return findings;
3194
+ }
3195
+ function validateTasks(change) {
3196
+ const findings = [];
3197
+ const citable = new Set(
3198
+ citableRequirements(change.capabilities).map((c) => `${c.capability} ${c.key}`)
3199
+ );
3200
+ for (const group of numberTasks(change.tasks)) {
3201
+ if (group.tasks.length === 0) {
3202
+ findings.push({
3203
+ severity: "error",
3204
+ kind: "task-group-empty",
3205
+ subject: { kind: "task-group", number: group.number, title: group.title },
3206
+ message: `Task group ${group.number} (\`${group.title}\`) carries no tasks.`
3207
+ });
3208
+ }
3209
+ const cites = group.cites;
3210
+ if (cites) {
3211
+ const subject = {
3212
+ kind: "task-group",
3213
+ number: group.number,
3214
+ title: group.title
3215
+ };
3216
+ if (cites.capability.includes("`") || cites.key.includes("`") || group.title.includes("`")) {
3217
+ findings.push({
3218
+ severity: "error",
3219
+ kind: "task-citation-unrepresentable",
3220
+ subject,
3221
+ message: `task group ${group.number} carries a backtick in its title or citation; the emitted citation could not be read back unambiguously.`
3222
+ });
3223
+ } else if (!citable.has(`${cites.capability} ${cites.key}`)) {
3224
+ findings.push({
3225
+ severity: "error",
3226
+ kind: "task-cites-absent-requirement",
3227
+ subject,
3228
+ message: `task group ${group.number} cites \`${cites.key}\` in \`${cites.capability}\`, which this change no longer declares.`
3229
+ });
3230
+ }
2159
3231
  }
2160
3232
  }
2161
- teardown() {
2162
- this.stopHeartbeat();
2163
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2164
- this.reconnectTimer = null;
3233
+ return findings;
3234
+ }
3235
+
3236
+ // src/workbench/shell/provenance.ts
3237
+ var NO_ISSUE = Object.freeze({ kind: "none" });
3238
+ function provenanceValue(provenance) {
3239
+ return provenance.kind === "issue" ? String(provenance.issue) : "none";
3240
+ }
3241
+ function provenanceFrontmatter(provenance) {
3242
+ return `---
3243
+ issue: ${provenanceValue(provenance)}
3244
+ ---
3245
+ `;
3246
+ }
3247
+ function emitChangeWithProvenance(change, provenance) {
3248
+ const emitted = emitChange(change);
3249
+ return {
3250
+ id: emitted.id,
3251
+ files: emitted.files.map(
3252
+ (file) => file.path === "proposal.md" ? { path: file.path, content: `${provenanceFrontmatter(provenance)}
3253
+ ${file.content}` } : file
3254
+ )
3255
+ };
3256
+ }
3257
+
3258
+ // src/workbench/approve/approve.ts
3259
+ var ARCHIVE_FLAGS = Object.freeze(["--json", "--yes"]);
3260
+ var FLAGS_APPROVAL_NEVER_PASSES = Object.freeze([
3261
+ "--skip-specs",
3262
+ "--no-validate"
3263
+ ]);
3264
+ function archiveArgv(changeId) {
3265
+ return ["archive", changeId, ...ARCHIVE_FLAGS];
3266
+ }
3267
+ function changeDirectoryOf(repoRoot, changeId) {
3268
+ return join7(repoRoot, "openspec", "changes", changeId);
3269
+ }
3270
+ function approveChange(repoRoot, change, provenance, options = {}) {
3271
+ const directory = changeDirectoryOf(repoRoot, change.id);
3272
+ const report2 = validateChange(change);
3273
+ if (!report2.valid) {
3274
+ return { outcome: "refused-validation", errors: report2.errors, warnings: report2.warnings };
3275
+ }
3276
+ const emitted = emitChangeWithProvenance(change, provenance);
3277
+ if (!existsSync5(directory)) return { outcome: "refused-not-emitted", directory };
3278
+ const differing = emittedDifferences(directory, emitted);
3279
+ if (differing.length > 0) return { outcome: "refused-emission-stale", directory, differing };
3280
+ const foldedCapabilities = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => f.path.split("/")[1]);
3281
+ const snapshot = takeSnapshot(repoRoot);
3282
+ try {
3283
+ const archive = runArchive(repoRoot, change.id, options);
3284
+ if (archive.kind === "failed") {
3285
+ return {
3286
+ outcome: "refused-fold-failed",
3287
+ code: archive.code,
3288
+ message: archive.message,
3289
+ rollback: restore(repoRoot, snapshot)
3290
+ };
3291
+ }
3292
+ const incomplete = incompleteness(repoRoot, change.id, archive, foldedCapabilities);
3293
+ if (incomplete) {
3294
+ return {
3295
+ outcome: "refused-fold-incomplete",
3296
+ reason: incomplete,
3297
+ rollback: restore(repoRoot, snapshot)
3298
+ };
3299
+ }
3300
+ return {
3301
+ outcome: "approved",
3302
+ archivedAs: archive.archivedAs,
3303
+ archivePath: archive.path,
3304
+ foldedCapabilities,
3305
+ totals: archive.totals,
3306
+ warnings: report2.warnings
3307
+ };
3308
+ } finally {
3309
+ discardSnapshot(snapshot);
3310
+ }
3311
+ }
3312
+ function emittedDifferences(directory, emitted) {
3313
+ const differing = [];
3314
+ for (const file of emitted.files) {
3315
+ const target = join7(directory, file.path);
3316
+ let onDisk;
2165
3317
  try {
2166
- this.ws?.close(1e3, "host shutting down");
3318
+ onDisk = readFileSync5(target, "utf8");
2167
3319
  } catch {
3320
+ differing.push(file.path);
3321
+ continue;
3322
+ }
3323
+ if (onDisk !== file.content) differing.push(file.path);
3324
+ }
3325
+ return differing;
3326
+ }
3327
+ var SNAPSHOT_TREES = Object.freeze(["specs", "changes"]);
3328
+ function takeSnapshot(repoRoot) {
3329
+ const directory = mkdtempSync(join7(tmpdir(), "specsketch-approve-"));
3330
+ const present = [];
3331
+ for (const tree of SNAPSHOT_TREES) {
3332
+ const source = join7(repoRoot, "openspec", tree);
3333
+ if (!existsSync5(source)) continue;
3334
+ cpSync(source, join7(directory, tree), { recursive: true });
3335
+ present.push(tree);
3336
+ }
3337
+ return { root: repoRoot, present, directory };
3338
+ }
3339
+ function restore(repoRoot, snapshot) {
3340
+ try {
3341
+ for (const tree of SNAPSHOT_TREES) {
3342
+ const target = join7(repoRoot, "openspec", tree);
3343
+ rmSync(target, { recursive: true, force: true });
3344
+ if (!snapshot.present.includes(tree)) continue;
3345
+ mkdirSync3(join7(repoRoot, "openspec"), { recursive: true });
3346
+ cpSync(join7(snapshot.directory, tree), target, { recursive: true });
3347
+ }
3348
+ return { kind: "restored" };
3349
+ } catch (error) {
3350
+ return { kind: "restore-failed", error: error instanceof Error ? error.message : String(error) };
3351
+ }
3352
+ }
3353
+ function discardSnapshot(snapshot) {
3354
+ rmSync(snapshot.directory, { recursive: true, force: true });
3355
+ }
3356
+ function resolveOpenspecBinary(repoRoot) {
3357
+ const local = join7(repoRoot, "node_modules", ".bin", "openspec");
3358
+ return existsSync5(local) ? local : "openspec";
3359
+ }
3360
+ var NO_TOTALS = Object.freeze({ added: 0, modified: 0, removed: 0, renamed: 0 });
3361
+ function runArchive(repoRoot, changeId, options) {
3362
+ const bin = options.openspecBin ?? resolveOpenspecBinary(repoRoot);
3363
+ let stdout;
3364
+ try {
3365
+ stdout = execFileSync(bin, [...archiveArgv(changeId)], {
3366
+ cwd: repoRoot,
3367
+ encoding: "utf8"
3368
+ });
3369
+ } catch (error) {
3370
+ const captured = error.stdout;
3371
+ if (typeof captured === "string" && captured.trim() !== "") stdout = captured;
3372
+ else {
3373
+ return {
3374
+ kind: "failed",
3375
+ code: "archive_not_run",
3376
+ message: error instanceof Error ? error.message : String(error)
3377
+ };
3378
+ }
3379
+ }
3380
+ return readArchiveJson(stdout);
3381
+ }
3382
+ function readArchiveJson(stdout) {
3383
+ let parsed;
3384
+ try {
3385
+ parsed = JSON.parse(stdout);
3386
+ } catch {
3387
+ return { kind: "failed", code: "archive_output_unreadable", message: stdout.trim() };
3388
+ }
3389
+ if (!parsed.archive) {
3390
+ const diagnostic = parsed.status?.[0];
3391
+ return {
3392
+ kind: "failed",
3393
+ code: diagnostic?.code ?? "archive_failed",
3394
+ message: diagnostic?.message ?? "openspec archive reported no result and no diagnostic."
3395
+ };
3396
+ }
3397
+ const totals = parsed.archive.totals;
3398
+ return {
3399
+ kind: "archived",
3400
+ archivedAs: parsed.archive.archivedAs ?? "",
3401
+ path: parsed.archive.path ?? "",
3402
+ specsUpdated: parsed.archive.specsUpdated === true,
3403
+ totals: totals ? {
3404
+ added: totals.added ?? 0,
3405
+ modified: totals.modified ?? 0,
3406
+ removed: totals.removed ?? 0,
3407
+ renamed: totals.renamed ?? 0
3408
+ } : NO_TOTALS
3409
+ };
3410
+ }
3411
+ function incompleteness(repoRoot, changeId, archive, foldedCapabilities) {
3412
+ if (!archive.specsUpdated) return { kind: "no-spec-updated" };
3413
+ const folded = archive.totals.added + archive.totals.modified + archive.totals.removed + archive.totals.renamed;
3414
+ if (folded === 0) return { kind: "nothing-folded" };
3415
+ for (const capability of foldedCapabilities) {
3416
+ if (!existsSync5(join7(repoRoot, "openspec", "specs", capability, "spec.md"))) {
3417
+ return { kind: "capability-not-folded", capability };
3418
+ }
3419
+ }
3420
+ if (existsSync5(changeDirectoryOf(repoRoot, changeId))) return { kind: "change-still-in-changes" };
3421
+ const archived = join7(repoRoot, "openspec", "changes", "archive", archive.archivedAs);
3422
+ if (archive.archivedAs === "" || !isDirectory(archived)) {
3423
+ return { kind: "archive-directory-missing", expected: archived };
3424
+ }
3425
+ return null;
3426
+ }
3427
+ function isDirectory(path) {
3428
+ try {
3429
+ return statSync3(path).isDirectory();
3430
+ } catch {
3431
+ return false;
3432
+ }
3433
+ }
3434
+
3435
+ // src/workbench/write/write-change.ts
3436
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
3437
+ import { dirname as dirname4, join as join8 } from "node:path";
3438
+ function changeDirectory(repoRoot, changeId) {
3439
+ return join8(repoRoot, "openspec", "changes", changeId);
3440
+ }
3441
+ function writeChange(repoRoot, change, provenance, options = {}) {
3442
+ const directory = changeDirectory(repoRoot, change.id);
3443
+ const report2 = validateChange(change);
3444
+ if (!report2.valid) {
3445
+ return {
3446
+ outcome: "validation-failed",
3447
+ directory,
3448
+ errors: report2.errors,
3449
+ warnings: report2.warnings
3450
+ };
3451
+ }
3452
+ if (!options.overwrite && isNonEmptyDirectory(directory)) {
3453
+ return { outcome: "directory-exists", directory };
3454
+ }
3455
+ const emitted = emitChangeWithProvenance(change, provenance);
3456
+ writeEmitted(directory, emitted);
3457
+ return {
3458
+ outcome: "written",
3459
+ directory,
3460
+ files: emitted.files.map((f) => f.path),
3461
+ warnings: report2.warnings
3462
+ };
3463
+ }
3464
+ function isNonEmptyDirectory(directory) {
3465
+ try {
3466
+ if (!statSync4(directory).isDirectory()) return true;
3467
+ return readdirSync6(directory).length > 0;
3468
+ } catch {
3469
+ return false;
3470
+ }
3471
+ }
3472
+ function readEmittedChange(directory, id) {
3473
+ const files = [];
3474
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
3475
+ files.push({ path: name, content: readFileSync6(join8(directory, name), "utf8") });
3476
+ }
3477
+ const specsDir = join8(directory, "specs");
3478
+ let capabilities = [];
3479
+ try {
3480
+ capabilities = readdirSync6(specsDir).filter((name) => statSync4(join8(specsDir, name)).isDirectory()).sort();
3481
+ } catch {
3482
+ capabilities = [];
3483
+ }
3484
+ for (const capability of capabilities) {
3485
+ files.push({
3486
+ path: `specs/${capability}/spec.md`,
3487
+ content: readFileSync6(join8(specsDir, capability, "spec.md"), "utf8")
3488
+ });
3489
+ }
3490
+ return { id, files };
3491
+ }
3492
+ function writeEmitted(directory, emitted) {
3493
+ mkdirSync4(directory, { recursive: true });
3494
+ for (const file of emitted.files) {
3495
+ const target = join8(directory, file.path);
3496
+ mkdirSync4(dirname4(target), { recursive: true });
3497
+ writeFileSync3(target, file.content, "utf8");
3498
+ }
3499
+ }
3500
+
3501
+ // src/workbench/core/reconstruct.ts
3502
+ function headingName(block) {
3503
+ const match = /^###[ \t]+Requirement:[ \t]*(.*)$/m.exec(block);
3504
+ return match?.[1]?.trim() ?? null;
3505
+ }
3506
+ function reconstructDraft(folded, delta) {
3507
+ const capability = delta.capability;
3508
+ if (folded.trim() === "") {
3509
+ const purpose = delta.purpose === null ? "" : `
3510
+ ## Purpose
3511
+ ${delta.purpose}
3512
+ `;
3513
+ const head = `# ${capability} Specification
3514
+ ${purpose}
3515
+ ## Requirements
3516
+
3517
+ `;
3518
+ return {
3519
+ outcome: "reconstructed",
3520
+ capability,
3521
+ draft: `${head}${delta.added.map((b) => b.trimEnd()).join("\n\n")}
3522
+ `
3523
+ };
3524
+ }
3525
+ const parsed = parseSpecFile(folded);
3526
+ if (parsed.outside.length > 0) {
3527
+ return {
3528
+ outcome: "unreconstructable",
3529
+ capability,
3530
+ reason: "the folded spec carries content outside its requirement blocks, which a rebuild would drop",
3531
+ draft: folded
3532
+ };
3533
+ }
3534
+ const blocks = /* @__PURE__ */ new Map();
3535
+ const order = [];
3536
+ for (const requirement of parsed.requirements) {
3537
+ blocks.set(requirement.name, requirement.text);
3538
+ order.push(requirement.name);
3539
+ }
3540
+ for (const pair of delta.renamed) {
3541
+ const existing = blocks.get(pair.from);
3542
+ if (existing === void 0) {
3543
+ return {
3544
+ outcome: "unreconstructable",
3545
+ capability,
3546
+ reason: `the delta renames \`${pair.from}\`, which the folded spec does not declare`,
3547
+ draft: folded
3548
+ };
2168
3549
  }
2169
- this.ws = null;
3550
+ blocks.delete(pair.from);
3551
+ blocks.set(pair.to, existing.replace(pair.from, pair.to));
3552
+ order[order.indexOf(pair.from)] = pair.to;
2170
3553
  }
2171
- };
2172
-
2173
- // server/workspace.ts
2174
- import { createHash } from "node:crypto";
2175
- import {
2176
- existsSync as existsSync2,
2177
- mkdirSync,
2178
- readFileSync as readFileSync2,
2179
- readdirSync as readdirSync2,
2180
- renameSync,
2181
- writeFileSync
2182
- } from "node:fs";
2183
- import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
2184
- var DIAGRAM_FILE = "diagram.excalidraw";
2185
- var SIDECAR_FILE = "specsketch.json";
2186
- var sha256 = (s) => createHash("sha256").update(s).digest("hex");
2187
- var ChangeWorkspace = class {
2188
- dir;
2189
- changeName;
2190
- constructor(dir) {
2191
- this.dir = resolve2(dir);
2192
- this.changeName = basename2(this.dir);
2193
- mkdirSync(this.dir, { recursive: true });
2194
- }
2195
- writeAtomic(relPath, content) {
2196
- const target = join4(this.dir, relPath);
2197
- mkdirSync(dirname2(target), { recursive: true });
2198
- const tmp = `${target}.tmp`;
2199
- writeFileSync(tmp, content, "utf8");
2200
- renameSync(tmp, target);
3554
+ for (const name of delta.removed) {
3555
+ if (!blocks.delete(name)) {
3556
+ return {
3557
+ outcome: "unreconstructable",
3558
+ capability,
3559
+ reason: `the delta removes \`${name}\`, which the folded spec does not declare`,
3560
+ draft: folded
3561
+ };
3562
+ }
3563
+ order.splice(order.indexOf(name), 1);
2201
3564
  }
2202
- readIfExists(relPath) {
2203
- const target = join4(this.dir, relPath);
2204
- return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
3565
+ for (const block of delta.modified) {
3566
+ const name = headingName(block);
3567
+ if (name === null || !blocks.has(name)) {
3568
+ return {
3569
+ outcome: "unreconstructable",
3570
+ capability,
3571
+ reason: name === null ? "the delta modifies a block with no readable heading" : `the delta modifies \`${name}\`, which is not in the folded spec after renames and removals`,
3572
+ draft: folded
3573
+ };
3574
+ }
3575
+ blocks.set(name, block.trimEnd());
2205
3576
  }
2206
- // ── Scene ────────────────────────────────────────────────────────────────
2207
- loadScene() {
2208
- const raw = this.readIfExists(DIAGRAM_FILE);
2209
- if (!raw) return null;
2210
- try {
2211
- const parsed = JSON.parse(raw);
3577
+ for (const block of delta.added) {
3578
+ const name = headingName(block);
3579
+ if (name === null) {
2212
3580
  return {
2213
- elements: Array.isArray(parsed.elements) ? parsed.elements : [],
2214
- appState: parsed.appState ?? void 0,
2215
- files: parsed.files ?? void 0
3581
+ outcome: "unreconstructable",
3582
+ capability,
3583
+ reason: "the delta adds a block with no readable heading",
3584
+ draft: folded
2216
3585
  };
2217
- } catch {
2218
- return null;
2219
3586
  }
3587
+ if (!blocks.has(name)) order.push(name);
3588
+ blocks.set(name, block.trimEnd());
2220
3589
  }
2221
- saveScene(scene, title, model) {
2222
- this.writeAtomic(
2223
- DIAGRAM_FILE,
2224
- JSON.stringify(
2225
- {
2226
- type: "excalidraw",
2227
- version: 2,
2228
- source: "coopcli-specsketch",
2229
- elements: scene.elements,
2230
- appState: scene.appState ?? {},
2231
- files: scene.files ?? {}
2232
- },
2233
- null,
2234
- 2
2235
- )
2236
- );
2237
- this.updateSidecar((s) => ({
2238
- ...s,
2239
- ...title !== void 0 ? { title } : {},
2240
- ...model !== void 0 ? { model } : {}
2241
- }));
3590
+ const body = order.map((name) => blocks.get(name).trimEnd()).join("\n\n");
3591
+ return {
3592
+ outcome: "reconstructed",
3593
+ capability,
3594
+ draft: `${parsed.headText.trimEnd()}
3595
+
3596
+ ${body}
3597
+ `
3598
+ };
3599
+ }
3600
+
3601
+ // src/workbench/agent/credential-probe.ts
3602
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync7, statSync as statSync5 } from "node:fs";
3603
+ import { delimiter, join as join9 } from "node:path";
3604
+ function readSubscriptionToken(home) {
3605
+ const path = join9(home, ".claude", ".credentials.json");
3606
+ if (!existsSync6(path)) return null;
3607
+ try {
3608
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
3609
+ const token = parsed.claudeAiOauth?.accessToken;
3610
+ if (typeof token !== "string" || token === "") return null;
3611
+ const expiresAt = parsed.claudeAiOauth?.expiresAt;
3612
+ if (typeof expiresAt === "number" && expiresAt <= Date.now()) return null;
3613
+ return token;
3614
+ } catch {
3615
+ return null;
2242
3616
  }
2243
- // ── Sidecar ──────────────────────────────────────────────────────────────
2244
- readSidecar() {
2245
- const raw = this.readIfExists(SIDECAR_FILE);
2246
- if (!raw) return {};
3617
+ }
3618
+
3619
+ // src/workbench/agent/model-boundary.ts
3620
+ import { builtinModels } from "@earendil-works/pi-ai/providers/all";
3621
+ var MAX_TOOL_ROUNDS = 6;
3622
+ var DEFAULT_PROVIDER = "anthropic";
3623
+ var DEFAULT_MODEL2 = "claude-opus-4-5";
3624
+ async function runTurn(input, deps = {}) {
3625
+ const provider = deps.provider ?? DEFAULT_PROVIDER;
3626
+ const modelId = deps.modelId ?? DEFAULT_MODEL2;
3627
+ const maxRounds = deps.maxRounds ?? MAX_TOOL_ROUNDS;
3628
+ if (deps.oauthToken) process.env.ANTHROPIC_OAUTH_TOKEN = deps.oauthToken;
3629
+ else if (deps.apiKey) process.env.ANTHROPIC_API_KEY = deps.apiKey;
3630
+ const models = deps.models ?? builtinModels();
3631
+ const model = models.getModel(provider, modelId);
3632
+ if (!model) return { kind: "failed", reason: `no such model: ${provider}/${modelId}` };
3633
+ const messages = [
3634
+ { role: "user", content: input.prompt, timestamp: Date.now() }
3635
+ ];
3636
+ const context = {
3637
+ systemPrompt: input.systemPrompt,
3638
+ messages,
3639
+ tools: [...input.tools]
3640
+ };
3641
+ const calls = [];
3642
+ const served = `${provider}/${modelId}`;
3643
+ let answer = "";
3644
+ for (let round = 0; round < maxRounds; round += 1) {
3645
+ const stream = models.stream(model, context);
3646
+ const pending = [];
3647
+ let message;
2247
3648
  try {
2248
- return JSON.parse(raw);
2249
- } catch {
2250
- return {};
3649
+ for await (const event of stream) {
3650
+ if (event.type === "toolcall_end") pending.push(event.toolCall);
3651
+ }
3652
+ message = await stream.result();
3653
+ } catch (err) {
3654
+ return { kind: "failed", reason: err instanceof Error ? err.message : String(err) };
3655
+ }
3656
+ if (message.stopReason === "error") {
3657
+ return { kind: "failed", reason: message.errorMessage ?? "the model returned an error" };
3658
+ }
3659
+ answer = textOf2(message);
3660
+ context.messages.push(message);
3661
+ if (pending.length === 0) return { kind: "answered", answer, calls, served };
3662
+ for (const call of pending) {
3663
+ const result = await input.execute(call.name, call.arguments);
3664
+ calls.push({ name: call.name, arguments: call.arguments, result });
3665
+ context.messages.push({
3666
+ role: "toolResult",
3667
+ toolCallId: call.id,
3668
+ toolName: call.name,
3669
+ content: [{ type: "text", text: result }],
3670
+ // The executor reports a refusal as ordinary text — a refused proposal is a
3671
+ // normal outcome the model should reason about, not a transport error.
3672
+ isError: false,
3673
+ timestamp: Date.now()
3674
+ });
2251
3675
  }
2252
3676
  }
2253
- updateSidecar(update) {
2254
- const now = (/* @__PURE__ */ new Date()).toISOString();
2255
- const current = this.readSidecar();
2256
- const next = update(current);
2257
- next.createdAt ??= now;
2258
- next.updatedAt = now;
2259
- this.writeAtomic(SIDECAR_FILE, JSON.stringify(next, null, 2));
3677
+ return { kind: "cut-short", answer, calls, served, rounds: maxRounds };
3678
+ }
3679
+ function textOf2(message) {
3680
+ return message.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
3681
+ }
3682
+
3683
+ // src/workbench/agent/tools.ts
3684
+ import { Type } from "@earendil-works/pi-ai";
3685
+ var AGENT_TOOLS = [
3686
+ {
3687
+ name: "add_task_group",
3688
+ description: "Propose a group of implementation tasks for this change. Use for planning the work; one group per coherent area, each task a single concrete action.",
3689
+ parameters: Type.Object({
3690
+ title: Type.String({ description: 'Short group title, e.g. "Search" or "Byte-safe writes"' }),
3691
+ tasks: Type.Array(Type.String(), {
3692
+ description: "Task titles, one per task. Each states one concrete action."
3693
+ }),
3694
+ capability: Type.Optional(
3695
+ Type.String({
3696
+ description: "Optional. The capability of the requirement this group implements \u2014 omit for a chore group that implements none."
3697
+ })
3698
+ ),
3699
+ requirement: Type.Optional(
3700
+ Type.String({
3701
+ description: "Optional. The exact requirement name this group implements, as it appears after `### Requirement:`."
3702
+ })
3703
+ )
3704
+ })
3705
+ },
3706
+ {
3707
+ name: "add_requirement",
3708
+ description: "Propose a requirement to draft into an open capability. The block must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps.",
3709
+ parameters: Type.Object({
3710
+ capability: Type.String({ description: "A capability already open in this change" }),
3711
+ block: Type.String({ description: "The complete requirement block, in markdown" })
3712
+ })
3713
+ },
3714
+ {
3715
+ name: "set_proposal_section",
3716
+ description: "Propose the body of one proposal section.",
3717
+ parameters: Type.Object({
3718
+ section: Type.String({
3719
+ description: `One of: ${PROPOSAL_SECTIONS.map((s) => s.id).join(", ")}`
3720
+ }),
3721
+ body: Type.String({ description: "The section body, in markdown" })
3722
+ })
3723
+ },
3724
+ {
3725
+ name: "add_design_block",
3726
+ description: "Propose a design block. Use `mermaid` for a diagram (C4Context / C4Container / C4Component / C4Dynamic render as diagrams), `markdown` for prose.",
3727
+ parameters: Type.Object({
3728
+ kind: Type.String({ description: "markdown | mermaid | sketch" }),
3729
+ title: Type.String({ description: "The block's heading" }),
3730
+ text: Type.String({ description: "The block body" })
3731
+ })
2260
3732
  }
2261
- // ── Artifacts ────────────────────────────────────────────────────────────
2262
- /** Current artifact files, however they were authored. */
2263
- readArtifacts() {
2264
- const specs = [];
2265
- const specsDir = join4(this.dir, "specs");
2266
- if (existsSync2(specsDir)) {
2267
- for (const entry of readdirSync2(specsDir, { withFileTypes: true })) {
2268
- if (!entry.isDirectory()) continue;
2269
- const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
2270
- if (spec !== void 0) specs.push({ capability: entry.name, spec });
3733
+ ];
3734
+ var DESIGN_KINDS = /* @__PURE__ */ new Set(["markdown", "mermaid", "sketch"]);
3735
+ function stripLeadingNumber(text) {
3736
+ return text.replace(/^\s*\d+(?:\.\d+)*[.)]?\s+/, "").trim();
3737
+ }
3738
+ function proposalFromToolCall(name, args, shape) {
3739
+ switch (name) {
3740
+ case "add_task_group": {
3741
+ const title = stripLeadingNumber(String(args["title"] ?? ""));
3742
+ const tasks = Array.isArray(args["tasks"]) ? args["tasks"].map((t) => stripLeadingNumber(String(t))).filter((t) => t !== "") : [];
3743
+ if (title === "" || tasks.length === 0) {
3744
+ return { kind: "refused", tool: name, missing: "a title and at least one task" };
3745
+ }
3746
+ const capability = String(args["capability"] ?? "").trim();
3747
+ const key = String(args["requirement"] ?? "").trim();
3748
+ if (capability === "" && key === "") return { kind: "task-group", title, tasks };
3749
+ if (capability === "" || key === "") {
3750
+ return {
3751
+ kind: "refused",
3752
+ tool: name,
3753
+ missing: "both a capability and a requirement name, or neither"
3754
+ };
2271
3755
  }
3756
+ if (shape.citable !== void 0 && !shape.citable.some((c) => c.capability === capability && c.key === key)) {
3757
+ return {
3758
+ kind: "refused",
3759
+ tool: name,
3760
+ missing: `\`${key}\` in \`${capability}\` is not a requirement this change declares`
3761
+ };
3762
+ }
3763
+ return { kind: "task-group", title, tasks, cites: { capability, key } };
2272
3764
  }
2273
- return {
2274
- proposal: this.readIfExists("proposal.md"),
2275
- design: this.readIfExists("design.md"),
2276
- tasks: this.readIfExists("tasks.md"),
2277
- specs
2278
- };
3765
+ case "add_requirement": {
3766
+ const capability = String(args["capability"] ?? "").trim();
3767
+ const block = String(args["block"] ?? "").trim();
3768
+ if (!shape.openCapabilities.includes(capability)) {
3769
+ return {
3770
+ kind: "refused",
3771
+ tool: name,
3772
+ missing: `capability \`${capability}\` is not open in this change`
3773
+ };
3774
+ }
3775
+ if (!/^###[ \t]+Requirement:/m.test(block)) {
3776
+ return { kind: "refused", tool: name, missing: "a `### Requirement:` heading" };
3777
+ }
3778
+ return { kind: "requirement", capability, block };
3779
+ }
3780
+ case "set_proposal_section": {
3781
+ const section2 = String(args["section"] ?? "").trim();
3782
+ const known = PROPOSAL_SECTIONS.find((s) => s.id === section2);
3783
+ if (!known) {
3784
+ return { kind: "refused", tool: name, missing: `no proposal section \`${section2}\`` };
3785
+ }
3786
+ return {
3787
+ kind: "proposal-section",
3788
+ section: known.id,
3789
+ body: String(args["body"] ?? "")
3790
+ };
3791
+ }
3792
+ case "add_design_block": {
3793
+ const blockKind = String(args["kind"] ?? "").trim();
3794
+ if (!DESIGN_KINDS.has(blockKind)) {
3795
+ return { kind: "refused", tool: name, missing: `no design block kind \`${blockKind}\`` };
3796
+ }
3797
+ const title = String(args["title"] ?? "").trim();
3798
+ if (title === "") return { kind: "refused", tool: name, missing: "a block title" };
3799
+ return {
3800
+ kind: "design-block",
3801
+ blockKind,
3802
+ title,
3803
+ text: String(args["text"] ?? "")
3804
+ };
3805
+ }
3806
+ default:
3807
+ return { kind: "refused", tool: name, missing: `no such tool \`${name}\`` };
2279
3808
  }
2280
- /** Reconstruct a SpecBundle view from the files on disk, or null if none. */
2281
- toBundle() {
2282
- const a = this.readArtifacts();
2283
- if (!a.proposal && !a.design && !a.tasks && a.specs.length === 0) return null;
2284
- return {
2285
- changeName: this.changeName,
2286
- summary: this.readSidecar().summary ?? "",
2287
- proposal: a.proposal ?? "",
2288
- design: a.design ?? "",
2289
- tasks: a.tasks ?? "",
2290
- specs: a.specs
2291
- };
3809
+ }
3810
+ function toolResultFor(proposal) {
3811
+ switch (proposal.kind) {
3812
+ case "task-group":
3813
+ return `Proposed task group "${proposal.title}" with ${proposal.tasks.length} task(s)${proposal.cites ? `, implementing \`${proposal.cites.key}\`` : ", implementing no requirement"}. Awaiting the author's apply; nothing is written yet.`;
3814
+ case "requirement":
3815
+ return `Proposed a requirement for \`${proposal.capability}\`. Awaiting the author's apply; nothing is written yet.`;
3816
+ case "proposal-section":
3817
+ return `Proposed a body for the \`${proposal.section}\` section. Awaiting the author's apply; nothing is written yet.`;
3818
+ case "design-block":
3819
+ return `Proposed a ${proposal.blockKind} design block "${proposal.title}". Awaiting the author's apply; nothing is written yet.`;
3820
+ case "refused":
3821
+ return `Refused: ${proposal.tool} needs ${proposal.missing}. Nothing was proposed.`;
2292
3822
  }
2293
- /**
2294
- * Materialize a generated bundle in OpenSpec layout, writing only files
2295
- * whose content actually changed. Returns the relative paths written.
2296
- */
2297
- writeBundle(bundle) {
2298
- const files = {
2299
- "proposal.md": bundle.proposal,
2300
- "design.md": bundle.design,
2301
- "tasks.md": bundle.tasks
2302
- };
2303
- for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
2304
- const written = [];
2305
- const manifest = {};
2306
- for (const [relPath, content] of Object.entries(files)) {
2307
- manifest[relPath] = sha256(content);
2308
- if (this.readIfExists(relPath) === content) continue;
2309
- this.writeAtomic(relPath, content);
2310
- written.push(relPath);
3823
+ }
3824
+
3825
+ // server/workbench-host.ts
3826
+ var CHANGE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3827
+ function changeContextFor(target, source) {
3828
+ const read = [];
3829
+ let changeText = "";
3830
+ if (source.change) {
3831
+ for (const file of emitChange({ ...source.change, id: target.changeId }).files) {
3832
+ if (file.content.replace(/^#[ \t].*$/m, "").trim() === "") continue;
3833
+ changeText += `
3834
+
3835
+ ===== ${file.path} (unsaved draft \u2014 what the author is looking at) =====
3836
+ ${file.content}`;
3837
+ read.push(`${file.path} (draft)`);
2311
3838
  }
2312
- this.updateSidecar((s) => ({
2313
- ...s,
2314
- summary: bundle.summary,
2315
- generated: manifest,
2316
- // The diff-before-write result IS the changed-file signal for the
2317
- // post-generation chat seed (generation-chat-reset-summary).
2318
- lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
2319
- }));
2320
- return written;
3839
+ return { read, changeText };
2321
3840
  }
2322
- // ── Spec review chat ─────────────────────────────────────────────────────
2323
- readChat() {
2324
- return this.readSidecar().chat ?? [];
3841
+ if (!target.resuming) return { read, changeText };
3842
+ for (const name of ["proposal.md", "tasks.md"]) {
3843
+ const file = join10(target.changeDir, name);
3844
+ if (!existsSync7(file)) continue;
3845
+ changeText += `
3846
+
3847
+ ===== ${name} =====
3848
+ ${readFileSync8(file, "utf8")}`;
3849
+ read.push(name);
3850
+ }
3851
+ for (const capability of source.openCapabilities ?? []) {
3852
+ const file = join10(target.changeDir, "specs", capability, "spec.md");
3853
+ if (!existsSync7(file)) continue;
3854
+ changeText += `
3855
+
3856
+ ===== specs/${capability}/spec.md (the change's deltas) =====
3857
+ ${readFileSync8(file, "utf8")}`;
3858
+ read.push(`specs/${capability}/spec.md`);
2325
3859
  }
2326
- writeChat(chat) {
2327
- this.updateSidecar((s) => ({ ...s, chat }));
3860
+ return { read, changeText };
3861
+ }
3862
+ var WorkbenchHostError = class extends Error {
3863
+ };
3864
+ function findRepoRoot(start) {
3865
+ let dir = resolve3(start);
3866
+ for (; ; ) {
3867
+ if (existsSync7(join10(dir, "openspec"))) return dir;
3868
+ const parent = dirname5(dir);
3869
+ if (parent === dir) return null;
3870
+ dir = parent;
2328
3871
  }
2329
- // ── Generation system prompt override ────────────────────────────────────
2330
- readSystemPrompt() {
2331
- return this.readSidecar().systemPrompt;
3872
+ }
3873
+ function resolveTarget(cwd, changeId) {
3874
+ if (changeId === void 0 || changeId === "") {
3875
+ throw new WorkbenchHostError("a change id is required: specsketch <change-id>");
2332
3876
  }
2333
- /** `undefined` clears the override (reset to the built-in default). */
2334
- writeSystemPrompt(systemPrompt) {
2335
- this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
2336
- ...s,
2337
- ...systemPrompt !== void 0 ? { systemPrompt } : {}
2338
- }));
3877
+ if (!CHANGE_ID.test(changeId)) {
3878
+ throw new WorkbenchHostError(
3879
+ `\`${changeId}\` is not a change id. Pass the id (\`my-change\`), not a path \u2014 the workbench resolves it under \`openspec/changes/\`.`
3880
+ );
2339
3881
  }
2340
- };
3882
+ const repoRoot = findRepoRoot(cwd);
3883
+ if (repoRoot === null) {
3884
+ throw new WorkbenchHostError(
3885
+ `no \`openspec/\` directory found in \`${resolve3(cwd)}\` or any parent. The workbench authors changes in an OpenSpec repository; run it inside one.`
3886
+ );
3887
+ }
3888
+ const changeDir = changeDirectory(repoRoot, changeId);
3889
+ return { repoRoot, changeId, changeDir, resuming: existsSync7(changeDir) };
3890
+ }
3891
+ function mountWorkbenchHost(app, options) {
3892
+ const { target } = options;
3893
+ const reader = createFoldedSpecReader(target.repoRoot);
3894
+ app.get("/api/workbench/context", (c) => {
3895
+ let document = null;
3896
+ let deltaCapabilities = [];
3897
+ const capabilities = [];
3898
+ if (target.resuming) {
3899
+ try {
3900
+ const emitted = readEmittedChange(target.changeDir, target.changeId);
3901
+ document = parseChangeDocument(emitted);
3902
+ deltaCapabilities = emitted.files.map((f) => /^specs\/([^/]+)\/spec\.md$/.exec(f.path)?.[1]).filter((n) => n !== void 0);
3903
+ for (const spec of document.deltaSpecs) {
3904
+ const folded = reader.readCapability(spec.capability);
3905
+ const rebuilt = reconstructDraft(folded ?? "", spec);
3906
+ capabilities.push({
3907
+ capability: spec.capability,
3908
+ folded,
3909
+ draft: rebuilt.draft,
3910
+ reconstructed: rebuilt.outcome === "reconstructed",
3911
+ ...rebuilt.outcome === "unreconstructable" ? { reason: rebuilt.reason } : {}
3912
+ });
3913
+ }
3914
+ } catch {
3915
+ document = null;
3916
+ }
3917
+ }
3918
+ return c.json({
3919
+ repoRoot: target.repoRoot,
3920
+ changeId: target.changeId,
3921
+ changeDir: target.changeDir,
3922
+ resuming: target.resuming,
3923
+ fixture: options.fixture === true,
3924
+ specsDir: reader.specsDir,
3925
+ document,
3926
+ deltaCapabilities,
3927
+ capabilities,
3928
+ // True when every delta this change carries was rebuilt. A capability that
3929
+ // failed is listed with its reason rather than appearing to carry no edits.
3930
+ draftsRestorable: capabilities.every((c2) => c2.reconstructed)
3931
+ });
3932
+ });
3933
+ app.get("/api/workbench/capabilities", (c) => {
3934
+ const open = c.req.queries("open") ?? [];
3935
+ return c.json({ capabilities: reader.listCapabilities(open) });
3936
+ });
3937
+ app.get("/api/workbench/capabilities/:name", (c) => {
3938
+ const name = c.req.param("name");
3939
+ const open = c.req.queries("open") ?? [];
3940
+ try {
3941
+ return c.json({ result: reader.openCapability(name, open) });
3942
+ } catch (err) {
3943
+ if (err instanceof RangeError) {
3944
+ return c.json({ error: "invalid-capability-name", message: err.message }, 400);
3945
+ }
3946
+ throw err;
3947
+ }
3948
+ });
3949
+ app.post("/api/workbench/emit", async (c) => {
3950
+ const body = await c.req.json();
3951
+ if (!body.change) return c.json({ error: "missing-change" }, 400);
3952
+ const result = writeChange(
3953
+ target.repoRoot,
3954
+ { ...body.change, id: target.changeId },
3955
+ options.provenance ?? NO_ISSUE,
3956
+ body.overwrite === true ? { overwrite: true } : {}
3957
+ );
3958
+ return c.json({ result }, result.outcome === "written" ? 200 : 409);
3959
+ });
3960
+ app.post("/api/workbench/archive", async (c) => {
3961
+ const body = await c.req.json();
3962
+ if (!body.change) return c.json({ error: "missing-change" }, 400);
3963
+ const result = approveChange(
3964
+ target.repoRoot,
3965
+ { ...body.change, id: target.changeId },
3966
+ options.provenance ?? NO_ISSUE
3967
+ );
3968
+ return c.json({ result }, result.outcome === "approved" ? 200 : 409);
3969
+ });
3970
+ app.post("/api/workbench/agent/turn", async (c) => {
3971
+ const body = await c.req.json();
3972
+ const prompt = (body.prompt ?? "").trim();
3973
+ if (prompt === "") return c.json({ error: "missing-prompt" }, 400);
3974
+ const token = readSubscriptionToken(options.home ?? homedir3());
3975
+ if (!token && !process.env["ANTHROPIC_API_KEY"]) {
3976
+ return c.json({ outcome: { kind: "no-credential" } }, 200);
3977
+ }
3978
+ const shape = {
3979
+ openCapabilities: body.openCapabilities ?? [],
3980
+ ...body.change ? { citable: citableRequirements(body.change.capabilities) } : {}
3981
+ };
3982
+ const { read, changeText } = changeContextFor(target, body);
3983
+ const proposals = [];
3984
+ const outcome = await runTurn(
3985
+ {
3986
+ systemPrompt: [
3987
+ "You are helping author an OpenSpec change in the SpecSketch workbench.",
3988
+ `The change is \`${target.changeId}\`, in the repository at ${target.repoRoot}.`,
3989
+ shape.openCapabilities.length > 0 ? `Capabilities open in this change: ${shape.openCapabilities.join(", ")}.` : "No capability is open in this change yet.",
3990
+ body.scope ? `The author is looking at ${body.scope}.` : "",
3991
+ "",
3992
+ "Use the tools to propose concrete items. Everything you propose is reviewed and",
3993
+ "applied by the author \u2014 you never write to the change yourself, so propose freely",
3994
+ "and say briefly what you proposed and why.",
3995
+ "",
3996
+ "Ground every proposal in the change below. Use its own vocabulary: if it says",
3997
+ '"changes", do not write "plans". Do not invent states, sections or files it does',
3998
+ "not define. Where it is silent on something you need, say so rather than",
3999
+ "inventing it.",
4000
+ changeText === "" ? "(This change has nothing written yet.)" : changeText
4001
+ ].filter((line) => line !== "").join("\n"),
4002
+ prompt,
4003
+ tools: AGENT_TOOLS,
4004
+ execute: (name, args) => {
4005
+ const proposal = proposalFromToolCall(name, args, shape);
4006
+ proposals.push(proposal);
4007
+ return toolResultFor(proposal);
4008
+ }
4009
+ },
4010
+ { oauthToken: token ?? void 0 }
4011
+ );
4012
+ return c.json({ outcome, proposals, read });
4013
+ });
4014
+ }
2341
4015
 
2342
4016
  // server/cli.ts
2343
- var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
4017
+ var CLI_VERSION = true ? "2026.08.17.1" : "dev";
4018
+ var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
2344
4019
  specsketch login [--profile <name>] [--web-url <url>]
4020
+ specsketch --version
4021
+
4022
+ specsketch add-user-search
2345
4023
 
2346
- specsketch ./openspec/changes/new-feature-change
4024
+ The workbench authors exactly ONE change per run, named by <change-id>. The id
4025
+ is resolved under openspec/changes/<change-id> in the repository you run it in.
4026
+ An existing change is resumed; an unknown id starts a new draft \u2014 authoring a
4027
+ change that does not exist yet is the ordinary case.
2347
4028
 
2348
- The directory is created if it doesn't exist. Diagrams autosave to
2349
- diagram.excalidraw inside it; Generate Spec writes proposal.md, design.md,
2350
- tasks.md and specs/<capability>/spec.md alongside.
4029
+ The id is required and never inferred. Guessing it (newest directory, only open
4030
+ change, last used) is right until two changes are in flight, which is exactly
4031
+ when being wrong costs the most.
2351
4032
 
2352
4033
  --port sets the preferred port (default 8787); if it's already in use the
2353
4034
  server moves to the next available port and prints the actual URL.
2354
4035
 
2355
- --share hosts the canvas as a live multiplayer session on your CoopCLI
2356
- account and prints a link coworkers can open in a browser \u2014 no account
2357
- needed on their side. Requires \`specsketch login\` first. The local server
2358
- still binds to 127.0.0.1 only; sharing runs over an outbound connection.
2359
-
2360
- login authenticates this machine against your CoopCLI account (opens a
2361
- browser, saves credentials to ~/.coopcli/config.json).`;
4036
+ --fixture opens the demo change instead of the repository, for trying the
4037
+ surface out. The workbench says on screen when it is showing fixture data.
4038
+ `;
2362
4039
  var UsageError = class extends Error {
2363
4040
  };
2364
4041
  function parseCliArgs(argv) {
@@ -2366,19 +4043,24 @@ function parseCliArgs(argv) {
2366
4043
  try {
2367
4044
  parsed = parseArgs2({
2368
4045
  args: argv,
2369
- options: { port: { type: "string" }, share: { type: "boolean" } },
4046
+ options: { port: { type: "string" }, fixture: { type: "boolean" } },
2370
4047
  allowPositionals: true
2371
4048
  });
2372
4049
  } catch (err) {
2373
4050
  throw new UsageError(err instanceof Error ? err.message : String(err));
2374
4051
  }
2375
- const [dir, ...rest] = parsed.positionals;
2376
- if (!dir || rest.length > 0) throw new UsageError("expected exactly one change directory");
4052
+ const [changeId, ...rest] = parsed.positionals;
4053
+ if (!changeId) throw new UsageError("a change id is required");
4054
+ if (rest.length > 0) {
4055
+ throw new UsageError(
4056
+ "expected exactly one change id \u2014 the workbench authors one change per run"
4057
+ );
4058
+ }
2377
4059
  const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
2378
4060
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
2379
4061
  throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
2380
4062
  }
2381
- return { dir, port, share: parsed.values.share === true };
4063
+ return { changeId, port, fixture: parsed.values.fixture === true };
2382
4064
  }
2383
4065
  var PORT_SCAN_LIMIT = 100;
2384
4066
  async function bindAvailablePort(start, tryBind) {
@@ -2405,25 +4087,25 @@ var MIME = {
2405
4087
  ".woff": "font/woff"
2406
4088
  };
2407
4089
  function resolveClientDir(moduleUrl) {
2408
- const here = dirname3(fileURLToPath(moduleUrl));
4090
+ const here = dirname6(fileURLToPath(moduleUrl));
2409
4091
  const candidates = [
2410
- join5(here, "..", "client"),
4092
+ join11(here, "..", "client"),
2411
4093
  // packed: dist/cli -> dist/client
2412
- join5(here, "..", "dist", "client")
4094
+ join11(here, "..", "dist", "client")
2413
4095
  // repo: server/ -> dist/client
2414
4096
  ];
2415
4097
  for (const dir of candidates) {
2416
- if (existsSync3(join5(dir, "index.html"))) return dir;
4098
+ if (existsSync8(join11(dir, "index.html"))) return dir;
2417
4099
  }
2418
4100
  return null;
2419
4101
  }
2420
4102
  function staticResponse(clientDir, pathname) {
2421
4103
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
2422
- const target = resolve3(clientDir, rel === "" ? "index.html" : rel);
2423
- 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), {
4104
+ const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
4105
+ if (!target.startsWith(resolve4(clientDir))) return null;
4106
+ const file = existsSync8(target) && extname(target) ? target : join11(clientDir, "index.html");
4107
+ if (!existsSync8(file)) return null;
4108
+ return new Response(readFileSync9(file), {
2427
4109
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
2428
4110
  });
2429
4111
  }
@@ -2435,12 +4117,12 @@ but "Generate Spec" needs one of:
2435
4117
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
2436
4118
  brew install anthropics/tap/ant # macOS
2437
4119
  ant auth login`;
2438
- function detectCredentialSource(env = process.env, home = homedir3()) {
4120
+ function detectCredentialSource(env = process.env, home = homedir4()) {
2439
4121
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
2440
4122
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
2441
4123
  try {
2442
- const dir = join5(home, ".config", "anthropic", "credentials");
2443
- if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
4124
+ const dir = join11(home, ".config", "anthropic", "credentials");
4125
+ if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2444
4126
  } catch {
2445
4127
  }
2446
4128
  return null;
@@ -2454,6 +4136,10 @@ function preflight(log = console.log) {
2454
4136
  }
2455
4137
  }
2456
4138
  async function main(argv) {
4139
+ if (argv[0] === "--version") {
4140
+ console.log(CLI_VERSION);
4141
+ return;
4142
+ }
2457
4143
  preflight();
2458
4144
  if (argv[0] === "login") {
2459
4145
  let loginArgs;
@@ -2489,26 +4175,25 @@ ${USAGE}` : err);
2489
4175
  console.error("specsketch: built UI assets not found (dist/client missing).");
2490
4176
  process.exit(1);
2491
4177
  }
2492
- if (args.share) {
2493
- try {
2494
- await readCoopAuth();
2495
- } catch (err) {
2496
- console.error(`specsketch: ${err instanceof CoopAuthError ? err.message : String(err)}`);
2497
- process.exit(1);
2498
- }
4178
+ let target;
4179
+ try {
4180
+ target = resolveTarget(process.cwd(), args.changeId);
4181
+ } catch (err) {
4182
+ console.error(
4183
+ err instanceof WorkbenchHostError ? `specsketch: ${err.message}
4184
+
4185
+ ${USAGE}` : `specsketch: ${err instanceof Error ? err.message : String(err)}`
4186
+ );
4187
+ process.exit(1);
2499
4188
  }
2500
- const workspace = new ChangeWorkspace(args.dir);
2501
- let remote = null;
4189
+ const workspace = new ChangeWorkspace(target.changeDir, { create: false });
2502
4190
  const app = createApp(
2503
4191
  new Anthropic(),
2504
4192
  workspace,
2505
- () => remote ? {
2506
- active: true,
2507
- shareUrl: remote.shareUrl,
2508
- status: remote.status,
2509
- participants: remote.participants.length
2510
- } : { active: false }
4193
+ () => ({ active: false }),
4194
+ { refRoot: target.repoRoot, harness: createClaudeHarness() }
2511
4195
  );
4196
+ mountWorkbenchHost(app, { target, fixture: args.fixture });
2512
4197
  app.get("*", (c) => {
2513
4198
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);
2514
4199
  return res ?? c.notFound();
@@ -2535,17 +4220,11 @@ ${USAGE}` : err);
2535
4220
  console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
2536
4221
  process.exit(1);
2537
4222
  }
2538
- if (args.share) {
2539
- try {
2540
- remote = await startShare(workspace, httpServer);
2541
- } catch (err) {
2542
- console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
2543
- process.exit(1);
2544
- }
2545
- }
2546
4223
  const source = detectCredentialSource();
2547
4224
  console.log(`coopcli specsketch`);
2548
- console.log(` workspace: ${workspace.dir}`);
4225
+ console.log(` repo: ${target.repoRoot}`);
4226
+ console.log(` change: ${target.changeId} (${target.resuming ? "resuming" : "new draft"})`);
4227
+ if (args.fixture) console.log(` data: demo fixture \u2014 NOT this repository`);
2549
4228
  console.log(` open: http://127.0.0.1:${port}`);
2550
4229
  if (moved) {
2551
4230
  console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
@@ -2557,56 +4236,6 @@ ${USAGE}` : err);
2557
4236
  \u26A0 ${CREDENTIALS_HINT}
2558
4237
  `);
2559
4238
  }
2560
- if (remote) {
2561
- console.log(`
2562
- \u2500\u2500 shared session \u2500\u2500`);
2563
- console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
2564
- console.log(`
2565
- ${remote.shareUrl}
2566
- `);
2567
- console.log(` Ctrl-C ends the session and invalidates the link.`);
2568
- }
2569
- }
2570
- async function startShare(workspace, server) {
2571
- const auth = await readCoopAuth();
2572
- const localClients = /* @__PURE__ */ new Set();
2573
- const remote = await RemoteSession.start({
2574
- ...auth,
2575
- workspace,
2576
- title: workspace.changeName,
2577
- hostName: auth.displayName,
2578
- log: (msg) => console.log(` ${msg}`),
2579
- onLocalBroadcast: (frame) => {
2580
- for (const client of localClients) client.send(frame);
2581
- }
2582
- });
2583
- const wss = new WebSocketServer({ noServer: true });
2584
- server.on("upgrade", (req, socket, head) => {
2585
- const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
2586
- if (pathname !== "/ws/local") {
2587
- socket.destroy();
2588
- return;
2589
- }
2590
- wss.handleUpgrade(req, socket, head, (ws) => {
2591
- localClients.add(ws);
2592
- ws.on("close", () => localClients.delete(ws));
2593
- ws.on(
2594
- "message",
2595
- (data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
2596
- );
2597
- });
2598
- });
2599
- let ending = false;
2600
- const endAndExit = async () => {
2601
- if (ending) return;
2602
- ending = true;
2603
- console.log("\nspecsketch: ending shared session\u2026");
2604
- await remote.end();
2605
- process.exit(0);
2606
- };
2607
- process.on("SIGINT", () => void endAndExit());
2608
- process.on("SIGTERM", () => void endAndExit());
2609
- return remote;
2610
4239
  }
2611
4240
 
2612
4241
  // server/cli-entry.ts