@coopcli/specsketch 5.5.7 → 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 +2007 -989
- 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 +13 -10
- 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,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// server/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
|
|
5
5
|
import { homedir as homedir4 } from "node:os";
|
|
6
|
-
import { dirname as
|
|
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";
|
|
@@ -121,7 +121,11 @@ function* mapMessage(message, expectStructuredOutput) {
|
|
|
121
121
|
switch (message.type) {
|
|
122
122
|
case "system": {
|
|
123
123
|
if (message.subtype === "init") {
|
|
124
|
-
yield {
|
|
124
|
+
yield {
|
|
125
|
+
type: "start",
|
|
126
|
+
sessionId: message.session_id,
|
|
127
|
+
...message.apiKeySource !== void 0 ? { apiKeySource: message.apiKeySource } : {}
|
|
128
|
+
};
|
|
125
129
|
}
|
|
126
130
|
return;
|
|
127
131
|
}
|
|
@@ -332,7 +336,7 @@ function lifeOSProfile(identity, mcpServers) {
|
|
|
332
336
|
}
|
|
333
337
|
|
|
334
338
|
// server/cli.ts
|
|
335
|
-
import
|
|
339
|
+
import "ws";
|
|
336
340
|
|
|
337
341
|
// server/app.ts
|
|
338
342
|
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
@@ -1092,14 +1096,9 @@ ${refContext}` : "");
|
|
|
1092
1096
|
return app;
|
|
1093
1097
|
}
|
|
1094
1098
|
|
|
1095
|
-
// server/coop-auth.ts
|
|
1096
|
-
import { homedir as homedir3 } from "node:os";
|
|
1097
|
-
import { join as join4 } from "node:path";
|
|
1098
|
-
|
|
1099
1099
|
// ../shared/src/cli-auth.ts
|
|
1100
1100
|
import { exec } from "node:child_process";
|
|
1101
1101
|
import { randomBytes } from "node:crypto";
|
|
1102
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
1103
1102
|
import { createServer } from "node:http";
|
|
1104
1103
|
import { hostname as hostname2 } from "node:os";
|
|
1105
1104
|
import { URL as URL2 } from "node:url";
|
|
@@ -1639,8 +1638,8 @@ function resolveDisplayName(name, email, userId) {
|
|
|
1639
1638
|
async function openBrowser(url) {
|
|
1640
1639
|
const escaped = url.replace(/"/g, '\\"');
|
|
1641
1640
|
const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
|
|
1642
|
-
return new Promise((
|
|
1643
|
-
exec(cmd, () =>
|
|
1641
|
+
return new Promise((resolve5) => {
|
|
1642
|
+
exec(cmd, () => resolve5());
|
|
1644
1643
|
});
|
|
1645
1644
|
}
|
|
1646
1645
|
var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
|
|
@@ -1725,14 +1724,15 @@ function deriveApiUrl(webUrl) {
|
|
|
1725
1724
|
async function login(input, deps) {
|
|
1726
1725
|
const state = randomBytes(16).toString("hex");
|
|
1727
1726
|
const machineId = input.machine ?? hostname2();
|
|
1727
|
+
const log = deps.log ?? ((message) => void process.stderr.write(message));
|
|
1728
1728
|
const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
|
|
1729
1729
|
try {
|
|
1730
1730
|
const webUrl = input.webUrl ?? "https://coopcli.com";
|
|
1731
1731
|
const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
|
|
1732
|
-
|
|
1732
|
+
log(`Opening browser for authentication...
|
|
1733
1733
|
`);
|
|
1734
1734
|
await deps.openBrowser(authUrl);
|
|
1735
|
-
|
|
1735
|
+
log(`Waiting for callback on port ${server.port}...
|
|
1736
1736
|
`);
|
|
1737
1737
|
const params = await server.waitForCallback();
|
|
1738
1738
|
const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
|
|
@@ -1749,7 +1749,7 @@ async function login(input, deps) {
|
|
|
1749
1749
|
});
|
|
1750
1750
|
const config = await deps.config.load();
|
|
1751
1751
|
if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
|
|
1752
|
-
|
|
1752
|
+
log(
|
|
1753
1753
|
`
|
|
1754
1754
|
Backend mismatch: you authenticated against ${params.apiUrl}
|
|
1755
1755
|
but your config points to ${config.apiUrl}
|
|
@@ -1773,73 +1773,6 @@ Backend mismatch: you authenticated against ${params.apiUrl}
|
|
|
1773
1773
|
server.close();
|
|
1774
1774
|
}
|
|
1775
1775
|
}
|
|
1776
|
-
var CliAuthError = class extends Error {
|
|
1777
|
-
constructor(message, kind) {
|
|
1778
|
-
super(message);
|
|
1779
|
-
this.kind = kind;
|
|
1780
|
-
this.name = "CliAuthError";
|
|
1781
|
-
}
|
|
1782
|
-
kind;
|
|
1783
|
-
};
|
|
1784
|
-
async function resolveCoopCredentials(opts) {
|
|
1785
|
-
const env = opts.env ?? process.env;
|
|
1786
|
-
const { configPath, loginHint } = opts;
|
|
1787
|
-
if (!existsSync2(configPath)) {
|
|
1788
|
-
throw new CliAuthError(`No CoopCLI config found at ${configPath}.
|
|
1789
|
-
${loginHint}`, "missing-config");
|
|
1790
|
-
}
|
|
1791
|
-
const store = new ConfigStore(configPath);
|
|
1792
|
-
let raw;
|
|
1793
|
-
try {
|
|
1794
|
-
raw = await store.loadRaw();
|
|
1795
|
-
} catch {
|
|
1796
|
-
throw new CliAuthError(
|
|
1797
|
-
`Could not read CoopCLI credentials from ${configPath}.
|
|
1798
|
-
${loginHint}`,
|
|
1799
|
-
"unreadable-config"
|
|
1800
|
-
);
|
|
1801
|
-
}
|
|
1802
|
-
const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
|
|
1803
|
-
const profile = raw.profiles[profileName];
|
|
1804
|
-
if (!profile) {
|
|
1805
|
-
const available = Object.keys(raw.profiles).join(", ");
|
|
1806
|
-
throw new CliAuthError(
|
|
1807
|
-
`CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
|
|
1808
|
-
${loginHint}`,
|
|
1809
|
-
"unknown-profile"
|
|
1810
|
-
);
|
|
1811
|
-
}
|
|
1812
|
-
if (!profile.auth) {
|
|
1813
|
-
throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
|
|
1814
|
-
${loginHint}`, "no-login");
|
|
1815
|
-
}
|
|
1816
|
-
const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
|
|
1817
|
-
return {
|
|
1818
|
-
apiUrl,
|
|
1819
|
-
key: profile.auth.key,
|
|
1820
|
-
userId: profile.auth.userId,
|
|
1821
|
-
displayName: profile.auth.displayName ?? null
|
|
1822
|
-
};
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// server/coop-auth.ts
|
|
1826
|
-
var CoopAuthError = class extends Error {
|
|
1827
|
-
};
|
|
1828
|
-
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.";
|
|
1829
|
-
async function readCoopAuth(configPath = join4(homedir3(), ".coopcli", "config.json"), env = process.env) {
|
|
1830
|
-
try {
|
|
1831
|
-
return await resolveCoopCredentials({
|
|
1832
|
-
configPath,
|
|
1833
|
-
env,
|
|
1834
|
-
profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
|
|
1835
|
-
apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
|
|
1836
|
-
loginHint: LOGIN_HINT
|
|
1837
|
-
});
|
|
1838
|
-
} catch (err) {
|
|
1839
|
-
if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
|
|
1840
|
-
throw err;
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
1776
|
|
|
1844
1777
|
// server/login.ts
|
|
1845
1778
|
import { parseArgs } from "node:util";
|
|
@@ -1880,776 +1813,34 @@ async function runLogin(args, deps = {}) {
|
|
|
1880
1813
|
if (profile) config.selectProfile(profile);
|
|
1881
1814
|
const result = await login(
|
|
1882
1815
|
{ webUrl: args.webUrl, loginCommand: "specsketch login" },
|
|
1883
|
-
|
|
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 }
|
|
1884
1819
|
);
|
|
1885
1820
|
(deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
|
|
1886
1821
|
}
|
|
1887
1822
|
|
|
1888
|
-
// server/remote-session.ts
|
|
1889
|
-
import { WebSocket } from "ws";
|
|
1890
|
-
|
|
1891
|
-
// ../shared/src/errors.ts
|
|
1892
|
-
function isApiErrorBody(value) {
|
|
1893
|
-
if (typeof value !== "object" || value === null) return false;
|
|
1894
|
-
const v = value;
|
|
1895
|
-
return typeof v.category === "string" && typeof v.code === "string" && typeof v.message === "string";
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
// ../shared/src/api-client.ts
|
|
1899
|
-
var CoopApiError = class extends Error {
|
|
1900
|
-
category;
|
|
1901
|
-
code;
|
|
1902
|
-
status;
|
|
1903
|
-
details;
|
|
1904
|
-
constructor(status, body) {
|
|
1905
|
-
super(body.message);
|
|
1906
|
-
this.name = "CoopApiError";
|
|
1907
|
-
this.category = body.category;
|
|
1908
|
-
this.code = body.code;
|
|
1909
|
-
this.status = status;
|
|
1910
|
-
this.details = body.details;
|
|
1911
|
-
}
|
|
1912
|
-
};
|
|
1913
|
-
function isCoopApiError(err) {
|
|
1914
|
-
return err instanceof CoopApiError;
|
|
1915
|
-
}
|
|
1916
|
-
async function streamToBuffer(stream) {
|
|
1917
|
-
const reader = stream.getReader();
|
|
1918
|
-
const chunks = [];
|
|
1919
|
-
for (; ; ) {
|
|
1920
|
-
const { done, value } = await reader.read();
|
|
1921
|
-
if (done) break;
|
|
1922
|
-
chunks.push(value);
|
|
1923
|
-
}
|
|
1924
|
-
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
1925
|
-
const result = new Uint8Array(total);
|
|
1926
|
-
let offset = 0;
|
|
1927
|
-
for (const chunk of chunks) {
|
|
1928
|
-
result.set(chunk, offset);
|
|
1929
|
-
offset += chunk.length;
|
|
1930
|
-
}
|
|
1931
|
-
return result;
|
|
1932
|
-
}
|
|
1933
|
-
var verbose = false;
|
|
1934
|
-
var ApiClient = class _ApiClient {
|
|
1935
|
-
constructor(apiUrl, token, userId) {
|
|
1936
|
-
this.apiUrl = apiUrl;
|
|
1937
|
-
this.token = token;
|
|
1938
|
-
this.userId = userId;
|
|
1939
|
-
}
|
|
1940
|
-
apiUrl;
|
|
1941
|
-
token;
|
|
1942
|
-
userId;
|
|
1943
|
-
/** Build a user-scoped path. Only callable when userId is set. */
|
|
1944
|
-
userPath(sub) {
|
|
1945
|
-
return `/api/u/${this.userId}${sub}`;
|
|
1946
|
-
}
|
|
1947
|
-
async request(method, path, body, params) {
|
|
1948
|
-
const url = new URL(path, this.apiUrl);
|
|
1949
|
-
if (params) {
|
|
1950
|
-
for (const [key, val] of Object.entries(params)) {
|
|
1951
|
-
if (val !== void 0) {
|
|
1952
|
-
url.searchParams.set(key, String(val));
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
if (verbose) {
|
|
1957
|
-
process.stderr.write(`[verbose] ${method} ${url.toString()}
|
|
1958
|
-
`);
|
|
1959
|
-
if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
|
|
1960
|
-
`);
|
|
1961
|
-
}
|
|
1962
|
-
const res = await fetch(url.toString(), {
|
|
1963
|
-
method,
|
|
1964
|
-
headers: {
|
|
1965
|
-
Authorization: `Bearer ${this.token}`,
|
|
1966
|
-
"Content-Type": "application/json"
|
|
1967
|
-
},
|
|
1968
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1969
|
-
});
|
|
1970
|
-
if (verbose) {
|
|
1971
|
-
process.stderr.write(`[verbose] ${res.status} ${res.statusText}
|
|
1972
|
-
`);
|
|
1973
|
-
}
|
|
1974
|
-
if (!res.ok) {
|
|
1975
|
-
let parsed = null;
|
|
1976
|
-
try {
|
|
1977
|
-
const json = await res.json();
|
|
1978
|
-
if (isApiErrorBody(json)) {
|
|
1979
|
-
parsed = json;
|
|
1980
|
-
} else {
|
|
1981
|
-
const legacy = json;
|
|
1982
|
-
parsed = {
|
|
1983
|
-
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1984
|
-
code: "UNKNOWN",
|
|
1985
|
-
message: legacy.error ?? legacy.message ?? res.statusText
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
} catch {
|
|
1989
|
-
parsed = {
|
|
1990
|
-
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1991
|
-
code: "UNKNOWN",
|
|
1992
|
-
message: res.statusText
|
|
1993
|
-
};
|
|
1994
|
-
}
|
|
1995
|
-
if (verbose) {
|
|
1996
|
-
process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
|
|
1997
|
-
`);
|
|
1998
|
-
}
|
|
1999
|
-
throw new CoopApiError(res.status, parsed);
|
|
2000
|
-
}
|
|
2001
|
-
return res.json();
|
|
2002
|
-
}
|
|
2003
|
-
async getMe() {
|
|
2004
|
-
const path = this.userId ? this.userPath("/me") : "/api/me";
|
|
2005
|
-
return this.request("GET", path);
|
|
2006
|
-
}
|
|
2007
|
-
async createTeam(input) {
|
|
2008
|
-
if (this.userId) {
|
|
2009
|
-
}
|
|
2010
|
-
return this.request("POST", "/api/teams", input);
|
|
2011
|
-
}
|
|
2012
|
-
async listTeams() {
|
|
2013
|
-
if (this.userId) {
|
|
2014
|
-
const me = await this.request("GET", this.userPath("/me"));
|
|
2015
|
-
return (me.teams ?? []).map((m) => ({
|
|
2016
|
-
id: m.teamId,
|
|
2017
|
-
name: m.teamName,
|
|
2018
|
-
type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
|
|
2019
|
-
plan: "hobby",
|
|
2020
|
-
createdBy: "",
|
|
2021
|
-
createdAt: m.joinedAt
|
|
2022
|
-
}));
|
|
2023
|
-
}
|
|
2024
|
-
return this.request("GET", "/api/teams");
|
|
2025
|
-
}
|
|
2026
|
-
async inviteToTeam(team, input) {
|
|
2027
|
-
return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
|
|
2028
|
-
}
|
|
2029
|
-
async joinTeam(team, inviteCode) {
|
|
2030
|
-
await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
|
|
2031
|
-
}
|
|
2032
|
-
async listTeamMembers(team) {
|
|
2033
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
|
|
2034
|
-
}
|
|
2035
|
-
async createSession(input) {
|
|
2036
|
-
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
2037
|
-
return this.request("POST", path, input);
|
|
2038
|
-
}
|
|
2039
|
-
async listSessions() {
|
|
2040
|
-
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
2041
|
-
const result = await this.request("GET", path);
|
|
2042
|
-
return result.sessions;
|
|
2043
|
-
}
|
|
2044
|
-
async joinSession(name) {
|
|
2045
|
-
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
|
|
2046
|
-
}
|
|
2047
|
-
async leaveSession(name) {
|
|
2048
|
-
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
|
|
2049
|
-
}
|
|
2050
|
-
async deleteSession(name) {
|
|
2051
|
-
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
|
|
2052
|
-
return this.request("DELETE", path);
|
|
2053
|
-
}
|
|
2054
|
-
async sendMessage(teamId, session, input) {
|
|
2055
|
-
if (this.userId) {
|
|
2056
|
-
return this.request(
|
|
2057
|
-
"POST",
|
|
2058
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
2059
|
-
input,
|
|
2060
|
-
{ team: teamId }
|
|
2061
|
-
);
|
|
2062
|
-
}
|
|
2063
|
-
return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
|
|
2064
|
-
}
|
|
2065
|
-
async getMessages(teamId, session, cursor, limit) {
|
|
2066
|
-
if (this.userId) {
|
|
2067
|
-
return this.request(
|
|
2068
|
-
"GET",
|
|
2069
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
2070
|
-
void 0,
|
|
2071
|
-
{ team: teamId, cursor, limit }
|
|
2072
|
-
);
|
|
2073
|
-
}
|
|
2074
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
2075
|
-
cursor,
|
|
2076
|
-
limit
|
|
2077
|
-
});
|
|
2078
|
-
}
|
|
2079
|
-
async getLatestMessages(teamId, session, count = 5) {
|
|
2080
|
-
if (this.userId) {
|
|
2081
|
-
return this.request(
|
|
2082
|
-
"GET",
|
|
2083
|
-
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
2084
|
-
void 0,
|
|
2085
|
-
{ team: teamId, latest: count }
|
|
2086
|
-
);
|
|
2087
|
-
}
|
|
2088
|
-
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
2089
|
-
latest: count
|
|
2090
|
-
});
|
|
2091
|
-
}
|
|
2092
|
-
async listSessionFiles(teamId, sessionName) {
|
|
2093
|
-
const result = await this.request(
|
|
2094
|
-
"GET",
|
|
2095
|
-
this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
|
|
2096
|
-
void 0,
|
|
2097
|
-
{ team: teamId }
|
|
2098
|
-
);
|
|
2099
|
-
return result.files;
|
|
2100
|
-
}
|
|
2101
|
-
async getNotifications(params) {
|
|
2102
|
-
const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
|
|
2103
|
-
return this.request("GET", path, void 0, params);
|
|
2104
|
-
}
|
|
2105
|
-
async markRead(ids) {
|
|
2106
|
-
const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
|
|
2107
|
-
await this.request("POST", path, { notificationIds: ids });
|
|
2108
|
-
}
|
|
2109
|
-
/** List the caller's user-scoped API keys. Requires user-level auth. */
|
|
2110
|
-
async listUserApiKeys() {
|
|
2111
|
-
const body = await this.request("GET", this.userPath("/keys"));
|
|
2112
|
-
return body.keys;
|
|
2113
|
-
}
|
|
2114
|
-
/** Revoke a user-scoped API key by id. Requires user-level auth. */
|
|
2115
|
-
async revokeUserApiKey(id) {
|
|
2116
|
-
await this.request("DELETE", this.userPath(`/keys/${encodeURIComponent(id)}`));
|
|
2117
|
-
}
|
|
2118
|
-
/**
|
|
2119
|
-
* Create a live canvas session (specsketch --share host). User-scoped
|
|
2120
|
-
* route; the caller validates the response shape against
|
|
2121
|
-
* canvasSessionCreatedSchema.
|
|
2122
|
-
*/
|
|
2123
|
-
async createCanvasSession(input) {
|
|
2124
|
-
return this.request("POST", this.userPath("/canvas/sessions"), input);
|
|
2125
|
-
}
|
|
2126
|
-
/** End a canvas session server-side (invalidates the share link). */
|
|
2127
|
-
async endCanvasSession(sessionId) {
|
|
2128
|
-
await this.request("DELETE", this.userPath(`/canvas/sessions/${encodeURIComponent(sessionId)}`));
|
|
2129
|
-
}
|
|
2130
|
-
async uploadFile(teamId, session, filename, body, size) {
|
|
2131
|
-
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/upload`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/upload`;
|
|
2132
|
-
const url = new URL(path, this.apiUrl);
|
|
2133
|
-
if (this.userId) url.searchParams.set("team", teamId);
|
|
2134
|
-
const form = new FormData();
|
|
2135
|
-
const bytes = new Uint8Array(
|
|
2136
|
-
body instanceof Buffer || body instanceof Uint8Array ? body : await streamToBuffer(body)
|
|
2137
|
-
);
|
|
2138
|
-
form.append("file", new Blob([bytes]), filename);
|
|
2139
|
-
form.append("filename", filename);
|
|
2140
|
-
const res = await fetch(url.toString(), {
|
|
2141
|
-
method: "POST",
|
|
2142
|
-
headers: { Authorization: `Bearer ${this.token}` },
|
|
2143
|
-
body: form
|
|
2144
|
-
});
|
|
2145
|
-
if (!res.ok) {
|
|
2146
|
-
const text = await res.text();
|
|
2147
|
-
let msg = res.statusText;
|
|
2148
|
-
try {
|
|
2149
|
-
msg = JSON.parse(text).message ?? msg;
|
|
2150
|
-
} catch {
|
|
2151
|
-
}
|
|
2152
|
-
throw new CoopApiError(res.status, { category: "user_action", code: "UPLOAD_FAILED", message: msg });
|
|
2153
|
-
}
|
|
2154
|
-
return res.json();
|
|
2155
|
-
}
|
|
2156
|
-
async downloadFile(teamId, session, fileId) {
|
|
2157
|
-
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`;
|
|
2158
|
-
const url = new URL(path, this.apiUrl);
|
|
2159
|
-
if (this.userId) url.searchParams.set("team", teamId);
|
|
2160
|
-
const res = await fetch(url.toString(), {
|
|
2161
|
-
method: "GET",
|
|
2162
|
-
headers: { Authorization: `Bearer ${this.token}` }
|
|
2163
|
-
});
|
|
2164
|
-
if (!res.ok) {
|
|
2165
|
-
const text = await res.text();
|
|
2166
|
-
let msg = res.statusText;
|
|
2167
|
-
try {
|
|
2168
|
-
msg = JSON.parse(text).message ?? msg;
|
|
2169
|
-
} catch {
|
|
2170
|
-
}
|
|
2171
|
-
throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
|
|
2172
|
-
}
|
|
2173
|
-
const disposition = res.headers.get("Content-Disposition") ?? "";
|
|
2174
|
-
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
|
2175
|
-
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
|
|
2176
|
-
const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
|
|
2177
|
-
return { filename, body: res.body, size };
|
|
2178
|
-
}
|
|
2179
|
-
// ── Agent pool (specsesh-agent-pool) — user-scoped routes ──────────────
|
|
2180
|
-
/** Enroll this machine in its org's execute pool. Identity comes from the key. */
|
|
2181
|
-
async poolJoin() {
|
|
2182
|
-
return this.request("POST", this.userPath("/pool/join"), {});
|
|
2183
|
-
}
|
|
2184
|
-
/** Check in; may return one assigned work item in the same round-trip (FIFO). */
|
|
2185
|
-
async poolCheckIn() {
|
|
2186
|
-
return this.request("POST", this.userPath("/pool/check-in"), {});
|
|
2187
|
-
}
|
|
2188
|
-
/**
|
|
2189
|
-
* The org pool's roster. `specsesh dispatch` reads it to surface a live-agent
|
|
2190
|
-
* count so a dispatch into a pool with zero live agents is a visible no-signal
|
|
2191
|
-
* enqueue, not a silent success (specsesh-work-dispatch §4.4).
|
|
2192
|
-
*/
|
|
2193
|
-
async poolRoster() {
|
|
2194
|
-
const body = await this.request("GET", this.userPath("/pool/roster"));
|
|
2195
|
-
return body.agents;
|
|
2196
|
-
}
|
|
2197
|
-
/**
|
|
2198
|
-
* Report completion of an assigned work item. `assignmentSeq` is REQUIRED
|
|
2199
|
-
* (specsesh-task-lifecycle §5) — echo back the epoch from the `WorkItem`
|
|
2200
|
-
* this caller was handed at check-in, so the DO's ABA fence can reject a
|
|
2201
|
-
* stale completion from a since-superseded assignment.
|
|
2202
|
-
*/
|
|
2203
|
-
async poolComplete(workId, status, resultRef, assignmentSeq) {
|
|
2204
|
-
await this.request("POST", this.userPath("/pool/complete"), { workId, status, resultRef, assignmentSeq });
|
|
2205
|
-
}
|
|
2206
|
-
/**
|
|
2207
|
-
* Dispatch a task record into the org pool as one work item (idempotent on
|
|
2208
|
-
* the qualified `dedupKey`). CLI producer of specsesh-work-dispatch; returns
|
|
2209
|
-
* the (possibly already-existing) work id.
|
|
2210
|
-
*/
|
|
2211
|
-
async poolEnqueue(kind, source, dedupKey) {
|
|
2212
|
-
return this.request("POST", this.userPath("/pool/enqueue"), { kind, source, dedupKey });
|
|
2213
|
-
}
|
|
2214
|
-
/**
|
|
2215
|
-
* Org-scoped cancel (specsesh-task-lifecycle §2) — any authenticated member
|
|
2216
|
-
* of the org may cancel any work item in its pool, regardless of which
|
|
2217
|
-
* agent (if any) holds it. `queued` cancels directly; `assigned`/`running`
|
|
2218
|
-
* cancels and frees the holding agent (no redelivery); an already-terminal
|
|
2219
|
-
* item rejects.
|
|
2220
|
-
*/
|
|
2221
|
-
async poolCancel(workId) {
|
|
2222
|
-
await this.request("POST", this.userPath("/pool/cancel"), { workId });
|
|
2223
|
-
}
|
|
2224
|
-
/**
|
|
2225
|
-
* Query a single work item's current status (specsesh-task-lifecycle §4) —
|
|
2226
|
-
* tenant-scoped, independent of whether the caller is the agent (if any)
|
|
2227
|
-
* holding it. Throws `CoopApiError` (`POOL_WORK_NOT_FOUND`, 404) if the
|
|
2228
|
-
* item does not exist in this organization's pool.
|
|
2229
|
-
*/
|
|
2230
|
-
async poolWork(workId) {
|
|
2231
|
-
const body = await this.request(
|
|
2232
|
-
"GET",
|
|
2233
|
-
this.userPath(`/pool/work/${encodeURIComponent(workId)}`)
|
|
2234
|
-
);
|
|
2235
|
-
return body.work;
|
|
2236
|
-
}
|
|
2237
|
-
/**
|
|
2238
|
-
* List every work item currently in the organization's pool, regardless of
|
|
2239
|
-
* status (specsesh-task-lifecycle §4).
|
|
2240
|
-
*/
|
|
2241
|
-
async poolWorkList() {
|
|
2242
|
-
const body = await this.request("GET", this.userPath("/pool/work"));
|
|
2243
|
-
return body.items;
|
|
2244
|
-
}
|
|
2245
|
-
getWebSocketUrl(session) {
|
|
2246
|
-
const wsBase = this.apiUrl.replace(
|
|
2247
|
-
/^https?:\/\//,
|
|
2248
|
-
(match) => match === "https://" ? "wss://" : "ws://"
|
|
2249
|
-
);
|
|
2250
|
-
return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
|
|
2251
|
-
}
|
|
2252
|
-
withToken(token) {
|
|
2253
|
-
return new _ApiClient(this.apiUrl, token, this.userId);
|
|
2254
|
-
}
|
|
2255
|
-
};
|
|
2256
|
-
|
|
2257
|
-
// ../shared/src/canvas-protocol.ts
|
|
2258
|
-
import { z as z4 } from "zod";
|
|
2259
|
-
var CANVAS_PROTOCOL_VERSION = 1;
|
|
2260
|
-
var canvasElementSchema = z4.object({
|
|
2261
|
-
id: z4.string().min(1),
|
|
2262
|
-
version: z4.number().int().nonnegative(),
|
|
2263
|
-
versionNonce: z4.number().int(),
|
|
2264
|
-
isDeleted: z4.boolean().optional()
|
|
2265
|
-
}).passthrough();
|
|
2266
|
-
var canvasFilesSchema = z4.record(z4.string(), z4.unknown());
|
|
2267
|
-
var canvasSceneSchema = z4.object({
|
|
2268
|
-
elements: z4.array(canvasElementSchema),
|
|
2269
|
-
files: canvasFilesSchema.optional()
|
|
2270
|
-
});
|
|
2271
|
-
var canvasParticipantSchema = z4.object({
|
|
2272
|
-
id: z4.string().min(1),
|
|
2273
|
-
role: z4.enum(["host", "guest"]),
|
|
2274
|
-
name: z4.string().optional(),
|
|
2275
|
-
color: z4.string().optional()
|
|
2276
|
-
});
|
|
2277
|
-
var canvasHelloSchema = z4.object({
|
|
2278
|
-
type: z4.literal("hello"),
|
|
2279
|
-
protocolVersion: z4.number().int().positive(),
|
|
2280
|
-
role: z4.enum(["host", "guest"]),
|
|
2281
|
-
/** Stable id across reconnects, if the client has one. */
|
|
2282
|
-
participantId: z4.string().min(1).max(64).optional(),
|
|
2283
|
-
name: z4.string().max(120).optional()
|
|
2284
|
-
});
|
|
2285
|
-
var canvasClientOpSchema = z4.object({
|
|
2286
|
-
type: z4.literal("op"),
|
|
2287
|
-
/** Changed or soft-deleted elements only — never the full scene. */
|
|
2288
|
-
elements: z4.array(canvasElementSchema).min(1),
|
|
2289
|
-
/** New binary files referenced by the changed elements, if any. */
|
|
2290
|
-
files: canvasFilesSchema.optional(),
|
|
2291
|
-
/** Client-local monotonic sequence, used by the sender for echo dedupe. */
|
|
2292
|
-
clientSeq: z4.number().int().nonnegative()
|
|
2293
|
-
});
|
|
2294
|
-
var canvasClientMessageSchema = z4.discriminatedUnion("type", [
|
|
2295
|
-
canvasHelloSchema,
|
|
2296
|
-
canvasClientOpSchema
|
|
2297
|
-
]);
|
|
2298
|
-
var canvasSnapshotSchema = z4.object({
|
|
2299
|
-
type: z4.literal("snapshot"),
|
|
2300
|
-
scene: canvasSceneSchema,
|
|
2301
|
-
revision: z4.number().int().nonnegative(),
|
|
2302
|
-
participants: z4.array(canvasParticipantSchema),
|
|
2303
|
-
/** Echoes the server's protocol version so clients can detect skew. */
|
|
2304
|
-
protocolVersion: z4.number().int().positive(),
|
|
2305
|
-
/** The participant id the server assigned to (or kept for) this client. */
|
|
2306
|
-
participantId: z4.string()
|
|
2307
|
-
});
|
|
2308
|
-
var canvasServerOpSchema = z4.object({
|
|
2309
|
-
type: z4.literal("op"),
|
|
2310
|
-
/** The elements that won reconciliation (never echoed to the sender). */
|
|
2311
|
-
elements: z4.array(canvasElementSchema),
|
|
2312
|
-
files: canvasFilesSchema.optional(),
|
|
2313
|
-
revision: z4.number().int().positive(),
|
|
2314
|
-
/** Participant id of the sender. */
|
|
2315
|
-
from: z4.string()
|
|
2316
|
-
});
|
|
2317
|
-
var canvasPresenceSchema = z4.object({
|
|
2318
|
-
type: z4.literal("presence"),
|
|
2319
|
-
participants: z4.array(canvasParticipantSchema)
|
|
2320
|
-
});
|
|
2321
|
-
var canvasEndSchema = z4.object({
|
|
2322
|
-
type: z4.literal("end"),
|
|
2323
|
-
reason: z4.string()
|
|
2324
|
-
});
|
|
2325
|
-
var canvasErrorSchema = z4.object({
|
|
2326
|
-
type: z4.literal("error"),
|
|
2327
|
-
code: z4.string(),
|
|
2328
|
-
message: z4.string()
|
|
2329
|
-
});
|
|
2330
|
-
var canvasServerMessageSchema = z4.discriminatedUnion("type", [
|
|
2331
|
-
canvasSnapshotSchema,
|
|
2332
|
-
canvasServerOpSchema,
|
|
2333
|
-
canvasPresenceSchema,
|
|
2334
|
-
canvasEndSchema,
|
|
2335
|
-
canvasErrorSchema
|
|
2336
|
-
]);
|
|
2337
|
-
var canvasSessionCreatedSchema = z4.object({
|
|
2338
|
-
sessionId: z4.string().min(1),
|
|
2339
|
-
/** Capability token — shown once; the server stores only its hash. */
|
|
2340
|
-
token: z4.string().min(1),
|
|
2341
|
-
shareUrl: z4.string().min(1),
|
|
2342
|
-
wsUrl: z4.string().min(1)
|
|
2343
|
-
});
|
|
2344
|
-
var canvasSessionSummarySchema = z4.object({
|
|
2345
|
-
sessionId: z4.string(),
|
|
2346
|
-
title: z4.string().nullable(),
|
|
2347
|
-
status: z4.enum(["active", "ended"]),
|
|
2348
|
-
createdAt: z4.number(),
|
|
2349
|
-
endedAt: z4.number().nullable()
|
|
2350
|
-
});
|
|
2351
|
-
function elementWins(local, incoming) {
|
|
2352
|
-
if (incoming.version !== local.version) return incoming.version > local.version;
|
|
2353
|
-
return incoming.versionNonce < local.versionNonce;
|
|
2354
|
-
}
|
|
2355
|
-
function reconcileCanvasElements(local, incoming) {
|
|
2356
|
-
const byId = new Map(local.map((el) => [el.id, el]));
|
|
2357
|
-
const accepted = [];
|
|
2358
|
-
for (const el of incoming) {
|
|
2359
|
-
const existing = byId.get(el.id);
|
|
2360
|
-
if (!existing || elementWins(existing, el)) {
|
|
2361
|
-
byId.set(el.id, el);
|
|
2362
|
-
accepted.push(el);
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
return { elements: [...byId.values()], accepted };
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
// server/remote-session.ts
|
|
2369
|
-
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
2370
|
-
var HEARTBEAT_TIMEOUT_MS = 75e3;
|
|
2371
|
-
var BACKOFF_MIN_MS = 1e3;
|
|
2372
|
-
var BACKOFF_MAX_MS = 3e4;
|
|
2373
|
-
var PENDING_OP_LIMIT = 200;
|
|
2374
|
-
function asCanvasElements(elements) {
|
|
2375
|
-
if (!elements) return [];
|
|
2376
|
-
return elements.filter(
|
|
2377
|
-
(el) => typeof el === "object" && el !== null && typeof el.id === "string" && typeof el.version === "number" && typeof el.versionNonce === "number"
|
|
2378
|
-
);
|
|
2379
|
-
}
|
|
2380
|
-
var RemoteSession = class _RemoteSession {
|
|
2381
|
-
sessionId;
|
|
2382
|
-
shareUrl;
|
|
2383
|
-
token;
|
|
2384
|
-
wsUrl;
|
|
2385
|
-
opts;
|
|
2386
|
-
status = "connecting";
|
|
2387
|
-
revision = 0;
|
|
2388
|
-
participants = [];
|
|
2389
|
-
ws = null;
|
|
2390
|
-
closed = false;
|
|
2391
|
-
clientSeq = 0;
|
|
2392
|
-
backoffMs;
|
|
2393
|
-
lastTrafficAt = 0;
|
|
2394
|
-
heartbeatTimer = null;
|
|
2395
|
-
reconnectTimer = null;
|
|
2396
|
-
pendingOps = [];
|
|
2397
|
-
api;
|
|
2398
|
-
constructor(opts, api, created) {
|
|
2399
|
-
this.opts = opts;
|
|
2400
|
-
this.api = api;
|
|
2401
|
-
this.sessionId = created.sessionId;
|
|
2402
|
-
this.token = created.token;
|
|
2403
|
-
this.shareUrl = created.shareUrl;
|
|
2404
|
-
this.wsUrl = created.wsUrl;
|
|
2405
|
-
this.backoffMs = opts.backoffMinMs ?? BACKOFF_MIN_MS;
|
|
2406
|
-
}
|
|
2407
|
-
/** Create the session server-side and open the host connection. */
|
|
2408
|
-
static async start(opts) {
|
|
2409
|
-
const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
|
|
2410
|
-
let response;
|
|
2411
|
-
try {
|
|
2412
|
-
response = await api.createCanvasSession({ title: opts.title });
|
|
2413
|
-
} catch (err) {
|
|
2414
|
-
if (isCoopApiError(err)) {
|
|
2415
|
-
if (err.status === 401 || err.status === 403) {
|
|
2416
|
-
throw new Error(
|
|
2417
|
-
"CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
|
|
2418
|
-
);
|
|
2419
|
-
}
|
|
2420
|
-
throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
|
|
2421
|
-
}
|
|
2422
|
-
throw err;
|
|
2423
|
-
}
|
|
2424
|
-
const created = canvasSessionCreatedSchema.parse(response);
|
|
2425
|
-
const session = new _RemoteSession(opts, api, created);
|
|
2426
|
-
session.connect();
|
|
2427
|
-
return session;
|
|
2428
|
-
}
|
|
2429
|
-
// ── Upstream connection ──
|
|
2430
|
-
connect() {
|
|
2431
|
-
if (this.closed) return;
|
|
2432
|
-
const factory = this.opts.wsFactory ?? ((url, headers) => new WebSocket(url, { headers }));
|
|
2433
|
-
const ws = factory(this.wsUrl, { Authorization: `Bearer ${this.opts.key}` });
|
|
2434
|
-
this.ws = ws;
|
|
2435
|
-
this.lastTrafficAt = Date.now();
|
|
2436
|
-
ws.on("open", () => {
|
|
2437
|
-
this.backoffMs = this.opts.backoffMinMs ?? BACKOFF_MIN_MS;
|
|
2438
|
-
this.status = "connected";
|
|
2439
|
-
ws.send(
|
|
2440
|
-
JSON.stringify({
|
|
2441
|
-
type: "hello",
|
|
2442
|
-
protocolVersion: CANVAS_PROTOCOL_VERSION,
|
|
2443
|
-
role: "host",
|
|
2444
|
-
name: this.opts.hostName ?? void 0
|
|
2445
|
-
})
|
|
2446
|
-
);
|
|
2447
|
-
for (const frame of this.pendingOps.splice(0)) ws.send(frame);
|
|
2448
|
-
});
|
|
2449
|
-
ws.on("message", (data) => this.handleUpstreamFrame(String(data)));
|
|
2450
|
-
ws.on("close", (code) => {
|
|
2451
|
-
if (ws !== this.ws) return;
|
|
2452
|
-
this.stopHeartbeat();
|
|
2453
|
-
if (this.closed) return;
|
|
2454
|
-
this.status = "reconnecting";
|
|
2455
|
-
this.opts.log?.(`share: connection lost (code ${code}) \u2014 reconnecting`);
|
|
2456
|
-
this.scheduleReconnect();
|
|
2457
|
-
});
|
|
2458
|
-
ws.on("error", () => {
|
|
2459
|
-
});
|
|
2460
|
-
this.startHeartbeat();
|
|
2461
|
-
}
|
|
2462
|
-
scheduleReconnect() {
|
|
2463
|
-
if (this.closed || this.reconnectTimer) return;
|
|
2464
|
-
const jitter = Math.random() * 0.3 + 0.85;
|
|
2465
|
-
const delay = Math.round(this.backoffMs * jitter);
|
|
2466
|
-
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
2467
|
-
this.reconnectTimer = setTimeout(() => {
|
|
2468
|
-
this.reconnectTimer = null;
|
|
2469
|
-
this.connect();
|
|
2470
|
-
}, delay);
|
|
2471
|
-
}
|
|
2472
|
-
startHeartbeat() {
|
|
2473
|
-
this.stopHeartbeat();
|
|
2474
|
-
this.heartbeatTimer = setInterval(() => {
|
|
2475
|
-
const ws = this.ws;
|
|
2476
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
2477
|
-
if (Date.now() - this.lastTrafficAt > HEARTBEAT_TIMEOUT_MS) {
|
|
2478
|
-
ws.terminate();
|
|
2479
|
-
return;
|
|
2480
|
-
}
|
|
2481
|
-
ws.send("ping");
|
|
2482
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
2483
|
-
this.heartbeatTimer.unref?.();
|
|
2484
|
-
}
|
|
2485
|
-
stopHeartbeat() {
|
|
2486
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
2487
|
-
this.heartbeatTimer = null;
|
|
2488
|
-
}
|
|
2489
|
-
// ── Frames from the DO ──
|
|
2490
|
-
handleUpstreamFrame(text) {
|
|
2491
|
-
this.lastTrafficAt = Date.now();
|
|
2492
|
-
if (text === "pong") return;
|
|
2493
|
-
const parsed = canvasServerMessageSchema.safeParse(
|
|
2494
|
-
(() => {
|
|
2495
|
-
try {
|
|
2496
|
-
return JSON.parse(text);
|
|
2497
|
-
} catch {
|
|
2498
|
-
return null;
|
|
2499
|
-
}
|
|
2500
|
-
})()
|
|
2501
|
-
);
|
|
2502
|
-
if (!parsed.success) return;
|
|
2503
|
-
const frame = parsed.data;
|
|
2504
|
-
switch (frame.type) {
|
|
2505
|
-
case "snapshot": {
|
|
2506
|
-
const local = this.opts.workspace.loadScene();
|
|
2507
|
-
const { elements, accepted } = reconcileCanvasElements(
|
|
2508
|
-
asCanvasElements(frame.scene.elements),
|
|
2509
|
-
asCanvasElements(local?.elements)
|
|
2510
|
-
);
|
|
2511
|
-
this.revision = frame.revision;
|
|
2512
|
-
this.participants = frame.participants;
|
|
2513
|
-
this.opts.workspace.saveScene({
|
|
2514
|
-
elements,
|
|
2515
|
-
appState: local?.appState,
|
|
2516
|
-
files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
|
|
2517
|
-
});
|
|
2518
|
-
if (accepted.length > 0) {
|
|
2519
|
-
this.sendUpstream(
|
|
2520
|
-
JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
|
|
2521
|
-
);
|
|
2522
|
-
}
|
|
2523
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2524
|
-
return;
|
|
2525
|
-
}
|
|
2526
|
-
case "op": {
|
|
2527
|
-
if (frame.revision > this.revision + 1) {
|
|
2528
|
-
this.ws?.terminate();
|
|
2529
|
-
return;
|
|
2530
|
-
}
|
|
2531
|
-
this.revision = frame.revision;
|
|
2532
|
-
this.applyRemoteElements(frame.elements, frame.files);
|
|
2533
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2534
|
-
return;
|
|
2535
|
-
}
|
|
2536
|
-
case "presence": {
|
|
2537
|
-
this.participants = frame.participants;
|
|
2538
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2539
|
-
return;
|
|
2540
|
-
}
|
|
2541
|
-
case "end": {
|
|
2542
|
-
this.opts.log?.(`share: session ended (${frame.reason})`);
|
|
2543
|
-
this.opts.onLocalBroadcast?.(text);
|
|
2544
|
-
this.closed = true;
|
|
2545
|
-
this.status = "ended";
|
|
2546
|
-
this.teardown();
|
|
2547
|
-
return;
|
|
2548
|
-
}
|
|
2549
|
-
case "error": {
|
|
2550
|
-
this.opts.log?.(`share: server error ${frame.code}: ${frame.message}`);
|
|
2551
|
-
return;
|
|
2552
|
-
}
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
|
-
/** Persist remote edits into the change directory — the durable copy. */
|
|
2556
|
-
applyRemoteElements(incoming, files) {
|
|
2557
|
-
const local = this.opts.workspace.loadScene();
|
|
2558
|
-
const { elements } = reconcileCanvasElements(asCanvasElements(local?.elements), incoming);
|
|
2559
|
-
this.opts.workspace.saveScene({
|
|
2560
|
-
elements,
|
|
2561
|
-
appState: local?.appState,
|
|
2562
|
-
files: { ...local?.files ?? {}, ...files ?? {} }
|
|
2563
|
-
});
|
|
2564
|
-
}
|
|
2565
|
-
// ── Frames from the host browser (via /ws/local) ──
|
|
2566
|
-
/**
|
|
2567
|
-
* Handle one message from a local browser socket. Ops are relayed upstream
|
|
2568
|
-
* verbatim (the browser already persists via its autosave PUT). A `hello`
|
|
2569
|
-
* is answered locally with a synthetic snapshot so the browser can seed
|
|
2570
|
-
* its revision/participant state without a round-trip to the DO.
|
|
2571
|
-
*/
|
|
2572
|
-
handleLocalFrame(text, reply) {
|
|
2573
|
-
const parsed = canvasClientMessageSchema.safeParse(
|
|
2574
|
-
(() => {
|
|
2575
|
-
try {
|
|
2576
|
-
return JSON.parse(text);
|
|
2577
|
-
} catch {
|
|
2578
|
-
return null;
|
|
2579
|
-
}
|
|
2580
|
-
})()
|
|
2581
|
-
);
|
|
2582
|
-
if (!parsed.success) return;
|
|
2583
|
-
if (parsed.data.type === "hello") {
|
|
2584
|
-
const scene = this.opts.workspace.loadScene();
|
|
2585
|
-
reply(
|
|
2586
|
-
JSON.stringify({
|
|
2587
|
-
type: "snapshot",
|
|
2588
|
-
scene: { elements: scene?.elements ?? [], files: scene?.files ?? void 0 },
|
|
2589
|
-
revision: this.revision,
|
|
2590
|
-
participants: this.participants,
|
|
2591
|
-
protocolVersion: CANVAS_PROTOCOL_VERSION,
|
|
2592
|
-
participantId: "host"
|
|
2593
|
-
})
|
|
2594
|
-
);
|
|
2595
|
-
return;
|
|
2596
|
-
}
|
|
2597
|
-
this.sendUpstream(text);
|
|
2598
|
-
}
|
|
2599
|
-
sendUpstream(frameText) {
|
|
2600
|
-
const ws = this.ws;
|
|
2601
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2602
|
-
ws.send(frameText);
|
|
2603
|
-
return;
|
|
2604
|
-
}
|
|
2605
|
-
this.pendingOps.push(frameText);
|
|
2606
|
-
if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
|
|
2607
|
-
}
|
|
2608
|
-
// ── Teardown ──
|
|
2609
|
-
/** End the session server-side (invalidates the share link) and close. */
|
|
2610
|
-
async end() {
|
|
2611
|
-
if (this.status === "ended") return;
|
|
2612
|
-
this.closed = true;
|
|
2613
|
-
this.status = "ended";
|
|
2614
|
-
this.teardown();
|
|
2615
|
-
try {
|
|
2616
|
-
await this.api.endCanvasSession(this.sessionId);
|
|
2617
|
-
} catch {
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
teardown() {
|
|
2621
|
-
this.stopHeartbeat();
|
|
2622
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
2623
|
-
this.reconnectTimer = null;
|
|
2624
|
-
try {
|
|
2625
|
-
this.ws?.close(1e3, "host shutting down");
|
|
2626
|
-
} catch {
|
|
2627
|
-
}
|
|
2628
|
-
this.ws = null;
|
|
2629
|
-
}
|
|
2630
|
-
};
|
|
2631
|
-
|
|
2632
1823
|
// server/workspace.ts
|
|
2633
1824
|
import { createHash as createHash2 } from "node:crypto";
|
|
2634
1825
|
import {
|
|
2635
|
-
existsSync as
|
|
1826
|
+
existsSync as existsSync4,
|
|
2636
1827
|
mkdirSync as mkdirSync2,
|
|
2637
1828
|
readFileSync as readFileSync3,
|
|
2638
1829
|
readdirSync as readdirSync4,
|
|
2639
1830
|
renameSync as renameSync2,
|
|
2640
1831
|
writeFileSync as writeFileSync2
|
|
2641
1832
|
} from "node:fs";
|
|
2642
|
-
import { basename as basename2, dirname as dirname3, join as
|
|
1833
|
+
import { basename as basename2, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
|
|
2643
1834
|
|
|
2644
1835
|
// ../shared/src/openspec-change-meta.ts
|
|
2645
1836
|
import { createHash } from "node:crypto";
|
|
2646
|
-
import { existsSync as
|
|
2647
|
-
import { join as
|
|
1837
|
+
import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1838
|
+
import { join as join4 } from "node:path";
|
|
2648
1839
|
import { Document, parseDocument } from "yaml";
|
|
2649
|
-
import { z as
|
|
1840
|
+
import { z as z4 } from "zod";
|
|
2650
1841
|
|
|
2651
1842
|
// ../shared/src/fs-atomic.ts
|
|
2652
|
-
import { existsSync as
|
|
1843
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
2653
1844
|
import { dirname as dirname2 } from "node:path";
|
|
2654
1845
|
function writeAtomicFile(target, content) {
|
|
2655
1846
|
mkdirSync(dirname2(target), { recursive: true });
|
|
@@ -2658,7 +1849,7 @@ function writeAtomicFile(target, content) {
|
|
|
2658
1849
|
renameSync(tmp, target);
|
|
2659
1850
|
}
|
|
2660
1851
|
function readIfExists(target) {
|
|
2661
|
-
return
|
|
1852
|
+
return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
|
|
2662
1853
|
}
|
|
2663
1854
|
|
|
2664
1855
|
// ../shared/src/openspec-change-meta.ts
|
|
@@ -2671,10 +1862,10 @@ var nodeChangeMetaFs = {
|
|
|
2671
1862
|
writeAtomicFile(path, content);
|
|
2672
1863
|
},
|
|
2673
1864
|
exists(path) {
|
|
2674
|
-
return
|
|
1865
|
+
return existsSync3(path);
|
|
2675
1866
|
},
|
|
2676
1867
|
readdir(path) {
|
|
2677
|
-
if (!
|
|
1868
|
+
if (!existsSync3(path)) return [];
|
|
2678
1869
|
return readdirSync3(path, { withFileTypes: true }).map((entry) => ({
|
|
2679
1870
|
name: entry.name,
|
|
2680
1871
|
directory: entry.isDirectory()
|
|
@@ -2688,12 +1879,12 @@ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
2688
1879
|
function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
|
|
2689
1880
|
const files = [];
|
|
2690
1881
|
for (const name of ["proposal.md", "design.md", "tasks.md"]) {
|
|
2691
|
-
if (fsx.exists(
|
|
1882
|
+
if (fsx.exists(join4(changeDir, name))) files.push(name);
|
|
2692
1883
|
}
|
|
2693
|
-
const specsDir =
|
|
1884
|
+
const specsDir = join4(changeDir, "specs");
|
|
2694
1885
|
for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2695
|
-
const rel =
|
|
2696
|
-
if (entry.directory && fsx.exists(
|
|
1886
|
+
const rel = join4("specs", entry.name, "spec.md");
|
|
1887
|
+
if (entry.directory && fsx.exists(join4(changeDir, rel))) files.push(rel);
|
|
2697
1888
|
}
|
|
2698
1889
|
return files;
|
|
2699
1890
|
}
|
|
@@ -2702,24 +1893,24 @@ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
|
|
|
2702
1893
|
if (files.length === 0) return null;
|
|
2703
1894
|
const parts = files.map(
|
|
2704
1895
|
(rel) => `${rel}
|
|
2705
|
-
${sha256(fsx.readIfExists(
|
|
1896
|
+
${sha256(fsx.readIfExists(join4(changeDir, rel)) ?? "")}`
|
|
2706
1897
|
);
|
|
2707
1898
|
return `sha256:${sha256(parts.join("\n"))}`;
|
|
2708
1899
|
}
|
|
2709
1900
|
var SPEC_STATUSES = ["draft", "in-progress"];
|
|
2710
|
-
var SpecStatus =
|
|
1901
|
+
var SpecStatus = z4.enum(SPEC_STATUSES);
|
|
2711
1902
|
var LEGACY_STATUS_COERCION = {
|
|
2712
1903
|
active: "in-progress",
|
|
2713
1904
|
done: "in-progress",
|
|
2714
1905
|
deferred: "draft"
|
|
2715
1906
|
};
|
|
2716
|
-
var StoredSpecStatus =
|
|
1907
|
+
var StoredSpecStatus = z4.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
|
|
2717
1908
|
(s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
|
|
2718
1909
|
);
|
|
2719
|
-
var SpecMetaSchema =
|
|
2720
|
-
specId:
|
|
2721
|
-
userStories:
|
|
2722
|
-
dependencies:
|
|
1910
|
+
var SpecMetaSchema = z4.object({
|
|
1911
|
+
specId: z4.string(),
|
|
1912
|
+
userStories: z4.array(z4.string()),
|
|
1913
|
+
dependencies: z4.array(z4.string()),
|
|
2723
1914
|
/** Working state (retired values coerce on read); absent = draft. */
|
|
2724
1915
|
status: StoredSpecStatus.optional(),
|
|
2725
1916
|
/**
|
|
@@ -2728,18 +1919,18 @@ var SpecMetaSchema = z5.object({
|
|
|
2728
1919
|
* into specs/ and archived (apply-first: the fold happens at approval).
|
|
2729
1920
|
* Absent = not-approved.
|
|
2730
1921
|
*/
|
|
2731
|
-
approved:
|
|
1922
|
+
approved: z4.boolean().optional(),
|
|
2732
1923
|
/**
|
|
2733
1924
|
* Legacy spelling of the fold axis (pre-rename metadata files). Read-only
|
|
2734
1925
|
* compatibility: readers treat `applied: true` as `approved: true`; no
|
|
2735
1926
|
* writer ever sets this field again.
|
|
2736
1927
|
*/
|
|
2737
|
-
applied:
|
|
1928
|
+
applied: z4.boolean().optional(),
|
|
2738
1929
|
/**
|
|
2739
1930
|
* Build flag — user-set, independent of the fold: true once the code has
|
|
2740
1931
|
* been built to match the approved diff. Absent = not-implemented.
|
|
2741
1932
|
*/
|
|
2742
|
-
implemented:
|
|
1933
|
+
implemented: z4.boolean().optional(),
|
|
2743
1934
|
/**
|
|
2744
1935
|
* Minimal implementation-task record written on fold (specplan-timeline-apply
|
|
2745
1936
|
* "Implementation task record on fold"): the creation time and a reference to
|
|
@@ -2748,18 +1939,18 @@ var SpecMetaSchema = z5.object({
|
|
|
2748
1939
|
* state — task execution/dispatch is a separate, not-yet-defined concern. A
|
|
2749
1940
|
* field addition to this sidecar, not a redefinition of its format.
|
|
2750
1941
|
*/
|
|
2751
|
-
task:
|
|
2752
|
-
createdAt:
|
|
2753
|
-
source:
|
|
1942
|
+
task: z4.object({
|
|
1943
|
+
createdAt: z4.string(),
|
|
1944
|
+
source: z4.string()
|
|
2754
1945
|
}).optional(),
|
|
2755
|
-
generation:
|
|
1946
|
+
generation: z4.object({
|
|
2756
1947
|
/** sha256 of the last generated body files — detects hand edits. */
|
|
2757
|
-
lastGeneratedBodyHash:
|
|
2758
|
-
lastGeneratedAt:
|
|
1948
|
+
lastGeneratedBodyHash: z4.string(),
|
|
1949
|
+
lastGeneratedAt: z4.string()
|
|
2759
1950
|
}).optional()
|
|
2760
1951
|
});
|
|
2761
1952
|
function writeChangeMeta(fsx, changeDir, meta) {
|
|
2762
|
-
const path =
|
|
1953
|
+
const path = join4(changeDir, CHANGE_META_FILE);
|
|
2763
1954
|
const raw = fsx.readIfExists(path);
|
|
2764
1955
|
const doc = raw === void 0 ? new Document({
|
|
2765
1956
|
specId: meta.specId,
|
|
@@ -2787,21 +1978,30 @@ var sha2562 = (s) => createHash2("sha256").update(s).digest("hex");
|
|
|
2787
1978
|
var ChangeWorkspace = class {
|
|
2788
1979
|
dir;
|
|
2789
1980
|
changeName;
|
|
2790
|
-
|
|
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 = {}) {
|
|
2791
1991
|
this.dir = resolve2(dir);
|
|
2792
1992
|
this.changeName = basename2(this.dir);
|
|
2793
|
-
mkdirSync2(this.dir, { recursive: true });
|
|
1993
|
+
if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
|
|
2794
1994
|
}
|
|
2795
1995
|
writeAtomic(relPath, content) {
|
|
2796
|
-
const target =
|
|
1996
|
+
const target = join5(this.dir, relPath);
|
|
2797
1997
|
mkdirSync2(dirname3(target), { recursive: true });
|
|
2798
1998
|
const tmp = `${target}.tmp`;
|
|
2799
1999
|
writeFileSync2(tmp, content, "utf8");
|
|
2800
2000
|
renameSync2(tmp, target);
|
|
2801
2001
|
}
|
|
2802
2002
|
readIfExists(relPath) {
|
|
2803
|
-
const target =
|
|
2804
|
-
return
|
|
2003
|
+
const target = join5(this.dir, relPath);
|
|
2004
|
+
return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
|
|
2805
2005
|
}
|
|
2806
2006
|
// ── Scene ────────────────────────────────────────────────────────────────
|
|
2807
2007
|
loadScene() {
|
|
@@ -2862,11 +2062,11 @@ var ChangeWorkspace = class {
|
|
|
2862
2062
|
/** Current artifact files, however they were authored. */
|
|
2863
2063
|
readArtifacts() {
|
|
2864
2064
|
const specs = [];
|
|
2865
|
-
const specsDir =
|
|
2866
|
-
if (
|
|
2065
|
+
const specsDir = join5(this.dir, "specs");
|
|
2066
|
+
if (existsSync4(specsDir)) {
|
|
2867
2067
|
for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
|
|
2868
2068
|
if (!entry.isDirectory()) continue;
|
|
2869
|
-
const spec = this.readIfExists(
|
|
2069
|
+
const spec = this.readIfExists(join5("specs", entry.name, "spec.md"));
|
|
2870
2070
|
if (spec !== void 0) specs.push({ capability: entry.name, spec });
|
|
2871
2071
|
}
|
|
2872
2072
|
}
|
|
@@ -2900,7 +2100,7 @@ var ChangeWorkspace = class {
|
|
|
2900
2100
|
"design.md": bundle.design,
|
|
2901
2101
|
"tasks.md": bundle.tasks
|
|
2902
2102
|
};
|
|
2903
|
-
for (const s of bundle.specs) files[
|
|
2103
|
+
for (const s of bundle.specs) files[join5("specs", s.capability, "spec.md")] = s.spec;
|
|
2904
2104
|
const written = [];
|
|
2905
2105
|
const manifest = {};
|
|
2906
2106
|
for (const [relPath, content] of Object.entries(files)) {
|
|
@@ -2949,49 +2149,1921 @@ var ChangeWorkspace = class {
|
|
|
2949
2149
|
}
|
|
2950
2150
|
};
|
|
2951
2151
|
|
|
2952
|
-
// server/
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
specsketch ./openspec/changes/new-feature-change
|
|
2957
|
-
|
|
2958
|
-
The directory is created if it doesn't exist. Diagrams autosave to
|
|
2959
|
-
diagram.excalidraw inside it; Generate Spec writes proposal.md, design.md,
|
|
2960
|
-
tasks.md and specs/<capability>/spec.md alongside.
|
|
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";
|
|
2961
2156
|
|
|
2962
|
-
|
|
2963
|
-
|
|
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";
|
|
2964
2160
|
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
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
|
+
}
|
|
2969
2281
|
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
var
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
parsed = parseArgs2({
|
|
2978
|
-
args: argv,
|
|
2979
|
-
options: { port: { type: "string" }, share: { type: "boolean" } },
|
|
2980
|
-
allowPositionals: true
|
|
2981
|
-
});
|
|
2982
|
-
} catch (err) {
|
|
2983
|
-
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
2984
|
-
}
|
|
2985
|
-
const [dir, ...rest] = parsed.positionals;
|
|
2986
|
-
if (!dir || rest.length > 0) throw new UsageError("expected exactly one change directory");
|
|
2987
|
-
const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
|
|
2988
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2989
|
-
throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
|
|
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\``;
|
|
2990
2289
|
}
|
|
2991
|
-
return
|
|
2290
|
+
return null;
|
|
2992
2291
|
}
|
|
2993
|
-
|
|
2994
|
-
|
|
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 }
|
|
2350
|
+
]);
|
|
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"
|
|
2365
|
+
});
|
|
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;
|
|
2700
|
+
}
|
|
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;
|
|
2728
|
+
}
|
|
2729
|
+
fence.lines.push(p);
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
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]
|
|
2805
|
+
});
|
|
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;
|
|
2881
|
+
}
|
|
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;
|
|
2890
|
+
}
|
|
2891
|
+
const to = RENAME_TO.exec(p);
|
|
2892
|
+
if (to && pendingFrom !== null) {
|
|
2893
|
+
renamed.push({ from: pendingFrom, to: (to[1] ?? "").trim() });
|
|
2894
|
+
pendingFrom = null;
|
|
2895
|
+
}
|
|
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.`
|
|
2960
|
+
}
|
|
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.`
|
|
2970
|
+
}
|
|
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."
|
|
2983
|
+
}
|
|
2984
|
+
];
|
|
2985
|
+
}
|
|
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."
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
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.`
|
|
3065
|
+
}
|
|
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
|
+
});
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
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
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
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
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
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;
|
|
3317
|
+
try {
|
|
3318
|
+
onDisk = readFileSync5(target, "utf8");
|
|
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
|
+
};
|
|
3549
|
+
}
|
|
3550
|
+
blocks.delete(pair.from);
|
|
3551
|
+
blocks.set(pair.to, existing.replace(pair.from, pair.to));
|
|
3552
|
+
order[order.indexOf(pair.from)] = pair.to;
|
|
3553
|
+
}
|
|
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);
|
|
3564
|
+
}
|
|
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());
|
|
3576
|
+
}
|
|
3577
|
+
for (const block of delta.added) {
|
|
3578
|
+
const name = headingName(block);
|
|
3579
|
+
if (name === null) {
|
|
3580
|
+
return {
|
|
3581
|
+
outcome: "unreconstructable",
|
|
3582
|
+
capability,
|
|
3583
|
+
reason: "the delta adds a block with no readable heading",
|
|
3584
|
+
draft: folded
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
if (!blocks.has(name)) order.push(name);
|
|
3588
|
+
blocks.set(name, block.trimEnd());
|
|
3589
|
+
}
|
|
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;
|
|
3616
|
+
}
|
|
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;
|
|
3648
|
+
try {
|
|
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
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
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
|
+
})
|
|
3732
|
+
}
|
|
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
|
+
};
|
|
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 } };
|
|
3764
|
+
}
|
|
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}\`` };
|
|
3808
|
+
}
|
|
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.`;
|
|
3822
|
+
}
|
|
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)`);
|
|
3838
|
+
}
|
|
3839
|
+
return { read, changeText };
|
|
3840
|
+
}
|
|
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`);
|
|
3859
|
+
}
|
|
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;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
function resolveTarget(cwd, changeId) {
|
|
3874
|
+
if (changeId === void 0 || changeId === "") {
|
|
3875
|
+
throw new WorkbenchHostError("a change id is required: specsketch <change-id>");
|
|
3876
|
+
}
|
|
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
|
+
);
|
|
3881
|
+
}
|
|
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
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// server/cli.ts
|
|
4017
|
+
var CLI_VERSION = true ? "2026.08.17.1" : "dev";
|
|
4018
|
+
var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
|
|
4019
|
+
specsketch login [--profile <name>] [--web-url <url>]
|
|
4020
|
+
specsketch --version
|
|
4021
|
+
|
|
4022
|
+
specsketch add-user-search
|
|
4023
|
+
|
|
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.
|
|
4028
|
+
|
|
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.
|
|
4032
|
+
|
|
4033
|
+
--port sets the preferred port (default 8787); if it's already in use the
|
|
4034
|
+
server moves to the next available port and prints the actual URL.
|
|
4035
|
+
|
|
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
|
+
`;
|
|
4039
|
+
var UsageError = class extends Error {
|
|
4040
|
+
};
|
|
4041
|
+
function parseCliArgs(argv) {
|
|
4042
|
+
let parsed;
|
|
4043
|
+
try {
|
|
4044
|
+
parsed = parseArgs2({
|
|
4045
|
+
args: argv,
|
|
4046
|
+
options: { port: { type: "string" }, fixture: { type: "boolean" } },
|
|
4047
|
+
allowPositionals: true
|
|
4048
|
+
});
|
|
4049
|
+
} catch (err) {
|
|
4050
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
4051
|
+
}
|
|
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
|
+
}
|
|
4059
|
+
const port = parsed.values.port === void 0 ? 8787 : Number(parsed.values.port);
|
|
4060
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
4061
|
+
throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
|
|
4062
|
+
}
|
|
4063
|
+
return { changeId, port, fixture: parsed.values.fixture === true };
|
|
4064
|
+
}
|
|
4065
|
+
var PORT_SCAN_LIMIT = 100;
|
|
4066
|
+
async function bindAvailablePort(start, tryBind) {
|
|
2995
4067
|
const end = Math.min(start + PORT_SCAN_LIMIT - 1, 65535);
|
|
2996
4068
|
for (let port = start; port <= end; port++) {
|
|
2997
4069
|
try {
|
|
@@ -3015,25 +4087,25 @@ var MIME = {
|
|
|
3015
4087
|
".woff": "font/woff"
|
|
3016
4088
|
};
|
|
3017
4089
|
function resolveClientDir(moduleUrl) {
|
|
3018
|
-
const here =
|
|
4090
|
+
const here = dirname6(fileURLToPath(moduleUrl));
|
|
3019
4091
|
const candidates = [
|
|
3020
|
-
|
|
4092
|
+
join11(here, "..", "client"),
|
|
3021
4093
|
// packed: dist/cli -> dist/client
|
|
3022
|
-
|
|
4094
|
+
join11(here, "..", "dist", "client")
|
|
3023
4095
|
// repo: server/ -> dist/client
|
|
3024
4096
|
];
|
|
3025
4097
|
for (const dir of candidates) {
|
|
3026
|
-
if (
|
|
4098
|
+
if (existsSync8(join11(dir, "index.html"))) return dir;
|
|
3027
4099
|
}
|
|
3028
4100
|
return null;
|
|
3029
4101
|
}
|
|
3030
4102
|
function staticResponse(clientDir, pathname) {
|
|
3031
4103
|
const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
|
|
3032
|
-
const target =
|
|
3033
|
-
if (!target.startsWith(
|
|
3034
|
-
const file =
|
|
3035
|
-
if (!
|
|
3036
|
-
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), {
|
|
3037
4109
|
headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
|
|
3038
4110
|
});
|
|
3039
4111
|
}
|
|
@@ -3049,8 +4121,8 @@ function detectCredentialSource(env = process.env, home = homedir4()) {
|
|
|
3049
4121
|
if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
|
|
3050
4122
|
if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
|
|
3051
4123
|
try {
|
|
3052
|
-
const dir =
|
|
3053
|
-
if (
|
|
4124
|
+
const dir = join11(home, ".config", "anthropic", "credentials");
|
|
4125
|
+
if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
|
|
3054
4126
|
} catch {
|
|
3055
4127
|
}
|
|
3056
4128
|
return null;
|
|
@@ -3064,6 +4136,10 @@ function preflight(log = console.log) {
|
|
|
3064
4136
|
}
|
|
3065
4137
|
}
|
|
3066
4138
|
async function main(argv) {
|
|
4139
|
+
if (argv[0] === "--version") {
|
|
4140
|
+
console.log(CLI_VERSION);
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
3067
4143
|
preflight();
|
|
3068
4144
|
if (argv[0] === "login") {
|
|
3069
4145
|
let loginArgs;
|
|
@@ -3099,27 +4175,25 @@ ${USAGE}` : err);
|
|
|
3099
4175
|
console.error("specsketch: built UI assets not found (dist/client missing).");
|
|
3100
4176
|
process.exit(1);
|
|
3101
4177
|
}
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
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);
|
|
3109
4188
|
}
|
|
3110
|
-
const workspace = new ChangeWorkspace(
|
|
3111
|
-
let remote = null;
|
|
4189
|
+
const workspace = new ChangeWorkspace(target.changeDir, { create: false });
|
|
3112
4190
|
const app = createApp(
|
|
3113
4191
|
new Anthropic(),
|
|
3114
4192
|
workspace,
|
|
3115
|
-
() =>
|
|
3116
|
-
|
|
3117
|
-
shareUrl: remote.shareUrl,
|
|
3118
|
-
status: remote.status,
|
|
3119
|
-
participants: remote.participants.length
|
|
3120
|
-
} : { active: false },
|
|
3121
|
-
{ refRoot: process.cwd(), harness: createClaudeHarness() }
|
|
4193
|
+
() => ({ active: false }),
|
|
4194
|
+
{ refRoot: target.repoRoot, harness: createClaudeHarness() }
|
|
3122
4195
|
);
|
|
4196
|
+
mountWorkbenchHost(app, { target, fixture: args.fixture });
|
|
3123
4197
|
app.get("*", (c) => {
|
|
3124
4198
|
const res = staticResponse(clientDir, new URL(c.req.url).pathname);
|
|
3125
4199
|
return res ?? c.notFound();
|
|
@@ -3146,17 +4220,11 @@ ${USAGE}` : err);
|
|
|
3146
4220
|
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
3147
4221
|
process.exit(1);
|
|
3148
4222
|
}
|
|
3149
|
-
if (args.share) {
|
|
3150
|
-
try {
|
|
3151
|
-
remote = await startShare(workspace, httpServer);
|
|
3152
|
-
} catch (err) {
|
|
3153
|
-
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
3154
|
-
process.exit(1);
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
4223
|
const source = detectCredentialSource();
|
|
3158
4224
|
console.log(`coopcli specsketch`);
|
|
3159
|
-
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`);
|
|
3160
4228
|
console.log(` open: http://127.0.0.1:${port}`);
|
|
3161
4229
|
if (moved) {
|
|
3162
4230
|
console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
|
|
@@ -3168,56 +4236,6 @@ ${USAGE}` : err);
|
|
|
3168
4236
|
\u26A0 ${CREDENTIALS_HINT}
|
|
3169
4237
|
`);
|
|
3170
4238
|
}
|
|
3171
|
-
if (remote) {
|
|
3172
|
-
console.log(`
|
|
3173
|
-
\u2500\u2500 shared session \u2500\u2500`);
|
|
3174
|
-
console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
|
|
3175
|
-
console.log(`
|
|
3176
|
-
${remote.shareUrl}
|
|
3177
|
-
`);
|
|
3178
|
-
console.log(` Ctrl-C ends the session and invalidates the link.`);
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
async function startShare(workspace, server) {
|
|
3182
|
-
const auth = await readCoopAuth();
|
|
3183
|
-
const localClients = /* @__PURE__ */ new Set();
|
|
3184
|
-
const remote = await RemoteSession.start({
|
|
3185
|
-
...auth,
|
|
3186
|
-
workspace,
|
|
3187
|
-
title: workspace.changeName,
|
|
3188
|
-
hostName: auth.displayName,
|
|
3189
|
-
log: (msg) => console.log(` ${msg}`),
|
|
3190
|
-
onLocalBroadcast: (frame) => {
|
|
3191
|
-
for (const client of localClients) client.send(frame);
|
|
3192
|
-
}
|
|
3193
|
-
});
|
|
3194
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
3195
|
-
server.on("upgrade", (req, socket, head) => {
|
|
3196
|
-
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
3197
|
-
if (pathname !== "/ws/local") {
|
|
3198
|
-
socket.destroy();
|
|
3199
|
-
return;
|
|
3200
|
-
}
|
|
3201
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3202
|
-
localClients.add(ws);
|
|
3203
|
-
ws.on("close", () => localClients.delete(ws));
|
|
3204
|
-
ws.on(
|
|
3205
|
-
"message",
|
|
3206
|
-
(data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
|
|
3207
|
-
);
|
|
3208
|
-
});
|
|
3209
|
-
});
|
|
3210
|
-
let ending = false;
|
|
3211
|
-
const endAndExit = async () => {
|
|
3212
|
-
if (ending) return;
|
|
3213
|
-
ending = true;
|
|
3214
|
-
console.log("\nspecsketch: ending shared session\u2026");
|
|
3215
|
-
await remote.end();
|
|
3216
|
-
process.exit(0);
|
|
3217
|
-
};
|
|
3218
|
-
process.on("SIGINT", () => void endAndExit());
|
|
3219
|
-
process.on("SIGTERM", () => void endAndExit());
|
|
3220
|
-
return remote;
|
|
3221
4239
|
}
|
|
3222
4240
|
|
|
3223
4241
|
// server/cli-entry.ts
|