gigaplan 0.1.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/README.md +114 -0
- package/bin/gigaplan.js +3 -0
- package/dist/cli.js +188 -0
- package/dist/markdown.js +196 -0
- package/dist/paths.js +13 -0
- package/dist/render.js +48 -0
- package/dist/review-store.js +84 -0
- package/dist/server.js +160 -0
- package/dist/session-store.js +92 -0
- package/dist/types.js +1 -0
- package/package.json +44 -0
- package/public/chrome.css +450 -0
- package/public/chrome.js +648 -0
- package/skills/gigaplan/SKILL.md +97 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import { parseBlocks, reconcileBlocks } from "./markdown.js";
|
|
7
|
+
import { renderSessionPage } from "./render.js";
|
|
8
|
+
import * as sessionStore from "./session-store.js";
|
|
9
|
+
import * as reviewStore from "./review-store.js";
|
|
10
|
+
import { PORT, LONG_POLL_TIMEOUT_MS, IDLE_TIMEOUT_MS } from "./paths.js";
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const watchers = new Map();
|
|
14
|
+
const sseClients = new Map();
|
|
15
|
+
function watchPlan(planPath) {
|
|
16
|
+
if (watchers.has(planPath))
|
|
17
|
+
return;
|
|
18
|
+
let debounceTimer;
|
|
19
|
+
const watcher = chokidar.watch(planPath, { ignoreInitial: true });
|
|
20
|
+
watcher.on("change", () => {
|
|
21
|
+
if (debounceTimer)
|
|
22
|
+
clearTimeout(debounceTimer);
|
|
23
|
+
debounceTimer = setTimeout(() => {
|
|
24
|
+
try {
|
|
25
|
+
const source = fs.readFileSync(planPath, "utf8");
|
|
26
|
+
const freshBlocks = parseBlocks(source);
|
|
27
|
+
const oldBlocks = reviewStore.getBlocks(planPath);
|
|
28
|
+
const result = reconcileBlocks(oldBlocks, freshBlocks);
|
|
29
|
+
reviewStore.applyReconciliation(planPath, result);
|
|
30
|
+
notifySse(planPath);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// The plan file may be transiently unreadable mid-write (editors that
|
|
34
|
+
// truncate-then-write); the next change event will settle it.
|
|
35
|
+
}
|
|
36
|
+
}, 150);
|
|
37
|
+
});
|
|
38
|
+
watchers.set(planPath, watcher);
|
|
39
|
+
}
|
|
40
|
+
function notifySse(planPath) {
|
|
41
|
+
const clients = sseClients.get(planPath) ?? [];
|
|
42
|
+
for (const res of clients) {
|
|
43
|
+
res.write("event: changed\ndata: {}\n\n");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function createApp() {
|
|
47
|
+
const app = express();
|
|
48
|
+
app.use(express.json());
|
|
49
|
+
app.use("/public", express.static(path.join(__dirname, "..", "public")));
|
|
50
|
+
let idleTimer;
|
|
51
|
+
const onIdleTimeout = () => {
|
|
52
|
+
sessionStore.clearServerLock();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
};
|
|
55
|
+
app.use((_req, _res, next) => {
|
|
56
|
+
if (idleTimer)
|
|
57
|
+
clearTimeout(idleTimer);
|
|
58
|
+
idleTimer = setTimeout(onIdleTimeout, IDLE_TIMEOUT_MS);
|
|
59
|
+
next();
|
|
60
|
+
});
|
|
61
|
+
app.post("/api/sessions", (req, res) => {
|
|
62
|
+
const { planPath } = req.body;
|
|
63
|
+
if (!planPath) {
|
|
64
|
+
res.status(400).json({ error: "planPath is required" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!fs.existsSync(planPath)) {
|
|
68
|
+
res.status(404).json({ error: `plan file not found: ${planPath}` });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
sessionStore.createOrReopenSession(planPath);
|
|
72
|
+
const source = fs.readFileSync(planPath, "utf8");
|
|
73
|
+
reviewStore.setBlocks(planPath, parseBlocks(source));
|
|
74
|
+
watchPlan(planPath);
|
|
75
|
+
const sessionKey = encodeURIComponent(planPath);
|
|
76
|
+
res.json({ sessionKey: planPath, url: `http://127.0.0.1:${PORT}/session/${sessionKey}` });
|
|
77
|
+
});
|
|
78
|
+
app.get("/session/:key", (req, res) => {
|
|
79
|
+
const planPath = req.params.key;
|
|
80
|
+
const session = sessionStore.getSession(planPath);
|
|
81
|
+
if (!session) {
|
|
82
|
+
res
|
|
83
|
+
.status(404)
|
|
84
|
+
.send("No gigaplan session for this plan file. Run `gigaplan review <path>` first.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const blocks = reviewStore.getBlocks(planPath);
|
|
88
|
+
res.type("html").send(renderSessionPage(planPath, planPath, blocks));
|
|
89
|
+
});
|
|
90
|
+
app.get("/api/sessions/:key/blocks", (req, res) => {
|
|
91
|
+
const planPath = req.params.key;
|
|
92
|
+
const blocks = reviewStore.getBlocks(planPath);
|
|
93
|
+
const recon = reviewStore.getLastReconciliation(planPath);
|
|
94
|
+
res.json({
|
|
95
|
+
blocks,
|
|
96
|
+
stale: Array.from(recon.stale.entries()).map(([id, previousExcerpt]) => ({
|
|
97
|
+
id,
|
|
98
|
+
previousExcerpt,
|
|
99
|
+
})),
|
|
100
|
+
orphaned: recon.orphaned,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
app.get("/api/sessions/:key/events", (req, res) => {
|
|
104
|
+
const planPath = req.params.key;
|
|
105
|
+
res.writeHead(200, {
|
|
106
|
+
"Content-Type": "text/event-stream",
|
|
107
|
+
"Cache-Control": "no-cache",
|
|
108
|
+
Connection: "keep-alive",
|
|
109
|
+
});
|
|
110
|
+
res.write("\n");
|
|
111
|
+
const list = sseClients.get(planPath) ?? [];
|
|
112
|
+
list.push(res);
|
|
113
|
+
sseClients.set(planPath, list);
|
|
114
|
+
req.on("close", () => {
|
|
115
|
+
const arr = sseClients.get(planPath) ?? [];
|
|
116
|
+
const idx = arr.indexOf(res);
|
|
117
|
+
if (idx !== -1)
|
|
118
|
+
arr.splice(idx, 1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
app.post("/api/sessions/:key/submit", (req, res) => {
|
|
122
|
+
const planPath = req.params.key;
|
|
123
|
+
const { verdict, globalComment, comments } = req.body;
|
|
124
|
+
const review = reviewStore.submitReview(planPath, verdict, globalComment ?? "", comments ?? []);
|
|
125
|
+
res.json({ ok: true, reviewId: review.reviewId });
|
|
126
|
+
});
|
|
127
|
+
app.get("/api/sessions/:key/poll", async (req, res) => {
|
|
128
|
+
const planPath = req.params.key;
|
|
129
|
+
const since = Number(req.query.since) || 0;
|
|
130
|
+
const review = await reviewStore.pollReview(planPath, since, LONG_POLL_TIMEOUT_MS);
|
|
131
|
+
if (!review) {
|
|
132
|
+
res.status(204).end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
res.json(review);
|
|
136
|
+
});
|
|
137
|
+
app.post("/api/sessions/:key/end", (req, res) => {
|
|
138
|
+
const planPath = req.params.key;
|
|
139
|
+
const session = sessionStore.endSession(planPath);
|
|
140
|
+
if (!session) {
|
|
141
|
+
res.status(404).json({ error: "no such session" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
res.json({ ok: true });
|
|
145
|
+
});
|
|
146
|
+
return app;
|
|
147
|
+
}
|
|
148
|
+
function startServer() {
|
|
149
|
+
const app = createApp();
|
|
150
|
+
const server = app.listen(PORT, "127.0.0.1", () => {
|
|
151
|
+
sessionStore.writeServerLock({ pid: process.pid, port: PORT });
|
|
152
|
+
});
|
|
153
|
+
server.on("error", (err) => {
|
|
154
|
+
console.error(`gigaplan server failed to start: ${String(err)}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (process.argv[1] === __filename) {
|
|
159
|
+
startServer();
|
|
160
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { STATE_DIR, STATE_FILE, SERVER_LOCK_FILE } from "./paths.js";
|
|
3
|
+
function ensureStateDir() {
|
|
4
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
5
|
+
}
|
|
6
|
+
function readState() {
|
|
7
|
+
ensureStateDir();
|
|
8
|
+
try {
|
|
9
|
+
const raw = fs.readFileSync(STATE_FILE, "utf8");
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return { sessions: {} };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeState(state) {
|
|
17
|
+
ensureStateDir();
|
|
18
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
19
|
+
}
|
|
20
|
+
export function getSession(planPath) {
|
|
21
|
+
return readState().sessions[planPath];
|
|
22
|
+
}
|
|
23
|
+
export function createOrReopenSession(planPath) {
|
|
24
|
+
const state = readState();
|
|
25
|
+
const existing = state.sessions[planPath];
|
|
26
|
+
if (existing) {
|
|
27
|
+
existing.ended = false;
|
|
28
|
+
writeState(state);
|
|
29
|
+
return existing;
|
|
30
|
+
}
|
|
31
|
+
const record = {
|
|
32
|
+
planPath,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
ended: false,
|
|
35
|
+
reopenable: true,
|
|
36
|
+
lastPolledReviewId: 0,
|
|
37
|
+
};
|
|
38
|
+
state.sessions[planPath] = record;
|
|
39
|
+
writeState(state);
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
export function endSession(planPath) {
|
|
43
|
+
const state = readState();
|
|
44
|
+
const existing = state.sessions[planPath];
|
|
45
|
+
if (!existing)
|
|
46
|
+
return undefined;
|
|
47
|
+
existing.ended = true;
|
|
48
|
+
existing.reopenable = true;
|
|
49
|
+
writeState(state);
|
|
50
|
+
return existing;
|
|
51
|
+
}
|
|
52
|
+
export function bumpLastPolledReviewId(planPath, reviewId) {
|
|
53
|
+
const state = readState();
|
|
54
|
+
const existing = state.sessions[planPath];
|
|
55
|
+
if (!existing)
|
|
56
|
+
return;
|
|
57
|
+
existing.lastPolledReviewId = reviewId;
|
|
58
|
+
writeState(state);
|
|
59
|
+
}
|
|
60
|
+
function isProcessAlive(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function readServerLock() {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(SERVER_LOCK_FILE, "utf8");
|
|
72
|
+
const lock = JSON.parse(raw);
|
|
73
|
+
if (isProcessAlive(lock.pid))
|
|
74
|
+
return lock;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function writeServerLock(lock) {
|
|
82
|
+
ensureStateDir();
|
|
83
|
+
fs.writeFileSync(SERVER_LOCK_FILE, JSON.stringify(lock, null, 2), "utf8");
|
|
84
|
+
}
|
|
85
|
+
export function clearServerLock() {
|
|
86
|
+
try {
|
|
87
|
+
fs.unlinkSync(SERVER_LOCK_FILE);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// already gone
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gigaplan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Review agent-authored implementation plans in a GitHub-PR-style browser UI. Agents write plain Markdown; gigaplan renders, themes, and collects structured feedback.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gigaplan": "bin/gigaplan.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"dist",
|
|
12
|
+
"public",
|
|
13
|
+
"skills"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.server.json && tsc -p tsconfig.client.json",
|
|
20
|
+
"prepare": "npm run build",
|
|
21
|
+
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.client.json",
|
|
22
|
+
"test": "node --import tsx --test test/**/*.test.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"chokidar": "^5.0.0",
|
|
26
|
+
"express": "^5.2.1",
|
|
27
|
+
"markdown-it": "^14.3.0",
|
|
28
|
+
"open": "^11.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/express": "^5.0.6",
|
|
32
|
+
"@types/markdown-it": "^14.1.2",
|
|
33
|
+
"@types/node": "^24.13.3",
|
|
34
|
+
"tsx": "^4.23.0",
|
|
35
|
+
"typescript": "^6.0.3"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"plan-review",
|
|
39
|
+
"code-review",
|
|
40
|
+
"claude-code",
|
|
41
|
+
"cli"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|