@coopcli/specplan 5.5.4 → 5.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/dist/cli/index.js +1820 -358
  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-DYWyHZ9F.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-CPH7BDQd.js +1 -0
  10. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-C_eOLe63.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-tsATc02E.js +132 -0
  15. package/dist/client/assets/bn-BD-2XOGV67Q-ClxPGgJn.js +5 -0
  16. package/dist/client/assets/c4Diagram-LMCZKHZV-DYnH3lWl.js +10 -0
  17. package/dist/client/assets/ca-ES-6MX7JW3Y-Ce-mC8Le.js +8 -0
  18. package/dist/client/assets/channel-aEQHEbQr.js +1 -0
  19. package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  20. package/dist/client/assets/chunk-32BRIVSS-BXANaFgi.js +1 -0
  21. package/dist/client/assets/chunk-52WLFC77-Kb6jbJaM.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-Dx3Quqbt.js +1 -0
  26. package/dist/client/assets/chunk-EIO257PC-BiAg7S3S.js +22 -0
  27. package/dist/client/assets/chunk-EX3LRPZG-Cvj4C7h5.js +231 -0
  28. package/dist/client/assets/chunk-FWX5IMBZ-Dwmh6LfQ.js +2 -0
  29. package/dist/client/assets/chunk-HOUHSVGY-B9wU44cg.js +1 -0
  30. package/dist/client/assets/chunk-ICXQ74PX-BSyEFY66.js +2 -0
  31. package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  32. package/dist/client/assets/chunk-K2UTITRG-CoqZrE_Y.js +35 -0
  33. package/dist/client/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  34. package/dist/client/assets/chunk-MOJQB5TN-CHtb7Cmu.js +88 -0
  35. package/dist/client/assets/chunk-OGEWGWER-1nrDWRbg.js +1 -0
  36. package/dist/client/assets/chunk-PUDLZKDR-COVK5AOh.js +156 -0
  37. package/dist/client/assets/chunk-Q4XR5HBZ-Bte6Z4Y6.js +70 -0
  38. package/dist/client/assets/chunk-RYQCIY6F-Y7iEjw2i.js +1 -0
  39. package/dist/client/assets/chunk-SRAX5OIU-Bpn6XoyU.js +1 -0
  40. package/dist/client/assets/chunk-V7JOEXUC-CsT2riud.js +206 -0
  41. package/dist/client/assets/chunk-VAUOI2AC-D14Dfnyc.js +1 -0
  42. package/dist/client/assets/chunk-VR4S4FIN-m_oV_gzU.js +1 -0
  43. package/dist/client/assets/chunk-WYO6CB5R-DWSMU6gp.js +127 -0
  44. package/dist/client/assets/chunk-XXDRQBXY-3irJBvUF.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-CEFMjdw9.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-DvYYcX0E.js +1 -0
  51. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DvYYcX0E.js +1 -0
  52. package/dist/client/assets/cose-bilkent-JH36ORCC-Df934yzX.js +1 -0
  53. package/dist/client/assets/cs-CZ-2BRQDIVT-C5IttjGJ.js +11 -0
  54. package/dist/client/assets/cynefin-VYW2F7L2-ClnIw26J.js +1 -0
  55. package/dist/client/assets/cynefinDiagram-TSTJHNR4-CPRYiRX_.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-C4nF9q2L.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-CGFM396M.js +3 -0
  63. package/dist/client/assets/diagram-G47NLZAW-BXvyFAv9.js +24 -0
  64. package/dist/client/assets/diagram-NH7WQ7WH-2R_WWGMz.js +24 -0
  65. package/dist/client/assets/diagram-OA4YK3LP-WUv5u0Y5.js +30 -0
  66. package/dist/client/assets/diagram-WEI45ONY-CNb7I-i_.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-DK2USxRt.js +29 -0
  70. package/dist/client/assets/dist-qx0Iv9vM.js +1 -0
  71. package/dist/client/assets/ebnfDiagram-CCIWWBDH-DlMvqVKR.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-nQNZerKT.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-BghYzyif.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-Ds32-z1u.js +1 -0
  85. package/dist/client/assets/fr-FR-RHASNOE6-C8c05zVc.js +9 -0
  86. package/dist/client/assets/ganttDiagram-NO4QXBWP-LgOQ3f8i.js +292 -0
  87. package/dist/client/assets/gitGraph-TEB2WS4Q-h8YftM84.js +1 -0
  88. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-C-2lUGWN.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-8w7tFoBO.js +1 -0
  96. package/dist/client/assets/image-blob-reduce.esm-DLFdRAwc.js +2 -0
  97. package/dist/client/assets/index-CX5vrCEC.js +92 -0
  98. package/dist/client/assets/index-Dgo32IwH.css +1 -0
  99. package/dist/client/assets/info-DKCQHKI2-CPjn8MeM.js +1 -0
  100. package/dist/client/assets/infoDiagram-FWYZ7A6U-BWNia170.js +2 -0
  101. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  102. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-DzGb1nwn.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-DCQtd1UV.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-TU2lEHmm.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-Bhnb99qa.js +1 -0
  115. package/dist/client/assets/linear-6pbkJ0-_.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-OvIPAtjO.js +7 -0
  120. package/dist/client/assets/mindmap-definition-LN4V7U3C-CVP_9PTJ.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-DZU_wayH.js +1 -0
  130. package/dist/client/assets/path-BWPyau1x.js +1 -0
  131. package/dist/client/assets/pegDiagram-2B236MQR-B5h_0epx.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-D1Ivmk2g.js +1 -0
  135. package/dist/client/assets/pieDiagram-ENE6RG2P-DFFO1w4T.js +39 -0
  136. package/dist/client/assets/pl-PL-T2D74RX3-qhaGLRNJ.js +9 -0
  137. package/dist/client/assets/prod-CkjOyc2K.js +149 -0
  138. package/dist/client/assets/prod-DtRVLazd.css +1 -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-CV3J7KXU.js +7 -0
  142. package/dist/client/assets/radar-I7S5WNFK-8MrgTeSz.js +1 -0
  143. package/dist/client/assets/railroad-3IZDKUUU-C5HUKwxx.js +1 -0
  144. package/dist/client/assets/railroad-abnf-AHOZXSZD-CmbAkvRY.js +1 -0
  145. package/dist/client/assets/railroad-ebnf-EBAXGLYW-B46ilc8r.js +1 -0
  146. package/dist/client/assets/railroad-peg-LSFZ7HO6-JRE4sO_Q.js +1 -0
  147. package/dist/client/assets/railroadDiagram-RFXS5EU6-CKh8T4lJ.js +1 -0
  148. package/dist/client/assets/requirementDiagram-TGXJPOKE-DjXuDke9.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-BejNjkdX.js +40 -0
  155. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-BvdwpZEb.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-_g_WvJI1.js +1 -0
  161. package/dist/client/assets/stateDiagram-2N3HPSRC-BnGJpSFl.js +1 -0
  162. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-mYNG_j99.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-B0KYjtx_.js +2 -0
  167. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CdcAToWt.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-CeR9YvHk.js +120 -0
  171. package/dist/client/assets/tr-TR-DEFEU3FU-kfju4l2S.js +7 -0
  172. package/dist/client/assets/treeView-QDETBFTQ-7aggPVAO.js +1 -0
  173. package/dist/client/assets/treemap-6X3UGDF4-BIYXi5iS.js +1 -0
  174. package/dist/client/assets/uk-UA-QMV73CPH-CKYaJ9WU.js +6 -0
  175. package/dist/client/assets/vennDiagram-L72KCM5P-CfmtNUHK.js +34 -0
  176. package/dist/client/assets/vi-VN-M7AON7JQ--RTGQlaN.js +5 -0
  177. package/dist/client/assets/wardley-OPB4EBWU-D_cAHc5x.js +1 -0
  178. package/dist/client/assets/wardleyDiagram-EHGQE667-BNoQXjla.js +78 -0
  179. package/dist/client/assets/xychartDiagram-FW5EYKEG-CPMslibf.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 +81 -76
  185. package/dist/client/assets/index-BElJYxPt.js +0 -78
  186. package/dist/client/assets/index-DevSOQuK.css +0 -1
package/dist/cli/index.js CHANGED
@@ -1,14 +1,313 @@
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 dirname7, extname, join as join12, 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 { type: "start", sessionId: message.session_id };
125
+ }
126
+ return;
127
+ }
128
+ case "assistant": {
129
+ const assistant = message;
130
+ for (const block of assistant.message.content) {
131
+ if (block.type === "text") {
132
+ yield { type: "text", text: block.text };
133
+ } else if (block.type === "thinking") {
134
+ yield { type: "thinking", text: block.thinking };
135
+ } else if (block.type === "tool_use") {
136
+ yield { type: "tool_use", id: block.id, name: block.name, input: block.input };
137
+ }
138
+ }
139
+ return;
140
+ }
141
+ case "user": {
142
+ const user = message;
143
+ const content = user.message.content;
144
+ if (typeof content === "string") return;
145
+ for (const block of content) {
146
+ if (block.type === "tool_result") {
147
+ yield {
148
+ type: "tool_result",
149
+ id: block.tool_use_id,
150
+ isError: block.is_error ?? false,
151
+ content: block.content
152
+ };
153
+ }
154
+ }
155
+ return;
156
+ }
157
+ case "result": {
158
+ if (message.subtype === "success") {
159
+ const missingStructured = expectStructuredOutput && message.structured_output === void 0;
160
+ yield {
161
+ type: "result",
162
+ text: message.result,
163
+ costUsd: message.total_cost_usd,
164
+ isError: message.is_error || missingStructured,
165
+ ...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
166
+ };
167
+ } else {
168
+ yield {
169
+ type: "result",
170
+ text: message.errors.join("\n"),
171
+ costUsd: message.total_cost_usd,
172
+ isError: true
173
+ };
174
+ }
175
+ return;
176
+ }
177
+ default:
178
+ return;
179
+ }
180
+ }
181
+ function buildOptions(input, resolved) {
182
+ const profile = input.profile;
183
+ const options = {
184
+ cwd: input.cwd,
185
+ // CRITICAL: an EXPLICIT settingSources: [] loads NOTHING. (The SDK's own
186
+ // default — when the option is omitted — loads ALL sources; we always
187
+ // pass the profile's list so inheritance is an explicit choice, and the
188
+ // profile's sources are what yield LifeOS inheritance.)
189
+ settingSources: profile.settingSources,
190
+ // Never 'bypassPermissions' / 'acceptEdits' — PermissionProfile's type
191
+ // only admits 'default' | 'plan' | 'dontAsk'.
192
+ permissionMode: profile.permissionMode,
193
+ allowedTools: profile.allowedTools,
194
+ canUseTool: makeGate(profile),
195
+ // Containment: destructive-Bash denial must survive a bare allowlisted
196
+ // `Bash` (allow rules short-circuit canUseTool), so it is enforced by a
197
+ // PreToolUse hook, which the SDK evaluates before allow rules.
198
+ hooks: { PreToolUse: [destructiveBashHook(profile)] },
199
+ // Options.env REPLACES the subprocess environment (no merge). The
200
+ // resolver hands back the FULL inherited env (HOME/PATH preserved) with
201
+ // ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN stripped on the subscription
202
+ // path, so the subprocess cannot prefer an API key over the login.
203
+ env: resolved.subprocessEnv
204
+ };
205
+ if (profile.disallowedTools) {
206
+ options.disallowedTools = profile.disallowedTools;
207
+ }
208
+ if (profile.mcpServers) {
209
+ options.mcpServers = profile.mcpServers;
210
+ }
211
+ if (profile.strictMcp !== void 0) {
212
+ options.strictMcpConfig = profile.strictMcp;
213
+ }
214
+ if (input.outputSchema) {
215
+ options.outputFormat = { type: "json_schema", schema: input.outputSchema };
216
+ }
217
+ return options;
218
+ }
219
+ function verifyBilling(resolved, apiKeySource, notify) {
220
+ if (apiKeySource === void 0) return;
221
+ const billedViaApiKey = apiKeySource !== "none";
222
+ if (resolved.path === "subscription" && billedViaApiKey) {
223
+ notify(
224
+ `Warning: the subscription path was selected but the run authenticated from an API-key source ('${apiKeySource}') \u2014 this run bills API credits, not your subscription.`
225
+ );
226
+ } else if (resolved.path !== "subscription" && !billedViaApiKey) {
227
+ notify(
228
+ `Warning: the ${resolved.path} (API credits) path was selected but the run authenticated as first-party (no API key) \u2014 this run bills the subscription.`
229
+ );
230
+ }
231
+ }
232
+ var SDK_UNAVAILABLE_MESSAGE = "Claude Agent SDK not available \u2014 reinstall the CLI (do not use --omit=optional). Global install is required for the agent path; pnpm dlx/npx re-download the ~268 MB native binary.";
233
+ function isSdkAbsence(error) {
234
+ const code = error?.code;
235
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
236
+ const message = error instanceof Error ? error.message : String(error);
237
+ return /Native CLI binary.*not found/i.test(message);
238
+ }
239
+ async function loadSdkQuery(loader) {
240
+ try {
241
+ const mod = await (loader ?? (() => import("@anthropic-ai/claude-agent-sdk")))();
242
+ return mod.query;
243
+ } catch (error) {
244
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
245
+ throw error;
246
+ }
247
+ }
248
+ function createClaudeHarness(deps) {
249
+ return {
250
+ id: "claude",
251
+ async *run(input) {
252
+ const resolved = resolveModelCredential({
253
+ env: deps?.env,
254
+ home: deps?.home,
255
+ useApi: input.useApi
256
+ });
257
+ const notify = input.onNotice ?? ((message) => console.error(message));
258
+ notify(resolved.notice);
259
+ const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
260
+ const expectStructuredOutput = input.outputSchema !== void 0;
261
+ try {
262
+ const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
263
+ for await (const message of stream) {
264
+ if (message.type === "system" && message.subtype === "init") {
265
+ verifyBilling(resolved, message.apiKeySource, notify);
266
+ }
267
+ yield* mapMessage(message, expectStructuredOutput);
268
+ }
269
+ } catch (error) {
270
+ if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
271
+ throw error;
272
+ }
273
+ }
274
+ };
275
+ }
276
+
277
+ // ../agent-harness/src/profiles.ts
278
+ var SIDE_EFFECT_GATED_TOOLS = ["SendMessage", "Send", "Deploy", "Publish"];
279
+ var BLOCKED_BASH_PATTERNS = [
280
+ /\bgit\s+push\b/,
281
+ /\bwrangler\s+deploy\b/,
282
+ /\brm\s+-rf\b/,
283
+ /\bnpm\s+publish\b/
284
+ ];
285
+ function blockedBashPatterns() {
286
+ return [...BLOCKED_BASH_PATTERNS];
287
+ }
288
+ function defaultHeadlessProfile(identity) {
289
+ return {
290
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch"],
291
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
292
+ allowGated: false,
293
+ blockedBashPatterns: blockedBashPatterns(),
294
+ permissionMode: "default",
295
+ settingSources: [],
296
+ identity
297
+ };
298
+ }
299
+ function readMostlyProfile(identity) {
300
+ return {
301
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch", "WebSearch"],
302
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
303
+ allowGated: false,
304
+ blockedBashPatterns: blockedBashPatterns(),
305
+ permissionMode: "default",
306
+ settingSources: [],
307
+ identity
308
+ };
309
+ }
310
+
12
311
  // src/lib/schema.ts
13
312
  import { z } from "zod";
