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