@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 +254 -218
- package/dist/client/assets/index-BElJYxPt.js +78 -0
- package/dist/client/index.html +1 -1
- package/package.json +12 -1
- package/dist/client/assets/index-GtBmBt6t.js +0 -78
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
|
|
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
|
|
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 {
|
|
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
|
|
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 =
|
|
716
|
+
let dir = dirname3(require2.resolve("@fission-ai/openspec"));
|
|
691
717
|
for (; ; ) {
|
|
692
|
-
const pkgPath =
|
|
693
|
-
if (
|
|
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
|
|
722
|
+
return join3(dir, typeof pkg.bin === "string" ? pkg.bin : pkg.bin.openspec ?? "");
|
|
697
723
|
}
|
|
698
724
|
}
|
|
699
|
-
const parent =
|
|
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:
|
|
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(
|
|
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[
|
|
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(
|
|
758
|
-
writeAtomicFile(
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
939
|
+
this.fsx = fsx;
|
|
940
|
+
this.fsx.mkdirp(this.dir);
|
|
910
941
|
}
|
|
911
942
|
// ── Root file ────────────────────────────────────────────────────────────
|
|
912
943
|
rootPath() {
|
|
913
|
-
return
|
|
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 =
|
|
1051
|
-
if (!
|
|
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
|
|
1058
|
-
(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1775
|
-
var DEFAULT_CONFIG_PATH =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2383
|
+
this.fsx = fsx;
|
|
2384
|
+
this.fsx.mkdirp(this.dir);
|
|
2349
2385
|
}
|
|
2350
2386
|
// ── Sidecar ──────────────────────────────────────────────────────────────
|
|
2351
2387
|
readSidecar() {
|
|
2352
|
-
const raw = readIfExists(
|
|
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(
|
|
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 =
|
|
2510
|
+
const here = dirname5(fileURLToPath(moduleUrl));
|
|
2475
2511
|
const candidates = [
|
|
2476
|
-
|
|
2512
|
+
join7(here, "..", "client"),
|
|
2477
2513
|
// packed: dist/cli -> dist/client
|
|
2478
|
-
|
|
2514
|
+
join7(here, "..", "dist", "client")
|
|
2479
2515
|
// repo: server/ -> dist/client
|
|
2480
2516
|
];
|
|
2481
2517
|
for (const dir of candidates) {
|
|
2482
|
-
if (existsSync4(
|
|
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 :
|
|
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 =
|
|
2509
|
-
if (
|
|
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;
|