@coopcli/specplan 5.3.1 → 5.5.0

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "node:fs";
4
+ import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "node:fs";
5
5
  import { homedir as homedir2 } from "node:os";
6
- import { dirname as dirname4, extname, join as join6, normalize as normalize2, resolve as resolve5 } from "node:path";
6
+ import { dirname as dirname5, extname, join as join7, normalize as normalize2, resolve as resolve5 } 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";
@@ -189,7 +189,6 @@ var PromptInfo = z.object({
189
189
  });
190
190
 
191
191
  // server/app.ts
192
- import { basename as basename3 } from "node:path";
193
192
  import { Hono } from "hono";
194
193
 
195
194
  // src/lib/dag.ts
@@ -329,10 +328,9 @@ function planErrors(plan) {
329
328
 
330
329
  // server/generate.ts
331
330
  import { execFile } from "node:child_process";
332
- import { createHash } from "node:crypto";
333
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
331
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
334
332
  import { createRequire } from "node:module";
335
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
333
+ import { dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
336
334
  import { promisify } from "node:util";
337
335
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
338
336
 
@@ -605,6 +603,78 @@ ${note}${r.content}
605
603
  return parts.join("\n\n");
606
604
  }
607
605
 
606
+ // server/spec-state.ts
607
+ import { createHash } from "node:crypto";
608
+ import { dirname as dirname2, join as join2 } from "node:path";
609
+
610
+ // server/fs-node.ts
611
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2 } from "node:fs";
612
+ var nodeSyncFs = {
613
+ readIfExists(path) {
614
+ return readIfExists(path);
615
+ },
616
+ writeAtomicFile(path, content) {
617
+ writeAtomicFile(path, content);
618
+ },
619
+ exists(path) {
620
+ return existsSync2(path);
621
+ },
622
+ mkdirp(path) {
623
+ mkdirSync2(path, { recursive: true });
624
+ },
625
+ readdir(path) {
626
+ if (!existsSync2(path)) return [];
627
+ return readdirSync2(path, { withFileTypes: true }).map((entry) => ({
628
+ name: entry.name,
629
+ directory: entry.isDirectory()
630
+ }));
631
+ }
632
+ };
633
+
634
+ // server/spec-state.ts
635
+ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
636
+ function listBodyFiles(changeDir, fsx = nodeSyncFs) {
637
+ const files = [];
638
+ for (const name of ["proposal.md", "design.md", "tasks.md"]) {
639
+ if (fsx.exists(join2(changeDir, name))) files.push(name);
640
+ }
641
+ const specsDir = join2(changeDir, "specs");
642
+ for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
643
+ const rel = join2("specs", entry.name, "spec.md");
644
+ if (entry.directory && fsx.exists(join2(changeDir, rel))) files.push(rel);
645
+ }
646
+ return files;
647
+ }
648
+ function bodyHash(changeDir, fsx = nodeSyncFs) {
649
+ const files = listBodyFiles(changeDir, fsx);
650
+ if (files.length === 0) return null;
651
+ const parts = files.map(
652
+ (rel) => `${rel}
653
+ ${sha256(fsx.readIfExists(join2(changeDir, rel)) ?? "")}`
654
+ );
655
+ return `sha256:${sha256(parts.join("\n"))}`;
656
+ }
657
+ function specState(store, specId) {
658
+ const changeDir = dirname2(store.specMetaPath(specId));
659
+ const currentHash = bodyHash(changeDir, store.fsx);
660
+ let meta = null;
661
+ try {
662
+ meta = store.readSpecMeta(specId);
663
+ } catch {
664
+ }
665
+ const status = meta?.status ?? "draft";
666
+ if (currentHash === null) return { hasSpec: false, handEdited: false, status };
667
+ return {
668
+ hasSpec: true,
669
+ handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
670
+ status
671
+ };
672
+ }
673
+ function readBodyFiles(store, specId) {
674
+ const changeDir = dirname2(store.specMetaPath(specId));
675
+ return listBodyFiles(changeDir, store.fsx).map((rel) => ({ path: rel, content: store.fsx.readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0);
676
+ }
677
+
608
678
  // server/generate.ts
609
679
  var SYSTEM = `You are a principal software architect and specification writer.
610
680
  Given one planned spec card from a project roadmap \u2014 its title, the user stories it
@@ -641,62 +711,18 @@ var GenerationFailed = class extends Error {
641
711
  }
642
712
  status;
643
713
  };
644
- var sha256 = (s) => createHash("sha256").update(s).digest("hex");
645
- function listBodyFiles(changeDir) {
646
- const files = [];
647
- for (const name of ["proposal.md", "design.md", "tasks.md"]) {
648
- if (existsSync2(join2(changeDir, name))) files.push(name);
649
- }
650
- const specsDir = join2(changeDir, "specs");
651
- if (existsSync2(specsDir)) {
652
- for (const entry of readdirSync2(specsDir, { withFileTypes: true }).sort(
653
- (a, b) => a.name.localeCompare(b.name)
654
- )) {
655
- const rel = join2("specs", entry.name, "spec.md");
656
- if (entry.isDirectory() && existsSync2(join2(changeDir, rel))) files.push(rel);
657
- }
658
- }
659
- return files;
660
- }
661
- function bodyHash(changeDir) {
662
- const files = listBodyFiles(changeDir);
663
- if (files.length === 0) return null;
664
- const parts = files.map((rel) => `${rel}
665
- ${sha256(readFileSync3(join2(changeDir, rel), "utf8"))}`);
666
- return `sha256:${sha256(parts.join("\n"))}`;
667
- }
668
- function specState(store, specId) {
669
- const changeDir = dirname2(store.specMetaPath(specId));
670
- const currentHash = bodyHash(changeDir);
671
- let meta = null;
672
- try {
673
- meta = store.readSpecMeta(specId);
674
- } catch {
675
- }
676
- const status = meta?.status ?? "draft";
677
- if (currentHash === null) return { hasSpec: false, handEdited: false, status };
678
- return {
679
- hasSpec: true,
680
- handEdited: meta?.generation?.lastGeneratedBodyHash !== currentHash,
681
- status
682
- };
683
- }
684
- function readBodyFiles(store, specId) {
685
- const changeDir = dirname2(store.specMetaPath(specId));
686
- return listBodyFiles(changeDir).map((rel) => ({ path: rel, content: readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0);
687
- }
688
714
  function resolveOpenspecBin() {
689
715
  const require2 = createRequire(import.meta.url);
690
- let dir = dirname2(require2.resolve("@fission-ai/openspec"));
716
+ let dir = dirname3(require2.resolve("@fission-ai/openspec"));
691
717
  for (; ; ) {
692
- const pkgPath = join2(dir, "package.json");
693
- if (existsSync2(pkgPath)) {
718
+ const pkgPath = join3(dir, "package.json");
719
+ if (existsSync3(pkgPath)) {
694
720
  const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
695
721
  if (pkg.name === "@fission-ai/openspec" && pkg.bin) {
696
- return join2(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
722
+ return join3(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
697
723
  }
698
724
  }
699
- const parent = dirname2(dir);
725
+ const parent = dirname3(dir);
700
726
  if (parent === dir) throw new Error("could not locate the @fission-ai/openspec bin");
701
727
  dir = parent;
702
728
  }
@@ -707,7 +733,7 @@ async function runOpenspecValidate(specId, rootDir) {
707
733
  const { stdout, stderr } = await promisify(execFile)(
708
734
  process.execPath,
709
735
  [bin, "validate", specId, "--strict"],
710
- { cwd: dirname2(resolve2(rootDir)) }
736
+ { cwd: dirname3(resolve2(rootDir)) }
711
737
  );
712
738
  return { ok: true, output: `${stdout}${stderr}`.trim() };
713
739
  } catch (err) {
@@ -736,7 +762,7 @@ Produce the OpenSpec change proposal for exactly this spec as structured output.
736
762
  Use exactly "${spec.id}" as the changeName.`;
737
763
  }
738
764
  function renderBaseline(changeDir) {
739
- const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join2(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
765
+ const sections = listBodyFiles(changeDir).map((rel) => ({ rel, content: readIfExists(join3(changeDir, rel)) })).filter((f) => f.content !== void 0).map((f) => `--- ${f.rel} ---
740
766
  ${f.content}`);
741
767
  if (sections.length === 0) return "";
742
768
  return `
@@ -751,11 +777,11 @@ function writeBundle(changeDir, bundle) {
751
777
  "design.md": bundle.design,
752
778
  "tasks.md": bundle.tasks
753
779
  };
754
- for (const s of bundle.specs) files[join2("specs", s.capability, "spec.md")] = s.spec;
780
+ for (const s of bundle.specs) files[join3("specs", s.capability, "spec.md")] = s.spec;
755
781
  const written = [];
756
782
  for (const [rel, content] of Object.entries(files)) {
757
- if (readIfExists(join2(changeDir, rel)) === content) continue;
758
- writeAtomicFile(join2(changeDir, rel), content);
783
+ if (readIfExists(join3(changeDir, rel)) === content) continue;
784
+ writeAtomicFile(join3(changeDir, rel), content);
759
785
  written.push(rel);
760
786
  }
761
787
  return written;
@@ -769,7 +795,7 @@ async function generateForSpec(deps, specId, model = DEFAULT_MODEL) {
769
795
  }
770
796
  const stories = storyNodes(plan, specId);
771
797
  const depSpecs = depNodes(plan, specId);
772
- const changeDir = dirname2(store.specMetaPath(specId));
798
+ const changeDir = dirname3(store.specMetaPath(specId));
773
799
  store.writeSpecMeta(
774
800
  specId,
775
801
  stories.map((s) => s.id),
@@ -836,9 +862,11 @@ function depNodes(plan, specId) {
836
862
  return specDependencies(plan, specId).map((id) => byId.get(id)).filter((n) => n?.type === "spec");
837
863
  }
838
864
 
865
+ // server/plan-routes.ts
866
+ import { basename as basename3 } from "node:path";
867
+
839
868
  // server/store.ts
840
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync3 } from "node:fs";
841
- import { basename as basename2, join as join3, resolve as resolve3 } from "node:path";
869
+ import { basename as basename2, join as join4, resolve as resolve3 } from "node:path";
842
870
  import { Document, parseDocument } from "yaml";
843
871
 
844
872
  // server/ulid.ts
@@ -904,16 +932,19 @@ var META_FILE = "specplan.yaml";
904
932
  var PlanStore = class {
905
933
  /** Resolved openspec root directory. */
906
934
  dir;
907
- constructor(dir) {
935
+ /** IO substrate — real disk by default, in-memory for the cloud target. */
936
+ fsx;
937
+ constructor(dir, fsx = nodeSyncFs) {
908
938
  this.dir = resolve3(dir);
909
- mkdirSync2(this.dir, { recursive: true });
939
+ this.fsx = fsx;
940
+ this.fsx.mkdirp(this.dir);
910
941
  }
911
942
  // ── Root file ────────────────────────────────────────────────────────────
912
943
  rootPath() {
913
- return join3(this.dir, ROOT_FILE);
944
+ return join4(this.dir, ROOT_FILE);
914
945
  }
915
946
  loadDoc() {
916
- const raw = readIfExists(this.rootPath());
947
+ const raw = this.fsx.readIfExists(this.rootPath());
917
948
  if (raw === void 0) return new Document({ version: 1, nodes: [], edges: [] });
918
949
  const doc = parseDocument(raw);
919
950
  const fault = doc.errors[0];
@@ -936,7 +967,7 @@ var PlanStore = class {
936
967
  return this.planOf(this.loadDoc());
937
968
  }
938
969
  save(doc) {
939
- writeAtomicFile(this.rootPath(), doc.toString());
970
+ this.fsx.writeAtomicFile(this.rootPath(), doc.toString());
940
971
  }
941
972
  // ── Ids ──────────────────────────────────────────────────────────────────
942
973
  mintId(prefix, existing) {
@@ -971,7 +1002,7 @@ var PlanStore = class {
971
1002
  }
972
1003
  /** A fresh per-spec metadata file: empty relations, explicit draft status. */
973
1004
  initSpecMeta(specId) {
974
- writeAtomicFile(
1005
+ this.fsx.writeAtomicFile(
975
1006
  this.specMetaPath(specId),
976
1007
  new Document({
977
1008
  specId,
@@ -1047,17 +1078,15 @@ var PlanStore = class {
1047
1078
  * next refresh. Never touches a change's body files.
1048
1079
  */
1049
1080
  adoptExistingChanges() {
1050
- const changesDir = join3(this.dir, "changes");
1051
- if (!existsSync3(changesDir)) return [];
1081
+ const changesDir = join4(this.dir, "changes");
1082
+ if (!this.fsx.exists(changesDir)) return [];
1052
1083
  const doc = this.loadDoc();
1053
1084
  const plan = this.planOf(doc);
1054
1085
  const known = new Set(plan.nodes.map((n) => n.id));
1055
1086
  let order = this.nextOrder(plan, "spec");
1056
1087
  const adopted = [];
1057
- for (const entry of readdirSync3(changesDir, { withFileTypes: true }).sort(
1058
- (a, b) => a.name.localeCompare(b.name)
1059
- )) {
1060
- if (!entry.isDirectory() || entry.name === "archive" || entry.name.startsWith(".")) {
1088
+ for (const entry of this.fsx.readdir(changesDir).sort((a, b) => a.name.localeCompare(b.name))) {
1089
+ if (!entry.directory || entry.name === "archive" || entry.name.startsWith(".")) {
1061
1090
  continue;
1062
1091
  }
1063
1092
  if (known.has(entry.name)) continue;
@@ -1083,10 +1112,10 @@ var PlanStore = class {
1083
1112
  }
1084
1113
  // ── Per-spec metadata files ──────────────────────────────────────────────
1085
1114
  specMetaPath(specId) {
1086
- return join3(this.dir, "changes", specId, META_FILE);
1115
+ return join4(this.dir, "changes", specId, META_FILE);
1087
1116
  }
1088
1117
  readSpecMeta(specId) {
1089
- const raw = readIfExists(this.specMetaPath(specId));
1118
+ const raw = this.fsx.readIfExists(this.specMetaPath(specId));
1090
1119
  if (raw === void 0) return null;
1091
1120
  const doc = parseDocument(raw);
1092
1121
  const fault = doc.errors[0];
@@ -1104,12 +1133,12 @@ var PlanStore = class {
1104
1133
  */
1105
1134
  writeSpecMeta(specId, userStories, dependencies) {
1106
1135
  const path = this.specMetaPath(specId);
1107
- const raw = readIfExists(path);
1136
+ const raw = this.fsx.readIfExists(path);
1108
1137
  const doc = raw === void 0 ? new Document({}) : parseDocument(raw);
1109
1138
  doc.setIn(["specId"], specId);
1110
1139
  doc.setIn(["userStories"], userStories);
1111
1140
  doc.setIn(["dependencies"], dependencies);
1112
- writeAtomicFile(path, doc.toString());
1141
+ this.fsx.writeAtomicFile(path, doc.toString());
1113
1142
  }
1114
1143
  /**
1115
1144
  * Set a spec's lifecycle status (draft | active | done | deferred) in its
@@ -1120,22 +1149,144 @@ var PlanStore = class {
1120
1149
  const node = this.load().nodes.find((n) => n.id === specId);
1121
1150
  if (!node || node.type !== "spec") throw new DagError(`unknown spec: ${specId}`);
1122
1151
  const path = this.specMetaPath(specId);
1123
- const raw = readIfExists(path);
1152
+ const raw = this.fsx.readIfExists(path);
1124
1153
  const doc = raw === void 0 ? new Document({ specId, userStories: [], dependencies: [] }) : parseDocument(raw);
1125
1154
  doc.setIn(["status"], status);
1126
- writeAtomicFile(path, doc.toString());
1155
+ this.fsx.writeAtomicFile(path, doc.toString());
1127
1156
  }
1128
1157
  /** Record a generation result in the spec's metadata file. */
1129
1158
  writeGeneration(specId, lastGeneratedBodyHash, lastGeneratedAt) {
1130
1159
  const path = this.specMetaPath(specId);
1131
- const raw = readIfExists(path);
1160
+ const raw = this.fsx.readIfExists(path);
1132
1161
  const doc = raw === void 0 ? new Document({ specId }) : parseDocument(raw);
1133
1162
  doc.setIn(["generation", "lastGeneratedBodyHash"], lastGeneratedBodyHash);
1134
1163
  doc.setIn(["generation", "lastGeneratedAt"], lastGeneratedAt);
1135
- writeAtomicFile(path, doc.toString());
1164
+ this.fsx.writeAtomicFile(path, doc.toString());
1136
1165
  }
1137
1166
  };
1138
1167
 
1168
+ // server/plan-routes.ts
1169
+ function planErrorResponse(c, err) {
1170
+ if (err instanceof DagError) return c.json({ error: err.message }, 400);
1171
+ if (err instanceof PlanFileError) return c.json({ error: err.message }, 500);
1172
+ throw err;
1173
+ }
1174
+ function registerPlanRoutes(app, store, workspace) {
1175
+ app.get("/api/session", (c) => {
1176
+ try {
1177
+ store.adoptExistingChanges();
1178
+ const plan = store.load();
1179
+ const specStates = Object.fromEntries(
1180
+ plan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id)])
1181
+ );
1182
+ return c.json({
1183
+ rootName: basename3(workspace.dir),
1184
+ model: GENERATION_MODELS.find((m) => m === workspace.readModel()),
1185
+ plan,
1186
+ positions: workspace.readPositions(),
1187
+ specStates,
1188
+ chat: workspace.readChat()
1189
+ });
1190
+ } catch (err) {
1191
+ return planErrorResponse(c, err);
1192
+ }
1193
+ });
1194
+ app.put("/api/session", async (c) => {
1195
+ const parsed = SessionSave.safeParse(await c.req.json().catch(() => null));
1196
+ if (!parsed.success) {
1197
+ return c.json({ error: "invalid session", detail: parsed.error.issues }, 400);
1198
+ }
1199
+ workspace.writeUiState(parsed.data);
1200
+ return c.json({ ok: true });
1201
+ });
1202
+ app.post("/api/plan/spec", async (c) => {
1203
+ const parsed = CreateSpecRequest.safeParse(await c.req.json().catch(() => null));
1204
+ if (!parsed.success) {
1205
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1206
+ }
1207
+ try {
1208
+ return c.json(
1209
+ { node: store.createSpec(parsed.data.title, parsed.data.description) },
1210
+ 201
1211
+ );
1212
+ } catch (err) {
1213
+ return planErrorResponse(c, err);
1214
+ }
1215
+ });
1216
+ app.post("/api/plan/story", async (c) => {
1217
+ const parsed = CreateStoryRequest.safeParse(await c.req.json().catch(() => null));
1218
+ if (!parsed.success) {
1219
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1220
+ }
1221
+ try {
1222
+ return c.json({ node: store.createStory(parsed.data.text) }, 201);
1223
+ } catch (err) {
1224
+ return planErrorResponse(c, err);
1225
+ }
1226
+ });
1227
+ app.post("/api/plan/link", async (c) => {
1228
+ const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1229
+ if (!parsed.success) {
1230
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1231
+ }
1232
+ try {
1233
+ return c.json({ edge: store.link(parsed.data.from, parsed.data.to) }, 201);
1234
+ } catch (err) {
1235
+ return planErrorResponse(c, err);
1236
+ }
1237
+ });
1238
+ app.get("/api/spec/:specId", (c) => {
1239
+ const specId = c.req.param("specId");
1240
+ try {
1241
+ const node = store.load().nodes.find((n) => n.id === specId);
1242
+ if (!node || node.type !== "spec") {
1243
+ return c.json({ error: `unknown spec: ${specId}` }, 404);
1244
+ }
1245
+ return c.json({
1246
+ specId,
1247
+ handEdited: specState(store, specId).handEdited,
1248
+ files: readBodyFiles(store, specId)
1249
+ });
1250
+ } catch (err) {
1251
+ return planErrorResponse(c, err);
1252
+ }
1253
+ });
1254
+ app.post("/api/plan/status", async (c) => {
1255
+ const parsed = SetStatusRequest.safeParse(await c.req.json().catch(() => null));
1256
+ if (!parsed.success) {
1257
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1258
+ }
1259
+ try {
1260
+ store.setStatus(parsed.data.specId, parsed.data.status);
1261
+ return c.json({ specId: parsed.data.specId, status: parsed.data.status });
1262
+ } catch (err) {
1263
+ return planErrorResponse(c, err);
1264
+ }
1265
+ });
1266
+ app.post("/api/plan/unlink", async (c) => {
1267
+ const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1268
+ if (!parsed.success) {
1269
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1270
+ }
1271
+ try {
1272
+ return c.json({ edge: store.unlink(parsed.data.from, parsed.data.to) });
1273
+ } catch (err) {
1274
+ return planErrorResponse(c, err);
1275
+ }
1276
+ });
1277
+ app.post("/api/plan/reorder", async (c) => {
1278
+ const parsed = ReorderRequest.safeParse(await c.req.json().catch(() => null));
1279
+ if (!parsed.success) {
1280
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1281
+ }
1282
+ try {
1283
+ return c.json({ node: store.reorder(parsed.data.nodeId, parsed.data.order) });
1284
+ } catch (err) {
1285
+ return planErrorResponse(c, err);
1286
+ }
1287
+ });
1288
+ }
1289
+
1139
1290
  // server/tools.ts
1140
1291
  import { z as z2 } from "zod";
1141
1292
  var CreateSpecInput = z2.object({ title: z2.string().min(1) });
@@ -1319,11 +1470,6 @@ function anthropicErrorResponse(c, err, kind) {
1319
1470
  }
1320
1471
  return c.json({ error: kind, detail: message }, 502);
1321
1472
  }
1322
- function planErrorResponse(c, err) {
1323
- if (err instanceof DagError) return c.json({ error: err.message }, 400);
1324
- if (err instanceof PlanFileError) return c.json({ error: err.message }, 500);
1325
- throw err;
1326
- }
1327
1473
  function createApp(anthropic, workspace, store, options) {
1328
1474
  const refRoot = options === void 0 ? process.cwd() : options.refRoot;
1329
1475
  const app = new Hono();
@@ -1337,85 +1483,7 @@ function createApp(anthropic, workspace, store, options) {
1337
1483
  })
1338
1484
  );
1339
1485
  if (workspace && store) {
1340
- app.get("/api/session", (c) => {
1341
- try {
1342
- store.adoptExistingChanges();
1343
- const plan = store.load();
1344
- const specStates = Object.fromEntries(
1345
- plan.nodes.filter((n) => n.type === "spec").map((n) => [n.id, specState(store, n.id)])
1346
- );
1347
- return c.json({
1348
- rootName: basename3(workspace.dir),
1349
- model: GENERATION_MODELS.find((m) => m === workspace.readModel()),
1350
- plan,
1351
- positions: workspace.readPositions(),
1352
- specStates,
1353
- chat: workspace.readChat()
1354
- });
1355
- } catch (err) {
1356
- return planErrorResponse(c, err);
1357
- }
1358
- });
1359
- app.put("/api/session", async (c) => {
1360
- const parsed = SessionSave.safeParse(await c.req.json().catch(() => null));
1361
- if (!parsed.success) {
1362
- return c.json({ error: "invalid session", detail: parsed.error.issues }, 400);
1363
- }
1364
- workspace.writeUiState(parsed.data);
1365
- return c.json({ ok: true });
1366
- });
1367
- app.post("/api/plan/spec", async (c) => {
1368
- const parsed = CreateSpecRequest.safeParse(await c.req.json().catch(() => null));
1369
- if (!parsed.success) {
1370
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1371
- }
1372
- try {
1373
- return c.json(
1374
- { node: store.createSpec(parsed.data.title, parsed.data.description) },
1375
- 201
1376
- );
1377
- } catch (err) {
1378
- return planErrorResponse(c, err);
1379
- }
1380
- });
1381
- app.post("/api/plan/story", async (c) => {
1382
- const parsed = CreateStoryRequest.safeParse(await c.req.json().catch(() => null));
1383
- if (!parsed.success) {
1384
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1385
- }
1386
- try {
1387
- return c.json({ node: store.createStory(parsed.data.text) }, 201);
1388
- } catch (err) {
1389
- return planErrorResponse(c, err);
1390
- }
1391
- });
1392
- app.post("/api/plan/link", async (c) => {
1393
- const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1394
- if (!parsed.success) {
1395
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1396
- }
1397
- try {
1398
- return c.json({ edge: store.link(parsed.data.from, parsed.data.to) }, 201);
1399
- } catch (err) {
1400
- return planErrorResponse(c, err);
1401
- }
1402
- });
1403
- app.get("/api/spec/:specId", (c) => {
1404
- const specId = c.req.param("specId");
1405
- try {
1406
- const node = store.load().nodes.find((n) => n.id === specId);
1407
- if (!node || node.type !== "spec") {
1408
- return c.json({ error: `unknown spec: ${specId}` }, 404);
1409
- }
1410
- return c.json({
1411
- specId,
1412
- handEdited: specState(store, specId).handEdited,
1413
- files: readBodyFiles(store, specId)
1414
- });
1415
- } catch (err) {
1416
- return planErrorResponse(c, err);
1417
- }
1418
- });
1486
+ registerPlanRoutes(app, store, workspace);
1419
1487
  app.post("/api/generate", async (c) => {
1420
1488
  const parsed = GenerateRequest.safeParse(await c.req.json().catch(() => null));
1421
1489
  if (!parsed.success) {
@@ -1531,40 +1599,6 @@ ${renderPlanContext(chatPlan, statuses)}`;
1531
1599
  workspace.writeChat([]);
1532
1600
  return c.json({ ok: true });
1533
1601
  });
1534
- app.post("/api/plan/status", async (c) => {
1535
- const parsed = SetStatusRequest.safeParse(await c.req.json().catch(() => null));
1536
- if (!parsed.success) {
1537
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1538
- }
1539
- try {
1540
- store.setStatus(parsed.data.specId, parsed.data.status);
1541
- return c.json({ specId: parsed.data.specId, status: parsed.data.status });
1542
- } catch (err) {
1543
- return planErrorResponse(c, err);
1544
- }
1545
- });
1546
- app.post("/api/plan/unlink", async (c) => {
1547
- const parsed = LinkRequest.safeParse(await c.req.json().catch(() => null));
1548
- if (!parsed.success) {
1549
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1550
- }
1551
- try {
1552
- return c.json({ edge: store.unlink(parsed.data.from, parsed.data.to) });
1553
- } catch (err) {
1554
- return planErrorResponse(c, err);
1555
- }
1556
- });
1557
- app.post("/api/plan/reorder", async (c) => {
1558
- const parsed = ReorderRequest.safeParse(await c.req.json().catch(() => null));
1559
- if (!parsed.success) {
1560
- return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
1561
- }
1562
- try {
1563
- return c.json({ node: store.reorder(parsed.data.nodeId, parsed.data.order) });
1564
- } catch (err) {
1565
- return planErrorResponse(c, err);
1566
- }
1567
- });
1568
1602
  }
