@coopcli/specplan 5.5.6 → 2026.817.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/dist/cli/index.js +2205 -370
  2. package/dist/client/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  3. package/dist/client/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  4. package/dist/client/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  5. package/dist/client/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  6. package/dist/client/assets/abnfDiagram-VRR7QNED-BYxvjjLu.js +1 -0
  7. package/dist/client/assets/ar-SA-G6X2FPQ2-gmQ0sqUV.js +10 -0
  8. package/dist/client/assets/arc-DqK6O3qL.js +1 -0
  9. package/dist/client/assets/architecture-TIHT7OUA-Cz2n_8dt.js +1 -0
  10. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-DaKJ9vFA.js +36 -0
  11. package/dist/client/assets/array-BifhSqXX.js +1 -0
  12. package/dist/client/assets/az-AZ-76LH7QW2-BanmmzZl.js +1 -0
  13. package/dist/client/assets/bg-BG-XCXSNQG7-CNoHpulr.js +5 -0
  14. package/dist/client/assets/blockDiagram-677ZJIJ3-xETyUDB6.js +132 -0
  15. package/dist/client/assets/bn-BD-2XOGV67Q-ClxPGgJn.js +5 -0
  16. package/dist/client/assets/c4Diagram-LMCZKHZV-CJGUNAXt.js +10 -0
  17. package/dist/client/assets/ca-ES-6MX7JW3Y-Ce-mC8Le.js +8 -0
  18. package/dist/client/assets/channel-CuBMvOiT.js +1 -0
  19. package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  20. package/dist/client/assets/chunk-32BRIVSS-6Rh4CCUQ.js +1 -0
  21. package/dist/client/assets/chunk-52WLFC77-A0SkaTYM.js +10 -0
  22. package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  23. package/dist/client/assets/chunk-6U3AYISY-C6-Mtja1.js +12 -0
  24. package/dist/client/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  25. package/dist/client/assets/chunk-C7G6YPKG-BMphmVWl.js +1 -0
  26. package/dist/client/assets/chunk-EIO257PC-BiAg7S3S.js +22 -0
  27. package/dist/client/assets/chunk-EX3LRPZG-xUcXnDRE.js +231 -0
  28. package/dist/client/assets/chunk-FWX5IMBZ-C_mfbGNa.js +2 -0
  29. package/dist/client/assets/chunk-HOUHSVGY-yNNNImSs.js +1 -0
  30. package/dist/client/assets/chunk-ICXQ74PX-CyZZ3kFK.js +2 -0
  31. package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  32. package/dist/client/assets/chunk-K2UTITRG-CL9ravuf.js +35 -0
  33. package/dist/client/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  34. package/dist/client/assets/chunk-MOJQB5TN-Bio7zOcN.js +88 -0
  35. package/dist/client/assets/chunk-OGEWGWER-vwCurcM9.js +1 -0
  36. package/dist/client/assets/chunk-PUDLZKDR-BWhPp8B4.js +156 -0
  37. package/dist/client/assets/chunk-Q4XR5HBZ-CvZ-Pz5B.js +70 -0
  38. package/dist/client/assets/chunk-RYQCIY6F-CtK1BTrZ.js +1 -0
  39. package/dist/client/assets/chunk-SRAX5OIU-Bpn6XoyU.js +1 -0
  40. package/dist/client/assets/chunk-V7JOEXUC-BP5dRCwG.js +206 -0
  41. package/dist/client/assets/chunk-VAUOI2AC-D7taetBr.js +1 -0
  42. package/dist/client/assets/chunk-VR4S4FIN-Q-TN3CuI.js +1 -0
  43. package/dist/client/assets/chunk-WYO6CB5R-qHfws5Na.js +127 -0
  44. package/dist/client/assets/chunk-XXDRQBXY-BSmtn7NR.js +1 -0
  45. package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  46. package/dist/client/assets/chunk-Z3N5DIM6-D9TMVU7m.js +1 -0
  47. package/dist/client/assets/chunk-ZGVPDNZ5-CHDpAKTq.js +62 -0
  48. package/dist/client/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  49. package/dist/client/assets/chunk-ZUYEQ4TG-BzFOpW--.js +7 -0
  50. package/dist/client/assets/classDiagram-OUVF2IWQ-B8_i7rCq.js +1 -0
  51. package/dist/client/assets/classDiagram-v2-EOCWNBFH-B8_i7rCq.js +1 -0
  52. package/dist/client/assets/cose-bilkent-JH36ORCC-dsywqjX4.js +1 -0
  53. package/dist/client/assets/cs-CZ-2BRQDIVT-C5IttjGJ.js +11 -0
  54. package/dist/client/assets/cynefin-VYW2F7L2-CeEsCHXm.js +1 -0
  55. package/dist/client/assets/cynefinDiagram-TSTJHNR4-DXw0GuLE.js +62 -0
  56. package/dist/client/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  57. package/dist/client/assets/da-DK-5WZEPLOC-CK6a5-PJ.js +5 -0
  58. package/dist/client/assets/dagre-CXRCoUWR.js +1 -0
  59. package/dist/client/assets/dagre-VKFMJZFB-D7iVblaF.js +4 -0
  60. package/dist/client/assets/de-DE-XR44H4JA-Dj9_g0KR.js +8 -0
  61. package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
  62. package/dist/client/assets/diagram-FQU43EPY-D8k_N9yq.js +3 -0
  63. package/dist/client/assets/diagram-G47NLZAW-CH7ECldl.js +24 -0
  64. package/dist/client/assets/diagram-NH7WQ7WH-eJ3LFOko.js +24 -0
  65. package/dist/client/assets/diagram-OA4YK3LP-BcBLwxof.js +30 -0
  66. package/dist/client/assets/diagram-WEI45ONY-g4Ydc6Nh.js +41 -0
  67. package/dist/client/assets/directory-open-01563666-D4xXyWb_.js +1 -0
  68. package/dist/client/assets/directory-open-4ed118d0-1i309Asm.js +1 -0
  69. package/dist/client/assets/dist-D2xnLDqk.js +29 -0
  70. package/dist/client/assets/dist-qx0Iv9vM.js +1 -0
  71. package/dist/client/assets/ebnfDiagram-CCIWWBDH-nijnOCKy.js +1 -0
  72. package/dist/client/assets/el-GR-BZB4AONW-BQgL2fhh.js +10 -0
  73. package/dist/client/assets/en-B4ZKOASM-Boax1qIS.js +1 -0
  74. package/dist/client/assets/erDiagram-Q63AITRT-Dj7bPJ8h.js +85 -0
  75. package/dist/client/assets/es-ES-U4NZUMDT-u-AjVvml.js +9 -0
  76. package/dist/client/assets/eu-ES-A7QVB2H4-DQvZM2Ew.js +11 -0
  77. package/dist/client/assets/eventmodeling-45OFAUF4-D-h9umx1.js +1 -0
  78. package/dist/client/assets/fa-IR-HGAKTJCU-CIyJhh-1.js +8 -0
  79. package/dist/client/assets/fi-FI-Z5N7JZ37-DuJtcsOn.js +6 -0
  80. package/dist/client/assets/file-open-002ab408-BHUWm0Sh.js +1 -0
  81. package/dist/client/assets/file-open-7c801643-DZtJT5zp.js +1 -0
  82. package/dist/client/assets/file-save-3189631c-CO9S4HFW.js +1 -0
  83. package/dist/client/assets/file-save-745eba88-Bwdfz6OZ.js +1 -0
  84. package/dist/client/assets/flowDiagram-23GEKE2U-C4Gtyi00.js +1 -0
  85. package/dist/client/assets/fr-FR-RHASNOE6-C8c05zVc.js +9 -0
  86. package/dist/client/assets/ganttDiagram-NO4QXBWP-BKwUgAKS.js +292 -0
  87. package/dist/client/assets/gitGraph-TEB2WS4Q-C3nx5-er.js +1 -0
  88. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-xX_vEW-n.js +106 -0
  89. package/dist/client/assets/gl-ES-HMX3MZ6V-BCLvpf87.js +10 -0
  90. package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
  91. package/dist/client/assets/he-IL-6SHJWFNN-C7LtfbKa.js +10 -0
  92. package/dist/client/assets/hi-IN-IWLTKZ5I-BMHouFZC.js +4 -0
  93. package/dist/client/assets/hu-HU-A5ZG7DT2-8WL43IqI.js +7 -0
  94. package/dist/client/assets/id-ID-SAP4L64H-CVHsWB3W.js +10 -0
  95. package/dist/client/assets/image-GAAHSSAO-BS5tZem_.js +1 -0
  96. package/dist/client/assets/image-blob-reduce.esm-DLFdRAwc.js +2 -0
  97. package/dist/client/assets/index-Cc2C1ER1.css +1 -0
  98. package/dist/client/assets/index-CcRUbBLK.js +92 -0
  99. package/dist/client/assets/info-DKCQHKI2-DtWLyMEA.js +1 -0
  100. package/dist/client/assets/infoDiagram-FWYZ7A6U-uJrkO1jE.js +2 -0
  101. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  102. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-grtc_5Gw.js +70 -0
  103. package/dist/client/assets/it-IT-JPQ66NNP-CGWJ-ggt.js +11 -0
  104. package/dist/client/assets/ja-JP-DBVTYXUO-BQ52sVjV.js +8 -0
  105. package/dist/client/assets/journeyDiagram-5HDEW3XC-D5mpwyTK.js +139 -0
  106. package/dist/client/assets/kaa-6HZHGXH3-HogrI5l2.js +1 -0
  107. package/dist/client/assets/kab-KAB-ZGHBKWFO-qODJe40j.js +8 -0
  108. package/dist/client/assets/kanban-definition-HUTT4EX6-C8FJz-rK.js +89 -0
  109. package/dist/client/assets/katex-CddkPoXu.js +257 -0
  110. package/dist/client/assets/kk-KZ-P5N5QNE5-Dtb9_W_T.js +1 -0
  111. package/dist/client/assets/km-KH-HSX4SM5Z-mZRhpSZ2.js +11 -0
  112. package/dist/client/assets/ko-KR-MTYHY66A-DHnGxFiW.js +9 -0
  113. package/dist/client/assets/ku-TR-6OUDTVRD-DxzL3_hu.js +9 -0
  114. package/dist/client/assets/line-FNoxgdds.js +1 -0
  115. package/dist/client/assets/linear-CmlO4_aU.js +1 -0
  116. package/dist/client/assets/lt-LT-XHIRWOB4-RPdyV_7p.js +3 -0
  117. package/dist/client/assets/lv-LV-5QDEKY6T-CeoJE7c3.js +7 -0
  118. package/dist/client/assets/map-DsCK-0Cs.js +1 -0
  119. package/dist/client/assets/mermaid-parser.core-CWtD7LBQ.js +7 -0
  120. package/dist/client/assets/mindmap-definition-LN4V7U3C-c0zWDYkC.js +96 -0
  121. package/dist/client/assets/mr-IN-CRQNXWMA-DNY1aRAe.js +13 -0
  122. package/dist/client/assets/my-MM-5M5IBNSE-Q10JlVxz.js +1 -0
  123. package/dist/client/assets/nb-NO-T6EIAALU-Bd8wZusD.js +10 -0
  124. package/dist/client/assets/nl-NL-IS3SIHDZ-pq4rexDX.js +8 -0
  125. package/dist/client/assets/nn-NO-6E72VCQL-B6QB31N3.js +8 -0
  126. package/dist/client/assets/oc-FR-POXYY2M6-CpSHziel.js +8 -0
  127. package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
  128. package/dist/client/assets/pa-IN-N4M65BXN-CQD1aMM3.js +4 -0
  129. package/dist/client/assets/packet-7NZHBO7P-CFjBM6rf.js +1 -0
  130. package/dist/client/assets/path-BWPyau1x.js +1 -0
  131. package/dist/client/assets/pegDiagram-2B236MQR-D5EdJTGE.js +1 -0
  132. package/dist/client/assets/percentages-BXMCSKIN-B6sdRC37.js +1 -0
  133. package/dist/client/assets/pica-D0pwGKw7.js +2 -0
  134. package/dist/client/assets/pie-RZYD4A2V-DzXcKfJc.js +1 -0
  135. package/dist/client/assets/pieDiagram-ENE6RG2P-CfJdJPbc.js +39 -0
  136. package/dist/client/assets/pl-PL-T2D74RX3-qhaGLRNJ.js +9 -0
  137. package/dist/client/assets/prod-DtRVLazd.css +1 -0
  138. package/dist/client/assets/prod-DxrTzQHE.js +149 -0
  139. package/dist/client/assets/pt-BR-5N22H2LF-C6PhCoZM.js +9 -0
  140. package/dist/client/assets/pt-PT-UZXXM6DQ-DtVpJ-mM.js +9 -0
  141. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-BERLxAab.js +7 -0
  142. package/dist/client/assets/radar-I7S5WNFK-BTKCUdOD.js +1 -0
  143. package/dist/client/assets/railroad-3IZDKUUU-BqlJYKVR.js +1 -0
  144. package/dist/client/assets/railroad-abnf-AHOZXSZD-sY1lWP7G.js +1 -0
  145. package/dist/client/assets/railroad-ebnf-EBAXGLYW-D9qoZ2zn.js +1 -0
  146. package/dist/client/assets/railroad-peg-LSFZ7HO6-ChaYlBF0.js +1 -0
  147. package/dist/client/assets/railroadDiagram-RFXS5EU6-CjYvoaz7.js +1 -0
  148. package/dist/client/assets/requirementDiagram-TGXJPOKE-CBOHTTAP.js +84 -0
  149. package/dist/client/assets/ro-RO-JPDTUUEW-BPyoL-x7.js +11 -0
  150. package/dist/client/assets/rolldown-runtime-aKtaBQYM.js +1 -0
  151. package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
  152. package/dist/client/assets/roundRect-D01gJrlt.js +1 -0
  153. package/dist/client/assets/ru-RU-B4JR7IUQ-C2-Ortyw.js +9 -0
  154. package/dist/client/assets/sankeyDiagram-HTMAVEWB-BhhsYxus.js +40 -0
  155. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-tBrxwb5x.js +162 -0
  156. package/dist/client/assets/si-LK-N5RQ5JYF-BQNg_jHA.js +1 -0
  157. package/dist/client/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  158. package/dist/client/assets/sk-SK-C5VTKIMK-DV-NI8B-.js +6 -0
  159. package/dist/client/assets/sl-SI-NN7IZMDC-DjSgCsh0.js +6 -0
  160. package/dist/client/assets/src-B56W74gN.js +1 -0
  161. package/dist/client/assets/stateDiagram-2N3HPSRC-CETKX_oh.js +1 -0
  162. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Q_oKpxae.js +1 -0
  163. package/dist/client/assets/subset-shared.chunk-CCxjxb7D.js +1 -0
  164. package/dist/client/assets/subset-worker.chunk-C7PizKXR.js +1 -0
  165. package/dist/client/assets/sv-SE-XGPEYMSR-ZW1ZTkc6.js +10 -0
  166. package/dist/client/assets/swimlanes-5IMT3BWC-Cp3oOe65.js +2 -0
  167. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BlNqO3f1.js +8 -0
  168. package/dist/client/assets/ta-IN-2NMHFXQM-IdwMbxCh.js +9 -0
  169. package/dist/client/assets/th-TH-HPSO5L25-DBFIAUWk.js +2 -0
  170. package/dist/client/assets/timeline-definition-FHXFAJF6-D4yGmEXE.js +120 -0
  171. package/dist/client/assets/tr-TR-DEFEU3FU-kfju4l2S.js +7 -0
  172. package/dist/client/assets/treeView-QDETBFTQ-Bq0uOqri.js +1 -0
  173. package/dist/client/assets/treemap-6X3UGDF4-DEB6PYX-.js +1 -0
  174. package/dist/client/assets/uk-UA-QMV73CPH-CKYaJ9WU.js +6 -0
  175. package/dist/client/assets/vennDiagram-L72KCM5P-D5QdiCle.js +34 -0
  176. package/dist/client/assets/vi-VN-M7AON7JQ--RTGQlaN.js +5 -0
  177. package/dist/client/assets/wardley-OPB4EBWU-DySMTzU2.js +1 -0
  178. package/dist/client/assets/wardleyDiagram-EHGQE667-B2KHXQxE.js +78 -0
  179. package/dist/client/assets/xychartDiagram-FW5EYKEG-HxzAIqAC.js +7 -0
  180. package/dist/client/assets/zh-CN-LNUGB5OW-CtzyHHQM.js +10 -0
  181. package/dist/client/assets/zh-HK-E62DVLB3-By0weXcL.js +1 -0
  182. package/dist/client/assets/zh-TW-RAJ6MFWO-DzD8u9Zq.js +9 -0
  183. package/dist/client/index.html +3 -2
  184. package/package.json +80 -76
  185. package/dist/client/assets/index-CRmpUkRq.css +0 -1
  186. package/dist/client/assets/index-WZJ90U6D.js +0 -78
package/dist/cli/index.js CHANGED
@@ -1,14 +1,317 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "node:fs";
5
- import { homedir as homedir2 } from "node:os";
6
- import { dirname as dirname5, extname, join as join7, normalize as normalize2, resolve as resolve5 } from "node:path";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync5 } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
+ import { dirname as dirname8, extname, join as join14, normalize as normalize2, resolve as resolve6 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs as parseArgs2 } from "node:util";
9
9
  import { serve } from "@hono/node-server";
10
10
  import Anthropic from "@anthropic-ai/sdk";
11
11
 
