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