@coopcli/specsketch 5.5.7 → 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 +2007 -989
  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 +13 -10
  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,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync6, readFileSync as readFileSync4, readdirSync as readdirSync5 } from "node:fs";
4
+ import { existsSync as existsSync8, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
5
5
  import { homedir as homedir4 } from "node:os";
6
- import { dirname as dirname4, extname, join as join7, normalize as normalize2, resolve as resolve3 } from "node:path";
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";
@@ -121,7 +121,11 @@ function* mapMessage(message, expectStructuredOutput) {
121
121
  switch (message.type) {
122
122
  case "system": {
123
123
  if (message.subtype === "init") {
124
- yield { type: "start", sessionId: message.session_id };
124
+ yield {
125
+ type: "start",
126
+ sessionId: message.session_id,
127
+ ...message.apiKeySource !== void 0 ? { apiKeySource: message.apiKeySource } : {}
128
+ };
125
129
  }
126
130
  return;
127
131
  }
@@ -332,7 +336,7 @@ function lifeOSProfile(identity, mcpServers) {
332
336
  }
333
337
 
334
338
  // server/cli.ts
335
- import { WebSocketServer } from "ws";
339
+ import "ws";
336
340
 
337
341
  // server/app.ts
338
342
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
@@ -1092,14 +1096,9 @@ ${refContext}` : "");
1092
1096
  return app;
1093
1097
  }
1094
1098
 
1095
- // server/coop-auth.ts
1096
- import { homedir as homedir3 } from "node:os";
1097
- import { join as join4 } from "node:path";
1098
-
1099
1099
  // ../shared/src/cli-auth.ts
1100
1100
  import { exec } from "node:child_process";
1101
1101
  import { randomBytes } from "node:crypto";
1102
- import { existsSync as existsSync2 } from "node:fs";
1103
1102
  import { createServer } from "node:http";
1104
1103
  import { hostname as hostname2 } from "node:os";
1105
1104
  import { URL as URL2 } from "node:url";
@@ -1639,8 +1638,8 @@ function resolveDisplayName(name, email, userId) {
1639
1638
  async function openBrowser(url) {
1640
1639
  const escaped = url.replace(/"/g, '\\"');
1641
1640
  const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
1642
- return new Promise((resolve4) => {
1643
- exec(cmd, () => resolve4());
1641
+ return new Promise((resolve5) => {
1642
+ exec(cmd, () => resolve5());
1644
1643
  });
1645
1644
  }
1646
1645
  var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
@@ -1725,14 +1724,15 @@ function deriveApiUrl(webUrl) {
1725
1724
  async function login(input, deps) {
1726
1725
  const state = randomBytes(16).toString("hex");
1727
1726
  const machineId = input.machine ?? hostname2();
1727
+ const log = deps.log ?? ((message) => void process.stderr.write(message));
1728
1728
  const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
1729
1729
  try {
1730
1730
  const webUrl = input.webUrl ?? "https://coopcli.com";
1731
1731
  const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
1732
- process.stderr.write(`Opening browser for authentication...
1732
+ log(`Opening browser for authentication...
1733
1733
  `);
1734
1734
  await deps.openBrowser(authUrl);
1735
- process.stderr.write(`Waiting for callback on port ${server.port}...
1735
+ log(`Waiting for callback on port ${server.port}...
1736
1736
  `);
1737
1737
  const params = await server.waitForCallback();
1738
1738
  const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
@@ -1749,7 +1749,7 @@ async function login(input, deps) {
1749
1749
  });
1750
1750
  const config = await deps.config.load();
1751
1751
  if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
1752
- process.stderr.write(
1752
+ log(
1753
1753
  `
1754
1754
  Backend mismatch: you authenticated against ${params.apiUrl}
1755
1755
  but your config points to ${config.apiUrl}
@@ -1773,73 +1773,6 @@ Backend mismatch: you authenticated against ${params.apiUrl}
1773
1773
  server.close();
1774
1774
  }
1775
1775
  }
1776
- var CliAuthError = class extends Error {
1777
- constructor(message, kind) {
1778
- super(message);
1779
- this.kind = kind;
1780
- this.name = "CliAuthError";
1781
- }
1782
- kind;
1783
- };
1784
- async function resolveCoopCredentials(opts) {
1785
- const env = opts.env ?? process.env;
1786
- const { configPath, loginHint } = opts;
1787
- if (!existsSync2(configPath)) {
1788
- throw new CliAuthError(`No CoopCLI config found at ${configPath}.
1789
- ${loginHint}`, "missing-config");
1790
- }
1791
- const store = new ConfigStore(configPath);
1792
- let raw;
1793
- try {
1794
- raw = await store.loadRaw();
1795
- } catch {
1796
- throw new CliAuthError(
1797
- `Could not read CoopCLI credentials from ${configPath}.
1798
- ${loginHint}`,
1799
- "unreadable-config"
1800
- );
1801
- }
1802
- const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
1803
- const profile = raw.profiles[profileName];
1804
- if (!profile) {
1805
- const available = Object.keys(raw.profiles).join(", ");
1806
- throw new CliAuthError(
1807
- `CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
1808
- ${loginHint}`,
1809
- "unknown-profile"
1810
- );
1811
- }
1812
- if (!profile.auth) {
1813
- throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
1814
- ${loginHint}`, "no-login");
1815
- }
1816
- const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
1817
- return {
1818
- apiUrl,
1819
- key: profile.auth.key,
1820
- userId: profile.auth.userId,
1821
- displayName: profile.auth.displayName ?? null
1822
- };
1823
- }
1824
-
1825
- // server/coop-auth.ts
1826
- var CoopAuthError = class extends Error {
1827
- };
1828
- var LOGIN_HINT = "--share needs a CoopCLI account. Run `specsketch login` (it opens your browser and saves credentials to ~/.coopcli/config.json), then rerun with --share.";
1829
- async function readCoopAuth(configPath = join4(homedir3(), ".coopcli", "config.json"), env = process.env) {
1830
- try {
1831
- return await resolveCoopCredentials({
1832
- configPath,
1833
- env,
1834
- profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
1835
- apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
1836
- loginHint: LOGIN_HINT
1837
- });
1838
- } catch (err) {
1839
- if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
1840
- throw err;
1841
- }
1842
- }
1843
1776
 
1844
1777
  // server/login.ts
1845
1778
  import { parseArgs } from "node:util";
@@ -1880,776 +1813,34 @@ async function runLogin(args, deps = {}) {
1880
1813
  if (profile) config.selectProfile(profile);
1881
1814
  const result = await login(
1882
1815
  { webUrl: args.webUrl, loginCommand: "specsketch login" },
1883
- { 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 }
1884
1819
  );
1885
1820
  (deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
1886
1821
  }
1887
1822
 
1888
- // server/remote-session.ts
1889
- import { WebSocket } from "ws";
1890
-
1891
- // ../shared/src/errors.ts
1892
- function isApiErrorBody(value) {
1893
- if (typeof value !== "object" || value === null) return false;
1894
- const v = value;
1895
- return typeof v.category === "string" && typeof v.code === "string" && typeof v.message === "string";
1896
- }
1897
-
1898
- // ../shared/src/api-client.ts
1899
- var CoopApiError = class extends Error {
1900
- category;
1901
- code;
1902
- status;
1903
- details;
1904
- constructor(status, body) {
1905
- super(body.message);
1906
- this.name = "CoopApiError";
1907
- this.category = body.category;
1908
- this.code = body.code;
1909
- this.status = status;
1910
- this.details = body.details;
1911
- }
1912
- };
1913
- function isCoopApiError(err) {
1914
- return err instanceof CoopApiError;
1915
- }
1916
- async function streamToBuffer(stream) {
1917
- const reader = stream.getReader();
1918
- const chunks = [];
1919
- for (; ; ) {
1920
- const { done, value } = await reader.read();
1921
- if (done) break;
1922
- chunks.push(value);
1923
- }
1924
- const total = chunks.reduce((sum, c) => sum + c.length, 0);
1925
- const result = new Uint8Array(total);
1926
- let offset = 0;
1927
- for (const chunk of chunks) {
1928
- result.set(chunk, offset);
1929
- offset += chunk.length;
1930
- }
1931
- return result;
1932
- }
1933
- var verbose = false;
1934
- var ApiClient = class _ApiClient {
1935
- constructor(apiUrl, token, userId) {
1936
- this.apiUrl = apiUrl;
1937
- this.token = token;
1938
- this.userId = userId;
1939
- }
1940
- apiUrl;
1941
- token;
1942
- userId;
1943
- /** Build a user-scoped path. Only callable when userId is set. */
1944
- userPath(sub) {
1945
- return `/api/u/${this.userId}${sub}`;
1946
- }
1947
- async request(method, path, body, params) {
1948
- const url = new URL(path, this.apiUrl);
1949
- if (params) {
1950
- for (const [key, val] of Object.entries(params)) {
1951
- if (val !== void 0) {
1952
- url.searchParams.set(key, String(val));
1953
- }
1954
- }
1955
- }
1956
- if (verbose) {
1957
- process.stderr.write(`[verbose] ${method} ${url.toString()}
1958
- `);
1959
- if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
1960
- `);
1961
- }
1962
- const res = await fetch(url.toString(), {
1963
- method,
1964
- headers: {
1965
- Authorization: `Bearer ${this.token}`,
1966
- "Content-Type": "application/json"
1967
- },
1968
- body: body !== void 0 ? JSON.stringify(body) : void 0
1969
- });
1970
- if (verbose) {
1971
- process.stderr.write(`[verbose] ${res.status} ${res.statusText}
1972
- `);
1973
- }
1974
- if (!res.ok) {
1975
- let parsed = null;
1976
- try {
1977
- const json = await res.json();
1978
- if (isApiErrorBody(json)) {
1979
- parsed = json;
1980
- } else {
1981
- const legacy = json;
1982
- parsed = {
1983
- category: res.status >= 500 ? "server_error" : "user_action",
1984
- code: "UNKNOWN",
1985
- message: legacy.error ?? legacy.message ?? res.statusText
1986
- };
1987
- }
1988
- } catch {
1989
- parsed = {
1990
- category: res.status >= 500 ? "server_error" : "user_action",
1991
- code: "UNKNOWN",
1992
- message: res.statusText
1993
- };
1994
- }
1995
- if (verbose) {
1996
- process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
1997
- `);
1998
- }
1999
- throw new CoopApiError(res.status, parsed);
2000
- }
2001
- return res.json();
2002
- }
2003
- async getMe() {
2004
- const path = this.userId ? this.userPath("/me") : "/api/me";
2005
- return this.request("GET", path);
2006
- }
2007
- async createTeam(input) {
2008
- if (this.userId) {
2009
- }
2010
- return this.request("POST", "/api/teams", input);
2011
- }
2012
- async listTeams() {
2013
- if (this.userId) {
2014
- const me = await this.request("GET", this.userPath("/me"));
2015
- return (me.teams ?? []).map((m) => ({
2016
- id: m.teamId,
2017
- name: m.teamName,
2018
- type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
2019
- plan: "hobby",
2020
- createdBy: "",
2021
- createdAt: m.joinedAt
2022
- }));
2023
- }
2024
- return this.request("GET", "/api/teams");
2025
- }
2026
- async inviteToTeam(team, input) {
2027
- return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
2028
- }
2029
- async joinTeam(team, inviteCode) {
2030
- await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
2031
- }
2032
- async listTeamMembers(team) {
2033
- return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
2034
- }
2035
- async createSession(input) {
2036
- const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
2037
- return this.request("POST", path, input);
2038
- }
2039
- async listSessions() {
2040
- const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
2041
- const result = await this.request("GET", path);
2042
- return result.sessions;
2043
- }
2044
- async joinSession(name) {
2045
- await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
2046
- }
2047
- async leaveSession(name) {
2048
- await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
2049
- }
2050
- async deleteSession(name) {
2051
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
2052
- return this.request("DELETE", path);
2053
- }
2054
- async sendMessage(teamId, session, input) {
2055
- if (this.userId) {
2056
- return this.request(
2057
- "POST",
2058
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
2059
- input,
2060
- { team: teamId }
2061
- );
2062
- }
2063
- return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
2064
- }
2065
- async getMessages(teamId, session, cursor, limit) {
2066
- if (this.userId) {
2067
- return this.request(
2068
- "GET",
2069
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
2070
- void 0,
2071
- { team: teamId, cursor, limit }
2072
- );
2073
- }
2074
- return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
2075
- cursor,
2076
- limit
2077
- });
2078
- }
2079
- async getLatestMessages(teamId, session, count = 5) {
2080
- if (this.userId) {
2081
- return this.request(
2082
- "GET",
2083
- this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
2084
- void 0,
2085
- { team: teamId, latest: count }
2086
- );
2087
- }
2088
- return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
2089
- latest: count
2090
- });
2091
- }
2092
- async listSessionFiles(teamId, sessionName) {
2093
- const result = await this.request(
2094
- "GET",
2095
- this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
2096
- void 0,
2097
- { team: teamId }
2098
- );
2099
- return result.files;
2100
- }
2101
- async getNotifications(params) {
2102
- const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
2103
- return this.request("GET", path, void 0, params);
2104
- }
2105
- async markRead(ids) {
2106
- const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
2107
- await this.request("POST", path, { notificationIds: ids });
2108
- }
2109
- /** List the caller's user-scoped API keys. Requires user-level auth. */
2110
- async listUserApiKeys() {
2111
- const body = await this.request("GET", this.userPath("/keys"));
2112
- return body.keys;
2113
- }
2114
- /** Revoke a user-scoped API key by id. Requires user-level auth. */
2115
- async revokeUserApiKey(id) {
2116
- await this.request("DELETE", this.userPath(`/keys/${encodeURIComponent(id)}`));
2117
- }
2118
- /**
2119
- * Create a live canvas session (specsketch --share host). User-scoped
2120
- * route; the caller validates the response shape against
2121
- * canvasSessionCreatedSchema.
2122
- */
2123
- async createCanvasSession(input) {
2124
- return this.request("POST", this.userPath("/canvas/sessions"), input);
2125
- }
2126
- /** End a canvas session server-side (invalidates the share link). */
2127
- async endCanvasSession(sessionId) {
2128
- await this.request("DELETE", this.userPath(`/canvas/sessions/${encodeURIComponent(sessionId)}`));
2129
- }
2130
- async uploadFile(teamId, session, filename, body, size) {
2131
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/upload`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/upload`;
2132
- const url = new URL(path, this.apiUrl);
2133
- if (this.userId) url.searchParams.set("team", teamId);
2134
- const form = new FormData();
2135
- const bytes = new Uint8Array(
2136
- body instanceof Buffer || body instanceof Uint8Array ? body : await streamToBuffer(body)
2137
- );
2138
- form.append("file", new Blob([bytes]), filename);
2139
- form.append("filename", filename);
2140
- const res = await fetch(url.toString(), {
2141
- method: "POST",
2142
- headers: { Authorization: `Bearer ${this.token}` },
2143
- body: form
2144
- });
2145
- if (!res.ok) {
2146
- const text = await res.text();
2147
- let msg = res.statusText;
2148
- try {
2149
- msg = JSON.parse(text).message ?? msg;
2150
- } catch {
2151
- }
2152
- throw new CoopApiError(res.status, { category: "user_action", code: "UPLOAD_FAILED", message: msg });
2153
- }
2154
- return res.json();
2155
- }
2156
- async downloadFile(teamId, session, fileId) {
2157
- const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`;
2158
- const url = new URL(path, this.apiUrl);
2159
- if (this.userId) url.searchParams.set("team", teamId);
2160
- const res = await fetch(url.toString(), {
2161
- method: "GET",
2162
- headers: { Authorization: `Bearer ${this.token}` }
2163
- });
2164
- if (!res.ok) {
2165
- const text = await res.text();
2166
- let msg = res.statusText;
2167
- try {
2168
- msg = JSON.parse(text).message ?? msg;
2169
- } catch {
2170
- }
2171
- throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
2172
- }
2173
- const disposition = res.headers.get("Content-Disposition") ?? "";
2174
- const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
2175
- const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
2176
- const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
2177
- return { filename, body: res.body, size };
2178
- }
2179
- // ── Agent pool (specsesh-agent-pool) — user-scoped routes ──────────────
2180
- /** Enroll this machine in its org's execute pool. Identity comes from the key. */
2181
- async poolJoin() {
2182
- return this.request("POST", this.userPath("/pool/join"), {});
2183
- }
2184
- /** Check in; may return one assigned work item in the same round-trip (FIFO). */
2185
- async poolCheckIn() {
2186
- return this.request("POST", this.userPath("/pool/check-in"), {});
2187
- }
2188
- /**
2189
- * The org pool's roster. `specsesh dispatch` reads it to surface a live-agent
2190
- * count so a dispatch into a pool with zero live agents is a visible no-signal
2191
- * enqueue, not a silent success (specsesh-work-dispatch §4.4).
2192
- */
2193
- async poolRoster() {
2194
- const body = await this.request("GET", this.userPath("/pool/roster"));
2195
- return body.agents;
2196
- }
2197
- /**
2198
- * Report completion of an assigned work item. `assignmentSeq` is REQUIRED
2199
- * (specsesh-task-lifecycle §5) — echo back the epoch from the `WorkItem`
2200
- * this caller was handed at check-in, so the DO's ABA fence can reject a
2201
- * stale completion from a since-superseded assignment.
2202
- */
2203
- async poolComplete(workId, status, resultRef, assignmentSeq) {
2204
- await this.request("POST", this.userPath("/pool/complete"), { workId, status, resultRef, assignmentSeq });
2205
- }
2206
- /**
2207
- * Dispatch a task record into the org pool as one work item (idempotent on
2208
- * the qualified `dedupKey`). CLI producer of specsesh-work-dispatch; returns
2209
- * the (possibly already-existing) work id.
2210
- */
2211
- async poolEnqueue(kind, source, dedupKey) {
2212
- return this.request("POST", this.userPath("/pool/enqueue"), { kind, source, dedupKey });
2213
- }
2214
- /**
2215
- * Org-scoped cancel (specsesh-task-lifecycle §2) — any authenticated member
2216
- * of the org may cancel any work item in its pool, regardless of which
2217
- * agent (if any) holds it. `queued` cancels directly; `assigned`/`running`
2218
- * cancels and frees the holding agent (no redelivery); an already-terminal
2219
- * item rejects.
2220
- */
2221
- async poolCancel(workId) {
2222
- await this.request("POST", this.userPath("/pool/cancel"), { workId });
2223
- }
2224
- /**
2225
- * Query a single work item's current status (specsesh-task-lifecycle §4) —
2226
- * tenant-scoped, independent of whether the caller is the agent (if any)
2227
- * holding it. Throws `CoopApiError` (`POOL_WORK_NOT_FOUND`, 404) if the
2228
- * item does not exist in this organization's pool.
2229
- */
2230
- async poolWork(workId) {
2231
- const body = await this.request(
2232
- "GET",
2233
- this.userPath(`/pool/work/${encodeURIComponent(workId)}`)
2234
- );
2235
- return body.work;
2236
- }
2237
- /**
2238
- * List every work item currently in the organization's pool, regardless of
2239
- * status (specsesh-task-lifecycle §4).
2240
- */
2241
- async poolWorkList() {
2242
- const body = await this.request("GET", this.userPath("/pool/work"));
2243
- return body.items;
2244
- }
2245
- getWebSocketUrl(session) {
2246
- const wsBase = this.apiUrl.replace(
2247
- /^https?:\/\//,
2248
- (match) => match === "https://" ? "wss://" : "ws://"
2249
- );
2250
- return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
2251
- }
2252
- withToken(token) {
2253
- return new _ApiClient(this.apiUrl, token, this.userId);
2254
- }
2255
- };
2256
-
2257
- // ../shared/src/canvas-protocol.ts
2258
- import { z as z4 } from "zod";
2259
- var CANVAS_PROTOCOL_VERSION = 1;
2260
- var canvasElementSchema = z4.object({
2261
- id: z4.string().min(1),
2262
- version: z4.number().int().nonnegative(),
2263
- versionNonce: z4.number().int(),
2264
- isDeleted: z4.boolean().optional()
2265
- }).passthrough();
2266
- var canvasFilesSchema = z4.record(z4.string(), z4.unknown());
2267
- var canvasSceneSchema = z4.object({
2268
- elements: z4.array(canvasElementSchema),
2269
- files: canvasFilesSchema.optional()
2270
- });
2271
- var canvasParticipantSchema = z4.object({
2272
- id: z4.string().min(1),
2273
- role: z4.enum(["host", "guest"]),
2274
- name: z4.string().optional(),
2275
- color: z4.string().optional()
2276
- });
2277
- var canvasHelloSchema = z4.object({
2278
- type: z4.literal("hello"),
2279
- protocolVersion: z4.number().int().positive(),
2280
- role: z4.enum(["host", "guest"]),
2281
- /** Stable id across reconnects, if the client has one. */
2282
- participantId: z4.string().min(1).max(64).optional(),
2283
- name: z4.string().max(120).optional()
2284
- });
2285
- var canvasClientOpSchema = z4.object({
2286
- type: z4.literal("op"),
2287
- /** Changed or soft-deleted elements only — never the full scene. */
2288
- elements: z4.array(canvasElementSchema).min(1),
2289
- /** New binary files referenced by the changed elements, if any. */
2290
- files: canvasFilesSchema.optional(),
2291
- /** Client-local monotonic sequence, used by the sender for echo dedupe. */
2292
- clientSeq: z4.number().int().nonnegative()
2293
- });
2294
- var canvasClientMessageSchema = z4.discriminatedUnion("type", [
2295
- canvasHelloSchema,
2296
- canvasClientOpSchema
2297
- ]);
2298
- var canvasSnapshotSchema = z4.object({
2299
- type: z4.literal("snapshot"),
2300
- scene: canvasSceneSchema,
2301
- revision: z4.number().int().nonnegative(),
2302
- participants: z4.array(canvasParticipantSchema),
2303
- /** Echoes the server's protocol version so clients can detect skew. */
2304
- protocolVersion: z4.number().int().positive(),
2305
- /** The participant id the server assigned to (or kept for) this client. */
2306
- participantId: z4.string()
2307
- });
2308
- var canvasServerOpSchema = z4.object({
2309
- type: z4.literal("op"),
2310
- /** The elements that won reconciliation (never echoed to the sender). */
2311
- elements: z4.array(canvasElementSchema),
2312
- files: canvasFilesSchema.optional(),
2313
- revision: z4.number().int().positive(),
2314
- /** Participant id of the sender. */
2315
- from: z4.string()
2316
- });
2317
- var canvasPresenceSchema = z4.object({
2318
- type: z4.literal("presence"),
2319
- participants: z4.array(canvasParticipantSchema)
2320
- });
2321
- var canvasEndSchema = z4.object({
2322
- type: z4.literal("end"),
2323
- reason: z4.string()
2324
- });
2325
- var canvasErrorSchema = z4.object({
2326
- type: z4.literal("error"),
2327
- code: z4.string(),
2328
- message: z4.string()
2329
- });
2330
- var canvasServerMessageSchema = z4.discriminatedUnion("type", [
2331
- canvasSnapshotSchema,
2332
- canvasServerOpSchema,
2333
- canvasPresenceSchema,
2334
- canvasEndSchema,
2335
- canvasErrorSchema
2336
- ]);
2337
- var canvasSessionCreatedSchema = z4.object({
2338
- sessionId: z4.string().min(1),
2339
- /** Capability token — shown once; the server stores only its hash. */
2340
- token: z4.string().min(1),
2341
- shareUrl: z4.string().min(1),
2342
- wsUrl: z4.string().min(1)
2343
- });
2344
- var canvasSessionSummarySchema = z4.object({
2345
- sessionId: z4.string(),
2346
- title: z4.string().nullable(),
2347
- status: z4.enum(["active", "ended"]),
2348
- createdAt: z4.number(),
2349
- endedAt: z4.number().nullable()
2350
- });
2351
- function elementWins(local, incoming) {
2352
- if (incoming.version !== local.version) return incoming.version > local.version;
2353
- return incoming.versionNonce < local.versionNonce;
2354
- }
2355
- function reconcileCanvasElements(local, incoming) {
2356
- const byId = new Map(local.map((el) => [el.id, el]));
2357
- const accepted = [];
2358
- for (const el of incoming) {
2359
- const existing = byId.get(el.id);
2360
- if (!existing || elementWins(existing, el)) {
2361
- byId.set(el.id, el);
2362
- accepted.push(el);
2363
- }
2364
- }
2365
- return { elements: [...byId.values()], accepted };
2366
- }
2367
-
2368
- // server/remote-session.ts
2369
- var HEARTBEAT_INTERVAL_MS = 3e4;
2370
- var HEARTBEAT_TIMEOUT_MS = 75e3;
2371
- var BACKOFF_MIN_MS = 1e3;
2372
- var BACKOFF_MAX_MS = 3e4;
2373
- var PENDING_OP_LIMIT = 200;
2374
- function asCanvasElements(elements) {
2375
- if (!elements) return [];
2376
- return elements.filter(
2377
- (el) => typeof el === "object" && el !== null && typeof el.id === "string" && typeof el.version === "number" && typeof el.versionNonce === "number"
2378
- );
2379
- }
2380
- var RemoteSession = class _RemoteSession {
2381
- sessionId;
2382
- shareUrl;
2383
- token;
2384
- wsUrl;
2385
- opts;
2386
- status = "connecting";
2387
- revision = 0;
2388
- participants = [];
2389
- ws = null;
2390
- closed = false;
2391
- clientSeq = 0;
2392
- backoffMs;
2393
- lastTrafficAt = 0;
2394
- heartbeatTimer = null;
2395
- reconnectTimer = null;
2396
- pendingOps = [];
2397
- api;
2398
- constructor(opts, api, created) {
2399
- this.opts = opts;
2400
- this.api = api;
2401
- this.sessionId = created.sessionId;
2402
- this.token = created.token;
2403
- this.shareUrl = created.shareUrl;
2404
- this.wsUrl = created.wsUrl;
2405
- this.backoffMs = opts.backoffMinMs ?? BACKOFF_MIN_MS;
2406
- }
2407
- /** Create the session server-side and open the host connection. */
2408
- static async start(opts) {
2409
- const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
2410
- let response;
2411
- try {
2412
- response = await api.createCanvasSession({ title: opts.title });
2413
- } catch (err) {
2414
- if (isCoopApiError(err)) {
2415
- if (err.status === 401 || err.status === 403) {
2416
- throw new Error(
2417
- "CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
2418
- );
2419
- }
2420
- throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
2421
- }
2422
- throw err;
2423
- }
2424
- const created = canvasSessionCreatedSchema.parse(response);
2425
- const session = new _RemoteSession(opts, api, created);
2426
- session.connect();
2427
- return session;
2428
- }
2429
- // ── Upstream connection ──
2430
- connect() {
2431
- if (this.closed) return;
2432
- const factory = this.opts.wsFactory ?? ((url, headers) => new WebSocket(url, { headers }));
2433
- const ws = factory(this.wsUrl, { Authorization: `Bearer ${this.opts.key}` });
2434
- this.ws = ws;
2435
- this.lastTrafficAt = Date.now();
2436
- ws.on("open", () => {
2437
- this.backoffMs = this.opts.backoffMinMs ?? BACKOFF_MIN_MS;
2438
- this.status = "connected";
2439
- ws.send(
2440
- JSON.stringify({
2441
- type: "hello",
2442
- protocolVersion: CANVAS_PROTOCOL_VERSION,
2443
- role: "host",
2444
- name: this.opts.hostName ?? void 0
2445
- })
2446
- );
2447
- for (const frame of this.pendingOps.splice(0)) ws.send(frame);
2448
- });
2449
- ws.on("message", (data) => this.handleUpstreamFrame(String(data)));
2450
- ws.on("close", (code) => {
2451
- if (ws !== this.ws) return;
2452
- this.stopHeartbeat();
2453
- if (this.closed) return;
2454
- this.status = "reconnecting";
2455
- this.opts.log?.(`share: connection lost (code ${code}) \u2014 reconnecting`);
2456
- this.scheduleReconnect();
2457
- });
2458
- ws.on("error", () => {
2459
- });
2460
- this.startHeartbeat();
2461
- }
2462
- scheduleReconnect() {
2463
- if (this.closed || this.reconnectTimer) return;
2464
- const jitter = Math.random() * 0.3 + 0.85;
2465
- const delay = Math.round(this.backoffMs * jitter);
2466
- this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
2467
- this.reconnectTimer = setTimeout(() => {
2468
- this.reconnectTimer = null;
2469
- this.connect();
2470
- }, delay);
2471
- }
2472
- startHeartbeat() {
2473
- this.stopHeartbeat();
2474
- this.heartbeatTimer = setInterval(() => {
2475
- const ws = this.ws;
2476
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2477
- if (Date.now() - this.lastTrafficAt > HEARTBEAT_TIMEOUT_MS) {
2478
- ws.terminate();
2479
- return;
2480
- }
2481
- ws.send("ping");
2482
- }, HEARTBEAT_INTERVAL_MS);
2483
- this.heartbeatTimer.unref?.();
2484
- }
2485
- stopHeartbeat() {
2486
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2487
- this.heartbeatTimer = null;
2488
- }
2489
- // ── Frames from the DO ──
2490
- handleUpstreamFrame(text) {
2491
- this.lastTrafficAt = Date.now();
2492
- if (text === "pong") return;
2493
- const parsed = canvasServerMessageSchema.safeParse(
2494
- (() => {
2495
- try {
2496
- return JSON.parse(text);
2497
- } catch {
2498
- return null;
2499
- }
2500
- })()
2501
- );
2502
- if (!parsed.success) return;
2503
- const frame = parsed.data;
2504
- switch (frame.type) {
2505
- case "snapshot": {
2506
- const local = this.opts.workspace.loadScene();
2507
- const { elements, accepted } = reconcileCanvasElements(
2508
- asCanvasElements(frame.scene.elements),
2509
- asCanvasElements(local?.elements)
2510
- );
2511
- this.revision = frame.revision;
2512
- this.participants = frame.participants;
2513
- this.opts.workspace.saveScene({
2514
- elements,
2515
- appState: local?.appState,
2516
- files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
2517
- });
2518
- if (accepted.length > 0) {
2519
- this.sendUpstream(
2520
- JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
2521
- );
2522
- }
2523
- this.opts.onLocalBroadcast?.(text);
2524
- return;
2525
- }
2526
- case "op": {
2527
- if (frame.revision > this.revision + 1) {
2528
- this.ws?.terminate();
2529
- return;
2530
- }
2531
- this.revision = frame.revision;
2532
- this.applyRemoteElements(frame.elements, frame.files);
2533
- this.opts.onLocalBroadcast?.(text);
2534
- return;
2535
- }
2536
- case "presence": {
2537
- this.participants = frame.participants;
2538
- this.opts.onLocalBroadcast?.(text);
2539
- return;
2540
- }
2541
- case "end": {
2542
- this.opts.log?.(`share: session ended (${frame.reason})`);
2543
- this.opts.onLocalBroadcast?.(text);
2544
- this.closed = true;
2545
- this.status = "ended";
2546
- this.teardown();
2547
- return;
2548
- }
2549
- case "error": {
2550
- this.opts.log?.(`share: server error ${frame.code}: ${frame.message}`);
2551
- return;
2552
- }
2553
- }
2554
- }
2555
- /** Persist remote edits into the change directory — the durable copy. */
2556
- applyRemoteElements(incoming, files) {
2557
- const local = this.opts.workspace.loadScene();
2558
- const { elements } = reconcileCanvasElements(asCanvasElements(local?.elements), incoming);
2559
- this.opts.workspace.saveScene({
2560
- elements,
2561
- appState: local?.appState,
2562
- files: { ...local?.files ?? {}, ...files ?? {} }
2563
- });
2564
- }
2565
- // ── Frames from the host browser (via /ws/local) ──
2566
- /**
2567
- * Handle one message from a local browser socket. Ops are relayed upstream
2568
- * verbatim (the browser already persists via its autosave PUT). A `hello`
2569
- * is answered locally with a synthetic snapshot so the browser can seed
2570
- * its revision/participant state without a round-trip to the DO.
2571
- */
2572
- handleLocalFrame(text, reply) {
2573
- const parsed = canvasClientMessageSchema.safeParse(
2574
- (() => {
2575
- try {
2576
- return JSON.parse(text);
2577
- } catch {
2578
- return null;
2579
- }
2580
- })()
2581
- );
2582
- if (!parsed.success) return;
2583
- if (parsed.data.type === "hello") {
2584
- const scene = this.opts.workspace.loadScene();
2585
- reply(
2586
- JSON.stringify({
2587
- type: "snapshot",
2588
- scene: { elements: scene?.elements ?? [], files: scene?.files ?? void 0 },
2589
- revision: this.revision,
2590
- participants: this.participants,
2591
- protocolVersion: CANVAS_PROTOCOL_VERSION,
2592
- participantId: "host"
2593
- })
2594
- );
2595
- return;
2596
- }
2597
- this.sendUpstream(text);
2598
- }
2599
- sendUpstream(frameText) {
2600
- const ws = this.ws;
2601
- if (ws && ws.readyState === WebSocket.OPEN) {
2602
- ws.send(frameText);
2603
- return;
2604
- }
2605
- this.pendingOps.push(frameText);
2606
- if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
2607
- }
2608
- // ── Teardown ──
2609
- /** End the session server-side (invalidates the share link) and close. */
2610
- async end() {
2611
- if (this.status === "ended") return;
2612
- this.closed = true;
2613
- this.status = "ended";
2614
- this.teardown();
2615
- try {
2616
- await this.api.endCanvasSession(this.sessionId);
2617
- } catch {
2618
- }
2619
- }
2620
- teardown() {
2621
- this.stopHeartbeat();
2622
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2623
- this.reconnectTimer = null;
2624
- try {
2625
- this.ws?.close(1e3, "host shutting down");
2626
- } catch {
2627
- }
2628
- this.ws = null;
2629
- }
2630
- };
2631
-
2632
1823
  // server/workspace.ts
2633
1824
  import { createHash as createHash2 } from "node:crypto";
2634
1825
  import {
2635
- existsSync as existsSync5,
1826
+ existsSync as existsSync4,
2636
1827
  mkdirSync as mkdirSync2,
2637
1828
  readFileSync as readFileSync3,
2638
1829
  readdirSync as readdirSync4,
2639
1830
  renameSync as renameSync2,
2640
1831
  writeFileSync as writeFileSync2
2641
1832
  } from "node:fs";
2642
- import { basename as basename2, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
1833
+ import { basename as basename2, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
2643
1834
 
2644
1835
  // ../shared/src/openspec-change-meta.ts
2645
1836
  import { createHash } from "node:crypto";
2646
- import { existsSync as existsSync4, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
2647
- import { join as join5 } from "node:path";
1837
+ import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
1838
+ import { join as join4 } from "node:path";
2648
1839
  import { Document, parseDocument } from "yaml";
2649
- import { z as z5 } from "zod";
1840
+ import { z as z4 } from "zod";
2650
1841
 
2651
1842
  // ../shared/src/fs-atomic.ts
2652
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
1843
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
2653
1844
  import { dirname as dirname2 } from "node:path";
2654
1845
  function writeAtomicFile(target, content) {
2655
1846
  mkdirSync(dirname2(target), { recursive: true });
@@ -2658,7 +1849,7 @@ function writeAtomicFile(target, content) {
2658
1849
  renameSync(tmp, target);
2659
1850
  }
2660
1851
  function readIfExists(target) {
2661
- return existsSync3(target) ? readFileSync2(target, "utf8") : void 0;
1852
+ return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
2662
1853
  }
2663
1854
 
2664
1855
  // ../shared/src/openspec-change-meta.ts
@@ -2671,10 +1862,10 @@ var nodeChangeMetaFs = {
2671
1862
  writeAtomicFile(path, content);
2672
1863
  },
2673
1864
  exists(path) {
2674
- return existsSync4(path);
1865
+ return existsSync3(path);
2675
1866
  },
2676
1867
  readdir(path) {
2677
- if (!existsSync4(path)) return [];
1868
+ if (!existsSync3(path)) return [];
2678
1869
  return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
2679
1870
  name: entry.name,
2680
1871
  directory: entry.isDirectory()
@@ -2688,12 +1879,12 @@ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
2688
1879
  function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
2689
1880
  const files = [];
2690
1881
  for (const name of ["proposal.md", "design.md", "tasks.md"]) {
2691
- if (fsx.exists(join5(changeDir, name))) files.push(name);
1882
+ if (fsx.exists(join4(changeDir, name))) files.push(name);
2692
1883
  }
2693
- const specsDir = join5(changeDir, "specs");
1884
+ const specsDir = join4(changeDir, "specs");
2694
1885
  for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
2695
- const rel = join5("specs", entry.name, "spec.md");
2696
- if (entry.directory && fsx.exists(join5(changeDir, rel))) files.push(rel);
1886
+ const rel = join4("specs", entry.name, "spec.md");
1887
+ if (entry.directory && fsx.exists(join4(changeDir, rel))) files.push(rel);
2697
1888
  }
2698
1889
  return files;
2699
1890
  }
@@ -2702,24 +1893,24 @@ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
2702
1893
  if (files.length === 0) return null;
2703
1894
  const parts = files.map(
2704
1895
  (rel) => `${rel}
2705
- ${sha256(fsx.readIfExists(join5(changeDir, rel)) ?? "")}`
1896
+ ${sha256(fsx.readIfExists(join4(changeDir, rel)) ?? "")}`
2706
1897
  );
2707
1898
  return `sha256:${sha256(parts.join("\n"))}`;
2708
1899
  }
2709
1900
  var SPEC_STATUSES = ["draft", "in-progress"];
2710
- var SpecStatus = z5.enum(SPEC_STATUSES);
1901
+ var SpecStatus = z4.enum(SPEC_STATUSES);
2711
1902
  var LEGACY_STATUS_COERCION = {
2712
1903
  active: "in-progress",
2713
1904
  done: "in-progress",
2714
1905
  deferred: "draft"
2715
1906
  };
2716
- var StoredSpecStatus = z5.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
1907
+ var StoredSpecStatus = z4.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
2717
1908
  (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
2718
1909
  );
2719
- var SpecMetaSchema = z5.object({
2720
- specId: z5.string(),
2721
- userStories: z5.array(z5.string()),
2722
- dependencies: z5.array(z5.string()),
1910
+ var SpecMetaSchema = z4.object({
1911
+ specId: z4.string(),
1912
+ userStories: z4.array(z4.string()),
1913
+ dependencies: z4.array(z4.string()),
2723
1914
  /** Working state (retired values coerce on read); absent = draft. */
2724
1915
  status: StoredSpecStatus.optional(),
2725
1916
  /**
@@ -2728,18 +1919,18 @@ var SpecMetaSchema = z5.object({
2728
1919
  * into specs/ and archived (apply-first: the fold happens at approval).
2729
1920
  * Absent = not-approved.
2730
1921
  */
2731
- approved: z5.boolean().optional(),
1922
+ approved: z4.boolean().optional(),
2732
1923
  /**
2733
1924
  * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
2734
1925
  * compatibility: readers treat `applied: true` as `approved: true`; no
2735
1926
  * writer ever sets this field again.
2736
1927
  */
2737
- applied: z5.boolean().optional(),
1928
+ applied: z4.boolean().optional(),
2738
1929
  /**
2739
1930
  * Build flag — user-set, independent of the fold: true once the code has
2740
1931
  * been built to match the approved diff. Absent = not-implemented.
2741
1932
  */
2742
- implemented: z5.boolean().optional(),
1933
+ implemented: z4.boolean().optional(),
2743
1934
  /**
2744
1935
  * Minimal implementation-task record written on fold (specplan-timeline-apply
2745
1936
  * "Implementation task record on fold"): the creation time and a reference to
@@ -2748,18 +1939,18 @@ var SpecMetaSchema = z5.object({
2748
1939
  * state — task execution/dispatch is a separate, not-yet-defined concern. A
2749
1940
  * field addition to this sidecar, not a redefinition of its format.
2750
1941
  */
2751
- task: z5.object({
2752
- createdAt: z5.string(),
2753
- source: z5.string()
1942
+ task: z4.object({
1943
+ createdAt: z4.string(),
1944
+ source: z4.string()
2754
1945
  }).optional(),
2755
- generation: z5.object({
1946
+ generation: z4.object({
2756
1947
  /** sha256 of the last generated body files — detects hand edits. */
2757
- lastGeneratedBodyHash: z5.string(),
2758
- lastGeneratedAt: z5.string()
1948
+ lastGeneratedBodyHash: z4.string(),
1949
+ lastGeneratedAt: z4.string()
2759
1950
  }).optional()
2760
1951
  });
2761
1952
  function writeChangeMeta(fsx, changeDir, meta) {
2762
- const path = join5(changeDir, CHANGE_META_FILE);
1953
+ const path = join4(changeDir, CHANGE_META_FILE);
2763
1954
  const raw = fsx.readIfExists(path);
2764
1955
  const doc = raw === void 0 ? new Document({
2765
1956
  specId: meta.specId,
@@ -2787,21 +1978,30 @@ var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
2787
1978
  var ChangeWorkspace = class {
2788
1979
  dir;
2789
1980
  changeName;
2790
- constructor(dir) {
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 = {}) {
2791
1991
  this.dir = resolve2(dir);
2792
1992
  this.changeName = basename2(this.dir);
2793
- mkdirSync2(this.dir, { recursive: true });
1993
+ if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
2794
1994
  }
2795
1995
  writeAtomic(relPath, content) {
2796
- const target = join6(this.dir, relPath);
1996
+ const target = join5(this.dir, relPath);
2797
1997
  mkdirSync2(dirname3(target), { recursive: true });
2798
1998
  const tmp = `${target}.tmp`;
2799
1999
  writeFileSync2(tmp, content, "utf8");
2800
2000
  renameSync2(tmp, target);
2801
2001
  }
2802
2002
  readIfExists(relPath) {
2803
- const target = join6(this.dir, relPath);
2804
- return existsSync5(target) ? readFileSync3(target, "utf8") : void 0;
2003
+ const target = join5(this.dir, relPath);
2004
+ return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
2805
2005
  }
2806
2006
  // ── Scene ────────────────────────────────────────────────────────────────
2807
2007
  loadScene() {
@@ -2862,11 +2062,11 @@ var ChangeWorkspace = class {
2862
2062
  /** Current artifact files, however they were authored. */
2863
2063
  readArtifacts() {
2864
2064
  const specs = [];
2865
- const specsDir = join6(this.dir, "specs");
2866
- if (existsSync5(specsDir)) {
2065
+ const specsDir = join5(this.dir, "specs");
2066
+ if (existsSync4(specsDir)) {
2867
2067
  for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
2868
2068
  if (!entry.isDirectory()) continue;
2869
- const spec = this.readIfExists(join6("specs", entry.name, "spec.md"));
2069
+ const spec = this.readIfExists(join5("specs", entry.name, "spec.md"));
2870
2070
  if (spec !== void 0) specs.push({ capability: entry.name, spec });
2871
2071
  }
2872
2072
  }
@@ -2900,7 +2100,7 @@ var ChangeWorkspace = class {
2900
2100
  "design.md": bundle.design,
2901
2101
  "tasks.md": bundle.tasks
2902
2102
  };
2903
- for (const s of bundle.specs) files[join6("specs", s.capability, "spec.md")] = s.spec;
2103
+ for (const s of bundle.specs) files[join5("specs", s.capability, "spec.md")] = s.spec;
2904
2104
  const written = [];
2905
2105
  const manifest = {};
2906
2106
  for (const [relPath, content] of Object.entries(files)) {
@@ -2949,49 +2149,1921 @@ var ChangeWorkspace = class {
2949
2149
  }
2950
2150
  };
2951
2151
 
2952
- // server/cli.ts
2953
- var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
2954
- specsketch login [--profile <name>] [--web-url <url>]
2955
-
2956
- specsketch ./openspec/changes/new-feature-change
2957
-
2958
- The directory is created if it doesn't exist. Diagrams autosave to
2959
- diagram.excalidraw inside it; Generate Spec writes proposal.md, design.md,
2960
- tasks.md and specs/<capability>/spec.md alongside.
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";
2961
2156
 
2962
- --port sets the preferred port (default 8787); if it's already in use the
2963
- server moves to the next available port and prints the actual URL.
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";
2964
2160
 
2965
- --share hosts the canvas as a live multiplayer session on your CoopCLI
2966
- account and prints a link coworkers can open in a browser \u2014 no account
2967
- needed on their side. Requires \`specsketch login\` first. The local server
2968
- still binds to 127.0.0.1 only; sharing runs over an outbound connection.
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
+ }
2969
2281
 
2970
- login authenticates this machine against your CoopCLI account (opens a
2971
- browser, saves credentials to ~/.coopcli/config.json).`;
2972
- var UsageError = class extends Error {
2973
- };
2974
- function parseCliArgs(argv) {
2975
- let parsed;
2976
- try {
2977
- parsed = parseArgs2({
2978
- args: argv,
2979
- options: { port: { type: "string" }, share: { type: "boolean" } },
2980
- allowPositionals: true
2981
- });
2982
- } catch (err) {
2983
- throw new UsageError(err instanceof Error ? err.message : String(err));
2984
- }
2985
- const [dir, ...rest] = parsed.positionals;
2986
- if (!dir || rest.length > 0) throw new UsageError("expected exactly one change directory");
2987
- const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
2988
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
2989
- throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
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\``;
2990
2289
  }
2991
- return { dir, port, share: parsed.values.share === true };
2290
+ return null;
2992
2291
  }
2993
- var PORT_SCAN_LIMIT = 100;
2994
- async function bindAvailablePort(start, tryBind) {
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 }
2350
+ ]);
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"
2365
+ });
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;
2700
+ }
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;
2728
+ }
2729
+ fence.lines.push(p);
2730
+ continue;
2731
+ }
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]
2805
+ });
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;
2881
+ }
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;
2890
+ }
2891
+ const to = RENAME_TO.exec(p);
2892
+ if (to && pendingFrom !== null) {
2893
+ renamed.push({ from: pendingFrom, to: (to[1] ?? "").trim() });
2894
+ pendingFrom = null;
2895
+ }
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.`
2960
+ }
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.`
2970
+ }
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."
2983
+ }
2984
+ ];
2985
+ }
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."
3021
+ });
3022
+ }
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.`
3065
+ }
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
+ });
3126
+ }
3127
+ }
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
+ });
3170
+ }
3171
+ }
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
+ }
3231
+ }
3232
+ }
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;
3317
+ try {
3318
+ onDisk = readFileSync5(target, "utf8");
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
+ };
3549
+ }
3550
+ blocks.delete(pair.from);
3551
+ blocks.set(pair.to, existing.replace(pair.from, pair.to));
3552
+ order[order.indexOf(pair.from)] = pair.to;
3553
+ }
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);
3564
+ }
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());
3576
+ }
3577
+ for (const block of delta.added) {
3578
+ const name = headingName(block);
3579
+ if (name === null) {
3580
+ return {
3581
+ outcome: "unreconstructable",
3582
+ capability,
3583
+ reason: "the delta adds a block with no readable heading",
3584
+ draft: folded
3585
+ };
3586
+ }
3587
+ if (!blocks.has(name)) order.push(name);
3588
+ blocks.set(name, block.trimEnd());
3589
+ }
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;
3616
+ }
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;
3648
+ try {
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
+ });
3675
+ }
3676
+ }
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
+ })
3732
+ }
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
+ };
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 } };
3764
+ }
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}\`` };
3808
+ }
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.`;
3822
+ }
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)`);
3838
+ }
3839
+ return { read, changeText };
3840
+ }
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`);
3859
+ }
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;
3871
+ }
3872
+ }
3873
+ function resolveTarget(cwd, changeId) {
3874
+ if (changeId === void 0 || changeId === "") {
3875
+ throw new WorkbenchHostError("a change id is required: specsketch <change-id>");
3876
+ }
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
+ );
3881
+ }
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
+ }
4015
+
4016
+ // server/cli.ts
4017
+ var CLI_VERSION = true ? "2026.08.17.1" : "dev";
4018
+ var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
4019
+ specsketch login [--profile <name>] [--web-url <url>]
4020
+ specsketch --version
4021
+
4022
+ specsketch add-user-search
4023
+
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.
4028
+
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.
4032
+
4033
+ --port sets the preferred port (default 8787); if it's already in use the
4034
+ server moves to the next available port and prints the actual URL.
4035
+
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
+ `;
4039
+ var UsageError = class extends Error {
4040
+ };
4041
+ function parseCliArgs(argv) {
4042
+ let parsed;
4043
+ try {
4044
+ parsed = parseArgs2({
4045
+ args: argv,
4046
+ options: { port: { type: "string" }, fixture: { type: "boolean" } },
4047
+ allowPositionals: true
4048
+ });
4049
+ } catch (err) {
4050
+ throw new UsageError(err instanceof Error ? err.message : String(err));
4051
+ }
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
+ }
4059
+ const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
4060
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
4061
+ throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
4062
+ }
4063
+ return { changeId, port, fixture: parsed.values.fixture === true };
4064
+ }
4065
+ var PORT_SCAN_LIMIT = 100;
4066
+ async function bindAvailablePort(start, tryBind) {
2995
4067
  const end = Math.min(start + PORT_SCAN_LIMIT - 1, 65535);
2996
4068
  for (let port = start; port <= end; port++) {
2997
4069
  try {
@@ -3015,25 +4087,25 @@ var MIME = {
3015
4087
  ".woff": "font/woff"
3016
4088
  };
3017
4089
  function resolveClientDir(moduleUrl) {
3018
- const here = dirname4(fileURLToPath(moduleUrl));
4090
+ const here = dirname6(fileURLToPath(moduleUrl));
3019
4091
  const candidates = [
3020
- join7(here, "..", "client"),
4092
+ join11(here, "..", "client"),
3021
4093
  // packed: dist/cli -> dist/client
3022
- join7(here, "..", "dist", "client")
4094
+ join11(here, "..", "dist", "client")
3023
4095
  // repo: server/ -> dist/client
3024
4096
  ];
3025
4097
  for (const dir of candidates) {
3026
- if (existsSync6(join7(dir, "index.html"))) return dir;
4098
+ if (existsSync8(join11(dir, "index.html"))) return dir;
3027
4099
  }
3028
4100
  return null;
3029
4101
  }
3030
4102
  function staticResponse(clientDir, pathname) {
3031
4103
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
3032
- const target = resolve3(clientDir, rel === "" ? "index.html" : rel);
3033
- if (!target.startsWith(resolve3(clientDir))) return null;
3034
- const file = existsSync6(target) && extname(target) ? target : join7(clientDir, "index.html");
3035
- if (!existsSync6(file)) return null;
3036
- return new Response(readFileSync4(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), {
3037
4109
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
3038
4110
  });
3039
4111
  }
@@ -3049,8 +4121,8 @@ function detectCredentialSource(env = process.env, home = homedir4()) {
3049
4121
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
3050
4122
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
3051
4123
  try {
3052
- const dir = join7(home, ".config", "anthropic", "credentials");
3053
- if (readdirSync5(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";
3054
4126
  } catch {
3055
4127
  }
3056
4128
  return null;
@@ -3064,6 +4136,10 @@ function preflight(log = console.log) {
3064
4136
  }
3065
4137
  }
3066
4138
  async function main(argv) {
4139
+ if (argv[0] === "--version") {
4140
+ console.log(CLI_VERSION);
4141
+ return;
4142
+ }
3067
4143
  preflight();
3068
4144
  if (argv[0] === "login") {
3069
4145
  let loginArgs;
@@ -3099,27 +4175,25 @@ ${USAGE}` : err);
3099
4175
  console.error("specsketch: built UI assets not found (dist/client missing).");
3100
4176
  process.exit(1);
3101
4177
  }
3102
- if (args.share) {
3103
- try {
3104
- await readCoopAuth();
3105
- } catch (err) {
3106
- console.error(`specsketch: ${err instanceof CoopAuthError ? err.message : String(err)}`);
3107
- process.exit(1);
3108
- }
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);
3109
4188
  }
3110
- const workspace = new ChangeWorkspace(args.dir);
3111
- let remote = null;
4189
+ const workspace = new ChangeWorkspace(target.changeDir, { create: false });
3112
4190
  const app = createApp(
3113
4191
  new Anthropic(),
3114
4192
  workspace,
3115
- () => remote ? {
3116
- active: true,
3117
- shareUrl: remote.shareUrl,
3118
- status: remote.status,
3119
- participants: remote.participants.length
3120
- } : { active: false },
3121
- { refRoot: process.cwd(), harness: createClaudeHarness() }
4193
+ () => ({ active: false }),
4194
+ { refRoot: target.repoRoot, harness: createClaudeHarness() }
3122
4195
  );
4196
+ mountWorkbenchHost(app, { target, fixture: args.fixture });
3123
4197
  app.get("*", (c) => {
3124
4198
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);
3125
4199
  return res ?? c.notFound();
@@ -3146,17 +4220,11 @@ ${USAGE}` : err);
3146
4220
  console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
3147
4221
  process.exit(1);
3148
4222
  }
3149
- if (args.share) {
3150
- try {
3151
- remote = await startShare(workspace, httpServer);
3152
- } catch (err) {
3153
- console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
3154
- process.exit(1);
3155
- }
3156
- }
3157
4223
  const source = detectCredentialSource();
3158
4224
  console.log(`coopcli specsketch`);
3159
- 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`);
3160
4228
  console.log(` open: http://127.0.0.1:${port}`);
3161
4229
  if (moved) {
3162
4230
  console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
@@ -3168,56 +4236,6 @@ ${USAGE}` : err);
3168
4236
  \u26A0 ${CREDENTIALS_HINT}
3169
4237
  `);
3170
4238
  }
3171
- if (remote) {
3172
- console.log(`
3173
- \u2500\u2500 shared session \u2500\u2500`);
3174
- console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
3175
- console.log(`
3176
- ${remote.shareUrl}
3177
- `);
3178
- console.log(` Ctrl-C ends the session and invalidates the link.`);
3179
- }
3180
- }
3181
- async function startShare(workspace, server) {
3182
- const auth = await readCoopAuth();
3183
- const localClients = /* @__PURE__ */ new Set();
3184
- const remote = await RemoteSession.start({
3185
- ...auth,
3186
- workspace,
3187
- title: workspace.changeName,
3188
- hostName: auth.displayName,
3189
- log: (msg) => console.log(` ${msg}`),
3190
- onLocalBroadcast: (frame) => {
3191
- for (const client of localClients) client.send(frame);
3192
- }
3193
- });
3194
- const wss = new WebSocketServer({ noServer: true });
3195
- server.on("upgrade", (req, socket, head) => {
3196
- const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
3197
- if (pathname !== "/ws/local") {
3198
- socket.destroy();
3199
- return;
3200
- }
3201
- wss.handleUpgrade(req, socket, head, (ws) => {
3202
- localClients.add(ws);
3203
- ws.on("close", () => localClients.delete(ws));
3204
- ws.on(
3205
- "message",
3206
- (data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
3207
- );
3208
- });
3209
- });
3210
- let ending = false;
3211
- const endAndExit = async () => {
3212
- if (ending) return;
3213
- ending = true;
3214
- console.log("\nspecsketch: ending shared session\u2026");
3215
- await remote.end();
3216
- process.exit(0);
3217
- };
3218
- process.on("SIGINT", () => void endAndExit());
3219
- process.on("SIGTERM", () => void endAndExit());
3220
- return remote;
3221
4239
  }
3222
4240
 
3223
4241
  // server/cli-entry.ts