1569
1603
  app.get(
1570
1604
  "/api/files",
@@ -1591,7 +1625,7 @@ import { parseArgs } from "node:util";
1591
1625
  // ../shared/src/cli-config.ts
1592
1626
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
1593
1627
  import { homedir, hostname } from "node:os";
1594
- import { join as join4, dirname as dirname3 } from "node:path";
1628
+ import { join as join5, dirname as dirname4 } from "node:path";
1595
1629
 
1596
1630
  // ../shared/src/schemas.ts
1597
1631
  import { z as z3 } from "zod";
@@ -1771,8 +1805,8 @@ var wsClientMessageSchema = z3.discriminatedUnion("type", [
1771
1805
  ]);
1772
1806
 
1773
1807
  // ../shared/src/cli-config.ts
1774
- var DEFAULT_CONFIG_DIR = join4(homedir(), ".coopcli");
1775
- var DEFAULT_CONFIG_PATH = join4(DEFAULT_CONFIG_DIR, "config.json");
1808
+ var DEFAULT_CONFIG_DIR = join5(homedir(), ".coopcli");
1809
+ var DEFAULT_CONFIG_PATH = join5(DEFAULT_CONFIG_DIR, "config.json");
1776
1810
  var PROD_API_URL = "https://api.coopcli.com";
1777
1811
  function emptyProfile(apiUrl) {
1778
1812
  return {
@@ -1818,7 +1852,7 @@ var ConfigStore = class {
1818
1852
  configDir;
1819
1853
  constructor(configPath) {
1820
1854
  this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
1821
- this.configDir = dirname3(this.configPath);
1855
+ this.configDir = dirname4(this.configPath);
1822
1856
  }
1823
1857
  /**
1824
1858
  * Select the profile this store operates on for the rest of the process
@@ -2337,19 +2371,21 @@ function validateCommand(rootDir, log = console.log, logError = console.error) {
2337
2371
  }
2338
2372
 
2339
2373
  // server/workspace.ts
2340
- import { join as join5, resolve as resolve4 } from "node:path";
2341
- import { mkdirSync as mkdirSync3 } from "node:fs";
2374
+ import { join as join6, resolve as resolve4 } from "node:path";
2342
2375
  var SIDECAR_FILE = "specplan-ui.json";
2343
2376
  var PlanWorkspace = class {
2344
2377
  /** Resolved openspec root directory. */
2345
2378
  dir;
2346
- constructor(dir) {
2379
+ /** IO substrate — real disk by default, in-memory for the cloud target. */
2380
+ fsx;
2381
+ constructor(dir, fsx = nodeSyncFs) {
2347
2382
  this.dir = resolve4(dir);
2348
- mkdirSync3(this.dir, { recursive: true });
2383
+ this.fsx = fsx;
2384
+ this.fsx.mkdirp(this.dir);
2349
2385
  }
2350
2386
  // ── Sidecar ──────────────────────────────────────────────────────────────
2351
2387
  readSidecar() {
2352
- const raw = readIfExists(join5(this.dir, SIDECAR_FILE));
2388
+ const raw = this.fsx.readIfExists(join6(this.dir, SIDECAR_FILE));
2353
2389
  if (!raw) return {};
2354
2390
  try {
2355
2391
  return JSON.parse(raw);
@@ -2363,7 +2399,7 @@ var PlanWorkspace = class {
2363
2399
  const next = update(current);
2364
2400
  next.createdAt ??= now;
2365
2401
  next.updatedAt = now;
2366
- writeAtomicFile(join5(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
2402
+ this.fsx.writeAtomicFile(join6(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
2367
2403
  }
2368
2404
  readModel() {
2369
2405
  return this.readSidecar().model;
@@ -2471,15 +2507,15 @@ var MIME = {
2471
2507
  ".woff": "font/woff"
2472
2508
  };
2473
2509
  function resolveClientDir(moduleUrl) {
2474
- const here = dirname4(fileURLToPath(moduleUrl));
2510
+ const here = dirname5(fileURLToPath(moduleUrl));
2475
2511
  const candidates = [
2476
- join6(here, "..", "client"),
2512
+ join7(here, "..", "client"),
2477
2513
  // packed: dist/cli -> dist/client
2478
- join6(here, "..", "dist", "client")
2514
+ join7(here, "..", "dist", "client")
2479
2515
  // repo: server/ -> dist/client
2480
2516
  ];
2481
2517
  for (const dir of candidates) {
2482
- if (existsSync4(join6(dir, "index.html"))) return dir;
2518
+ if (existsSync4(join7(dir, "index.html"))) return dir;
2483
2519
  }
2484
2520
  return null;
2485
2521
  }
@@ -2487,7 +2523,7 @@ function staticResponse(clientDir, pathname) {
2487
2523
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
2488
2524
  const target = resolve5(clientDir, rel === "" ? "index.html" : rel);
2489
2525
  if (!target.startsWith(resolve5(clientDir))) return null;
2490
- const file = existsSync4(target) && extname(target) ? target : join6(clientDir, "index.html");
2526
+ const file = existsSync4(target) && extname(target) ? target : join7(clientDir, "index.html");
2491
2527
  if (!existsSync4(file)) return null;
2492
2528
  return new Response(readFileSync4(file), {
2493
2529
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
@@ -2505,8 +2541,8 @@ function detectCredentialSource(env = process.env, home = homedir2()) {
2505
2541
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
2506
2542
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
2507
2543
  try {
2508
- const dir = join6(home, ".config", "anthropic", "credentials");
2509
- if (readdirSync4(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2544
+ const dir = join7(home, ".config", "anthropic", "credentials");
2545
+ if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2510
2546
  } catch {
2511
2547
  }
2512
2548
  return null;