@coopcli/specsketch 5.5.6 → 2026.817.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +2753 -1124
- package/dist/client/assets/index-DyUTkivM.css +1 -0
- package/dist/client/assets/index-Tyf33hWt.js +196 -0
- package/dist/client/index.html +3 -4
- package/package.json +80 -74
- 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-CTNJVYti.js +0 -1
- package/dist/client/assets/ar-SA-G6X2FPQ2-Dx5RkIpf.js +0 -10
- package/dist/client/assets/arc-CS-8wk7Q.js +0 -1
- package/dist/client/assets/architecture-TIHT7OUA-b9_tu6da.js +0 -1
- package/dist/client/assets/architectureDiagram-ZJ3FMSHR-C7zIp5ev.js +0 -36
- package/dist/client/assets/array-BifhSqXX.js +0 -1
- package/dist/client/assets/az-AZ-76LH7QW2-lKEr0lki.js +0 -1
- package/dist/client/assets/bg-BG-XCXSNQG7-BrueLcBf.js +0 -5
- package/dist/client/assets/blockDiagram-677ZJIJ3-B3Y6utF7.js +0 -132
- package/dist/client/assets/bn-BD-2XOGV67Q-BVnHj_Zk.js +0 -5
- package/dist/client/assets/c4Diagram-LMCZKHZV-Dt_cruyV.js +0 -10
- package/dist/client/assets/ca-ES-6MX7JW3Y-BM6SppAL.js +0 -8
- package/dist/client/assets/channel-Dz1KOO7f.js +0 -1
- package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
- package/dist/client/assets/chunk-32BRIVSS-Ca80Axke.js +0 -1
- package/dist/client/assets/chunk-52WLFC77-CtSrQbPt.js +0 -10
- package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
- package/dist/client/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
- package/dist/client/assets/chunk-C7G6YPKG-B2LFv6gm.js +0 -1
- package/dist/client/assets/chunk-EIO257PC-BAdMCi3M.js +0 -22
- package/dist/client/assets/chunk-EX3LRPZG-BJ4MtZ6_.js +0 -231
- package/dist/client/assets/chunk-FWX5IMBZ-Oeex84SP.js +0 -2
- package/dist/client/assets/chunk-HOUHSVGY-CwXgFjr1.js +0 -1
- package/dist/client/assets/chunk-ICXQ74PX-CNjPZw9f.js +0 -2
- package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
- package/dist/client/assets/chunk-KEIR6QF5-BfrZ3jm6.js +0 -161
- package/dist/client/assets/chunk-MOJQB5TN-CH9qxz0s.js +0 -88
- package/dist/client/assets/chunk-OGEWGWER-CXz-hiqH.js +0 -1
- package/dist/client/assets/chunk-PUDLZKDR-DeKKZ45n.js +0 -156
- package/dist/client/assets/chunk-Q4XR5HBZ-BfA-corw.js +0 -70
- package/dist/client/assets/chunk-RYQCIY6F-CmGX33Ed.js +0 -1
- package/dist/client/assets/chunk-V7JOEXUC-DBArf2HI.js +0 -206
- package/dist/client/assets/chunk-VAUOI2AC-Ce5MjSpg.js +0 -1
- package/dist/client/assets/chunk-VR4S4FIN-DUkeWBHv.js +0 -1
- package/dist/client/assets/chunk-WYO6CB5R-5IIfqwS1.js +0 -127
- package/dist/client/assets/chunk-XXDRQBXY-B_lJn00H.js +0 -1
- package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
- package/dist/client/assets/chunk-ZGVPDNZ5-BVFUMmyX.js +0 -62
- package/dist/client/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
- package/dist/client/assets/classDiagram-OUVF2IWQ-5dxJ_ejB.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-5dxJ_ejB.js +0 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-pqulBXGU.js +0 -1
- package/dist/client/assets/cs-CZ-2BRQDIVT-BeWSN4Lg.js +0 -11
- package/dist/client/assets/cynefin-VYW2F7L2-BPoEGt5P.js +0 -1
- package/dist/client/assets/cynefinDiagram-TSTJHNR4-D-HGa5eC.js +0 -62
- package/dist/client/assets/cytoscape.esm-B3I8pqwA.js +0 -321
- package/dist/client/assets/da-DK-5WZEPLOC-C7Kj8cSr.js +0 -5
- package/dist/client/assets/dagre-CXRCoUWR.js +0 -1
- package/dist/client/assets/dagre-VKFMJZFB-DYJas6XO.js +0 -4
- package/dist/client/assets/de-DE-XR44H4JA-CGONhJum.js +0 -8
- package/dist/client/assets/defaultLocale-C8Fc0cco.js +0 -1
- package/dist/client/assets/diagram-FQU43EPY-CovScBBf.js +0 -3
- package/dist/client/assets/diagram-G47NLZAW-6_UOQ1JD.js +0 -24
- package/dist/client/assets/diagram-NH7WQ7WH-DfM8Ux2D.js +0 -24
- package/dist/client/assets/diagram-OA4YK3LP-BFJDUTm_.js +0 -30
- package/dist/client/assets/diagram-WEI45ONY-8OKtOSaq.js +0 -41
- package/dist/client/assets/directory-open-01563666-D4xXyWb_.js +0 -1
- package/dist/client/assets/directory-open-4ed118d0-1i309Asm.js +0 -1
- package/dist/client/assets/dist-C8c_sMtm.js +0 -1
- package/dist/client/assets/dist-CBoHd3nF.js +0 -29
- package/dist/client/assets/ebnfDiagram-CCIWWBDH-TBneTBs-.js +0 -1
- package/dist/client/assets/el-GR-BZB4AONW-C9Q6-3JL.js +0 -10
- package/dist/client/assets/en-B4ZKOASM-DooiP2-J.js +0 -1
- package/dist/client/assets/erDiagram-Q63AITRT-Dy5QBJCT.js +0 -85
- package/dist/client/assets/es-ES-U4NZUMDT-DKxT2wWX.js +0 -9
- package/dist/client/assets/eu-ES-A7QVB2H4-DrKnZ3gY.js +0 -11
- package/dist/client/assets/eventmodeling-45OFAUF4-DP9O5S3e.js +0 -1
- package/dist/client/assets/fa-IR-HGAKTJCU-DjA2Px-T.js +0 -8
- package/dist/client/assets/fi-FI-Z5N7JZ37-NUghXbpa.js +0 -6
- package/dist/client/assets/file-open-002ab408-BHUWm0Sh.js +0 -1
- package/dist/client/assets/file-open-7c801643-DZtJT5zp.js +0 -1
- package/dist/client/assets/file-save-3189631c-CO9S4HFW.js +0 -1
- package/dist/client/assets/file-save-745eba88-Bwdfz6OZ.js +0 -1
- package/dist/client/assets/flowDiagram-23GEKE2U-CUBp7I9C.js +0 -1
- package/dist/client/assets/fr-FR-RHASNOE6-9Vmv2nhB.js +0 -9
- package/dist/client/assets/ganttDiagram-NO4QXBWP-DNoDTMaW.js +0 -292
- package/dist/client/assets/gitGraph-TEB2WS4Q-BdzEwYG8.js +0 -1
- package/dist/client/assets/gitGraphDiagram-IHSO6WYX-Coi-sbuV.js +0 -106
- package/dist/client/assets/gl-ES-HMX3MZ6V-CrTqlGTr.js +0 -10
- package/dist/client/assets/graphlib-B8gBHxth.js +0 -1
- package/dist/client/assets/he-IL-6SHJWFNN-BDoFv8vR.js +0 -10
- package/dist/client/assets/hi-IN-IWLTKZ5I-DwqsXYOg.js +0 -4
- package/dist/client/assets/hu-HU-A5ZG7DT2-Bfb8Cu1b.js +0 -7
- package/dist/client/assets/id-ID-SAP4L64H-DOW7LO7-.js +0 -10
- package/dist/client/assets/image-GAAHSSAO-BGgn-PVN.js +0 -1
- package/dist/client/assets/image-blob-reduce.esm-DLFdRAwc.js +0 -2
- package/dist/client/assets/index-oPO4_QxD.js +0 -270
- package/dist/client/assets/index-pqEtQj4K.css +0 -1
- package/dist/client/assets/info-DKCQHKI2-DrYXVDvR.js +0 -1
- package/dist/client/assets/infoDiagram-FWYZ7A6U-lZtFhAXP.js +0 -2
- package/dist/client/assets/init-D6jRqBbL.js +0 -1
- package/dist/client/assets/ishikawaDiagram-FXEZZL3T-BREj5_gD.js +0 -70
- package/dist/client/assets/it-IT-JPQ66NNP-Bgr9Cu8C.js +0 -11
- package/dist/client/assets/ja-JP-DBVTYXUO-BGNdWvdH.js +0 -8
- package/dist/client/assets/journeyDiagram-5HDEW3XC-CMBikQPO.js +0 -139
- package/dist/client/assets/kaa-6HZHGXH3-CRDlnQ9T.js +0 -1
- package/dist/client/assets/kab-KAB-ZGHBKWFO-C4Lqev4p.js +0 -8
- package/dist/client/assets/kanban-definition-HUTT4EX6-BWBx3j_A.js +0 -89
- package/dist/client/assets/katex-CddkPoXu.js +0 -257
- package/dist/client/assets/kk-KZ-P5N5QNE5-DgFr17Od.js +0 -1
- package/dist/client/assets/km-KH-HSX4SM5Z-BDvXuwGA.js +0 -11
- package/dist/client/assets/ko-KR-MTYHY66A-BNhHcL3I.js +0 -9
- package/dist/client/assets/ku-TR-6OUDTVRD-DNktxnYd.js +0 -9
- package/dist/client/assets/line-Dt0OF9ws.js +0 -1
- package/dist/client/assets/linear-Dcedcc10.js +0 -1
- package/dist/client/assets/lt-LT-XHIRWOB4-aRviZ0MH.js +0 -3
- package/dist/client/assets/lv-LV-5QDEKY6T-CoVYcDKN.js +0 -7
- package/dist/client/assets/map-DsCK-0Cs.js +0 -1
- package/dist/client/assets/mermaid-parser.core-PdKOsWiX.js +0 -7
- package/dist/client/assets/mindmap-definition-LN4V7U3C-CQ5v-EBF.js +0 -96
- package/dist/client/assets/mr-IN-CRQNXWMA-BxQJIhtJ.js +0 -13
- package/dist/client/assets/my-MM-5M5IBNSE-FhM1FFVw.js +0 -1
- package/dist/client/assets/nb-NO-T6EIAALU-CU26LQ4M.js +0 -10
- package/dist/client/assets/nl-NL-IS3SIHDZ-DEbkQpIZ.js +0 -8
- package/dist/client/assets/nn-NO-6E72VCQL-DTXzh0og.js +0 -8
- package/dist/client/assets/oc-FR-POXYY2M6-BMWu-GuX.js +0 -8
- package/dist/client/assets/ordinal-hYBb2elL.js +0 -1
- package/dist/client/assets/pa-IN-N4M65BXN-BvvaU4wr.js +0 -4
- package/dist/client/assets/packet-7NZHBO7P-D3npuWOU.js +0 -1
- package/dist/client/assets/path-BWPyau1x.js +0 -1
- package/dist/client/assets/pegDiagram-2B236MQR-BRo6DMPk.js +0 -1
- package/dist/client/assets/percentages-BXMCSKIN-EXL0wbvf.js +0 -1
- package/dist/client/assets/pica-LQ2hHv1b.js +0 -2
- package/dist/client/assets/pie-RZYD4A2V-CNkV5sKG.js +0 -1
- package/dist/client/assets/pieDiagram-ENE6RG2P-DjyEljFW.js +0 -39
- package/dist/client/assets/pl-PL-T2D74RX3-Q4-sfWh2.js +0 -9
- package/dist/client/assets/pt-BR-5N22H2LF-D3I0MlSt.js +0 -9
- package/dist/client/assets/pt-PT-UZXXM6DQ-CjvJgWuJ.js +0 -9
- package/dist/client/assets/quadrantDiagram-ABIIQ3AL-DwPjmUqM.js +0 -7
- package/dist/client/assets/radar-I7S5WNFK-BUDPffFb.js +0 -1
- package/dist/client/assets/railroad-3IZDKUUU-Cdo3kV8g.js +0 -1
- package/dist/client/assets/railroad-abnf-AHOZXSZD-DhwVR0O6.js +0 -1
- package/dist/client/assets/railroad-ebnf-EBAXGLYW-BH-sR6KG.js +0 -1
- package/dist/client/assets/railroad-peg-LSFZ7HO6-DNVw0v5I.js +0 -1
- package/dist/client/assets/railroadDiagram-RFXS5EU6-DM4LPeTp.js +0 -1
- package/dist/client/assets/requirementDiagram-TGXJPOKE-BgufYhbt.js +0 -84
- package/dist/client/assets/ro-RO-JPDTUUEW-DTqaPOB6.js +0 -11
- package/dist/client/assets/rolldown-runtime-BHe-jwch.js +0 -1
- package/dist/client/assets/rough.esm-CSKSodPl.js +0 -1
- package/dist/client/assets/roundRect-D01gJrlt.js +0 -1
- package/dist/client/assets/ru-RU-B4JR7IUQ-DGLVQl1a.js +0 -9
- package/dist/client/assets/sankeyDiagram-HTMAVEWB-B1Sjq8RS.js +0 -40
- package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D5SV4V4W.js +0 -162
- package/dist/client/assets/si-LK-N5RQ5JYF-C3gSdqxC.js +0 -1
- package/dist/client/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
- package/dist/client/assets/sk-SK-C5VTKIMK-Bg1sgW2y.js +0 -6
- package/dist/client/assets/sl-SI-NN7IZMDC-5tRgzcvI.js +0 -6
- package/dist/client/assets/src-DpN7Pt3R.js +0 -1
- package/dist/client/assets/stateDiagram-2N3HPSRC-BDd2Qyke.js +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc9mjKnL.js +0 -1
- package/dist/client/assets/subset-shared.chunk-CBqF3lLT.js +0 -1
- package/dist/client/assets/subset-worker.chunk-kFoGWzk5.js +0 -1
- package/dist/client/assets/sv-SE-XGPEYMSR-BOzuSEwa.js +0 -10
- package/dist/client/assets/swimlanes-5IMT3BWC-DjFw-CEo.js +0 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DxdVR5MZ.js +0 -8
- package/dist/client/assets/ta-IN-2NMHFXQM-C7WRODt_.js +0 -9
- package/dist/client/assets/th-TH-HPSO5L25-WFuWcN58.js +0 -2
- package/dist/client/assets/timeline-definition-FHXFAJF6-BItaRl8g.js +0 -120
- package/dist/client/assets/tr-TR-DEFEU3FU-DtSAkhUR.js +0 -7
- package/dist/client/assets/treeView-QDETBFTQ-BsqpkMak.js +0 -1
- package/dist/client/assets/treemap-6X3UGDF4-CF2ZChjy.js +0 -1
- package/dist/client/assets/uk-UA-QMV73CPH-BLNYCDf-.js +0 -6
- package/dist/client/assets/vennDiagram-L72KCM5P-DkVOQsDM.js +0 -34
- package/dist/client/assets/vi-VN-M7AON7JQ-CC-oykOI.js +0 -5
- package/dist/client/assets/wardley-OPB4EBWU-BfqiNa2m.js +0 -1
- package/dist/client/assets/wardleyDiagram-EHGQE667-C7jTfbZa.js +0 -78
- package/dist/client/assets/xychartDiagram-FW5EYKEG-KJVcpo2q.js +0 -7
- package/dist/client/assets/zh-CN-LNUGB5OW-BpxSN9jo.js +0 -10
- package/dist/client/assets/zh-HK-E62DVLB3-BVqdbOUX.js +0 -1
- package/dist/client/assets/zh-TW-RAJ6MFWO-c__UZRJ7.js +0 -9
package/dist/cli/index.js
CHANGED
|
@@ -1,18 +1,347 @@
|
|
|
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 existsSync8, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
|
|
5
|
+
import { homedir as homedir4 } from "node:os";
|
|
6
|
+
import { dirname as dirname6, extname, join as join11, normalize as normalize2, resolve as resolve4 } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import Anthropic from "@anthropic-ai/sdk";
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
// ../agent-harness/src/model-auth.ts
|
|
13
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
var ModelAuthError = class extends Error {
|
|
17
|
+
};
|
|
18
|
+
var API_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
|
19
|
+
function hasAntAuthProfile(home) {
|
|
20
|
+
try {
|
|
21
|
+
return readdirSync(join(home, ".config", "anthropic", "credentials")).some(
|
|
22
|
+
(f) => f.endsWith(".json")
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function resolveModelCredential(opts = {}) {
|
|
29
|
+
const env = opts.env ?? process.env;
|
|
30
|
+
const home = opts.home ?? homedir();
|
|
31
|
+
const useApi = Boolean(opts.useApi) || Boolean(env.COOP_USE_API);
|
|
32
|
+
const hasApiKey = Boolean(env.ANTHROPIC_API_KEY);
|
|
33
|
+
const hasAntAuth = Boolean(env.ANTHROPIC_AUTH_TOKEN) || hasAntAuthProfile(home);
|
|
34
|
+
const hasSubscription = existsSync(join(home, ".claude", ".credentials.json"));
|
|
35
|
+
if (useApi) {
|
|
36
|
+
if (hasApiKey || hasAntAuth) return apiResult(env, hasApiKey ? "api-key" : "ant-auth");
|
|
37
|
+
throw new ModelAuthError(
|
|
38
|
+
"--api / COOP_USE_API is set but no API credential was found. Set ANTHROPIC_API_KEY=sk-ant-\u2026 or run `ant auth login`."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (hasSubscription) {
|
|
42
|
+
const subprocessEnv = { ...env };
|
|
43
|
+
for (const k of API_ENV_KEYS) delete subprocessEnv[k];
|
|
44
|
+
return {
|
|
45
|
+
path: "subscription",
|
|
46
|
+
billsApiCredits: false,
|
|
47
|
+
notice: "Model auth: Max/Pro subscription login \u2014 billing your subscription.",
|
|
48
|
+
subprocessEnv
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (hasApiKey) return apiResult(env, "api-key");
|
|
52
|
+
if (hasAntAuth) return apiResult(env, "ant-auth");
|
|
53
|
+
throw new ModelAuthError(
|
|
54
|
+
"No Anthropic model credential found. Either:\n \u2022 log in your own Claude Code for subscription billing: claude login\n \u2022 or use API credits: export ANTHROPIC_API_KEY=sk-ant-\u2026 (or: ant auth login)"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
function apiResult(env, path) {
|
|
58
|
+
return {
|
|
59
|
+
path,
|
|
60
|
+
billsApiCredits: true,
|
|
61
|
+
notice: `Model auth: ${path === "api-key" ? "ANTHROPIC_API_KEY" : "ant auth OAuth token"} \u2014 billing API credits, NOT your Max subscription.`,
|
|
62
|
+
subprocessEnv: { ...env }
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ../agent-harness/src/claude.ts
|
|
67
|
+
function makeGate(profile) {
|
|
68
|
+
return async (toolName, input) => {
|
|
69
|
+
if (!profile.allowedTools.includes(toolName)) {
|
|
70
|
+
return {
|
|
71
|
+
behavior: "deny",
|
|
72
|
+
message: `Tool "${toolName}" is not in the headless allowlist for agent "${profile.identity}".`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (toolName === "Bash") {
|
|
76
|
+
const command = typeof input["command"] === "string" ? input["command"] : "";
|
|
77
|
+
for (const pattern of profile.blockedBashPatterns) {
|
|
78
|
+
if (pattern.test(command)) {
|
|
79
|
+
return {
|
|
80
|
+
behavior: "deny",
|
|
81
|
+
message: `Bash command blocked by headless profile pattern ${String(pattern)} for agent "${profile.identity}".`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (profile.gatedTools.includes(toolName) && !profile.allowGated) {
|
|
87
|
+
return {
|
|
88
|
+
behavior: "deny",
|
|
89
|
+
message: `Tool "${toolName}" is a gated side-effect tool and this profile does not allow gated tools (allowGated: false).`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { behavior: "allow", updatedInput: input };
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function destructiveBashHook(profile) {
|
|
96
|
+
return {
|
|
97
|
+
matcher: "Bash",
|
|
98
|
+
hooks: [
|
|
99
|
+
async (input) => {
|
|
100
|
+
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
101
|
+
const ti = input.tool_input;
|
|
102
|
+
const command = typeof ti === "object" && ti !== null ? String(ti.command ?? "") : "";
|
|
103
|
+
for (const pattern of profile.blockedBashPatterns) {
|
|
104
|
+
if (pattern.test(command)) {
|
|
105
|
+
return {
|
|
106
|
+
continue: true,
|
|
107
|
+
hookSpecificOutput: {
|
|
108
|
+
hookEventName: "PreToolUse",
|
|
109
|
+
permissionDecision: "deny",
|
|
110
|
+
permissionDecisionReason: `Destructive command blocked by headless profile (${String(pattern)}) for agent "${profile.identity}".`
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { continue: true };
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function* mapMessage(message, expectStructuredOutput) {
|
|
121
|
+
switch (message.type) {
|
|
122
|
+
case "system": {
|
|
123
|
+
if (message.subtype === "init") {
|
|
124
|
+
yield {
|
|
125
|
+
type: "start",
|
|
126
|
+
sessionId: message.session_id,
|
|
127
|
+
...message.apiKeySource !== void 0 ? { apiKeySource: message.apiKeySource } : {}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
case "assistant": {
|
|
133
|
+
const assistant = message;
|
|
134
|
+
for (const block of assistant.message.content) {
|
|
135
|
+
if (block.type === "text") {
|
|
136
|
+
yield { type: "text", text: block.text };
|
|
137
|
+
} else if (block.type === "thinking") {
|
|
138
|
+
yield { type: "thinking", text: block.thinking };
|
|
139
|
+
} else if (block.type === "tool_use") {
|
|
140
|
+
yield { type: "tool_use", id: block.id, name: block.name, input: block.input };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case "user": {
|
|
146
|
+
const user = message;
|
|
147
|
+
const content = user.message.content;
|
|
148
|
+
if (typeof content === "string") return;
|
|
149
|
+
for (const block of content) {
|
|
150
|
+
if (block.type === "tool_result") {
|
|
151
|
+
yield {
|
|
152
|
+
type: "tool_result",
|
|
153
|
+
id: block.tool_use_id,
|
|
154
|
+
isError: block.is_error ?? false,
|
|
155
|
+
content: block.content
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case "result": {
|
|
162
|
+
if (message.subtype === "success") {
|
|
163
|
+
const missingStructured = expectStructuredOutput && message.structured_output === void 0;
|
|
164
|
+
yield {
|
|
165
|
+
type: "result",
|
|
166
|
+
text: message.result,
|
|
167
|
+
costUsd: message.total_cost_usd,
|
|
168
|
+
isError: message.is_error || missingStructured,
|
|
169
|
+
...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
|
|
170
|
+
};
|
|
171
|
+
} else {
|
|
172
|
+
yield {
|
|
173
|
+
type: "result",
|
|
174
|
+
text: message.errors.join("\n"),
|
|
175
|
+
costUsd: message.total_cost_usd,
|
|
176
|
+
isError: true
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function buildOptions(input, resolved) {
|
|
186
|
+
const profile = input.profile;
|
|
187
|
+
const options = {
|
|
188
|
+
cwd: input.cwd,
|
|
189
|
+
// CRITICAL: an EXPLICIT settingSources: [] loads NOTHING. (The SDK's own
|
|
190
|
+
// default — when the option is omitted — loads ALL sources; we always
|
|
191
|
+
// pass the profile's list so inheritance is an explicit choice, and the
|
|
192
|
+
// profile's sources are what yield LifeOS inheritance.)
|
|
193
|
+
settingSources: profile.settingSources,
|
|
194
|
+
// Never 'bypassPermissions' / 'acceptEdits' — PermissionProfile's type
|
|
195
|
+
// only admits 'default' | 'plan' | 'dontAsk'.
|
|
196
|
+
permissionMode: profile.permissionMode,
|
|
197
|
+
allowedTools: profile.allowedTools,
|
|
198
|
+
canUseTool: makeGate(profile),
|
|
199
|
+
// Containment: destructive-Bash denial must survive a bare allowlisted
|
|
200
|
+
// `Bash` (allow rules short-circuit canUseTool), so it is enforced by a
|
|
201
|
+
// PreToolUse hook, which the SDK evaluates before allow rules.
|
|
202
|
+
hooks: { PreToolUse: [destructiveBashHook(profile)] },
|
|
203
|
+
// Options.env REPLACES the subprocess environment (no merge). The
|
|
204
|
+
// resolver hands back the FULL inherited env (HOME/PATH preserved) with
|
|
205
|
+
// ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN stripped on the subscription
|
|
206
|
+
// path, so the subprocess cannot prefer an API key over the login.
|
|
207
|
+
env: resolved.subprocessEnv
|
|
208
|
+
};
|
|
209
|
+
if (profile.disallowedTools) {
|
|
210
|
+
options.disallowedTools = profile.disallowedTools;
|
|
211
|
+
}
|
|
212
|
+
if (profile.mcpServers) {
|
|
213
|
+
options.mcpServers = profile.mcpServers;
|
|
214
|
+
}
|
|
215
|
+
if (profile.strictMcp !== void 0) {
|
|
216
|
+
options.strictMcpConfig = profile.strictMcp;
|
|
217
|
+
}
|
|
218
|
+
if (input.outputSchema) {
|
|
219
|
+
options.outputFormat = { type: "json_schema", schema: input.outputSchema };
|
|
220
|
+
}
|
|
221
|
+
return options;
|
|
222
|
+
}
|
|
223
|
+
function verifyBilling(resolved, apiKeySource, notify) {
|
|
224
|
+
if (apiKeySource === void 0) return;
|
|
225
|
+
const billedViaApiKey = apiKeySource !== "none";
|
|
226
|
+
if (resolved.path === "subscription" && billedViaApiKey) {
|
|
227
|
+
notify(
|
|
228
|
+
`Warning: the subscription path was selected but the run authenticated from an API-key source ('${apiKeySource}') \u2014 this run bills API credits, not your subscription.`
|
|
229
|
+
);
|
|
230
|
+
} else if (resolved.path !== "subscription" && !billedViaApiKey) {
|
|
231
|
+
notify(
|
|
232
|
+
`Warning: the ${resolved.path} (API credits) path was selected but the run authenticated as first-party (no API key) \u2014 this run bills the subscription.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
var SDK_UNAVAILABLE_MESSAGE = "Claude Agent SDK not available \u2014 reinstall the CLI (do not use --omit=optional). Global install is required for the agent path; pnpm dlx/npx re-download the ~268 MB native binary.";
|
|
237
|
+
function isSdkAbsence(error) {
|
|
238
|
+
const code = error?.code;
|
|
239
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
|
|
240
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
241
|
+
return /Native CLI binary.*not found/i.test(message);
|
|
242
|
+
}
|
|
243
|
+
async function loadSdkQuery(loader) {
|
|
244
|
+
try {
|
|
245
|
+
const mod = await (loader ?? (() => import("@anthropic-ai/claude-agent-sdk")))();
|
|
246
|
+
return mod.query;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function createClaudeHarness(deps) {
|
|
253
|
+
return {
|
|
254
|
+
id: "claude",
|
|
255
|
+
async *run(input) {
|
|
256
|
+
const resolved = resolveModelCredential({
|
|
257
|
+
env: deps?.env,
|
|
258
|
+
home: deps?.home,
|
|
259
|
+
useApi: input.useApi
|
|
260
|
+
});
|
|
261
|
+
const notify = input.onNotice ?? ((message) => console.error(message));
|
|
262
|
+
notify(resolved.notice);
|
|
263
|
+
const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
|
|
264
|
+
const expectStructuredOutput = input.outputSchema !== void 0;
|
|
265
|
+
try {
|
|
266
|
+
const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
|
|
267
|
+
for await (const message of stream) {
|
|
268
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
269
|
+
verifyBilling(resolved, message.apiKeySource, notify);
|
|
270
|
+
}
|
|
271
|
+
yield* mapMessage(message, expectStructuredOutput);
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ../agent-harness/src/profiles.ts
|
|
282
|
+
var SIDE_EFFECT_GATED_TOOLS = ["SendMessage", "Send", "Deploy", "Publish"];
|
|
283
|
+
var BLOCKED_BASH_PATTERNS = [
|
|
284
|
+
/\bgit\s+push\b/,
|
|
285
|
+
/\bwrangler\s+deploy\b/,
|
|
286
|
+
/\brm\s+-rf\b/,
|
|
287
|
+
/\bnpm\s+publish\b/
|
|
288
|
+
];
|
|
289
|
+
function blockedBashPatterns() {
|
|
290
|
+
return [...BLOCKED_BASH_PATTERNS];
|
|
291
|
+
}
|
|
292
|
+
function defaultHeadlessProfile(identity) {
|
|
293
|
+
return {
|
|
294
|
+
allowedTools: ["Read", "Grep", "Glob", "WebFetch"],
|
|
295
|
+
gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
|
|
296
|
+
allowGated: false,
|
|
297
|
+
blockedBashPatterns: blockedBashPatterns(),
|
|
298
|
+
permissionMode: "default",
|
|
299
|
+
settingSources: [],
|
|
300
|
+
identity
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function lifeOSProfile(identity, mcpServers) {
|
|
304
|
+
const profile = {
|
|
305
|
+
allowedTools: [
|
|
306
|
+
"Read",
|
|
307
|
+
"Grep",
|
|
308
|
+
"Glob",
|
|
309
|
+
"WebFetch",
|
|
310
|
+
"WebSearch",
|
|
311
|
+
"Write",
|
|
312
|
+
"Edit",
|
|
313
|
+
"NotebookEdit",
|
|
314
|
+
// NOTE: a bare 'Bash' allow rule auto-approves the tool and
|
|
315
|
+
// short-circuits canUseTool — blockedBashPatterns stay enforced only
|
|
316
|
+
// because the harness installs them as a PreToolUse hook (claude.ts
|
|
317
|
+
// destructiveBashHook), which the SDK evaluates before allow rules.
|
|
318
|
+
"Bash",
|
|
319
|
+
"Agent",
|
|
320
|
+
"Task",
|
|
321
|
+
"Skill",
|
|
322
|
+
"TodoWrite"
|
|
323
|
+
],
|
|
324
|
+
gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
|
|
325
|
+
allowGated: false,
|
|
326
|
+
blockedBashPatterns: blockedBashPatterns(),
|
|
327
|
+
permissionMode: "default",
|
|
328
|
+
settingSources: ["user", "project", "local"],
|
|
329
|
+
identity
|
|
330
|
+
};
|
|
331
|
+
if (mcpServers) {
|
|
332
|
+
profile.mcpServers = mcpServers;
|
|
333
|
+
profile.strictMcp = true;
|
|
334
|
+
}
|
|
335
|
+
return profile;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// server/cli.ts
|
|
339
|
+
import "ws";
|
|
12
340
|
|
|
13
341
|
// server/app.ts
|
|
14
342
|
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
15
343
|
import { Hono } from "hono";
|
|
344
|
+
import { z as z2 } from "zod";
|
|
16
345
|
|
|
17
346
|
// src/lib/schema.ts
|
|
18
347
|
import { z } from "zod";
|
|
@@ -137,8 +466,8 @@ var SessionSnapshot = z.object({
|
|
|
137
466
|
});
|
|
138
467
|
|
|
139
468
|
// server/references.ts
|
|
140
|
-
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
141
|
-
import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
469
|
+
import { readFileSync, readdirSync as readdirSync2, realpathSync, statSync } from "node:fs";
|
|
470
|
+
import { basename, isAbsolute, join as join2, normalize, relative, resolve, sep } from "node:path";
|
|
142
471
|
var REF_FILE_BYTE_LIMIT = 64 * 1024;
|
|
143
472
|
var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
|
|
144
473
|
var REF_DIR_FILE_LIMIT = 50;
|
|
@@ -164,14 +493,14 @@ function collectEntries(root) {
|
|
|
164
493
|
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
165
494
|
let entries;
|
|
166
495
|
try {
|
|
167
|
-
entries =
|
|
496
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
168
497
|
} catch {
|
|
169
498
|
return;
|
|
170
499
|
}
|
|
171
500
|
for (const entry of entries) {
|
|
172
501
|
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
173
502
|
if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
174
|
-
const full =
|
|
503
|
+
const full = join2(dir, entry.name);
|
|
175
504
|
if (entry.isDirectory()) {
|
|
176
505
|
dirs.push(relative(root, full));
|
|
177
506
|
walk(full);
|
|
@@ -258,7 +587,7 @@ function expandReferences(text, root) {
|
|
|
258
587
|
if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
|
|
259
588
|
let body;
|
|
260
589
|
try {
|
|
261
|
-
body = readClamped(
|
|
590
|
+
body = readClamped(join2(rootReal, relPath));
|
|
262
591
|
} catch {
|
|
263
592
|
continue;
|
|
264
593
|
}
|
|
@@ -305,7 +634,7 @@ function resolveOne(rootReal, ref) {
|
|
|
305
634
|
if (real !== rootReal && !insideRoot(rootReal, real)) {
|
|
306
635
|
return { reason: "resolves outside the working directory" };
|
|
307
636
|
}
|
|
308
|
-
const dirFiles = collectFiles(direct).map((p) => relative(rootReal,
|
|
637
|
+
const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join2(direct, p))).sort((a, b) => {
|
|
309
638
|
const depth = a.split(sep).length - b.split(sep).length;
|
|
310
639
|
return depth !== 0 ? depth : a.localeCompare(b);
|
|
311
640
|
});
|
|
@@ -331,7 +660,7 @@ function resolveOne(rootReal, ref) {
|
|
|
331
660
|
if (matches.length === 0) return { reason: "not found" };
|
|
332
661
|
const best = matches[0];
|
|
333
662
|
try {
|
|
334
|
-
const body = readClamped(
|
|
663
|
+
const body = readClamped(join2(rootReal, best));
|
|
335
664
|
return {
|
|
336
665
|
path: best,
|
|
337
666
|
...body,
|
|
@@ -513,6 +842,7 @@ function bundleFilePaths(b) {
|
|
|
513
842
|
...b.specs.map((s) => `specs/${s.capability}/spec.md`)
|
|
514
843
|
];
|
|
515
844
|
}
|
|
845
|
+
var SPEC_BUNDLE_JSON_SCHEMA = z2.toJSONSchema(SpecBundle, { target: "draft-7" });
|
|
516
846
|
var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
|
|
517
847
|
var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
|
|
518
848
|
function parseWithModelGate(anthropic, request) {
|
|
@@ -546,6 +876,9 @@ function anthropicErrorResponse(c, err, kind) {
|
|
|
546
876
|
}
|
|
547
877
|
function createApp(anthropic, workspace, getShare, options) {
|
|
548
878
|
const refRoot = options === void 0 ? process.cwd() : options.refRoot;
|
|
879
|
+
const harness = options?.harness;
|
|
880
|
+
const reviewProfile = lifeOSProfile("specsketch-review");
|
|
881
|
+
const generateProfile = defaultHeadlessProfile("specsketch-generate");
|
|
549
882
|
const app = new Hono();
|
|
550
883
|
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
551
884
|
app.get(
|
|
@@ -624,25 +957,65 @@ Produce the OpenSpec change proposal for this architecture as structured output.
|
|
|
624
957
|
Referenced files (resolved from @-references in the input):
|
|
625
958
|
|
|
626
959
|
${refContext}` : "");
|
|
627
|
-
let
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
960
|
+
let output;
|
|
961
|
+
if (harness) {
|
|
962
|
+
let structured;
|
|
963
|
+
let resultText = "";
|
|
964
|
+
let resultError = false;
|
|
965
|
+
try {
|
|
966
|
+
for await (const ev of harness.run({
|
|
967
|
+
prompt: `${system}
|
|
968
|
+
|
|
969
|
+
${prompt}`,
|
|
970
|
+
cwd: refRoot ?? process.cwd(),
|
|
971
|
+
session: workspace?.changeName ?? "specsketch-generate",
|
|
972
|
+
profile: generateProfile,
|
|
973
|
+
outputSchema: SPEC_BUNDLE_JSON_SCHEMA
|
|
974
|
+
})) {
|
|
975
|
+
if (ev.type === "result") {
|
|
976
|
+
structured = ev.structuredOutput;
|
|
977
|
+
resultText = ev.text;
|
|
978
|
+
resultError = ev.isError;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
} catch (err) {
|
|
982
|
+
return anthropicErrorResponse(c, err, "generation-failed");
|
|
983
|
+
}
|
|
984
|
+
if (structured === void 0) {
|
|
985
|
+
return resultError ? c.json(
|
|
986
|
+
{ error: "generation-failed", detail: resultText || "the agent run failed" },
|
|
987
|
+
502
|
|
988
|
+
) : c.json({ error: "model returned no structured output" }, 502);
|
|
989
|
+
}
|
|
990
|
+
const checked = SpecBundle.safeParse(structured);
|
|
991
|
+
if (!checked.success) {
|
|
992
|
+
return c.json(
|
|
993
|
+
{ error: "model returned invalid structured output", detail: checked.error.issues },
|
|
994
|
+
502
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
output = checked.data;
|
|
998
|
+
} else {
|
|
999
|
+
let msg;
|
|
1000
|
+
try {
|
|
1001
|
+
msg = await parseWithModelGate(anthropic, {
|
|
1002
|
+
model,
|
|
1003
|
+
max_tokens: 16e3,
|
|
1004
|
+
// non-streaming stays under the SDK HTTP timeout
|
|
1005
|
+
system,
|
|
1006
|
+
messages: [{ role: "user", content: prompt }],
|
|
1007
|
+
output_config: { format: zodOutputFormat(SpecBundle) }
|
|
1008
|
+
// No temperature/top_p or thinking config — both 400 on modern models.
|
|
1009
|
+
});
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
return anthropicErrorResponse(c, err, "generation-failed");
|
|
1012
|
+
}
|
|
1013
|
+
if (msg.stop_reason === "refusal") {
|
|
1014
|
+
return c.json({ error: "refusal", detail: msg.stop_details }, 422);
|
|
1015
|
+
}
|
|
1016
|
+
if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
|
|
1017
|
+
output = SpecBundle.parse(msg.parsed_output);
|
|
643
1018
|
}
|
|
644
|
-
if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
|
|
645
|
-
const output = SpecBundle.parse(msg.parsed_output);
|
|
646
1019
|
const bundle = workspace ? { ...output, changeName: workspace.changeName } : output;
|
|
647
1020
|
const changedFiles = workspace ? workspace.writeBundle(bundle) : bundleFilePaths(bundle);
|
|
648
1021
|
const seed = await makeSeed(anthropic, model, conversation, changedFiles, bundle);
|
|
@@ -654,43 +1027,56 @@ ${refContext}` : "");
|
|
|
654
1027
|
if (!parsed.success) {
|
|
655
1028
|
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
656
1029
|
}
|
|
657
|
-
const { message, conversation
|
|
1030
|
+
const { message, conversation } = parsed.data;
|
|
658
1031
|
const bundle = parsed.data.bundle ?? workspace?.toBundle() ?? null;
|
|
659
1032
|
if (!bundle) return c.json({ error: "no spec to chat about \u2014 generate one first" }, 400);
|
|
1033
|
+
if (!harness) {
|
|
1034
|
+
return c.json(
|
|
1035
|
+
{ error: "chat-unavailable", detail: "no agent harness configured for this server" },
|
|
1036
|
+
500
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
660
1039
|
const refs = expandReferences(message, refRoot);
|
|
661
1040
|
const refContext = renderReferenceContext(refs);
|
|
662
|
-
const history = conversation.map((m) =>
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
${
|
|
674
|
-
messages: [
|
|
675
|
-
...history,
|
|
676
|
-
{
|
|
677
|
-
role: "user",
|
|
678
|
-
content: message + (refContext ? `
|
|
1041
|
+
const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
|
|
1042
|
+
${m.text}`).join("\n\n");
|
|
1043
|
+
const prompt = `${CHAT_SYSTEM}
|
|
1044
|
+
|
|
1045
|
+
${renderBundleContext(bundle)}` + (history ? `
|
|
1046
|
+
|
|
1047
|
+
Conversation so far:
|
|
1048
|
+
|
|
1049
|
+
${history}` : "") + `
|
|
1050
|
+
|
|
1051
|
+
[user]
|
|
1052
|
+
${message}` + (refContext ? `
|
|
679
1053
|
|
|
680
1054
|
Referenced files (resolved from @-references in the message):
|
|
681
1055
|
|
|
682
|
-
${refContext}` : "")
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1056
|
+
${refContext}` : "");
|
|
1057
|
+
let answer = "";
|
|
1058
|
+
let resultText = "";
|
|
1059
|
+
let resultError = false;
|
|
1060
|
+
try {
|
|
1061
|
+
for await (const ev of harness.run({
|
|
1062
|
+
prompt,
|
|
1063
|
+
cwd: refRoot ?? process.cwd(),
|
|
1064
|
+
session: workspace?.changeName ?? bundle.changeName ?? "specsketch-review",
|
|
1065
|
+
profile: reviewProfile
|
|
1066
|
+
})) {
|
|
1067
|
+
if (ev.type === "text") answer += ev.text;
|
|
1068
|
+
else if (ev.type === "result") {
|
|
1069
|
+
resultText = ev.text;
|
|
1070
|
+
resultError = ev.isError;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
686
1073
|
} catch (err) {
|
|
687
1074
|
return anthropicErrorResponse(c, err, "chat-failed");
|
|
688
1075
|
}
|
|
689
|
-
|
|
690
|
-
|
|
1076
|
+
const text = answer.trim() || resultText.trim();
|
|
1077
|
+
if (!text) {
|
|
1078
|
+
return resultError ? c.json({ error: "chat-failed", detail: resultText || "the agent run failed" }, 502) : c.json({ error: "model returned no text" }, 502);
|
|
691
1079
|
}
|
|
692
|
-
const text = textOf(msg);
|
|
693
|
-
if (!text) return c.json({ error: "model returned no text" }, 502);
|
|
694
1080
|
const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
695
1081
|
workspace?.writeChat([
|
|
696
1082
|
...conversation,
|
|
@@ -710,28 +1096,23 @@ ${refContext}` : "")
|
|
|
710
1096
|
return app;
|
|
711
1097
|
}
|
|
712
1098
|
|
|
713
|
-
// server/coop-auth.ts
|
|
714
|
-
import { homedir as homedir2 } from "node:os";
|
|
715
|
-
import { join as join3 } from "node:path";
|
|
716
|
-
|
|
717
1099
|
// ../shared/src/cli-auth.ts
|
|
718
1100
|
import { exec } from "node:child_process";
|
|
719
1101
|
import { randomBytes } from "node:crypto";
|
|
720
|
-
import { existsSync } from "node:fs";
|
|
721
1102
|
import { createServer } from "node:http";
|
|
722
1103
|
import { hostname as hostname2 } from "node:os";
|
|
723
1104
|
import { URL as URL2 } from "node:url";
|
|
724
1105
|
|
|
725
1106
|
// ../shared/src/cli-config.ts
|
|
726
1107
|
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
727
|
-
import { homedir, hostname } from "node:os";
|
|
728
|
-
import { join as
|
|
1108
|
+
import { homedir as homedir2, hostname } from "node:os";
|
|
1109
|
+
import { join as join3, dirname } from "node:path";
|
|
729
1110
|
|
|
730
1111
|
// ../shared/src/schemas.ts
|
|
731
|
-
import { z as
|
|
732
|
-
var planTierSchema =
|
|
733
|
-
var jurisdictionSchema =
|
|
734
|
-
var locationHintSchema =
|
|
1112
|
+
import { z as z3 } from "zod";
|
|
1113
|
+
var planTierSchema = z3.enum(["hobby", "squad", "org"]);
|
|
1114
|
+
var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
|
|
1115
|
+
var locationHintSchema = z3.enum([
|
|
735
1116
|
"wnam",
|
|
736
1117
|
"enam",
|
|
737
1118
|
"weur",
|
|
@@ -742,135 +1123,135 @@ var locationHintSchema = z2.enum([
|
|
|
742
1123
|
"me",
|
|
743
1124
|
"sam"
|
|
744
1125
|
]);
|
|
745
|
-
var sessionNameSchema =
|
|
1126
|
+
var sessionNameSchema = z3.string().min(1).max(63).regex(
|
|
746
1127
|
/^[a-z0-9][a-z0-9-]*$/,
|
|
747
1128
|
"Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
748
1129
|
);
|
|
749
|
-
var teamNameSchema =
|
|
1130
|
+
var teamNameSchema = z3.string().min(1).max(63).regex(
|
|
750
1131
|
/^[a-z0-9][a-z0-9-]*$/,
|
|
751
1132
|
"Team name must be lowercase alphanumeric with hyphens"
|
|
752
1133
|
);
|
|
753
|
-
var messageKindSchema =
|
|
754
|
-
var messageContentSchema =
|
|
1134
|
+
var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
|
|
1135
|
+
var messageContentSchema = z3.object({
|
|
755
1136
|
kind: messageKindSchema,
|
|
756
1137
|
// No .max() here: the 10 000-char body limit is business logic owned by the
|
|
757
1138
|
// SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
|
|
758
1139
|
// both REST and WS. A schema cap would pre-empt it as a generic 400.
|
|
759
|
-
body:
|
|
1140
|
+
body: z3.string().min(1),
|
|
760
1141
|
// Two-arg z.record: identical semantics in zod 3, required form in zod 4
|
|
761
1142
|
// (specsketch bundles this file against zod 4 — keep every construct dual-safe).
|
|
762
|
-
metadata:
|
|
1143
|
+
metadata: z3.record(z3.string(), z3.unknown()).optional()
|
|
763
1144
|
});
|
|
764
|
-
var createTeamInputSchema =
|
|
1145
|
+
var createTeamInputSchema = z3.object({
|
|
765
1146
|
name: teamNameSchema
|
|
766
1147
|
});
|
|
767
|
-
var createSessionInputSchema =
|
|
1148
|
+
var createSessionInputSchema = z3.object({
|
|
768
1149
|
name: sessionNameSchema,
|
|
769
|
-
teamId:
|
|
1150
|
+
teamId: z3.string().optional(),
|
|
770
1151
|
jurisdiction: jurisdictionSchema.optional(),
|
|
771
1152
|
locationHint: locationHintSchema.optional()
|
|
772
1153
|
});
|
|
773
|
-
var sendMessageInputSchema =
|
|
1154
|
+
var sendMessageInputSchema = z3.object({
|
|
774
1155
|
content: messageContentSchema
|
|
775
1156
|
});
|
|
776
|
-
var inviteInputSchema =
|
|
777
|
-
email:
|
|
778
|
-
role:
|
|
1157
|
+
var inviteInputSchema = z3.object({
|
|
1158
|
+
email: z3.string().email(),
|
|
1159
|
+
role: z3.enum(["admin", "member"]).default("member")
|
|
779
1160
|
});
|
|
780
|
-
var joinTeamInputSchema =
|
|
781
|
-
inviteCode:
|
|
1161
|
+
var joinTeamInputSchema = z3.object({
|
|
1162
|
+
inviteCode: z3.string().min(1)
|
|
782
1163
|
});
|
|
783
|
-
var markReadInputSchema =
|
|
784
|
-
notificationIds:
|
|
1164
|
+
var markReadInputSchema = z3.object({
|
|
1165
|
+
notificationIds: z3.array(z3.string().min(1))
|
|
785
1166
|
});
|
|
786
|
-
var pollQuerySchema =
|
|
787
|
-
since:
|
|
788
|
-
limit:
|
|
789
|
-
session:
|
|
1167
|
+
var pollQuerySchema = z3.object({
|
|
1168
|
+
since: z3.string().optional(),
|
|
1169
|
+
limit: z3.coerce.number().int().min(1).max(100).default(50),
|
|
1170
|
+
session: z3.string().optional()
|
|
790
1171
|
});
|
|
791
|
-
var messagesQuerySchema =
|
|
792
|
-
cursor:
|
|
793
|
-
limit:
|
|
1172
|
+
var messagesQuerySchema = z3.object({
|
|
1173
|
+
cursor: z3.string().optional(),
|
|
1174
|
+
limit: z3.coerce.number().int().min(1).max(100).default(50)
|
|
794
1175
|
});
|
|
795
|
-
var teamTypeSchema =
|
|
796
|
-
var teamRoleSchema =
|
|
797
|
-
var accessPermissionSchema =
|
|
798
|
-
var accessGrantInputSchema =
|
|
799
|
-
userId:
|
|
1176
|
+
var teamTypeSchema = z3.enum(["personal", "shared"]);
|
|
1177
|
+
var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
|
|
1178
|
+
var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
|
|
1179
|
+
var accessGrantInputSchema = z3.object({
|
|
1180
|
+
userId: z3.string().min(1),
|
|
800
1181
|
sessionName: sessionNameSchema.nullable().default(null),
|
|
801
1182
|
// null = general
|
|
802
1183
|
permission: accessPermissionSchema
|
|
803
1184
|
});
|
|
804
|
-
var machineIdSchema =
|
|
1185
|
+
var machineIdSchema = z3.string().min(1).max(63).regex(
|
|
805
1186
|
/^[a-z0-9][a-z0-9-]*$/,
|
|
806
1187
|
"Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
807
1188
|
);
|
|
808
|
-
var clerkProfileInputSchema =
|
|
809
|
-
email:
|
|
810
|
-
displayName:
|
|
811
|
-
avatarUrl:
|
|
812
|
-
provider:
|
|
813
|
-
providerUserId:
|
|
1189
|
+
var clerkProfileInputSchema = z3.object({
|
|
1190
|
+
email: z3.string().email().nullable(),
|
|
1191
|
+
displayName: z3.string().min(1).max(100),
|
|
1192
|
+
avatarUrl: z3.string().url().nullable(),
|
|
1193
|
+
provider: z3.string().min(1).max(50),
|
|
1194
|
+
providerUserId: z3.string().min(1).max(100)
|
|
814
1195
|
});
|
|
815
|
-
var sessionEnrollmentSchema =
|
|
816
|
-
sessionId:
|
|
817
|
-
team:
|
|
818
|
-
teamName:
|
|
819
|
-
key:
|
|
820
|
-
enrolledAt:
|
|
821
|
-
apiUrl:
|
|
822
|
-
machineId:
|
|
1196
|
+
var sessionEnrollmentSchema = z3.object({
|
|
1197
|
+
sessionId: z3.string(),
|
|
1198
|
+
team: z3.string(),
|
|
1199
|
+
teamName: z3.string().optional(),
|
|
1200
|
+
key: z3.string().startsWith("api_"),
|
|
1201
|
+
enrolledAt: z3.string().datetime(),
|
|
1202
|
+
apiUrl: z3.string().url().optional(),
|
|
1203
|
+
machineId: z3.string().optional()
|
|
823
1204
|
});
|
|
824
|
-
var authConfigSchema =
|
|
825
|
-
key:
|
|
826
|
-
userId:
|
|
827
|
-
displayName:
|
|
828
|
-
email:
|
|
829
|
-
machineId:
|
|
830
|
-
authenticatedAt:
|
|
1205
|
+
var authConfigSchema = z3.object({
|
|
1206
|
+
key: z3.string().startsWith("api_"),
|
|
1207
|
+
userId: z3.string().min(1),
|
|
1208
|
+
displayName: z3.string().nullable().optional().default(null),
|
|
1209
|
+
email: z3.string().email().nullable(),
|
|
1210
|
+
machineId: z3.string().nullable(),
|
|
1211
|
+
authenticatedAt: z3.string().datetime()
|
|
831
1212
|
});
|
|
832
1213
|
var configBaseFields = {
|
|
833
|
-
apiUrl:
|
|
834
|
-
machineId:
|
|
835
|
-
sessions:
|
|
836
|
-
defaults:
|
|
837
|
-
team:
|
|
838
|
-
session:
|
|
1214
|
+
apiUrl: z3.string().url(),
|
|
1215
|
+
machineId: z3.string().nullable(),
|
|
1216
|
+
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1217
|
+
defaults: z3.object({
|
|
1218
|
+
team: z3.string().nullable(),
|
|
1219
|
+
session: z3.string().nullable()
|
|
839
1220
|
}),
|
|
840
|
-
poll:
|
|
841
|
-
lastPollAt:
|
|
1221
|
+
poll: z3.object({
|
|
1222
|
+
lastPollAt: z3.string().datetime().nullable()
|
|
842
1223
|
})
|
|
843
1224
|
};
|
|
844
|
-
var coopConfigV1Schema =
|
|
845
|
-
version:
|
|
1225
|
+
var coopConfigV1Schema = z3.object({
|
|
1226
|
+
version: z3.literal(1),
|
|
846
1227
|
...configBaseFields
|
|
847
1228
|
});
|
|
848
|
-
var coopConfigV2Schema =
|
|
849
|
-
version:
|
|
1229
|
+
var coopConfigV2Schema = z3.object({
|
|
1230
|
+
version: z3.literal(2),
|
|
850
1231
|
...configBaseFields,
|
|
851
1232
|
auth: authConfigSchema.optional()
|
|
852
1233
|
});
|
|
853
|
-
var profileNameSchema =
|
|
1234
|
+
var profileNameSchema = z3.string().regex(
|
|
854
1235
|
/^[a-z0-9][a-z0-9_-]{0,63}$/,
|
|
855
1236
|
"Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
|
|
856
1237
|
);
|
|
857
|
-
var coopProfileSchema =
|
|
858
|
-
apiUrl:
|
|
1238
|
+
var coopProfileSchema = z3.object({
|
|
1239
|
+
apiUrl: z3.string().url(),
|
|
859
1240
|
auth: authConfigSchema.optional(),
|
|
860
|
-
sessions:
|
|
861
|
-
defaults:
|
|
862
|
-
team:
|
|
863
|
-
session:
|
|
1241
|
+
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1242
|
+
defaults: z3.object({
|
|
1243
|
+
team: z3.string().nullable(),
|
|
1244
|
+
session: z3.string().nullable()
|
|
864
1245
|
}),
|
|
865
|
-
poll:
|
|
866
|
-
lastPollAt:
|
|
1246
|
+
poll: z3.object({
|
|
1247
|
+
lastPollAt: z3.string().datetime().nullable()
|
|
867
1248
|
})
|
|
868
1249
|
});
|
|
869
|
-
var coopConfigV3ObjectSchema =
|
|
870
|
-
version:
|
|
871
|
-
machineId:
|
|
872
|
-
defaultProfile:
|
|
873
|
-
profiles:
|
|
1250
|
+
var coopConfigV3ObjectSchema = z3.object({
|
|
1251
|
+
version: z3.literal(3),
|
|
1252
|
+
machineId: z3.string().nullable(),
|
|
1253
|
+
defaultProfile: z3.string(),
|
|
1254
|
+
profiles: z3.record(profileNameSchema, coopProfileSchema)
|
|
874
1255
|
});
|
|
875
1256
|
function requireDefaultProfilePointer(config, ctx) {
|
|
876
1257
|
if (!(config.defaultProfile in config.profiles)) {
|
|
@@ -884,7 +1265,7 @@ function requireDefaultProfilePointer(config, ctx) {
|
|
|
884
1265
|
var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
|
|
885
1266
|
requireDefaultProfilePointer
|
|
886
1267
|
);
|
|
887
|
-
var coopConfigSchema =
|
|
1268
|
+
var coopConfigSchema = z3.discriminatedUnion("version", [
|
|
888
1269
|
coopConfigV1Schema,
|
|
889
1270
|
coopConfigV2Schema,
|
|
890
1271
|
coopConfigV3ObjectSchema
|
|
@@ -893,20 +1274,39 @@ var coopConfigSchema = z2.discriminatedUnion("version", [
|
|
|
893
1274
|
requireDefaultProfilePointer(config, ctx);
|
|
894
1275
|
}
|
|
895
1276
|
});
|
|
896
|
-
var hookStdinSchema =
|
|
897
|
-
session_id:
|
|
898
|
-
cwd:
|
|
899
|
-
hook_event_name:
|
|
1277
|
+
var hookStdinSchema = z3.object({
|
|
1278
|
+
session_id: z3.string().optional(),
|
|
1279
|
+
cwd: z3.string().optional(),
|
|
1280
|
+
hook_event_name: z3.string().optional()
|
|
900
1281
|
}).passthrough();
|
|
901
|
-
var wsClientMessageSchema =
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1282
|
+
var wsClientMessageSchema = z3.discriminatedUnion("type", [
|
|
1283
|
+
z3.object({ type: z3.literal("message"), content: messageContentSchema }),
|
|
1284
|
+
z3.object({ type: z3.literal("ack"), data: z3.object({ messageId: z3.string() }) }),
|
|
1285
|
+
z3.object({ type: z3.literal("presence_request") }),
|
|
1286
|
+
z3.object({ type: z3.literal("ping") })
|
|
905
1287
|
]);
|
|
1288
|
+
var workKindSchema = z3.enum(["implement", "generate"]);
|
|
1289
|
+
var workCompletionStatusSchema = z3.enum(["succeeded", "failed"]);
|
|
1290
|
+
var poolJoinInputSchema = z3.object({}).passthrough();
|
|
1291
|
+
var poolCheckInInputSchema = z3.object({}).passthrough();
|
|
1292
|
+
var poolCompleteInputSchema = z3.object({
|
|
1293
|
+
workId: z3.string().min(1),
|
|
1294
|
+
status: workCompletionStatusSchema,
|
|
1295
|
+
resultRef: z3.string().max(512),
|
|
1296
|
+
assignmentSeq: z3.number().int().nonnegative()
|
|
1297
|
+
});
|
|
1298
|
+
var poolEnqueueInputSchema = z3.object({
|
|
1299
|
+
kind: workKindSchema,
|
|
1300
|
+
source: z3.string().min(1),
|
|
1301
|
+
dedupKey: z3.string().min(1).max(512).optional()
|
|
1302
|
+
});
|
|
1303
|
+
var poolCancelInputSchema = z3.object({
|
|
1304
|
+
workId: z3.string().min(1)
|
|
1305
|
+
});
|
|
906
1306
|
|
|
907
1307
|
// ../shared/src/cli-config.ts
|
|
908
|
-
var DEFAULT_CONFIG_DIR =
|
|
909
|
-
var DEFAULT_CONFIG_PATH =
|
|
1308
|
+
var DEFAULT_CONFIG_DIR = join3(homedir2(), ".coopcli");
|
|
1309
|
+
var DEFAULT_CONFIG_PATH = join3(DEFAULT_CONFIG_DIR, "config.json");
|
|
910
1310
|
var PROD_API_URL = "https://api.coopcli.com";
|
|
911
1311
|
function emptyProfile(apiUrl) {
|
|
912
1312
|
return {
|
|
@@ -1238,8 +1638,8 @@ function resolveDisplayName(name, email, userId) {
|
|
|
1238
1638
|
async function openBrowser(url) {
|
|
1239
1639
|
const escaped = url.replace(/"/g, '\\"');
|
|
1240
1640
|
const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
|
|
1241
|
-
return new Promise((
|
|
1242
|
-
exec(cmd, () =>
|
|
1641
|
+
return new Promise((resolve5) => {
|
|
1642
|
+
exec(cmd, () => resolve5());
|
|
1243
1643
|
});
|
|
1244
1644
|
}
|
|
1245
1645
|
var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
|
|
@@ -1324,14 +1724,15 @@ function deriveApiUrl(webUrl) {
|
|
|
1324
1724
|
async function login(input, deps) {
|
|
1325
1725
|
const state = randomBytes(16).toString("hex");
|
|
1326
1726
|
const machineId = input.machine ?? hostname2();
|
|
1727
|
+
const log = deps.log ?? ((message) => void process.stderr.write(message));
|
|
1327
1728
|
const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
|
|
1328
1729
|
try {
|
|
1329
1730
|
const webUrl = input.webUrl ?? "https://coopcli.com";
|
|
1330
1731
|
const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
|
|
1331
|
-
|
|
1732
|
+
log(`Opening browser for authentication...
|
|
1332
1733
|
`);
|
|
1333
1734
|
await deps.openBrowser(authUrl);
|
|
1334
|
-
|
|
1735
|
+
log(`Waiting for callback on port ${server.port}...
|
|
1335
1736
|
`);
|
|
1336
1737
|
const params = await server.waitForCallback();
|
|
1337
1738
|
const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
|
|
@@ -1348,7 +1749,7 @@ async function login(input, deps) {
|
|
|
1348
1749
|
});
|
|
1349
1750
|
const config = await deps.config.load();
|
|
1350
1751
|
if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
|
|
1351
|
-
|
|
1752
|
+
log(
|
|
1352
1753
|
`
|
|
1353
1754
|
Backend mismatch: you authenticated against ${params.apiUrl}
|
|
1354
1755
|
but your config points to ${config.apiUrl}
|
|
@@ -1372,73 +1773,6 @@ Backend mismatch: you authenticated against ${params.apiUrl}
|
|
|
1372
1773
|
server.close();
|
|
1373
1774
|
}
|
|
1374
1775
|
}
|
|
1375
|
-
var CliAuthError = class extends Error {
|
|
1376
|
-
constructor(message, kind) {
|
|
1377
|
-
super(message);
|
|
1378
|
-
this.kind = kind;
|
|
1379
|
-
this.name = "CliAuthError";
|
|
1380
|
-
}
|
|
1381
|
-
kind;
|
|
1382
|
-
};
|
|
1383
|
-
async function resolveCoopCredentials(opts) {
|
|
1384
|
-
const env = opts.env ?? process.env;
|
|
1385
|
-
const { configPath, loginHint } = opts;
|
|
1386
|
-
if (!existsSync(configPath)) {
|
|
1387
|
-
throw new CliAuthError(`No CoopCLI config found at ${configPath}.
|
|
1388
|
-
${loginHint}`, "missing-config");
|
|
1389
|
-
}
|
|
1390
|
-
const store = new ConfigStore(configPath);
|
|
1391
|
-
let raw;
|
|
1392
|
-
try {
|
|
1393
|
-
raw = await store.loadRaw();
|
|
1394
|
-
} catch {
|
|
1395
|
-
throw new CliAuthError(
|
|
1396
|
-
`Could not read CoopCLI credentials from ${configPath}.
|
|
1397
|
-
${loginHint}`,
|
|
1398
|
-
"unreadable-config"
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
|
|
1402
|
-
const profile = raw.profiles[profileName];
|
|
1403
|
-
if (!profile) {
|
|
1404
|
-
const available = Object.keys(raw.profiles).join(", ");
|
|
1405
|
-
throw new CliAuthError(
|
|
1406
|
-
`CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
|
|
1407
|
-
${loginHint}`,
|
|
1408
|
-
"unknown-profile"
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
if (!profile.auth) {
|
|
1412
|
-
throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
|
|
1413
|
-
${loginHint}`, "no-login");
|
|
1414
|
-
}
|
|
1415
|
-
const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
|
|
1416
|
-
return {
|
|
1417
|
-
apiUrl,
|
|
1418
|
-
key: profile.auth.key,
|
|
1419
|
-
userId: profile.auth.userId,
|
|
1420
|
-
displayName: profile.auth.displayName ?? null
|
|
1421
|
-
};
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// server/coop-auth.ts
|
|
1425
|
-
var CoopAuthError = class extends Error {
|
|
1426
|
-
};
|
|
1427
|
-
var LOGIN_HINT = "--share needs a CoopCLI account. Run `specsketch login` (it opens your browser and saves credentials to ~/.coopcli/config.json), then rerun with --share.";
|
|
1428
|
-
async function readCoopAuth(configPath = join3(homedir2(), ".coopcli", "config.json"), env = process.env) {
|
|
1429
|
-
try {
|
|
1430
|
-
return await resolveCoopCredentials({
|
|
1431
|
-
configPath,
|
|
1432
|
-
env,
|
|
1433
|
-
profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
|
|
1434
|
-
apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
|
|
1435
|
-
loginHint: LOGIN_HINT
|
|
1436
|
-
});
|
|
1437
|
-
} catch (err) {
|
|
1438
|
-
if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
|
|
1439
|
-
throw err;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
1776
|
|
|
1443
1777
|
// server/login.ts
|
|
1444
1778
|
import { parseArgs } from "node:util";
|
|
@@ -1479,886 +1813,2229 @@ async function runLogin(args, deps = {}) {
|
|
|
1479
1813
|
if (profile) config.selectProfile(profile);
|
|
1480
1814
|
const result = await login(
|
|
1481
1815
|
{ webUrl: args.webUrl, loginCommand: "specsketch login" },
|
|
1482
|
-
|
|
1816
|
+
// Forward the writer too, so a caller supplying one gets ALL of login's output
|
|
1817
|
+
// rather than just this command's closing line (issue #1).
|
|
1818
|
+
{ config, openBrowser: deps.openBrowser ?? openBrowser, log: deps.log }
|
|
1483
1819
|
);
|
|
1484
1820
|
(deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
|
|
1485
1821
|
}
|
|
1486
1822
|
|
|
1487
|
-
// server/
|
|
1488
|
-
import {
|
|
1823
|
+
// server/workspace.ts
|
|
1824
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1825
|
+
import {
|
|
1826
|
+
existsSync as existsSync4,
|
|
1827
|
+
mkdirSync as mkdirSync2,
|
|
1828
|
+
readFileSync as readFileSync3,
|
|
1829
|
+
readdirSync as readdirSync4,
|
|
1830
|
+
renameSync as renameSync2,
|
|
1831
|
+
writeFileSync as writeFileSync2
|
|
1832
|
+
} from "node:fs";
|
|
1833
|
+
import { basename as basename2, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
|
|
1834
|
+
|
|
1835
|
+
// ../shared/src/openspec-change-meta.ts
|
|
1836
|
+
import { createHash } from "node:crypto";
|
|
1837
|
+
import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1838
|
+
import { join as join4 } from "node:path";
|
|
1839
|
+
import { Document, parseDocument } from "yaml";
|
|
1840
|
+
import { z as z4 } from "zod";
|
|
1489
1841
|
|
|
1490
|
-
// ../shared/src/
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1842
|
+
// ../shared/src/fs-atomic.ts
|
|
1843
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
1844
|
+
import { dirname as dirname2 } from "node:path";
|
|
1845
|
+
function writeAtomicFile(target, content) {
|
|
1846
|
+
mkdirSync(dirname2(target), { recursive: true });
|
|
1847
|
+
const tmp = `${target}.tmp`;
|
|
1848
|
+
writeFileSync(tmp, content, "utf8");
|
|
1849
|
+
renameSync(tmp, target);
|
|
1850
|
+
}
|
|
1851
|
+
function readIfExists(target) {
|
|
1852
|
+
return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
|
|
1495
1853
|
}
|
|
1496
1854
|
|
|
1497
|
-
// ../shared/src/
|
|
1498
|
-
var
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1855
|
+
// ../shared/src/openspec-change-meta.ts
|
|
1856
|
+
var CHANGE_META_FILE = "specplan.yaml";
|
|
1857
|
+
var nodeChangeMetaFs = {
|
|
1858
|
+
readIfExists(path) {
|
|
1859
|
+
return readIfExists(path);
|
|
1860
|
+
},
|
|
1861
|
+
writeAtomicFile(path, content) {
|
|
1862
|
+
writeAtomicFile(path, content);
|
|
1863
|
+
},
|
|
1864
|
+
exists(path) {
|
|
1865
|
+
return existsSync3(path);
|
|
1866
|
+
},
|
|
1867
|
+
readdir(path) {
|
|
1868
|
+
if (!existsSync3(path)) return [];
|
|
1869
|
+
return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
|
|
1870
|
+
name: entry.name,
|
|
1871
|
+
directory: entry.isDirectory()
|
|
1872
|
+
}));
|
|
1873
|
+
},
|
|
1874
|
+
realpath(path) {
|
|
1875
|
+
return realpathSync2(path);
|
|
1510
1876
|
}
|
|
1511
1877
|
};
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
const chunks = [];
|
|
1518
|
-
for (; ; ) {
|
|
1519
|
-
const { done, value } = await reader.read();
|
|
1520
|
-
if (done) break;
|
|
1521
|
-
chunks.push(value);
|
|
1522
|
-
}
|
|
1523
|
-
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
1524
|
-
const result = new Uint8Array(total);
|
|
1525
|
-
let offset = 0;
|
|
1526
|
-
for (const chunk of chunks) {
|
|
1527
|
-
result.set(chunk, offset);
|
|
1528
|
-
offset += chunk.length;
|
|
1529
|
-
}
|
|
1530
|
-
return result;
|
|
1531
|
-
}
|
|
1532
|
-
var verbose = false;
|
|
1533
|
-
var ApiClient = class _ApiClient {
|
|
1534
|
-
constructor(apiUrl, token, userId) {
|
|
1535
|
-
this.apiUrl = apiUrl;
|
|
1536
|
-
this.token = token;
|
|
1537
|
-
this.userId = userId;
|
|
1538
|
-
}
|
|
1539
|
-
apiUrl;
|
|
1540
|
-
token;
|
|
1541
|
-
userId;
|
|
1542
|
-
/** Build a user-scoped path. Only callable when userId is set. */
|
|
1543
|
-
userPath(sub) {
|
|
1544
|
-
return `/api/u/${this.userId}${sub}`;
|
|
1545
|
-
}
|
|
1546
|
-
async request(method, path, body, params) {
|
|
1547
|
-
const url = new URL(path, this.apiUrl);
|
|
1548
|
-
if (params) {
|
|
1549
|
-
for (const [key, val] of Object.entries(params)) {
|
|
1550
|
-
if (val !== void 0) {
|
|
1551
|
-
url.searchParams.set(key, String(val));
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
if (verbose) {
|
|
1556
|
-
process.stderr.write(`[verbose] ${method} ${url.toString()}
|
|
1557
|
-
`);
|
|
1558
|
-
if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
|
|
1559
|
-
`);
|
|
1560
|
-
}
|
|
1561
|
-
const res = await fetch(url.toString(), {
|
|
1562
|
-
method,
|
|
1563
|
-
headers: {
|
|
1564
|
-
Authorization: `Bearer ${this.token}`,
|
|
1565
|
-
"Content-Type": "application/json"
|
|
1566
|
-
},
|
|
1567
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1568
|
-
});
|
|
1569
|
-
if (verbose) {
|
|
1570
|
-
process.stderr.write(`[verbose] ${res.status} ${res.statusText}
|
|
1571
|
-
`);
|
|
1572
|
-
}
|
|
1573
|
-
if (!res.ok) {
|
|
1574
|
-
let parsed = null;
|
|
1575
|
-
try {
|
|
1576
|
-
const json = await res.json();
|
|
1577
|
-
if (isApiErrorBody(json)) {
|
|
1578
|
-
parsed = json;
|
|
1579
|
-
} else {
|
|
1580
|
-
const legacy = json;
|
|
1581
|
-
parsed = {
|
|
1582
|
-
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1583
|
-
code: "UNKNOWN",
|
|
1584
|
-
message: legacy.error ?? legacy.message ?? res.statusText
|
|
1585
|
-
};
|
|
1586
|
-
}
|
|
1587
|
-
} catch {
|
|
1588
|
-
parsed = {
|
|
1589
|
-
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1590
|
-
code: "UNKNOWN",
|
|
1591
|
-
message: res.statusText
|
|
1592
|
-
};
|
|
1593
|
-
}
|
|
1594
|
-
if (verbose) {
|
|
1595
|
-
process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
|
|
1596
|
-
`);
|
|
1597
|
-
}
|
|
1598
|
-
throw new CoopApiError(res.status, parsed);
|
|
1599
|
-
}
|
|
1600
|
-
return res.json();
|
|
1601
|
-
}
|
|
1602
|
-
async getMe() {
|
|
1603
|
-
const path = this.userId ? this.userPath("/me") : "/api/me";
|
|
1604
|
-
return this.request("GET", path);
|
|
1605
|
-
}
|
|
1606
|
-
async createTeam(input) {
|
|
1607
|
-
if (this.userId) {
|
|
1608
|
-
}
|
|
1609
|
-
return this.request("POST", "/api/teams", input);
|
|
1610
|
-
}
|
|
1611
|
-
async listTeams() {
|
|
1612
|
-
if (this.userId) {
|
|
1613
|
-
const me = await this.request("GET", this.userPath("/me"));
|
|
1614
|
-
return (me.teams ?? []).map((m) => ({
|
|
1615
|
-
id: m.teamId,
|
|
1616
|
-
name: m.teamName,
|
|
1617
|
-
type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
|
|
1618
|
-
plan: "hobby",
|
|
1619
|
-
createdBy: "",
|
|
1620
|
-
createdAt: m.joinedAt
|
|
1621
|
-
}));
|
|
1622
|
-
}
|
|
1623
|
-
return this.request("GET", "/api/teams");
|
|
1624
|
-
}
|
|
1625
|
-
async inviteToTeam(team, input) {
|
|
1626
|
-
return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
|
|
1627
|
-
}
|
|
1628
|
-
async joinTeam(team, inviteCode) {
|
|
1629
|
-
await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
|
|
1630
|
-
}
|
|
1631
|
-
async listTeamMembers(team) {
|
|
1632
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
|
|
1633
|
-
}
|
|
1634
|
-
async createSession(input) {
|
|
1635
|
-
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
1636
|
-
return this.request("POST", path, input);
|
|
1637
|
-
}
|
|
1638
|
-
async listSessions() {
|
|
1639
|
-
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
1640
|
-
const result = await this.request("GET", path);
|
|
1641
|
-
return result.sessions;
|
|
1642
|
-
}
|
|
1643
|
-
async joinSession(name) {
|
|
1644
|
-
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
|
|
1645
|
-
}
|
|
1646
|
-
async leaveSession(name) {
|
|
1647
|
-
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
|
|
1648
|
-
}
|
|
1649
|
-
async deleteSession(name) {
|
|
1650
|
-
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
|
|
1651
|
-
return this.request("DELETE", path);
|
|
1652
|
-
}
|
|
1653
|
-
async sendMessage(teamId, session, input) {
|
|
1654
|
-
if (this.userId) {
|
|
1655
|
-
return this.request(
|
|
1656
|
-
"POST",
|
|
1657
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1658
|
-
input,
|
|
1659
|
-
{ team: teamId }
|
|
1660
|
-
);
|
|
1661
|
-
}
|
|
1662
|
-
return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
|
|
1663
|
-
}
|
|
1664
|
-
async getMessages(teamId, session, cursor, limit) {
|
|
1665
|
-
if (this.userId) {
|
|
1666
|
-
return this.request(
|
|
1667
|
-
"GET",
|
|
1668
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1669
|
-
void 0,
|
|
1670
|
-
{ team: teamId, cursor, limit }
|
|
1671
|
-
);
|
|
1672
|
-
}
|
|
1673
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
1674
|
-
cursor,
|
|
1675
|
-
limit
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
async getLatestMessages(teamId, session, count = 5) {
|
|
1679
|
-
if (this.userId) {
|
|
1680
|
-
return this.request(
|
|
1681
|
-
"GET",
|
|
1682
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1683
|
-
void 0,
|
|
1684
|
-
{ team: teamId, latest: count }
|
|
1685
|
-
);
|
|
1686
|
-
}
|
|
1687
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
1688
|
-
latest: count
|
|
1689
|
-
});
|
|
1690
|
-
}
|
|
1691
|
-
async queryAIContext(teamId, sessionName, input) {
|
|
1692
|
-
return this.request(
|
|
1693
|
-
"POST",
|
|
1694
|
-
this.userPath(`/sessions/${encodeURIComponent(sessionName)}/ai-context`),
|
|
1695
|
-
input,
|
|
1696
|
-
{ team: teamId }
|
|
1697
|
-
);
|
|
1698
|
-
}
|
|
1699
|
-
async listSessionFiles(teamId, sessionName) {
|
|
1700
|
-
const result = await this.request(
|
|
1701
|
-
"GET",
|
|
1702
|
-
this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
|
|
1703
|
-
void 0,
|
|
1704
|
-
{ team: teamId }
|
|
1705
|
-
);
|
|
1706
|
-
return result.files;
|
|
1707
|
-
}
|
|
1708
|
-
async getNotifications(params) {
|
|
1709
|
-
const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
|
|
1710
|
-
return this.request("GET", path, void 0, params);
|
|
1711
|
-
}
|
|
1712
|
-
async markRead(ids) {
|
|
1713
|
-
const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
|
|
1714
|
-
await this.request("POST", path, { notificationIds: ids });
|
|
1715
|
-
}
|
|
1716
|
-
/** List the caller's user-scoped API keys. Requires user-level auth. */
|
|
1717
|
-
async listUserApiKeys() {
|
|
1718
|
-
const body = await this.request("GET", this.userPath("/keys"));
|
|
1719
|
-
return body.keys;
|
|
1878
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
1879
|
+
function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
|
|
1880
|
+
const files = [];
|
|
1881
|
+
for (const name of ["proposal.md", "design.md", "tasks.md"]) {
|
|
1882
|
+
if (fsx.exists(join4(changeDir, name))) files.push(name);
|
|
1720
1883
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1884
|
+
const specsDir = join4(changeDir, "specs");
|
|
1885
|
+
for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1886
|
+
const rel = join4("specs", entry.name, "spec.md");
|
|
1887
|
+
if (entry.directory && fsx.exists(join4(changeDir, rel))) files.push(rel);
|
|
1724
1888
|
}
|
|
1889
|
+
return files;
|
|
1890
|
+
}
|
|
1891
|
+
function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
|
|
1892
|
+
const files = listBodyFiles(changeDir, fsx);
|
|
1893
|
+
if (files.length === 0) return null;
|
|
1894
|
+
const parts = files.map(
|
|
1895
|
+
(rel) => `${rel}
|
|
1896
|
+
${sha256(fsx.readIfExists(join4(changeDir, rel)) ?? "")}`
|
|
1897
|
+
);
|
|
1898
|
+
return `sha256:${sha256(parts.join("\n"))}`;
|
|
1899
|
+
}
|
|
1900
|
+
var SPEC_STATUSES = ["draft", "in-progress"];
|
|
1901
|
+
var SpecStatus = z4.enum(SPEC_STATUSES);
|
|
1902
|
+
var LEGACY_STATUS_COERCION = {
|
|
1903
|
+
active: "in-progress",
|
|
1904
|
+
done: "in-progress",
|
|
1905
|
+
deferred: "draft"
|
|
1906
|
+
};
|
|
1907
|
+
var StoredSpecStatus = z4.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
|
|
1908
|
+
(s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
|
|
1909
|
+
);
|
|
1910
|
+
var SpecMetaSchema = z4.object({
|
|
1911
|
+
specId: z4.string(),
|
|
1912
|
+
userStories: z4.array(z4.string()),
|
|
1913
|
+
dependencies: z4.array(z4.string()),
|
|
1914
|
+
/** Working state (retired values coerce on read); absent = draft. */
|
|
1915
|
+
status: StoredSpecStatus.optional(),
|
|
1725
1916
|
/**
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1917
|
+
* Approved axis (formerly `applied`) — fold-driven, SEPARATE from the
|
|
1918
|
+
* working status above. True when the proposal's delta has been folded
|
|
1919
|
+
* into specs/ and archived (apply-first: the fold happens at approval).
|
|
1920
|
+
* Absent = not-approved.
|
|
1729
1921
|
*/
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1922
|
+
approved: z4.boolean().optional(),
|
|
1923
|
+
/**
|
|
1924
|
+
* Legacy spelling of the fold axis (pre-rename metadata files). Read-only
|
|
1925
|
+
* compatibility: readers treat `applied: true` as `approved: true`; no
|
|
1926
|
+
* writer ever sets this field again.
|
|
1927
|
+
*/
|
|
1928
|
+
applied: z4.boolean().optional(),
|
|
1929
|
+
/**
|
|
1930
|
+
* Build flag — user-set, independent of the fold: true once the code has
|
|
1931
|
+
* been built to match the approved diff. Absent = not-implemented.
|
|
1932
|
+
*/
|
|
1933
|
+
implemented: z4.boolean().optional(),
|
|
1934
|
+
/**
|
|
1935
|
+
* Minimal implementation-task record written on fold (specplan-timeline-apply
|
|
1936
|
+
* "Implementation task record on fold"): the creation time and a reference to
|
|
1937
|
+
* the archived change's own id/path (from which the delta artifact is
|
|
1938
|
+
* materialized on demand). Deliberately carries NO status and NO dispatch
|
|
1939
|
+
* state — task execution/dispatch is a separate, not-yet-defined concern. A
|
|
1940
|
+
* field addition to this sidecar, not a redefinition of its format.
|
|
1941
|
+
*/
|
|
1942
|
+
task: z4.object({
|
|
1943
|
+
createdAt: z4.string(),
|
|
1944
|
+
source: z4.string()
|
|
1945
|
+
}).optional(),
|
|
1946
|
+
generation: z4.object({
|
|
1947
|
+
/** sha256 of the last generated body files — detects hand edits. */
|
|
1948
|
+
lastGeneratedBodyHash: z4.string(),
|
|
1949
|
+
lastGeneratedAt: z4.string()
|
|
1950
|
+
}).optional()
|
|
1951
|
+
});
|
|
1952
|
+
function writeChangeMeta(fsx, changeDir, meta) {
|
|
1953
|
+
const path = join4(changeDir, CHANGE_META_FILE);
|
|
1954
|
+
const raw = fsx.readIfExists(path);
|
|
1955
|
+
const doc = raw === void 0 ? new Document({
|
|
1956
|
+
specId: meta.specId,
|
|
1957
|
+
userStories: meta.userStories ?? [],
|
|
1958
|
+
dependencies: meta.dependencies ?? [],
|
|
1959
|
+
status: meta.status ?? "draft"
|
|
1960
|
+
}) : parseDocument(raw);
|
|
1961
|
+
if (meta.approved !== void 0) doc.setIn(["approved"], meta.approved);
|
|
1962
|
+
if (meta.implemented !== void 0) doc.setIn(["implemented"], meta.implemented);
|
|
1963
|
+
if (meta.task !== void 0) {
|
|
1964
|
+
doc.setIn(["task", "createdAt"], meta.task.createdAt);
|
|
1965
|
+
doc.setIn(["task", "source"], meta.task.source);
|
|
1966
|
+
}
|
|
1967
|
+
if (meta.generation !== void 0) {
|
|
1968
|
+
doc.setIn(["generation", "lastGeneratedBodyHash"], meta.generation.lastGeneratedBodyHash);
|
|
1969
|
+
doc.setIn(["generation", "lastGeneratedAt"], meta.generation.lastGeneratedAt);
|
|
1970
|
+
}
|
|
1971
|
+
fsx.writeAtomicFile(path, doc.toString());
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// server/workspace.ts
|
|
1975
|
+
var DIAGRAM_FILE = "diagram.excalidraw";
|
|
1976
|
+
var SIDECAR_FILE = "specsketch.json";
|
|
1977
|
+
var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
|
|
1978
|
+
var ChangeWorkspace = class {
|
|
1979
|
+
dir;
|
|
1980
|
+
changeName;
|
|
1981
|
+
/**
|
|
1982
|
+
* `create` defaults to true, which is the canvas-era contract: constructing a
|
|
1983
|
+
* workspace makes its directory. The workbench passes `false`, because opening a
|
|
1984
|
+
* change id that does not exist must not put an empty directory in the repository —
|
|
1985
|
+
* the next run would report `resuming` for a change nobody started, and
|
|
1986
|
+
* `openspec:check` WARNs about a change carrying no delta specs. `writeAtomic`
|
|
1987
|
+
* creates the directory when something is actually written, which is the first
|
|
1988
|
+
* moment the change is real.
|
|
1989
|
+
*/
|
|
1990
|
+
constructor(dir, options = {}) {
|
|
1991
|
+
this.dir = resolve2(dir);
|
|
1992
|
+
this.changeName = basename2(this.dir);
|
|
1993
|
+
if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
|
|
1762
1994
|
}
|
|
1763
|
-
|
|
1764
|
-
const
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
headers: { Authorization: `Bearer ${this.token}` }
|
|
1770
|
-
});
|
|
1771
|
-
if (!res.ok) {
|
|
1772
|
-
const text = await res.text();
|
|
1773
|
-
let msg = res.statusText;
|
|
1774
|
-
try {
|
|
1775
|
-
msg = JSON.parse(text).message ?? msg;
|
|
1776
|
-
} catch {
|
|
1777
|
-
}
|
|
1778
|
-
throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
|
|
1779
|
-
}
|
|
1780
|
-
const disposition = res.headers.get("Content-Disposition") ?? "";
|
|
1781
|
-
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
|
1782
|
-
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
|
|
1783
|
-
const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
|
|
1784
|
-
return { filename, body: res.body, size };
|
|
1785
|
-
}
|
|
1786
|
-
getWebSocketUrl(session) {
|
|
1787
|
-
const wsBase = this.apiUrl.replace(
|
|
1788
|
-
/^https?:\/\//,
|
|
1789
|
-
(match) => match === "https://" ? "wss://" : "ws://"
|
|
1790
|
-
);
|
|
1791
|
-
return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
|
|
1995
|
+
writeAtomic(relPath, content) {
|
|
1996
|
+
const target = join5(this.dir, relPath);
|
|
1997
|
+
mkdirSync2(dirname3(target), { recursive: true });
|
|
1998
|
+
const tmp = `${target}.tmp`;
|
|
1999
|
+
writeFileSync2(tmp, content, "utf8");
|
|
2000
|
+
renameSync2(tmp, target);
|
|
1792
2001
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
2002
|
+
readIfExists(relPath) {
|
|
2003
|
+
const target = join5(this.dir, relPath);
|
|
2004
|
+
return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
|
|
1795
2005
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
})
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2006
|
+
// ── Scene ────────────────────────────────────────────────────────────────
|
|
2007
|
+
loadScene() {
|
|
2008
|
+
const raw = this.readIfExists(DIAGRAM_FILE);
|
|
2009
|
+
if (!raw) return null;
|
|
2010
|
+
try {
|
|
2011
|
+
const parsed = JSON.parse(raw);
|
|
2012
|
+
return {
|
|
2013
|
+
elements: Array.isArray(parsed.elements) ? parsed.elements : [],
|
|
2014
|
+
appState: parsed.appState ?? void 0,
|
|
2015
|
+
files: parsed.files ?? void 0
|
|
2016
|
+
};
|
|
2017
|
+
} catch {
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
saveScene(scene, title, model) {
|
|
2022
|
+
this.writeAtomic(
|
|
2023
|
+
DIAGRAM_FILE,
|
|
2024
|
+
JSON.stringify(
|
|
2025
|
+
{
|
|
2026
|
+
type: "excalidraw",
|
|
2027
|
+
version: 2,
|
|
2028
|
+
source: "coopcli-specsketch",
|
|
2029
|
+
elements: scene.elements,
|
|
2030
|
+
appState: scene.appState ?? {},
|
|
2031
|
+
files: scene.files ?? {}
|
|
2032
|
+
},
|
|
2033
|
+
null,
|
|
2034
|
+
2
|
|
2035
|
+
)
|
|
2036
|
+
);
|
|
2037
|
+
this.updateSidecar((s) => ({
|
|
2038
|
+
...s,
|
|
2039
|
+
...title !== void 0 ? { title } : {},
|
|
2040
|
+
...model !== void 0 ? { model } : {}
|
|
2041
|
+
}));
|
|
2042
|
+
}
|
|
2043
|
+
// ── Sidecar ──────────────────────────────────────────────────────────────
|
|
2044
|
+
readSidecar() {
|
|
2045
|
+
const raw = this.readIfExists(SIDECAR_FILE);
|
|
2046
|
+
if (!raw) return {};
|
|
2047
|
+
try {
|
|
2048
|
+
return JSON.parse(raw);
|
|
2049
|
+
} catch {
|
|
2050
|
+
return {};
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
updateSidecar(update) {
|
|
2054
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2055
|
+
const current = this.readSidecar();
|
|
2056
|
+
const next = update(current);
|
|
2057
|
+
next.createdAt ??= now;
|
|
2058
|
+
next.updatedAt = now;
|
|
2059
|
+
this.writeAtomic(SIDECAR_FILE, JSON.stringify(next, null, 2));
|
|
2060
|
+
}
|
|
2061
|
+
// ── Artifacts ────────────────────────────────────────────────────────────
|
|
2062
|
+
/** Current artifact files, however they were authored. */
|
|
2063
|
+
readArtifacts() {
|
|
2064
|
+
const specs = [];
|
|
2065
|
+
const specsDir = join5(this.dir, "specs");
|
|
2066
|
+
if (existsSync4(specsDir)) {
|
|
2067
|
+
for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
|
|
2068
|
+
if (!entry.isDirectory()) continue;
|
|
2069
|
+
const spec = this.readIfExists(join5("specs", entry.name, "spec.md"));
|
|
2070
|
+
if (spec !== void 0) specs.push({ capability: entry.name, spec });
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return {
|
|
2074
|
+
proposal: this.readIfExists("proposal.md"),
|
|
2075
|
+
design: this.readIfExists("design.md"),
|
|
2076
|
+
tasks: this.readIfExists("tasks.md"),
|
|
2077
|
+
specs
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
/** Reconstruct a SpecBundle view from the files on disk, or null if none. */
|
|
2081
|
+
toBundle() {
|
|
2082
|
+
const a = this.readArtifacts();
|
|
2083
|
+
if (!a.proposal && !a.design && !a.tasks && a.specs.length === 0) return null;
|
|
2084
|
+
return {
|
|
2085
|
+
changeName: this.changeName,
|
|
2086
|
+
summary: this.readSidecar().summary ?? "",
|
|
2087
|
+
proposal: a.proposal ?? "",
|
|
2088
|
+
design: a.design ?? "",
|
|
2089
|
+
tasks: a.tasks ?? "",
|
|
2090
|
+
specs: a.specs
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Materialize a generated bundle in OpenSpec layout, writing only files
|
|
2095
|
+
* whose content actually changed. Returns the relative paths written.
|
|
2096
|
+
*/
|
|
2097
|
+
writeBundle(bundle) {
|
|
2098
|
+
const files = {
|
|
2099
|
+
"proposal.md": bundle.proposal,
|
|
2100
|
+
"design.md": bundle.design,
|
|
2101
|
+
"tasks.md": bundle.tasks
|
|
2102
|
+
};
|
|
2103
|
+
for (const s of bundle.specs) files[join5("specs", s.capability, "spec.md")] = s.spec;
|
|
2104
|
+
const written = [];
|
|
2105
|
+
const manifest = {};
|
|
2106
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
2107
|
+
manifest[relPath] = sha2562(content);
|
|
2108
|
+
if (this.readIfExists(relPath) === content) continue;
|
|
2109
|
+
this.writeAtomic(relPath, content);
|
|
2110
|
+
written.push(relPath);
|
|
2111
|
+
}
|
|
2112
|
+
this.updateSidecar((s) => ({
|
|
2113
|
+
...s,
|
|
2114
|
+
summary: bundle.summary,
|
|
2115
|
+
generated: manifest,
|
|
2116
|
+
// The diff-before-write result IS the changed-file signal for the
|
|
2117
|
+
// post-generation chat seed (generation-chat-reset-summary).
|
|
2118
|
+
lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
|
|
2119
|
+
}));
|
|
2120
|
+
const lastGeneratedBodyHash = bodyHash(this.dir, nodeChangeMetaFs);
|
|
2121
|
+
if (lastGeneratedBodyHash) {
|
|
2122
|
+
writeChangeMeta(nodeChangeMetaFs, this.dir, {
|
|
2123
|
+
specId: this.changeName,
|
|
2124
|
+
userStories: [],
|
|
2125
|
+
dependencies: [],
|
|
2126
|
+
status: "draft",
|
|
2127
|
+
generation: { lastGeneratedBodyHash, lastGeneratedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
return written;
|
|
2131
|
+
}
|
|
2132
|
+
// ── Spec review chat ─────────────────────────────────────────────────────
|
|
2133
|
+
readChat() {
|
|
2134
|
+
return this.readSidecar().chat ?? [];
|
|
2135
|
+
}
|
|
2136
|
+
writeChat(chat) {
|
|
2137
|
+
this.updateSidecar((s) => ({ ...s, chat }));
|
|
2138
|
+
}
|
|
2139
|
+
// ── Generation system prompt override ────────────────────────────────────
|
|
2140
|
+
readSystemPrompt() {
|
|
2141
|
+
return this.readSidecar().systemPrompt;
|
|
2142
|
+
}
|
|
2143
|
+
/** `undefined` clears the override (reset to the built-in default). */
|
|
2144
|
+
writeSystemPrompt(systemPrompt) {
|
|
2145
|
+
this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
|
|
2146
|
+
...s,
|
|
2147
|
+
...systemPrompt !== void 0 ? { systemPrompt } : {}
|
|
2148
|
+
}));
|
|
2149
|
+
}
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
// server/workbench-host.ts
|
|
2153
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
|
|
2154
|
+
import { homedir as homedir3 } from "node:os";
|
|
2155
|
+
import { dirname as dirname5, join as join10, resolve as resolve3 } from "node:path";
|
|
2156
|
+
|
|
2157
|
+
// src/workbench/core/capability-store.ts
|
|
2158
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync5, statSync as statSync2 } from "node:fs";
|
|
2159
|
+
import { join as join6 } from "node:path";
|
|
2160
|
+
|
|
2161
|
+
// src/workbench/core/spec-parse.ts
|
|
2162
|
+
var REQUIREMENT_HEADING = /^###[ \t]+Requirement:[ \t]*(.*)$/;
|
|
2163
|
+
var TERMINATING_HEADING = /^#{1,3}(?!#)(?:[ \t]|$)/;
|
|
2164
|
+
var HIGHER_LEVEL_HEADING = /^#{1,2}(?!#)(?:[ \t]|$)/;
|
|
2165
|
+
var FENCE_DELIMITER = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
|
|
2166
|
+
function probe(line) {
|
|
2167
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
2168
|
+
}
|
|
2169
|
+
function isBlank(line) {
|
|
2170
|
+
return probe(line).trim() === "";
|
|
2171
|
+
}
|
|
2172
|
+
function advanceFence(open, probed, lineNumber, rawLine) {
|
|
2173
|
+
const delimiter2 = FENCE_DELIMITER.exec(probed);
|
|
2174
|
+
if (!delimiter2) return open;
|
|
2175
|
+
const marker = delimiter2[1];
|
|
2176
|
+
const info = delimiter2[2] ?? "";
|
|
2177
|
+
const char = marker[0];
|
|
2178
|
+
if (open === null) {
|
|
2179
|
+
if (char === "`" && info.includes("`")) return null;
|
|
2180
|
+
return { char, length: marker.length, openLine: lineNumber, openText: rawLine };
|
|
2181
|
+
}
|
|
2182
|
+
if (char === open.char && marker.length >= open.length && info.trim() === "") return null;
|
|
2183
|
+
return open;
|
|
2184
|
+
}
|
|
2185
|
+
function closeBlock(open) {
|
|
2186
|
+
const lines = open.lines.slice();
|
|
2187
|
+
while (lines.length > 1 && isBlank(lines[lines.length - 1])) lines.pop();
|
|
2188
|
+
const bodyLines = lines.slice(1);
|
|
2189
|
+
return {
|
|
2190
|
+
index: open.index,
|
|
2191
|
+
name: open.name,
|
|
2192
|
+
headingLine: open.headingLine,
|
|
2193
|
+
lines,
|
|
2194
|
+
text: lines.join("\n"),
|
|
2195
|
+
bodyLines,
|
|
2196
|
+
bodyText: bodyLines.join("\n"),
|
|
2197
|
+
startLine: open.startLine,
|
|
2198
|
+
endLine: open.startLine + lines.length,
|
|
2199
|
+
higherLevelHeadings: open.higherLevelHeadings.filter(
|
|
2200
|
+
(h) => h.line < open.startLine + lines.length
|
|
2201
|
+
)
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
function parseSpecFile(src) {
|
|
2205
|
+
const lines = src.split("\n");
|
|
2206
|
+
const head = [];
|
|
2207
|
+
const requirements = [];
|
|
2208
|
+
const outside = [];
|
|
2209
|
+
let open = null;
|
|
2210
|
+
let outsideStart = -1;
|
|
2211
|
+
let outsideLines = [];
|
|
2212
|
+
const fence = { open: null };
|
|
2213
|
+
const flushOutside = () => {
|
|
2214
|
+
if (outsideStart >= 0) {
|
|
2215
|
+
outside.push({ startLine: outsideStart, lines: outsideLines });
|
|
2216
|
+
outsideStart = -1;
|
|
2217
|
+
outsideLines = [];
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
lines.forEach((line, i) => {
|
|
2221
|
+
const p = probe(line);
|
|
2222
|
+
const wasFenced = fence.open !== null;
|
|
2223
|
+
fence.open = advanceFence(fence.open, p, i, line);
|
|
2224
|
+
const fenced = wasFenced || fence.open !== null;
|
|
2225
|
+
if (!fenced) {
|
|
2226
|
+
const requirementHeading = REQUIREMENT_HEADING.exec(p);
|
|
2227
|
+
if (requirementHeading) {
|
|
2228
|
+
if (open) requirements.push(closeBlock(open));
|
|
2229
|
+
flushOutside();
|
|
2230
|
+
open = {
|
|
2231
|
+
index: requirements.length,
|
|
2232
|
+
name: (requirementHeading[1] ?? "").trim(),
|
|
2233
|
+
headingLine: line,
|
|
2234
|
+
startLine: i,
|
|
2235
|
+
lines: [line],
|
|
2236
|
+
higherLevelHeadings: []
|
|
2237
|
+
};
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
if (TERMINATING_HEADING.test(p)) {
|
|
2241
|
+
if (open) {
|
|
2242
|
+
requirements.push(closeBlock(open));
|
|
2243
|
+
open = null;
|
|
2244
|
+
}
|
|
2245
|
+
if (requirements.length === 0) {
|
|
2246
|
+
head.push(line);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (outsideStart < 0) outsideStart = i;
|
|
2250
|
+
outsideLines.push(line);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
if (open) {
|
|
2255
|
+
const block = open;
|
|
2256
|
+
if (HIGHER_LEVEL_HEADING.test(p)) block.higherLevelHeadings.push({ line: i, text: line });
|
|
2257
|
+
block.lines.push(line);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
if (requirements.length === 0) {
|
|
2261
|
+
head.push(line);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
if (outsideStart < 0) outsideStart = i;
|
|
2265
|
+
outsideLines.push(line);
|
|
2266
|
+
});
|
|
2267
|
+
if (open) requirements.push(closeBlock(open));
|
|
2268
|
+
flushOutside();
|
|
2269
|
+
const unterminatedFence = fence.open === null ? null : { openLine: fence.open.openLine, openText: fence.open.openText };
|
|
2270
|
+
return {
|
|
2271
|
+
head,
|
|
2272
|
+
headText: head.join("\n"),
|
|
2273
|
+
requirements,
|
|
2274
|
+
outside,
|
|
2275
|
+
unterminatedFence
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
function countRequirements(src) {
|
|
2279
|
+
return parseSpecFile(src).requirements.length;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// src/workbench/core/capability-store.ts
|
|
2283
|
+
var CAPABILITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2284
|
+
var SPEC_FILE = "spec.md";
|
|
2285
|
+
function validateCapabilityName(name) {
|
|
2286
|
+
if (name.length === 0) return "a capability name cannot be empty";
|
|
2287
|
+
if (!CAPABILITY_NAME.test(name)) {
|
|
2288
|
+
return `\`${name}\` is not a capability name: expected lowercase kebab-case, e.g. \`specsketch-delta-derivation\``;
|
|
2289
|
+
}
|
|
2290
|
+
return null;
|
|
2291
|
+
}
|
|
2292
|
+
function readIfPresent(path) {
|
|
2293
|
+
try {
|
|
2294
|
+
if (!statSync2(path).isFile()) return null;
|
|
2295
|
+
} catch {
|
|
2296
|
+
return null;
|
|
2297
|
+
}
|
|
2298
|
+
return readFileSync4(path, "utf8");
|
|
2299
|
+
}
|
|
2300
|
+
function createFoldedSpecReader(repoRoot) {
|
|
2301
|
+
const specsDir = join6(repoRoot, "openspec", "specs");
|
|
2302
|
+
const specPath = (name) => join6(specsDir, name, SPEC_FILE);
|
|
2303
|
+
const readCapability = (name) => {
|
|
2304
|
+
const invalid = validateCapabilityName(name);
|
|
2305
|
+
if (invalid) throw new RangeError(invalid);
|
|
2306
|
+
return readIfPresent(specPath(name));
|
|
2307
|
+
};
|
|
2308
|
+
const listCapabilities = (openCapabilities = []) => {
|
|
2309
|
+
const open = new Set(openCapabilities);
|
|
2310
|
+
const listings = [];
|
|
2311
|
+
for (const entry of readdirSync5(specsDir, { withFileTypes: true })) {
|
|
2312
|
+
if (!entry.isDirectory()) continue;
|
|
2313
|
+
const folded = readIfPresent(specPath(entry.name));
|
|
2314
|
+
if (folded === null) continue;
|
|
2315
|
+
listings.push({
|
|
2316
|
+
name: entry.name,
|
|
2317
|
+
requirementCount: countRequirements(folded),
|
|
2318
|
+
alreadyOpen: open.has(entry.name)
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
listings.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2322
|
+
return listings;
|
|
2323
|
+
};
|
|
2324
|
+
const openCapability = (name, openCapabilities = []) => {
|
|
2325
|
+
const invalid = validateCapabilityName(name);
|
|
2326
|
+
if (invalid) return { outcome: "invalid-name", capability: name, reason: invalid };
|
|
2327
|
+
if (new Set(openCapabilities).has(name)) return { outcome: "already-open", capability: name };
|
|
2328
|
+
const folded = readIfPresent(specPath(name));
|
|
2329
|
+
if (folded === null) return { outcome: "no-folded-counterpart", capability: name };
|
|
2330
|
+
return { outcome: "opened", capability: name, draft: folded, folded };
|
|
2331
|
+
};
|
|
2332
|
+
return Object.freeze({ specsDir, listCapabilities, readCapability, openCapability });
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// src/workbench/approve/approve.ts
|
|
2336
|
+
import { execFileSync } from "node:child_process";
|
|
2337
|
+
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync5, rmSync, statSync as statSync3 } from "node:fs";
|
|
2338
|
+
import { tmpdir } from "node:os";
|
|
2339
|
+
import { join as join7 } from "node:path";
|
|
2340
|
+
|
|
2341
|
+
// src/workbench/core/emit.ts
|
|
2342
|
+
var PROPOSAL_SECTIONS = Object.freeze([
|
|
2343
|
+
{ id: "why", title: "Why", level: 2, required: true, skippable: false },
|
|
2344
|
+
{ id: "what-changes", title: "What Changes", level: 2, required: true, skippable: false },
|
|
2345
|
+
{ id: "system-context", title: "System context", level: 3, required: true, skippable: true },
|
|
2346
|
+
{ id: "containers", title: "Containers", level: 3, required: true, skippable: true },
|
|
2347
|
+
{ id: "components", title: "Components", level: 3, required: true, skippable: true },
|
|
2348
|
+
{ id: "code", title: "Code", level: 3, required: false, skippable: true },
|
|
2349
|
+
{ id: "impact", title: "Impact", level: 2, required: true, skippable: false }
|
|
1877
2350
|
]);
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2351
|
+
function emptyProposal() {
|
|
2352
|
+
return {
|
|
2353
|
+
why: { state: "empty" },
|
|
2354
|
+
"what-changes": { state: "empty" },
|
|
2355
|
+
"system-context": { state: "empty" },
|
|
2356
|
+
containers: { state: "empty" },
|
|
2357
|
+
components: { state: "empty" },
|
|
2358
|
+
code: { state: "empty" },
|
|
2359
|
+
impact: { state: "empty" }
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
var DESIGN_FENCES = Object.freeze({
|
|
2363
|
+
mermaid: "mermaid",
|
|
2364
|
+
sketch: "specsketch-sketch"
|
|
1891
2365
|
});
|
|
1892
|
-
function
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2366
|
+
function numberTasks(groups) {
|
|
2367
|
+
return groups.map((group, g) => ({
|
|
2368
|
+
...group,
|
|
2369
|
+
number: `${g + 1}`,
|
|
2370
|
+
title: group.title,
|
|
2371
|
+
tasks: group.tasks.map((task, t) => ({ ...task, number: `${g + 1}.${t + 1}` }))
|
|
2372
|
+
}));
|
|
2373
|
+
}
|
|
2374
|
+
var SCENARIO_HEADING = /^####[ \t]+Scenario:[ \t]*(.*)$/;
|
|
2375
|
+
var ANY_HEADING = /^#{1,6}[ \t]/;
|
|
2376
|
+
function probe2(line) {
|
|
2377
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
2378
|
+
}
|
|
2379
|
+
function parseScenarios(block) {
|
|
2380
|
+
const scenarios = [];
|
|
2381
|
+
let open = null;
|
|
2382
|
+
for (const line of block.bodyLines) {
|
|
2383
|
+
const p = probe2(line);
|
|
2384
|
+
const heading = SCENARIO_HEADING.exec(p);
|
|
2385
|
+
if (heading) {
|
|
2386
|
+
if (open) scenarios.push(open);
|
|
2387
|
+
open = { name: (heading[1] ?? "").trim(), steps: [] };
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
if (ANY_HEADING.test(p)) {
|
|
2391
|
+
if (open) scenarios.push(open);
|
|
2392
|
+
open = null;
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
if (open && p.trim() !== "") open.steps.push(p);
|
|
2396
|
+
}
|
|
2397
|
+
if (open) scenarios.push(open);
|
|
2398
|
+
return scenarios;
|
|
2399
|
+
}
|
|
2400
|
+
function requirementProse(block) {
|
|
2401
|
+
const prose = [];
|
|
2402
|
+
for (const line of block.bodyLines) {
|
|
2403
|
+
const p = probe2(line);
|
|
2404
|
+
if (SCENARIO_HEADING.test(p) || ANY_HEADING.test(p)) break;
|
|
2405
|
+
prose.push(p);
|
|
2406
|
+
}
|
|
2407
|
+
return prose.join("\n").trim();
|
|
2408
|
+
}
|
|
2409
|
+
function draftBlockOf(delta) {
|
|
2410
|
+
return delta.operation === "REMOVED" ? null : delta.block;
|
|
2411
|
+
}
|
|
2412
|
+
function deltasOf(entry) {
|
|
2413
|
+
return entry.derivation.outcome === "derived" ? entry.derivation.deltas : [];
|
|
2414
|
+
}
|
|
2415
|
+
function emitsDeltaSpec(entry) {
|
|
2416
|
+
return deltasOf(entry).length > 0;
|
|
2417
|
+
}
|
|
2418
|
+
function testingRows(capabilities) {
|
|
2419
|
+
const rows = [];
|
|
2420
|
+
for (const entry of capabilities) {
|
|
2421
|
+
for (const delta of deltasOf(entry)) {
|
|
2422
|
+
const block = draftBlockOf(delta);
|
|
2423
|
+
if (!block) continue;
|
|
2424
|
+
for (const scenario of parseScenarios(block)) {
|
|
2425
|
+
rows.push({
|
|
2426
|
+
index: rows.length + 1,
|
|
2427
|
+
capability: entry.capability,
|
|
2428
|
+
requirement: block.name,
|
|
2429
|
+
scenario: scenario.name
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
return rows;
|
|
2435
|
+
}
|
|
2436
|
+
function citableRequirements(capabilities) {
|
|
2437
|
+
const citable = [];
|
|
2438
|
+
for (const entry of capabilities) {
|
|
2439
|
+
for (const delta of deltasOf(entry)) {
|
|
2440
|
+
citable.push({ capability: entry.capability, key: delta.key });
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
return citable;
|
|
2444
|
+
}
|
|
2445
|
+
function purposeOf(entry) {
|
|
2446
|
+
if (entry.derivation.outcome !== "derived") return null;
|
|
2447
|
+
if (entry.derivation.foldedComparison.kind !== "no-folded-counterpart") return null;
|
|
2448
|
+
return readPurposeBody(entry);
|
|
2449
|
+
}
|
|
2450
|
+
var PURPOSE_HEADING = /^##[ \t]+Purpose[ \t]*$/;
|
|
2451
|
+
function readPurposeBody(entry) {
|
|
2452
|
+
const body = [];
|
|
2453
|
+
let inPurpose = false;
|
|
2454
|
+
for (const line of entry.draft.split("\n")) {
|
|
2455
|
+
const p = probe2(line);
|
|
2456
|
+
if (PURPOSE_HEADING.test(p)) {
|
|
2457
|
+
inPurpose = true;
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
if (inPurpose && ANY_HEADING.test(p)) break;
|
|
2461
|
+
if (inPurpose) body.push(p);
|
|
2462
|
+
}
|
|
2463
|
+
const text = body.join("\n").trim();
|
|
2464
|
+
return text === "" ? null : text;
|
|
2465
|
+
}
|
|
2466
|
+
function deltaSpecDocument(entry) {
|
|
2467
|
+
const deltas = deltasOf(entry);
|
|
2468
|
+
return {
|
|
2469
|
+
capability: entry.capability,
|
|
2470
|
+
purpose: purposeOf(entry),
|
|
2471
|
+
added: deltas.filter((d) => d.operation === "ADDED").map((d) => blockText(d)),
|
|
2472
|
+
// A declared rename whose body also changed emits RENAMED *and* MODIFIED: the
|
|
2473
|
+
// rename names the heading change, the modification carries the body change
|
|
2474
|
+
// with the folded block as its base. Emitting only the rename would discard a
|
|
2475
|
+
// body edit the author made; refusing would make a legitimate edit and a
|
|
2476
|
+
// legitimate rename mutually exclusive. See `docs/specsketch-workbench-issues.md` #8.
|
|
2477
|
+
modified: deltas.filter((d) => d.operation === "MODIFIED" || d.operation === "RENAMED" && d.bodyChanged).map((d) => blockText(d)),
|
|
2478
|
+
removed: deltas.filter((d) => d.operation === "REMOVED").map((d) => d.base.name),
|
|
2479
|
+
renamed: deltas.filter((d) => d.operation === "RENAMED").map((d) => ({ from: d.base.name, to: d.block.name }))
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
function blockText(delta) {
|
|
2483
|
+
const block = draftBlockOf(delta);
|
|
2484
|
+
if (!block) throw new RangeError(`no draft block for ${delta.operation} delta \`${delta.key}\``);
|
|
2485
|
+
return block.text;
|
|
2486
|
+
}
|
|
2487
|
+
function changeDocument(change) {
|
|
2488
|
+
return {
|
|
2489
|
+
id: change.id,
|
|
2490
|
+
proposal: change.proposal,
|
|
2491
|
+
design: change.design,
|
|
2492
|
+
groups: numberTasks(change.tasks),
|
|
2493
|
+
testing: testingRows(change.capabilities),
|
|
2494
|
+
deltaSpecs: change.capabilities.filter(emitsDeltaSpec).map(deltaSpecDocument).sort((a, b) => a.capability < b.capability ? -1 : a.capability > b.capability ? 1 : 0)
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
var SKIPPED_MARKER = "_Skipped._";
|
|
2498
|
+
var UNSATISFIED_MARKER = "_Unsatisfied \u2014 this section has no body and no recorded skip._";
|
|
2499
|
+
function renderProposalSection(spec, section2) {
|
|
2500
|
+
const heading = `${"#".repeat(spec.level)} ${spec.title}`;
|
|
2501
|
+
switch (section2.state) {
|
|
2502
|
+
case "authored":
|
|
2503
|
+
return `${heading}
|
|
2504
|
+
|
|
2505
|
+
${section2.body}
|
|
2506
|
+
`;
|
|
2507
|
+
case "skipped":
|
|
2508
|
+
return `${heading}
|
|
2509
|
+
|
|
2510
|
+
${SKIPPED_MARKER} ${section2.reason.trim()}
|
|
2511
|
+
`;
|
|
2512
|
+
case "empty":
|
|
2513
|
+
return `${heading}
|
|
2514
|
+
|
|
2515
|
+
${UNSATISFIED_MARKER}
|
|
2516
|
+
`;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
function renderProposal(doc) {
|
|
2520
|
+
const parts = [`# ${doc.id}
|
|
2521
|
+
`];
|
|
2522
|
+
for (const spec of PROPOSAL_SECTIONS) {
|
|
2523
|
+
parts.push(renderProposalSection(spec, doc.proposal[spec.id]));
|
|
2524
|
+
}
|
|
2525
|
+
return `${parts.join("\n")}`;
|
|
2526
|
+
}
|
|
2527
|
+
function renderDesign(doc) {
|
|
2528
|
+
const parts = [`# Design \u2014 ${doc.id}
|
|
2529
|
+
`];
|
|
2530
|
+
for (const block of doc.design) {
|
|
2531
|
+
const title = (block.title ?? "").trim();
|
|
2532
|
+
const heading = title === "" ? "" : `## ${title}
|
|
2533
|
+
|
|
2534
|
+
`;
|
|
2535
|
+
if (block.kind === "markdown") {
|
|
2536
|
+
parts.push(`${heading}${block.text.trim()}
|
|
2537
|
+
`);
|
|
2538
|
+
continue;
|
|
2539
|
+
}
|
|
2540
|
+
const info = block.kind === "mermaid" ? DESIGN_FENCES.mermaid : DESIGN_FENCES.sketch;
|
|
2541
|
+
parts.push(`${heading}\`\`\`${info}
|
|
2542
|
+
${block.text.trim()}
|
|
2543
|
+
\`\`\`
|
|
2544
|
+
`);
|
|
2545
|
+
}
|
|
2546
|
+
return parts.join("\n");
|
|
2547
|
+
}
|
|
2548
|
+
function cell(text) {
|
|
2549
|
+
return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
2550
|
+
}
|
|
2551
|
+
function uncell(text) {
|
|
2552
|
+
return text.replace(/\\\|/g, "|").replace(/\\\\/g, "\\");
|
|
2553
|
+
}
|
|
2554
|
+
function renderTasks(doc) {
|
|
2555
|
+
const parts = [`# Tasks \u2014 ${doc.id}
|
|
2556
|
+
`];
|
|
2557
|
+
for (const group of doc.groups) {
|
|
2558
|
+
const citation = group.cites ? ` \u2014 implements \`${group.cites.capability}\`: \`${group.cites.key}\`` : "";
|
|
2559
|
+
const lines = [`## ${group.number}. ${group.title}${citation}
|
|
2560
|
+
`];
|
|
2561
|
+
for (const task of group.tasks) {
|
|
2562
|
+
const box = task.done ? "x" : " ";
|
|
2563
|
+
lines.push(`- [${box}] ${task.number} ${task.title}`);
|
|
2564
|
+
}
|
|
2565
|
+
parts.push(`${lines.join("\n")}
|
|
2566
|
+
`);
|
|
2567
|
+
}
|
|
2568
|
+
parts.push(renderTestingTable(doc.testing));
|
|
2569
|
+
return parts.join("\n");
|
|
2570
|
+
}
|
|
2571
|
+
function renderTestingTable(rows) {
|
|
2572
|
+
const lines = [
|
|
2573
|
+
"## Testing",
|
|
2574
|
+
"",
|
|
2575
|
+
"One test per scenario. This table is derived from the change's deltas \u2014 it is not",
|
|
2576
|
+
"editable text, and its row count is the change's scenario count.",
|
|
2577
|
+
"",
|
|
2578
|
+
"| # | Capability | Requirement | Scenario |",
|
|
2579
|
+
"|---:|---|---|---|"
|
|
2580
|
+
];
|
|
2581
|
+
for (const row of rows) {
|
|
2582
|
+
lines.push(
|
|
2583
|
+
`| ${row.index} | \`${cell(row.capability)}\` | ${cell(row.requirement)} | ${cell(row.scenario)} |`
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
if (rows.length === 0) lines.push("| \u2014 | \u2014 | \u2014 | \u2014 |");
|
|
2587
|
+
return `${lines.join("\n")}
|
|
2588
|
+
`;
|
|
2589
|
+
}
|
|
2590
|
+
function renderDeltaSpec(doc, changeId) {
|
|
2591
|
+
const parts = [`# ${doc.capability} (delta) \u2014 ${changeId}
|
|
2592
|
+
`];
|
|
2593
|
+
if (doc.purpose !== null) parts.push(`## Purpose
|
|
2594
|
+
|
|
2595
|
+
${doc.purpose}
|
|
2596
|
+
`);
|
|
2597
|
+
if (doc.added.length > 0) parts.push(section("## ADDED Requirements", doc.added));
|
|
2598
|
+
if (doc.modified.length > 0) parts.push(section("## MODIFIED Requirements", doc.modified));
|
|
2599
|
+
if (doc.removed.length > 0) {
|
|
2600
|
+
parts.push(
|
|
2601
|
+
section(
|
|
2602
|
+
"## REMOVED Requirements",
|
|
2603
|
+
doc.removed.map((name) => `### Requirement: ${name}`)
|
|
2604
|
+
)
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
if (doc.renamed.length > 0) {
|
|
2608
|
+
const pairs = doc.renamed.map(
|
|
2609
|
+
(pair) => `- FROM: \`### Requirement: ${pair.from}\`
|
|
2610
|
+
- TO: \`### Requirement: ${pair.to}\``
|
|
2611
|
+
);
|
|
2612
|
+
parts.push(`## RENAMED Requirements
|
|
2613
|
+
|
|
2614
|
+
${pairs.join("\n\n")}
|
|
2615
|
+
`);
|
|
2616
|
+
}
|
|
2617
|
+
return parts.join("\n");
|
|
2618
|
+
}
|
|
2619
|
+
function section(heading, blocks) {
|
|
2620
|
+
return `${heading}
|
|
2621
|
+
|
|
2622
|
+
${blocks.map((b) => b.trimEnd()).join("\n\n")}
|
|
2623
|
+
`;
|
|
2624
|
+
}
|
|
2625
|
+
function renderChangeDocument(doc) {
|
|
2626
|
+
const files = [
|
|
2627
|
+
{ path: "proposal.md", content: renderProposal(doc) },
|
|
2628
|
+
{ path: "design.md", content: renderDesign(doc) },
|
|
2629
|
+
{ path: "tasks.md", content: renderTasks(doc) }
|
|
2630
|
+
];
|
|
2631
|
+
for (const spec of doc.deltaSpecs) {
|
|
2632
|
+
files.push({
|
|
2633
|
+
path: `specs/${spec.capability}/spec.md`,
|
|
2634
|
+
content: renderDeltaSpec(spec, doc.id)
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
return { id: doc.id, files };
|
|
2638
|
+
}
|
|
2639
|
+
function emitChange(change) {
|
|
2640
|
+
return renderChangeDocument(changeDocument(change));
|
|
2641
|
+
}
|
|
2642
|
+
var TASK_LINE = /^- \[([ xX])\] (\d+\.\d+) (.*)$/;
|
|
2643
|
+
var GROUP_CITATION = /^(.*) — implements `([^`]+)`: `([^`]+)`$/;
|
|
2644
|
+
var GROUP_HEADING = /^## (\d+)\. (.*)$/;
|
|
2645
|
+
var FENCE = /^```(\S*)\s*$/;
|
|
2646
|
+
function parseChangeDocument(emitted) {
|
|
2647
|
+
const byPath = new Map(emitted.files.map((f) => [f.path, f.content]));
|
|
2648
|
+
const proposalSrc = byPath.get("proposal.md") ?? "";
|
|
2649
|
+
const designSrc = byPath.get("design.md") ?? "";
|
|
2650
|
+
const tasksSrc = byPath.get("tasks.md") ?? "";
|
|
2651
|
+
const groups = parseTasksFile(tasksSrc);
|
|
2652
|
+
const deltaSpecs = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => parseDeltaSpecFile(capabilityOfPath(f.path), f.content));
|
|
2653
|
+
return {
|
|
2654
|
+
id: emitted.id,
|
|
2655
|
+
proposal: parseProposalFile(proposalSrc),
|
|
2656
|
+
design: parseDesignFile(designSrc),
|
|
2657
|
+
groups: numberTasks(groups),
|
|
2658
|
+
testing: parseTestingTable(tasksSrc),
|
|
2659
|
+
deltaSpecs
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
function capabilityOfPath(path) {
|
|
2663
|
+
const parts = path.split("/");
|
|
2664
|
+
return parts[1] ?? "";
|
|
2665
|
+
}
|
|
2666
|
+
function parseProposalFile(src) {
|
|
2667
|
+
const lines = src.split("\n");
|
|
2668
|
+
const bodies = /* @__PURE__ */ new Map();
|
|
2669
|
+
let current = null;
|
|
2670
|
+
for (const line of lines) {
|
|
2671
|
+
const p = probe2(line);
|
|
2672
|
+
const spec = PROPOSAL_SECTIONS.find(
|
|
2673
|
+
(s) => p === `${"#".repeat(s.level)} ${s.title}`
|
|
2674
|
+
);
|
|
2675
|
+
if (spec) {
|
|
2676
|
+
current = spec.id;
|
|
2677
|
+
bodies.set(spec.id, []);
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
if (ANY_HEADING.test(p)) {
|
|
2681
|
+
current = null;
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
if (current) bodies.get(current).push(p);
|
|
2685
|
+
}
|
|
2686
|
+
const draft = emptyProposal();
|
|
2687
|
+
for (const spec of PROPOSAL_SECTIONS) {
|
|
2688
|
+
const body = (bodies.get(spec.id) ?? []).join("\n").trim();
|
|
2689
|
+
if (body === "" || body === UNSATISFIED_MARKER) {
|
|
2690
|
+
draft[spec.id] = { state: "empty" };
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
if (body.startsWith(SKIPPED_MARKER)) {
|
|
2694
|
+
draft[spec.id] = { state: "skipped", reason: body.slice(SKIPPED_MARKER.length).trim() };
|
|
2695
|
+
continue;
|
|
2696
|
+
}
|
|
2697
|
+
draft[spec.id] = { state: "authored", body };
|
|
2698
|
+
}
|
|
2699
|
+
return draft;
|
|
1920
2700
|
}
|
|
1921
|
-
var
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
/** Create the session server-side and open the host connection. */
|
|
1949
|
-
static async start(opts) {
|
|
1950
|
-
const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
|
|
1951
|
-
let response;
|
|
1952
|
-
try {
|
|
1953
|
-
response = await api.createCanvasSession({ title: opts.title });
|
|
1954
|
-
} catch (err) {
|
|
1955
|
-
if (isCoopApiError(err)) {
|
|
1956
|
-
if (err.status === 401 || err.status === 403) {
|
|
1957
|
-
throw new Error(
|
|
1958
|
-
"CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
|
|
2701
|
+
var DESIGN_HEADING = /^##[ \t]+(.*)$/;
|
|
2702
|
+
function parseDesignFile(src) {
|
|
2703
|
+
const lines = src.split("\n");
|
|
2704
|
+
const blocks = [];
|
|
2705
|
+
let markdown = [];
|
|
2706
|
+
let title = null;
|
|
2707
|
+
let fence = null;
|
|
2708
|
+
const withTitle = (block) => {
|
|
2709
|
+
if (title === null) return block;
|
|
2710
|
+
const titled = { ...block, title };
|
|
2711
|
+
title = null;
|
|
2712
|
+
return titled;
|
|
2713
|
+
};
|
|
2714
|
+
const flushMarkdown = () => {
|
|
2715
|
+
const text = markdown.join("\n").trim();
|
|
2716
|
+
if (text !== "" || title !== null) blocks.push(withTitle({ kind: "markdown", text }));
|
|
2717
|
+
markdown = [];
|
|
2718
|
+
};
|
|
2719
|
+
for (const [i, line] of lines.entries()) {
|
|
2720
|
+
const p = probe2(line);
|
|
2721
|
+
const fenceMatch = FENCE.exec(p);
|
|
2722
|
+
if (fence) {
|
|
2723
|
+
if (p === "```") {
|
|
2724
|
+
const kind = fence.info === DESIGN_FENCES.mermaid ? "mermaid" : "sketch";
|
|
2725
|
+
blocks.push(withTitle({ kind, text: fence.lines.join("\n").trim() }));
|
|
2726
|
+
fence = null;
|
|
2727
|
+
continue;
|
|
1962
2728
|
}
|
|
1963
|
-
|
|
2729
|
+
fence.lines.push(p);
|
|
2730
|
+
continue;
|
|
1964
2731
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
if (
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2732
|
+
if (fenceMatch && isDesignFence(fenceMatch[1] ?? "")) {
|
|
2733
|
+
if (markdown.join("\n").trim() !== "") flushMarkdown();
|
|
2734
|
+
markdown = [];
|
|
2735
|
+
fence = { info: fenceMatch[1], lines: [] };
|
|
2736
|
+
continue;
|
|
2737
|
+
}
|
|
2738
|
+
const heading = DESIGN_HEADING.exec(p);
|
|
2739
|
+
if (heading) {
|
|
2740
|
+
flushMarkdown();
|
|
2741
|
+
title = (heading[1] ?? "").trim();
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
if (i === 0 && p.startsWith("# Design \u2014 ")) continue;
|
|
2745
|
+
markdown.push(p);
|
|
2746
|
+
}
|
|
2747
|
+
flushMarkdown();
|
|
2748
|
+
return blocks;
|
|
2749
|
+
}
|
|
2750
|
+
function isDesignFence(info) {
|
|
2751
|
+
return info === DESIGN_FENCES.mermaid || info === DESIGN_FENCES.sketch;
|
|
2752
|
+
}
|
|
2753
|
+
function parseTasksFile(src) {
|
|
2754
|
+
const groups = [];
|
|
2755
|
+
let open = null;
|
|
2756
|
+
for (const line of src.split("\n")) {
|
|
2757
|
+
const p = probe2(line);
|
|
2758
|
+
const heading = GROUP_HEADING.exec(p);
|
|
2759
|
+
if (heading) {
|
|
2760
|
+
if (open) groups.push(open);
|
|
2761
|
+
const text = (heading[2] ?? "").trim();
|
|
2762
|
+
const cited = GROUP_CITATION.exec(text);
|
|
2763
|
+
open = cited ? {
|
|
2764
|
+
title: (cited[1] ?? "").trim(),
|
|
2765
|
+
tasks: [],
|
|
2766
|
+
cites: { capability: cited[2], key: cited[3] }
|
|
2767
|
+
} : { title: text, tasks: [] };
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
if (/^##[ \t]/.test(p)) {
|
|
2771
|
+
if (open) groups.push(open);
|
|
2772
|
+
open = null;
|
|
2773
|
+
continue;
|
|
2774
|
+
}
|
|
2775
|
+
const task = TASK_LINE.exec(p);
|
|
2776
|
+
if (task && open) {
|
|
2777
|
+
const done = (task[1] ?? " ").toLowerCase() === "x";
|
|
2778
|
+
open.tasks.push({ title: (task[3] ?? "").trim(), done });
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
if (open) groups.push(open);
|
|
2782
|
+
return groups;
|
|
2783
|
+
}
|
|
2784
|
+
function parseTestingTable(src) {
|
|
2785
|
+
const rows = [];
|
|
2786
|
+
let inTable = false;
|
|
2787
|
+
for (const line of src.split("\n")) {
|
|
2788
|
+
const p = probe2(line);
|
|
2789
|
+
if (p.startsWith("| # | Capability |")) {
|
|
2790
|
+
inTable = true;
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
if (!inTable) continue;
|
|
2794
|
+
if (!p.startsWith("|")) break;
|
|
2795
|
+
if (/^\|[-: |]+\|$/.test(p)) continue;
|
|
2796
|
+
const cells = splitRow(p);
|
|
2797
|
+
if (cells.length !== 4) continue;
|
|
2798
|
+
const index = Number.parseInt(cells[0], 10);
|
|
2799
|
+
if (!Number.isFinite(index)) continue;
|
|
2800
|
+
rows.push({
|
|
2801
|
+
index,
|
|
2802
|
+
capability: cells[1].replace(/^`|`$/g, ""),
|
|
2803
|
+
requirement: cells[2],
|
|
2804
|
+
scenario: cells[3]
|
|
2000
2805
|
});
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2806
|
+
}
|
|
2807
|
+
return rows;
|
|
2808
|
+
}
|
|
2809
|
+
function splitRow(line) {
|
|
2810
|
+
const cells = [];
|
|
2811
|
+
let buffer = "";
|
|
2812
|
+
let escaped = false;
|
|
2813
|
+
for (const char of line.slice(1, line.lastIndexOf("|"))) {
|
|
2814
|
+
if (escaped) {
|
|
2815
|
+
buffer += `\\${char}`;
|
|
2816
|
+
escaped = false;
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
if (char === "\\") {
|
|
2820
|
+
escaped = true;
|
|
2821
|
+
continue;
|
|
2822
|
+
}
|
|
2823
|
+
if (char === "|") {
|
|
2824
|
+
cells.push(uncell(buffer.trim()));
|
|
2825
|
+
buffer = "";
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
buffer += char;
|
|
2829
|
+
}
|
|
2830
|
+
cells.push(uncell(buffer.trim()));
|
|
2831
|
+
return cells;
|
|
2832
|
+
}
|
|
2833
|
+
var DELTA_SECTION = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements[ \t]*$/;
|
|
2834
|
+
var REQUIREMENT_HEADING2 = /^###[ \t]+Requirement:[ \t]*(.*)$/;
|
|
2835
|
+
var RENAME_FROM = /^-[ \t]+FROM:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
|
|
2836
|
+
var RENAME_TO = /^-[ \t]+TO:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
|
|
2837
|
+
function parseDeltaSpecFile(capability, src) {
|
|
2838
|
+
const added = [];
|
|
2839
|
+
const modified = [];
|
|
2840
|
+
const removed = [];
|
|
2841
|
+
const renamed = [];
|
|
2842
|
+
const purposeLines = [];
|
|
2843
|
+
let operation = null;
|
|
2844
|
+
let block = null;
|
|
2845
|
+
let pendingFrom = null;
|
|
2846
|
+
const flushBlock = () => {
|
|
2847
|
+
if (!block) return;
|
|
2848
|
+
const text = block.join("\n").trimEnd();
|
|
2849
|
+
if (operation === "ADDED") added.push(text);
|
|
2850
|
+
if (operation === "MODIFIED") modified.push(text);
|
|
2851
|
+
block = null;
|
|
2852
|
+
};
|
|
2853
|
+
for (const line of src.split("\n")) {
|
|
2854
|
+
const p = probe2(line);
|
|
2855
|
+
const sectionHeading = DELTA_SECTION.exec(p);
|
|
2856
|
+
if (sectionHeading) {
|
|
2857
|
+
flushBlock();
|
|
2858
|
+
operation = sectionHeading[1];
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
if (PURPOSE_HEADING.test(p)) {
|
|
2862
|
+
flushBlock();
|
|
2863
|
+
operation = "PURPOSE";
|
|
2864
|
+
continue;
|
|
2865
|
+
}
|
|
2866
|
+
if (/^##(?!#)[ \t]/.test(p)) {
|
|
2867
|
+
flushBlock();
|
|
2868
|
+
operation = null;
|
|
2869
|
+
continue;
|
|
2870
|
+
}
|
|
2871
|
+
if (operation === "PURPOSE") {
|
|
2872
|
+
purposeLines.push(p);
|
|
2873
|
+
continue;
|
|
2874
|
+
}
|
|
2875
|
+
const requirementHeading = REQUIREMENT_HEADING2.exec(p);
|
|
2876
|
+
if (requirementHeading) {
|
|
2877
|
+
flushBlock();
|
|
2878
|
+
if (operation === "REMOVED") {
|
|
2879
|
+
removed.push((requirementHeading[1] ?? "").trim());
|
|
2880
|
+
continue;
|
|
2021
2881
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
// ── Frames from the DO ──
|
|
2031
|
-
handleUpstreamFrame(text) {
|
|
2032
|
-
this.lastTrafficAt = Date.now();
|
|
2033
|
-
if (text === "pong") return;
|
|
2034
|
-
const parsed = canvasServerMessageSchema.safeParse(
|
|
2035
|
-
(() => {
|
|
2036
|
-
try {
|
|
2037
|
-
return JSON.parse(text);
|
|
2038
|
-
} catch {
|
|
2039
|
-
return null;
|
|
2040
|
-
}
|
|
2041
|
-
})()
|
|
2042
|
-
);
|
|
2043
|
-
if (!parsed.success) return;
|
|
2044
|
-
const frame = parsed.data;
|
|
2045
|
-
switch (frame.type) {
|
|
2046
|
-
case "snapshot": {
|
|
2047
|
-
const local = this.opts.workspace.loadScene();
|
|
2048
|
-
const { elements, accepted } = reconcileCanvasElements(
|
|
2049
|
-
asCanvasElements(frame.scene.elements),
|
|
2050
|
-
asCanvasElements(local?.elements)
|
|
2051
|
-
);
|
|
2052
|
-
this.revision = frame.revision;
|
|
2053
|
-
this.participants = frame.participants;
|
|
2054
|
-
this.opts.workspace.saveScene({
|
|
2055
|
-
elements,
|
|
2056
|
-
appState: local?.appState,
|
|
2057
|
-
files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
|
|
2058
|
-
});
|
|
2059
|
-
if (accepted.length > 0) {
|
|
2060
|
-
this.sendUpstream(
|
|
2061
|
-
JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
|
|
2062
|
-
);
|
|
2063
|
-
}
|
|
2064
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2065
|
-
return;
|
|
2882
|
+
if (operation === "ADDED" || operation === "MODIFIED") block = [p];
|
|
2883
|
+
continue;
|
|
2884
|
+
}
|
|
2885
|
+
if (operation === "RENAMED") {
|
|
2886
|
+
const from = RENAME_FROM.exec(p);
|
|
2887
|
+
if (from) {
|
|
2888
|
+
pendingFrom = (from[1] ?? "").trim();
|
|
2889
|
+
continue;
|
|
2066
2890
|
}
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
}
|
|
2072
|
-
this.revision = frame.revision;
|
|
2073
|
-
this.applyRemoteElements(frame.elements, frame.files);
|
|
2074
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2075
|
-
return;
|
|
2891
|
+
const to = RENAME_TO.exec(p);
|
|
2892
|
+
if (to && pendingFrom !== null) {
|
|
2893
|
+
renamed.push({ from: pendingFrom, to: (to[1] ?? "").trim() });
|
|
2894
|
+
pendingFrom = null;
|
|
2076
2895
|
}
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2896
|
+
continue;
|
|
2897
|
+
}
|
|
2898
|
+
if (block) block.push(p);
|
|
2899
|
+
}
|
|
2900
|
+
flushBlock();
|
|
2901
|
+
const purpose = purposeLines.join("\n").trim();
|
|
2902
|
+
return {
|
|
2903
|
+
capability,
|
|
2904
|
+
purpose: purpose === "" ? null : purpose,
|
|
2905
|
+
added,
|
|
2906
|
+
modified,
|
|
2907
|
+
removed,
|
|
2908
|
+
renamed
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/workbench/core/validate.ts
|
|
2913
|
+
var SHALL_OR_MUST = /\b(SHALL|MUST)\b/;
|
|
2914
|
+
function validateChange(change) {
|
|
2915
|
+
const findings = [
|
|
2916
|
+
...validateProposal(change),
|
|
2917
|
+
...validateDesign(change),
|
|
2918
|
+
...validateCapabilities(change),
|
|
2919
|
+
...validateTasks(change)
|
|
2920
|
+
];
|
|
2921
|
+
return report(findings);
|
|
2922
|
+
}
|
|
2923
|
+
function report(findings) {
|
|
2924
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
2925
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
2926
|
+
return { findings, errors, warnings, valid: errors.length === 0 };
|
|
2927
|
+
}
|
|
2928
|
+
function validateProposal(change) {
|
|
2929
|
+
const findings = [];
|
|
2930
|
+
for (const spec of PROPOSAL_SECTIONS) {
|
|
2931
|
+
const section2 = change.proposal[spec.id];
|
|
2932
|
+
const subject = {
|
|
2933
|
+
kind: "proposal-section",
|
|
2934
|
+
section: spec.id,
|
|
2935
|
+
title: spec.title
|
|
2936
|
+
};
|
|
2937
|
+
if (section2.state === "skipped") {
|
|
2938
|
+
findings.push(...validateSkip(spec, section2.reason, subject));
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
if (section2.state === "empty" && spec.required) {
|
|
2942
|
+
findings.push({
|
|
2943
|
+
severity: "error",
|
|
2944
|
+
kind: "proposal-section-unsatisfied",
|
|
2945
|
+
subject,
|
|
2946
|
+
message: `\`${spec.title}\` has no body and no recorded skip, and is required.`
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
return findings;
|
|
2951
|
+
}
|
|
2952
|
+
function validateSkip(spec, reason, subject) {
|
|
2953
|
+
if (!spec.skippable) {
|
|
2954
|
+
return [
|
|
2955
|
+
{
|
|
2956
|
+
severity: "error",
|
|
2957
|
+
kind: "proposal-section-not-skippable",
|
|
2958
|
+
subject,
|
|
2959
|
+
message: `\`${spec.title}\` is not skippable \u2014 only the C4 levels are.`
|
|
2081
2960
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2961
|
+
];
|
|
2962
|
+
}
|
|
2963
|
+
if (reason.trim() === "") {
|
|
2964
|
+
return [
|
|
2965
|
+
{
|
|
2966
|
+
severity: "error",
|
|
2967
|
+
kind: "proposal-skip-without-reason",
|
|
2968
|
+
subject,
|
|
2969
|
+
message: `\`${spec.title}\` is skipped with no recorded reason; a skip requires one.`
|
|
2089
2970
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2971
|
+
];
|
|
2972
|
+
}
|
|
2973
|
+
return [];
|
|
2974
|
+
}
|
|
2975
|
+
function validateDesign(change) {
|
|
2976
|
+
if (change.design.length === 0) {
|
|
2977
|
+
return [
|
|
2978
|
+
{
|
|
2979
|
+
severity: "warning",
|
|
2980
|
+
kind: "design-empty",
|
|
2981
|
+
subject: { kind: "change", change: change.id },
|
|
2982
|
+
message: "`design.md` carries no blocks; the change will emit a title and nothing else."
|
|
2093
2983
|
}
|
|
2094
|
-
|
|
2984
|
+
];
|
|
2095
2985
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2986
|
+
const findings = [];
|
|
2987
|
+
change.design.forEach((block, index) => {
|
|
2988
|
+
if ((block.title ?? "").trim() !== "") return;
|
|
2989
|
+
findings.push({
|
|
2990
|
+
severity: "error",
|
|
2991
|
+
kind: "design-block-untitled",
|
|
2992
|
+
// An untitled block has no name to be reported under, so it is named by where
|
|
2993
|
+
// it is and what it is — the two things the author can find it by.
|
|
2994
|
+
subject: { kind: "design-block", index, blockKind: block.kind },
|
|
2995
|
+
message: `Design block ${index + 1} (\`${block.kind}\`) carries no title, and a block's title is emitted as its \`##\` heading.`
|
|
2996
|
+
});
|
|
2997
|
+
});
|
|
2998
|
+
return findings;
|
|
2999
|
+
}
|
|
3000
|
+
function validateCapabilities(change) {
|
|
3001
|
+
const findings = [];
|
|
3002
|
+
let derivedDeltas = 0;
|
|
3003
|
+
for (const entry of change.capabilities) {
|
|
3004
|
+
if (entry.derivation.outcome === "refused") {
|
|
3005
|
+
findings.push(...refusalFindings(entry));
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
const deltas = deltasOf(entry);
|
|
3009
|
+
derivedDeltas += deltas.length;
|
|
3010
|
+
findings.push(...emptyDerivationFindings(entry));
|
|
3011
|
+
findings.push(...conditionFindings(entry));
|
|
3012
|
+
findings.push(...requirementFindings(entry));
|
|
3013
|
+
findings.push(...duplicateIdentityFindings(entry));
|
|
3014
|
+
}
|
|
3015
|
+
if (derivedDeltas === 0) {
|
|
3016
|
+
findings.push({
|
|
3017
|
+
severity: "error",
|
|
3018
|
+
kind: "no-delta-derived",
|
|
3019
|
+
subject: { kind: "change", change: change.id },
|
|
3020
|
+
message: "the change derives no delta at all, so it alters nothing \u2014 there is no operation to fold."
|
|
2104
3021
|
});
|
|
2105
3022
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
3023
|
+
return findings;
|
|
3024
|
+
}
|
|
3025
|
+
function refusalFindings(entry) {
|
|
3026
|
+
if (entry.derivation.outcome !== "refused") return [];
|
|
3027
|
+
return entry.derivation.refusals.map((refusal) => {
|
|
3028
|
+
if (refusal.kind === "duplicate-identity") {
|
|
3029
|
+
const keys = refusal.collisions.map((c) => `\`${c.key}\``).join(", ");
|
|
3030
|
+
return {
|
|
3031
|
+
severity: "error",
|
|
3032
|
+
kind: "duplicate-requirement-identity",
|
|
3033
|
+
subject: { kind: "capability", capability: entry.capability },
|
|
3034
|
+
message: `two requirements in the ${refusal.side} \`${entry.capability}\` claim one identity: ${keys}. One of them would derive nothing.`
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
if (refusal.kind === "unterminated-fence") {
|
|
3038
|
+
return {
|
|
3039
|
+
severity: "error",
|
|
3040
|
+
kind: "capability-derivation-refused",
|
|
3041
|
+
subject: { kind: "capability", capability: entry.capability },
|
|
3042
|
+
message: `the ${refusal.side} \`${entry.capability}\` opens a fence on line ${refusal.openLine + 1} (\`${refusal.openText.trim()}\`) and never closes it, so everything after it sits inside the requirement above.`
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
return {
|
|
3046
|
+
severity: "error",
|
|
3047
|
+
kind: "capability-derivation-refused",
|
|
3048
|
+
subject: { kind: "capability", capability: entry.capability },
|
|
3049
|
+
message: `\`${entry.capability}\` derives nothing: ${refusal.kind}.`
|
|
3050
|
+
};
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
function emptyDerivationFindings(entry) {
|
|
3054
|
+
if (entry.derivation.outcome !== "derived") return [];
|
|
3055
|
+
if (entry.derivation.deltas.length > 0) return [];
|
|
3056
|
+
const subject = { kind: "capability", capability: entry.capability };
|
|
3057
|
+
switch (entry.derivation.foldedComparison.kind) {
|
|
3058
|
+
case "identical":
|
|
3059
|
+
return [
|
|
3060
|
+
{
|
|
3061
|
+
severity: "warning",
|
|
3062
|
+
kind: "capability-unchanged",
|
|
3063
|
+
subject,
|
|
3064
|
+
message: `\`${entry.capability}\` is open but byte-identical to its folded file; no delta spec is emitted for it.`
|
|
2120
3065
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
3066
|
+
];
|
|
3067
|
+
case "differs-outside-requirements":
|
|
3068
|
+
return [
|
|
3069
|
+
{
|
|
3070
|
+
severity: "error",
|
|
3071
|
+
kind: "capability-changed-outside-requirements",
|
|
3072
|
+
subject,
|
|
3073
|
+
message: `\`${entry.capability}\` differs from its folded file only outside its requirements (its \`## Purpose\` or head). OpenSpec has no operation for that edit \u2014 a delta file carrying no operation cannot fold. Edit \`openspec/specs/${entry.capability}/spec.md\` directly, or revert the edit.`
|
|
3074
|
+
}
|
|
3075
|
+
];
|
|
3076
|
+
case "differs-in-requirement-order":
|
|
3077
|
+
return [
|
|
3078
|
+
{
|
|
3079
|
+
severity: "error",
|
|
3080
|
+
kind: "capability-requirements-reordered",
|
|
3081
|
+
subject,
|
|
3082
|
+
message: `\`${entry.capability}\`'s requirements are reordered and otherwise unchanged. OpenSpec has no move operation, so the reordering cannot be folded and would be lost.`
|
|
3083
|
+
}
|
|
3084
|
+
];
|
|
3085
|
+
case "no-folded-counterpart":
|
|
3086
|
+
return [
|
|
3087
|
+
{
|
|
3088
|
+
severity: "error",
|
|
3089
|
+
kind: "new-capability-declares-no-requirement",
|
|
3090
|
+
subject,
|
|
3091
|
+
message: `\`${entry.capability}\` is a new capability that declares no requirement; there is nothing to add.`
|
|
3092
|
+
}
|
|
3093
|
+
];
|
|
3094
|
+
case "differs":
|
|
3095
|
+
return [
|
|
3096
|
+
{
|
|
3097
|
+
severity: "error",
|
|
3098
|
+
kind: "capability-differs-without-delta",
|
|
3099
|
+
subject,
|
|
3100
|
+
message: `\`${entry.capability}\` differs from its folded file but derives no delta; the derivation and the comparison disagree about the same two documents.`
|
|
3101
|
+
}
|
|
3102
|
+
];
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
function conditionFindings(entry) {
|
|
3106
|
+
if (entry.derivation.outcome !== "derived") return [];
|
|
3107
|
+
const findings = [];
|
|
3108
|
+
const isNew = entry.derivation.foldedComparison.kind === "no-folded-counterpart";
|
|
3109
|
+
const subject = { kind: "capability", capability: entry.capability };
|
|
3110
|
+
for (const condition of entry.derivation.conditions) {
|
|
3111
|
+
if (isNew && (condition.kind === "purpose-empty" || condition.kind === "purpose-missing")) {
|
|
3112
|
+
findings.push({
|
|
3113
|
+
severity: "error",
|
|
3114
|
+
kind: "new-capability-purpose-empty",
|
|
3115
|
+
subject,
|
|
3116
|
+
message: `new capability \`${entry.capability}\` has ${condition.kind === "purpose-missing" ? "no `## Purpose` heading" : "an empty `## Purpose`"}; it seeds the folded spec's Purpose, which cannot be left blank.`
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
if (condition.kind === "identity-scheme-changed") {
|
|
3120
|
+
findings.push({
|
|
3121
|
+
severity: "warning",
|
|
3122
|
+
kind: "identity-scheme-changed",
|
|
3123
|
+
subject,
|
|
3124
|
+
message: `\`${entry.capability}\`'s folded file resolves on ${condition.foldedScheme} identity and its draft on ${condition.draftScheme}, so no key matches across them: every requirement derives as a removal plus an addition. Declare the renames if that is not what you meant.`
|
|
3125
|
+
});
|
|
2137
3126
|
}
|
|
2138
|
-
this.sendUpstream(text);
|
|
2139
3127
|
}
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
3128
|
+
return findings;
|
|
3129
|
+
}
|
|
3130
|
+
function requirementFindings(entry) {
|
|
3131
|
+
const findings = [];
|
|
3132
|
+
for (const delta of deltasOf(entry)) {
|
|
3133
|
+
const block = draftBlockOf(delta);
|
|
3134
|
+
if (!block) continue;
|
|
3135
|
+
const subject = {
|
|
3136
|
+
kind: "requirement",
|
|
3137
|
+
capability: entry.capability,
|
|
3138
|
+
requirement: block.name
|
|
3139
|
+
};
|
|
3140
|
+
if (!SHALL_OR_MUST.test(requirementProse(block))) {
|
|
3141
|
+
findings.push({
|
|
3142
|
+
severity: "error",
|
|
3143
|
+
kind: "requirement-without-shall",
|
|
3144
|
+
subject,
|
|
3145
|
+
message: `\`${block.name}\` in \`${entry.capability}\` states no obligation: its text carries neither SHALL nor MUST.`
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
const scenarios = parseScenarios(block);
|
|
3149
|
+
if (scenarios.length === 0) {
|
|
3150
|
+
findings.push({
|
|
3151
|
+
severity: "error",
|
|
3152
|
+
kind: "requirement-without-scenario",
|
|
3153
|
+
subject,
|
|
3154
|
+
message: `\`${block.name}\` in \`${entry.capability}\` has no \`#### Scenario:\`; a requirement is not testable without one.`
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
for (const scenario of scenarios) {
|
|
3158
|
+
if (scenario.steps.length > 0) continue;
|
|
3159
|
+
findings.push({
|
|
3160
|
+
severity: "error",
|
|
3161
|
+
kind: "scenario-without-steps",
|
|
3162
|
+
subject: {
|
|
3163
|
+
kind: "scenario",
|
|
3164
|
+
capability: entry.capability,
|
|
3165
|
+
requirement: block.name,
|
|
3166
|
+
scenario: scenario.name
|
|
3167
|
+
},
|
|
3168
|
+
message: `scenario \`${scenario.name}\` of \`${block.name}\` in \`${entry.capability}\` has no steps.`
|
|
3169
|
+
});
|
|
2145
3170
|
}
|
|
2146
|
-
this.pendingOps.push(frameText);
|
|
2147
|
-
if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
|
|
2148
3171
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
3172
|
+
return findings;
|
|
3173
|
+
}
|
|
3174
|
+
function duplicateIdentityFindings(entry) {
|
|
3175
|
+
const findings = [];
|
|
3176
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3177
|
+
for (const delta of deltasOf(entry)) {
|
|
3178
|
+
const block = draftBlockOf(delta);
|
|
3179
|
+
const name = block ? block.name : delta.operation === "REMOVED" ? delta.base.name : delta.key;
|
|
3180
|
+
const section2 = delta.operation === "RENAMED" ? "MODIFIED" : delta.operation;
|
|
3181
|
+
const slot = `${section2} ${name}`;
|
|
3182
|
+
seen.set(slot, { name, count: (seen.get(slot)?.count ?? 0) + 1 });
|
|
3183
|
+
}
|
|
3184
|
+
for (const { name, count } of seen.values()) {
|
|
3185
|
+
if (count < 2) continue;
|
|
3186
|
+
findings.push({
|
|
3187
|
+
severity: "error",
|
|
3188
|
+
kind: "duplicate-requirement-identity",
|
|
3189
|
+
subject: { kind: "requirement", capability: entry.capability, requirement: name },
|
|
3190
|
+
message: `\`${name}\` appears ${count} times in \`${entry.capability}\`'s deltas; one of them would be invisible after the fold.`
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
return findings;
|
|
3194
|
+
}
|
|
3195
|
+
function validateTasks(change) {
|
|
3196
|
+
const findings = [];
|
|
3197
|
+
const citable = new Set(
|
|
3198
|
+
citableRequirements(change.capabilities).map((c) => `${c.capability} ${c.key}`)
|
|
3199
|
+
);
|
|
3200
|
+
for (const group of numberTasks(change.tasks)) {
|
|
3201
|
+
if (group.tasks.length === 0) {
|
|
3202
|
+
findings.push({
|
|
3203
|
+
severity: "error",
|
|
3204
|
+
kind: "task-group-empty",
|
|
3205
|
+
subject: { kind: "task-group", number: group.number, title: group.title },
|
|
3206
|
+
message: `Task group ${group.number} (\`${group.title}\`) carries no tasks.`
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
const cites = group.cites;
|
|
3210
|
+
if (cites) {
|
|
3211
|
+
const subject = {
|
|
3212
|
+
kind: "task-group",
|
|
3213
|
+
number: group.number,
|
|
3214
|
+
title: group.title
|
|
3215
|
+
};
|
|
3216
|
+
if (cites.capability.includes("`") || cites.key.includes("`") || group.title.includes("`")) {
|
|
3217
|
+
findings.push({
|
|
3218
|
+
severity: "error",
|
|
3219
|
+
kind: "task-citation-unrepresentable",
|
|
3220
|
+
subject,
|
|
3221
|
+
message: `task group ${group.number} carries a backtick in its title or citation; the emitted citation could not be read back unambiguously.`
|
|
3222
|
+
});
|
|
3223
|
+
} else if (!citable.has(`${cites.capability} ${cites.key}`)) {
|
|
3224
|
+
findings.push({
|
|
3225
|
+
severity: "error",
|
|
3226
|
+
kind: "task-cites-absent-requirement",
|
|
3227
|
+
subject,
|
|
3228
|
+
message: `task group ${group.number} cites \`${cites.key}\` in \`${cites.capability}\`, which this change no longer declares.`
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
2159
3231
|
}
|
|
2160
3232
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
3233
|
+
return findings;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// src/workbench/shell/provenance.ts
|
|
3237
|
+
var NO_ISSUE = Object.freeze({ kind: "none" });
|
|
3238
|
+
function provenanceValue(provenance) {
|
|
3239
|
+
return provenance.kind === "issue" ? String(provenance.issue) : "none";
|
|
3240
|
+
}
|
|
3241
|
+
function provenanceFrontmatter(provenance) {
|
|
3242
|
+
return `---
|
|
3243
|
+
issue: ${provenanceValue(provenance)}
|
|
3244
|
+
---
|
|
3245
|
+
`;
|
|
3246
|
+
}
|
|
3247
|
+
function emitChangeWithProvenance(change, provenance) {
|
|
3248
|
+
const emitted = emitChange(change);
|
|
3249
|
+
return {
|
|
3250
|
+
id: emitted.id,
|
|
3251
|
+
files: emitted.files.map(
|
|
3252
|
+
(file) => file.path === "proposal.md" ? { path: file.path, content: `${provenanceFrontmatter(provenance)}
|
|
3253
|
+
${file.content}` } : file
|
|
3254
|
+
)
|
|
3255
|
+
};
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// src/workbench/approve/approve.ts
|
|
3259
|
+
var ARCHIVE_FLAGS = Object.freeze(["--json", "--yes"]);
|
|
3260
|
+
var FLAGS_APPROVAL_NEVER_PASSES = Object.freeze([
|
|
3261
|
+
"--skip-specs",
|
|
3262
|
+
"--no-validate"
|
|
3263
|
+
]);
|
|
3264
|
+
function archiveArgv(changeId) {
|
|
3265
|
+
return ["archive", changeId, ...ARCHIVE_FLAGS];
|
|
3266
|
+
}
|
|
3267
|
+
function changeDirectoryOf(repoRoot, changeId) {
|
|
3268
|
+
return join7(repoRoot, "openspec", "changes", changeId);
|
|
3269
|
+
}
|
|
3270
|
+
function approveChange(repoRoot, change, provenance, options = {}) {
|
|
3271
|
+
const directory = changeDirectoryOf(repoRoot, change.id);
|
|
3272
|
+
const report2 = validateChange(change);
|
|
3273
|
+
if (!report2.valid) {
|
|
3274
|
+
return { outcome: "refused-validation", errors: report2.errors, warnings: report2.warnings };
|
|
3275
|
+
}
|
|
3276
|
+
const emitted = emitChangeWithProvenance(change, provenance);
|
|
3277
|
+
if (!existsSync5(directory)) return { outcome: "refused-not-emitted", directory };
|
|
3278
|
+
const differing = emittedDifferences(directory, emitted);
|
|
3279
|
+
if (differing.length > 0) return { outcome: "refused-emission-stale", directory, differing };
|
|
3280
|
+
const foldedCapabilities = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => f.path.split("/")[1]);
|
|
3281
|
+
const snapshot = takeSnapshot(repoRoot);
|
|
3282
|
+
try {
|
|
3283
|
+
const archive = runArchive(repoRoot, change.id, options);
|
|
3284
|
+
if (archive.kind === "failed") {
|
|
3285
|
+
return {
|
|
3286
|
+
outcome: "refused-fold-failed",
|
|
3287
|
+
code: archive.code,
|
|
3288
|
+
message: archive.message,
|
|
3289
|
+
rollback: restore(repoRoot, snapshot)
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
const incomplete = incompleteness(repoRoot, change.id, archive, foldedCapabilities);
|
|
3293
|
+
if (incomplete) {
|
|
3294
|
+
return {
|
|
3295
|
+
outcome: "refused-fold-incomplete",
|
|
3296
|
+
reason: incomplete,
|
|
3297
|
+
rollback: restore(repoRoot, snapshot)
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
return {
|
|
3301
|
+
outcome: "approved",
|
|
3302
|
+
archivedAs: archive.archivedAs,
|
|
3303
|
+
archivePath: archive.path,
|
|
3304
|
+
foldedCapabilities,
|
|
3305
|
+
totals: archive.totals,
|
|
3306
|
+
warnings: report2.warnings
|
|
3307
|
+
};
|
|
3308
|
+
} finally {
|
|
3309
|
+
discardSnapshot(snapshot);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
function emittedDifferences(directory, emitted) {
|
|
3313
|
+
const differing = [];
|
|
3314
|
+
for (const file of emitted.files) {
|
|
3315
|
+
const target = join7(directory, file.path);
|
|
3316
|
+
let onDisk;
|
|
2165
3317
|
try {
|
|
2166
|
-
|
|
3318
|
+
onDisk = readFileSync5(target, "utf8");
|
|
2167
3319
|
} catch {
|
|
3320
|
+
differing.push(file.path);
|
|
3321
|
+
continue;
|
|
3322
|
+
}
|
|
3323
|
+
if (onDisk !== file.content) differing.push(file.path);
|
|
3324
|
+
}
|
|
3325
|
+
return differing;
|
|
3326
|
+
}
|
|
3327
|
+
var SNAPSHOT_TREES = Object.freeze(["specs", "changes"]);
|
|
3328
|
+
function takeSnapshot(repoRoot) {
|
|
3329
|
+
const directory = mkdtempSync(join7(tmpdir(), "specsketch-approve-"));
|
|
3330
|
+
const present = [];
|
|
3331
|
+
for (const tree of SNAPSHOT_TREES) {
|
|
3332
|
+
const source = join7(repoRoot, "openspec", tree);
|
|
3333
|
+
if (!existsSync5(source)) continue;
|
|
3334
|
+
cpSync(source, join7(directory, tree), { recursive: true });
|
|
3335
|
+
present.push(tree);
|
|
3336
|
+
}
|
|
3337
|
+
return { root: repoRoot, present, directory };
|
|
3338
|
+
}
|
|
3339
|
+
function restore(repoRoot, snapshot) {
|
|
3340
|
+
try {
|
|
3341
|
+
for (const tree of SNAPSHOT_TREES) {
|
|
3342
|
+
const target = join7(repoRoot, "openspec", tree);
|
|
3343
|
+
rmSync(target, { recursive: true, force: true });
|
|
3344
|
+
if (!snapshot.present.includes(tree)) continue;
|
|
3345
|
+
mkdirSync3(join7(repoRoot, "openspec"), { recursive: true });
|
|
3346
|
+
cpSync(join7(snapshot.directory, tree), target, { recursive: true });
|
|
3347
|
+
}
|
|
3348
|
+
return { kind: "restored" };
|
|
3349
|
+
} catch (error) {
|
|
3350
|
+
return { kind: "restore-failed", error: error instanceof Error ? error.message : String(error) };
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
function discardSnapshot(snapshot) {
|
|
3354
|
+
rmSync(snapshot.directory, { recursive: true, force: true });
|
|
3355
|
+
}
|
|
3356
|
+
function resolveOpenspecBinary(repoRoot) {
|
|
3357
|
+
const local = join7(repoRoot, "node_modules", ".bin", "openspec");
|
|
3358
|
+
return existsSync5(local) ? local : "openspec";
|
|
3359
|
+
}
|
|
3360
|
+
var NO_TOTALS = Object.freeze({ added: 0, modified: 0, removed: 0, renamed: 0 });
|
|
3361
|
+
function runArchive(repoRoot, changeId, options) {
|
|
3362
|
+
const bin = options.openspecBin ?? resolveOpenspecBinary(repoRoot);
|
|
3363
|
+
let stdout;
|
|
3364
|
+
try {
|
|
3365
|
+
stdout = execFileSync(bin, [...archiveArgv(changeId)], {
|
|
3366
|
+
cwd: repoRoot,
|
|
3367
|
+
encoding: "utf8"
|
|
3368
|
+
});
|
|
3369
|
+
} catch (error) {
|
|
3370
|
+
const captured = error.stdout;
|
|
3371
|
+
if (typeof captured === "string" && captured.trim() !== "") stdout = captured;
|
|
3372
|
+
else {
|
|
3373
|
+
return {
|
|
3374
|
+
kind: "failed",
|
|
3375
|
+
code: "archive_not_run",
|
|
3376
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return readArchiveJson(stdout);
|
|
3381
|
+
}
|
|
3382
|
+
function readArchiveJson(stdout) {
|
|
3383
|
+
let parsed;
|
|
3384
|
+
try {
|
|
3385
|
+
parsed = JSON.parse(stdout);
|
|
3386
|
+
} catch {
|
|
3387
|
+
return { kind: "failed", code: "archive_output_unreadable", message: stdout.trim() };
|
|
3388
|
+
}
|
|
3389
|
+
if (!parsed.archive) {
|
|
3390
|
+
const diagnostic = parsed.status?.[0];
|
|
3391
|
+
return {
|
|
3392
|
+
kind: "failed",
|
|
3393
|
+
code: diagnostic?.code ?? "archive_failed",
|
|
3394
|
+
message: diagnostic?.message ?? "openspec archive reported no result and no diagnostic."
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
const totals = parsed.archive.totals;
|
|
3398
|
+
return {
|
|
3399
|
+
kind: "archived",
|
|
3400
|
+
archivedAs: parsed.archive.archivedAs ?? "",
|
|
3401
|
+
path: parsed.archive.path ?? "",
|
|
3402
|
+
specsUpdated: parsed.archive.specsUpdated === true,
|
|
3403
|
+
totals: totals ? {
|
|
3404
|
+
added: totals.added ?? 0,
|
|
3405
|
+
modified: totals.modified ?? 0,
|
|
3406
|
+
removed: totals.removed ?? 0,
|
|
3407
|
+
renamed: totals.renamed ?? 0
|
|
3408
|
+
} : NO_TOTALS
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
function incompleteness(repoRoot, changeId, archive, foldedCapabilities) {
|
|
3412
|
+
if (!archive.specsUpdated) return { kind: "no-spec-updated" };
|
|
3413
|
+
const folded = archive.totals.added + archive.totals.modified + archive.totals.removed + archive.totals.renamed;
|
|
3414
|
+
if (folded === 0) return { kind: "nothing-folded" };
|
|
3415
|
+
for (const capability of foldedCapabilities) {
|
|
3416
|
+
if (!existsSync5(join7(repoRoot, "openspec", "specs", capability, "spec.md"))) {
|
|
3417
|
+
return { kind: "capability-not-folded", capability };
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
if (existsSync5(changeDirectoryOf(repoRoot, changeId))) return { kind: "change-still-in-changes" };
|
|
3421
|
+
const archived = join7(repoRoot, "openspec", "changes", "archive", archive.archivedAs);
|
|
3422
|
+
if (archive.archivedAs === "" || !isDirectory(archived)) {
|
|
3423
|
+
return { kind: "archive-directory-missing", expected: archived };
|
|
3424
|
+
}
|
|
3425
|
+
return null;
|
|
3426
|
+
}
|
|
3427
|
+
function isDirectory(path) {
|
|
3428
|
+
try {
|
|
3429
|
+
return statSync3(path).isDirectory();
|
|
3430
|
+
} catch {
|
|
3431
|
+
return false;
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
// src/workbench/write/write-change.ts
|
|
3436
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3437
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
3438
|
+
function changeDirectory(repoRoot, changeId) {
|
|
3439
|
+
return join8(repoRoot, "openspec", "changes", changeId);
|
|
3440
|
+
}
|
|
3441
|
+
function writeChange(repoRoot, change, provenance, options = {}) {
|
|
3442
|
+
const directory = changeDirectory(repoRoot, change.id);
|
|
3443
|
+
const report2 = validateChange(change);
|
|
3444
|
+
if (!report2.valid) {
|
|
3445
|
+
return {
|
|
3446
|
+
outcome: "validation-failed",
|
|
3447
|
+
directory,
|
|
3448
|
+
errors: report2.errors,
|
|
3449
|
+
warnings: report2.warnings
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
if (!options.overwrite && isNonEmptyDirectory(directory)) {
|
|
3453
|
+
return { outcome: "directory-exists", directory };
|
|
3454
|
+
}
|
|
3455
|
+
const emitted = emitChangeWithProvenance(change, provenance);
|
|
3456
|
+
writeEmitted(directory, emitted);
|
|
3457
|
+
return {
|
|
3458
|
+
outcome: "written",
|
|
3459
|
+
directory,
|
|
3460
|
+
files: emitted.files.map((f) => f.path),
|
|
3461
|
+
warnings: report2.warnings
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
function isNonEmptyDirectory(directory) {
|
|
3465
|
+
try {
|
|
3466
|
+
if (!statSync4(directory).isDirectory()) return true;
|
|
3467
|
+
return readdirSync6(directory).length > 0;
|
|
3468
|
+
} catch {
|
|
3469
|
+
return false;
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
function readEmittedChange(directory, id) {
|
|
3473
|
+
const files = [];
|
|
3474
|
+
for (const name of ["proposal.md", "design.md", "tasks.md"]) {
|
|
3475
|
+
files.push({ path: name, content: readFileSync6(join8(directory, name), "utf8") });
|
|
3476
|
+
}
|
|
3477
|
+
const specsDir = join8(directory, "specs");
|
|
3478
|
+
let capabilities = [];
|
|
3479
|
+
try {
|
|
3480
|
+
capabilities = readdirSync6(specsDir).filter((name) => statSync4(join8(specsDir, name)).isDirectory()).sort();
|
|
3481
|
+
} catch {
|
|
3482
|
+
capabilities = [];
|
|
3483
|
+
}
|
|
3484
|
+
for (const capability of capabilities) {
|
|
3485
|
+
files.push({
|
|
3486
|
+
path: `specs/${capability}/spec.md`,
|
|
3487
|
+
content: readFileSync6(join8(specsDir, capability, "spec.md"), "utf8")
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
return { id, files };
|
|
3491
|
+
}
|
|
3492
|
+
function writeEmitted(directory, emitted) {
|
|
3493
|
+
mkdirSync4(directory, { recursive: true });
|
|
3494
|
+
for (const file of emitted.files) {
|
|
3495
|
+
const target = join8(directory, file.path);
|
|
3496
|
+
mkdirSync4(dirname4(target), { recursive: true });
|
|
3497
|
+
writeFileSync3(target, file.content, "utf8");
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
// src/workbench/core/reconstruct.ts
|
|
3502
|
+
function headingName(block) {
|
|
3503
|
+
const match = /^###[ \t]+Requirement:[ \t]*(.*)$/m.exec(block);
|
|
3504
|
+
return match?.[1]?.trim() ?? null;
|
|
3505
|
+
}
|
|
3506
|
+
function reconstructDraft(folded, delta) {
|
|
3507
|
+
const capability = delta.capability;
|
|
3508
|
+
if (folded.trim() === "") {
|
|
3509
|
+
const purpose = delta.purpose === null ? "" : `
|
|
3510
|
+
## Purpose
|
|
3511
|
+
${delta.purpose}
|
|
3512
|
+
`;
|
|
3513
|
+
const head = `# ${capability} Specification
|
|
3514
|
+
${purpose}
|
|
3515
|
+
## Requirements
|
|
3516
|
+
|
|
3517
|
+
`;
|
|
3518
|
+
return {
|
|
3519
|
+
outcome: "reconstructed",
|
|
3520
|
+
capability,
|
|
3521
|
+
draft: `${head}${delta.added.map((b) => b.trimEnd()).join("\n\n")}
|
|
3522
|
+
`
|
|
3523
|
+
};
|
|
3524
|
+
}
|
|
3525
|
+
const parsed = parseSpecFile(folded);
|
|
3526
|
+
if (parsed.outside.length > 0) {
|
|
3527
|
+
return {
|
|
3528
|
+
outcome: "unreconstructable",
|
|
3529
|
+
capability,
|
|
3530
|
+
reason: "the folded spec carries content outside its requirement blocks, which a rebuild would drop",
|
|
3531
|
+
draft: folded
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
3535
|
+
const order = [];
|
|
3536
|
+
for (const requirement of parsed.requirements) {
|
|
3537
|
+
blocks.set(requirement.name, requirement.text);
|
|
3538
|
+
order.push(requirement.name);
|
|
3539
|
+
}
|
|
3540
|
+
for (const pair of delta.renamed) {
|
|
3541
|
+
const existing = blocks.get(pair.from);
|
|
3542
|
+
if (existing === void 0) {
|
|
3543
|
+
return {
|
|
3544
|
+
outcome: "unreconstructable",
|
|
3545
|
+
capability,
|
|
3546
|
+
reason: `the delta renames \`${pair.from}\`, which the folded spec does not declare`,
|
|
3547
|
+
draft: folded
|
|
3548
|
+
};
|
|
2168
3549
|
}
|
|
2169
|
-
|
|
3550
|
+
blocks.delete(pair.from);
|
|
3551
|
+
blocks.set(pair.to, existing.replace(pair.from, pair.to));
|
|
3552
|
+
order[order.indexOf(pair.from)] = pair.to;
|
|
2170
3553
|
}
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
writeFileSync
|
|
2182
|
-
} from "node:fs";
|
|
2183
|
-
import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
|
|
2184
|
-
var DIAGRAM_FILE = "diagram.excalidraw";
|
|
2185
|
-
var SIDECAR_FILE = "specsketch.json";
|
|
2186
|
-
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
2187
|
-
var ChangeWorkspace = class {
|
|
2188
|
-
dir;
|
|
2189
|
-
changeName;
|
|
2190
|
-
constructor(dir) {
|
|
2191
|
-
this.dir = resolve2(dir);
|
|
2192
|
-
this.changeName = basename2(this.dir);
|
|
2193
|
-
mkdirSync(this.dir, { recursive: true });
|
|
2194
|
-
}
|
|
2195
|
-
writeAtomic(relPath, content) {
|
|
2196
|
-
const target = join4(this.dir, relPath);
|
|
2197
|
-
mkdirSync(dirname2(target), { recursive: true });
|
|
2198
|
-
const tmp = `${target}.tmp`;
|
|
2199
|
-
writeFileSync(tmp, content, "utf8");
|
|
2200
|
-
renameSync(tmp, target);
|
|
3554
|
+
for (const name of delta.removed) {
|
|
3555
|
+
if (!blocks.delete(name)) {
|
|
3556
|
+
return {
|
|
3557
|
+
outcome: "unreconstructable",
|
|
3558
|
+
capability,
|
|
3559
|
+
reason: `the delta removes \`${name}\`, which the folded spec does not declare`,
|
|
3560
|
+
draft: folded
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
order.splice(order.indexOf(name), 1);
|
|
2201
3564
|
}
|
|
2202
|
-
|
|
2203
|
-
const
|
|
2204
|
-
|
|
3565
|
+
for (const block of delta.modified) {
|
|
3566
|
+
const name = headingName(block);
|
|
3567
|
+
if (name === null || !blocks.has(name)) {
|
|
3568
|
+
return {
|
|
3569
|
+
outcome: "unreconstructable",
|
|
3570
|
+
capability,
|
|
3571
|
+
reason: name === null ? "the delta modifies a block with no readable heading" : `the delta modifies \`${name}\`, which is not in the folded spec after renames and removals`,
|
|
3572
|
+
draft: folded
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
blocks.set(name, block.trimEnd());
|
|
2205
3576
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
if (!raw) return null;
|
|
2210
|
-
try {
|
|
2211
|
-
const parsed = JSON.parse(raw);
|
|
3577
|
+
for (const block of delta.added) {
|
|
3578
|
+
const name = headingName(block);
|
|
3579
|
+
if (name === null) {
|
|
2212
3580
|
return {
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
3581
|
+
outcome: "unreconstructable",
|
|
3582
|
+
capability,
|
|
3583
|
+
reason: "the delta adds a block with no readable heading",
|
|
3584
|
+
draft: folded
|
|
2216
3585
|
};
|
|
2217
|
-
} catch {
|
|
2218
|
-
return null;
|
|
2219
3586
|
}
|
|
3587
|
+
if (!blocks.has(name)) order.push(name);
|
|
3588
|
+
blocks.set(name, block.trimEnd());
|
|
2220
3589
|
}
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
3590
|
+
const body = order.map((name) => blocks.get(name).trimEnd()).join("\n\n");
|
|
3591
|
+
return {
|
|
3592
|
+
outcome: "reconstructed",
|
|
3593
|
+
capability,
|
|
3594
|
+
draft: `${parsed.headText.trimEnd()}
|
|
3595
|
+
|
|
3596
|
+
${body}
|
|
3597
|
+
`
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// src/workbench/agent/credential-probe.ts
|
|
3602
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync7, statSync as statSync5 } from "node:fs";
|
|
3603
|
+
import { delimiter, join as join9 } from "node:path";
|
|
3604
|
+
function readSubscriptionToken(home) {
|
|
3605
|
+
const path = join9(home, ".claude", ".credentials.json");
|
|
3606
|
+
if (!existsSync6(path)) return null;
|
|
3607
|
+
try {
|
|
3608
|
+
const parsed = JSON.parse(readFileSync7(path, "utf8"));
|
|
3609
|
+
const token = parsed.claudeAiOauth?.accessToken;
|
|
3610
|
+
if (typeof token !== "string" || token === "") return null;
|
|
3611
|
+
const expiresAt = parsed.claudeAiOauth?.expiresAt;
|
|
3612
|
+
if (typeof expiresAt === "number" && expiresAt <= Date.now()) return null;
|
|
3613
|
+
return token;
|
|
3614
|
+
} catch {
|
|
3615
|
+
return null;
|
|
2242
3616
|
}
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
// src/workbench/agent/model-boundary.ts
|
|
3620
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
3621
|
+
var MAX_TOOL_ROUNDS = 6;
|
|
3622
|
+
var DEFAULT_PROVIDER = "anthropic";
|
|
3623
|
+
var DEFAULT_MODEL2 = "claude-opus-4-5";
|
|
3624
|
+
async function runTurn(input, deps = {}) {
|
|
3625
|
+
const provider = deps.provider ?? DEFAULT_PROVIDER;
|
|
3626
|
+
const modelId = deps.modelId ?? DEFAULT_MODEL2;
|
|
3627
|
+
const maxRounds = deps.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
3628
|
+
if (deps.oauthToken) process.env.ANTHROPIC_OAUTH_TOKEN = deps.oauthToken;
|
|
3629
|
+
else if (deps.apiKey) process.env.ANTHROPIC_API_KEY = deps.apiKey;
|
|
3630
|
+
const models = deps.models ?? builtinModels();
|
|
3631
|
+
const model = models.getModel(provider, modelId);
|
|
3632
|
+
if (!model) return { kind: "failed", reason: `no such model: ${provider}/${modelId}` };
|
|
3633
|
+
const messages = [
|
|
3634
|
+
{ role: "user", content: input.prompt, timestamp: Date.now() }
|
|
3635
|
+
];
|
|
3636
|
+
const context = {
|
|
3637
|
+
systemPrompt: input.systemPrompt,
|
|
3638
|
+
messages,
|
|
3639
|
+
tools: [...input.tools]
|
|
3640
|
+
};
|
|
3641
|
+
const calls = [];
|
|
3642
|
+
const served = `${provider}/${modelId}`;
|
|
3643
|
+
let answer = "";
|
|
3644
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
3645
|
+
const stream = models.stream(model, context);
|
|
3646
|
+
const pending = [];
|
|
3647
|
+
let message;
|
|
2247
3648
|
try {
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
3649
|
+
for await (const event of stream) {
|
|
3650
|
+
if (event.type === "toolcall_end") pending.push(event.toolCall);
|
|
3651
|
+
}
|
|
3652
|
+
message = await stream.result();
|
|
3653
|
+
} catch (err) {
|
|
3654
|
+
return { kind: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
3655
|
+
}
|
|
3656
|
+
if (message.stopReason === "error") {
|
|
3657
|
+
return { kind: "failed", reason: message.errorMessage ?? "the model returned an error" };
|
|
3658
|
+
}
|
|
3659
|
+
answer = textOf2(message);
|
|
3660
|
+
context.messages.push(message);
|
|
3661
|
+
if (pending.length === 0) return { kind: "answered", answer, calls, served };
|
|
3662
|
+
for (const call of pending) {
|
|
3663
|
+
const result = await input.execute(call.name, call.arguments);
|
|
3664
|
+
calls.push({ name: call.name, arguments: call.arguments, result });
|
|
3665
|
+
context.messages.push({
|
|
3666
|
+
role: "toolResult",
|
|
3667
|
+
toolCallId: call.id,
|
|
3668
|
+
toolName: call.name,
|
|
3669
|
+
content: [{ type: "text", text: result }],
|
|
3670
|
+
// The executor reports a refusal as ordinary text — a refused proposal is a
|
|
3671
|
+
// normal outcome the model should reason about, not a transport error.
|
|
3672
|
+
isError: false,
|
|
3673
|
+
timestamp: Date.now()
|
|
3674
|
+
});
|
|
2251
3675
|
}
|
|
2252
3676
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
3677
|
+
return { kind: "cut-short", answer, calls, served, rounds: maxRounds };
|
|
3678
|
+
}
|
|
3679
|
+
function textOf2(message) {
|
|
3680
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
// src/workbench/agent/tools.ts
|
|
3684
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
3685
|
+
var AGENT_TOOLS = [
|
|
3686
|
+
{
|
|
3687
|
+
name: "add_task_group",
|
|
3688
|
+
description: "Propose a group of implementation tasks for this change. Use for planning the work; one group per coherent area, each task a single concrete action.",
|
|
3689
|
+
parameters: Type.Object({
|
|
3690
|
+
title: Type.String({ description: 'Short group title, e.g. "Search" or "Byte-safe writes"' }),
|
|
3691
|
+
tasks: Type.Array(Type.String(), {
|
|
3692
|
+
description: "Task titles, one per task. Each states one concrete action."
|
|
3693
|
+
}),
|
|
3694
|
+
capability: Type.Optional(
|
|
3695
|
+
Type.String({
|
|
3696
|
+
description: "Optional. The capability of the requirement this group implements \u2014 omit for a chore group that implements none."
|
|
3697
|
+
})
|
|
3698
|
+
),
|
|
3699
|
+
requirement: Type.Optional(
|
|
3700
|
+
Type.String({
|
|
3701
|
+
description: "Optional. The exact requirement name this group implements, as it appears after `### Requirement:`."
|
|
3702
|
+
})
|
|
3703
|
+
)
|
|
3704
|
+
})
|
|
3705
|
+
},
|
|
3706
|
+
{
|
|
3707
|
+
name: "add_requirement",
|
|
3708
|
+
description: "Propose a requirement to draft into an open capability. The block must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps.",
|
|
3709
|
+
parameters: Type.Object({
|
|
3710
|
+
capability: Type.String({ description: "A capability already open in this change" }),
|
|
3711
|
+
block: Type.String({ description: "The complete requirement block, in markdown" })
|
|
3712
|
+
})
|
|
3713
|
+
},
|
|
3714
|
+
{
|
|
3715
|
+
name: "set_proposal_section",
|
|
3716
|
+
description: "Propose the body of one proposal section.",
|
|
3717
|
+
parameters: Type.Object({
|
|
3718
|
+
section: Type.String({
|
|
3719
|
+
description: `One of: ${PROPOSAL_SECTIONS.map((s) => s.id).join(", ")}`
|
|
3720
|
+
}),
|
|
3721
|
+
body: Type.String({ description: "The section body, in markdown" })
|
|
3722
|
+
})
|
|
3723
|
+
},
|
|
3724
|
+
{
|
|
3725
|
+
name: "add_design_block",
|
|
3726
|
+
description: "Propose a design block. Use `mermaid` for a diagram (C4Context / C4Container / C4Component / C4Dynamic render as diagrams), `markdown` for prose.",
|
|
3727
|
+
parameters: Type.Object({
|
|
3728
|
+
kind: Type.String({ description: "markdown | mermaid | sketch" }),
|
|
3729
|
+
title: Type.String({ description: "The block's heading" }),
|
|
3730
|
+
text: Type.String({ description: "The block body" })
|
|
3731
|
+
})
|
|
2260
3732
|
}
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
3733
|
+
];
|
|
3734
|
+
var DESIGN_KINDS = /* @__PURE__ */ new Set(["markdown", "mermaid", "sketch"]);
|
|
3735
|
+
function stripLeadingNumber(text) {
|
|
3736
|
+
return text.replace(/^\s*\d+(?:\.\d+)*[.)]?\s+/, "").trim();
|
|
3737
|
+
}
|
|
3738
|
+
function proposalFromToolCall(name, args, shape) {
|
|
3739
|
+
switch (name) {
|
|
3740
|
+
case "add_task_group": {
|
|
3741
|
+
const title = stripLeadingNumber(String(args["title"] ?? ""));
|
|
3742
|
+
const tasks = Array.isArray(args["tasks"]) ? args["tasks"].map((t) => stripLeadingNumber(String(t))).filter((t) => t !== "") : [];
|
|
3743
|
+
if (title === "" || tasks.length === 0) {
|
|
3744
|
+
return { kind: "refused", tool: name, missing: "a title and at least one task" };
|
|
3745
|
+
}
|
|
3746
|
+
const capability = String(args["capability"] ?? "").trim();
|
|
3747
|
+
const key = String(args["requirement"] ?? "").trim();
|
|
3748
|
+
if (capability === "" && key === "") return { kind: "task-group", title, tasks };
|
|
3749
|
+
if (capability === "" || key === "") {
|
|
3750
|
+
return {
|
|
3751
|
+
kind: "refused",
|
|
3752
|
+
tool: name,
|
|
3753
|
+
missing: "both a capability and a requirement name, or neither"
|
|
3754
|
+
};
|
|
2271
3755
|
}
|
|
3756
|
+
if (shape.citable !== void 0 && !shape.citable.some((c) => c.capability === capability && c.key === key)) {
|
|
3757
|
+
return {
|
|
3758
|
+
kind: "refused",
|
|
3759
|
+
tool: name,
|
|
3760
|
+
missing: `\`${key}\` in \`${capability}\` is not a requirement this change declares`
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
return { kind: "task-group", title, tasks, cites: { capability, key } };
|
|
2272
3764
|
}
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
3765
|
+
case "add_requirement": {
|
|
3766
|
+
const capability = String(args["capability"] ?? "").trim();
|
|
3767
|
+
const block = String(args["block"] ?? "").trim();
|
|
3768
|
+
if (!shape.openCapabilities.includes(capability)) {
|
|
3769
|
+
return {
|
|
3770
|
+
kind: "refused",
|
|
3771
|
+
tool: name,
|
|
3772
|
+
missing: `capability \`${capability}\` is not open in this change`
|
|
3773
|
+
};
|
|
3774
|
+
}
|
|
3775
|
+
if (!/^###[ \t]+Requirement:/m.test(block)) {
|
|
3776
|
+
return { kind: "refused", tool: name, missing: "a `### Requirement:` heading" };
|
|
3777
|
+
}
|
|
3778
|
+
return { kind: "requirement", capability, block };
|
|
3779
|
+
}
|
|
3780
|
+
case "set_proposal_section": {
|
|
3781
|
+
const section2 = String(args["section"] ?? "").trim();
|
|
3782
|
+
const known = PROPOSAL_SECTIONS.find((s) => s.id === section2);
|
|
3783
|
+
if (!known) {
|
|
3784
|
+
return { kind: "refused", tool: name, missing: `no proposal section \`${section2}\`` };
|
|
3785
|
+
}
|
|
3786
|
+
return {
|
|
3787
|
+
kind: "proposal-section",
|
|
3788
|
+
section: known.id,
|
|
3789
|
+
body: String(args["body"] ?? "")
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
case "add_design_block": {
|
|
3793
|
+
const blockKind = String(args["kind"] ?? "").trim();
|
|
3794
|
+
if (!DESIGN_KINDS.has(blockKind)) {
|
|
3795
|
+
return { kind: "refused", tool: name, missing: `no design block kind \`${blockKind}\`` };
|
|
3796
|
+
}
|
|
3797
|
+
const title = String(args["title"] ?? "").trim();
|
|
3798
|
+
if (title === "") return { kind: "refused", tool: name, missing: "a block title" };
|
|
3799
|
+
return {
|
|
3800
|
+
kind: "design-block",
|
|
3801
|
+
blockKind,
|
|
3802
|
+
title,
|
|
3803
|
+
text: String(args["text"] ?? "")
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
default:
|
|
3807
|
+
return { kind: "refused", tool: name, missing: `no such tool \`${name}\`` };
|
|
2279
3808
|
}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
3809
|
+
}
|
|
3810
|
+
function toolResultFor(proposal) {
|
|
3811
|
+
switch (proposal.kind) {
|
|
3812
|
+
case "task-group":
|
|
3813
|
+
return `Proposed task group "${proposal.title}" with ${proposal.tasks.length} task(s)${proposal.cites ? `, implementing \`${proposal.cites.key}\`` : ", implementing no requirement"}. Awaiting the author's apply; nothing is written yet.`;
|
|
3814
|
+
case "requirement":
|
|
3815
|
+
return `Proposed a requirement for \`${proposal.capability}\`. Awaiting the author's apply; nothing is written yet.`;
|
|
3816
|
+
case "proposal-section":
|
|
3817
|
+
return `Proposed a body for the \`${proposal.section}\` section. Awaiting the author's apply; nothing is written yet.`;
|
|
3818
|
+
case "design-block":
|
|
3819
|
+
return `Proposed a ${proposal.blockKind} design block "${proposal.title}". Awaiting the author's apply; nothing is written yet.`;
|
|
3820
|
+
case "refused":
|
|
3821
|
+
return `Refused: ${proposal.tool} needs ${proposal.missing}. Nothing was proposed.`;
|
|
2292
3822
|
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
if (this.readIfExists(relPath) === content) continue;
|
|
2309
|
-
this.writeAtomic(relPath, content);
|
|
2310
|
-
written.push(relPath);
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
// server/workbench-host.ts
|
|
3826
|
+
var CHANGE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3827
|
+
function changeContextFor(target, source) {
|
|
3828
|
+
const read = [];
|
|
3829
|
+
let changeText = "";
|
|
3830
|
+
if (source.change) {
|
|
3831
|
+
for (const file of emitChange({ ...source.change, id: target.changeId }).files) {
|
|
3832
|
+
if (file.content.replace(/^#[ \t].*$/m, "").trim() === "") continue;
|
|
3833
|
+
changeText += `
|
|
3834
|
+
|
|
3835
|
+
===== ${file.path} (unsaved draft \u2014 what the author is looking at) =====
|
|
3836
|
+
${file.content}`;
|
|
3837
|
+
read.push(`${file.path} (draft)`);
|
|
2311
3838
|
}
|
|
2312
|
-
|
|
2313
|
-
...s,
|
|
2314
|
-
summary: bundle.summary,
|
|
2315
|
-
generated: manifest,
|
|
2316
|
-
// The diff-before-write result IS the changed-file signal for the
|
|
2317
|
-
// post-generation chat seed (generation-chat-reset-summary).
|
|
2318
|
-
lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
|
|
2319
|
-
}));
|
|
2320
|
-
return written;
|
|
3839
|
+
return { read, changeText };
|
|
2321
3840
|
}
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
3841
|
+
if (!target.resuming) return { read, changeText };
|
|
3842
|
+
for (const name of ["proposal.md", "tasks.md"]) {
|
|
3843
|
+
const file = join10(target.changeDir, name);
|
|
3844
|
+
if (!existsSync7(file)) continue;
|
|
3845
|
+
changeText += `
|
|
3846
|
+
|
|
3847
|
+
===== ${name} =====
|
|
3848
|
+
${readFileSync8(file, "utf8")}`;
|
|
3849
|
+
read.push(name);
|
|
3850
|
+
}
|
|
3851
|
+
for (const capability of source.openCapabilities ?? []) {
|
|
3852
|
+
const file = join10(target.changeDir, "specs", capability, "spec.md");
|
|
3853
|
+
if (!existsSync7(file)) continue;
|
|
3854
|
+
changeText += `
|
|
3855
|
+
|
|
3856
|
+
===== specs/${capability}/spec.md (the change's deltas) =====
|
|
3857
|
+
${readFileSync8(file, "utf8")}`;
|
|
3858
|
+
read.push(`specs/${capability}/spec.md`);
|
|
2325
3859
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
3860
|
+
return { read, changeText };
|
|
3861
|
+
}
|
|
3862
|
+
var WorkbenchHostError = class extends Error {
|
|
3863
|
+
};
|
|
3864
|
+
function findRepoRoot(start) {
|
|
3865
|
+
let dir = resolve3(start);
|
|
3866
|
+
for (; ; ) {
|
|
3867
|
+
if (existsSync7(join10(dir, "openspec"))) return dir;
|
|
3868
|
+
const parent = dirname5(dir);
|
|
3869
|
+
if (parent === dir) return null;
|
|
3870
|
+
dir = parent;
|
|
2328
3871
|
}
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
3872
|
+
}
|
|
3873
|
+
function resolveTarget(cwd, changeId) {
|
|
3874
|
+
if (changeId === void 0 || changeId === "") {
|
|
3875
|
+
throw new WorkbenchHostError("a change id is required: specsketch <change-id>");
|
|
2332
3876
|
}
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
...systemPrompt !== void 0 ? { systemPrompt } : {}
|
|
2338
|
-
}));
|
|
3877
|
+
if (!CHANGE_ID.test(changeId)) {
|
|
3878
|
+
throw new WorkbenchHostError(
|
|
3879
|
+
`\`${changeId}\` is not a change id. Pass the id (\`my-change\`), not a path \u2014 the workbench resolves it under \`openspec/changes/\`.`
|
|
3880
|
+
);
|
|
2339
3881
|
}
|
|
2340
|
-
|
|
3882
|
+
const repoRoot = findRepoRoot(cwd);
|
|
3883
|
+
if (repoRoot === null) {
|
|
3884
|
+
throw new WorkbenchHostError(
|
|
3885
|
+
`no \`openspec/\` directory found in \`${resolve3(cwd)}\` or any parent. The workbench authors changes in an OpenSpec repository; run it inside one.`
|
|
3886
|
+
);
|
|
3887
|
+
}
|
|
3888
|
+
const changeDir = changeDirectory(repoRoot, changeId);
|
|
3889
|
+
return { repoRoot, changeId, changeDir, resuming: existsSync7(changeDir) };
|
|
3890
|
+
}
|
|
3891
|
+
function mountWorkbenchHost(app, options) {
|
|
3892
|
+
const { target } = options;
|
|
3893
|
+
const reader = createFoldedSpecReader(target.repoRoot);
|
|
3894
|
+
app.get("/api/workbench/context", (c) => {
|
|
3895
|
+
let document = null;
|
|
3896
|
+
let deltaCapabilities = [];
|
|
3897
|
+
const capabilities = [];
|
|
3898
|
+
if (target.resuming) {
|
|
3899
|
+
try {
|
|
3900
|
+
const emitted = readEmittedChange(target.changeDir, target.changeId);
|
|
3901
|
+
document = parseChangeDocument(emitted);
|
|
3902
|
+
deltaCapabilities = emitted.files.map((f) => /^specs\/([^/]+)\/spec\.md$/.exec(f.path)?.[1]).filter((n) => n !== void 0);
|
|
3903
|
+
for (const spec of document.deltaSpecs) {
|
|
3904
|
+
const folded = reader.readCapability(spec.capability);
|
|
3905
|
+
const rebuilt = reconstructDraft(folded ?? "", spec);
|
|
3906
|
+
capabilities.push({
|
|
3907
|
+
capability: spec.capability,
|
|
3908
|
+
folded,
|
|
3909
|
+
draft: rebuilt.draft,
|
|
3910
|
+
reconstructed: rebuilt.outcome === "reconstructed",
|
|
3911
|
+
...rebuilt.outcome === "unreconstructable" ? { reason: rebuilt.reason } : {}
|
|
3912
|
+
});
|
|
3913
|
+
}
|
|
3914
|
+
} catch {
|
|
3915
|
+
document = null;
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
return c.json({
|
|
3919
|
+
repoRoot: target.repoRoot,
|
|
3920
|
+
changeId: target.changeId,
|
|
3921
|
+
changeDir: target.changeDir,
|
|
3922
|
+
resuming: target.resuming,
|
|
3923
|
+
fixture: options.fixture === true,
|
|
3924
|
+
specsDir: reader.specsDir,
|
|
3925
|
+
document,
|
|
3926
|
+
deltaCapabilities,
|
|
3927
|
+
capabilities,
|
|
3928
|
+
// True when every delta this change carries was rebuilt. A capability that
|
|
3929
|
+
// failed is listed with its reason rather than appearing to carry no edits.
|
|
3930
|
+
draftsRestorable: capabilities.every((c2) => c2.reconstructed)
|
|
3931
|
+
});
|
|
3932
|
+
});
|
|
3933
|
+
app.get("/api/workbench/capabilities", (c) => {
|
|
3934
|
+
const open = c.req.queries("open") ?? [];
|
|
3935
|
+
return c.json({ capabilities: reader.listCapabilities(open) });
|
|
3936
|
+
});
|
|
3937
|
+
app.get("/api/workbench/capabilities/:name", (c) => {
|
|
3938
|
+
const name = c.req.param("name");
|
|
3939
|
+
const open = c.req.queries("open") ?? [];
|
|
3940
|
+
try {
|
|
3941
|
+
return c.json({ result: reader.openCapability(name, open) });
|
|
3942
|
+
} catch (err) {
|
|
3943
|
+
if (err instanceof RangeError) {
|
|
3944
|
+
return c.json({ error: "invalid-capability-name", message: err.message }, 400);
|
|
3945
|
+
}
|
|
3946
|
+
throw err;
|
|
3947
|
+
}
|
|
3948
|
+
});
|
|
3949
|
+
app.post("/api/workbench/emit", async (c) => {
|
|
3950
|
+
const body = await c.req.json();
|
|
3951
|
+
if (!body.change) return c.json({ error: "missing-change" }, 400);
|
|
3952
|
+
const result = writeChange(
|
|
3953
|
+
target.repoRoot,
|
|
3954
|
+
{ ...body.change, id: target.changeId },
|
|
3955
|
+
options.provenance ?? NO_ISSUE,
|
|
3956
|
+
body.overwrite === true ? { overwrite: true } : {}
|
|
3957
|
+
);
|
|
3958
|
+
return c.json({ result }, result.outcome === "written" ? 200 : 409);
|
|
3959
|
+
});
|
|
3960
|
+
app.post("/api/workbench/archive", async (c) => {
|
|
3961
|
+
const body = await c.req.json();
|
|
3962
|
+
if (!body.change) return c.json({ error: "missing-change" }, 400);
|
|
3963
|
+
const result = approveChange(
|
|
3964
|
+
target.repoRoot,
|
|
3965
|
+
{ ...body.change, id: target.changeId },
|
|
3966
|
+
options.provenance ?? NO_ISSUE
|
|
3967
|
+
);
|
|
3968
|
+
return c.json({ result }, result.outcome === "approved" ? 200 : 409);
|
|
3969
|
+
});
|
|
3970
|
+
app.post("/api/workbench/agent/turn", async (c) => {
|
|
3971
|
+
const body = await c.req.json();
|
|
3972
|
+
const prompt = (body.prompt ?? "").trim();
|
|
3973
|
+
if (prompt === "") return c.json({ error: "missing-prompt" }, 400);
|
|
3974
|
+
const token = readSubscriptionToken(options.home ?? homedir3());
|
|
3975
|
+
if (!token && !process.env["ANTHROPIC_API_KEY"]) {
|
|
3976
|
+
return c.json({ outcome: { kind: "no-credential" } }, 200);
|
|
3977
|
+
}
|
|
3978
|
+
const shape = {
|
|
3979
|
+
openCapabilities: body.openCapabilities ?? [],
|
|
3980
|
+
...body.change ? { citable: citableRequirements(body.change.capabilities) } : {}
|
|
3981
|
+
};
|
|
3982
|
+
const { read, changeText } = changeContextFor(target, body);
|
|
3983
|
+
const proposals = [];
|
|
3984
|
+
const outcome = await runTurn(
|
|
3985
|
+
{
|
|
3986
|
+
systemPrompt: [
|
|
3987
|
+
"You are helping author an OpenSpec change in the SpecSketch workbench.",
|
|
3988
|
+
`The change is \`${target.changeId}\`, in the repository at ${target.repoRoot}.`,
|
|
3989
|
+
shape.openCapabilities.length > 0 ? `Capabilities open in this change: ${shape.openCapabilities.join(", ")}.` : "No capability is open in this change yet.",
|
|
3990
|
+
body.scope ? `The author is looking at ${body.scope}.` : "",
|
|
3991
|
+
"",
|
|
3992
|
+
"Use the tools to propose concrete items. Everything you propose is reviewed and",
|
|
3993
|
+
"applied by the author \u2014 you never write to the change yourself, so propose freely",
|
|
3994
|
+
"and say briefly what you proposed and why.",
|
|
3995
|
+
"",
|
|
3996
|
+
"Ground every proposal in the change below. Use its own vocabulary: if it says",
|
|
3997
|
+
'"changes", do not write "plans". Do not invent states, sections or files it does',
|
|
3998
|
+
"not define. Where it is silent on something you need, say so rather than",
|
|
3999
|
+
"inventing it.",
|
|
4000
|
+
changeText === "" ? "(This change has nothing written yet.)" : changeText
|
|
4001
|
+
].filter((line) => line !== "").join("\n"),
|
|
4002
|
+
prompt,
|
|
4003
|
+
tools: AGENT_TOOLS,
|
|
4004
|
+
execute: (name, args) => {
|
|
4005
|
+
const proposal = proposalFromToolCall(name, args, shape);
|
|
4006
|
+
proposals.push(proposal);
|
|
4007
|
+
return toolResultFor(proposal);
|
|
4008
|
+
}
|
|
4009
|
+
},
|
|
4010
|
+
{ oauthToken: token ?? void 0 }
|
|
4011
|
+
);
|
|
4012
|
+
return c.json({ outcome, proposals, read });
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
2341
4015
|
|
|
2342
4016
|
// server/cli.ts
|
|
2343
|
-
var
|
|
4017
|
+
var CLI_VERSION = true ? "2026.08.17.1" : "dev";
|
|
4018
|
+
var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
|
|
2344
4019
|
specsketch login [--profile <name>] [--web-url <url>]
|
|
4020
|
+
specsketch --version
|
|
4021
|
+
|
|
4022
|
+
specsketch add-user-search
|
|
2345
4023
|
|
|
2346
|
-
|
|
4024
|
+
The workbench authors exactly ONE change per run, named by <change-id>. The id
|
|
4025
|
+
is resolved under openspec/changes/<change-id> in the repository you run it in.
|
|
4026
|
+
An existing change is resumed; an unknown id starts a new draft \u2014 authoring a
|
|
4027
|
+
change that does not exist yet is the ordinary case.
|
|
2347
4028
|
|
|
2348
|
-
The
|
|
2349
|
-
|
|
2350
|
-
|
|
4029
|
+
The id is required and never inferred. Guessing it (newest directory, only open
|
|
4030
|
+
change, last used) is right until two changes are in flight, which is exactly
|
|
4031
|
+
when being wrong costs the most.
|
|
2351
4032
|
|
|
2352
4033
|
--port sets the preferred port (default 8787); if it's already in use the
|
|
2353
4034
|
server moves to the next available port and prints the actual URL.
|
|
2354
4035
|
|
|
2355
|
-
--
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
still binds to 127.0.0.1 only; sharing runs over an outbound connection.
|
|
2359
|
-
|
|
2360
|
-
login authenticates this machine against your CoopCLI account (opens a
|
|
2361
|
-
browser, saves credentials to ~/.coopcli/config.json).`;
|
|
4036
|
+
--fixture opens the demo change instead of the repository, for trying the
|
|
4037
|
+
surface out. The workbench says on screen when it is showing fixture data.
|
|
4038
|
+
`;
|
|
2362
4039
|
var UsageError = class extends Error {
|
|
2363
4040
|
};
|
|
2364
4041
|
function parseCliArgs(argv) {
|
|
@@ -2366,19 +4043,24 @@ function parseCliArgs(argv) {
|
|
|
2366
4043
|
try {
|
|
2367
4044
|
parsed = parseArgs2({
|
|
2368
4045
|
args: argv,
|
|
2369
|
-
options: { port: { type: "string" },
|
|
4046
|
+
options: { port: { type: "string" }, fixture: { type: "boolean" } },
|
|
2370
4047
|
allowPositionals: true
|
|
2371
4048
|
});
|
|
2372
4049
|
} catch (err) {
|
|
2373
4050
|
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
2374
4051
|
}
|
|
2375
|
-
const [
|
|
2376
|
-
if (!
|
|
4052
|
+
const [changeId, ...rest] = parsed.positionals;
|
|
4053
|
+
if (!changeId) throw new UsageError("a change id is required");
|
|
4054
|
+
if (rest.length > 0) {
|
|
4055
|
+
throw new UsageError(
|
|
4056
|
+
"expected exactly one change id \u2014 the workbench authors one change per run"
|
|
4057
|
+
);
|
|
4058
|
+
}
|
|
2377
4059
|
const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
|
|
2378
4060
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2379
4061
|
throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
|
|
2380
4062
|
}
|
|
2381
|
-
return {
|
|
4063
|
+
return { changeId, port, fixture: parsed.values.fixture === true };
|
|
2382
4064
|
}
|
|
2383
4065
|
var PORT_SCAN_LIMIT = 100;
|
|
2384
4066
|
async function bindAvailablePort(start, tryBind) {
|
|
@@ -2405,25 +4087,25 @@ var MIME = {
|
|
|
2405
4087
|
".woff": "font/woff"
|
|
2406
4088
|
};
|
|
2407
4089
|
function resolveClientDir(moduleUrl) {
|
|
2408
|
-
const here =
|
|
4090
|
+
const here = dirname6(fileURLToPath(moduleUrl));
|
|
2409
4091
|
const candidates = [
|
|
2410
|
-
|
|
4092
|
+
join11(here, "..", "client"),
|
|
2411
4093
|
// packed: dist/cli -> dist/client
|
|
2412
|
-
|
|
4094
|
+
join11(here, "..", "dist", "client")
|
|
2413
4095
|
// repo: server/ -> dist/client
|
|
2414
4096
|
];
|
|
2415
4097
|
for (const dir of candidates) {
|
|
2416
|
-
if (
|
|
4098
|
+
if (existsSync8(join11(dir, "index.html"))) return dir;
|
|
2417
4099
|
}
|
|
2418
4100
|
return null;
|
|
2419
4101
|
}
|
|
2420
4102
|
function staticResponse(clientDir, pathname) {
|
|
2421
4103
|
const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
|
|
2422
|
-
const target =
|
|
2423
|
-
if (!target.startsWith(
|
|
2424
|
-
const file =
|
|
2425
|
-
if (!
|
|
2426
|
-
return new Response(
|
|
4104
|
+
const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
|
|
4105
|
+
if (!target.startsWith(resolve4(clientDir))) return null;
|
|
4106
|
+
const file = existsSync8(target) && extname(target) ? target : join11(clientDir, "index.html");
|
|
4107
|
+
if (!existsSync8(file)) return null;
|
|
4108
|
+
return new Response(readFileSync9(file), {
|
|
2427
4109
|
headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
|
|
2428
4110
|
});
|
|
2429
4111
|
}
|
|
@@ -2435,12 +4117,12 @@ but "Generate Spec" needs one of:
|
|
|
2435
4117
|
\u2022 or the Anthropic CLI's login profile (no key handling needed):
|
|
2436
4118
|
brew install anthropics/tap/ant # macOS
|
|
2437
4119
|
ant auth login`;
|
|
2438
|
-
function detectCredentialSource(env = process.env, home =
|
|
4120
|
+
function detectCredentialSource(env = process.env, home = homedir4()) {
|
|
2439
4121
|
if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
|
|
2440
4122
|
if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
|
|
2441
4123
|
try {
|
|
2442
|
-
const dir =
|
|
2443
|
-
if (
|
|
4124
|
+
const dir = join11(home, ".config", "anthropic", "credentials");
|
|
4125
|
+
if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
|
|
2444
4126
|
} catch {
|
|
2445
4127
|
}
|
|
2446
4128
|
return null;
|
|
@@ -2454,6 +4136,10 @@ function preflight(log = console.log) {
|
|
|
2454
4136
|
}
|
|
2455
4137
|
}
|
|
2456
4138
|
async function main(argv) {
|
|
4139
|
+
if (argv[0] === "--version") {
|
|
4140
|
+
console.log(CLI_VERSION);
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
2457
4143
|
preflight();
|
|
2458
4144
|
if (argv[0] === "login") {
|
|
2459
4145
|
let loginArgs;
|
|
@@ -2489,26 +4175,25 @@ ${USAGE}` : err);
|
|
|
2489
4175
|
console.error("specsketch: built UI assets not found (dist/client missing).");
|
|
2490
4176
|
process.exit(1);
|
|
2491
4177
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
4178
|
+
let target;
|
|
4179
|
+
try {
|
|
4180
|
+
target = resolveTarget(process.cwd(), args.changeId);
|
|
4181
|
+
} catch (err) {
|
|
4182
|
+
console.error(
|
|
4183
|
+
err instanceof WorkbenchHostError ? `specsketch: ${err.message}
|
|
4184
|
+
|
|
4185
|
+
${USAGE}` : `specsketch: ${err instanceof Error ? err.message : String(err)}`
|
|
4186
|
+
);
|
|
4187
|
+
process.exit(1);
|
|
2499
4188
|
}
|
|
2500
|
-
const workspace = new ChangeWorkspace(
|
|
2501
|
-
let remote = null;
|
|
4189
|
+
const workspace = new ChangeWorkspace(target.changeDir, { create: false });
|
|
2502
4190
|
const app = createApp(
|
|
2503
4191
|
new Anthropic(),
|
|
2504
4192
|
workspace,
|
|
2505
|
-
() =>
|
|
2506
|
-
|
|
2507
|
-
shareUrl: remote.shareUrl,
|
|
2508
|
-
status: remote.status,
|
|
2509
|
-
participants: remote.participants.length
|
|
2510
|
-
} : { active: false }
|
|
4193
|
+
() => ({ active: false }),
|
|
4194
|
+
{ refRoot: target.repoRoot, harness: createClaudeHarness() }
|
|
2511
4195
|
);
|
|
4196
|
+
mountWorkbenchHost(app, { target, fixture: args.fixture });
|
|
2512
4197
|
app.get("*", (c) => {
|
|
2513
4198
|
const res = staticResponse(clientDir, new URL(c.req.url).pathname);
|
|
2514
4199
|
return res ?? c.notFound();
|
|
@@ -2535,17 +4220,11 @@ ${USAGE}` : err);
|
|
|
2535
4220
|
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
2536
4221
|
process.exit(1);
|
|
2537
4222
|
}
|
|
2538
|
-
if (args.share) {
|
|
2539
|
-
try {
|
|
2540
|
-
remote = await startShare(workspace, httpServer);
|
|
2541
|
-
} catch (err) {
|
|
2542
|
-
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
2543
|
-
process.exit(1);
|
|
2544
|
-
}
|
|
2545
|
-
}
|
|
2546
4223
|
const source = detectCredentialSource();
|
|
2547
4224
|
console.log(`coopcli specsketch`);
|
|
2548
|
-
console.log(`
|
|
4225
|
+
console.log(` repo: ${target.repoRoot}`);
|
|
4226
|
+
console.log(` change: ${target.changeId} (${target.resuming ? "resuming" : "new draft"})`);
|
|
4227
|
+
if (args.fixture) console.log(` data: demo fixture \u2014 NOT this repository`);
|
|
2549
4228
|
console.log(` open: http://127.0.0.1:${port}`);
|
|
2550
4229
|
if (moved) {
|
|
2551
4230
|
console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
|
|
@@ -2557,56 +4236,6 @@ ${USAGE}` : err);
|
|
|
2557
4236
|
\u26A0 ${CREDENTIALS_HINT}
|
|
2558
4237
|
`);
|
|
2559
4238
|
}
|
|
2560
|
-
if (remote) {
|
|
2561
|
-
console.log(`
|
|
2562
|
-
\u2500\u2500 shared session \u2500\u2500`);
|
|
2563
|
-
console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
|
|
2564
|
-
console.log(`
|
|
2565
|
-
${remote.shareUrl}
|
|
2566
|
-
`);
|
|
2567
|
-
console.log(` Ctrl-C ends the session and invalidates the link.`);
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
async function startShare(workspace, server) {
|
|
2571
|
-
const auth = await readCoopAuth();
|
|
2572
|
-
const localClients = /* @__PURE__ */ new Set();
|
|
2573
|
-
const remote = await RemoteSession.start({
|
|
2574
|
-
...auth,
|
|
2575
|
-
workspace,
|
|
2576
|
-
title: workspace.changeName,
|
|
2577
|
-
hostName: auth.displayName,
|
|
2578
|
-
log: (msg) => console.log(` ${msg}`),
|
|
2579
|
-
onLocalBroadcast: (frame) => {
|
|
2580
|
-
for (const client of localClients) client.send(frame);
|
|
2581
|
-
}
|
|
2582
|
-
});
|
|
2583
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
2584
|
-
server.on("upgrade", (req, socket, head) => {
|
|
2585
|
-
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
2586
|
-
if (pathname !== "/ws/local") {
|
|
2587
|
-
socket.destroy();
|
|
2588
|
-
return;
|
|
2589
|
-
}
|
|
2590
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2591
|
-
localClients.add(ws);
|
|
2592
|
-
ws.on("close", () => localClients.delete(ws));
|
|
2593
|
-
ws.on(
|
|
2594
|
-
"message",
|
|
2595
|
-
(data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
|
|
2596
|
-
);
|
|
2597
|
-
});
|
|
2598
|
-
});
|
|
2599
|
-
let ending = false;
|
|
2600
|
-
const endAndExit = async () => {
|
|
2601
|
-
if (ending) return;
|
|
2602
|
-
ending = true;
|
|
2603
|
-
console.log("\nspecsketch: ending shared session\u2026");
|
|
2604
|
-
await remote.end();
|
|
2605
|
-
process.exit(0);
|
|
2606
|
-
};
|
|
2607
|
-
process.on("SIGINT", () => void endAndExit());
|
|
2608
|
-
process.on("SIGTERM", () => void endAndExit());
|
|
2609
|
-
return remote;
|
|
2610
4239
|
}
|
|
2611
4240
|
|
|
2612
4241
|
// server/cli-entry.ts
|