12
+ // ../agent-harness/src/model-auth.ts
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ var ModelAuthError = class extends Error {
17
+ };
18
+ var API_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
19
+ function hasAntAuthProfile(home) {
20
+ try {
21
+ return readdirSync(join(home, ".config", "anthropic", "credentials")).some(
22
+ (f) => f.endsWith(".json")
23
+ );
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ function resolveModelCredential(opts = {}) {
29
+ const env = opts.env ?? process.env;
30
+ const home = opts.home ?? homedir();
31
+ const useApi = Boolean(opts.useApi) || Boolean(env.COOP_USE_API);
32
+ const hasApiKey = Boolean(env.ANTHROPIC_API_KEY);
33
+ const hasAntAuth = Boolean(env.ANTHROPIC_AUTH_TOKEN) || hasAntAuthProfile(home);
34
+ const hasSubscription = existsSync(join(home, ".claude", ".credentials.json"));
35
+ if (useApi) {
36
+ if (hasApiKey || hasAntAuth) return apiResult(env, hasApiKey ? "api-key" : "ant-auth");
37
+ throw new ModelAuthError(
38
+ "--api / COOP_USE_API is set but no API credential was found. Set ANTHROPIC_API_KEY=sk-ant-\u2026 or run `ant auth login`."
39
+ );
40
+ }
41
+ if (hasSubscription) {
42
+ const subprocessEnv = { ...env };
43
+ for (const k of API_ENV_KEYS) delete subprocessEnv[k];
44
+ return {
45
+ path: "subscription",
46
+ billsApiCredits: false,
47
+ notice: "Model auth: Max/Pro subscription login \u2014 billing your subscription.",
48
+ subprocessEnv
49
+ };
50
+ }
51
+ if (hasApiKey) return apiResult(env, "api-key");
52
+ if (hasAntAuth) return apiResult(env, "ant-auth");
53
+ throw new ModelAuthError(
54
+ "No Anthropic model credential found. Either:\n \u2022 log in your own Claude Code for subscription billing: claude login\n \u2022 or use API credits: export ANTHROPIC_API_KEY=sk-ant-\u2026 (or: ant auth login)"
55
+ );
56
+ }
57
+ function apiResult(env, path) {
58
+ return {
59
+ path,
60
+ billsApiCredits: true,
61
+ notice: `Model auth: ${path === "api-key" ? "ANTHROPIC_API_KEY" : "ant auth OAuth token"} \u2014 billing API credits, NOT your Max subscription.`,
62
+ subprocessEnv: { ...env }
63
+ };
64
+ }
65
+
66
+ // ../agent-harness/src/claude.ts
67
+ function makeGate(profile) {
68
+ return async (toolName, input) => {
69
+ if (!profile.allowedTools.includes(toolName)) {
70
+ return {
71
+ behavior: "deny",
72
+ message: `Tool "${toolName}" is not in the headless allowlist for agent "${profile.identity}".`
73
+ };
74
+ }
75
+ if (toolName === "Bash") {
76
+ const command = typeof input["command"] === "string" ? input["command"] : "";
77
+ for (const pattern of profile.blockedBashPatterns) {
78
+ if (pattern.test(command)) {
79
+ return {
80
+ behavior: "deny",
81
+ message: `Bash command blocked by headless profile pattern ${String(pattern)} for agent "${profile.identity}".`
82
+ };
83
+ }
84
+ }
85
+ }
86
+ if (profile.gatedTools.includes(toolName) && !profile.allowGated) {
87
+ return {
88
+ behavior: "deny",
89
+ message: `Tool "${toolName}" is a gated side-effect tool and this profile does not allow gated tools (allowGated: false).`
90
+ };
91
+ }
92
+ return { behavior: "allow", updatedInput: input };
93
+ };
94
+ }
95
+ function destructiveBashHook(profile) {
96
+ return {
97
+ matcher: "Bash",
98
+ hooks: [
99
+ async (input) => {
100
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
101
+ const ti = input.tool_input;
102
+ const command = typeof ti === "object" && ti !== null ? String(ti.command ?? "") : "";
103
+ for (const pattern of profile.blockedBashPatterns) {
104
+ if (pattern.test(command)) {
105
+ return {
106
+ continue: true,
107
+ hookSpecificOutput: {
108
+ hookEventName: "PreToolUse",
109
+ permissionDecision: "deny",
110
+ permissionDecisionReason: `Destructive command blocked by headless profile (${String(pattern)}) for agent "${profile.identity}".`
111
+ }
112
+ };
113
+ }
114
+ }
115
+ return { continue: true };
116
+ }
117
+ ]
118
+ };
119
+ }
120
+ function* mapMessage(message, expectStructuredOutput) {
121
+ switch (message.type) {
122
+ case "system": {
123
+ if (message.subtype === "init") {
124
+ yield {
125
+ type: "start",
126
+ sessionId: message.session_id,
127
+ ...message.apiKeySource !== void 0 ? { apiKeySource: message.apiKeySource } : {}
128
+ };
129
+ }
130
+ return;
131
+ }
132
+ case "assistant": {
133
+ const assistant = message;
134
+ for (const block of assistant.message.content) {
135
+ if (block.type === "text") {
136
+ yield { type: "text", text: block.text };
137
+ } else if (block.type === "thinking") {
138
+ yield { type: "thinking", text: block.thinking };
139
+ } else if (block.type === "tool_use") {
140
+ yield { type: "tool_use", id: block.id, name: block.name, input: block.input };
141
+ }
142
+ }
143
+ return;
144
+ }
145
+ case "user": {
146
+ const user = message;
147
+ const content = user.message.content;
148
+ if (typeof content === "string") return;
149
+ for (const block of content) {
150
+ if (block.type === "tool_result") {
151
+ yield {
152
+ type: "tool_result",
153
+ id: block.tool_use_id,
154
+ isError: block.is_error ?? false,
155
+ content: block.content
156
+ };
157
+ }
158
+ }
159
+ return;
160
+ }
161
+ case "result": {
162
+ if (message.subtype === "success") {
163
+ const missingStructured = expectStructuredOutput && message.structured_output === void 0;
164
+ yield {
165
+ type: "result",
166
+ text: message.result,
167
+ costUsd: message.total_cost_usd,
168
+ isError: message.is_error || missingStructured,
169
+ ...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
170
+ };
171
+ } else {
172
+ yield {
173
+ type: "result",
174
+ text: message.errors.join("\n"),
175
+ costUsd: message.total_cost_usd,
176
+ isError: true
177
+ };
178
+ }
179
+ return;
180
+ }
181
+ default:
182
+ return;
183
+ }
184
+ }
185
+ function buildOptions(input, resolved) {
186
+ const profile = input.profile;
187
+ const options = {
188
+ cwd: input.cwd,
189
+ // CRITICAL: an EXPLICIT settingSources: [] loads NOTHING. (The SDK's own
190
+ // default — when the option is omitted — loads ALL sources; we always
191
+ // pass the profile's list so inheritance is an explicit choice, and the
192
+ // profile's sources are what yield LifeOS inheritance.)
193
+ settingSources: profile.settingSources,
194
+ // Never 'bypassPermissions' / 'acceptEdits' — PermissionProfile's type
195
+ // only admits 'default' | 'plan' | 'dontAsk'.
196
+ permissionMode: profile.permissionMode,
197
+ allowedTools: profile.allowedTools,
198
+ canUseTool: makeGate(profile),
199
+ // Containment: destructive-Bash denial must survive a bare allowlisted
200
+ // `Bash` (allow rules short-circuit canUseTool), so it is enforced by a
201
+ // PreToolUse hook, which the SDK evaluates before allow rules.
202
+ hooks: { PreToolUse: [destructiveBashHook(profile)] },
203
+ // Options.env REPLACES the subprocess environment (no merge). The
204
+ // resolver hands back the FULL inherited env (HOME/PATH preserved) with
205
+ // ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN stripped on the subscription
206
+ // path, so the subprocess cannot prefer an API key over the login.
207
+ env: resolved.subprocessEnv
208
+ };
209
+ if (profile.disallowedTools) {
210
+ options.disallowedTools = profile.disallowedTools;
211
+ }
212
+ if (profile.mcpServers) {
213
+ options.mcpServers = profile.mcpServers;
214
+ }
215
+ if (profile.strictMcp !== void 0) {
216
+ options.strictMcpConfig = profile.strictMcp;
217
+ }
218
+ if (input.outputSchema) {
219
+ options.outputFormat = { type: "json_schema", schema: input.outputSchema };
220
+ }
221
+ return options;
222
+ }
223
+ function verifyBilling(resolved, apiKeySource, notify) {
224
+ if (apiKeySource === void 0) return;
225
+ const billedViaApiKey = apiKeySource !== "none";
226
+ if (resolved.path === "subscription" && billedViaApiKey) {
227
+ notify(
228
+ `Warning: the subscription path was selected but the run authenticated from an API-key source ('${apiKeySource}') \u2014 this run bills API credits, not your subscription.`
229
+ );
230
+ } else if (resolved.path !== "subscription" && !billedViaApiKey) {
231
+ notify(
232
+ `Warning: the ${resolved.path} (API credits) path was selected but the run authenticated as first-party (no API key) \u2014 this run bills the subscription.`
233
+ );
234
+ }
235
+ }
236
+ var SDK_UNAVAILABLE_MESSAGE = "Claude Agent SDK not available \u2014 reinstall the CLI (do not use --omit=optional). Global install is required for the agent path; pnpm dlx/npx re-download the ~268 MB native binary.";
237
+ function isSdkAbsence(error) {
238
+ const code = error?.code;
239
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
240
+ const message = error instanceof Error ? error.message : String(error);
241
+ return /Native CLI binary.*not found/i.test(message);
242
+ }
243
+ async function loadSdkQuery(loader) {
244
+ try {
245
+ const mod = await (loader ?? (() => import("@anthropic-ai/claude-agent-sdk")))();
246
+ return mod.query;
247
+ } catch (error) {
248
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
249
+ throw error;
250
+ }
251
+ }
252
+ function createClaudeHarness(deps) {
253
+ return {
254
+ id: "claude",
255
+ async *run(input) {
256
+ const resolved = resolveModelCredential({
257
+ env: deps?.env,
258
+ home: deps?.home,
259
+ useApi: input.useApi
260
+ });
261
+ const notify = input.onNotice ?? ((message) => console.error(message));
262
+ notify(resolved.notice);
263
+ const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
264
+ const expectStructuredOutput = input.outputSchema !== void 0;
265
+ try {
266
+ const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
267
+ for await (const message of stream) {
268
+ if (message.type === "system" && message.subtype === "init") {
269
+ verifyBilling(resolved, message.apiKeySource, notify);
270
+ }
271
+ yield* mapMessage(message, expectStructuredOutput);
272
+ }
273
+ } catch (error) {
274
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
275
+ throw error;
276
+ }
277
+ }
278
+ };
279
+ }
280
+
281
+ // ../agent-harness/src/profiles.ts
282
+ var SIDE_EFFECT_GATED_TOOLS = ["SendMessage", "Send", "Deploy", "Publish"];
283
+ var BLOCKED_BASH_PATTERNS = [
284
+ /\bgit\s+push\b/,
285
+ /\bwrangler\s+deploy\b/,
286
+ /\brm\s+-rf\b/,
287
+ /\bnpm\s+publish\b/
288
+ ];
289
+ function blockedBashPatterns() {
290
+ return [...BLOCKED_BASH_PATTERNS];
291
+ }
292
+ function defaultHeadlessProfile(identity) {
293
+ return {
294
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch"],
295
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
296
+ allowGated: false,
297
+ blockedBashPatterns: blockedBashPatterns(),
298
+ permissionMode: "default",
299
+ settingSources: [],
300
+ identity
301
+ };
302
+ }
303
+ function readMostlyProfile(identity) {
304
+ return {
305
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch", "WebSearch"],
306
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
307
+ allowGated: false,
308
+ blockedBashPatterns: blockedBashPatterns(),
309
+ permissionMode: "default",
310
+ settingSources: [],
311
+ identity
312
+ };
313
+ }
314
+
12
315
  // src/lib/schema.ts
13
316
  import { z } from "zod";
14
317
  var GENERATION_MODELS = [
@@ -18,14 +321,24 @@ var GENERATION_MODELS = [
18
321
  ];
19
322
  var DEFAULT_MODEL = "claude-opus-4-8";
20
323
  var GenerationModel = z.enum(GENERATION_MODELS);
21
- var NodeType = z.enum(["spec", "user_story"]);
22
- var SpecNode = z.object({
324
+ var NodeType = z.enum(["capability", "proposal", "user_story"]);
325
+ var CapabilityNode = z.object({
326
+ /** The capability directory basename (`specs/<cap>/`). */
327
+ id: z.string(),
328
+ type: z.literal("capability"),
329
+ title: z.string(),
330
+ /** Sibling ordering hint — display/tie-break only, never affects edges. */
331
+ order: z.number(),
332
+ /** Repo-relative `specs/<cap>/spec.md` for the read projection. */
333
+ specPath: z.string().optional()
334
+ });
335
+ var ProposalNode = z.object({
23
336
  /** Server-minted `spec-<ulid>` (lowercase). */
24
337
  id: z.string(),
25
- type: z.literal("spec"),
338
+ type: z.literal("proposal"),
26
339
  title: z.string(),
27
340
  /**
28
- * Author's description/prompt for the spec — steering context for
341
+ * Author's description/prompt for the proposal — steering context for
29
342
  * generation. May contain `@` file references, which expand at
30
343
  * generation time like any other input.
31
344
  */
@@ -41,8 +354,12 @@ var StoryNode = z.object({
41
354
  text: z.string(),
42
355
  order: z.number()
43
356
  });
44
- var PlanNode = z.discriminatedUnion("type", [SpecNode, StoryNode]);
45
- var EdgeType = z.enum(["dependency", "containment"]);
357
+ var PlanNode = z.discriminatedUnion("type", [
358
+ CapabilityNode,
359
+ ProposalNode,
360
+ StoryNode
361
+ ]);
362
+ var EdgeType = z.enum(["stacked", "containment", "targets"]);
46
363
  var PlanEdge = z.object({
47
364
  from: z.string(),
48
365
  to: z.string(),
@@ -53,20 +370,8 @@ var Plan = z.object({
53
370
  nodes: z.array(PlanNode),
54
371
  edges: z.array(PlanEdge)
55
372
  });
56
- var SPEC_STATUSES = ["draft", "active", "done", "deferred"];
373
+ var SPEC_STATUSES = ["draft", "in-progress"];
57
374
  var SpecStatus = z.enum(SPEC_STATUSES);
58
- var SpecMeta = z.object({
59
- specId: z.string(),
60
- userStories: z.array(z.string()),
61
- dependencies: z.array(z.string()),
62
- /** Lifecycle status; absent = draft. */
63
- status: SpecStatus.optional(),
64
- generation: z.object({
65
- /** sha256 of the last generated body files — detects hand edits. */
66
- lastGeneratedBodyHash: z.string(),
67
- lastGeneratedAt: z.string()
68
- }).optional()
69
- });
70
375
  var ChatRole = z.enum(["user", "assistant", "summary"]);
71
376
  var ChatMessage = z.object({
72
377
  role: ChatRole,
@@ -84,7 +389,14 @@ var ChatRequest = z.object({
84
389
  message: z.string().min(1),
85
390
  /** Conversation history preceding `message`, oldest first. */
86
391
  conversation: z.array(ChatMessage).default([]),
87
- model: GenerationModel.optional()
392
+ model: GenerationModel.optional(),
393
+ /**
394
+ * Chat runtime (agent-harness change, phase 2). `structural` (default) is
395
+ * the non-destructive ≤8-round tool loop that mutates the PlanStore DAG.
396
+ * `power` runs the read-mostly full-tool agent harness BESIDE it — plan
397
+ * mutations still flow only through the structural tools.
398
+ */
399
+ mode: z.enum(["structural", "power"]).default("structural")
88
400
  });
89
401
  var UnresolvedRefInfo = z.object({ ref: z.string(), reason: z.string() });
90
402
  var ChatReply = z.object({
@@ -130,7 +442,7 @@ var GenerateResponse = z.object({
130
442
  /** Change-dir-relative paths written by this generation. */
131
443
  changedFiles: z.array(z.string())
132
444
  });
133
- var NodePosition = z.object({ x: z.number(), y: z.number() });
445
+ var NodePosition = z.object({ y: z.number() });
134
446
  var SpecState = z.object({
135
447
  /** Body files (proposal/design/tasks/specs) exist in the change dir. */
136
448
  hasSpec: z.boolean(),
@@ -139,22 +451,61 @@ var SpecState = z.object({
139
451
  * generated by specplan) — regeneration will be metadata-only.
140
452
  */
141
453
  handEdited: z.boolean(),
142
- /** Lifecycle status from the per-spec metadata file (absent = draft). */
143
- status: SpecStatus
454
+ /** Working state from the per-spec metadata file (absent = draft). */
455
+ status: SpecStatus,
456
+ /** Approved/fold axis (own axis, distinct from `status`); absent = not-approved. */
457
+ approved: z.boolean().optional(),
458
+ /** Build flag (user-set); absent = not-implemented. */
459
+ implemented: z.boolean().optional(),
460
+ /**
461
+ * Read-only delta-completion projection of the change's `tasks.md`
462
+ * checkboxes (`- [ ]` / `- [x]`). Absent when the change has no `tasks.md`
463
+ * (or it carries no checkboxes). NEVER authored or persisted from the card —
464
+ * the ledger + `tasks.md` are the single source of truth.
465
+ */
466
+ tasks: z.object({ done: z.number(), total: z.number() }).optional(),
467
+ /**
468
+ * Read-only lifecycle status projected from the repo `SPECS.md` ledger row
469
+ * for this change (e.g. `draft` / `approved` / `implemented`). Absent when
470
+ * the ledger has no row for the change (or no `SPECS.md` exists). Display
471
+ * only — no writer.
472
+ */
473
+ ledgerStatus: z.string().optional()
144
474
  });
145
475
  var SetStatusRequest = z.object({
146
476
  specId: z.string(),
147
477
  status: SpecStatus
148
478
  });
479
+ var SetImplementedRequest = z.object({
480
+ specId: z.string(),
481
+ implemented: z.boolean()
482
+ });
483
+ var ApplyRequest = z.object({
484
+ specId: z.string().min(1),
485
+ confirmHandEdit: z.boolean().optional()
486
+ });
149
487
  var SessionSnapshot = z.object({
150
488
  /** Display name of the plan root (the openspec directory). */
151
489
  rootName: z.string(),
152
490
  model: GenerationModel.optional(),
153
491
  plan: Plan,
154
- /** Canvas layout per node id (UI sidecar state, not plan data). */
155
- positions: z.record(z.string(), NodePosition),
156
492
  /** Per-spec-node on-disk change state, keyed by spec id. */
157
493
  specStates: z.record(z.string(), SpecState),
494
+ /**
495
+ * Collapsed hyphen-prefix group keys over the capability root lane (UI
496
+ * sidecar state, view-only — never plan data). ABSENT means the plan has
497
+ * never persisted a collapse state: the UI applies the first-load default
498
+ * (collapsed to the first hyphen segment). Present-but-empty means the
499
+ * user expanded everything, and that is remembered.
500
+ */
501
+ collapsedGroups: z.array(z.string()).optional(),
502
+ /**
503
+ * "Hide specs with no scheduled items" toggle (UI sidecar state, view-only —
504
+ * never plan data). Absent = the default (show every row). Hiding is a pure
505
+ * view filter — it drops the empty rows' edges and re-packs the rest without
506
+ * touching plan nodes/edges.
507
+ */
508
+ hideEmptySpecs: z.boolean().optional(),
158
509
  /** Persisted assistant chat, oldest first. */
159
510
  chat: z.array(ChatMessage).optional()
160
511
  });
@@ -162,11 +513,22 @@ var SpecArtifacts = z.object({
162
513
  specId: z.string(),
163
514
  handEdited: z.boolean(),
164
515
  /** Change-dir-relative body files in canonical order. */
165
- files: z.array(z.object({ path: z.string(), content: z.string() }))
516
+ files: z.array(z.object({ path: z.string(), content: z.string() })),
517
+ /**
518
+ * Raw `diagram.excalidraw` JSON when the change dir carries one — for the
519
+ * read-only excalidraw preview in the viewer dialog. Its OWN field on
520
+ * purpose: the diagram is deliberately NOT a body file (not in
521
+ * listBodyFiles/bodyHash/files[]), so its presence never flips the
522
+ * hand-edit gate.
523
+ */
524
+ diagram: z.string().optional()
166
525
  });
167
526
  var SessionSave = z.object({
168
527
  model: GenerationModel.optional(),
169
- positions: z.record(z.string(), NodePosition).optional()
528
+ /** Collapsed group keys (view-only grouping state, remembered per plan). */
529
+ collapsedGroups: z.array(z.string()).optional(),
530
+ /** "Hide specs with no scheduled items" toggle (view-only). */
531
+ hideEmptySpecs: z.boolean().optional()
170
532
  });
171
533
  var CreateSpecRequest = z.object({
172
534
  title: z.string().min(1),
@@ -176,6 +538,7 @@ var CreateSpecRequest = z.object({
176
538
  var CreateStoryRequest = z.object({ text: z.string().min(1) });
177
539
  var LinkRequest = z.object({ from: z.string(), to: z.string() });
178
540
  var ReorderRequest = z.object({ nodeId: z.string(), order: z.number() });
541
+ var ArrangeRequest = z.object({ order: z.array(z.string()) });
179
542
  var PromptSave = z.object({
180
543
  system: z.string().min(1).nullable()
181
544
  });
@@ -188,8 +551,19 @@ var PromptInfo = z.object({
188
551
  custom: z.boolean()
189
552
  });
190
553
 
191
- // server/app.ts
192
- import { Hono } from "hono";
554
+ // server/apply.ts
555
+ import { execFile as execFile2 } from "node:child_process";
556
+ import { dirname as dirname5, join as join7, resolve as resolve3 } from "node:path";
557
+ import { promisify as promisify2 } from "node:util";
558
+
559
+ // server/generate.ts
560
+ import { execFile } from "node:child_process";
561
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
562
+ import { createRequire } from "node:module";
563
+ import { dirname as dirname4, join as join6, resolve as resolve2 } from "node:path";
564
+ import { promisify } from "node:util";
565
+ import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
566
+ import { z as z3 } from "zod";
193
567
 
194
568
  // src/lib/dag.ts
195
569
  function storyError(text) {
@@ -215,13 +589,13 @@ function storyError(text) {
215
589
  var DagError = class extends Error {
216
590
  };
217
591
  function label(node) {
218
- const name = node.type === "spec" ? node.title : node.text;
592
+ const name = node.type === "user_story" ? node.text : node.title;
219
593
  return `${node.id} ("${name.length > 40 ? `${name.slice(0, 40)}\u2026` : name}")`;
220
594
  }
221
595
  function dependencyAdjacency(edges) {
222
596
  const adj = /* @__PURE__ */ new Map();
223
597
  for (const e of edges) {
224
- if (e.type !== "dependency") continue;
598
+ if (e.type !== "stacked") continue;
225
599
  const next = adj.get(e.from) ?? [];
226
600
  next.push(e.to);
227
601
  adj.set(e.from, next);
@@ -251,32 +625,45 @@ function deriveEdge(plan, from, to) {
251
625
  if (!toNode) throw new DagError(`unknown node: ${to}`);
252
626
  if (fromNode.type === "user_story") {
253
627
  throw new DagError(
254
- `user stories carry no edges of their own \u2014 ${label(fromNode)} cannot be a link source; link from a spec instead`
628
+ `user stories carry no edges of their own \u2014 ${label(fromNode)} cannot be a link source; link from a proposal instead`
629
+ );
630
+ }
631
+ if (toNode.type === "capability") {
632
+ throw new DagError(
633
+ `capabilities are read-only roots \u2014 ${label(toNode)} cannot be a link target`
255
634
  );
256
635
  }
257
636
  if (from === to) throw new DagError(`cannot link ${label(fromNode)} to itself`);
258
637
  if (plan.edges.some((e) => e.from === from && e.to === to)) {
259
638
  throw new DagError(`link already exists: ${from} -> ${to}`);
260
639
  }
640
+ if (fromNode.type === "capability") {
641
+ if (toNode.type === "user_story") {
642
+ throw new DagError(
643
+ `capabilities contain no user stories \u2014 ${label(fromNode)} may only target a proposal`
644
+ );
645
+ }
646
+ return { from, to, type: "targets" };
647
+ }
261
648
  if (toNode.type === "user_story") return { from, to, type: "containment" };
262
649
  const path = dependencyPath(plan.edges, to, from);
263
650
  if (path) {
264
651
  const cycle = [from, ...path].map((id) => nodes.get(id)).filter((n) => n !== void 0);
265
- throw new DagError(`dependency cycle: ${cycle.map((n) => label(n)).join(" -> ")}`);
652
+ throw new DagError(`stacked cycle: ${cycle.map((n) => label(n)).join(" -> ")}`);
266
653
  }
267
- return { from, to, type: "dependency" };
654
+ return { from, to, type: "stacked" };
268
655
  }
269
656
  function containedStories(plan, specId) {
270
657
  return plan.edges.filter((e) => e.type === "containment" && e.from === specId).map((e) => e.to);
271
658
  }
272
659
  function specDependencies(plan, specId) {
273
- return plan.edges.filter((e) => e.type === "dependency" && e.from === specId).map((e) => e.to);
660
+ return plan.edges.filter((e) => e.type === "stacked" && e.from === specId).map((e) => e.to);
274
661
  }
275
662
  function backlog(plan) {
276
663
  const linked = new Set(plan.edges.flatMap((e) => [e.from, e.to]));
277
664
  const free = plan.nodes.filter((n) => !linked.has(n.id));
278
665
  return {
279
- specs: sortSiblings(free.filter((n) => n.type === "spec")),
666
+ specs: sortSiblings(free.filter((n) => n.type === "proposal")),
280
667
  stories: sortSiblings(free.filter((n) => n.type === "user_story"))
281
668
  };
282
669
  }
@@ -305,7 +692,19 @@ function planErrors(plan) {
305
692
  errors.push(`user story ${e.from} carries an edge of its own (${e.from} -> ${e.to})`);
306
693
  continue;
307
694
  }
308
- const expected = toNode.type === "user_story" ? "containment" : "dependency";
695
+ if (toNode.type === "capability") {
696
+ errors.push(
697
+ `capability ${e.to} is a read-only root and cannot be a link target (${e.from} -> ${e.to})`
698
+ );
699
+ continue;
700
+ }
701
+ if (fromNode.type === "capability" && toNode.type === "user_story") {
702
+ errors.push(
703
+ `capability ${e.from} contains no user stories \u2014 it may only target a proposal (${e.from} -> ${e.to})`
704
+ );
705
+ continue;
706
+ }
707
+ const expected = fromNode.type === "capability" ? "targets" : toNode.type === "user_story" ? "containment" : "stacked";
309
708
  if (e.type !== expected) {
310
709
  errors.push(`edge ${e.from} -> ${e.to} must be type "${expected}", found "${e.type}"`);
311
710
  }
@@ -314,28 +713,20 @@ function planErrors(plan) {
314
713
  seenEdges.add(key);
315
714
  }
316
715
  const depEdges = plan.edges.filter(
317
- (e) => e.type === "dependency" && byId.has(e.from) && byId.has(e.to)
716
+ (e) => e.type === "stacked" && byId.has(e.from) && byId.has(e.to)
318
717
  );
319
718
  for (const e of depEdges) {
320
719
  const path = dependencyPath(depEdges, e.to, e.from);
321
720
  if (path) {
322
- errors.push(`dependency cycle: ${[e.from, ...path].join(" -> ")}`);
721
+ errors.push(`stacked cycle: ${[e.from, ...path].join(" -> ")}`);
323
722
  break;
324
723
  }
325
724
  }
326
725
  return errors;
327
726
  }
328
727
 
329
- // server/generate.ts
330
- import { execFile } from "node:child_process";
331
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
332
- import { createRequire } from "node:module";
333
- import { dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
334
- import { promisify } from "node:util";
335
- import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
336
-
337
728
  // server/atomic.ts
338
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
729
+ import { existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
339
730
  import { dirname } from "node:path";
340
731
  function writeAtomicFile(target, content) {
341
732
  mkdirSync(dirname(target), { recursive: true });
@@ -344,9 +735,389 @@ function writeAtomicFile(target, content) {
344
735
  renameSync(tmp, target);
345
736
  }
346
737
  function readIfExists(target) {
347
- return existsSync(target) ? readFileSync(target, "utf8") : void 0;
738
+ return existsSync2(target) ? readFileSync(target, "utf8") : void 0;
739
+ }
740
+
741
+ // server/journal.ts
742
+ import { join as join4 } from "node:path";
743
+
744
+ // ../shared/src/openspec-change-meta.ts
745
+ import { createHash } from "node:crypto";
746
+ import { existsSync as existsSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
747
+ import { join as join2 } from "node:path";
748
+ import { Document, parseDocument } from "yaml";
749
+ import { z as z2 } from "zod";
750
+
751
+ // ../shared/src/fs-atomic.ts
752
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
753
+ import { dirname as dirname2 } from "node:path";
754
+ function writeAtomicFile2(target, content) {
755
+ mkdirSync2(dirname2(target), { recursive: true });
756
+ const tmp = `${target}.tmp`;
757
+ writeFileSync2(tmp, content, "utf8");
758
+ renameSync2(tmp, target);
759
+ }
760
+ function readIfExists2(target) {
761
+ return existsSync3(target) ? readFileSync2(target, "utf8") : void 0;
762
+ }
763
+
764
+ // ../shared/src/openspec-change-meta.ts
765
+ var CHANGE_META_FILE = "specplan.yaml";
766
+ var nodeChangeMetaFs = {
767
+ readIfExists(path) {
768
+ return readIfExists2(path);
769
+ },
770
+ writeAtomicFile(path, content) {
771
+ writeAtomicFile2(path, content);
772
+ },
773
+ exists(path) {
774
+ return existsSync4(path);
775
+ },
776
+ readdir(path) {
777
+ if (!existsSync4(path)) return [];
778
+ return readdirSync2(path, { withFileTypes: true }).map((entry) => ({
779
+ name: entry.name,
780
+ directory: entry.isDirectory()
781
+ }));
782
+ },
783
+ realpath(path) {
784
+ return realpathSync(path);
785
+ }
786
+ };
787
+ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
788
+ function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
789
+ const files = [];
790
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
791
+ if (fsx.exists(join2(changeDir, name))) files.push(name);
792
+ }
793
+ const specsDir = join2(changeDir, "specs");
794
+ for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
795
+ const rel = join2("specs", entry.name, "spec.md");
796
+ if (entry.directory && fsx.exists(join2(changeDir, rel))) files.push(rel);
797
+ }
798
+ return files;
799
+ }
800
+ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
801
+ const files = listBodyFiles(changeDir, fsx);
802
+ if (files.length === 0) return null;
803
+ const parts = files.map(
804
+ (rel) => `${rel}
805
+ ${sha256(fsx.readIfExists(join2(changeDir, rel)) ?? "")}`
806
+ );
807
+ return `sha256:${sha256(parts.join("\n"))}`;
808
+ }
809
+ var SPEC_STATUSES2 = ["draft", "in-progress"];
810
+ var SpecStatus2 = z2.enum(SPEC_STATUSES2);
811
+ var LEGACY_STATUS_COERCION = {
812
+ active: "in-progress",
813
+ done: "in-progress",
814
+ deferred: "draft"
815
+ };
816
+ var StoredSpecStatus = z2.enum([...SPEC_STATUSES2, "active", "done", "deferred"]).transform(
817
+ (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
818
+ );
819
+ var SpecMetaSchema = z2.object({
820
+ specId: z2.string(),
821
+ userStories: z2.array(z2.string()),
822
+ dependencies: z2.array(z2.string()),
823
+ /** Working state (retired values coerce on read); absent = draft. */
824
+ status: StoredSpecStatus.optional(),
825
+ /**
826
+ * Approved axis (formerly `applied`) — fold-driven, SEPARATE from the
827
+ * working status above. True when the proposal's delta has been folded
828
+ * into specs/ and archived (apply-first: the fold happens at approval).
829
+ * Absent = not-approved.
830
+ */
831
+ approved: z2.boolean().optional(),
832
+ /**
833
+ * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
834
+ * compatibility: readers treat `applied: true` as `approved: true`; no
835
+ * writer ever sets this field again.
836
+ */
837
+ applied: z2.boolean().optional(),
838
+ /**
839
+ * Build flag — user-set, independent of the fold: true once the code has
840
+ * been built to match the approved diff. Absent = not-implemented.
841
+ */
842
+ implemented: z2.boolean().optional(),
843
+ /**
844
+ * Minimal implementation-task record written on fold (specplan-timeline-apply
845
+ * "Implementation task record on fold"): the creation time and a reference to
846
+ * the archived change's own id/path (from which the delta artifact is
847
+ * materialized on demand). Deliberately carries NO status and NO dispatch
848
+ * state — task execution/dispatch is a separate, not-yet-defined concern. A
849
+ * field addition to this sidecar, not a redefinition of its format.
850
+ */
851
+ task: z2.object({
852
+ createdAt: z2.string(),
853
+ source: z2.string()
854
+ }).optional(),
855
+ generation: z2.object({
856
+ /** sha256 of the last generated body files — detects hand edits. */
857
+ lastGeneratedBodyHash: z2.string(),
858
+ lastGeneratedAt: z2.string()
859
+ }).optional()
860
+ });
861
+ function writeChangeMeta(fsx, changeDir, meta) {
862
+ const path = join2(changeDir, CHANGE_META_FILE);
863
+ const raw = fsx.readIfExists(path);
864
+ const doc = raw === void 0 ? new Document({
865
+ specId: meta.specId,
866
+ userStories: meta.userStories ?? [],
867
+ dependencies: meta.dependencies ?? [],
868
+ status: meta.status ?? "draft"
869
+ }) : parseDocument(raw);
870
+ if (meta.approved !== void 0) doc.setIn(["approved"], meta.approved);
871
+ if (meta.implemented !== void 0) doc.setIn(["implemented"], meta.implemented);
872
+ if (meta.task !== void 0) {
873
+ doc.setIn(["task", "createdAt"], meta.task.createdAt);
874
+ doc.setIn(["task", "source"], meta.task.source);
875
+ }
876
+ if (meta.generation !== void 0) {
877
+ doc.setIn(["generation", "lastGeneratedBodyHash"], meta.generation.lastGeneratedBodyHash);
878
+ doc.setIn(["generation", "lastGeneratedAt"], meta.generation.lastGeneratedAt);
879
+ }
880
+ fsx.writeAtomicFile(path, doc.toString());
881
+ }
882
+
883
+ // server/spec-state.ts
884
+ import { dirname as dirname3, join as join3 } from "node:path";
885
+
886
+ // server/fs-node.ts
887
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync3, rmSync } from "node:fs";
888
+ var nodeSyncFs = {
889
+ readIfExists(path) {
890
+ return readIfExists(path);
891
+ },
892
+ writeAtomicFile(path, content) {
893
+ writeAtomicFile(path, content);
894
+ },
895
+ removeFile(path) {
896
+ rmSync(path, { force: true });
897
+ },
898
+ removeDir(path) {
899
+ rmSync(path, { force: true, recursive: true });
900
+ },
901
+ exists(path) {
902
+ return existsSync5(path);
903
+ },
904
+ mkdirp(path) {
905
+ mkdirSync3(path, { recursive: true });
906
+ },
907
+ readdir(path) {
908
+ if (!existsSync5(path)) return [];
909
+ return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
910
+ name: entry.name,
911
+ directory: entry.isDirectory()
912
+ }));
913
+ }
914
+ };
915
+
916
+ // server/spec-state.ts
917
+ function parseTaskProgress(md) {
918
+ const items = md.match(/^[ \t]*[-*] \[[ xX]\]/gm) ?? [];
919
+ if (items.length === 0) return null;
920
+ const done = items.filter((m) => /\[[xX]\]/.test(m)).length;
921
+ return { done, total: items.length };
922
+ }
923
+ function readTaskProgress(changeDir, fsx = nodeSyncFs) {
924
+ const md = fsx.readIfExists(join3(changeDir, "tasks.md"));
925
+ return md === void 0 ? null : parseTaskProgress(md);
926
+ }
927
+ function parseLedgerStatus(md, specId) {
928
+ const cellsOf3 = (line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
929
+ let statusIdx = 2;
930
+ for (const line of md.split(/\r?\n/)) {
931
+ if (!line.trim().startsWith("|")) continue;
932
+ const cells = cellsOf3(line);
933
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
934
+ const lower = cells.map((c) => c.toLowerCase());
935
+ if (lower.includes("status") && lower.includes("change")) {
936
+ statusIdx = lower.indexOf("status");
937
+ continue;
938
+ }
939
+ const first = cells[0] ?? "";
940
+ if (first.includes(specId)) {
941
+ const status = cells[statusIdx];
942
+ if (status) return status;
943
+ }
944
+ }
945
+ return void 0;
946
+ }
947
+ function readLedgerStatus(store, specId) {
948
+ const md = store.fsx.readIfExists(join3(store.dir, "..", "SPECS.md"));
949
+ return md === void 0 ? void 0 : parseLedgerStatus(md, specId);
950
+ }
951
+ function specState(store, specId) {
952
+ const changeDir = dirname3(store.specMetaPath(specId));
953
+ const currentHash = bodyHash(changeDir, store.fsx);
954
+ let meta = null;
955
+ try {
956
+ meta = store.readSpecMeta(specId);
957
+ } catch {
958
+ }
959
+ const status = meta?.status ?? "draft";
960
+ const tasks = readTaskProgress(changeDir, store.fsx);
961
+ const ledgerStatus = readLedgerStatus(store, specId);
962
+ const projections = {
963
+ ...tasks ? { tasks } : {},
964
+ ...ledgerStatus !== void 0 ? { ledgerStatus } : {}
965
+ };
966
+ const axes = {
967
+ ...meta?.approved === true || meta?.applied === true ? { approved: true } : {},
968
+ ...meta?.implemented === true ? { implemented: true } : {}
969
+ };
970
+ if (currentHash === null)
971
+ return { hasSpec: false, handEdited: false, status, ...axes, ...projections };
972
+ return {
973
+ hasSpec: true,
974
+ handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
975
+ status,
976
+ ...axes,
977
+ ...projections
978
+ };
979
+ }
980
+ function readDiagram(store, specId) {
981
+ const changeDir = dirname3(store.specMetaPath(specId));
982
+ return store.fsx.readIfExists(join3(changeDir, "diagram.excalidraw"));
983
+ }
984
+ function readBodyFiles(store, specId) {
985
+ const changeDir = dirname3(store.specMetaPath(specId));
986
+ return listBodyFiles(changeDir, store.fsx).map((rel) => ({ path: rel, content: store.fsx.readIfExists(join3(changeDir, rel)) })).filter((f) => f.content !== void 0);
348
987
  }
349
988
 
989
+ // server/journal.ts
990
+ var JOURNAL_FILE = "specplan-journal.ndjson";
991
+ function escapeRegExp(s) {
992
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
993
+ }
994
+ var PlanJournal = class {
995
+ constructor(fsx, dir) {
996
+ this.fsx = fsx;
997
+ this.dir = dir;
998
+ }
999
+ fsx;
1000
+ dir;
1001
+ get path() {
1002
+ return join4(this.dir, JOURNAL_FILE);
1003
+ }
1004
+ /**
1005
+ * The complete (parseable) journal lines as raw strings, in order. A line
1006
+ * that fails to parse — only ever the final line, from a torn write — is
1007
+ * skipped, so the reader always sees whole entries and an append heals the
1008
+ * file. Prior lines are kept as their exact bytes (no re-serialization).
1009
+ */
1010
+ completeLines() {
1011
+ const raw = this.fsx.readIfExists(this.path);
1012
+ if (raw === void 0 || raw === "") return [];
1013
+ const complete = [];
1014
+ for (const line of raw.split("\n")) {
1015
+ if (line.length === 0) continue;
1016
+ try {
1017
+ JSON.parse(line);
1018
+ complete.push(line);
1019
+ } catch {
1020
+ }
1021
+ }
1022
+ return complete;
1023
+ }
1024
+ /** Every complete entry, in append order. Tolerates a torn final line. */
1025
+ read() {
1026
+ return this.completeLines().map((l) => JSON.parse(l));
1027
+ }
1028
+ /**
1029
+ * Append exactly one entry, atomically. Read-modify-write: the prior complete
1030
+ * lines are preserved verbatim, any torn final line is dropped (healed), the
1031
+ * new entry is serialized to one line, and the whole file is rewritten
1032
+ * atomically. `at` defaults to the current ISO timestamp.
1033
+ */
1034
+ append(entry) {
1035
+ const record = { ...entry, at: entry.at ?? (/* @__PURE__ */ new Date()).toISOString() };
1036
+ const lines = this.completeLines();
1037
+ lines.push(JSON.stringify(record));
1038
+ this.fsx.writeAtomicFile(this.path, `${lines.join("\n")}
1039
+ `);
1040
+ }
1041
+ /**
1042
+ * Compare the journal's most recent recorded state for each target against
1043
+ * that target's ACTUAL current on-disk state, and report every divergence.
1044
+ * READ-ONLY — it never rewrites the journal, the plan, or the ledger.
1045
+ *
1046
+ * Per target that appears in the journal:
1047
+ * - if an apply entry recorded `approved: true`: the change must be archived
1048
+ * (a `changes/archive/*-<id>/` dir must exist), its `changes/<id>/` dir must
1049
+ * be gone, and its SPECS.md ledger row (when a ledger exists) must read
1050
+ * approved — each miss is a report;
1051
+ * - if NO apply was journaled but the change IS archived on disk, a manual
1052
+ * `openspec archive` ran out-of-band — a report.
1053
+ */
1054
+ reconcile(store) {
1055
+ const byTarget = /* @__PURE__ */ new Map();
1056
+ for (const entry of this.read()) {
1057
+ if (typeof entry.targetId !== "string" || entry.targetId.length === 0) continue;
1058
+ const list = byTarget.get(entry.targetId) ?? [];
1059
+ list.push(entry);
1060
+ byTarget.set(entry.targetId, list);
1061
+ }
1062
+ const reports = [];
1063
+ const ledgerMd = store.fsx.readIfExists(join4(store.dir, "..", "SPECS.md"));
1064
+ for (const [targetId, entries] of byTarget) {
1065
+ const lastApply = [...entries].reverse().find((e) => e.action === "apply");
1066
+ const journaledApproved = lastApply?.approved === true;
1067
+ const archivedDir = this.locateArchived(store, targetId);
1068
+ if (journaledApproved) {
1069
+ if (!archivedDir) {
1070
+ reports.push({
1071
+ targetId,
1072
+ expected: "archived under changes/archive/",
1073
+ actual: "no archived directory found",
1074
+ detail: `journal records approved: true for ${targetId} but no changes/archive/*-${targetId}/ directory exists`
1075
+ });
1076
+ }
1077
+ const activeDir = join4(store.dir, "changes", targetId);
1078
+ if (store.fsx.exists(activeDir) && listBodyFiles(activeDir, store.fsx).length > 0) {
1079
+ reports.push({
1080
+ targetId,
1081
+ expected: "change directory removed (archived)",
1082
+ actual: `changes/${targetId}/ still present`,
1083
+ detail: `journal records approved: true for ${targetId} but its change directory still exists under changes/, unarchived`
1084
+ });
1085
+ }
1086
+ if (ledgerMd !== void 0) {
1087
+ const status = parseLedgerStatus(ledgerMd, targetId);
1088
+ if (status === void 0 || !/^(archived|approved)\b/i.test(status)) {
1089
+ reports.push({
1090
+ targetId,
1091
+ expected: "SPECS.md row status archived",
1092
+ actual: status === void 0 ? "no SPECS.md row" : `SPECS.md row status "${status}"`,
1093
+ detail: `journal records approved: true for ${targetId} but its SPECS.md ledger row ${status === void 0 ? "is missing" : `reads "${status}"`}`
1094
+ });
1095
+ }
1096
+ }
1097
+ } else if (archivedDir) {
1098
+ reports.push({
1099
+ targetId,
1100
+ expected: "not archived (no apply recorded in journal)",
1101
+ actual: `archived on disk at ${archivedDir}`,
1102
+ detail: `${targetId} is archived on disk but the journal has no apply entry \u2014 a manual \`openspec archive\` likely ran outside the app`
1103
+ });
1104
+ }
1105
+ }
1106
+ return reports;
1107
+ }
1108
+ /**
1109
+ * The `changes/archive/YYYY-MM-DD-<targetId>/` directory (newest if several),
1110
+ * relative to the openspec root, or null when none exists.
1111
+ */
1112
+ locateArchived(store, targetId) {
1113
+ const archiveDir = join4(store.dir, "changes", "archive");
1114
+ const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(targetId)}$`);
1115
+ const names = store.fsx.readdir(archiveDir).filter((e) => e.directory && re.test(e.name)).map((e) => e.name).sort();
1116
+ const newest = names[names.length - 1];
1117
+ return newest ? join4("changes", "archive", newest) : null;
1118
+ }
1119
+ };
1120
+
350
1121
  // server/model.ts
351
1122
  var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
352
1123
  var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
@@ -373,8 +1144,8 @@ function toolUsesOf(msg) {
373
1144
  }
374
1145
 
375
1146
  // server/references.ts
376
- import { readFileSync as readFileSync2, readdirSync, realpathSync, statSync } from "node:fs";
377
- import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
1147
+ import { readFileSync as readFileSync3, readdirSync as readdirSync4, realpathSync as realpathSync2, statSync } from "node:fs";
1148
+ import { basename, isAbsolute, join as join5, normalize, relative, resolve, sep } from "node:path";
378
1149
  var REF_FILE_BYTE_LIMIT = 64 * 1024;
379
1150
  var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
380
1151
  var REF_DIR_FILE_LIMIT = 50;
@@ -400,14 +1171,14 @@ function collectEntries(root) {
400
1171
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
401
1172
  let entries;
402
1173
  try {
403
- entries = readdirSync(dir, { withFileTypes: true });
1174
+ entries = readdirSync4(dir, { withFileTypes: true });
404
1175
  } catch {
405
1176
  return;
406
1177
  }
407
1178
  for (const entry of entries) {
408
1179
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
409
1180
  if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
410
- const full = join(dir, entry.name);
1181
+ const full = join5(dir, entry.name);
411
1182
  if (entry.isDirectory()) {
412
1183
  dirs.push(relative(root, full));
413
1184
  walk(full);
@@ -429,7 +1200,7 @@ function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
429
1200
  if (root === void 0) return [];
430
1201
  let rootReal;
431
1202
  try {
432
- rootReal = realpathSync(resolve(root));
1203
+ rootReal = realpathSync2(resolve(root));
433
1204
  } catch {
434
1205
  return [];
435
1206
  }
@@ -450,7 +1221,7 @@ function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
450
1221
  ).slice(0, limit).map(({ p }) => p);
451
1222
  }
452
1223
  function readClamped(target) {
453
- const raw = readFileSync2(target);
1224
+ const raw = readFileSync3(target);
454
1225
  if (raw.byteLength <= REF_FILE_BYTE_LIMIT) {
455
1226
  return { content: raw.toString("utf8"), truncated: false };
456
1227
  }
@@ -468,7 +1239,7 @@ function expandReferences(text, root) {
468
1239
  let rootReal = null;
469
1240
  if (root !== void 0) {
470
1241
  try {
471
- rootReal = realpathSync(resolve(root));
1242
+ rootReal = realpathSync2(resolve(root));
472
1243
  } catch {
473
1244
  rootReal = null;
474
1245
  }
@@ -494,7 +1265,7 @@ function expandReferences(text, root) {
494
1265
  if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
495
1266
  let body;
496
1267
  try {
497
- body = readClamped(join(rootReal, relPath));
1268
+ body = readClamped(join5(rootReal, relPath));
498
1269
  } catch {
499
1270
  continue;
500
1271
  }
@@ -534,14 +1305,14 @@ function resolveOne(rootReal, ref) {
534
1305
  if (stats?.isDirectory()) {
535
1306
  let real;
536
1307
  try {
537
- real = realpathSync(direct);
1308
+ real = realpathSync2(direct);
538
1309
  } catch {
539
1310
  return { reason: "not readable" };
540
1311
  }
541
1312
  if (real !== rootReal && !insideRoot(rootReal, real)) {
542
1313
  return { reason: "resolves outside the working directory" };
543
1314
  }
544
- const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
1315
+ const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join5(direct, p))).sort((a, b) => {
545
1316
  const depth = a.split(sep).length - b.split(sep).length;
546
1317
  return depth !== 0 ? depth : a.localeCompare(b);
547
1318
  });
@@ -551,7 +1322,7 @@ function resolveOne(rootReal, ref) {
551
1322
  if (stats?.isFile()) {
552
1323
  let real;
553
1324
  try {
554
- real = realpathSync(direct);
1325
+ real = realpathSync2(direct);
555
1326
  } catch {
556
1327
  return { reason: "not readable" };
557
1328
  }
@@ -567,7 +1338,7 @@ function resolveOne(rootReal, ref) {
567
1338
  if (matches.length === 0) return { reason: "not found" };
568
1339
  const best = matches[0];
569
1340
  try {
570
- const body = readClamped(join(rootReal, best));
1341
+ const body = readClamped(join5(rootReal, best));
571
1342
  return {
572
1343
  path: best,
573
1344
  ...body,
@@ -603,78 +1374,6 @@ ${note}${r.content}
603
1374
  return parts.join("\n\n");
604
1375
  }
605
1376
 
606
- // server/spec-state.ts
607
- import { createHash } from "node:crypto";
608
- import { dirname as dirname2, join as join2 } from "node:path";
609
-
610
- // server/fs-node.ts
611
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2 } from "node:fs";
612
- var nodeSyncFs = {
613
- readIfExists(path) {
614
- return readIfExists(path);
615
- },
616
- writeAtomicFile(path, content) {
617
- writeAtomicFile(path, content);
618
- },
619
- exists(path) {
620
- return existsSync2(path);
621
- },
622
- mkdirp(path) {
623
- mkdirSync2(path, { recursive: true });
624
- },
625
- readdir(path) {
626
- if (!existsSync2(path)) return [];
627
- return readdirSync2(path, { withFileTypes: true }).map((entry) => ({
628
- name: entry.name,
629
- directory: entry.isDirectory()
630
- }));
631
- }
632
- };
633
-
634
- // server/spec-state.ts
635
- var sha256 = (s) => createHash("sha256").update(s).digest("hex");
636
- function listBodyFiles(changeDir, fsx = nodeSyncFs) {
637
- const files = [];
638
- for (const name of ["proposal.md", "design.md", "tasks.md"]) {
639
- if (fsx.exists(join2(changeDir, name))) files.push(name);
640
- }
641
- const specsDir = join2(changeDir, "specs");
642
- for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
643
- const rel = join2("specs", entry.name, "spec.md");
644
- if (entry.directory && fsx.exists(join2(changeDir, rel))) files.push(rel);
645
- }
646
- return files;
647
- }
648
- function bodyHash(changeDir, fsx = nodeSyncFs) {
649
- const files = listBodyFiles(changeDir, fsx);
650
- if (files.length === 0) return null;
651
- const parts = files.map(
652
- (rel) => `${rel}
653
- ${sha256(fsx.readIfExists(join2(changeDir, rel)) ?? "")}`
654
- );
655
- return `sha256:${sha256(parts.join("\n"))}`;
656
- }
657
- function specState(store, specId) {
658
- const changeDir = dirname2(store.specMetaPath(specId));
659
- const currentHash = bodyHash(changeDir, store.fsx);
660
- let meta = null;
661
- try {
662
- meta = store.readSpecMeta(specId);
663
- } catch {
664
- }
665
- const status = meta?.status ?? "draft";
666
- if (currentHash === null) return { hasSpec: false, handEdited: false, status };
667
- return {
668
- hasSpec: true,
669
- handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
670
- status
671
- };
672
- }
673
- function readBodyFiles(store, specId) {
674
- const changeDir = dirname2(store.specMetaPath(specId));
675
- return listBodyFiles(changeDir, store.fsx).map((rel) => ({ path: rel, content: store.fsx.readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0);
676
- }
677
-
678
1377
  // server/generate.ts
679
1378
  var SYSTEM = `You are a principal software architect and specification writer.
680
1379
  Given one planned spec card from a project roadmap \u2014 its title, the user stories it
@@ -713,16 +1412,16 @@ var GenerationFailed = class extends Error {
713
1412
  };
714
1413
  function resolveOpenspecBin() {
715
1414
  const require2 = createRequire(import.meta.url);
716
- let dir = dirname3(require2.resolve("@fission-ai/openspec"));
1415
+ let dir = dirname4(require2.resolve("@fission-ai/openspec"));
717
1416
  for (; ; ) {
718
- const pkgPath = join3(dir, "package.json");
719
- if (existsSync3(pkgPath)) {
720
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1417
+ const pkgPath = join6(dir, "package.json");
1418
+ if (existsSync6(pkgPath)) {
1419
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
721
1420
  if (pkg.name === "@fission-ai/openspec" && pkg.bin) {
722
- return join3(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
1421
+ return join6(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
723
1422
  }
724
1423
  }
725
- const parent = dirname3(dir);
1424
+ const parent = dirname4(dir);
726
1425
  if (parent === dir) throw new Error("could not locate the @fission-ai/openspec bin");
727
1426
  dir = parent;
728
1427
  }
@@ -733,7 +1432,7 @@ async function runOpenspecValidate(specId, rootDir) {
733
1432
  const { stdout, stderr } = await promisify(execFile)(
734
1433
  process.execPath,
735
1434
  [bin, "validate", specId, "--strict"],
736
- { cwd: dirname3(resolve2(rootDir)) }
1435
+ { cwd: dirname4(resolve2(rootDir)) }
737
1436
  );
738
1437
  return { ok: true, output: `${stdout}${stderr}`.trim() };
739
1438
  } catch (err) {
@@ -762,7 +1461,7 @@ Produce the OpenSpec change proposal for exactly this spec as structured output.
762
1461
  Use exactly "${spec.id}" as the changeName.`;
763
1462
  }
764
1463
  function renderBaseline(changeDir) {
765
- const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join3(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
1464
+ const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join6(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
766
1465
  ${f.content}`);
767
1466
  if (sections.length === 0) return "";
768
1467
  return `
@@ -777,25 +1476,32 @@ function writeBundle(changeDir, bundle) {
777
1476
  "design.md": bundle.design,
778
1477
  "tasks.md": bundle.tasks
779
1478
  };
780
- for (const s of bundle.specs) files[join3("specs", s.capability, "spec.md")] = s.spec;
1479
+ for (const s of bundle.specs) files[join6("specs", s.capability, "spec.md")] = s.spec;
781
1480
  const written = [];
782
1481
  for (const [rel, content] of Object.entries(files)) {
783
- if (readIfExists(join3(changeDir, rel)) === content) continue;
784
- writeAtomicFile(join3(changeDir, rel), content);
1482
+ if (readIfExists(join6(changeDir, rel)) === content) continue;
1483
+ writeAtomicFile(join6(changeDir, rel), content);
785
1484
  written.push(rel);
786
1485
  }
787
1486
  return written;
788
1487
  }
1488
+ var SPEC_BUNDLE_SCHEMA = z3.toJSONSchema(SpecBundle, { target: "draft-7" });
789
1489
  async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
790
1490
  const { store } = deps;
791
1491
  const plan = store.load();
792
1492
  const node = plan.nodes.find((n) => n.id === specId);
793
- if (!node || node.type !== "spec") {
1493
+ if (!node || node.type !== "proposal") {
794
1494
  throw new GenerationFailed(`unknown spec: ${specId}`, 400);
795
1495
  }
1496
+ if (specState(store, specId).approved) {
1497
+ throw new GenerationFailed(
1498
+ `${specId} is approved: its OpenSpec delta has already been folded into specs/ and archived. Generation is refused \u2014 it would recreate an already-archived change id under changes/. Create a new proposal instead.`,
1499
+ 409
1500
+ );
1501
+ }
796
1502
  const stories = storyNodes(plan, specId);
797
1503
  const depSpecs = depNodes(plan, specId);
798
- const changeDir = dirname3(store.specMetaPath(specId));
1504
+ const changeDir = dirname4(store.specMetaPath(specId));
799
1505
  store.writeSpecMeta(
800
1506
  specId,
801
1507
  stories.map((s) => s.id),
@@ -805,6 +1511,7 @@ async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
805
1511
  if (currentHash !== null) {
806
1512
  const recorded = store.readSpecMeta(specId)?.generation?.lastGeneratedBodyHash;
807
1513
  if (recorded !== currentHash) {
1514
+ journalGenerate(store, specId, "metadata-only");
808
1515
  return { specId, mode: "metadata-only", changedFiles: ["specplan.yaml"] };
809
1516
  }
810
1517
  }
@@ -824,22 +1531,59 @@ async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
824
1531
  Referenced files (resolved from @-references in the input):
825
1532
 
826
1533
  ${refContext}` : "");
827
- const msg = await parseWithModelGate(deps.anthropic, {
828
- model,
829
- max_tokens: 16e3,
830
- // non-streaming stays under the SDK HTTP timeout
831
- system,
832
- messages: [{ role: "user", content: prompt }],
833
- output_config: { format: zodOutputFormat(SpecBundle) }
834
- // No temperature/top_p or thinking config — both 400 on modern models.
835
- });
836
- if (msg.stop_reason === "refusal") {
837
- throw new GenerationFailed(`the model refused this generation`, 422);
838
- }
839
- if (!msg.parsed_output) {
840
- throw new GenerationFailed("model returned no structured output", 502);
1534
+ let output;
1535
+ if (deps.harness) {
1536
+ const profile = defaultHeadlessProfile("specplan-generate");
1537
+ let structured;
1538
+ let resultText = "";
1539
+ let resultError = false;
1540
+ for await (const ev of deps.harness.run({
1541
+ prompt: `${system}
1542
+
1543
+ ${prompt}`,
1544
+ cwd: deps.refRoot ?? process.cwd(),
1545
+ session: specId,
1546
+ profile,
1547
+ outputSchema: SPEC_BUNDLE_SCHEMA
1548
+ })) {
1549
+ if (ev.type === "result") {
1550
+ structured = ev.structuredOutput;
1551
+ resultText = ev.text;
1552
+ resultError = ev.isError;
1553
+ }
1554
+ }
1555
+ if (structured === void 0) {
1556
+ throw new GenerationFailed(
1557
+ resultError ? resultText || "the agent run failed" : "model returned no structured output",
1558
+ 502
1559
+ );
1560
+ }
1561
+ const parsed = SpecBundle.safeParse(structured);
1562
+ if (!parsed.success) {
1563
+ throw new GenerationFailed(
1564
+ `model returned structured output that does not match the SpecBundle schema: ${parsed.error.message}`,
1565
+ 502
1566
+ );
1567
+ }
1568
+ output = parsed.data;
1569
+ } else {
1570
+ const msg = await parseWithModelGate(deps.anthropic, {
1571
+ model,
1572
+ max_tokens: 16e3,
1573
+ // non-streaming stays under the SDK HTTP timeout
1574
+ system,
1575
+ messages: [{ role: "user", content: prompt }],
1576
+ output_config: { format: zodOutputFormat(SpecBundle) }
1577
+ // No temperature/top_p or thinking config — both 400 on modern models.
1578
+ });
1579
+ if (msg.stop_reason === "refusal") {
1580
+ throw new GenerationFailed(`the model refused this generation`, 422);
1581
+ }
1582
+ if (!msg.parsed_output) {
1583
+ throw new GenerationFailed("model returned no structured output", 502);
1584
+ }
1585
+ output = SpecBundle.parse(msg.parsed_output);
841
1586
  }
842
- const output = SpecBundle.parse(msg.parsed_output);
843
1587
  const bundle = { ...output, changeName: specId };
844
1588
  const changedFiles = writeBundle(changeDir, bundle);
845
1589
  store.writeGeneration(specId, bodyHash(changeDir) ?? "", (/* @__PURE__ */ new Date()).toISOString());
@@ -851,23 +1595,214 @@ ${verdict.output}`,
851
1595
  422
852
1596
  );
853
1597
  }
1598
+ journalGenerate(store, specId, "generated");
854
1599
  return { specId, mode: "generated", changedFiles };
855
1600
  }
1601
+ function journalGenerate(store, specId, mode) {
1602
+ new PlanJournal(store.fsx, store.dir).append({
1603
+ action: "generate",
1604
+ targetId: specId,
1605
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1606
+ mode
1607
+ });
1608
+ }
856
1609
  function storyNodes(plan, specId) {
857
1610
  const byId = new Map(plan.nodes.map((n) => [n.id, n]));
858
1611
  return containedStories(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "user_story");
859
1612
  }
860
1613
  function depNodes(plan, specId) {
861
1614
  const byId = new Map(plan.nodes.map((n) => [n.id, n]));
862
- return specDependencies(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "spec");
1615
+ return specDependencies(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "proposal");
863
1616
  }
864
1617
 
1618
+ // server/apply.ts
1619
+ import { materializeDeltaArtifact } from "@coopcli/shared/materialize-delta";
1620
+ var ApplyFailed = class extends Error {
1621
+ constructor(message, status) {
1622
+ super(message);
1623
+ this.status = status;
1624
+ }
1625
+ status;
1626
+ };
1627
+ async function runOpenspecArchive(specId, rootDir) {
1628
+ const bin = resolveOpenspecBin();
1629
+ try {
1630
+ const { stdout, stderr } = await promisify2(execFile2)(
1631
+ process.execPath,
1632
+ [bin, "archive", specId, "--yes"],
1633
+ { cwd: dirname5(resolve3(rootDir)) }
1634
+ );
1635
+ return { ok: true, output: `${stdout}${stderr}`.trim() };
1636
+ } catch (err) {
1637
+ const e = err;
1638
+ return {
1639
+ ok: false,
1640
+ output: `${e.stdout ?? ""}${e.stderr ?? ""}`.trim() || (e.message ?? String(err))
1641
+ };
1642
+ }
1643
+ }
1644
+ async function applyForSpec(deps, specId, opts = {}) {
1645
+ const { store } = deps;
1646
+ const runValidate = deps.runValidate ?? runOpenspecValidate;
1647
+ const runArchive = deps.runArchive ?? runOpenspecArchive;
1648
+ const node = store.load().nodes.find((n) => n.id === specId);
1649
+ if (!node || node.type !== "proposal") {
1650
+ throw new ApplyFailed(`unknown spec: ${specId}`, 400);
1651
+ }
1652
+ const state = specState(store, specId);
1653
+ if (state.approved === true) {
1654
+ throw new ApplyFailed(
1655
+ `${specId} is already approved (folded and archived) \u2014 apply is a one-way transition`,
1656
+ 409
1657
+ );
1658
+ }
1659
+ const verdict = await runValidate(specId, store.dir);
1660
+ if (!verdict.ok) {
1661
+ throw new ApplyFailed(
1662
+ `\`openspec validate ${specId} --strict\` failed:
1663
+ ${verdict.output}`,
1664
+ 422
1665
+ );
1666
+ }
1667
+ if (state.handEdited && !opts.confirmHandEdit) {
1668
+ throw new ApplyFailed(
1669
+ `${specId}'s body was hand-edited since it was last generated \u2014 re-run with --confirm-hand-edit to fold it anyway`,
1670
+ 409
1671
+ );
1672
+ }
1673
+ const fingerprint = bodyHash(dirname5(store.specMetaPath(specId)), store.fsx);
1674
+ const archived = await runArchive(specId, store.dir);
1675
+ if (!archived.ok) {
1676
+ throw new ApplyFailed(`\`openspec archive ${specId}\` failed:
1677
+ ${archived.output}`, 502);
1678
+ }
1679
+ const archivedRelDir = locateArchivedDir(store, specId);
1680
+ if (!archivedRelDir) {
1681
+ throw new ApplyFailed(
1682
+ `\`openspec archive ${specId}\` reported success but its archived directory could not be located under changes/archive/`,
1683
+ 502
1684
+ );
1685
+ }
1686
+ store.markApplied(specId, archivedRelDir, {
1687
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1688
+ source: archivedRelDir
1689
+ });
1690
+ const newCapabilities = store.adoptExistingCapabilities().map((n) => n.id);
1691
+ const ledgerRow = maintainLedgerRow(store, specId, archivedRelDir);
1692
+ new PlanJournal(store.fsx, store.dir).append({
1693
+ action: "apply",
1694
+ targetId: specId,
1695
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1696
+ approved: true,
1697
+ fingerprint
1698
+ });
1699
+ return { specId, approved: true, archivedTo: archivedRelDir, newCapabilities, ledgerRow };
1700
+ }
1701
+ function escapeRegExp2(s) {
1702
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1703
+ }
1704
+ function locateArchivedDir(store, specId) {
1705
+ const archiveDir = join7(store.dir, "changes", "archive");
1706
+ const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp2(specId)}$`);
1707
+ const names = store.fsx.readdir(archiveDir).filter((e) => e.directory && re.test(e.name)).map((e) => e.name).sort();
1708
+ const newest = names[names.length - 1];
1709
+ return newest ? join7("changes", "archive", newest) : null;
1710
+ }
1711
+ var ARCHIVED_LEDGER_STATUS = "archived";
1712
+ function cellsOf(line) {
1713
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
1714
+ }
1715
+ function replaceStatusCell(rawLine, statusIdx, next) {
1716
+ const parts = rawLine.split("|");
1717
+ const idx = statusIdx + 1;
1718
+ const seg = parts[idx];
1719
+ if (seg === void 0) return rawLine;
1720
+ const leading = seg.match(/^\s*/)?.[0] ?? " ";
1721
+ const trailing = seg.match(/\s*$/)?.[0] ?? " ";
1722
+ parts[idx] = `${leading}${next}${trailing}`;
1723
+ return parts.join("|");
1724
+ }
1725
+ function deriveDomains(store, archivedRelDir) {
1726
+ const specsDir = join7(store.dir, archivedRelDir, "specs");
1727
+ const caps = store.fsx.readdir(specsDir).filter((e) => e.directory).map((e) => e.name).sort((a, b) => a.localeCompare(b));
1728
+ return caps.length > 0 ? caps.join(", ") : "(unknown)";
1729
+ }
1730
+ function activeTableInsertIndex(lines) {
1731
+ let sawActive = false;
1732
+ for (let i = 0; i < lines.length; i++) {
1733
+ const trimmed = lines[i].trim();
1734
+ if (/^##\s+active changes/i.test(trimmed)) {
1735
+ sawActive = true;
1736
+ continue;
1737
+ }
1738
+ if (!sawActive) continue;
1739
+ if (trimmed.startsWith("|")) {
1740
+ const cells = cellsOf(lines[i]);
1741
+ if (cells.every((c) => /^:?-+:?$/.test(c))) return i + 1;
1742
+ }
1743
+ }
1744
+ return null;
1745
+ }
1746
+ function freshLedger(row) {
1747
+ return `# Spec Ledger
1748
+
1749
+ ## Active changes
1750
+
1751
+ | Change | Domains | Status | Notes |
1752
+ |---|---|---|---|
1753
+ ${row}
1754
+ `;
1755
+ }
1756
+ function maintainLedgerRow(store, specId, archivedRelDir) {
1757
+ const ledgerPath = join7(store.dir, "..", "SPECS.md");
1758
+ const md = store.fsx.readIfExists(ledgerPath);
1759
+ const archivedBase = archivedRelDir.split("/").pop() ?? archivedRelDir;
1760
+ const token = `\`${specId}\``;
1761
+ if (md !== void 0) {
1762
+ const lines = md.split("\n");
1763
+ let statusIdx = 2;
1764
+ for (let i = 0; i < lines.length; i++) {
1765
+ const line = lines[i];
1766
+ if (!line.trim().startsWith("|")) continue;
1767
+ const cells = cellsOf(line);
1768
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
1769
+ const lower = cells.map((c) => c.toLowerCase());
1770
+ if (lower.includes("status") && lower.includes("change")) {
1771
+ statusIdx = lower.indexOf("status");
1772
+ continue;
1773
+ }
1774
+ if ((cells[0] ?? "").includes(token)) {
1775
+ lines[i] = replaceStatusCell(line, statusIdx, ARCHIVED_LEDGER_STATUS);
1776
+ store.fsx.writeAtomicFile(ledgerPath, lines.join("\n"));
1777
+ return "updated";
1778
+ }
1779
+ }
1780
+ const row2 = `| ${token} (archived \`${archivedBase}\`) | ${deriveDomains(store, archivedRelDir)} | ${ARCHIVED_LEDGER_STATUS} | (auto-created by specplan apply) |`;
1781
+ const insertAt = activeTableInsertIndex(lines);
1782
+ if (insertAt !== null) {
1783
+ lines.splice(insertAt, 0, row2);
1784
+ store.fsx.writeAtomicFile(ledgerPath, lines.join("\n"));
1785
+ return "created";
1786
+ }
1787
+ store.fsx.writeAtomicFile(ledgerPath, `${md.replace(/\n*$/, "")}
1788
+
1789
+ ${freshLedger(row2)}`);
1790
+ return "created";
1791
+ }
1792
+ const row = `| ${token} (archived \`${archivedBase}\`) | ${deriveDomains(store, archivedRelDir)} | ${ARCHIVED_LEDGER_STATUS} | (auto-created by specplan apply) |`;
1793
+ store.fsx.writeAtomicFile(ledgerPath, freshLedger(row));
1794
+ return "created";
1795
+ }
1796
+
1797
+ // server/app.ts
1798
+ import { Hono } from "hono";
1799
+
865
1800
  // server/plan-routes.ts
866
- import { basename as basename3 } from "node:path";
1801
+ import { basename as basename3, join as join9 } from "node:path";
867
1802
 
868
1803
  // server/store.ts
869
- import { basename as basename2, join as join4, resolve as resolve3 } from "node:path";
870
- import { Document, parseDocument } from "yaml";
1804
+ import { basename as basename2, join as join8, resolve as resolve4 } from "node:path";
1805
+ import { Document as Document2, isMap, isScalar, isSeq, parseDocument as parseDocument2 } from "yaml";
871
1806
 
872
1807
  // server/ulid.ts
873
1808
  var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -929,30 +1864,132 @@ var PlanFileError = class extends Error {
929
1864
  };
930
1865
  var ROOT_FILE = "specplan.yaml";
931
1866
  var META_FILE = "specplan.yaml";
1867
+ var CURRENT_PLAN_VERSION = 3;
932
1868
  var PlanStore = class {
933
1869
  /** Resolved openspec root directory. */
934
1870
  dir;
935
1871
  /** IO substrate — real disk by default, in-memory for the cloud target. */
936
1872
  fsx;
937
1873
  constructor(dir, fsx = nodeSyncFs) {
938
- this.dir = resolve3(dir);
1874
+ this.dir = resolve4(dir);
939
1875
  this.fsx = fsx;
940
1876
  this.fsx.mkdirp(this.dir);
941
1877
  }
942
- // ── Root file ────────────────────────────────────────────────────────────
943
- rootPath() {
944
- return join4(this.dir, ROOT_FILE);
1878
+ // ── Root file ────────────────────────────────────────────────────────────
1879
+ rootPath() {
1880
+ return join8(this.dir, ROOT_FILE);
1881
+ }
1882
+ loadDoc() {
1883
+ const raw = this.fsx.readIfExists(this.rootPath());
1884
+ if (raw === void 0) {
1885
+ return new Document2({ version: CURRENT_PLAN_VERSION, nodes: [], edges: [] });
1886
+ }
1887
+ const doc = parseDocument2(raw);
1888
+ const fault = doc.errors[0];
1889
+ if (fault) throw new PlanFileError(`${ROOT_FILE}: ${fault.message}`);
1890
+ if (doc.getIn(["nodes"]) === void 0) doc.setIn(["nodes"], doc.createNode([]));
1891
+ if (doc.getIn(["edges"]) === void 0) doc.setIn(["edges"], doc.createNode([]));
1892
+ this.migrate(doc);
1893
+ return doc;
1894
+ }
1895
+ /**
1896
+ * The ordered forward-migration chain: `migrations[n]` transforms a
1897
+ * document that is AT version n into the version-`n+1` SHAPE (the runner
1898
+ * stamps the version after each step). Steps operate on the yaml Document —
1899
+ * never a `toJS()` projection — so hand-written comments survive
1900
+ * byte-for-byte, and each runs only while the file is below
1901
+ * `CURRENT_PLAN_VERSION`, so re-reading a migrated file is a no-op. The
1902
+ * whole run is persisted in ONE atomic write (see `migrate`).
1903
+ */
1904
+ migrations = {
1905
+ // v1 → v2: the split node model (see migrateV1toV2).
1906
+ 1: (doc) => this.migrateV1toV2(doc),
1907
+ // v2 → v3 (positions-drop): free pixel positions are no longer
1908
+ // persisted in the plan file — a card's durable placement is its grid
1909
+ // slot (spec-row membership + `order`), and any residual sidecar pixel
1910
+ // positions are ignored on read + stripped at the workspace boundary.
1911
+ // Nothing in specplan.yaml changes; this step is a pure version stamp.
1912
+ 2: () => {
1913
+ }
1914
+ };
1915
+ /**
1916
+ * Run the forward-migration chain in place, then persist once. The declared
1917
+ * version is read from the doc (a hand skeleton without one is treated as
1918
+ * v1). A version ABOVE `CURRENT_PLAN_VERSION` is refused BEFORE any write —
1919
+ * an older build must never silently downgrade or corrupt a plan a newer
1920
+ * build authored. A version AT current is a no-op (the file is not
1921
+ * rewritten). Otherwise each `v → v+1` step runs in sequence, the version
1922
+ * is stamped after each, and the result is written in a single atomic save
1923
+ * so a throw mid-chain can never leave a half-migrated file behind.
1924
+ */
1925
+ migrate(doc) {
1926
+ const declared = doc.getIn(["version"]);
1927
+ let version = typeof declared === "number" ? declared : 1;
1928
+ if (version > CURRENT_PLAN_VERSION) {
1929
+ throw new PlanFileError(
1930
+ `${ROOT_FILE}: plan file is version ${version}, but this specplan understands version ${CURRENT_PLAN_VERSION} at most \u2014 upgrade specplan; the file was not modified`
1931
+ );
1932
+ }
1933
+ if (version === CURRENT_PLAN_VERSION) return;
1934
+ while (version < CURRENT_PLAN_VERSION) {
1935
+ this.migrations[version]?.(doc);
1936
+ version += 1;
1937
+ doc.setIn(["version"], version);
1938
+ }
1939
+ this.save(doc);
1940
+ }
1941
+ /**
1942
+ * The v1 → v2 forward step (split node model). Operates on the yaml
1943
+ * Document — never a `toJS()` projection — so hand-written comments survive
1944
+ * byte-for-byte. Per node: `type: spec` → `type: proposal` with every other
1945
+ * field untouched; a proposal whose `specFile` sits under `changes/archive/`
1946
+ * is stamped `approved: true` in its metadata file (fold-driven axis — an
1947
+ * archived change IS a folded/approved one). Per edge: `type: dependency` →
1948
+ * `type: stacked` (the ratified vocabulary rename; endpoints never move).
1949
+ * `user_story` nodes and all edge endpoints are untouched. The chain owns
1950
+ * the version stamp; this step performs only the v1→v2 content substitutions.
1951
+ */
1952
+ migrateV1toV2(doc) {
1953
+ const nodes = doc.getIn(["nodes"]);
1954
+ if (isSeq(nodes)) {
1955
+ for (const node of nodes.items) {
1956
+ if (!isMap(node)) continue;
1957
+ const type = node.get("type", true);
1958
+ if (!isScalar(type) || type.value !== "spec") continue;
1959
+ type.value = "proposal";
1960
+ const specFile = node.get("specFile");
1961
+ if (typeof specFile === "string" && specFile.includes("changes/archive/")) {
1962
+ this.stampApprovedAt(String(node.get("id") ?? ""), specFile);
1963
+ }
1964
+ }
1965
+ }
1966
+ const edges = doc.getIn(["edges"]);
1967
+ if (isSeq(edges)) {
1968
+ for (const edge of edges.items) {
1969
+ if (!isMap(edge)) continue;
1970
+ const type = edge.get("type", true);
1971
+ if (isScalar(type) && type.value === "dependency") type.value = "stacked";
1972
+ }
1973
+ }
945
1974
  }
946
- loadDoc() {
947
- const raw = this.fsx.readIfExists(this.rootPath());
948
- if (raw === void 0) return new Document({ version: 1, nodes: [], edges: [] });
949
- const doc = parseDocument(raw);
950
- const fault = doc.errors[0];
951
- if (fault) throw new PlanFileError(`${ROOT_FILE}: ${fault.message}`);
952
- if (doc.getIn(["version"]) === void 0) doc.setIn(["version"], 1);
953
- if (doc.getIn(["nodes"]) === void 0) doc.setIn(["nodes"], doc.createNode([]));
954
- if (doc.getIn(["edges"]) === void 0) doc.setIn(["edges"], doc.createNode([]));
955
- return doc;
1975
+ /**
1976
+ * Migration helper: stamp `approved: true` in the metadata file the node's
1977
+ * `specFile` names (archived changes live under changes/archive/, NOT at
1978
+ * specMetaPath). Preserves comments by editing the existing document;
1979
+ * creates a minimal one when absent; never rewrites an unparseable file.
1980
+ */
1981
+ stampApprovedAt(specId, specFile) {
1982
+ const prefix = `${basename2(this.dir)}/`;
1983
+ const rel = specFile.startsWith(prefix) ? specFile.slice(prefix.length) : specFile;
1984
+ try {
1985
+ const path = join8(this.dir, rel);
1986
+ const raw = this.fsx.readIfExists(path);
1987
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
1988
+ if (doc.errors.length > 0) return;
1989
+ doc.setIn(["approved"], true);
1990
+ this.fsx.writeAtomicFile(path, doc.toString());
1991
+ } catch {
1992
+ }
956
1993
  }
957
1994
  planOf(doc) {
958
1995
  const parsed = Plan.safeParse(doc.toJS());
@@ -981,7 +2018,7 @@ var PlanStore = class {
981
2018
  return orders.length === 0 ? 0 : Math.max(...orders) + 1;
982
2019
  }
983
2020
  // ── Mutations (canvas routes and chat tools share these) ─────────────────
984
- createSpec(title, description) {
2021
+ createProposal(title, description) {
985
2022
  const trimmed = title.trim();
986
2023
  if (!trimmed) throw new DagError("spec title must not be empty");
987
2024
  const doc = this.loadDoc();
@@ -989,11 +2026,11 @@ var PlanStore = class {
989
2026
  const id = this.mintId("spec", new Set(plan.nodes.map((n) => n.id)));
990
2027
  const node = {
991
2028
  id,
992
- type: "spec",
2029
+ type: "proposal",
993
2030
  title: trimmed,
994
2031
  ...description?.trim() ? { description: description.trim() } : {},
995
2032
  specFile: `${basename2(this.dir)}/changes/${id}/${META_FILE}`,
996
- order: this.nextOrder(plan, "spec")
2033
+ order: this.nextOrder(plan, "proposal")
997
2034
  };
998
2035
  doc.addIn(["nodes"], node);
999
2036
  this.save(doc);
@@ -1004,7 +2041,7 @@ var PlanStore = class {
1004
2041
  initSpecMeta(specId) {
1005
2042
  this.fsx.writeAtomicFile(
1006
2043
  this.specMetaPath(specId),
1007
- new Document({
2044
+ new Document2({
1008
2045
  specId,
1009
2046
  userStories: [],
1010
2047
  dependencies: [],
@@ -1029,15 +2066,20 @@ var PlanStore = class {
1029
2066
  this.save(doc);
1030
2067
  return node;
1031
2068
  }
1032
- /** Add an edge; the type is derived (spec→spec dependency, spec→story containment). */
2069
+ /**
2070
+ * Add an edge; the type is derived (proposal→proposal stacked,
2071
+ * proposal→story containment, capability→proposal targets).
2072
+ */
1033
2073
  link(from, to) {
1034
2074
  const doc = this.loadDoc();
1035
2075
  const plan = this.planOf(doc);
1036
2076
  const edge = deriveEdge(plan, from, to);
1037
2077
  doc.addIn(["edges"], edge);
1038
2078
  this.save(doc);
1039
- const next = { ...plan, edges: [...plan.edges, edge] };
1040
- this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2079
+ if (plan.nodes.find((n) => n.id === from)?.type === "proposal") {
2080
+ const next = { ...plan, edges: [...plan.edges, edge] };
2081
+ this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2082
+ }
1041
2083
  return edge;
1042
2084
  }
1043
2085
  /**
@@ -1053,10 +2095,50 @@ var PlanStore = class {
1053
2095
  if (!edge) throw new DagError(`no link exists: ${from} -> ${to}`);
1054
2096
  doc.deleteIn(["edges", idx]);
1055
2097
  this.save(doc);
1056
- const next = { ...plan, edges: plan.edges.filter((_, i) => i !== idx) };
1057
- this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2098
+ if (plan.nodes.find((n) => n.id === from)?.type === "proposal") {
2099
+ const next = { ...plan, edges: plan.edges.filter((_, i) => i !== idx) };
2100
+ this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2101
+ }
1058
2102
  return edge;
1059
2103
  }
2104
+ /**
2105
+ * Drop a proposal node and every edge touching it — the counterpart to
2106
+ * `adoptExistingChanges`, which adds a node for every change directory and
2107
+ * (until this existed) never removed one.
2108
+ *
2109
+ * Deleting a change without this leaves a node whose `specFile` points at a
2110
+ * directory that is gone: a dangling reference on the canvas, which is the
2111
+ * SAME failure as the stale roadmap row this change exists to fix. Observed
2112
+ * live — deleting a scratch change left its node behind in `specplan.yaml`.
2113
+ *
2114
+ * Returns false when no such node exists, which is not an error: a change can
2115
+ * be deleted before the plan ever adopted it.
2116
+ */
2117
+ removeProposal(nodeId) {
2118
+ const doc = this.loadDoc();
2119
+ const plan = this.planOf(doc);
2120
+ const idx = plan.nodes.findIndex((n) => n.id === nodeId);
2121
+ if (idx < 0) return false;
2122
+ const doomed = plan.edges.map((edge, i) => ({ edge, i })).filter(({ edge }) => edge.from === nodeId || edge.to === nodeId).map(({ i }) => i).reverse();
2123
+ for (const i of doomed) doc.deleteIn(["edges", i]);
2124
+ doc.deleteIn(["nodes", idx]);
2125
+ this.save(doc);
2126
+ const next = {
2127
+ ...plan,
2128
+ nodes: plan.nodes.filter((_, i) => i !== idx),
2129
+ edges: plan.edges.filter((e) => e.from !== nodeId && e.to !== nodeId)
2130
+ };
2131
+ for (const source of new Set(
2132
+ plan.edges.filter((e) => e.to === nodeId).map((e) => e.from)
2133
+ )) {
2134
+ if (next.nodes.find((n) => n.id === source)?.type !== "proposal") continue;
2135
+ try {
2136
+ this.writeSpecMeta(source, containedStories(next, source), specDependencies(next, source));
2137
+ } catch {
2138
+ }
2139
+ }
2140
+ return true;
2141
+ }
1060
2142
  /** Update one node's sibling `order` hint. Never touches edges. */
1061
2143
  reorder(nodeId, order) {
1062
2144
  const doc = this.loadDoc();
@@ -1068,22 +2150,42 @@ var PlanStore = class {
1068
2150
  this.save(doc);
1069
2151
  return { ...node, order };
1070
2152
  }
2153
+ /**
2154
+ * Reflow a whole row (snap-grid drag-to-place): assign each node in
2155
+ * `orderedIds` its column rank as the new `order`, in ONE atomic write — the
2156
+ * insert-and-reflow of a drop must never be N separate `reorder` calls.
2157
+ * Surgical `setIn` per node so hand-written comments survive. Never touches
2158
+ * edges. Rejects an unknown id BEFORE any write (the file is left untouched).
2159
+ */
2160
+ arrangeRow(orderedIds) {
2161
+ const doc = this.loadDoc();
2162
+ const plan = this.planOf(doc);
2163
+ const indexById = new Map(plan.nodes.map((n, i) => [n.id, i]));
2164
+ for (const id of orderedIds) {
2165
+ if (!indexById.has(id)) throw new DagError(`unknown node: ${id}`);
2166
+ }
2167
+ orderedIds.forEach((id, order) => {
2168
+ doc.setIn(["nodes", indexById.get(id), "order"], order);
2169
+ });
2170
+ this.save(doc);
2171
+ return orderedIds.map((id, order) => ({ ...plan.nodes[indexById.get(id)], order }));
2172
+ }
1071
2173
  /**
1072
2174
  * Adopt pre-existing OpenSpec change directories (layered-storage:
1073
2175
  * "Landing existing changes on the canvas"): every directory under
1074
2176
  * <root>/changes/ — except `archive` — that is not yet a node becomes a
1075
- * spec node whose id IS the change-dir basename (the D5 rule), with its
1076
- * per-spec metadata file initialized when missing. Idempotent; runs on
1077
- * every session load so a change dir created mid-session lands on the
2177
+ * proposal node whose id IS the change-dir basename (the D5 rule), with
2178
+ * its per-spec metadata file initialized when missing. Idempotent; runs
2179
+ * on every session load so a change dir created mid-session lands on the
1078
2180
  * next refresh. Never touches a change's body files.
1079
2181
  */
1080
2182
  adoptExistingChanges() {
1081
- const changesDir = join4(this.dir, "changes");
2183
+ const changesDir = join8(this.dir, "changes");
1082
2184
  if (!this.fsx.exists(changesDir)) return [];
1083
2185
  const doc = this.loadDoc();
1084
2186
  const plan = this.planOf(doc);
1085
2187
  const known = new Set(plan.nodes.map((n) => n.id));
1086
- let order = this.nextOrder(plan, "spec");
2188
+ let order = this.nextOrder(plan, "proposal");
1087
2189
  const adopted = [];
1088
2190
  for (const entry of this.fsx.readdir(changesDir).sort((a, b) => a.name.localeCompare(b.name))) {
1089
2191
  if (!entry.directory || entry.name === "archive" || entry.name.startsWith(".")) {
@@ -1092,7 +2194,7 @@ var PlanStore = class {
1092
2194
  if (known.has(entry.name)) continue;
1093
2195
  const node = {
1094
2196
  id: entry.name,
1095
- type: "spec",
2197
+ type: "proposal",
1096
2198
  title: entry.name,
1097
2199
  specFile: `${basename2(this.dir)}/changes/${entry.name}/${META_FILE}`,
1098
2200
  order: order++
@@ -1110,17 +2212,113 @@ var PlanStore = class {
1110
2212
  }
1111
2213
  return adopted;
1112
2214
  }
2215
+ /**
2216
+ * Adopt current-truth capability directories as read-only capability
2217
+ * roots (sibling of adoptExistingChanges): every directory under
2218
+ * <root>/specs/ that is not yet a node becomes a `capability` node whose
2219
+ * id IS the capability-dir basename. Additive and idempotent — a
2220
+ * capability already present is skipped — and it NEVER writes under
2221
+ * specs/ (capabilities are projected, not authored: no metadata file, no
2222
+ * spec.md rewrite, ever).
2223
+ */
2224
+ adoptExistingCapabilities() {
2225
+ const specsDir = join8(this.dir, "specs");
2226
+ if (!this.fsx.exists(specsDir)) return [];
2227
+ const doc = this.loadDoc();
2228
+ const plan = this.planOf(doc);
2229
+ const known = new Set(plan.nodes.map((n) => n.id));
2230
+ let order = this.nextOrder(plan, "capability");
2231
+ const adopted = [];
2232
+ for (const entry of this.fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
2233
+ if (!entry.directory || entry.name.startsWith(".")) continue;
2234
+ if (known.has(entry.name)) continue;
2235
+ const node = {
2236
+ id: entry.name,
2237
+ type: "capability",
2238
+ title: entry.name,
2239
+ order: order++,
2240
+ specPath: `${basename2(this.dir)}/specs/${entry.name}/spec.md`
2241
+ };
2242
+ doc.addIn(["nodes"], node);
2243
+ adopted.push(node);
2244
+ }
2245
+ if (adopted.length === 0) return [];
2246
+ this.save(doc);
2247
+ return adopted;
2248
+ }
1113
2249
  // ── Per-spec metadata files ──────────────────────────────────────────────
2250
+ /**
2251
+ * The proposal node's `specFile` (undefined for a non-proposal / unknown id).
2252
+ * Read defensively — a corrupt root never propagates out of a path lookup.
2253
+ */
2254
+ specFileOf(specId) {
2255
+ try {
2256
+ const node = this.load().nodes.find((n) => n.id === specId);
2257
+ return node?.type === "proposal" ? node.specFile : void 0;
2258
+ } catch {
2259
+ return void 0;
2260
+ }
2261
+ }
2262
+ /**
2263
+ * The metadata-file path for a spec's change. Normally
2264
+ * `changes/<specId>/specplan.yaml`, BUT once a proposal is folded/archived
2265
+ * its node's `specFile` points under `changes/archive/YYYY-MM-DD-<id>/`
2266
+ * (markApplied / the v1→v2 migration): this follows that pointer so every
2267
+ * reader (`specState`, `readSpecMeta`, the body/diagram viewers) and the
2268
+ * state writers land on the ARCHIVED sidecar — never resurrecting a stray
2269
+ * `changes/<specId>/` dir for an already-archived change.
2270
+ */
1114
2271
  specMetaPath(specId) {
1115
- return join4(this.dir, "changes", specId, META_FILE);
2272
+ const specFile = this.specFileOf(specId);
2273
+ if (specFile?.includes("changes/archive/")) {
2274
+ const prefix = `${basename2(this.dir)}/`;
2275
+ const rel = specFile.startsWith(prefix) ? specFile.slice(prefix.length) : specFile;
2276
+ return join8(this.dir, rel);
2277
+ }
2278
+ return join8(this.dir, "changes", specId, META_FILE);
2279
+ }
2280
+ /**
2281
+ * Record a completed fold (specplan-timeline-apply, after `openspec archive`
2282
+ * has already moved the change dir). In ONE pass: (a) repoint the proposal
2283
+ * node's `specFile` in the root doc to the archived sidecar (surgical
2284
+ * `setIn`, comment-preserving), and (b) write `approved: true` + the minimal
2285
+ * implementation-task record into the sidecar AT THE ARCHIVED path (via the
2286
+ * shared comment-preserving writer). Deliberately NOT `setApproved`: that
2287
+ * writer targets `changes/<specId>/specplan.yaml`, which the archive just
2288
+ * moved — calling it would recreate a stray active change dir (the exact
2289
+ * resurrection hazard the v1→v2 `stampApprovedAt` precedent avoids).
2290
+ *
2291
+ * `archivedRelDir` is the archive directory relative to the openspec root,
2292
+ * e.g. `changes/archive/2026-07-27-<specId>`.
2293
+ */
2294
+ markApplied(specId, archivedRelDir, task) {
2295
+ const newSpecFile = `${basename2(this.dir)}/${archivedRelDir}/${META_FILE}`;
2296
+ const doc = this.loadDoc();
2297
+ const nodes = doc.getIn(["nodes"]);
2298
+ if (isSeq(nodes)) {
2299
+ for (let i = 0; i < nodes.items.length; i++) {
2300
+ const node = nodes.items[i];
2301
+ if (!isMap(node)) continue;
2302
+ if (String(node.get("id") ?? "") === specId) {
2303
+ doc.setIn(["nodes", i, "specFile"], newSpecFile);
2304
+ break;
2305
+ }
2306
+ }
2307
+ }
2308
+ this.save(doc);
2309
+ writeChangeMeta(this.fsx, join8(this.dir, archivedRelDir), {
2310
+ specId,
2311
+ approved: true,
2312
+ task
2313
+ });
1116
2314
  }
1117
2315
  readSpecMeta(specId) {
1118
2316
  const raw = this.fsx.readIfExists(this.specMetaPath(specId));
1119
2317
  if (raw === void 0) return null;
1120
- const doc = parseDocument(raw);
2318
+ const doc = parseDocument2(raw);
1121
2319
  const fault = doc.errors[0];
1122
2320
  if (fault) throw new PlanFileError(`changes/${specId}/${META_FILE}: ${fault.message}`);
1123
- const parsed = SpecMeta.safeParse(doc.toJS());
2321
+ const parsed = SpecMetaSchema.safeParse(doc.toJS());
1124
2322
  if (!parsed.success) {
1125
2323
  const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1126
2324
  throw new PlanFileError(`changes/${specId}/${META_FILE}: ${issues}`);
@@ -1128,37 +2326,75 @@ var PlanStore = class {
1128
2326
  return parsed.data;
1129
2327
  }
1130
2328
  /**
1131
- * Write a spec's metadata file, preserving everything this write does not
1132
- * own (comments, the generation block) by editing the existing document.
2329
+ * Write a proposal's metadata file, preserving everything this write does
2330
+ * not own (comments, the generation block, status, approved, implemented) by editing
2331
+ * the existing document. Proposal-only by contract — capability nodes
2332
+ * have no metadata file and callers must never route one here.
1133
2333
  */
1134
2334
  writeSpecMeta(specId, userStories, dependencies) {
1135
2335
  const path = this.specMetaPath(specId);
1136
2336
  const raw = this.fsx.readIfExists(path);
1137
- const doc = raw === void 0 ? new Document({}) : parseDocument(raw);
2337
+ const doc = raw === void 0 ? new Document2({}) : parseDocument2(raw);
1138
2338
  doc.setIn(["specId"], specId);
1139
2339
  doc.setIn(["userStories"], userStories);
1140
2340
  doc.setIn(["dependencies"], dependencies);
1141
2341
  this.fsx.writeAtomicFile(path, doc.toString());
1142
2342
  }
1143
2343
  /**
1144
- * Set a spec's lifecycle status (draft | active | done | deferred) in its
1145
- * per-spec metadata file. User-owned, free transitions; everything else
1146
- * in the file (stories, dependencies, generation, comments) is preserved.
2344
+ * Set a proposal's WORKING state (draft | in-progress) in its per-spec
2345
+ * metadata file. User-owned; everything else in the file (stories,
2346
+ * dependencies, generation, comments and the separate `approved` and
2347
+ * `implemented` axes) is preserved: the working-state writer NEVER sets
2348
+ * `approved`. Proposal-only: a capability has no metadata file.
1147
2349
  */
1148
2350
  setStatus(specId, status) {
1149
2351
  const node = this.load().nodes.find((n) => n.id === specId);
1150
- if (!node || node.type !== "spec") throw new DagError(`unknown spec: ${specId}`);
2352
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
1151
2353
  const path = this.specMetaPath(specId);
1152
2354
  const raw = this.fsx.readIfExists(path);
1153
- const doc = raw === void 0 ? new Document({ specId, userStories: [], dependencies: [] }) : parseDocument(raw);
2355
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
1154
2356
  doc.setIn(["status"], status);
1155
2357
  this.fsx.writeAtomicFile(path, doc.toString());
1156
2358
  }
2359
+ /**
2360
+ * Set a proposal's approved/fold state (formerly `setApplied`) — its OWN
2361
+ * axis, deliberately distinct from `setStatus` (the working states) and
2362
+ * `setImplemented` (the build flag). Setting one never touches the others.
2363
+ * Fold/migration-only by contract: under apply-first, `approved` flips
2364
+ * when a delta is folded into specs/ and archived — never from a chip.
2365
+ * Proposal-only: a capability has no metadata file. (The apply *action*
2366
+ * that runs the fold belongs to specplan-timeline-apply; this is just the
2367
+ * state writer.)
2368
+ */
2369
+ setApproved(specId, approved) {
2370
+ const node = this.load().nodes.find((n) => n.id === specId);
2371
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
2372
+ const path = this.specMetaPath(specId);
2373
+ const raw = this.fsx.readIfExists(path);
2374
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
2375
+ doc.setIn(["approved"], approved);
2376
+ this.fsx.writeAtomicFile(path, doc.toString());
2377
+ }
2378
+ /**
2379
+ * Set a proposal's build flag (`implemented`) — user-set, independent of
2380
+ * the fold: true once the code has been built to match the approved diff.
2381
+ * Never touches `status` or `approved`. Proposal-only: a capability has
2382
+ * no metadata file.
2383
+ */
2384
+ setImplemented(specId, implemented) {
2385
+ const node = this.load().nodes.find((n) => n.id === specId);
2386
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
2387
+ const path = this.specMetaPath(specId);
2388
+ const raw = this.fsx.readIfExists(path);
2389
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
2390
+ doc.setIn(["implemented"], implemented);
2391
+ this.fsx.writeAtomicFile(path, doc.toString());
2392
+ }
1157
2393
  /** Record a generation result in the spec's metadata file. */
1158
2394
  writeGeneration(specId, lastGeneratedBodyHash, lastGeneratedAt) {
1159
2395
  const path = this.specMetaPath(specId);
1160
2396
  const raw = this.fsx.readIfExists(path);
1161
- const doc = raw === void 0 ? new Document({ specId }) : parseDocument(raw);
2397
+ const doc = raw === void 0 ? new Document2({ specId }) : parseDocument2(raw);
1162
2398
  doc.setIn(["generation", "lastGeneratedBodyHash"], lastGeneratedBodyHash);
1163
2399
  doc.setIn(["generation", "lastGeneratedAt"], lastGeneratedAt);
1164
2400
  this.fsx.writeAtomicFile(path, doc.toString());
@@ -1171,20 +2407,28 @@ function planErrorResponse(c, err) {
1171
2407
  if (err instanceof PlanFileError) return c.json({ error: err.message }, 500);
1172
2408
  throw err;
1173
2409
  }
1174
- function registerPlanRoutes(app, store, workspace) {
2410
+ function registerPlanRoutes(app, store, workspace, opts = {}) {
2411
+ const journal = new PlanJournal(store.fsx, store.dir);
1175
2412
  app.get("/api/session", (c) => {
1176
2413
  try {
1177
2414
  store.adoptExistingChanges();
2415
+ store.adoptExistingCapabilities();
1178
2416
  const plan = store.load();
1179
2417
  const specStates = Object.fromEntries(
1180
- plan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id)])
2418
+ plan.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id)])
1181
2419
  );
2420
+ const collapsedGroups = workspace.readCollapsedGroups();
2421
+ const hideEmptySpecs = workspace.readHideEmptySpecs();
1182
2422
  return c.json({
1183
2423
  rootName: basename3(workspace.dir),
1184
2424
  model: GENERATION_MODELS.find((m) => m === workspace.readModel()),
1185
2425
  plan,
1186
- positions: workspace.readPositions(),
1187
2426
  specStates,
2427
+ // Absent (never saved) → the UI applies the first-load default
2428
+ // collapse; present (even empty) → the remembered per-plan state.
2429
+ ...collapsedGroups !== void 0 ? { collapsedGroups } : {},
2430
+ // Absent → the default (show every row); present → the remembered toggle.
2431
+ ...hideEmptySpecs !== void 0 ? { hideEmptySpecs } : {},
1188
2432
  chat: workspace.readChat()
1189
2433
  });
1190
2434
  } catch (err) {
@@ -1205,10 +2449,9 @@ function registerPlanRoutes(app, store, workspace) {
1205
2449
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1206
2450
  }
1207
2451
  try {
1208
- return c.json(
1209
- { node: store.createSpec(parsed.data.title, parsed.data.description) },
1210
- 201
1211
- );
2452
+ const node = store.createProposal(parsed.data.title, parsed.data.description);
2453
+ journal.append({ action: "create_spec", targetId: node.id, at: (/* @__PURE__ */ new Date()).toISOString() });
2454
+ return c.json({ node }, 201);
1212
2455
  } catch (err) {
1213
2456
  return planErrorResponse(c, err);
1214
2457
  }
@@ -1219,7 +2462,9 @@ function registerPlanRoutes(app, store, workspace) {
1219
2462
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1220
2463
  }
1221
2464
  try {
1222
- return c.json({ node: store.createStory(parsed.data.text) }, 201);
2465
+ const node = store.createStory(parsed.data.text);
2466
+ journal.append({ action: "create_story", targetId: node.id, at: (/* @__PURE__ */ new Date()).toISOString() });
2467
+ return c.json({ node }, 201);
1223
2468
  } catch (err) {
1224
2469
  return planErrorResponse(c, err);
1225
2470
  }
@@ -1230,7 +2475,14 @@ function registerPlanRoutes(app, store, workspace) {
1230
2475
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1231
2476
  }
1232
2477
  try {
1233
- return c.json({ edge: store.link(parsed.data.from, parsed.data.to) }, 201);
2478
+ const edge = store.link(parsed.data.from, parsed.data.to);
2479
+ journal.append({
2480
+ action: "link",
2481
+ targetId: parsed.data.from,
2482
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2483
+ to: parsed.data.to
2484
+ });
2485
+ return c.json({ edge }, 201);
1234
2486
  } catch (err) {
1235
2487
  return planErrorResponse(c, err);
1236
2488
  }
@@ -1239,18 +2491,33 @@ function registerPlanRoutes(app, store, workspace) {
1239
2491
  const specId = c.req.param("specId");
1240
2492
  try {
1241
2493
  const node = store.load().nodes.find((n) => n.id === specId);
1242
- if (!node || node.type !== "spec") {
2494
+ if (!node || node.type !== "proposal") {
1243
2495
  return c.json({ error: `unknown spec: ${specId}` }, 404);
1244
2496
  }
2497
+ const diagram = readDiagram(store, specId);
1245
2498
  return c.json({
1246
2499
  specId,
1247
2500
  handEdited: specState(store, specId).handEdited,
1248
- files: readBodyFiles(store, specId)
2501
+ files: readBodyFiles(store, specId),
2502
+ ...diagram !== void 0 ? { diagram } : {}
1249
2503
  });
1250
2504
  } catch (err) {
1251
2505
  return planErrorResponse(c, err);
1252
2506
  }
1253
2507
  });
2508
+ app.get("/api/capability/:id", (c) => {
2509
+ const id = c.req.param("id");
2510
+ try {
2511
+ const node = store.load().nodes.find((n) => n.id === id);
2512
+ if (!node || node.type !== "capability") {
2513
+ return c.json({ error: `unknown capability: ${id}` }, 404);
2514
+ }
2515
+ const content = store.fsx.readIfExists(join9(store.dir, "specs", id, "spec.md")) ?? "";
2516
+ return c.json({ id, content });
2517
+ } catch (err) {
2518
+ return planErrorResponse(c, err);
2519
+ }
2520
+ });
1254
2521
  app.post("/api/plan/status", async (c) => {
1255
2522
  const parsed = SetStatusRequest.safeParse(await c.req.json().catch(() => null));
1256
2523
  if (!parsed.success) {
@@ -1258,11 +2525,61 @@ function registerPlanRoutes(app, store, workspace) {
1258
2525
  }
1259
2526
  try {
1260
2527
  store.setStatus(parsed.data.specId, parsed.data.status);
2528
+ journal.append({
2529
+ action: "status",
2530
+ targetId: parsed.data.specId,
2531
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2532
+ status: parsed.data.status
2533
+ });
1261
2534
  return c.json({ specId: parsed.data.specId, status: parsed.data.status });
1262
2535
  } catch (err) {
1263
2536
  return planErrorResponse(c, err);
1264
2537
  }
1265
2538
  });
2539
+ app.post("/api/plan/apply", async (c) => {
2540
+ if (!opts.apply) {
2541
+ return c.json({ error: "apply is not available on this deployment target yet" }, 501);
2542
+ }
2543
+ const parsed = ApplyRequest.safeParse(await c.req.json().catch(() => null));
2544
+ if (!parsed.success) {
2545
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2546
+ }
2547
+ try {
2548
+ const result = await opts.apply(parsed.data.specId, parsed.data.confirmHandEdit ?? false);
2549
+ return c.json(result);
2550
+ } catch (err) {
2551
+ const status = err.status;
2552
+ if (typeof status === "number") {
2553
+ return c.json({ error: err.message }, status);
2554
+ }
2555
+ return planErrorResponse(c, err);
2556
+ }
2557
+ });
2558
+ app.post("/api/plan/implemented", async (c) => {
2559
+ const parsed = SetImplementedRequest.safeParse(await c.req.json().catch(() => null));
2560
+ if (!parsed.success) {
2561
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2562
+ }
2563
+ try {
2564
+ store.setImplemented(parsed.data.specId, parsed.data.implemented);
2565
+ journal.append({
2566
+ action: "implemented",
2567
+ targetId: parsed.data.specId,
2568
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2569
+ implemented: parsed.data.implemented
2570
+ });
2571
+ return c.json({ specId: parsed.data.specId, implemented: parsed.data.implemented });
2572
+ } catch (err) {
2573
+ return planErrorResponse(c, err);
2574
+ }
2575
+ });
2576
+ app.get("/api/plan/reconcile", (c) => {
2577
+ try {
2578
+ return c.json({ report: journal.reconcile(store) });
2579
+ } catch (err) {
2580
+ return planErrorResponse(c, err);
2581
+ }
2582
+ });
1266
2583
  app.post("/api/plan/unlink", async (c) => {
1267
2584
  const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1268
2585
  if (!parsed.success) {
@@ -1285,15 +2602,285 @@ function registerPlanRoutes(app, store, workspace) {
1285
2602
  return planErrorResponse(c, err);
1286
2603
  }
1287
2604
  });
2605
+ app.post("/api/plan/arrange", async (c) => {
2606
+ const parsed = ArrangeRequest.safeParse(await c.req.json().catch(() => null));
2607
+ if (!parsed.success) {
2608
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2609
+ }
2610
+ try {
2611
+ return c.json({ nodes: store.arrangeRow(parsed.data.order) });
2612
+ } catch (err) {
2613
+ return planErrorResponse(c, err);
2614
+ }
2615
+ });
2616
+ }
2617
+
2618
+ // server/app.ts
2619
+ import { dirname as dirname6, join as join11 } from "node:path";
2620
+
2621
+ // server/gallery.ts
2622
+ import { join as join10 } from "node:path";
2623
+ var SNIPPET_LIMIT = 180;
2624
+ var REQUIRED_PROPOSAL_SECTIONS = [
2625
+ { title: "Why", level: 2 },
2626
+ { title: "What Changes", level: 2 },
2627
+ { title: "System context", level: 3 },
2628
+ { title: "Containers", level: 3 },
2629
+ { title: "Components", level: 3 },
2630
+ { title: "Impact", level: 2 }
2631
+ ];
2632
+ function titleOf(proposal, id) {
2633
+ const heading = /^#[ \t]+(.+)$/m.exec(proposal ?? "");
2634
+ const text = heading?.[1]?.trim();
2635
+ return text === void 0 || text === "" ? id : text;
2636
+ }
2637
+ function snippetOf(proposal, limit = SNIPPET_LIMIT) {
2638
+ if (!proposal) return "";
2639
+ const why = /^##[ \t]+Why[ \t]*$/m.exec(proposal);
2640
+ if (!why) return "";
2641
+ const after = proposal.slice(why.index + why[0].length);
2642
+ const untilNextHeading = after.split(/^#{1,6}[ \t]/m)[0] ?? "";
2643
+ const paragraph = untilNextHeading.split(/\n\s*\n/).map((p) => p.trim()).find((p) => p !== "");
2644
+ if (paragraph === void 0) return "";
2645
+ const flat = paragraph.replace(/\s+/g, " ").trim();
2646
+ return truncateOnWord(flat, limit);
2647
+ }
2648
+ function truncateOnWord(text, limit) {
2649
+ if (text.length <= limit) return text;
2650
+ const clipped = text.slice(0, limit);
2651
+ const lastSpace = clipped.lastIndexOf(" ");
2652
+ return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}\u2026`;
2653
+ }
2654
+ function taskCounts(tasks) {
2655
+ if (!tasks) return { done: 0, total: 0 };
2656
+ const items = tasks.match(/^[ \t]*-[ \t]+\[[ xX]\]/gm) ?? [];
2657
+ const done = items.filter((i) => /\[[xX]\]/.test(i)).length;
2658
+ return { done, total: items.length };
2659
+ }
2660
+ function proposalState(proposal) {
2661
+ if (!proposal || proposal.trim() === "") return "absent";
2662
+ let filled = 0;
2663
+ for (const section of REQUIRED_PROPOSAL_SECTIONS) {
2664
+ const hashes = "#".repeat(section.level);
2665
+ const heading = new RegExp(`^${hashes}[ \\t]+${section.title}[ \\t]*$`, "m").exec(proposal);
2666
+ if (!heading) continue;
2667
+ const after = proposal.slice(heading.index + heading[0].length);
2668
+ const body = (after.split(/^#{1,6}[ \t]/m)[0] ?? "").trim();
2669
+ if (body !== "") filled += 1;
2670
+ }
2671
+ if (filled === 0) return "absent";
2672
+ return filled === REQUIRED_PROPOSAL_SECTIONS.length ? "complete" : "partial";
2673
+ }
2674
+ function designState(design) {
2675
+ if (!design || design.trim() === "") return "absent";
2676
+ return /^##[ \t]+\S/m.test(design) ? "complete" : "absent";
2677
+ }
2678
+ var DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
2679
+ function archivedIds(deps) {
2680
+ const dir = join10(deps.root, "changes", "archive");
2681
+ if (!deps.fsx.exists(dir)) return /* @__PURE__ */ new Set();
2682
+ const ids = /* @__PURE__ */ new Set();
2683
+ for (const entry of deps.fsx.readdir(dir)) {
2684
+ if (!entry.directory || entry.name.startsWith(".")) continue;
2685
+ ids.add(entry.name.replace(DATE_PREFIX, ""));
2686
+ ids.add(entry.name);
2687
+ }
2688
+ return ids;
2689
+ }
2690
+ function listChanges(deps) {
2691
+ const changesDir = join10(deps.root, "changes");
2692
+ if (!deps.fsx.exists(changesDir)) return [];
2693
+ const cards = [];
2694
+ for (const entry of deps.fsx.readdir(changesDir).sort((a, b) => a.name.localeCompare(b.name))) {
2695
+ if (!entry.directory || entry.name === "archive" || entry.name.startsWith(".")) continue;
2696
+ cards.push(readChange(deps, changesDir, entry.name));
2697
+ }
2698
+ return cards;
2699
+ }
2700
+ function readChange(deps, changesDir, id) {
2701
+ const dir = join10(changesDir, id);
2702
+ const proposal = deps.fsx.readIfExists(join10(dir, "proposal.md"));
2703
+ const design = deps.fsx.readIfExists(join10(dir, "design.md"));
2704
+ const tasks = deps.fsx.readIfExists(join10(dir, "tasks.md"));
2705
+ const specsDir = join10(dir, "specs");
2706
+ const capabilities = deps.fsx.exists(specsDir) ? deps.fsx.readdir(specsDir).filter((e) => e.directory && deps.fsx.exists(join10(specsDir, e.name, "spec.md"))).map((e) => e.name).sort() : [];
2707
+ const counts = taskCounts(tasks);
2708
+ const states = {
2709
+ proposal: proposalState(proposal),
2710
+ design: designState(design),
2711
+ // Deltas have no middle state either.
2712
+ deltas: capabilities.length > 0 ? "complete" : "absent",
2713
+ tasks: counts.total === 0 ? "absent" : counts.done === counts.total ? "complete" : "partial"
2714
+ };
2715
+ return {
2716
+ id,
2717
+ title: titleOf(proposal, id),
2718
+ snippet: snippetOf(proposal),
2719
+ capabilities,
2720
+ ...states,
2721
+ tasksDone: counts.done,
2722
+ tasksTotal: counts.total,
2723
+ tasksOpen: counts.total - counts.done,
2724
+ // Derived from the four states rather than re-read, so "empty" cannot disagree with
2725
+ // the indicators sitting next to it on the same card.
2726
+ empty: Object.values(states).every((state) => state === "absent")
2727
+ };
2728
+ }
2729
+ function deleteChange(deps, id) {
2730
+ const name = id.trim();
2731
+ if (name === "") return { outcome: "refused", reason: "no change was named" };
2732
+ if (name === "archive") {
2733
+ return { outcome: "refused", reason: "the archive is not a change and cannot be deleted" };
2734
+ }
2735
+ if (name.startsWith(".") || /[/\\]/.test(name) || name.split(".").includes("..")) {
2736
+ return { outcome: "refused", reason: `\`${name}\` is not a change directory name` };
2737
+ }
2738
+ const dir = join10(deps.root, "changes", name);
2739
+ if (!deps.fsx.exists(dir)) {
2740
+ return { outcome: "refused", reason: `\`${name}\` has no directory under openspec/changes/` };
2741
+ }
2742
+ deps.fsx.removeDir(dir);
2743
+ return { outcome: "deleted" };
2744
+ }
2745
+ function readChangePreview(deps, id) {
2746
+ const dir = join10(deps.root, "changes", id);
2747
+ if (!deps.fsx.exists(dir)) return null;
2748
+ const documents = ["proposal.md", "design.md", "tasks.md"].map((path) => ({
2749
+ path,
2750
+ content: deps.fsx.readIfExists(join10(dir, path)) ?? null
2751
+ }));
2752
+ const specsDir = join10(dir, "specs");
2753
+ if (deps.fsx.exists(specsDir)) {
2754
+ for (const entry of deps.fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
2755
+ if (!entry.directory) continue;
2756
+ const path = `specs/${entry.name}/spec.md`;
2757
+ const content = deps.fsx.readIfExists(join10(specsDir, entry.name, "spec.md"));
2758
+ if (content !== void 0) documents.push({ path, content });
2759
+ }
2760
+ }
2761
+ return {
2762
+ id,
2763
+ title: titleOf(deps.fsx.readIfExists(join10(dir, "proposal.md")), id),
2764
+ // The real id, never a placeholder: a command the reader has to edit is one they
2765
+ // will mistype.
2766
+ command: `specsketch ${id}`,
2767
+ documents
2768
+ };
2769
+ }
2770
+
2771
+ // server/roadmap.ts
2772
+ var ROW_ID = /^\|[^|]*\|\s*`([a-z0-9][a-z0-9-]*)`\s*\|/;
2773
+ function readOrder(roadmap) {
2774
+ const rows = [];
2775
+ roadmap.split("\n").forEach((text, line) => {
2776
+ const match = ROW_ID.exec(text);
2777
+ if (match?.[1]) rows.push({ id: match[1], line, text });
2778
+ });
2779
+ return { rows, order: rows.map((r) => r.id) };
2780
+ }
2781
+ function reorder(roadmap, id, beforeId) {
2782
+ const lines = roadmap.split("\n");
2783
+ const { rows } = readOrder(roadmap);
2784
+ const moving = rows.find((r) => r.id === id);
2785
+ if (!moving) return { outcome: "refused", reason: `\`${id}\` has no row in the roadmap` };
2786
+ if (beforeId !== null && !rows.some((r) => r.id === beforeId)) {
2787
+ return { outcome: "refused", reason: `\`${beforeId}\` has no row in the roadmap` };
2788
+ }
2789
+ if (id === beforeId) {
2790
+ return { outcome: "refused", reason: "a change cannot be moved before itself" };
2791
+ }
2792
+ const row = lines[moving.line];
2793
+ lines.splice(moving.line, 1);
2794
+ let insertAt;
2795
+ if (beforeId === null) {
2796
+ const remaining = readOrder(lines.join("\n")).rows;
2797
+ const last = remaining[remaining.length - 1];
2798
+ insertAt = last === void 0 ? lines.length : last.line + 1;
2799
+ } else {
2800
+ const target = readOrder(lines.join("\n")).rows.find((r) => r.id === beforeId);
2801
+ if (!target) return { outcome: "refused", reason: `\`${beforeId}\` vanished while reordering` };
2802
+ insertAt = target.line;
2803
+ }
2804
+ lines.splice(insertAt, 0, row);
2805
+ const content = lines.join("\n");
2806
+ const before = roadmap.split("\n");
2807
+ const withoutMoved = (all) => {
2808
+ const copy = [...all];
2809
+ const at = copy.indexOf(row);
2810
+ if (at >= 0) copy.splice(at, 1);
2811
+ return copy;
2812
+ };
2813
+ const kept = withoutMoved(before);
2814
+ const now = withoutMoved(lines);
2815
+ if (kept.length !== now.length || kept.some((line, i) => line !== now[i])) {
2816
+ return {
2817
+ outcome: "refused",
2818
+ reason: "the reorder would have changed a line it did not move"
2819
+ };
2820
+ }
2821
+ return { outcome: "reordered", content };
2822
+ }
2823
+ function cellsOf2(line) {
2824
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
2825
+ }
2826
+ function isSeparator(line) {
2827
+ return line.trim().startsWith("|") && cellsOf2(line).every((c) => /^\s*:?-+:?\s*$/.test(c));
2828
+ }
2829
+ function governingHeader(lines, index) {
2830
+ for (let i = Math.min(index, lines.length) - 1; i >= 1; i -= 1) {
2831
+ if (!isSeparator(lines[i])) continue;
2832
+ return (lines[i - 1] ?? "").trim().startsWith("|") ? i - 1 : null;
2833
+ }
2834
+ return null;
2835
+ }
2836
+ function addRow(roadmap, id) {
2837
+ const lines = roadmap.split("\n");
2838
+ const { rows } = readOrder(roadmap);
2839
+ if (rows.some((r) => r.id === id)) {
2840
+ return { outcome: "refused", reason: `\`${id}\` already has a roadmap row` };
2841
+ }
2842
+ const firstSeparator = lines.findIndex(isSeparator);
2843
+ const insertAt = rows.length > 0 ? rows[rows.length - 1].line + 1 : firstSeparator + 1;
2844
+ const headerAt = governingHeader(lines, insertAt);
2845
+ if (headerAt === null) {
2846
+ return { outcome: "refused", reason: "the roadmap carries no table to append a row to" };
2847
+ }
2848
+ const header = cellsOf2(lines[headerAt]).map((c) => c.trim());
2849
+ const rowsHere = rows.filter((r) => governingHeader(lines, r.line) === headerAt);
2850
+ const idColumn = rowsHere.length > 0 ? cellsOf2(rowsHere[0].text).findIndex((c) => /^\s*`[^`]+`\s*$/.test(c)) : header.findIndex((h) => /change[- ]?id|^id$/i.test(h));
2851
+ if (idColumn < 0) {
2852
+ return { outcome: "refused", reason: "the roadmap table declares no change-id column" };
2853
+ }
2854
+ const cells = header.map(() => "");
2855
+ cells[idColumn] = `\`${id}\``;
2856
+ if (/^#$/.test(header[0] ?? "") && idColumn !== 0) {
2857
+ const written = rows.map((r) => Number.parseInt((cellsOf2(r.text)[0] ?? "").trim(), 10)).filter((n) => Number.isFinite(n));
2858
+ cells[0] = String(written.length > 0 ? Math.max(...written) + 1 : rows.length + 1);
2859
+ }
2860
+ const row = `| ${cells.join(" | ")} |`;
2861
+ lines.splice(insertAt, 0, row);
2862
+ const content = lines.join("\n");
2863
+ const before = roadmap.split("\n");
2864
+ const after = lines.filter((_, i) => i !== insertAt);
2865
+ if (before.length !== after.length || before.some((line, i) => line !== after[i])) {
2866
+ return { outcome: "refused", reason: "the append would have changed a line it did not add" };
2867
+ }
2868
+ if (cellsOf2(row).length !== header.length) {
2869
+ return {
2870
+ outcome: "refused",
2871
+ reason: `the appended row would carry ${cellsOf2(row).length} cells in a ${header.length}-column table`
2872
+ };
2873
+ }
2874
+ return { outcome: "reordered", content };
1288
2875
  }
1289
2876
 
1290
2877
  // server/tools.ts
1291
- import { z as z2 } from "zod";
1292
- var CreateSpecInput = z2.object({ title: z2.string().min(1) });
1293
- var CreateStoryInput = z2.object({ text: z2.string().min(1) });
1294
- var LinkDependencyInput = z2.object({ fromSpecId: z2.string(), toSpecId: z2.string() });
1295
- var ContainStoryInput = z2.object({ specId: z2.string(), storyId: z2.string() });
1296
- var ReorderInput = z2.object({ nodeId: z2.string(), order: z2.number() });
2878
+ import { z as z4 } from "zod";
2879
+ var CreateSpecInput = z4.object({ title: z4.string().min(1) });
2880
+ var CreateStoryInput = z4.object({ text: z4.string().min(1) });
2881
+ var LinkDependencyInput = z4.object({ fromSpecId: z4.string(), toSpecId: z4.string() });
2882
+ var ContainStoryInput = z4.object({ specId: z4.string(), storyId: z4.string() });
2883
+ var ReorderInput = z4.object({ nodeId: z4.string(), order: z4.number() });
1297
2884
  var CHAT_TOOLS = [
1298
2885
  {
1299
2886
  name: "create_spec",
@@ -1367,7 +2954,7 @@ function executeToolCall(store, name, input) {
1367
2954
  switch (name) {
1368
2955
  case "create_spec": {
1369
2956
  const { title } = parseInput(CreateSpecInput, input, name);
1370
- const node = store.createSpec(title);
2957
+ const node = store.createProposal(title);
1371
2958
  return { tool: name, summary: `created spec ${node.id} "${node.title}"` };
1372
2959
  }
1373
2960
  case "create_user_story": {
@@ -1378,9 +2965,9 @@ function executeToolCall(store, name, input) {
1378
2965
  case "link_dependency": {
1379
2966
  const { fromSpecId, toSpecId } = parseInput(LinkDependencyInput, input, name);
1380
2967
  for (const id of [fromSpecId, toSpecId]) {
1381
- if (nodeType(store, id) !== "spec") {
2968
+ if (nodeType(store, id) !== "proposal") {
1382
2969
  throw new DagError(
1383
- `link_dependency links specs only \u2014 ${id} is a user story (use contain_story)`
2970
+ `link_dependency links specs only \u2014 ${id} is not a spec (use contain_story for user stories)`
1384
2971
  );
1385
2972
  }
1386
2973
  }
@@ -1389,7 +2976,7 @@ function executeToolCall(store, name, input) {
1389
2976
  }
1390
2977
  case "contain_story": {
1391
2978
  const { specId, storyId } = parseInput(ContainStoryInput, input, name);
1392
- if (nodeType(store, specId) !== "spec") {
2979
+ if (nodeType(store, specId) !== "proposal") {
1393
2980
  throw new DagError(`contain_story: ${specId} is not a spec`);
1394
2981
  }
1395
2982
  if (nodeType(store, storyId) !== "user_story") {
@@ -1424,6 +3011,13 @@ You CANNOT delete, remove, or rename anything \u2014 no such tool exists, by des
1424
3011
  to delete or remove an item, explain that SpecPlan's assistant is non-destructive (the
1425
3012
  user can edit specplan.yaml by hand) and make NO tool calls.
1426
3013
 
3014
+ You also CANNOT approve, close, or apply a proposal. Applying folds a proposal's delta into
3015
+ specs/ and archives it \u2014 a consuming, largely-irreversible lifecycle transition, the same
3016
+ category the no-delete rule protects \u2014 so there is deliberately no chat tool for it. Apply
3017
+ is a canvas-only, user-gated control (the Apply/Close button) or the headless
3018
+ "specplan apply" CLI verb. If asked to approve, close, or apply a proposal, explain this in
3019
+ plain text and make NO tool calls.
3020
+
1427
3021
  Ground answers in the current plan below. "=== @<path> ===" blocks are real file contents
1428
3022
  resolved from @-references; they are authoritative over your assumptions. Answers render
1429
3023
  as plain text in a narrow side pane: be concise, short paragraphs or simple "- " lists,
@@ -1435,7 +3029,7 @@ function renderPlanContext(plan, statuses) {
1435
3029
  );
1436
3030
  const lines = ["Current plan:"];
1437
3031
  for (const node of plan.nodes) {
1438
- if (node.type !== "spec") continue;
3032
+ if (node.type !== "proposal") continue;
1439
3033
  lines.push(
1440
3034
  `- spec ${node.id} "${node.title}" (order ${node.order}, status ${statuses?.[node.id] ?? "draft"})`
1441
3035
  );
@@ -1447,7 +3041,7 @@ function renderPlanContext(plan, statuses) {
1447
3041
  }
1448
3042
  const free = backlog(plan);
1449
3043
  const backlogLines = [
1450
- ...free.specs.map((n) => `- ${n.id} (spec): ${n.type === "spec" ? n.title : ""}`),
3044
+ ...free.specs.map((n) => `- ${n.id} (spec): ${n.type === "proposal" ? n.title : ""}`),
1451
3045
  ...free.stories.map((n) => `- ${n.id} (story): ${n.type === "user_story" ? n.text : ""}`)
1452
3046
  ];
1453
3047
  lines.push("", "Backlog (not yet linked into the plan):");
@@ -1470,10 +3064,82 @@ function anthropicErrorResponse(c, err, kind) {
1470
3064
  }
1471
3065
  return c.json({ error: kind, detail: message }, 502);
1472
3066
  }
3067
+ function renderToolUseSummary(name, input) {
3068
+ try {
3069
+ const json = JSON.stringify(input);
3070
+ return json && json !== "{}" && json !== "null" ? `${name} ${json}` : name;
3071
+ } catch {
3072
+ return name;
3073
+ }
3074
+ }
1473
3075
  function createApp(anthropic, workspace, store, options) {
1474
3076
  const refRoot = options === void 0 ? process.cwd() : options.refRoot;
3077
+ const harness = options?.harness;
3078
+ const powerProfile = readMostlyProfile("specplan-assistant");
1475
3079
  const app = new Hono();
1476
3080
  app.get("/api/health", (c) => c.json({ ok: true }));
3081
+ if (store) {
3082
+ const roadmapPath = () => {
3083
+ const beside = join11(dirname6(store.dir), "roadmap.md");
3084
+ return store.fsx.exists(beside) ? beside : join11(store.dir, "roadmap.md");
3085
+ };
3086
+ app.get("/api/gallery", (c) => {
3087
+ const cards = listChanges({ fsx: store.fsx, root: store.dir });
3088
+ const roadmap = store.fsx.readIfExists(roadmapPath());
3089
+ const order = roadmap === void 0 ? [] : readOrder(roadmap).order;
3090
+ const rank = new Map(order.map((id, i) => [id, i]));
3091
+ const roadmapped = cards.filter((card) => rank.has(card.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
3092
+ const backlog2 = cards.filter((card) => !rank.has(card.id));
3093
+ const present = new Set(cards.map((card) => card.id));
3094
+ const archive = archivedIds({ fsx: store.fsx, root: store.dir });
3095
+ const absent = order.filter((id) => !present.has(id));
3096
+ const archived = absent.filter((id) => archive.has(id));
3097
+ const missing = absent.filter((id) => !archive.has(id));
3098
+ return c.json({
3099
+ roadmap: roadmapped,
3100
+ backlog: backlog2,
3101
+ archived,
3102
+ missing,
3103
+ hasRoadmap: roadmap !== void 0
3104
+ });
3105
+ });
3106
+ app.get("/api/gallery/:id", (c) => {
3107
+ const preview = readChangePreview({ fsx: store.fsx, root: store.dir }, c.req.param("id"));
3108
+ if (preview === null) return c.json({ error: "no-such-change" }, 404);
3109
+ return c.json(preview);
3110
+ });
3111
+ app.post("/api/gallery/roadmap", async (c) => {
3112
+ const body = await c.req.json();
3113
+ const path = roadmapPath();
3114
+ const roadmap = store.fsx.readIfExists(path);
3115
+ if (roadmap === void 0) {
3116
+ return c.json({ outcome: "refused", reason: "no roadmap.md to add to" }, 409);
3117
+ }
3118
+ const result = addRow(roadmap, body.id ?? "");
3119
+ if (result.outcome !== "reordered") return c.json(result, 409);
3120
+ store.fsx.writeAtomicFile(path, result.content);
3121
+ return c.json({ outcome: "added" });
3122
+ });
3123
+ app.delete("/api/gallery/:id", (c) => {
3124
+ const id = c.req.param("id");
3125
+ const result = deleteChange({ fsx: store.fsx, root: store.dir }, id);
3126
+ if (result.outcome !== "deleted") return c.json(result, 409);
3127
+ store.removeProposal(id);
3128
+ return c.json(result);
3129
+ });
3130
+ app.post("/api/gallery/reorder", async (c) => {
3131
+ const body = await c.req.json();
3132
+ const path = roadmapPath();
3133
+ const roadmap = store.fsx.readIfExists(path);
3134
+ if (roadmap === void 0) {
3135
+ return c.json({ outcome: "refused", reason: "no roadmap.md to reorder" }, 409);
3136
+ }
3137
+ const result = reorder(roadmap, body.id ?? "", body.before ?? null);
3138
+ if (result.outcome !== "reordered") return c.json(result, 409);
3139
+ store.fsx.writeAtomicFile(path, result.content);
3140
+ return c.json({ outcome: "reordered" });
3141
+ });
3142
+ }
1477
3143
  app.get(
1478
3144
  "/api/config",
1479
3145
  (c) => c.json({
@@ -1483,7 +3149,9 @@ function createApp(anthropic, workspace, store, options) {
1483
3149
  })
1484
3150
  );
1485
3151
  if (workspace && store) {
1486
- registerPlanRoutes(app, store, workspace);
3152
+ registerPlanRoutes(app, store, workspace, {
3153
+ apply: (specId, confirmHandEdit) => applyForSpec({ store }, specId, { confirmHandEdit })
3154
+ });
1487
3155
  app.post("/api/generate", async (c) => {
1488
3156
  const parsed = GenerateRequest.safeParse(await c.req.json().catch(() => null));
1489
3157
  if (!parsed.success) {
@@ -1495,6 +3163,9 @@ function createApp(anthropic, workspace, store, options) {
1495
3163
  {
1496
3164
  anthropic,
1497
3165
  store,
3166
+ // Present → harness structured generation (subscription-first);
3167
+ // absent → the raw @anthropic-ai/sdk path (API credits).
3168
+ harness,
1498
3169
  systemPrompt: workspace.readSystemPrompt(),
1499
3170
  refRoot
1500
3171
  },
@@ -1514,9 +3185,76 @@ function createApp(anthropic, workspace, store, options) {
1514
3185
  if (!parsed.success) {
1515
3186
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1516
3187
  }
1517
- const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
3188
+ const { message, conversation, model = DEFAULT_MODEL, mode } = parsed.data;
1518
3189
  const refs = expandReferences(message, refRoot);
1519
3190
  const refContext = renderReferenceContext(refs);
3191
+ if (mode === "power") {
3192
+ if (!harness) {
3193
+ return c.json(
3194
+ { error: "power-mode-unavailable", detail: "no agent harness configured for this server" },
3195
+ 500
3196
+ );
3197
+ }
3198
+ const chatPlan2 = store.load();
3199
+ const statuses2 = Object.fromEntries(
3200
+ chatPlan2.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id).status])
3201
+ );
3202
+ const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
3203
+ ${m.text}`).join("\n\n");
3204
+ const prompt = `${CHAT_SYSTEM}
3205
+
3206
+ ${renderPlanContext(chatPlan2, statuses2)}` + (history ? `
3207
+
3208
+ Conversation so far:
3209
+
3210
+ ${history}` : "") + `
3211
+
3212
+ [user]
3213
+ ${message}` + (refContext ? `
3214
+
3215
+ Referenced files (resolved from @-references in the message):
3216
+
3217
+ ${refContext}` : "");
3218
+ let answer = "";
3219
+ let resultText = "";
3220
+ let resultError = false;
3221
+ const executed2 = [];
3222
+ try {
3223
+ for await (const ev of harness.run({
3224
+ prompt,
3225
+ cwd: refRoot ?? process.cwd(),
3226
+ session: "specplan-assistant",
3227
+ profile: powerProfile
3228
+ })) {
3229
+ if (ev.type === "text") answer += ev.text;
3230
+ else if (ev.type === "tool_use") {
3231
+ executed2.push({ tool: ev.name, summary: renderToolUseSummary(ev.name, ev.input) });
3232
+ } else if (ev.type === "result") {
3233
+ resultText = ev.text;
3234
+ resultError = ev.isError;
3235
+ }
3236
+ }
3237
+ } catch (err) {
3238
+ return anthropicErrorResponse(c, err, "chat-failed");
3239
+ }
3240
+ const text2 = answer.trim() || resultText.trim();
3241
+ if (!text2) {
3242
+ return resultError ? c.json({ error: "chat-failed", detail: resultText || "the agent run failed" }, 502) : c.json({ error: "model returned no text" }, 502);
3243
+ }
3244
+ const reply2 = { role: "assistant", text: text2, at: (/* @__PURE__ */ new Date()).toISOString() };
3245
+ workspace.writeChat([
3246
+ ...conversation,
3247
+ { role: "user", text: message, at: reply2.at },
3248
+ reply2
3249
+ ]);
3250
+ return c.json({
3251
+ reply: reply2,
3252
+ toolCalls: executed2,
3253
+ plan: store.load(),
3254
+ resolvedRefs: refs.resolved.map((r) => r.path),
3255
+ unresolvedRefs: refs.unresolved
3256
+ });
3257
+ }
1520
3258
  const messages = [
1521
3259
  ...conversation.map((m) => ({
1522
3260
  role: m.role === "user" ? "user" : "assistant",
@@ -1533,7 +3271,7 @@ ${refContext}` : "")
1533
3271
  ];
1534
3272
  const chatPlan = store.load();
1535
3273
  const statuses = Object.fromEntries(
1536
- chatPlan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id).status])
3274
+ chatPlan.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id).status])
1537
3275
  );
1538
3276
  const system = `${CHAT_SYSTEM}
1539
3277
 
@@ -1624,14 +3362,14 @@ import { parseArgs } from "node:util";
1624
3362
 
1625
3363
  // ../shared/src/cli-config.ts
1626
3364
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
1627
- import { homedir, hostname } from "node:os";
1628
- import { join as join5, dirname as dirname4 } from "node:path";
3365
+ import { homedir as homedir2, hostname } from "node:os";
3366
+ import { join as join12, dirname as dirname7 } from "node:path";
1629
3367
 
1630
3368
  // ../shared/src/schemas.ts
1631
- import { z as z3 } from "zod";
1632
- var planTierSchema = z3.enum(["hobby", "squad", "org"]);
1633
- var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
1634
- var locationHintSchema = z3.enum([
3369
+ import { z as z5 } from "zod";
3370
+ var planTierSchema = z5.enum(["hobby", "squad", "org"]);
3371
+ var jurisdictionSchema = z5.enum(["eu", "fedramp"]);
3372
+ var locationHintSchema = z5.enum([
1635
3373
  "wnam",
1636
3374
  "enam",
1637
3375
  "weur",
@@ -1642,135 +3380,135 @@ var locationHintSchema = z3.enum([
1642
3380
  "me",
1643
3381
  "sam"
1644
3382
  ]);
1645
- var sessionNameSchema = z3.string().min(1).max(63).regex(
3383
+ var sessionNameSchema = z5.string().min(1).max(63).regex(
1646
3384
  /^[a-z0-9][a-z0-9-]*$/,
1647
3385
  "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1648
3386
  );
1649
- var teamNameSchema = z3.string().min(1).max(63).regex(
3387
+ var teamNameSchema = z5.string().min(1).max(63).regex(
1650
3388
  /^[a-z0-9][a-z0-9-]*$/,
1651
3389
  "Team name must be lowercase alphanumeric with hyphens"
1652
3390
  );
1653
- var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
1654
- var messageContentSchema = z3.object({
3391
+ var messageKindSchema = z5.enum(["text", "context", "request", "system"]);
3392
+ var messageContentSchema = z5.object({
1655
3393
  kind: messageKindSchema,
1656
3394
  // No .max() here: the 10 000-char body limit is business logic owned by the
1657
3395
  // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
1658
3396
  // both REST and WS. A schema cap would pre-empt it as a generic 400.
1659
- body: z3.string().min(1),
3397
+ body: z5.string().min(1),
1660
3398
  // Two-arg z.record: identical semantics in zod 3, required form in zod 4
1661
3399
  // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
1662
- metadata: z3.record(z3.string(), z3.unknown()).optional()
3400
+ metadata: z5.record(z5.string(), z5.unknown()).optional()
1663
3401
  });
1664
- var createTeamInputSchema = z3.object({
3402
+ var createTeamInputSchema = z5.object({
1665
3403
  name: teamNameSchema
1666
3404
  });
1667
- var createSessionInputSchema = z3.object({
3405
+ var createSessionInputSchema = z5.object({
1668
3406
  name: sessionNameSchema,
1669
- teamId: z3.string().optional(),
3407
+ teamId: z5.string().optional(),
1670
3408
  jurisdiction: jurisdictionSchema.optional(),
1671
3409
  locationHint: locationHintSchema.optional()
1672
3410
  });
1673
- var sendMessageInputSchema = z3.object({
3411
+ var sendMessageInputSchema = z5.object({
1674
3412
  content: messageContentSchema
1675
3413
  });
1676
- var inviteInputSchema = z3.object({
1677
- email: z3.string().email(),
1678
- role: z3.enum(["admin", "member"]).default("member")
3414
+ var inviteInputSchema = z5.object({
3415
+ email: z5.string().email(),
3416
+ role: z5.enum(["admin", "member"]).default("member")
1679
3417
  });
1680
- var joinTeamInputSchema = z3.object({
1681
- inviteCode: z3.string().min(1)
3418
+ var joinTeamInputSchema = z5.object({
3419
+ inviteCode: z5.string().min(1)
1682
3420
  });
1683
- var markReadInputSchema = z3.object({
1684
- notificationIds: z3.array(z3.string().min(1))
3421
+ var markReadInputSchema = z5.object({
3422
+ notificationIds: z5.array(z5.string().min(1))
1685
3423
  });
1686
- var pollQuerySchema = z3.object({
1687
- since: z3.string().optional(),
1688
- limit: z3.coerce.number().int().min(1).max(100).default(50),
1689
- session: z3.string().optional()
3424
+ var pollQuerySchema = z5.object({
3425
+ since: z5.string().optional(),
3426
+ limit: z5.coerce.number().int().min(1).max(100).default(50),
3427
+ session: z5.string().optional()
1690
3428
  });
1691
- var messagesQuerySchema = z3.object({
1692
- cursor: z3.string().optional(),
1693
- limit: z3.coerce.number().int().min(1).max(100).default(50)
3429
+ var messagesQuerySchema = z5.object({
3430
+ cursor: z5.string().optional(),
3431
+ limit: z5.coerce.number().int().min(1).max(100).default(50)
1694
3432
  });
1695
- var teamTypeSchema = z3.enum(["personal", "shared"]);
1696
- var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
1697
- var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
1698
- var accessGrantInputSchema = z3.object({
1699
- userId: z3.string().min(1),
3433
+ var teamTypeSchema = z5.enum(["personal", "shared"]);
3434
+ var teamRoleSchema = z5.enum(["owner", "admin", "member"]);
3435
+ var accessPermissionSchema = z5.enum(["read", "write", "admin"]);
3436
+ var accessGrantInputSchema = z5.object({
3437
+ userId: z5.string().min(1),
1700
3438
  sessionName: sessionNameSchema.nullable().default(null),
1701
3439
  // null = general
1702
3440
  permission: accessPermissionSchema
1703
3441
  });
1704
- var machineIdSchema = z3.string().min(1).max(63).regex(
3442
+ var machineIdSchema = z5.string().min(1).max(63).regex(
1705
3443
  /^[a-z0-9][a-z0-9-]*$/,
1706
3444
  "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1707
3445
  );
1708
- var clerkProfileInputSchema = z3.object({
1709
- email: z3.string().email().nullable(),
1710
- displayName: z3.string().min(1).max(100),
1711
- avatarUrl: z3.string().url().nullable(),
1712
- provider: z3.string().min(1).max(50),
1713
- providerUserId: z3.string().min(1).max(100)
3446
+ var clerkProfileInputSchema = z5.object({
3447
+ email: z5.string().email().nullable(),
3448
+ displayName: z5.string().min(1).max(100),
3449
+ avatarUrl: z5.string().url().nullable(),
3450
+ provider: z5.string().min(1).max(50),
3451
+ providerUserId: z5.string().min(1).max(100)
1714
3452
  });
1715
- var sessionEnrollmentSchema = z3.object({
1716
- sessionId: z3.string(),
1717
- team: z3.string(),
1718
- teamName: z3.string().optional(),
1719
- key: z3.string().startsWith("api_"),
1720
- enrolledAt: z3.string().datetime(),
1721
- apiUrl: z3.string().url().optional(),
1722
- machineId: z3.string().optional()
3453
+ var sessionEnrollmentSchema = z5.object({
3454
+ sessionId: z5.string(),
3455
+ team: z5.string(),
3456
+ teamName: z5.string().optional(),
3457
+ key: z5.string().startsWith("api_"),
3458
+ enrolledAt: z5.string().datetime(),
3459
+ apiUrl: z5.string().url().optional(),
3460
+ machineId: z5.string().optional()
1723
3461
  });
1724
- var authConfigSchema = z3.object({
1725
- key: z3.string().startsWith("api_"),
1726
- userId: z3.string().min(1),
1727
- displayName: z3.string().nullable().optional().default(null),
1728
- email: z3.string().email().nullable(),
1729
- machineId: z3.string().nullable(),
1730
- authenticatedAt: z3.string().datetime()
3462
+ var authConfigSchema = z5.object({
3463
+ key: z5.string().startsWith("api_"),
3464
+ userId: z5.string().min(1),
3465
+ displayName: z5.string().nullable().optional().default(null),
3466
+ email: z5.string().email().nullable(),
3467
+ machineId: z5.string().nullable(),
3468
+ authenticatedAt: z5.string().datetime()
1731
3469
  });
1732
3470
  var configBaseFields = {
1733
- apiUrl: z3.string().url(),
1734
- machineId: z3.string().nullable(),
1735
- sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1736
- defaults: z3.object({
1737
- team: z3.string().nullable(),
1738
- session: z3.string().nullable()
3471
+ apiUrl: z5.string().url(),
3472
+ machineId: z5.string().nullable(),
3473
+ sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3474
+ defaults: z5.object({
3475
+ team: z5.string().nullable(),
3476
+ session: z5.string().nullable()
1739
3477
  }),
1740
- poll: z3.object({
1741
- lastPollAt: z3.string().datetime().nullable()
3478
+ poll: z5.object({
3479
+ lastPollAt: z5.string().datetime().nullable()
1742
3480
  })
1743
3481
  };
1744
- var coopConfigV1Schema = z3.object({
1745
- version: z3.literal(1),
3482
+ var coopConfigV1Schema = z5.object({
3483
+ version: z5.literal(1),
1746
3484
  ...configBaseFields
1747
3485
  });
1748
- var coopConfigV2Schema = z3.object({
1749
- version: z3.literal(2),
3486
+ var coopConfigV2Schema = z5.object({
3487
+ version: z5.literal(2),
1750
3488
  ...configBaseFields,
1751
3489
  auth: authConfigSchema.optional()
1752
3490
  });
1753
- var profileNameSchema = z3.string().regex(
3491
+ var profileNameSchema = z5.string().regex(
1754
3492
  /^[a-z0-9][a-z0-9_-]{0,63}$/,
1755
3493
  "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
1756
3494
  );
1757
- var coopProfileSchema = z3.object({
1758
- apiUrl: z3.string().url(),
3495
+ var coopProfileSchema = z5.object({
3496
+ apiUrl: z5.string().url(),
1759
3497
  auth: authConfigSchema.optional(),
1760
- sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1761
- defaults: z3.object({
1762
- team: z3.string().nullable(),
1763
- session: z3.string().nullable()
3498
+ sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3499
+ defaults: z5.object({
3500
+ team: z5.string().nullable(),
3501
+ session: z5.string().nullable()
1764
3502
  }),
1765
- poll: z3.object({
1766
- lastPollAt: z3.string().datetime().nullable()
3503
+ poll: z5.object({
3504
+ lastPollAt: z5.string().datetime().nullable()
1767
3505
  })
1768
3506
  });
1769
- var coopConfigV3ObjectSchema = z3.object({
1770
- version: z3.literal(3),
1771
- machineId: z3.string().nullable(),
1772
- defaultProfile: z3.string(),
1773
- profiles: z3.record(profileNameSchema, coopProfileSchema)
3507
+ var coopConfigV3ObjectSchema = z5.object({
3508
+ version: z5.literal(3),
3509
+ machineId: z5.string().nullable(),
3510
+ defaultProfile: z5.string(),
3511
+ profiles: z5.record(profileNameSchema, coopProfileSchema)
1774
3512
  });
1775
3513
  function requireDefaultProfilePointer(config, ctx) {
1776
3514
  if (!(config.defaultProfile in config.profiles)) {
@@ -1784,7 +3522,7 @@ function requireDefaultProfilePointer(config, ctx) {
1784
3522
  var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
1785
3523
  requireDefaultProfilePointer
1786
3524
  );
1787
- var coopConfigSchema = z3.discriminatedUnion("version", [
3525
+ var coopConfigSchema = z5.discriminatedUnion("version", [
1788
3526
  coopConfigV1Schema,
1789
3527
  coopConfigV2Schema,
1790
3528
  coopConfigV3ObjectSchema
@@ -1793,20 +3531,39 @@ var coopConfigSchema = z3.discriminatedUnion("version", [
1793
3531
  requireDefaultProfilePointer(config, ctx);
1794
3532
  }
1795
3533
  });
1796
- var hookStdinSchema = z3.object({
1797
- session_id: z3.string().optional(),
1798
- cwd: z3.string().optional(),
1799
- hook_event_name: z3.string().optional()
3534
+ var hookStdinSchema = z5.object({
3535
+ session_id: z5.string().optional(),
3536
+ cwd: z5.string().optional(),
3537
+ hook_event_name: z5.string().optional()
1800
3538
  }).passthrough();
1801
- var wsClientMessageSchema = z3.discriminatedUnion("type", [
1802
- z3.object({ type: z3.literal("message"), content: messageContentSchema }),
1803
- z3.object({ type: z3.literal("presence_request") }),
1804
- z3.object({ type: z3.literal("ping") })
3539
+ var wsClientMessageSchema = z5.discriminatedUnion("type", [
3540
+ z5.object({ type: z5.literal("message"), content: messageContentSchema }),
3541
+ z5.object({ type: z5.literal("ack"), data: z5.object({ messageId: z5.string() }) }),
3542
+ z5.object({ type: z5.literal("presence_request") }),
3543
+ z5.object({ type: z5.literal("ping") })
1805
3544
  ]);
3545
+ var workKindSchema = z5.enum(["implement", "generate"]);
3546
+ var workCompletionStatusSchema = z5.enum(["succeeded", "failed"]);
3547
+ var poolJoinInputSchema = z5.object({}).passthrough();
3548
+ var poolCheckInInputSchema = z5.object({}).passthrough();
3549
+ var poolCompleteInputSchema = z5.object({
3550
+ workId: z5.string().min(1),
3551
+ status: workCompletionStatusSchema,
3552
+ resultRef: z5.string().max(512),
3553
+ assignmentSeq: z5.number().int().nonnegative()
3554
+ });
3555
+ var poolEnqueueInputSchema = z5.object({
3556
+ kind: workKindSchema,
3557
+ source: z5.string().min(1),
3558
+ dedupKey: z5.string().min(1).max(512).optional()
3559
+ });
3560
+ var poolCancelInputSchema = z5.object({
3561
+ workId: z5.string().min(1)
3562
+ });
1806
3563
 
1807
3564
  // ../shared/src/cli-config.ts
1808
- var DEFAULT_CONFIG_DIR = join5(homedir(), ".coopcli");
1809
- var DEFAULT_CONFIG_PATH = join5(DEFAULT_CONFIG_DIR, "config.json");
3565
+ var DEFAULT_CONFIG_DIR = join12(homedir2(), ".coopcli");
3566
+ var DEFAULT_CONFIG_PATH = join12(DEFAULT_CONFIG_DIR, "config.json");
1810
3567
  var PROD_API_URL = "https://api.coopcli.com";
1811
3568
  function emptyProfile(apiUrl) {
1812
3569
  return {
@@ -1852,7 +3609,7 @@ var ConfigStore = class {
1852
3609
  configDir;
1853
3610
  constructor(configPath) {
1854
3611
  this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
1855
- this.configDir = dirname4(this.configPath);
3612
+ this.configDir = dirname7(this.configPath);
1856
3613
  }
1857
3614
  /**
1858
3615
  * Select the profile this store operates on for the rest of the process
@@ -2143,8 +3900,8 @@ function resolveDisplayName(name, email, userId) {
2143
3900
  async function openBrowser(url) {
2144
3901
  const escaped = url.replace(/"/g, '\\"');
2145
3902
  const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
2146
- return new Promise((resolve6) => {
2147
- exec(cmd, () => resolve6());
3903
+ return new Promise((resolve7) => {
3904
+ exec(cmd, () => resolve7());
2148
3905
  });
2149
3906
  }
2150
3907
  var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
@@ -2229,14 +3986,15 @@ function deriveApiUrl(webUrl) {
2229
3986
  async function login(input, deps) {
2230
3987
  const state = randomBytes(16).toString("hex");
2231
3988
  const machineId = input.machine ?? hostname2();
3989
+ const log = deps.log ?? ((message) => void process.stderr.write(message));
2232
3990
  const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
2233
3991
  try {
2234
3992
  const webUrl = input.webUrl ?? "https://coopcli.com";
2235
3993
  const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
2236
- process.stderr.write(`Opening browser for authentication...
3994
+ log(`Opening browser for authentication...
2237
3995
  `);
2238
3996
  await deps.openBrowser(authUrl);
2239
- process.stderr.write(`Waiting for callback on port ${server.port}...
3997
+ log(`Waiting for callback on port ${server.port}...
2240
3998
  `);
2241
3999
  const params = await server.waitForCallback();
2242
4000
  const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
@@ -2253,7 +4011,7 @@ async function login(input, deps) {
2253
4011
  });
2254
4012
  const config = await deps.config.load();
2255
4013
  if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
2256
- process.stderr.write(
4014
+ log(
2257
4015
  `
2258
4016
  Backend mismatch: you authenticated against ${params.apiUrl}
2259
4017
  but your config points to ${config.apiUrl}
@@ -2333,7 +4091,7 @@ function collectTreeErrors(rootDir) {
2333
4091
  const errors = planErrors(plan);
2334
4092
  const nodeIds = new Set(plan.nodes.map((n) => n.id));
2335
4093
  for (const node of plan.nodes) {
2336
- if (node.type !== "spec") continue;
4094
+ if (node.type !== "proposal") continue;
2337
4095
  let meta;
2338
4096
  try {
2339
4097
  meta = store.readSpecMeta(node.id);
@@ -2371,7 +4129,7 @@ function validateCommand(rootDir, log = console.log, logError = console.error) {
2371
4129
  }
2372
4130
 
2373
4131
  // server/workspace.ts
2374
- import { join as join6, resolve as resolve4 } from "node:path";
4132
+ import { join as join13, resolve as resolve5 } from "node:path";
2375
4133
  var SIDECAR_FILE = "specplan-ui.json";
2376
4134
  var PlanWorkspace = class {
2377
4135
  /** Resolved openspec root directory. */
@@ -2379,13 +4137,13 @@ var PlanWorkspace = class {
2379
4137
  /** IO substrate — real disk by default, in-memory for the cloud target. */
2380
4138
  fsx;
2381
4139
  constructor(dir, fsx = nodeSyncFs) {
2382
- this.dir = resolve4(dir);
4140
+ this.dir = resolve5(dir);
2383
4141
  this.fsx = fsx;
2384
4142
  this.fsx.mkdirp(this.dir);
2385
4143
  }
2386
4144
  // ── Sidecar ──────────────────────────────────────────────────────────────
2387
4145
  readSidecar() {
2388
- const raw = this.fsx.readIfExists(join6(this.dir, SIDECAR_FILE));
4146
+ const raw = this.fsx.readIfExists(join13(this.dir, SIDECAR_FILE));
2389
4147
  if (!raw) return {};
2390
4148
  try {
2391
4149
  return JSON.parse(raw);
@@ -2397,22 +4155,34 @@ var PlanWorkspace = class {
2397
4155
  const now = (/* @__PURE__ */ new Date()).toISOString();
2398
4156
  const current = this.readSidecar();
2399
4157
  const next = update(current);
4158
+ delete next.positions;
2400
4159
  next.createdAt ??= now;
2401
4160
  next.updatedAt = now;
2402
- this.fsx.writeAtomicFile(join6(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
4161
+ this.fsx.writeAtomicFile(join13(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
2403
4162
  }
2404
4163
  readModel() {
2405
4164
  return this.readSidecar().model;
2406
4165
  }
2407
- readPositions() {
2408
- return this.readSidecar().positions ?? {};
4166
+ /**
4167
+ * Collapsed group keys, or undefined when the plan has never saved a
4168
+ * collapse state (the UI then applies the first-load default).
4169
+ */
4170
+ readCollapsedGroups() {
4171
+ const stored = this.readSidecar().collapsedGroups;
4172
+ return Array.isArray(stored) ? stored.filter((k) => typeof k === "string") : void 0;
4173
+ }
4174
+ /** The persisted hide-empty toggle, or undefined when never saved. */
4175
+ readHideEmptySpecs() {
4176
+ const stored = this.readSidecar().hideEmptySpecs;
4177
+ return typeof stored === "boolean" ? stored : void 0;
2409
4178
  }
2410
- /** One sidecar write for the debounced UI autosave (model + positions). */
4179
+ /** One sidecar write for the debounced UI autosave (model + grouping + toggles). */
2411
4180
  writeUiState(state) {
2412
4181
  this.updateSidecar((s) => ({
2413
4182
  ...s,
2414
4183
  ...state.model !== void 0 ? { model: state.model } : {},
2415
- ...state.positions !== void 0 ? { positions: state.positions } : {}
4184
+ ...state.collapsedGroups !== void 0 ? { collapsedGroups: state.collapsedGroups } : {},
4185
+ ...state.hideEmptySpecs !== void 0 ? { hideEmptySpecs: state.hideEmptySpecs } : {}
2416
4186
  }));
2417
4187
  }
2418
4188
  // ── Assistant chat ───────────────────────────────────────────────────────
@@ -2436,8 +4206,11 @@ var PlanWorkspace = class {
2436
4206
  };
2437
4207
 
2438
4208
  // server/cli.ts
4209
+ var CLI_VERSION = true ? "2026.08.17.1" : "dev";
2439
4210
  var USAGE = `Usage: specplan [rootDir] [--port <n>]
4211
+ specplan --version
2440
4212
  specplan generate <specId> [rootDir]
4213
+ specplan apply <proposalId> [rootDir] [--confirm-hand-edit]
2441
4214
  specplan validate [rootDir]
2442
4215
  specplan login [--profile <name>] [--web-url <url>]
2443
4216
 
@@ -2454,6 +4227,10 @@ server moves to the next available port and prints the actual URL.
2454
4227
  generate <specId> runs OpenSpec generation for one spec card headlessly;
2455
4228
  exits non-zero when the output fails \`openspec validate --strict\`.
2456
4229
 
4230
+ apply <proposalId> folds a proposal's delta into openspec/specs/ and archives
4231
+ it (delegating to \`openspec archive\`); exits non-zero on any gate failure.
4232
+ Pass --confirm-hand-edit to fold a body edited since it was last generated.
4233
+
2457
4234
  validate checks the plan files (schema, acyclicity, containment, story
2458
4235
  formula, id references) and exits non-zero with named errors.
2459
4236
 
@@ -2463,6 +4240,65 @@ var UsageError = class extends Error {
2463
4240
  };
2464
4241
  var DEFAULT_ROOT_DIR = "./openspec";
2465
4242
  var DEFAULT_PORT = 8789;
4243
+ function buildGenerateContext(rootDir) {
4244
+ const workspace = new PlanWorkspace(rootDir);
4245
+ const store = new PlanStore(rootDir);
4246
+ const model = GENERATION_MODELS.find((m) => m === workspace.readModel()) ?? DEFAULT_MODEL;
4247
+ return {
4248
+ deps: {
4249
+ anthropic: new Anthropic(),
4250
+ store,
4251
+ systemPrompt: workspace.readSystemPrompt(),
4252
+ refRoot: process.cwd(),
4253
+ harness: createClaudeHarness()
4254
+ },
4255
+ model
4256
+ };
4257
+ }
4258
+ function parseApplyArgs(args) {
4259
+ let confirmHandEdit = false;
4260
+ const positionals = [];
4261
+ for (const tok of args) {
4262
+ if (tok === "--confirm-hand-edit") {
4263
+ confirmHandEdit = true;
4264
+ continue;
4265
+ }
4266
+ if (tok.startsWith("-")) throw new UsageError(`unknown option: ${tok}`);
4267
+ positionals.push(tok);
4268
+ }
4269
+ const [proposalId, rootDir = DEFAULT_ROOT_DIR, ...rest] = positionals;
4270
+ if (!proposalId) throw new UsageError("apply requires a proposal id");
4271
+ if (rest.length > 0) {
4272
+ throw new UsageError("apply takes a proposal id and at most one root directory");
4273
+ }
4274
+ return { proposalId, rootDir, confirmHandEdit };
4275
+ }
4276
+ async function runApply(args, io = console, applyImpl = applyForSpec) {
4277
+ let parsed;
4278
+ try {
4279
+ parsed = parseApplyArgs(args);
4280
+ } catch (err) {
4281
+ io.error(
4282
+ `specplan: ${err instanceof Error ? err.message : String(err)}
4283
+
4284
+ ${USAGE}`
4285
+ );
4286
+ return 1;
4287
+ }
4288
+ const store = new PlanStore(parsed.rootDir);
4289
+ try {
4290
+ const result = await applyImpl({ store }, parsed.proposalId, {
4291
+ confirmHandEdit: parsed.confirmHandEdit
4292
+ });
4293
+ io.log(`specplan: applied ${result.specId} \u2014 folded and archived to ${result.archivedTo}`);
4294
+ for (const cap of result.newCapabilities) io.log(` new capability: ${cap}`);
4295
+ io.log(` SPECS.md row ${result.ledgerRow}`);
4296
+ return 0;
4297
+ } catch (err) {
4298
+ io.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
4299
+ return 1;
4300
+ }
4301
+ }
2466
4302
  function parseCliArgs(argv) {
2467
4303
  let parsed;
2468
4304
  try {
@@ -2507,25 +4343,25 @@ var MIME = {
2507
4343
  ".woff": "font/woff"
2508
4344
  };
2509
4345
  function resolveClientDir(moduleUrl) {
2510
- const here = dirname5(fileURLToPath(moduleUrl));
4346
+ const here = dirname8(fileURLToPath(moduleUrl));
2511
4347
  const candidates = [
2512
- join7(here, "..", "client"),
4348
+ join14(here, "..", "client"),
2513
4349
  // packed: dist/cli -> dist/client
2514
- join7(here, "..", "dist", "client")
4350
+ join14(here, "..", "dist", "client")
2515
4351
  // repo: server/ -> dist/client
2516
4352
  ];
2517
4353
  for (const dir of candidates) {
2518
- if (existsSync4(join7(dir, "index.html"))) return dir;
4354
+ if (existsSync7(join14(dir, "index.html"))) return dir;
2519
4355
  }
2520
4356
  return null;
2521
4357
  }
2522
4358
  function staticResponse(clientDir, pathname) {
2523
4359
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
2524
- const target = resolve5(clientDir, rel === "" ? "index.html" : rel);
2525
- if (!target.startsWith(resolve5(clientDir))) return null;
2526
- const file = existsSync4(target) && extname(target) ? target : join7(clientDir, "index.html");
2527
- if (!existsSync4(file)) return null;
2528
- return new Response(readFileSync4(file), {
4360
+ const target = resolve6(clientDir, rel === "" ? "index.html" : rel);
4361
+ if (!target.startsWith(resolve6(clientDir))) return null;
4362
+ const file = existsSync7(target) && extname(target) ? target : join14(clientDir, "index.html");
4363
+ if (!existsSync7(file)) return null;
4364
+ return new Response(readFileSync5(file), {
2529
4365
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
2530
4366
  });
2531
4367
  }
@@ -2537,12 +4373,12 @@ but chat and "Generate OpenSpec" need one of:
2537
4373
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
2538
4374
  brew install anthropics/tap/ant # macOS
2539
4375
  ant auth login`;
2540
- function detectCredentialSource(env = process.env, home = homedir2()) {
4376
+ function detectCredentialSource(env = process.env, home = homedir3()) {
2541
4377
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
2542
4378
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
2543
4379
  try {
2544
- const dir = join7(home, ".config", "anthropic", "credentials");
2545
- if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
4380
+ const dir = join14(home, ".config", "anthropic", "credentials");
4381
+ if (readdirSync5(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2546
4382
  } catch {
2547
4383
  }
2548
4384
  return null;
@@ -2556,6 +4392,10 @@ function preflight(log = console.log) {
2556
4392
  }
2557
4393
  }
2558
4394
  async function main(argv) {
4395
+ if (argv[0] === "--version") {
4396
+ console.log(CLI_VERSION);
4397
+ return;
4398
+ }
2559
4399
  preflight();
2560
4400
  if (argv[0] === "login") {
2561
4401
  let loginArgs;
@@ -2595,20 +4435,9 @@ ${USAGE}`);
2595
4435
  ${USAGE}`);
2596
4436
  process.exit(1);
2597
4437
  }
2598
- const workspace2 = new PlanWorkspace(rootDir);
2599
- const store2 = new PlanStore(rootDir);
2600
- const model = GENERATION_MODELS.find((m) => m === workspace2.readModel()) ?? DEFAULT_MODEL;
4438
+ const { deps, model } = buildGenerateContext(rootDir);
2601
4439
  try {
2602
- const result = await generateForSpec(
2603
- {
2604
- anthropic: new Anthropic(),
2605
- store: store2,
2606
- systemPrompt: workspace2.readSystemPrompt(),
2607
- refRoot: process.cwd()
2608
- },
2609
- specId,
2610
- model
2611
- );
4440
+ const result = await generateForSpec(deps, specId, model);
2612
4441
  if (result.mode === "metadata-only") {
2613
4442
  console.log(
2614
4443
  `specplan: ${specId} body is hand-edited \u2014 updated metadata only (changes/${specId}/specplan.yaml)`
@@ -2625,6 +4454,9 @@ ${USAGE}`);
2625
4454
  }
2626
4455
  return;
2627
4456
  }
4457
+ if (argv[0] === "apply") {
4458
+ process.exit(await runApply(argv.slice(1)));
4459
+ }
2628
4460
  let args;
2629
4461
  try {
2630
4462
  args = parseCliArgs(argv);
@@ -2641,7 +4473,10 @@ ${USAGE}` : err);
2641
4473
  }
2642
4474
  const workspace = new PlanWorkspace(args.rootDir);
2643
4475
  const store = new PlanStore(args.rootDir);
2644
- const app = createApp(new Anthropic(), workspace, store);
4476
+ const app = createApp(new Anthropic(), workspace, store, {
4477
+ refRoot: process.cwd(),
4478
+ harness: createClaudeHarness()
4479
+ });
2645
4480
  app.get("*", (c) => {
2646
4481
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);
2647
4482
  return res ?? c.notFound();