14
313
  var GENERATION_MODELS = [
@@ -18,14 +317,24 @@ var GENERATION_MODELS = [
18
317
  ];
19
318
  var DEFAULT_MODEL = "claude-opus-4-8";
20
319
  var GenerationModel = z.enum(GENERATION_MODELS);
21
- var NodeType = z.enum(["spec", "user_story"]);
22
- var SpecNode = z.object({
320
+ var NodeType = z.enum(["capability", "proposal", "user_story"]);
321
+ var CapabilityNode = z.object({
322
+ /** The capability directory basename (`specs/<cap>/`). */
323
+ id: z.string(),
324
+ type: z.literal("capability"),
325
+ title: z.string(),
326
+ /** Sibling ordering hint — display/tie-break only, never affects edges. */
327
+ order: z.number(),
328
+ /** Repo-relative `specs/<cap>/spec.md` for the read projection. */
329
+ specPath: z.string().optional()
330
+ });
331
+ var ProposalNode = z.object({
23
332
  /** Server-minted `spec-<ulid>` (lowercase). */
24
333
  id: z.string(),
25
- type: z.literal("spec"),
334
+ type: z.literal("proposal"),
26
335
  title: z.string(),
27
336
  /**
28
- * Author's description/prompt for the spec — steering context for
337
+ * Author's description/prompt for the proposal — steering context for
29
338
  * generation. May contain `@` file references, which expand at
30
339
  * generation time like any other input.
31
340
  */
@@ -41,8 +350,12 @@ var StoryNode = z.object({
41
350
  text: z.string(),
42
351
  order: z.number()
43
352
  });
44
- var PlanNode = z.discriminatedUnion("type", [SpecNode, StoryNode]);
45
- var EdgeType = z.enum(["dependency", "containment"]);
353
+ var PlanNode = z.discriminatedUnion("type", [
354
+ CapabilityNode,
355
+ ProposalNode,
356
+ StoryNode
357
+ ]);
358
+ var EdgeType = z.enum(["stacked", "containment", "targets"]);
46
359
  var PlanEdge = z.object({
47
360
  from: z.string(),
48
361
  to: z.string(),
@@ -53,20 +366,8 @@ var Plan = z.object({
53
366
  nodes: z.array(PlanNode),
54
367
  edges: z.array(PlanEdge)
55
368
  });
56
- var SPEC_STATUSES = ["draft", "active", "done", "deferred"];
369
+ var SPEC_STATUSES = ["draft", "in-progress"];
57
370
  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
371
  var ChatRole = z.enum(["user", "assistant", "summary"]);
71
372
  var ChatMessage = z.object({
72
373
  role: ChatRole,
@@ -84,7 +385,14 @@ var ChatRequest = z.object({
84
385
  message: z.string().min(1),
85
386
  /** Conversation history preceding `message`, oldest first. */
86
387
  conversation: z.array(ChatMessage).default([]),
87
- model: GenerationModel.optional()
388
+ model: GenerationModel.optional(),
389
+ /**
390
+ * Chat runtime (agent-harness change, phase 2). `structural` (default) is
391
+ * the non-destructive ≤8-round tool loop that mutates the PlanStore DAG.
392
+ * `power` runs the read-mostly full-tool agent harness BESIDE it — plan
393
+ * mutations still flow only through the structural tools.
394
+ */
395
+ mode: z.enum(["structural", "power"]).default("structural")
88
396
  });
89
397
  var UnresolvedRefInfo = z.object({ ref: z.string(), reason: z.string() });
90
398
  var ChatReply = z.object({
@@ -130,7 +438,7 @@ var GenerateResponse = z.object({
130
438
  /** Change-dir-relative paths written by this generation. */
131
439
  changedFiles: z.array(z.string())
132
440
  });
133
- var NodePosition = z.object({ x: z.number(), y: z.number() });
441
+ var NodePosition = z.object({ y: z.number() });
134
442
  var SpecState = z.object({
135
443
  /** Body files (proposal/design/tasks/specs) exist in the change dir. */
136
444
  hasSpec: z.boolean(),
@@ -139,22 +447,61 @@ var SpecState = z.object({
139
447
  * generated by specplan) — regeneration will be metadata-only.
140
448
  */
141
449
  handEdited: z.boolean(),
142
- /** Lifecycle status from the per-spec metadata file (absent = draft). */
143
- status: SpecStatus
450
+ /** Working state from the per-spec metadata file (absent = draft). */
451
+ status: SpecStatus,
452
+ /** Approved/fold axis (own axis, distinct from `status`); absent = not-approved. */
453
+ approved: z.boolean().optional(),
454
+ /** Build flag (user-set); absent = not-implemented. */
455
+ implemented: z.boolean().optional(),
456
+ /**
457
+ * Read-only delta-completion projection of the change's `tasks.md`
458
+ * checkboxes (`- [ ]` / `- [x]`). Absent when the change has no `tasks.md`
459
+ * (or it carries no checkboxes). NEVER authored or persisted from the card —
460
+ * the ledger + `tasks.md` are the single source of truth.
461
+ */
462
+ tasks: z.object({ done: z.number(), total: z.number() }).optional(),
463
+ /**
464
+ * Read-only lifecycle status projected from the repo `SPECS.md` ledger row
465
+ * for this change (e.g. `draft` / `approved` / `implemented`). Absent when
466
+ * the ledger has no row for the change (or no `SPECS.md` exists). Display
467
+ * only — no writer.
468
+ */
469
+ ledgerStatus: z.string().optional()
144
470
  });
145
471
  var SetStatusRequest = z.object({
146
472
  specId: z.string(),
147
473
  status: SpecStatus
148
474
  });
475
+ var SetImplementedRequest = z.object({
476
+ specId: z.string(),
477
+ implemented: z.boolean()
478
+ });
479
+ var ApplyRequest = z.object({
480
+ specId: z.string().min(1),
481
+ confirmHandEdit: z.boolean().optional()
482
+ });
149
483
  var SessionSnapshot = z.object({
150
484
  /** Display name of the plan root (the openspec directory). */
151
485
  rootName: z.string(),
152
486
  model: GenerationModel.optional(),
153
487
  plan: Plan,
154
- /** Canvas layout per node id (UI sidecar state, not plan data). */
155
- positions: z.record(z.string(), NodePosition),
156
488
  /** Per-spec-node on-disk change state, keyed by spec id. */
157
489
  specStates: z.record(z.string(), SpecState),
490
+ /**
491
+ * Collapsed hyphen-prefix group keys over the capability root lane (UI
492
+ * sidecar state, view-only — never plan data). ABSENT means the plan has
493
+ * never persisted a collapse state: the UI applies the first-load default
494
+ * (collapsed to the first hyphen segment). Present-but-empty means the
495
+ * user expanded everything, and that is remembered.
496
+ */
497
+ collapsedGroups: z.array(z.string()).optional(),
498
+ /**
499
+ * "Hide specs with no scheduled items" toggle (UI sidecar state, view-only —
500
+ * never plan data). Absent = the default (show every row). Hiding is a pure
501
+ * view filter — it drops the empty rows' edges and re-packs the rest without
502
+ * touching plan nodes/edges.
503
+ */
504
+ hideEmptySpecs: z.boolean().optional(),
158
505
  /** Persisted assistant chat, oldest first. */
159
506
  chat: z.array(ChatMessage).optional()
160
507
  });
@@ -162,11 +509,22 @@ var SpecArtifacts = z.object({
162
509
  specId: z.string(),
163
510
  handEdited: z.boolean(),
164
511
  /** Change-dir-relative body files in canonical order. */
165
- files: z.array(z.object({ path: z.string(), content: z.string() }))
512
+ files: z.array(z.object({ path: z.string(), content: z.string() })),
513
+ /**
514
+ * Raw `diagram.excalidraw` JSON when the change dir carries one — for the
515
+ * read-only excalidraw preview in the viewer dialog. Its OWN field on
516
+ * purpose: the diagram is deliberately NOT a body file (not in
517
+ * listBodyFiles/bodyHash/files[]), so its presence never flips the
518
+ * hand-edit gate.
519
+ */
520
+ diagram: z.string().optional()
166
521
  });
167
522
  var SessionSave = z.object({
168
523
  model: GenerationModel.optional(),
169
- positions: z.record(z.string(), NodePosition).optional()
524
+ /** Collapsed group keys (view-only grouping state, remembered per plan). */
525
+ collapsedGroups: z.array(z.string()).optional(),
526
+ /** "Hide specs with no scheduled items" toggle (view-only). */
527
+ hideEmptySpecs: z.boolean().optional()
170
528
  });
171
529
  var CreateSpecRequest = z.object({
172
530
  title: z.string().min(1),
@@ -176,6 +534,7 @@ var CreateSpecRequest = z.object({
176
534
  var CreateStoryRequest = z.object({ text: z.string().min(1) });
177
535
  var LinkRequest = z.object({ from: z.string(), to: z.string() });
178
536
  var ReorderRequest = z.object({ nodeId: z.string(), order: z.number() });
537
+ var ArrangeRequest = z.object({ order: z.array(z.string()) });
179
538
  var PromptSave = z.object({
180
539
  system: z.string().min(1).nullable()
181
540
  });
@@ -188,8 +547,19 @@ var PromptInfo = z.object({
188
547
  custom: z.boolean()
189
548
  });
190
549
 
191
- // server/app.ts
192
- import { Hono } from "hono";
550
+ // server/apply.ts
551
+ import { execFile as execFile2 } from "node:child_process";
552
+ import { dirname as dirname5, join as join7, resolve as resolve3 } from "node:path";
553
+ import { promisify as promisify2 } from "node:util";
554
+
555
+ // server/generate.ts
556
+ import { execFile } from "node:child_process";
557
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
558
+ import { createRequire } from "node:module";
559
+ import { dirname as dirname4, join as join6, resolve as resolve2 } from "node:path";
560
+ import { promisify } from "node:util";
561
+ import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
562
+ import { z as z3 } from "zod";
193
563
 
194
564
  // src/lib/dag.ts
195
565
  function storyError(text) {
@@ -215,13 +585,13 @@ function storyError(text) {
215
585
  var DagError = class extends Error {
216
586
  };
217
587
  function label(node) {
218
- const name = node.type === "spec" ? node.title : node.text;
588
+ const name = node.type === "user_story" ? node.text : node.title;
219
589
  return `${node.id} ("${name.length > 40 ? `${name.slice(0, 40)}\u2026` : name}")`;
220
590
  }
221
591
  function dependencyAdjacency(edges) {
222
592
  const adj = /* @__PURE__ */ new Map();
223
593
  for (const e of edges) {
224
- if (e.type !== "dependency") continue;
594
+ if (e.type !== "stacked") continue;
225
595
  const next = adj.get(e.from) ?? [];
226
596
  next.push(e.to);
227
597
  adj.set(e.from, next);
@@ -251,32 +621,45 @@ function deriveEdge(plan, from, to) {
251
621
  if (!toNode) throw new DagError(`unknown node: ${to}`);
252
622
  if (fromNode.type === "user_story") {
253
623
  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`
624
+ `user stories carry no edges of their own \u2014 ${label(fromNode)} cannot be a link source; link from a proposal instead`
625
+ );
626
+ }
627
+ if (toNode.type === "capability") {
628
+ throw new DagError(
629
+ `capabilities are read-only roots \u2014 ${label(toNode)} cannot be a link target`
255
630
  );
256
631
  }
257
632
  if (from === to) throw new DagError(`cannot link ${label(fromNode)} to itself`);
258
633
  if (plan.edges.some((e) => e.from === from && e.to === to)) {
259
634
  throw new DagError(`link already exists: ${from} -> ${to}`);
260
635
  }
636
+ if (fromNode.type === "capability") {
637
+ if (toNode.type === "user_story") {
638
+ throw new DagError(
639
+ `capabilities contain no user stories \u2014 ${label(fromNode)} may only target a proposal`
640
+ );
641
+ }
642
+ return { from, to, type: "targets" };
643
+ }
261
644
  if (toNode.type === "user_story") return { from, to, type: "containment" };
262
645
  const path = dependencyPath(plan.edges, to, from);
263
646
  if (path) {
264
647
  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(" -> ")}`);
648
+ throw new DagError(`stacked cycle: ${cycle.map((n) => label(n)).join(" -> ")}`);
266
649
  }
267
- return { from, to, type: "dependency" };
650
+ return { from, to, type: "stacked" };
268
651
  }
269
652
  function containedStories(plan, specId) {
270
653
  return plan.edges.filter((e) => e.type === "containment" && e.from === specId).map((e) => e.to);
271
654
  }
272
655
  function specDependencies(plan, specId) {
273
- return plan.edges.filter((e) => e.type === "dependency" && e.from === specId).map((e) => e.to);
656
+ return plan.edges.filter((e) => e.type === "stacked" && e.from === specId).map((e) => e.to);
274
657
  }
275
658
  function backlog(plan) {
276
659
  const linked = new Set(plan.edges.flatMap((e) => [e.from, e.to]));
277
660
  const free = plan.nodes.filter((n) => !linked.has(n.id));
278
661
  return {
279
- specs: sortSiblings(free.filter((n) => n.type === "spec")),
662
+ specs: sortSiblings(free.filter((n) => n.type === "proposal")),
280
663
  stories: sortSiblings(free.filter((n) => n.type === "user_story"))
281
664
  };
282
665
  }
@@ -305,7 +688,19 @@ function planErrors(plan) {
305
688
  errors.push(`user story ${e.from} carries an edge of its own (${e.from} -> ${e.to})`);
306
689
  continue;
307
690
  }
308
- const expected = toNode.type === "user_story" ? "containment" : "dependency";
691
+ if (toNode.type === "capability") {
692
+ errors.push(
693
+ `capability ${e.to} is a read-only root and cannot be a link target (${e.from} -> ${e.to})`
694
+ );
695
+ continue;
696
+ }
697
+ if (fromNode.type === "capability" && toNode.type === "user_story") {
698
+ errors.push(
699
+ `capability ${e.from} contains no user stories \u2014 it may only target a proposal (${e.from} -> ${e.to})`
700
+ );
701
+ continue;
702
+ }
703
+ const expected = fromNode.type === "capability" ? "targets" : toNode.type === "user_story" ? "containment" : "stacked";
309
704
  if (e.type !== expected) {
310
705
  errors.push(`edge ${e.from} -> ${e.to} must be type "${expected}", found "${e.type}"`);
311
706
  }
@@ -314,28 +709,20 @@ function planErrors(plan) {
314
709
  seenEdges.add(key);
315
710
  }
316
711
  const depEdges = plan.edges.filter(
317
- (e) => e.type === "dependency" && byId.has(e.from) && byId.has(e.to)
712
+ (e) => e.type === "stacked" && byId.has(e.from) && byId.has(e.to)
318
713
  );
319
714
  for (const e of depEdges) {
320
715
  const path = dependencyPath(depEdges, e.to, e.from);
321
716
  if (path) {
322
- errors.push(`dependency cycle: ${[e.from, ...path].join(" -> ")}`);
717
+ errors.push(`stacked cycle: ${[e.from, ...path].join(" -> ")}`);
323
718
  break;
324
719
  }
325
720
  }
326
721
  return errors;
327
722
  }
328
723
 
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
724
  // server/atomic.ts
338
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
725
+ import { existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
339
726
  import { dirname } from "node:path";
340
727
  function writeAtomicFile(target, content) {
341
728
  mkdirSync(dirname(target), { recursive: true });
@@ -344,9 +731,386 @@ function writeAtomicFile(target, content) {
344
731
  renameSync(tmp, target);
345
732
  }
346
733
  function readIfExists(target) {
347
- return existsSync(target) ? readFileSync(target, "utf8") : void 0;
734
+ return existsSync2(target) ? readFileSync(target, "utf8") : void 0;
735
+ }
736
+
737
+ // server/journal.ts
738
+ import { join as join4 } from "node:path";
739
+
740
+ // ../shared/src/openspec-change-meta.ts
741
+ import { createHash } from "node:crypto";
742
+ import { existsSync as existsSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
743
+ import { join as join2 } from "node:path";
744
+ import { Document, parseDocument } from "yaml";
745
+ import { z as z2 } from "zod";
746
+
747
+ // ../shared/src/fs-atomic.ts
748
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
749
+ import { dirname as dirname2 } from "node:path";
750
+ function writeAtomicFile2(target, content) {
751
+ mkdirSync2(dirname2(target), { recursive: true });
752
+ const tmp = `${target}.tmp`;
753
+ writeFileSync2(tmp, content, "utf8");
754
+ renameSync2(tmp, target);
755
+ }
756
+ function readIfExists2(target) {
757
+ return existsSync3(target) ? readFileSync2(target, "utf8") : void 0;
348
758
  }
349
759
 
760
+ // ../shared/src/openspec-change-meta.ts
761
+ var CHANGE_META_FILE = "specplan.yaml";
762
+ var nodeChangeMetaFs = {
763
+ readIfExists(path) {
764
+ return readIfExists2(path);
765
+ },
766
+ writeAtomicFile(path, content) {
767
+ writeAtomicFile2(path, content);
768
+ },
769
+ exists(path) {
770
+ return existsSync4(path);
771
+ },
772
+ readdir(path) {
773
+ if (!existsSync4(path)) return [];
774
+ return readdirSync2(path, { withFileTypes: true }).map((entry) => ({
775
+ name: entry.name,
776
+ directory: entry.isDirectory()
777
+ }));
778
+ },
779
+ realpath(path) {
780
+ return realpathSync(path);
781
+ }
782
+ };
783
+ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
784
+ function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
785
+ const files = [];
786
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
787
+ if (fsx.exists(join2(changeDir, name))) files.push(name);
788
+ }
789
+ const specsDir = join2(changeDir, "specs");
790
+ for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
791
+ const rel = join2("specs", entry.name, "spec.md");
792
+ if (entry.directory && fsx.exists(join2(changeDir, rel))) files.push(rel);
793
+ }
794
+ return files;
795
+ }
796
+ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
797
+ const files = listBodyFiles(changeDir, fsx);
798
+ if (files.length === 0) return null;
799
+ const parts = files.map(
800
+ (rel) => `${rel}
801
+ ${sha256(fsx.readIfExists(join2(changeDir, rel)) ?? "")}`
802
+ );
803
+ return `sha256:${sha256(parts.join("\n"))}`;
804
+ }
805
+ var SPEC_STATUSES2 = ["draft", "in-progress"];
806
+ var SpecStatus2 = z2.enum(SPEC_STATUSES2);
807
+ var LEGACY_STATUS_COERCION = {
808
+ active: "in-progress",
809
+ done: "in-progress",
810
+ deferred: "draft"
811
+ };
812
+ var StoredSpecStatus = z2.enum([...SPEC_STATUSES2, "active", "done", "deferred"]).transform(
813
+ (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
814
+ );
815
+ var SpecMetaSchema = z2.object({
816
+ specId: z2.string(),
817
+ userStories: z2.array(z2.string()),
818
+ dependencies: z2.array(z2.string()),
819
+ /** Working state (retired values coerce on read); absent = draft. */
820
+ status: StoredSpecStatus.optional(),
821
+ /**
822
+ * Approved axis (formerly `applied`) — fold-driven, SEPARATE from the
823
+ * working status above. True when the proposal's delta has been folded
824
+ * into specs/ and archived (apply-first: the fold happens at approval).
825
+ * Absent = not-approved.
826
+ */
827
+ approved: z2.boolean().optional(),
828
+ /**
829
+ * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
830
+ * compatibility: readers treat `applied: true` as `approved: true`; no
831
+ * writer ever sets this field again.
832
+ */
833
+ applied: z2.boolean().optional(),
834
+ /**
835
+ * Build flag — user-set, independent of the fold: true once the code has
836
+ * been built to match the approved diff. Absent = not-implemented.
837
+ */
838
+ implemented: z2.boolean().optional(),
839
+ /**
840
+ * Minimal implementation-task record written on fold (specplan-timeline-apply
841
+ * "Implementation task record on fold"): the creation time and a reference to
842
+ * the archived change's own id/path (from which the delta artifact is
843
+ * materialized on demand). Deliberately carries NO status and NO dispatch
844
+ * state — task execution/dispatch is a separate, not-yet-defined concern. A
845
+ * field addition to this sidecar, not a redefinition of its format.
846
+ */
847
+ task: z2.object({
848
+ createdAt: z2.string(),
849
+ source: z2.string()
850
+ }).optional(),
851
+ generation: z2.object({
852
+ /** sha256 of the last generated body files — detects hand edits. */
853
+ lastGeneratedBodyHash: z2.string(),
854
+ lastGeneratedAt: z2.string()
855
+ }).optional()
856
+ });
857
+ function writeChangeMeta(fsx, changeDir, meta) {
858
+ const path = join2(changeDir, CHANGE_META_FILE);
859
+ const raw = fsx.readIfExists(path);
860
+ const doc = raw === void 0 ? new Document({
861
+ specId: meta.specId,
862
+ userStories: meta.userStories ?? [],
863
+ dependencies: meta.dependencies ?? [],
864
+ status: meta.status ?? "draft"
865
+ }) : parseDocument(raw);
866
+ if (meta.approved !== void 0) doc.setIn(["approved"], meta.approved);
867
+ if (meta.implemented !== void 0) doc.setIn(["implemented"], meta.implemented);
868
+ if (meta.task !== void 0) {
869
+ doc.setIn(["task", "createdAt"], meta.task.createdAt);
870
+ doc.setIn(["task", "source"], meta.task.source);
871
+ }
872
+ if (meta.generation !== void 0) {
873
+ doc.setIn(["generation", "lastGeneratedBodyHash"], meta.generation.lastGeneratedBodyHash);
874
+ doc.setIn(["generation", "lastGeneratedAt"], meta.generation.lastGeneratedAt);
875
+ }
876
+ fsx.writeAtomicFile(path, doc.toString());
877
+ }
878
+
879
+ // server/spec-state.ts
880
+ import { dirname as dirname3, join as join3 } from "node:path";
881
+
882
+ // server/fs-node.ts
883
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync3, rmSync } from "node:fs";
884
+ var nodeSyncFs = {
885
+ readIfExists(path) {
886
+ return readIfExists(path);
887
+ },
888
+ writeAtomicFile(path, content) {
889
+ writeAtomicFile(path, content);
890
+ },
891
+ removeFile(path) {
892
+ rmSync(path, { force: true });
893
+ },
894
+ exists(path) {
895
+ return existsSync5(path);
896
+ },
897
+ mkdirp(path) {
898
+ mkdirSync3(path, { recursive: true });
899
+ },
900
+ readdir(path) {
901
+ if (!existsSync5(path)) return [];
902
+ return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
903
+ name: entry.name,
904
+ directory: entry.isDirectory()
905
+ }));
906
+ }
907
+ };
908
+
909
+ // server/spec-state.ts
910
+ function parseTaskProgress(md) {
911
+ const items = md.match(/^[ \t]*[-*] \[[ xX]\]/gm) ?? [];
912
+ if (items.length === 0) return null;
913
+ const done = items.filter((m) => /\[[xX]\]/.test(m)).length;
914
+ return { done, total: items.length };
915
+ }
916
+ function readTaskProgress(changeDir, fsx = nodeSyncFs) {
917
+ const md = fsx.readIfExists(join3(changeDir, "tasks.md"));
918
+ return md === void 0 ? null : parseTaskProgress(md);
919
+ }
920
+ function parseLedgerStatus(md, specId) {
921
+ const cellsOf2 = (line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
922
+ let statusIdx = 2;
923
+ for (const line of md.split(/\r?\n/)) {
924
+ if (!line.trim().startsWith("|")) continue;
925
+ const cells = cellsOf2(line);
926
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
927
+ const lower = cells.map((c) => c.toLowerCase());
928
+ if (lower.includes("status") && lower.includes("change")) {
929
+ statusIdx = lower.indexOf("status");
930
+ continue;
931
+ }
932
+ const first = cells[0] ?? "";
933
+ if (first.includes(specId)) {
934
+ const status = cells[statusIdx];
935
+ if (status) return status;
936
+ }
937
+ }
938
+ return void 0;
939
+ }
940
+ function readLedgerStatus(store, specId) {
941
+ const md = store.fsx.readIfExists(join3(store.dir, "..", "SPECS.md"));
942
+ return md === void 0 ? void 0 : parseLedgerStatus(md, specId);
943
+ }
944
+ function specState(store, specId) {
945
+ const changeDir = dirname3(store.specMetaPath(specId));
946
+ const currentHash = bodyHash(changeDir, store.fsx);
947
+ let meta = null;
948
+ try {
949
+ meta = store.readSpecMeta(specId);
950
+ } catch {
951
+ }
952
+ const status = meta?.status ?? "draft";
953
+ const tasks = readTaskProgress(changeDir, store.fsx);
954
+ const ledgerStatus = readLedgerStatus(store, specId);
955
+ const projections = {
956
+ ...tasks ? { tasks } : {},
957
+ ...ledgerStatus !== void 0 ? { ledgerStatus } : {}
958
+ };
959
+ const axes = {
960
+ ...meta?.approved === true || meta?.applied === true ? { approved: true } : {},
961
+ ...meta?.implemented === true ? { implemented: true } : {}
962
+ };
963
+ if (currentHash === null)
964
+ return { hasSpec: false, handEdited: false, status, ...axes, ...projections };
965
+ return {
966
+ hasSpec: true,
967
+ handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
968
+ status,
969
+ ...axes,
970
+ ...projections
971
+ };
972
+ }
973
+ function readDiagram(store, specId) {
974
+ const changeDir = dirname3(store.specMetaPath(specId));
975
+ return store.fsx.readIfExists(join3(changeDir, "diagram.excalidraw"));
976
+ }
977
+ function readBodyFiles(store, specId) {
978
+ const changeDir = dirname3(store.specMetaPath(specId));
979
+ return listBodyFiles(changeDir, store.fsx).map((rel) => ({ path: rel, content: store.fsx.readIfExists(join3(changeDir, rel)) })).filter((f) => f.content !== void 0);
980
+ }
981
+
982
+ // server/journal.ts
983
+ var JOURNAL_FILE = "specplan-journal.ndjson";
984
+ function escapeRegExp(s) {
985
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
986
+ }
987
+ var PlanJournal = class {
988
+ constructor(fsx, dir) {
989
+ this.fsx = fsx;
990
+ this.dir = dir;
991
+ }
992
+ fsx;
993
+ dir;
994
+ get path() {
995
+ return join4(this.dir, JOURNAL_FILE);
996
+ }
997
+ /**
998
+ * The complete (parseable) journal lines as raw strings, in order. A line
999
+ * that fails to parse — only ever the final line, from a torn write — is
1000
+ * skipped, so the reader always sees whole entries and an append heals the
1001
+ * file. Prior lines are kept as their exact bytes (no re-serialization).
1002
+ */
1003
+ completeLines() {
1004
+ const raw = this.fsx.readIfExists(this.path);
1005
+ if (raw === void 0 || raw === "") return [];
1006
+ const complete = [];
1007
+ for (const line of raw.split("\n")) {
1008
+ if (line.length === 0) continue;
1009
+ try {
1010
+ JSON.parse(line);
1011
+ complete.push(line);
1012
+ } catch {
1013
+ }
1014
+ }
1015
+ return complete;
1016
+ }
1017
+ /** Every complete entry, in append order. Tolerates a torn final line. */
1018
+ read() {
1019
+ return this.completeLines().map((l) => JSON.parse(l));
1020
+ }
1021
+ /**
1022
+ * Append exactly one entry, atomically. Read-modify-write: the prior complete
1023
+ * lines are preserved verbatim, any torn final line is dropped (healed), the
1024
+ * new entry is serialized to one line, and the whole file is rewritten
1025
+ * atomically. `at` defaults to the current ISO timestamp.
1026
+ */
1027
+ append(entry) {
1028
+ const record = { ...entry, at: entry.at ?? (/* @__PURE__ */ new Date()).toISOString() };
1029
+ const lines = this.completeLines();
1030
+ lines.push(JSON.stringify(record));
1031
+ this.fsx.writeAtomicFile(this.path, `${lines.join("\n")}
1032
+ `);
1033
+ }
1034
+ /**
1035
+ * Compare the journal's most recent recorded state for each target against
1036
+ * that target's ACTUAL current on-disk state, and report every divergence.
1037
+ * READ-ONLY — it never rewrites the journal, the plan, or the ledger.
1038
+ *
1039
+ * Per target that appears in the journal:
1040
+ * - if an apply entry recorded `approved: true`: the change must be archived
1041
+ * (a `changes/archive/*-<id>/` dir must exist), its `changes/<id>/` dir must
1042
+ * be gone, and its SPECS.md ledger row (when a ledger exists) must read
1043
+ * approved — each miss is a report;
1044
+ * - if NO apply was journaled but the change IS archived on disk, a manual
1045
+ * `openspec archive` ran out-of-band — a report.
1046
+ */
1047
+ reconcile(store) {
1048
+ const byTarget = /* @__PURE__ */ new Map();
1049
+ for (const entry of this.read()) {
1050
+ if (typeof entry.targetId !== "string" || entry.targetId.length === 0) continue;
1051
+ const list = byTarget.get(entry.targetId) ?? [];
1052
+ list.push(entry);
1053
+ byTarget.set(entry.targetId, list);
1054
+ }
1055
+ const reports = [];
1056
+ const ledgerMd = store.fsx.readIfExists(join4(store.dir, "..", "SPECS.md"));
1057
+ for (const [targetId, entries] of byTarget) {
1058
+ const lastApply = [...entries].reverse().find((e) => e.action === "apply");
1059
+ const journaledApproved = lastApply?.approved === true;
1060
+ const archivedDir = this.locateArchived(store, targetId);
1061
+ if (journaledApproved) {
1062
+ if (!archivedDir) {
1063
+ reports.push({
1064
+ targetId,
1065
+ expected: "archived under changes/archive/",
1066
+ actual: "no archived directory found",
1067
+ detail: `journal records approved: true for ${targetId} but no changes/archive/*-${targetId}/ directory exists`
1068
+ });
1069
+ }
1070
+ const activeDir = join4(store.dir, "changes", targetId);
1071
+ if (store.fsx.exists(activeDir) && listBodyFiles(activeDir, store.fsx).length > 0) {
1072
+ reports.push({
1073
+ targetId,
1074
+ expected: "change directory removed (archived)",
1075
+ actual: `changes/${targetId}/ still present`,
1076
+ detail: `journal records approved: true for ${targetId} but its change directory still exists under changes/, unarchived`
1077
+ });
1078
+ }
1079
+ if (ledgerMd !== void 0) {
1080
+ const status = parseLedgerStatus(ledgerMd, targetId);
1081
+ if (status === void 0 || !/^approved\b/i.test(status)) {
1082
+ reports.push({
1083
+ targetId,
1084
+ expected: "SPECS.md row status approved",
1085
+ actual: status === void 0 ? "no SPECS.md row" : `SPECS.md row status "${status}"`,
1086
+ detail: `journal records approved: true for ${targetId} but its SPECS.md ledger row ${status === void 0 ? "is missing" : `reads "${status}"`}`
1087
+ });
1088
+ }
1089
+ }
1090
+ } else if (archivedDir) {
1091
+ reports.push({
1092
+ targetId,
1093
+ expected: "not archived (no apply recorded in journal)",
1094
+ actual: `archived on disk at ${archivedDir}`,
1095
+ detail: `${targetId} is archived on disk but the journal has no apply entry \u2014 a manual \`openspec archive\` likely ran outside the app`
1096
+ });
1097
+ }
1098
+ }
1099
+ return reports;
1100
+ }
1101
+ /**
1102
+ * The `changes/archive/YYYY-MM-DD-<targetId>/` directory (newest if several),
1103
+ * relative to the openspec root, or null when none exists.
1104
+ */
1105
+ locateArchived(store, targetId) {
1106
+ const archiveDir = join4(store.dir, "changes", "archive");
1107
+ const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(targetId)}$`);
1108
+ const names = store.fsx.readdir(archiveDir).filter((e) => e.directory && re.test(e.name)).map((e) => e.name).sort();
1109
+ const newest = names[names.length - 1];
1110
+ return newest ? join4("changes", "archive", newest) : null;
1111
+ }
1112
+ };
1113
+
350
1114
  // server/model.ts
351
1115
  var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
352
1116
  var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
@@ -373,8 +1137,8 @@ function toolUsesOf(msg) {
373
1137
  }
374
1138
 
375
1139
  // 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";
1140
+ import { readFileSync as readFileSync3, readdirSync as readdirSync4, realpathSync as realpathSync2, statSync } from "node:fs";
1141
+ import { basename, isAbsolute, join as join5, normalize, relative, resolve, sep } from "node:path";
378
1142
  var REF_FILE_BYTE_LIMIT = 64 * 1024;
379
1143
  var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
380
1144
  var REF_DIR_FILE_LIMIT = 50;
@@ -400,14 +1164,14 @@ function collectEntries(root) {
400
1164
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
401
1165
  let entries;
402
1166
  try {
403
- entries = readdirSync(dir, { withFileTypes: true });
1167
+ entries = readdirSync4(dir, { withFileTypes: true });
404
1168
  } catch {
405
1169
  return;
406
1170
  }
407
1171
  for (const entry of entries) {
408
1172
  if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
409
1173
  if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
410
- const full = join(dir, entry.name);
1174
+ const full = join5(dir, entry.name);
411
1175
  if (entry.isDirectory()) {
412
1176
  dirs.push(relative(root, full));
413
1177
  walk(full);
@@ -429,7 +1193,7 @@ function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
429
1193
  if (root === void 0) return [];
430
1194
  let rootReal;
431
1195
  try {
432
- rootReal = realpathSync(resolve(root));
1196
+ rootReal = realpathSync2(resolve(root));
433
1197
  } catch {
434
1198
  return [];
435
1199
  }
@@ -450,7 +1214,7 @@ function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
450
1214
  ).slice(0, limit).map(({ p }) => p);
451
1215
  }
452
1216
  function readClamped(target) {
453
- const raw = readFileSync2(target);
1217
+ const raw = readFileSync3(target);
454
1218
  if (raw.byteLength <= REF_FILE_BYTE_LIMIT) {
455
1219
  return { content: raw.toString("utf8"), truncated: false };
456
1220
  }
@@ -468,7 +1232,7 @@ function expandReferences(text, root) {
468
1232
  let rootReal = null;
469
1233
  if (root !== void 0) {
470
1234
  try {
471
- rootReal = realpathSync(resolve(root));
1235
+ rootReal = realpathSync2(resolve(root));
472
1236
  } catch {
473
1237
  rootReal = null;
474
1238
  }
@@ -494,7 +1258,7 @@ function expandReferences(text, root) {
494
1258
  if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
495
1259
  let body;
496
1260
  try {
497
- body = readClamped(join(rootReal, relPath));
1261
+ body = readClamped(join5(rootReal, relPath));
498
1262
  } catch {
499
1263
  continue;
500
1264
  }
@@ -534,14 +1298,14 @@ function resolveOne(rootReal, ref) {
534
1298
  if (stats?.isDirectory()) {
535
1299
  let real;
536
1300
  try {
537
- real = realpathSync(direct);
1301
+ real = realpathSync2(direct);
538
1302
  } catch {
539
1303
  return { reason: "not readable" };
540
1304
  }
541
1305
  if (real !== rootReal && !insideRoot(rootReal, real)) {
542
1306
  return { reason: "resolves outside the working directory" };
543
1307
  }
544
- const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
1308
+ const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join5(direct, p))).sort((a, b) => {
545
1309
  const depth = a.split(sep).length - b.split(sep).length;
546
1310
  return depth !== 0 ? depth : a.localeCompare(b);
547
1311
  });
@@ -551,7 +1315,7 @@ function resolveOne(rootReal, ref) {
551
1315
  if (stats?.isFile()) {
552
1316
  let real;
553
1317
  try {
554
- real = realpathSync(direct);
1318
+ real = realpathSync2(direct);
555
1319
  } catch {
556
1320
  return { reason: "not readable" };
557
1321
  }
@@ -567,7 +1331,7 @@ function resolveOne(rootReal, ref) {
567
1331
  if (matches.length === 0) return { reason: "not found" };
568
1332
  const best = matches[0];
569
1333
  try {
570
- const body = readClamped(join(rootReal, best));
1334
+ const body = readClamped(join5(rootReal, best));
571
1335
  return {
572
1336
  path: best,
573
1337
  ...body,
@@ -603,78 +1367,6 @@ ${note}${r.content}
603
1367
  return parts.join("\n\n");
604
1368
  }
605
1369
 
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
1370
  // server/generate.ts
679
1371
  var SYSTEM = `You are a principal software architect and specification writer.
680
1372
  Given one planned spec card from a project roadmap \u2014 its title, the user stories it
@@ -713,16 +1405,16 @@ var GenerationFailed = class extends Error {
713
1405
  };
714
1406
  function resolveOpenspecBin() {
715
1407
  const require2 = createRequire(import.meta.url);
716
- let dir = dirname3(require2.resolve("@fission-ai/openspec"));
1408
+ let dir = dirname4(require2.resolve("@fission-ai/openspec"));
717
1409
  for (; ; ) {
718
- const pkgPath = join3(dir, "package.json");
719
- if (existsSync3(pkgPath)) {
720
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1410
+ const pkgPath = join6(dir, "package.json");
1411
+ if (existsSync6(pkgPath)) {
1412
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
721
1413
  if (pkg.name === "@fission-ai/openspec" && pkg.bin) {
722
- return join3(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
1414
+ return join6(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
723
1415
  }
724
1416
  }
725
- const parent = dirname3(dir);
1417
+ const parent = dirname4(dir);
726
1418
  if (parent === dir) throw new Error("could not locate the @fission-ai/openspec bin");
727
1419
  dir = parent;
728
1420
  }
@@ -733,7 +1425,7 @@ async function runOpenspecValidate(specId, rootDir) {
733
1425
  const { stdout, stderr } = await promisify(execFile)(
734
1426
  process.execPath,
735
1427
  [bin, "validate", specId, "--strict"],
736
- { cwd: dirname3(resolve2(rootDir)) }
1428
+ { cwd: dirname4(resolve2(rootDir)) }
737
1429
  );
738
1430
  return { ok: true, output: `${stdout}${stderr}`.trim() };
739
1431
  } catch (err) {
@@ -762,7 +1454,7 @@ Produce the OpenSpec change proposal for exactly this spec as structured output.
762
1454
  Use exactly "${spec.id}" as the changeName.`;
763
1455
  }
764
1456
  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} ---
1457
+ const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join6(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
766
1458
  ${f.content}`);
767
1459
  if (sections.length === 0) return "";
768
1460
  return `
@@ -777,25 +1469,32 @@ function writeBundle(changeDir, bundle) {
777
1469
  "design.md": bundle.design,
778
1470
  "tasks.md": bundle.tasks
779
1471
  };
780
- for (const s of bundle.specs) files[join3("specs", s.capability, "spec.md")] = s.spec;
1472
+ for (const s of bundle.specs) files[join6("specs", s.capability, "spec.md")] = s.spec;
781
1473
  const written = [];
782
1474
  for (const [rel, content] of Object.entries(files)) {
783
- if (readIfExists(join3(changeDir, rel)) === content) continue;
784
- writeAtomicFile(join3(changeDir, rel), content);
1475
+ if (readIfExists(join6(changeDir, rel)) === content) continue;
1476
+ writeAtomicFile(join6(changeDir, rel), content);
785
1477
  written.push(rel);
786
1478
  }
787
1479
  return written;
788
1480
  }
1481
+ var SPEC_BUNDLE_SCHEMA = z3.toJSONSchema(SpecBundle, { target: "draft-7" });
789
1482
  async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
790
1483
  const { store } = deps;
791
1484
  const plan = store.load();
792
1485
  const node = plan.nodes.find((n) => n.id === specId);
793
- if (!node || node.type !== "spec") {
1486
+ if (!node || node.type !== "proposal") {
794
1487
  throw new GenerationFailed(`unknown spec: ${specId}`, 400);
795
1488
  }
1489
+ if (specState(store, specId).approved) {
1490
+ throw new GenerationFailed(
1491
+ `${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.`,
1492
+ 409
1493
+ );
1494
+ }
796
1495
  const stories = storyNodes(plan, specId);
797
1496
  const depSpecs = depNodes(plan, specId);
798
- const changeDir = dirname3(store.specMetaPath(specId));
1497
+ const changeDir = dirname4(store.specMetaPath(specId));
799
1498
  store.writeSpecMeta(
800
1499
  specId,
801
1500
  stories.map((s) => s.id),
@@ -805,6 +1504,7 @@ async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
805
1504
  if (currentHash !== null) {
806
1505
  const recorded = store.readSpecMeta(specId)?.generation?.lastGeneratedBodyHash;
807
1506
  if (recorded !== currentHash) {
1507
+ journalGenerate(store, specId, "metadata-only");
808
1508
  return { specId, mode: "metadata-only", changedFiles: ["specplan.yaml"] };
809
1509
  }
810
1510
  }
@@ -824,22 +1524,59 @@ async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
824
1524
  Referenced files (resolved from @-references in the input):
825
1525
 
826
1526
  ${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);
1527
+ let output;
1528
+ if (deps.harness) {
1529
+ const profile = defaultHeadlessProfile("specplan-generate");
1530
+ let structured;
1531
+ let resultText = "";
1532
+ let resultError = false;
1533
+ for await (const ev of deps.harness.run({
1534
+ prompt: `${system}
1535
+
1536
+ ${prompt}`,
1537
+ cwd: deps.refRoot ?? process.cwd(),
1538
+ session: specId,
1539
+ profile,
1540
+ outputSchema: SPEC_BUNDLE_SCHEMA
1541
+ })) {
1542
+ if (ev.type === "result") {
1543
+ structured = ev.structuredOutput;
1544
+ resultText = ev.text;
1545
+ resultError = ev.isError;
1546
+ }
1547
+ }
1548
+ if (structured === void 0) {
1549
+ throw new GenerationFailed(
1550
+ resultError ? resultText || "the agent run failed" : "model returned no structured output",
1551
+ 502
1552
+ );
1553
+ }
1554
+ const parsed = SpecBundle.safeParse(structured);
1555
+ if (!parsed.success) {
1556
+ throw new GenerationFailed(
1557
+ `model returned structured output that does not match the SpecBundle schema: ${parsed.error.message}`,
1558
+ 502
1559
+ );
1560
+ }
1561
+ output = parsed.data;
1562
+ } else {
1563
+ const msg = await parseWithModelGate(deps.anthropic, {
1564
+ model,
1565
+ max_tokens: 16e3,
1566
+ // non-streaming stays under the SDK HTTP timeout
1567
+ system,
1568
+ messages: [{ role: "user", content: prompt }],
1569
+ output_config: { format: zodOutputFormat(SpecBundle) }
1570
+ // No temperature/top_p or thinking config — both 400 on modern models.
1571
+ });
1572
+ if (msg.stop_reason === "refusal") {
1573
+ throw new GenerationFailed(`the model refused this generation`, 422);
1574
+ }
1575
+ if (!msg.parsed_output) {
1576
+ throw new GenerationFailed("model returned no structured output", 502);
1577
+ }
1578
+ output = SpecBundle.parse(msg.parsed_output);
841
1579
  }
842
- const output = SpecBundle.parse(msg.parsed_output);
843
1580
  const bundle = { ...output, changeName: specId };
844
1581
  const changedFiles = writeBundle(changeDir, bundle);
845
1582
  store.writeGeneration(specId, bodyHash(changeDir) ?? "", (/* @__PURE__ */ new Date()).toISOString());
@@ -851,23 +1588,214 @@ ${verdict.output}`,
851
1588
  422
852
1589
  );
853
1590
  }
1591
+ journalGenerate(store, specId, "generated");
854
1592
  return { specId, mode: "generated", changedFiles };
855
1593
  }
1594
+ function journalGenerate(store, specId, mode) {
1595
+ new PlanJournal(store.fsx, store.dir).append({
1596
+ action: "generate",
1597
+ targetId: specId,
1598
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1599
+ mode
1600
+ });
1601
+ }
856
1602
  function storyNodes(plan, specId) {
857
1603
  const byId = new Map(plan.nodes.map((n) => [n.id, n]));
858
1604
  return containedStories(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "user_story");
859
1605
  }
860
1606
  function depNodes(plan, specId) {
861
1607
  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");
1608
+ return specDependencies(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "proposal");
1609
+ }
1610
+
1611
+ // server/apply.ts
1612
+ import { materializeDeltaArtifact } from "@coopcli/shared/materialize-delta";
1613
+ var ApplyFailed = class extends Error {
1614
+ constructor(message, status) {
1615
+ super(message);
1616
+ this.status = status;
1617
+ }
1618
+ status;
1619
+ };
1620
+ async function runOpenspecArchive(specId, rootDir) {
1621
+ const bin = resolveOpenspecBin();
1622
+ try {
1623
+ const { stdout, stderr } = await promisify2(execFile2)(
1624
+ process.execPath,
1625
+ [bin, "archive", specId, "--yes"],
1626
+ { cwd: dirname5(resolve3(rootDir)) }
1627
+ );
1628
+ return { ok: true, output: `${stdout}${stderr}`.trim() };
1629
+ } catch (err) {
1630
+ const e = err;
1631
+ return {
1632
+ ok: false,
1633
+ output: `${e.stdout ?? ""}${e.stderr ?? ""}`.trim() || (e.message ?? String(err))
1634
+ };
1635
+ }
1636
+ }
1637
+ async function applyForSpec(deps, specId, opts = {}) {
1638
+ const { store } = deps;
1639
+ const runValidate = deps.runValidate ?? runOpenspecValidate;
1640
+ const runArchive = deps.runArchive ?? runOpenspecArchive;
1641
+ const node = store.load().nodes.find((n) => n.id === specId);
1642
+ if (!node || node.type !== "proposal") {
1643
+ throw new ApplyFailed(`unknown spec: ${specId}`, 400);
1644
+ }
1645
+ const state = specState(store, specId);
1646
+ if (state.approved === true) {
1647
+ throw new ApplyFailed(
1648
+ `${specId} is already approved (folded and archived) \u2014 apply is a one-way transition`,
1649
+ 409
1650
+ );
1651
+ }
1652
+ const verdict = await runValidate(specId, store.dir);
1653
+ if (!verdict.ok) {
1654
+ throw new ApplyFailed(
1655
+ `\`openspec validate ${specId} --strict\` failed:
1656
+ ${verdict.output}`,
1657
+ 422
1658
+ );
1659
+ }
1660
+ if (state.handEdited && !opts.confirmHandEdit) {
1661
+ throw new ApplyFailed(
1662
+ `${specId}'s body was hand-edited since it was last generated \u2014 re-run with --confirm-hand-edit to fold it anyway`,
1663
+ 409
1664
+ );
1665
+ }
1666
+ const fingerprint = bodyHash(dirname5(store.specMetaPath(specId)), store.fsx);
1667
+ const archived = await runArchive(specId, store.dir);
1668
+ if (!archived.ok) {
1669
+ throw new ApplyFailed(`\`openspec archive ${specId}\` failed:
1670
+ ${archived.output}`, 502);
1671
+ }
1672
+ const archivedRelDir = locateArchivedDir(store, specId);
1673
+ if (!archivedRelDir) {
1674
+ throw new ApplyFailed(
1675
+ `\`openspec archive ${specId}\` reported success but its archived directory could not be located under changes/archive/`,
1676
+ 502
1677
+ );
1678
+ }
1679
+ store.markApplied(specId, archivedRelDir, {
1680
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1681
+ source: archivedRelDir
1682
+ });
1683
+ const newCapabilities = store.adoptExistingCapabilities().map((n) => n.id);
1684
+ const ledgerRow = maintainLedgerRow(store, specId, archivedRelDir);
1685
+ new PlanJournal(store.fsx, store.dir).append({
1686
+ action: "apply",
1687
+ targetId: specId,
1688
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1689
+ approved: true,
1690
+ fingerprint
1691
+ });
1692
+ return { specId, approved: true, archivedTo: archivedRelDir, newCapabilities, ledgerRow };
1693
+ }
1694
+ function escapeRegExp2(s) {
1695
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1696
+ }
1697
+ function locateArchivedDir(store, specId) {
1698
+ const archiveDir = join7(store.dir, "changes", "archive");
1699
+ const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp2(specId)}$`);
1700
+ const names = store.fsx.readdir(archiveDir).filter((e) => e.directory && re.test(e.name)).map((e) => e.name).sort();
1701
+ const newest = names[names.length - 1];
1702
+ return newest ? join7("changes", "archive", newest) : null;
1703
+ }
1704
+ var APPROVED_LEDGER_STATUS = "approved `[not-implemented]`";
1705
+ function cellsOf(line) {
1706
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
1707
+ }
1708
+ function replaceStatusCell(rawLine, statusIdx, next) {
1709
+ const parts = rawLine.split("|");
1710
+ const idx = statusIdx + 1;
1711
+ const seg = parts[idx];
1712
+ if (seg === void 0) return rawLine;
1713
+ const leading = seg.match(/^\s*/)?.[0] ?? " ";
1714
+ const trailing = seg.match(/\s*$/)?.[0] ?? " ";
1715
+ parts[idx] = `${leading}${next}${trailing}`;
1716
+ return parts.join("|");
1717
+ }
1718
+ function deriveDomains(store, archivedRelDir) {
1719
+ const specsDir = join7(store.dir, archivedRelDir, "specs");
1720
+ const caps = store.fsx.readdir(specsDir).filter((e) => e.directory).map((e) => e.name).sort((a, b) => a.localeCompare(b));
1721
+ return caps.length > 0 ? caps.join(", ") : "(unknown)";
1722
+ }
1723
+ function activeTableInsertIndex(lines) {
1724
+ let sawActive = false;
1725
+ for (let i = 0; i < lines.length; i++) {
1726
+ const trimmed = lines[i].trim();
1727
+ if (/^##\s+active changes/i.test(trimmed)) {
1728
+ sawActive = true;
1729
+ continue;
1730
+ }
1731
+ if (!sawActive) continue;
1732
+ if (trimmed.startsWith("|")) {
1733
+ const cells = cellsOf(lines[i]);
1734
+ if (cells.every((c) => /^:?-+:?$/.test(c))) return i + 1;
1735
+ }
1736
+ }
1737
+ return null;
1738
+ }
1739
+ function freshLedger(row) {
1740
+ return `# Spec Ledger
1741
+
1742
+ ## Active changes
1743
+
1744
+ | Change | Domains | Status | Notes |
1745
+ |---|---|---|---|
1746
+ ${row}
1747
+ `;
1748
+ }
1749
+ function maintainLedgerRow(store, specId, archivedRelDir) {
1750
+ const ledgerPath = join7(store.dir, "..", "SPECS.md");
1751
+ const md = store.fsx.readIfExists(ledgerPath);
1752
+ const archivedBase = archivedRelDir.split("/").pop() ?? archivedRelDir;
1753
+ const token = `\`${specId}\``;
1754
+ if (md !== void 0) {
1755
+ const lines = md.split("\n");
1756
+ let statusIdx = 2;
1757
+ for (let i = 0; i < lines.length; i++) {
1758
+ const line = lines[i];
1759
+ if (!line.trim().startsWith("|")) continue;
1760
+ const cells = cellsOf(line);
1761
+ if (cells.every((c) => /^:?-+:?$/.test(c))) continue;
1762
+ const lower = cells.map((c) => c.toLowerCase());
1763
+ if (lower.includes("status") && lower.includes("change")) {
1764
+ statusIdx = lower.indexOf("status");
1765
+ continue;
1766
+ }
1767
+ if ((cells[0] ?? "").includes(token)) {
1768
+ lines[i] = replaceStatusCell(line, statusIdx, APPROVED_LEDGER_STATUS);
1769
+ store.fsx.writeAtomicFile(ledgerPath, lines.join("\n"));
1770
+ return "updated";
1771
+ }
1772
+ }
1773
+ const row2 = `| ${token} (archived \`${archivedBase}\`) | ${deriveDomains(store, archivedRelDir)} | ${APPROVED_LEDGER_STATUS} | (auto-created by specplan apply) |`;
1774
+ const insertAt = activeTableInsertIndex(lines);
1775
+ if (insertAt !== null) {
1776
+ lines.splice(insertAt, 0, row2);
1777
+ store.fsx.writeAtomicFile(ledgerPath, lines.join("\n"));
1778
+ return "created";
1779
+ }
1780
+ store.fsx.writeAtomicFile(ledgerPath, `${md.replace(/\n*$/, "")}
1781
+
1782
+ ${freshLedger(row2)}`);
1783
+ return "created";
1784
+ }
1785
+ const row = `| ${token} (archived \`${archivedBase}\`) | ${deriveDomains(store, archivedRelDir)} | ${APPROVED_LEDGER_STATUS} | (auto-created by specplan apply) |`;
1786
+ store.fsx.writeAtomicFile(ledgerPath, freshLedger(row));
1787
+ return "created";
863
1788
  }
864
1789
 
1790
+ // server/app.ts
1791
+ import { Hono } from "hono";
1792
+
865
1793
  // server/plan-routes.ts
866
- import { basename as basename3 } from "node:path";
1794
+ import { basename as basename3, join as join9 } from "node:path";
867
1795
 
868
1796
  // server/store.ts
869
- import { basename as basename2, join as join4, resolve as resolve3 } from "node:path";
870
- import { Document, parseDocument } from "yaml";
1797
+ import { basename as basename2, join as join8, resolve as resolve4 } from "node:path";
1798
+ import { Document as Document2, isMap, isScalar, isSeq, parseDocument as parseDocument2 } from "yaml";
871
1799
 
872
1800
  // server/ulid.ts
873
1801
  var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -929,31 +1857,133 @@ var PlanFileError = class extends Error {
929
1857
  };
930
1858
  var ROOT_FILE = "specplan.yaml";
931
1859
  var META_FILE = "specplan.yaml";
1860
+ var CURRENT_PLAN_VERSION = 3;
932
1861
  var PlanStore = class {
933
1862
  /** Resolved openspec root directory. */
934
1863
  dir;
935
1864
  /** IO substrate — real disk by default, in-memory for the cloud target. */
936
1865
  fsx;
937
1866
  constructor(dir, fsx = nodeSyncFs) {
938
- this.dir = resolve3(dir);
1867
+ this.dir = resolve4(dir);
939
1868
  this.fsx = fsx;
940
1869
  this.fsx.mkdirp(this.dir);
941
1870
  }
942
1871
  // ── Root file ────────────────────────────────────────────────────────────
943
1872
  rootPath() {
944
- return join4(this.dir, ROOT_FILE);
1873
+ return join8(this.dir, ROOT_FILE);
945
1874
  }
946
1875
  loadDoc() {
947
1876
  const raw = this.fsx.readIfExists(this.rootPath());
948
- if (raw === void 0) return new Document({ version: 1, nodes: [], edges: [] });
949
- const doc = parseDocument(raw);
1877
+ if (raw === void 0) {
1878
+ return new Document2({ version: CURRENT_PLAN_VERSION, nodes: [], edges: [] });
1879
+ }
1880
+ const doc = parseDocument2(raw);
950
1881
  const fault = doc.errors[0];
951
1882
  if (fault) throw new PlanFileError(`${ROOT_FILE}: ${fault.message}`);
952
- if (doc.getIn(["version"]) === void 0) doc.setIn(["version"], 1);
953
1883
  if (doc.getIn(["nodes"]) === void 0) doc.setIn(["nodes"], doc.createNode([]));
954
1884
  if (doc.getIn(["edges"]) === void 0) doc.setIn(["edges"], doc.createNode([]));
1885
+ this.migrate(doc);
955
1886
  return doc;
956
1887
  }
1888
+ /**
1889
+ * The ordered forward-migration chain: `migrations[n]` transforms a
1890
+ * document that is AT version n into the version-`n+1` SHAPE (the runner
1891
+ * stamps the version after each step). Steps operate on the yaml Document —
1892
+ * never a `toJS()` projection — so hand-written comments survive
1893
+ * byte-for-byte, and each runs only while the file is below
1894
+ * `CURRENT_PLAN_VERSION`, so re-reading a migrated file is a no-op. The
1895
+ * whole run is persisted in ONE atomic write (see `migrate`).
1896
+ */
1897
+ migrations = {
1898
+ // v1 → v2: the split node model (see migrateV1toV2).
1899
+ 1: (doc) => this.migrateV1toV2(doc),
1900
+ // v2 → v3 (positions-drop): free pixel positions are no longer
1901
+ // persisted in the plan file — a card's durable placement is its grid
1902
+ // slot (spec-row membership + `order`), and any residual sidecar pixel
1903
+ // positions are ignored on read + stripped at the workspace boundary.
1904
+ // Nothing in specplan.yaml changes; this step is a pure version stamp.
1905
+ 2: () => {
1906
+ }
1907
+ };
1908
+ /**
1909
+ * Run the forward-migration chain in place, then persist once. The declared
1910
+ * version is read from the doc (a hand skeleton without one is treated as
1911
+ * v1). A version ABOVE `CURRENT_PLAN_VERSION` is refused BEFORE any write —
1912
+ * an older build must never silently downgrade or corrupt a plan a newer
1913
+ * build authored. A version AT current is a no-op (the file is not
1914
+ * rewritten). Otherwise each `v → v+1` step runs in sequence, the version
1915
+ * is stamped after each, and the result is written in a single atomic save
1916
+ * so a throw mid-chain can never leave a half-migrated file behind.
1917
+ */
1918
+ migrate(doc) {
1919
+ const declared = doc.getIn(["version"]);
1920
+ let version = typeof declared === "number" ? declared : 1;
1921
+ if (version > CURRENT_PLAN_VERSION) {
1922
+ throw new PlanFileError(
1923
+ `${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`
1924
+ );
1925
+ }
1926
+ if (version === CURRENT_PLAN_VERSION) return;
1927
+ while (version < CURRENT_PLAN_VERSION) {
1928
+ this.migrations[version]?.(doc);
1929
+ version += 1;
1930
+ doc.setIn(["version"], version);
1931
+ }
1932
+ this.save(doc);
1933
+ }
1934
+ /**
1935
+ * The v1 → v2 forward step (split node model). Operates on the yaml
1936
+ * Document — never a `toJS()` projection — so hand-written comments survive
1937
+ * byte-for-byte. Per node: `type: spec` → `type: proposal` with every other
1938
+ * field untouched; a proposal whose `specFile` sits under `changes/archive/`
1939
+ * is stamped `approved: true` in its metadata file (fold-driven axis — an
1940
+ * archived change IS a folded/approved one). Per edge: `type: dependency` →
1941
+ * `type: stacked` (the ratified vocabulary rename; endpoints never move).
1942
+ * `user_story` nodes and all edge endpoints are untouched. The chain owns
1943
+ * the version stamp; this step performs only the v1→v2 content substitutions.
1944
+ */
1945
+ migrateV1toV2(doc) {
1946
+ const nodes = doc.getIn(["nodes"]);
1947
+ if (isSeq(nodes)) {
1948
+ for (const node of nodes.items) {
1949
+ if (!isMap(node)) continue;
1950
+ const type = node.get("type", true);
1951
+ if (!isScalar(type) || type.value !== "spec") continue;
1952
+ type.value = "proposal";
1953
+ const specFile = node.get("specFile");
1954
+ if (typeof specFile === "string" && specFile.includes("changes/archive/")) {
1955
+ this.stampApprovedAt(String(node.get("id") ?? ""), specFile);
1956
+ }
1957
+ }
1958
+ }
1959
+ const edges = doc.getIn(["edges"]);
1960
+ if (isSeq(edges)) {
1961
+ for (const edge of edges.items) {
1962
+ if (!isMap(edge)) continue;
1963
+ const type = edge.get("type", true);
1964
+ if (isScalar(type) && type.value === "dependency") type.value = "stacked";
1965
+ }
1966
+ }
1967
+ }
1968
+ /**
1969
+ * Migration helper: stamp `approved: true` in the metadata file the node's
1970
+ * `specFile` names (archived changes live under changes/archive/, NOT at
1971
+ * specMetaPath). Preserves comments by editing the existing document;
1972
+ * creates a minimal one when absent; never rewrites an unparseable file.
1973
+ */
1974
+ stampApprovedAt(specId, specFile) {
1975
+ const prefix = `${basename2(this.dir)}/`;
1976
+ const rel = specFile.startsWith(prefix) ? specFile.slice(prefix.length) : specFile;
1977
+ try {
1978
+ const path = join8(this.dir, rel);
1979
+ const raw = this.fsx.readIfExists(path);
1980
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
1981
+ if (doc.errors.length > 0) return;
1982
+ doc.setIn(["approved"], true);
1983
+ this.fsx.writeAtomicFile(path, doc.toString());
1984
+ } catch {
1985
+ }
1986
+ }
957
1987
  planOf(doc) {
958
1988
  const parsed = Plan.safeParse(doc.toJS());
959
1989
  if (!parsed.success) {
@@ -981,7 +2011,7 @@ var PlanStore = class {
981
2011
  return orders.length === 0 ? 0 : Math.max(...orders) + 1;
982
2012
  }
983
2013
  // ── Mutations (canvas routes and chat tools share these) ─────────────────
984
- createSpec(title, description) {
2014
+ createProposal(title, description) {
985
2015
  const trimmed = title.trim();
986
2016
  if (!trimmed) throw new DagError("spec title must not be empty");
987
2017
  const doc = this.loadDoc();
@@ -989,11 +2019,11 @@ var PlanStore = class {
989
2019
  const id = this.mintId("spec", new Set(plan.nodes.map((n) => n.id)));
990
2020
  const node = {
991
2021
  id,
992
- type: "spec",
2022
+ type: "proposal",
993
2023
  title: trimmed,
994
2024
  ...description?.trim() ? { description: description.trim() } : {},
995
2025
  specFile: `${basename2(this.dir)}/changes/${id}/${META_FILE}`,
996
- order: this.nextOrder(plan, "spec")
2026
+ order: this.nextOrder(plan, "proposal")
997
2027
  };
998
2028
  doc.addIn(["nodes"], node);
999
2029
  this.save(doc);
@@ -1004,7 +2034,7 @@ var PlanStore = class {
1004
2034
  initSpecMeta(specId) {
1005
2035
  this.fsx.writeAtomicFile(
1006
2036
  this.specMetaPath(specId),
1007
- new Document({
2037
+ new Document2({
1008
2038
  specId,
1009
2039
  userStories: [],
1010
2040
  dependencies: [],
@@ -1029,15 +2059,20 @@ var PlanStore = class {
1029
2059
  this.save(doc);
1030
2060
  return node;
1031
2061
  }
1032
- /** Add an edge; the type is derived (spec→spec dependency, spec→story containment). */
2062
+ /**
2063
+ * Add an edge; the type is derived (proposal→proposal stacked,
2064
+ * proposal→story containment, capability→proposal targets).
2065
+ */
1033
2066
  link(from, to) {
1034
2067
  const doc = this.loadDoc();
1035
2068
  const plan = this.planOf(doc);
1036
2069
  const edge = deriveEdge(plan, from, to);
1037
2070
  doc.addIn(["edges"], edge);
1038
2071
  this.save(doc);
1039
- const next = { ...plan, edges: [...plan.edges, edge] };
1040
- this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2072
+ if (plan.nodes.find((n) => n.id === from)?.type === "proposal") {
2073
+ const next = { ...plan, edges: [...plan.edges, edge] };
2074
+ this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2075
+ }
1041
2076
  return edge;
1042
2077
  }
1043
2078
  /**
@@ -1053,8 +2088,10 @@ var PlanStore = class {
1053
2088
  if (!edge) throw new DagError(`no link exists: ${from} -> ${to}`);
1054
2089
  doc.deleteIn(["edges", idx]);
1055
2090
  this.save(doc);
1056
- const next = { ...plan, edges: plan.edges.filter((_, i) => i !== idx) };
1057
- this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2091
+ if (plan.nodes.find((n) => n.id === from)?.type === "proposal") {
2092
+ const next = { ...plan, edges: plan.edges.filter((_, i) => i !== idx) };
2093
+ this.writeSpecMeta(from, containedStories(next, from), specDependencies(next, from));
2094
+ }
1058
2095
  return edge;
1059
2096
  }
1060
2097
  /** Update one node's sibling `order` hint. Never touches edges. */
@@ -1068,22 +2105,42 @@ var PlanStore = class {
1068
2105
  this.save(doc);
1069
2106
  return { ...node, order };
1070
2107
  }
2108
+ /**
2109
+ * Reflow a whole row (snap-grid drag-to-place): assign each node in
2110
+ * `orderedIds` its column rank as the new `order`, in ONE atomic write — the
2111
+ * insert-and-reflow of a drop must never be N separate `reorder` calls.
2112
+ * Surgical `setIn` per node so hand-written comments survive. Never touches
2113
+ * edges. Rejects an unknown id BEFORE any write (the file is left untouched).
2114
+ */
2115
+ arrangeRow(orderedIds) {
2116
+ const doc = this.loadDoc();
2117
+ const plan = this.planOf(doc);
2118
+ const indexById = new Map(plan.nodes.map((n, i) => [n.id, i]));
2119
+ for (const id of orderedIds) {
2120
+ if (!indexById.has(id)) throw new DagError(`unknown node: ${id}`);
2121
+ }
2122
+ orderedIds.forEach((id, order) => {
2123
+ doc.setIn(["nodes", indexById.get(id), "order"], order);
2124
+ });
2125
+ this.save(doc);
2126
+ return orderedIds.map((id, order) => ({ ...plan.nodes[indexById.get(id)], order }));
2127
+ }
1071
2128
  /**
1072
2129
  * Adopt pre-existing OpenSpec change directories (layered-storage:
1073
2130
  * "Landing existing changes on the canvas"): every directory under
1074
2131
  * <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
2132
+ * proposal node whose id IS the change-dir basename (the D5 rule), with
2133
+ * its per-spec metadata file initialized when missing. Idempotent; runs
2134
+ * on every session load so a change dir created mid-session lands on the
1078
2135
  * next refresh. Never touches a change's body files.
1079
2136
  */
1080
2137
  adoptExistingChanges() {
1081
- const changesDir = join4(this.dir, "changes");
2138
+ const changesDir = join8(this.dir, "changes");
1082
2139
  if (!this.fsx.exists(changesDir)) return [];
1083
2140
  const doc = this.loadDoc();
1084
2141
  const plan = this.planOf(doc);
1085
2142
  const known = new Set(plan.nodes.map((n) => n.id));
1086
- let order = this.nextOrder(plan, "spec");
2143
+ let order = this.nextOrder(plan, "proposal");
1087
2144
  const adopted = [];
1088
2145
  for (const entry of this.fsx.readdir(changesDir).sort((a, b) => a.name.localeCompare(b.name))) {
1089
2146
  if (!entry.directory || entry.name === "archive" || entry.name.startsWith(".")) {
@@ -1092,7 +2149,7 @@ var PlanStore = class {
1092
2149
  if (known.has(entry.name)) continue;
1093
2150
  const node = {
1094
2151
  id: entry.name,
1095
- type: "spec",
2152
+ type: "proposal",
1096
2153
  title: entry.name,
1097
2154
  specFile: `${basename2(this.dir)}/changes/${entry.name}/${META_FILE}`,
1098
2155
  order: order++
@@ -1110,17 +2167,113 @@ var PlanStore = class {
1110
2167
  }
1111
2168
  return adopted;
1112
2169
  }
2170
+ /**
2171
+ * Adopt current-truth capability directories as read-only capability
2172
+ * roots (sibling of adoptExistingChanges): every directory under
2173
+ * <root>/specs/ that is not yet a node becomes a `capability` node whose
2174
+ * id IS the capability-dir basename. Additive and idempotent — a
2175
+ * capability already present is skipped — and it NEVER writes under
2176
+ * specs/ (capabilities are projected, not authored: no metadata file, no
2177
+ * spec.md rewrite, ever).
2178
+ */
2179
+ adoptExistingCapabilities() {
2180
+ const specsDir = join8(this.dir, "specs");
2181
+ if (!this.fsx.exists(specsDir)) return [];
2182
+ const doc = this.loadDoc();
2183
+ const plan = this.planOf(doc);
2184
+ const known = new Set(plan.nodes.map((n) => n.id));
2185
+ let order = this.nextOrder(plan, "capability");
2186
+ const adopted = [];
2187
+ for (const entry of this.fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
2188
+ if (!entry.directory || entry.name.startsWith(".")) continue;
2189
+ if (known.has(entry.name)) continue;
2190
+ const node = {
2191
+ id: entry.name,
2192
+ type: "capability",
2193
+ title: entry.name,
2194
+ order: order++,
2195
+ specPath: `${basename2(this.dir)}/specs/${entry.name}/spec.md`
2196
+ };
2197
+ doc.addIn(["nodes"], node);
2198
+ adopted.push(node);
2199
+ }
2200
+ if (adopted.length === 0) return [];
2201
+ this.save(doc);
2202
+ return adopted;
2203
+ }
1113
2204
  // ── Per-spec metadata files ──────────────────────────────────────────────
2205
+ /**
2206
+ * The proposal node's `specFile` (undefined for a non-proposal / unknown id).
2207
+ * Read defensively — a corrupt root never propagates out of a path lookup.
2208
+ */
2209
+ specFileOf(specId) {
2210
+ try {
2211
+ const node = this.load().nodes.find((n) => n.id === specId);
2212
+ return node?.type === "proposal" ? node.specFile : void 0;
2213
+ } catch {
2214
+ return void 0;
2215
+ }
2216
+ }
2217
+ /**
2218
+ * The metadata-file path for a spec's change. Normally
2219
+ * `changes/<specId>/specplan.yaml`, BUT once a proposal is folded/archived
2220
+ * its node's `specFile` points under `changes/archive/YYYY-MM-DD-<id>/`
2221
+ * (markApplied / the v1→v2 migration): this follows that pointer so every
2222
+ * reader (`specState`, `readSpecMeta`, the body/diagram viewers) and the
2223
+ * state writers land on the ARCHIVED sidecar — never resurrecting a stray
2224
+ * `changes/<specId>/` dir for an already-archived change.
2225
+ */
1114
2226
  specMetaPath(specId) {
1115
- return join4(this.dir, "changes", specId, META_FILE);
2227
+ const specFile = this.specFileOf(specId);
2228
+ if (specFile?.includes("changes/archive/")) {
2229
+ const prefix = `${basename2(this.dir)}/`;
2230
+ const rel = specFile.startsWith(prefix) ? specFile.slice(prefix.length) : specFile;
2231
+ return join8(this.dir, rel);
2232
+ }
2233
+ return join8(this.dir, "changes", specId, META_FILE);
2234
+ }
2235
+ /**
2236
+ * Record a completed fold (specplan-timeline-apply, after `openspec archive`
2237
+ * has already moved the change dir). In ONE pass: (a) repoint the proposal
2238
+ * node's `specFile` in the root doc to the archived sidecar (surgical
2239
+ * `setIn`, comment-preserving), and (b) write `approved: true` + the minimal
2240
+ * implementation-task record into the sidecar AT THE ARCHIVED path (via the
2241
+ * shared comment-preserving writer). Deliberately NOT `setApproved`: that
2242
+ * writer targets `changes/<specId>/specplan.yaml`, which the archive just
2243
+ * moved — calling it would recreate a stray active change dir (the exact
2244
+ * resurrection hazard the v1→v2 `stampApprovedAt` precedent avoids).
2245
+ *
2246
+ * `archivedRelDir` is the archive directory relative to the openspec root,
2247
+ * e.g. `changes/archive/2026-07-27-<specId>`.
2248
+ */
2249
+ markApplied(specId, archivedRelDir, task) {
2250
+ const newSpecFile = `${basename2(this.dir)}/${archivedRelDir}/${META_FILE}`;
2251
+ const doc = this.loadDoc();
2252
+ const nodes = doc.getIn(["nodes"]);
2253
+ if (isSeq(nodes)) {
2254
+ for (let i = 0; i < nodes.items.length; i++) {
2255
+ const node = nodes.items[i];
2256
+ if (!isMap(node)) continue;
2257
+ if (String(node.get("id") ?? "") === specId) {
2258
+ doc.setIn(["nodes", i, "specFile"], newSpecFile);
2259
+ break;
2260
+ }
2261
+ }
2262
+ }
2263
+ this.save(doc);
2264
+ writeChangeMeta(this.fsx, join8(this.dir, archivedRelDir), {
2265
+ specId,
2266
+ approved: true,
2267
+ task
2268
+ });
1116
2269
  }
1117
2270
  readSpecMeta(specId) {
1118
2271
  const raw = this.fsx.readIfExists(this.specMetaPath(specId));
1119
2272
  if (raw === void 0) return null;
1120
- const doc = parseDocument(raw);
2273
+ const doc = parseDocument2(raw);
1121
2274
  const fault = doc.errors[0];
1122
2275
  if (fault) throw new PlanFileError(`changes/${specId}/${META_FILE}: ${fault.message}`);
1123
- const parsed = SpecMeta.safeParse(doc.toJS());
2276
+ const parsed = SpecMetaSchema.safeParse(doc.toJS());
1124
2277
  if (!parsed.success) {
1125
2278
  const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1126
2279
  throw new PlanFileError(`changes/${specId}/${META_FILE}: ${issues}`);
@@ -1128,37 +2281,75 @@ var PlanStore = class {
1128
2281
  return parsed.data;
1129
2282
  }
1130
2283
  /**
1131
- * Write a spec's metadata file, preserving everything this write does not
1132
- * own (comments, the generation block) by editing the existing document.
2284
+ * Write a proposal's metadata file, preserving everything this write does
2285
+ * not own (comments, the generation block, status, approved, implemented) by editing
2286
+ * the existing document. Proposal-only by contract — capability nodes
2287
+ * have no metadata file and callers must never route one here.
1133
2288
  */
1134
2289
  writeSpecMeta(specId, userStories, dependencies) {
1135
2290
  const path = this.specMetaPath(specId);
1136
2291
  const raw = this.fsx.readIfExists(path);
1137
- const doc = raw === void 0 ? new Document({}) : parseDocument(raw);
2292
+ const doc = raw === void 0 ? new Document2({}) : parseDocument2(raw);
1138
2293
  doc.setIn(["specId"], specId);
1139
2294
  doc.setIn(["userStories"], userStories);
1140
2295
  doc.setIn(["dependencies"], dependencies);
1141
2296
  this.fsx.writeAtomicFile(path, doc.toString());
1142
2297
  }
1143
2298
  /**
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.
2299
+ * Set a proposal's WORKING state (draft | in-progress) in its per-spec
2300
+ * metadata file. User-owned; everything else in the file (stories,
2301
+ * dependencies, generation, comments and the separate `approved` and
2302
+ * `implemented` axes) is preserved: the working-state writer NEVER sets
2303
+ * `approved`. Proposal-only: a capability has no metadata file.
1147
2304
  */
1148
2305
  setStatus(specId, status) {
1149
2306
  const node = this.load().nodes.find((n) => n.id === specId);
1150
- if (!node || node.type !== "spec") throw new DagError(`unknown spec: ${specId}`);
2307
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
1151
2308
  const path = this.specMetaPath(specId);
1152
2309
  const raw = this.fsx.readIfExists(path);
1153
- const doc = raw === void 0 ? new Document({ specId, userStories: [], dependencies: [] }) : parseDocument(raw);
2310
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
1154
2311
  doc.setIn(["status"], status);
1155
2312
  this.fsx.writeAtomicFile(path, doc.toString());
1156
2313
  }
2314
+ /**
2315
+ * Set a proposal's approved/fold state (formerly `setApplied`) — its OWN
2316
+ * axis, deliberately distinct from `setStatus` (the working states) and
2317
+ * `setImplemented` (the build flag). Setting one never touches the others.
2318
+ * Fold/migration-only by contract: under apply-first, `approved` flips
2319
+ * when a delta is folded into specs/ and archived — never from a chip.
2320
+ * Proposal-only: a capability has no metadata file. (The apply *action*
2321
+ * that runs the fold belongs to specplan-timeline-apply; this is just the
2322
+ * state writer.)
2323
+ */
2324
+ setApproved(specId, approved) {
2325
+ const node = this.load().nodes.find((n) => n.id === specId);
2326
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
2327
+ const path = this.specMetaPath(specId);
2328
+ const raw = this.fsx.readIfExists(path);
2329
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
2330
+ doc.setIn(["approved"], approved);
2331
+ this.fsx.writeAtomicFile(path, doc.toString());
2332
+ }
2333
+ /**
2334
+ * Set a proposal's build flag (`implemented`) — user-set, independent of
2335
+ * the fold: true once the code has been built to match the approved diff.
2336
+ * Never touches `status` or `approved`. Proposal-only: a capability has
2337
+ * no metadata file.
2338
+ */
2339
+ setImplemented(specId, implemented) {
2340
+ const node = this.load().nodes.find((n) => n.id === specId);
2341
+ if (!node || node.type !== "proposal") throw new DagError(`unknown spec: ${specId}`);
2342
+ const path = this.specMetaPath(specId);
2343
+ const raw = this.fsx.readIfExists(path);
2344
+ const doc = raw === void 0 ? new Document2({ specId, userStories: [], dependencies: [] }) : parseDocument2(raw);
2345
+ doc.setIn(["implemented"], implemented);
2346
+ this.fsx.writeAtomicFile(path, doc.toString());
2347
+ }
1157
2348
  /** Record a generation result in the spec's metadata file. */
1158
2349
  writeGeneration(specId, lastGeneratedBodyHash, lastGeneratedAt) {
1159
2350
  const path = this.specMetaPath(specId);
1160
2351
  const raw = this.fsx.readIfExists(path);
1161
- const doc = raw === void 0 ? new Document({ specId }) : parseDocument(raw);
2352
+ const doc = raw === void 0 ? new Document2({ specId }) : parseDocument2(raw);
1162
2353
  doc.setIn(["generation", "lastGeneratedBodyHash"], lastGeneratedBodyHash);
1163
2354
  doc.setIn(["generation", "lastGeneratedAt"], lastGeneratedAt);
1164
2355
  this.fsx.writeAtomicFile(path, doc.toString());
@@ -1171,20 +2362,28 @@ function planErrorResponse(c, err) {
1171
2362
  if (err instanceof PlanFileError) return c.json({ error: err.message }, 500);
1172
2363
  throw err;
1173
2364
  }
1174
- function registerPlanRoutes(app, store, workspace) {
2365
+ function registerPlanRoutes(app, store, workspace, opts = {}) {
2366
+ const journal = new PlanJournal(store.fsx, store.dir);
1175
2367
  app.get("/api/session", (c) => {
1176
2368
  try {
1177
2369
  store.adoptExistingChanges();
2370
+ store.adoptExistingCapabilities();
1178
2371
  const plan = store.load();
1179
2372
  const specStates = Object.fromEntries(
1180
- plan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id)])
2373
+ plan.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id)])
1181
2374
  );
2375
+ const collapsedGroups = workspace.readCollapsedGroups();
2376
+ const hideEmptySpecs = workspace.readHideEmptySpecs();
1182
2377
  return c.json({
1183
2378
  rootName: basename3(workspace.dir),
1184
2379
  model: GENERATION_MODELS.find((m) => m === workspace.readModel()),
1185
2380
  plan,
1186
- positions: workspace.readPositions(),
1187
2381
  specStates,
2382
+ // Absent (never saved) → the UI applies the first-load default
2383
+ // collapse; present (even empty) → the remembered per-plan state.
2384
+ ...collapsedGroups !== void 0 ? { collapsedGroups } : {},
2385
+ // Absent → the default (show every row); present → the remembered toggle.
2386
+ ...hideEmptySpecs !== void 0 ? { hideEmptySpecs } : {},
1188
2387
  chat: workspace.readChat()
1189
2388
  });
1190
2389
  } catch (err) {
@@ -1205,10 +2404,9 @@ function registerPlanRoutes(app, store, workspace) {
1205
2404
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1206
2405
  }
1207
2406
  try {
1208
- return c.json(
1209
- { node: store.createSpec(parsed.data.title, parsed.data.description) },
1210
- 201
1211
- );
2407
+ const node = store.createProposal(parsed.data.title, parsed.data.description);
2408
+ journal.append({ action: "create_spec", targetId: node.id, at: (/* @__PURE__ */ new Date()).toISOString() });
2409
+ return c.json({ node }, 201);
1212
2410
  } catch (err) {
1213
2411
  return planErrorResponse(c, err);
1214
2412
  }
@@ -1219,7 +2417,9 @@ function registerPlanRoutes(app, store, workspace) {
1219
2417
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1220
2418
  }
1221
2419
  try {
1222
- return c.json({ node: store.createStory(parsed.data.text) }, 201);
2420
+ const node = store.createStory(parsed.data.text);
2421
+ journal.append({ action: "create_story", targetId: node.id, at: (/* @__PURE__ */ new Date()).toISOString() });
2422
+ return c.json({ node }, 201);
1223
2423
  } catch (err) {
1224
2424
  return planErrorResponse(c, err);
1225
2425
  }
@@ -1230,7 +2430,14 @@ function registerPlanRoutes(app, store, workspace) {
1230
2430
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1231
2431
  }
1232
2432
  try {
1233
- return c.json({ edge: store.link(parsed.data.from, parsed.data.to) }, 201);
2433
+ const edge = store.link(parsed.data.from, parsed.data.to);
2434
+ journal.append({
2435
+ action: "link",
2436
+ targetId: parsed.data.from,
2437
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2438
+ to: parsed.data.to
2439
+ });
2440
+ return c.json({ edge }, 201);
1234
2441
  } catch (err) {
1235
2442
  return planErrorResponse(c, err);
1236
2443
  }
@@ -1239,18 +2446,33 @@ function registerPlanRoutes(app, store, workspace) {
1239
2446
  const specId = c.req.param("specId");
1240
2447
  try {
1241
2448
  const node = store.load().nodes.find((n) => n.id === specId);
1242
- if (!node || node.type !== "spec") {
2449
+ if (!node || node.type !== "proposal") {
1243
2450
  return c.json({ error: `unknown spec: ${specId}` }, 404);
1244
2451
  }
2452
+ const diagram = readDiagram(store, specId);
1245
2453
  return c.json({
1246
2454
  specId,
1247
2455
  handEdited: specState(store, specId).handEdited,
1248
- files: readBodyFiles(store, specId)
2456
+ files: readBodyFiles(store, specId),
2457
+ ...diagram !== void 0 ? { diagram } : {}
1249
2458
  });
1250
2459
  } catch (err) {
1251
2460
  return planErrorResponse(c, err);
1252
2461
  }
1253
2462
  });
2463
+ app.get("/api/capability/:id", (c) => {
2464
+ const id = c.req.param("id");
2465
+ try {
2466
+ const node = store.load().nodes.find((n) => n.id === id);
2467
+ if (!node || node.type !== "capability") {
2468
+ return c.json({ error: `unknown capability: ${id}` }, 404);
2469
+ }
2470
+ const content = store.fsx.readIfExists(join9(store.dir, "specs", id, "spec.md")) ?? "";
2471
+ return c.json({ id, content });
2472
+ } catch (err) {
2473
+ return planErrorResponse(c, err);
2474
+ }
2475
+ });
1254
2476
  app.post("/api/plan/status", async (c) => {
1255
2477
  const parsed = SetStatusRequest.safeParse(await c.req.json().catch(() => null));
1256
2478
  if (!parsed.success) {
@@ -1258,11 +2480,61 @@ function registerPlanRoutes(app, store, workspace) {
1258
2480
  }
1259
2481
  try {
1260
2482
  store.setStatus(parsed.data.specId, parsed.data.status);
2483
+ journal.append({
2484
+ action: "status",
2485
+ targetId: parsed.data.specId,
2486
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2487
+ status: parsed.data.status
2488
+ });
1261
2489
  return c.json({ specId: parsed.data.specId, status: parsed.data.status });
1262
2490
  } catch (err) {
1263
2491
  return planErrorResponse(c, err);
1264
2492
  }
1265
2493
  });
2494
+ app.post("/api/plan/apply", async (c) => {
2495
+ if (!opts.apply) {
2496
+ return c.json({ error: "apply is not available on this deployment target yet" }, 501);
2497
+ }
2498
+ const parsed = ApplyRequest.safeParse(await c.req.json().catch(() => null));
2499
+ if (!parsed.success) {
2500
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2501
+ }
2502
+ try {
2503
+ const result = await opts.apply(parsed.data.specId, parsed.data.confirmHandEdit ?? false);
2504
+ return c.json(result);
2505
+ } catch (err) {
2506
+ const status = err.status;
2507
+ if (typeof status === "number") {
2508
+ return c.json({ error: err.message }, status);
2509
+ }
2510
+ return planErrorResponse(c, err);
2511
+ }
2512
+ });
2513
+ app.post("/api/plan/implemented", async (c) => {
2514
+ const parsed = SetImplementedRequest.safeParse(await c.req.json().catch(() => null));
2515
+ if (!parsed.success) {
2516
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2517
+ }
2518
+ try {
2519
+ store.setImplemented(parsed.data.specId, parsed.data.implemented);
2520
+ journal.append({
2521
+ action: "implemented",
2522
+ targetId: parsed.data.specId,
2523
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2524
+ implemented: parsed.data.implemented
2525
+ });
2526
+ return c.json({ specId: parsed.data.specId, implemented: parsed.data.implemented });
2527
+ } catch (err) {
2528
+ return planErrorResponse(c, err);
2529
+ }
2530
+ });
2531
+ app.get("/api/plan/reconcile", (c) => {
2532
+ try {
2533
+ return c.json({ report: journal.reconcile(store) });
2534
+ } catch (err) {
2535
+ return planErrorResponse(c, err);
2536
+ }
2537
+ });
1266
2538
  app.post("/api/plan/unlink", async (c) => {
1267
2539
  const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1268
2540
  if (!parsed.success) {
@@ -1285,15 +2557,26 @@ function registerPlanRoutes(app, store, workspace) {
1285
2557
  return planErrorResponse(c, err);
1286
2558
  }
1287
2559
  });
2560
+ app.post("/api/plan/arrange", async (c) => {
2561
+ const parsed = ArrangeRequest.safeParse(await c.req.json().catch(() => null));
2562
+ if (!parsed.success) {
2563
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
2564
+ }
2565
+ try {
2566
+ return c.json({ nodes: store.arrangeRow(parsed.data.order) });
2567
+ } catch (err) {
2568
+ return planErrorResponse(c, err);
2569
+ }
2570
+ });
1288
2571
  }
1289
2572
 
1290
2573
  // 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() });
2574
+ import { z as z4 } from "zod";
2575
+ var CreateSpecInput = z4.object({ title: z4.string().min(1) });
2576
+ var CreateStoryInput = z4.object({ text: z4.string().min(1) });
2577
+ var LinkDependencyInput = z4.object({ fromSpecId: z4.string(), toSpecId: z4.string() });
2578
+ var ContainStoryInput = z4.object({ specId: z4.string(), storyId: z4.string() });
2579
+ var ReorderInput = z4.object({ nodeId: z4.string(), order: z4.number() });
1297
2580
  var CHAT_TOOLS = [
1298
2581
  {
1299
2582
  name: "create_spec",
@@ -1367,7 +2650,7 @@ function executeToolCall(store, name, input) {
1367
2650
  switch (name) {
1368
2651
  case "create_spec": {
1369
2652
  const { title } = parseInput(CreateSpecInput, input, name);
1370
- const node = store.createSpec(title);
2653
+ const node = store.createProposal(title);
1371
2654
  return { tool: name, summary: `created spec ${node.id} "${node.title}"` };
1372
2655
  }
1373
2656
  case "create_user_story": {
@@ -1378,9 +2661,9 @@ function executeToolCall(store, name, input) {
1378
2661
  case "link_dependency": {
1379
2662
  const { fromSpecId, toSpecId } = parseInput(LinkDependencyInput, input, name);
1380
2663
  for (const id of [fromSpecId, toSpecId]) {
1381
- if (nodeType(store, id) !== "spec") {
2664
+ if (nodeType(store, id) !== "proposal") {
1382
2665
  throw new DagError(
1383
- `link_dependency links specs only \u2014 ${id} is a user story (use contain_story)`
2666
+ `link_dependency links specs only \u2014 ${id} is not a spec (use contain_story for user stories)`
1384
2667
  );
1385
2668
  }
1386
2669
  }
@@ -1389,7 +2672,7 @@ function executeToolCall(store, name, input) {
1389
2672
  }
1390
2673
  case "contain_story": {
1391
2674
  const { specId, storyId } = parseInput(ContainStoryInput, input, name);
1392
- if (nodeType(store, specId) !== "spec") {
2675
+ if (nodeType(store, specId) !== "proposal") {
1393
2676
  throw new DagError(`contain_story: ${specId} is not a spec`);
1394
2677
  }
1395
2678
  if (nodeType(store, storyId) !== "user_story") {
@@ -1424,6 +2707,13 @@ You CANNOT delete, remove, or rename anything \u2014 no such tool exists, by des
1424
2707
  to delete or remove an item, explain that SpecPlan's assistant is non-destructive (the
1425
2708
  user can edit specplan.yaml by hand) and make NO tool calls.
1426
2709
 
2710
+ You also CANNOT approve, close, or apply a proposal. Applying folds a proposal's delta into
2711
+ specs/ and archives it \u2014 a consuming, largely-irreversible lifecycle transition, the same
2712
+ category the no-delete rule protects \u2014 so there is deliberately no chat tool for it. Apply
2713
+ is a canvas-only, user-gated control (the Apply/Close button) or the headless
2714
+ "specplan apply" CLI verb. If asked to approve, close, or apply a proposal, explain this in
2715
+ plain text and make NO tool calls.
2716
+
1427
2717
  Ground answers in the current plan below. "=== @<path> ===" blocks are real file contents
1428
2718
  resolved from @-references; they are authoritative over your assumptions. Answers render
1429
2719
  as plain text in a narrow side pane: be concise, short paragraphs or simple "- " lists,
@@ -1435,7 +2725,7 @@ function renderPlanContext(plan, statuses) {
1435
2725
  );
1436
2726
  const lines = ["Current plan:"];
1437
2727
  for (const node of plan.nodes) {
1438
- if (node.type !== "spec") continue;
2728
+ if (node.type !== "proposal") continue;
1439
2729
  lines.push(
1440
2730
  `- spec ${node.id} "${node.title}" (order ${node.order}, status ${statuses?.[node.id] ?? "draft"})`
1441
2731
  );
@@ -1447,7 +2737,7 @@ function renderPlanContext(plan, statuses) {
1447
2737
  }
1448
2738
  const free = backlog(plan);
1449
2739
  const backlogLines = [
1450
- ...free.specs.map((n) => `- ${n.id} (spec): ${n.type === "spec" ? n.title : ""}`),
2740
+ ...free.specs.map((n) => `- ${n.id} (spec): ${n.type === "proposal" ? n.title : ""}`),
1451
2741
  ...free.stories.map((n) => `- ${n.id} (story): ${n.type === "user_story" ? n.text : ""}`)
1452
2742
  ];
1453
2743
  lines.push("", "Backlog (not yet linked into the plan):");
@@ -1470,8 +2760,18 @@ function anthropicErrorResponse(c, err, kind) {
1470
2760
  }
1471
2761
  return c.json({ error: kind, detail: message }, 502);
1472
2762
  }
2763
+ function renderToolUseSummary(name, input) {
2764
+ try {
2765
+ const json = JSON.stringify(input);
2766
+ return json && json !== "{}" && json !== "null" ? `${name} ${json}` : name;
2767
+ } catch {
2768
+ return name;
2769
+ }
2770
+ }
1473
2771
  function createApp(anthropic, workspace, store, options) {
1474
2772
  const refRoot = options === void 0 ? process.cwd() : options.refRoot;
2773
+ const harness = options?.harness;
2774
+ const powerProfile = readMostlyProfile("specplan-assistant");
1475
2775
  const app = new Hono();
1476
2776
  app.get("/api/health", (c) => c.json({ ok: true }));
1477
2777
  app.get(
@@ -1483,7 +2783,9 @@ function createApp(anthropic, workspace, store, options) {
1483
2783
  })
1484
2784
  );
1485
2785
  if (workspace && store) {
1486
- registerPlanRoutes(app, store, workspace);
2786
+ registerPlanRoutes(app, store, workspace, {
2787
+ apply: (specId, confirmHandEdit) => applyForSpec({ store }, specId, { confirmHandEdit })
2788
+ });
1487
2789
  app.post("/api/generate", async (c) => {
1488
2790
  const parsed = GenerateRequest.safeParse(await c.req.json().catch(() => null));
1489
2791
  if (!parsed.success) {
@@ -1495,6 +2797,9 @@ function createApp(anthropic, workspace, store, options) {
1495
2797
  {
1496
2798
  anthropic,
1497
2799
  store,
2800
+ // Present → harness structured generation (subscription-first);
2801
+ // absent → the raw @anthropic-ai/sdk path (API credits).
2802
+ harness,
1498
2803
  systemPrompt: workspace.readSystemPrompt(),
1499
2804
  refRoot
1500
2805
  },
@@ -1514,9 +2819,76 @@ function createApp(anthropic, workspace, store, options) {
1514
2819
  if (!parsed.success) {
1515
2820
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1516
2821
  }
1517
- const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
2822
+ const { message, conversation, model = DEFAULT_MODEL, mode } = parsed.data;
1518
2823
  const refs = expandReferences(message, refRoot);
1519
2824
  const refContext = renderReferenceContext(refs);
2825
+ if (mode === "power") {
2826
+ if (!harness) {
2827
+ return c.json(
2828
+ { error: "power-mode-unavailable", detail: "no agent harness configured for this server" },
2829
+ 500
2830
+ );
2831
+ }
2832
+ const chatPlan2 = store.load();
2833
+ const statuses2 = Object.fromEntries(
2834
+ chatPlan2.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id).status])
2835
+ );
2836
+ const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
2837
+ ${m.text}`).join("\n\n");
2838
+ const prompt = `${CHAT_SYSTEM}
2839
+
2840
+ ${renderPlanContext(chatPlan2, statuses2)}` + (history ? `
2841
+
2842
+ Conversation so far:
2843
+
2844
+ ${history}` : "") + `
2845
+
2846
+ [user]
2847
+ ${message}` + (refContext ? `
2848
+
2849
+ Referenced files (resolved from @-references in the message):
2850
+
2851
+ ${refContext}` : "");
2852
+ let answer = "";
2853
+ let resultText = "";
2854
+ let resultError = false;
2855
+ const executed2 = [];
2856
+ try {
2857
+ for await (const ev of harness.run({
2858
+ prompt,
2859
+ cwd: refRoot ?? process.cwd(),
2860
+ session: "specplan-assistant",
2861
+ profile: powerProfile
2862
+ })) {
2863
+ if (ev.type === "text") answer += ev.text;
2864
+ else if (ev.type === "tool_use") {
2865
+ executed2.push({ tool: ev.name, summary: renderToolUseSummary(ev.name, ev.input) });
2866
+ } else if (ev.type === "result") {
2867
+ resultText = ev.text;
2868
+ resultError = ev.isError;
2869
+ }
2870
+ }
2871
+ } catch (err) {
2872
+ return anthropicErrorResponse(c, err, "chat-failed");
2873
+ }
2874
+ const text2 = answer.trim() || resultText.trim();
2875
+ if (!text2) {
2876
+ return resultError ? c.json({ error: "chat-failed", detail: resultText || "the agent run failed" }, 502) : c.json({ error: "model returned no text" }, 502);
2877
+ }
2878
+ const reply2 = { role: "assistant", text: text2, at: (/* @__PURE__ */ new Date()).toISOString() };
2879
+ workspace.writeChat([
2880
+ ...conversation,
2881
+ { role: "user", text: message, at: reply2.at },
2882
+ reply2
2883
+ ]);
2884
+ return c.json({
2885
+ reply: reply2,
2886
+ toolCalls: executed2,
2887
+ plan: store.load(),
2888
+ resolvedRefs: refs.resolved.map((r) => r.path),
2889
+ unresolvedRefs: refs.unresolved
2890
+ });
2891
+ }
1520
2892
  const messages = [
1521
2893
  ...conversation.map((m) => ({
1522
2894
  role: m.role === "user" ? "user" : "assistant",
@@ -1533,7 +2905,7 @@ ${refContext}` : "")
1533
2905
  ];
1534
2906
  const chatPlan = store.load();
1535
2907
  const statuses = Object.fromEntries(
1536
- chatPlan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id).status])
2908
+ chatPlan.nodes.filter((n) => n.type === "proposal").map((n) => [n.id, specState(store, n.id).status])
1537
2909
  );
1538
2910
  const system = `${CHAT_SYSTEM}
1539
2911
 
@@ -1624,14 +2996,14 @@ import { parseArgs } from "node:util";
1624
2996
 
1625
2997
  // ../shared/src/cli-config.ts
1626
2998
  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";
2999
+ import { homedir as homedir2, hostname } from "node:os";
3000
+ import { join as join10, dirname as dirname6 } from "node:path";
1629
3001
 
1630
3002
  // ../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([
3003
+ import { z as z5 } from "zod";
3004
+ var planTierSchema = z5.enum(["hobby", "squad", "org"]);
3005
+ var jurisdictionSchema = z5.enum(["eu", "fedramp"]);
3006
+ var locationHintSchema = z5.enum([
1635
3007
  "wnam",
1636
3008
  "enam",
1637
3009
  "weur",
@@ -1642,135 +3014,135 @@ var locationHintSchema = z3.enum([
1642
3014
  "me",
1643
3015
  "sam"
1644
3016
  ]);
1645
- var sessionNameSchema = z3.string().min(1).max(63).regex(
3017
+ var sessionNameSchema = z5.string().min(1).max(63).regex(
1646
3018
  /^[a-z0-9][a-z0-9-]*$/,
1647
3019
  "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1648
3020
  );
1649
- var teamNameSchema = z3.string().min(1).max(63).regex(
3021
+ var teamNameSchema = z5.string().min(1).max(63).regex(
1650
3022
  /^[a-z0-9][a-z0-9-]*$/,
1651
3023
  "Team name must be lowercase alphanumeric with hyphens"
1652
3024
  );
1653
- var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
1654
- var messageContentSchema = z3.object({
3025
+ var messageKindSchema = z5.enum(["text", "context", "request", "system"]);
3026
+ var messageContentSchema = z5.object({
1655
3027
  kind: messageKindSchema,
1656
3028
  // No .max() here: the 10 000-char body limit is business logic owned by the
1657
3029
  // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
1658
3030
  // both REST and WS. A schema cap would pre-empt it as a generic 400.
1659
- body: z3.string().min(1),
3031
+ body: z5.string().min(1),
1660
3032
  // Two-arg z.record: identical semantics in zod 3, required form in zod 4
1661
3033
  // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
1662
- metadata: z3.record(z3.string(), z3.unknown()).optional()
3034
+ metadata: z5.record(z5.string(), z5.unknown()).optional()
1663
3035
  });
1664
- var createTeamInputSchema = z3.object({
3036
+ var createTeamInputSchema = z5.object({
1665
3037
  name: teamNameSchema
1666
3038
  });
1667
- var createSessionInputSchema = z3.object({
3039
+ var createSessionInputSchema = z5.object({
1668
3040
  name: sessionNameSchema,
1669
- teamId: z3.string().optional(),
3041
+ teamId: z5.string().optional(),
1670
3042
  jurisdiction: jurisdictionSchema.optional(),
1671
3043
  locationHint: locationHintSchema.optional()
1672
3044
  });
1673
- var sendMessageInputSchema = z3.object({
3045
+ var sendMessageInputSchema = z5.object({
1674
3046
  content: messageContentSchema
1675
3047
  });
1676
- var inviteInputSchema = z3.object({
1677
- email: z3.string().email(),
1678
- role: z3.enum(["admin", "member"]).default("member")
3048
+ var inviteInputSchema = z5.object({
3049
+ email: z5.string().email(),
3050
+ role: z5.enum(["admin", "member"]).default("member")
1679
3051
  });
1680
- var joinTeamInputSchema = z3.object({
1681
- inviteCode: z3.string().min(1)
3052
+ var joinTeamInputSchema = z5.object({
3053
+ inviteCode: z5.string().min(1)
1682
3054
  });
1683
- var markReadInputSchema = z3.object({
1684
- notificationIds: z3.array(z3.string().min(1))
3055
+ var markReadInputSchema = z5.object({
3056
+ notificationIds: z5.array(z5.string().min(1))
1685
3057
  });
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()
3058
+ var pollQuerySchema = z5.object({
3059
+ since: z5.string().optional(),
3060
+ limit: z5.coerce.number().int().min(1).max(100).default(50),
3061
+ session: z5.string().optional()
1690
3062
  });
1691
- var messagesQuerySchema = z3.object({
1692
- cursor: z3.string().optional(),
1693
- limit: z3.coerce.number().int().min(1).max(100).default(50)
3063
+ var messagesQuerySchema = z5.object({
3064
+ cursor: z5.string().optional(),
3065
+ limit: z5.coerce.number().int().min(1).max(100).default(50)
1694
3066
  });
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),
3067
+ var teamTypeSchema = z5.enum(["personal", "shared"]);
3068
+ var teamRoleSchema = z5.enum(["owner", "admin", "member"]);
3069
+ var accessPermissionSchema = z5.enum(["read", "write", "admin"]);
3070
+ var accessGrantInputSchema = z5.object({
3071
+ userId: z5.string().min(1),
1700
3072
  sessionName: sessionNameSchema.nullable().default(null),
1701
3073
  // null = general
1702
3074
  permission: accessPermissionSchema
1703
3075
  });
1704
- var machineIdSchema = z3.string().min(1).max(63).regex(
3076
+ var machineIdSchema = z5.string().min(1).max(63).regex(
1705
3077
  /^[a-z0-9][a-z0-9-]*$/,
1706
3078
  "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1707
3079
  );
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)
3080
+ var clerkProfileInputSchema = z5.object({
3081
+ email: z5.string().email().nullable(),
3082
+ displayName: z5.string().min(1).max(100),
3083
+ avatarUrl: z5.string().url().nullable(),
3084
+ provider: z5.string().min(1).max(50),
3085
+ providerUserId: z5.string().min(1).max(100)
1714
3086
  });
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()
3087
+ var sessionEnrollmentSchema = z5.object({
3088
+ sessionId: z5.string(),
3089
+ team: z5.string(),
3090
+ teamName: z5.string().optional(),
3091
+ key: z5.string().startsWith("api_"),
3092
+ enrolledAt: z5.string().datetime(),
3093
+ apiUrl: z5.string().url().optional(),
3094
+ machineId: z5.string().optional()
1723
3095
  });
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()
3096
+ var authConfigSchema = z5.object({
3097
+ key: z5.string().startsWith("api_"),
3098
+ userId: z5.string().min(1),
3099
+ displayName: z5.string().nullable().optional().default(null),
3100
+ email: z5.string().email().nullable(),
3101
+ machineId: z5.string().nullable(),
3102
+ authenticatedAt: z5.string().datetime()
1731
3103
  });
1732
3104
  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()
3105
+ apiUrl: z5.string().url(),
3106
+ machineId: z5.string().nullable(),
3107
+ sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3108
+ defaults: z5.object({
3109
+ team: z5.string().nullable(),
3110
+ session: z5.string().nullable()
1739
3111
  }),
1740
- poll: z3.object({
1741
- lastPollAt: z3.string().datetime().nullable()
3112
+ poll: z5.object({
3113
+ lastPollAt: z5.string().datetime().nullable()
1742
3114
  })
1743
3115
  };
1744
- var coopConfigV1Schema = z3.object({
1745
- version: z3.literal(1),
3116
+ var coopConfigV1Schema = z5.object({
3117
+ version: z5.literal(1),
1746
3118
  ...configBaseFields
1747
3119
  });
1748
- var coopConfigV2Schema = z3.object({
1749
- version: z3.literal(2),
3120
+ var coopConfigV2Schema = z5.object({
3121
+ version: z5.literal(2),
1750
3122
  ...configBaseFields,
1751
3123
  auth: authConfigSchema.optional()
1752
3124
  });
1753
- var profileNameSchema = z3.string().regex(
3125
+ var profileNameSchema = z5.string().regex(
1754
3126
  /^[a-z0-9][a-z0-9_-]{0,63}$/,
1755
3127
  "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
1756
3128
  );
1757
- var coopProfileSchema = z3.object({
1758
- apiUrl: z3.string().url(),
3129
+ var coopProfileSchema = z5.object({
3130
+ apiUrl: z5.string().url(),
1759
3131
  auth: authConfigSchema.optional(),
1760
- sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1761
- defaults: z3.object({
1762
- team: z3.string().nullable(),
1763
- session: z3.string().nullable()
3132
+ sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3133
+ defaults: z5.object({
3134
+ team: z5.string().nullable(),
3135
+ session: z5.string().nullable()
1764
3136
  }),
1765
- poll: z3.object({
1766
- lastPollAt: z3.string().datetime().nullable()
3137
+ poll: z5.object({
3138
+ lastPollAt: z5.string().datetime().nullable()
1767
3139
  })
1768
3140
  });
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)
3141
+ var coopConfigV3ObjectSchema = z5.object({
3142
+ version: z5.literal(3),
3143
+ machineId: z5.string().nullable(),
3144
+ defaultProfile: z5.string(),
3145
+ profiles: z5.record(profileNameSchema, coopProfileSchema)
1774
3146
  });
1775
3147
  function requireDefaultProfilePointer(config, ctx) {
1776
3148
  if (!(config.defaultProfile in config.profiles)) {
@@ -1784,7 +3156,7 @@ function requireDefaultProfilePointer(config, ctx) {
1784
3156
  var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
1785
3157
  requireDefaultProfilePointer
1786
3158
  );
1787
- var coopConfigSchema = z3.discriminatedUnion("version", [
3159
+ var coopConfigSchema = z5.discriminatedUnion("version", [
1788
3160
  coopConfigV1Schema,
1789
3161
  coopConfigV2Schema,
1790
3162
  coopConfigV3ObjectSchema
@@ -1793,20 +3165,39 @@ var coopConfigSchema = z3.discriminatedUnion("version", [
1793
3165
  requireDefaultProfilePointer(config, ctx);
1794
3166
  }
1795
3167
  });
1796
- var hookStdinSchema = z3.object({
1797
- session_id: z3.string().optional(),
1798
- cwd: z3.string().optional(),
1799
- hook_event_name: z3.string().optional()
3168
+ var hookStdinSchema = z5.object({
3169
+ session_id: z5.string().optional(),
3170
+ cwd: z5.string().optional(),
3171
+ hook_event_name: z5.string().optional()
1800
3172
  }).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") })
3173
+ var wsClientMessageSchema = z5.discriminatedUnion("type", [
3174
+ z5.object({ type: z5.literal("message"), content: messageContentSchema }),
3175
+ z5.object({ type: z5.literal("ack"), data: z5.object({ messageId: z5.string() }) }),
3176
+ z5.object({ type: z5.literal("presence_request") }),
3177
+ z5.object({ type: z5.literal("ping") })
1805
3178
  ]);
3179
+ var workKindSchema = z5.enum(["implement", "generate"]);
3180
+ var workCompletionStatusSchema = z5.enum(["succeeded", "failed"]);
3181
+ var poolJoinInputSchema = z5.object({}).passthrough();
3182
+ var poolCheckInInputSchema = z5.object({}).passthrough();
3183
+ var poolCompleteInputSchema = z5.object({
3184
+ workId: z5.string().min(1),
3185
+ status: workCompletionStatusSchema,
3186
+ resultRef: z5.string().max(512),
3187
+ assignmentSeq: z5.number().int().nonnegative()
3188
+ });
3189
+ var poolEnqueueInputSchema = z5.object({
3190
+ kind: workKindSchema,
3191
+ source: z5.string().min(1),
3192
+ dedupKey: z5.string().min(1).max(512).optional()
3193
+ });
3194
+ var poolCancelInputSchema = z5.object({
3195
+ workId: z5.string().min(1)
3196
+ });
1806
3197
 
1807
3198
  // ../shared/src/cli-config.ts
1808
- var DEFAULT_CONFIG_DIR = join5(homedir(), ".coopcli");
1809
- var DEFAULT_CONFIG_PATH = join5(DEFAULT_CONFIG_DIR, "config.json");
3199
+ var DEFAULT_CONFIG_DIR = join10(homedir2(), ".coopcli");
3200
+ var DEFAULT_CONFIG_PATH = join10(DEFAULT_CONFIG_DIR, "config.json");
1810
3201
  var PROD_API_URL = "https://api.coopcli.com";
1811
3202
  function emptyProfile(apiUrl) {
1812
3203
  return {
@@ -1852,7 +3243,7 @@ var ConfigStore = class {
1852
3243
  configDir;
1853
3244
  constructor(configPath) {
1854
3245
  this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
1855
- this.configDir = dirname4(this.configPath);
3246
+ this.configDir = dirname6(this.configPath);
1856
3247
  }
1857
3248
  /**
1858
3249
  * Select the profile this store operates on for the rest of the process
@@ -2143,8 +3534,8 @@ function resolveDisplayName(name, email, userId) {
2143
3534
  async function openBrowser(url) {
2144
3535
  const escaped = url.replace(/"/g, '\\"');
2145
3536
  const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
2146
- return new Promise((resolve6) => {
2147
- exec(cmd, () => resolve6());
3537
+ return new Promise((resolve7) => {
3538
+ exec(cmd, () => resolve7());
2148
3539
  });
2149
3540
  }
2150
3541
  var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
@@ -2333,7 +3724,7 @@ function collectTreeErrors(rootDir) {
2333
3724
  const errors = planErrors(plan);
2334
3725
  const nodeIds = new Set(plan.nodes.map((n) => n.id));
2335
3726
  for (const node of plan.nodes) {
2336
- if (node.type !== "spec") continue;
3727
+ if (node.type !== "proposal") continue;
2337
3728
  let meta;
2338
3729
  try {
2339
3730
  meta = store.readSpecMeta(node.id);
@@ -2371,7 +3762,7 @@ function validateCommand(rootDir, log = console.log, logError = console.error) {
2371
3762
  }
2372
3763
 
2373
3764
  // server/workspace.ts
2374
- import { join as join6, resolve as resolve4 } from "node:path";
3765
+ import { join as join11, resolve as resolve5 } from "node:path";
2375
3766
  var SIDECAR_FILE = "specplan-ui.json";
2376
3767
  var PlanWorkspace = class {
2377
3768
  /** Resolved openspec root directory. */
@@ -2379,13 +3770,13 @@ var PlanWorkspace = class {
2379
3770
  /** IO substrate — real disk by default, in-memory for the cloud target. */
2380
3771
  fsx;
2381
3772
  constructor(dir, fsx = nodeSyncFs) {
2382
- this.dir = resolve4(dir);
3773
+ this.dir = resolve5(dir);
2383
3774
  this.fsx = fsx;
2384
3775
  this.fsx.mkdirp(this.dir);
2385
3776
  }
2386
3777
  // ── Sidecar ──────────────────────────────────────────────────────────────
2387
3778
  readSidecar() {
2388
- const raw = this.fsx.readIfExists(join6(this.dir, SIDECAR_FILE));
3779
+ const raw = this.fsx.readIfExists(join11(this.dir, SIDECAR_FILE));
2389
3780
  if (!raw) return {};
2390
3781
  try {
2391
3782
  return JSON.parse(raw);
@@ -2397,22 +3788,34 @@ var PlanWorkspace = class {
2397
3788
  const now = (/* @__PURE__ */ new Date()).toISOString();
2398
3789
  const current = this.readSidecar();
2399
3790
  const next = update(current);
3791
+ delete next.positions;
2400
3792
  next.createdAt ??= now;
2401
3793
  next.updatedAt = now;
2402
- this.fsx.writeAtomicFile(join6(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
3794
+ this.fsx.writeAtomicFile(join11(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
2403
3795
  }
2404
3796
  readModel() {
2405
3797
  return this.readSidecar().model;
2406
3798
  }
2407
- readPositions() {
2408
- return this.readSidecar().positions ?? {};
3799
+ /**
3800
+ * Collapsed group keys, or undefined when the plan has never saved a
3801
+ * collapse state (the UI then applies the first-load default).
3802
+ */
3803
+ readCollapsedGroups() {
3804
+ const stored = this.readSidecar().collapsedGroups;
3805
+ return Array.isArray(stored) ? stored.filter((k) => typeof k === "string") : void 0;
2409
3806
  }
2410
- /** One sidecar write for the debounced UI autosave (model + positions). */
3807
+ /** The persisted hide-empty toggle, or undefined when never saved. */
3808
+ readHideEmptySpecs() {
3809
+ const stored = this.readSidecar().hideEmptySpecs;
3810
+ return typeof stored === "boolean" ? stored : void 0;
3811
+ }
3812
+ /** One sidecar write for the debounced UI autosave (model + grouping + toggles). */
2411
3813
  writeUiState(state) {
2412
3814
  this.updateSidecar((s) => ({
2413
3815
  ...s,
2414
3816
  ...state.model !== void 0 ? { model: state.model } : {},
2415
- ...state.positions !== void 0 ? { positions: state.positions } : {}
3817
+ ...state.collapsedGroups !== void 0 ? { collapsedGroups: state.collapsedGroups } : {},
3818
+ ...state.hideEmptySpecs !== void 0 ? { hideEmptySpecs: state.hideEmptySpecs } : {}
2416
3819
  }));
2417
3820
  }
2418
3821
  // ── Assistant chat ───────────────────────────────────────────────────────
@@ -2438,6 +3841,7 @@ var PlanWorkspace = class {
2438
3841
  // server/cli.ts
2439
3842
  var USAGE = `Usage: specplan [rootDir] [--port <n>]
2440
3843
  specplan generate <specId> [rootDir]
3844
+ specplan apply <proposalId> [rootDir] [--confirm-hand-edit]
2441
3845
  specplan validate [rootDir]
2442
3846
  specplan login [--profile <name>] [--web-url <url>]
2443
3847
 
@@ -2454,6 +3858,10 @@ server moves to the next available port and prints the actual URL.
2454
3858
  generate <specId> runs OpenSpec generation for one spec card headlessly;
2455
3859
  exits non-zero when the output fails \`openspec validate --strict\`.
2456
3860
 
3861
+ apply <proposalId> folds a proposal's delta into openspec/specs/ and archives
3862
+ it (delegating to \`openspec archive\`); exits non-zero on any gate failure.
3863
+ Pass --confirm-hand-edit to fold a body edited since it was last generated.
3864
+
2457
3865
  validate checks the plan files (schema, acyclicity, containment, story
2458
3866
  formula, id references) and exits non-zero with named errors.
2459
3867
 
@@ -2463,6 +3871,65 @@ var UsageError = class extends Error {
2463
3871
  };
2464
3872
  var DEFAULT_ROOT_DIR = "./openspec";
2465
3873
  var DEFAULT_PORT = 8789;
3874
+ function buildGenerateContext(rootDir) {
3875
+ const workspace = new PlanWorkspace(rootDir);
3876
+ const store = new PlanStore(rootDir);
3877
+ const model = GENERATION_MODELS.find((m) => m === workspace.readModel()) ?? DEFAULT_MODEL;
3878
+ return {
3879
+ deps: {
3880
+ anthropic: new Anthropic(),
3881
+ store,
3882
+ systemPrompt: workspace.readSystemPrompt(),
3883
+ refRoot: process.cwd(),
3884
+ harness: createClaudeHarness()
3885
+ },
3886
+ model
3887
+ };
3888
+ }
3889
+ function parseApplyArgs(args) {
3890
+ let confirmHandEdit = false;
3891
+ const positionals = [];
3892
+ for (const tok of args) {
3893
+ if (tok === "--confirm-hand-edit") {
3894
+ confirmHandEdit = true;
3895
+ continue;
3896
+ }
3897
+ if (tok.startsWith("-")) throw new UsageError(`unknown option: ${tok}`);
3898
+ positionals.push(tok);
3899
+ }
3900
+ const [proposalId, rootDir = DEFAULT_ROOT_DIR, ...rest] = positionals;
3901
+ if (!proposalId) throw new UsageError("apply requires a proposal id");
3902
+ if (rest.length > 0) {
3903
+ throw new UsageError("apply takes a proposal id and at most one root directory");
3904
+ }
3905
+ return { proposalId, rootDir, confirmHandEdit };
3906
+ }
3907
+ async function runApply(args, io = console, applyImpl = applyForSpec) {
3908
+ let parsed;
3909
+ try {
3910
+ parsed = parseApplyArgs(args);
3911
+ } catch (err) {
3912
+ io.error(
3913
+ `specplan: ${err instanceof Error ? err.message : String(err)}
3914
+
3915
+ ${USAGE}`
3916
+ );
3917
+ return 1;
3918
+ }
3919
+ const store = new PlanStore(parsed.rootDir);
3920
+ try {
3921
+ const result = await applyImpl({ store }, parsed.proposalId, {
3922
+ confirmHandEdit: parsed.confirmHandEdit
3923
+ });
3924
+ io.log(`specplan: applied ${result.specId} \u2014 folded and archived to ${result.archivedTo}`);
3925
+ for (const cap of result.newCapabilities) io.log(` new capability: ${cap}`);
3926
+ io.log(` SPECS.md row ${result.ledgerRow}`);
3927
+ return 0;
3928
+ } catch (err) {
3929
+ io.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
3930
+ return 1;
3931
+ }
3932
+ }
2466
3933
  function parseCliArgs(argv) {
2467
3934
  let parsed;
2468
3935
  try {
@@ -2507,25 +3974,25 @@ var MIME = {
2507
3974
  ".woff": "font/woff"
2508
3975
  };
2509
3976
  function resolveClientDir(moduleUrl) {
2510
- const here = dirname5(fileURLToPath(moduleUrl));
3977
+ const here = dirname7(fileURLToPath(moduleUrl));
2511
3978
  const candidates = [
2512
- join7(here, "..", "client"),
3979
+ join12(here, "..", "client"),
2513
3980
  // packed: dist/cli -> dist/client
2514
- join7(here, "..", "dist", "client")
3981
+ join12(here, "..", "dist", "client")
2515
3982
  // repo: server/ -> dist/client
2516
3983
  ];
2517
3984
  for (const dir of candidates) {
2518
- if (existsSync4(join7(dir, "index.html"))) return dir;
3985
+ if (existsSync7(join12(dir, "index.html"))) return dir;
2519
3986
  }
2520
3987
  return null;
2521
3988
  }
2522
3989
  function staticResponse(clientDir, pathname) {
2523
3990
  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), {
3991
+ const target = resolve6(clientDir, rel === "" ? "index.html" : rel);
3992
+ if (!target.startsWith(resolve6(clientDir))) return null;
3993
+ const file = existsSync7(target) && extname(target) ? target : join12(clientDir, "index.html");
3994
+ if (!existsSync7(file)) return null;
3995
+ return new Response(readFileSync5(file), {
2529
3996
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
2530
3997
  });
2531
3998
  }
@@ -2537,12 +4004,12 @@ but chat and "Generate OpenSpec" need one of:
2537
4004
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
2538
4005
  brew install anthropics/tap/ant # macOS
2539
4006
  ant auth login`;
2540
- function detectCredentialSource(env = process.env, home = homedir2()) {
4007
+ function detectCredentialSource(env = process.env, home = homedir3()) {
2541
4008
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
2542
4009
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
2543
4010
  try {
2544
- const dir = join7(home, ".config", "anthropic", "credentials");
2545
- if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
4011
+ const dir = join12(home, ".config", "anthropic", "credentials");
4012
+ if (readdirSync5(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2546
4013
  } catch {
2547
4014
  }
2548
4015
  return null;
@@ -2595,20 +4062,9 @@ ${USAGE}`);
2595
4062
  ${USAGE}`);
2596
4063
  process.exit(1);
2597
4064
  }
2598
- const workspace2 = new PlanWorkspace(rootDir);
2599
- const store2 = new PlanStore(rootDir);
2600
- const model = GENERATION_MODELS.find((m) => m === workspace2.readModel()) ?? DEFAULT_MODEL;
4065
+ const { deps, model } = buildGenerateContext(rootDir);
2601
4066
  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
- );
4067
+ const result = await generateForSpec(deps, specId, model);
2612
4068
  if (result.mode === "metadata-only") {
2613
4069
  console.log(
2614
4070
  `specplan: ${specId} body is hand-edited \u2014 updated metadata only (changes/${specId}/specplan.yaml)`
@@ -2625,6 +4081,9 @@ ${USAGE}`);
2625
4081
  }
2626
4082
  return;
2627
4083
  }
4084
+ if (argv[0] === "apply") {
4085
+ process.exit(await runApply(argv.slice(1)));
4086
+ }
2628
4087
  let args;
2629
4088
  try {
2630
4089
  args = parseCliArgs(argv);
@@ -2641,7 +4100,10 @@ ${USAGE}` : err);
2641
4100
  }
2642
4101
  const workspace = new PlanWorkspace(args.rootDir);
2643
4102
  const store = new PlanStore(args.rootDir);
2644
- const app = createApp(new Anthropic(), workspace, store);
4103
+ const app = createApp(new Anthropic(), workspace, store, {
4104
+ refRoot: process.cwd(),
4105
+ harness: createClaudeHarness()
4106
+ });
2645
4107
  app.get("*", (c) => {
2646
4108
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);
2647
4109
  return res ?? c.notFound();