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 ADDED
@@ -0,0 +1,114 @@
1
+ # gigaplan
2
+
3
+ Review agent-authored implementation plans in a browser, styled as a proper
4
+ review tool rather than a wall of terminal text. The agent writes a plain
5
+ Markdown plan — never HTML — and gigaplan renders it into numbered, collapsible
6
+ sections with per-step inline comments, a review-coverage sidebar, and a
7
+ finish-your-review panel (Approve / Approve with comments / Request changes),
8
+ then hands the structured feedback back to the agent to act on.
9
+
10
+ The UI follows the "Atelier / Terminal" design system (warm-paper light theme,
11
+ phosphor-terminal dark theme, Bricolage Grotesque + JetBrains Mono type) —
12
+ see `skills/gigaplan/SKILL.md` for the review workflow and `public/chrome.css`
13
+ for the theme itself.
14
+
15
+ ## Getting started
16
+
17
+ gigaplan isn't published to npm yet, so for now it runs from a local checkout
18
+ of this repo:
19
+
20
+ ```
21
+ npm install # also builds it — see the `prepare` script
22
+ ```
23
+
24
+ Try it against any Markdown file:
25
+
26
+ ```
27
+ node bin/gigaplan.js review path/to/plan.md
28
+ ```
29
+
30
+ That opens your browser straight to the review page. Leave a few comments,
31
+ pick a verdict, and submit — then, in a second terminal:
32
+
33
+ ```
34
+ node bin/gigaplan.js poll path/to/plan.md
35
+ ```
36
+
37
+ blocks until you do, then prints back exactly the compact-markdown feedback an
38
+ agent would read. `gigaplan end path/to/plan.md` marks the session done
39
+ afterward (the shared local server itself stays running independently — see
40
+ Configuration below).
41
+
42
+ Optionally, run `npm link` once if you'd rather type the bare `gigaplan`
43
+ command than the `node bin/gigaplan.js` prefix.
44
+
45
+ ### Installing the skill
46
+
47
+ The point of gigaplan is for an *agent* — any agent, not tied to one product —
48
+ to drive this loop on your behalf: write the plan, open it for you, wait for
49
+ your review, and revise based on your comments, looping until you approve it.
50
+ Install `skills/gigaplan/SKILL.md` via skills.sh so it's picked up
51
+ automatically, without you needing to invoke anything by name.
52
+ `skills/gigaplan/SKILL.md` documents the exact workflow and CLI contract an
53
+ agent follows — read it if you want to know precisely what happens under the
54
+ hood.
55
+
56
+ Once gigaplan is published, `npx -y gigaplan ...` per command replaces the
57
+ local-checkout commands above — no local build needed, matching what the
58
+ skill already documents.
59
+
60
+ ## How it works
61
+
62
+ ```
63
+ agent writes plan.md (plain Markdown, never HTML)
64
+ -> npx -y gigaplan review <path> opens a browser tab rendering the plan as
65
+ numbered sections (## headings), each with
66
+ per-item inline comments
67
+ -> reviewer comments on specific steps and/or leaves overall feedback, then
68
+ selects Approve / Request changes and submits
69
+ -> npx -y gigaplan poll <path> blocks, then prints the review as compact markdown
70
+ -> agent revises plan.md and loops back to `review` (reopens the same tab) until approved
71
+ -> npx -y gigaplan end <path> marks the session done
72
+ ```
73
+
74
+ Live-reload: edit the plan file while the tab is open and it updates without a
75
+ manual refresh. A comment anchored to a section whose text changed is flagged
76
+ "content changed since this comment was left"; a comment on a deleted section is
77
+ kept, not silently dropped, in a "Comments on removed content" area.
78
+
79
+ ## CLI
80
+
81
+ ```
82
+ gigaplan review <path> Open (or reuse) the browser review session for this plan file.
83
+ gigaplan poll <path> Block until a review is submitted; print it as compact markdown.
84
+ gigaplan end <path> Mark the review session done (the shared local server stays up;
85
+ it shuts itself down after a period of inactivity).
86
+ ```
87
+
88
+ Locally (pre-publish), that's `node bin/gigaplan.js <command>` unless you've
89
+ `npm link`-ed it (see Getting started).
90
+
91
+ ## Configuration
92
+
93
+ | Env var | Default | Purpose |
94
+ | --- | --- | --- |
95
+ | `GIGAPLAN_PORT` | `4873` | Port the shared local server binds to (loopback-only). |
96
+ | `GIGAPLAN_IDLE_TIMEOUT_MS` | `1800000` (30 min) | How long the server stays up with no requests before it shuts itself down. |
97
+
98
+ State (session records and the server's pid/port lock) lives in `~/.gigaplan/`.
99
+
100
+ ## Contributing
101
+
102
+ ```
103
+ npm run typecheck # tsc --noEmit against both the server and client TypeScript
104
+ npm test # node:test, run directly against .ts sources via tsx
105
+ npm run build # compiles src/*.ts -> dist/, client/chrome.ts -> public/chrome.js
106
+ ```
107
+
108
+ Read `CLAUDE.md` first — it covers the architecture (why the project is
109
+ ESM-only, the two separate TypeScript compilations for server vs. browser
110
+ code, the request flow through the server, how comment anchoring and
111
+ live-reload reconciliation work) and what manual verification looks like for
112
+ the parts of the codebase the test suite doesn't cover (the CLI process
113
+ lifecycle and the browser UI have no automated coverage — changes there need
114
+ an actual browser, not just a green type-check).
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/cli.js";
3
+ main();
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as http from "node:http";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import open from "open";
7
+ import { canonicalPlanPath, PORT, LONG_POLL_TIMEOUT_MS } from "./paths.js";
8
+ import { readServerLock, getSession, bumpLastPolledReviewId } from "./session-store.js";
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ function printUsage() {
11
+ console.log([
12
+ "Usage: gigaplan <command> <path-to-plan.md>",
13
+ "",
14
+ "Commands:",
15
+ " review <path> Open (or reuse) the browser review session for this plan file",
16
+ " poll <path> Block until a review is submitted; print it as compact markdown",
17
+ " end <path> Mark the review session done",
18
+ ].join("\n"));
19
+ }
20
+ function requestJson(method, urlPath, body, timeoutMs = 10_000) {
21
+ return new Promise((resolve, reject) => {
22
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
23
+ const req = http.request({
24
+ host: "127.0.0.1",
25
+ port: PORT,
26
+ path: urlPath,
27
+ method,
28
+ timeout: timeoutMs,
29
+ headers: payload
30
+ ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
31
+ : undefined,
32
+ }, (res) => {
33
+ const chunks = [];
34
+ res.on("data", (chunk) => chunks.push(chunk));
35
+ res.on("end", () => {
36
+ const text = Buffer.concat(chunks).toString("utf8");
37
+ const status = res.statusCode ?? 0;
38
+ if (status === 204 || text.length === 0) {
39
+ resolve({ status, body: undefined });
40
+ return;
41
+ }
42
+ try {
43
+ resolve({ status, body: JSON.parse(text) });
44
+ }
45
+ catch (err) {
46
+ reject(err);
47
+ }
48
+ });
49
+ });
50
+ req.on("error", reject);
51
+ req.on("timeout", () => req.destroy(new Error("request timed out")));
52
+ if (payload)
53
+ req.write(payload);
54
+ req.end();
55
+ });
56
+ }
57
+ async function ensureServerRunning() {
58
+ const existing = readServerLock();
59
+ if (existing)
60
+ return;
61
+ const serverEntry = path.join(__dirname, "server.js");
62
+ const child = spawn(process.execPath, [serverEntry], {
63
+ detached: true,
64
+ stdio: "ignore",
65
+ });
66
+ child.unref();
67
+ // Wait for the freshly spawned server to bind and write its lock file.
68
+ const deadline = Date.now() + 5000;
69
+ while (Date.now() < deadline) {
70
+ if (readServerLock())
71
+ return;
72
+ await new Promise((r) => setTimeout(r, 100));
73
+ }
74
+ throw new Error("gigaplan server did not start in time");
75
+ }
76
+ async function cmdReview(rawPath) {
77
+ if (!rawPath) {
78
+ console.error("gigaplan review: missing <path-to-plan.md>");
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const planPath = canonicalPlanPath(rawPath);
83
+ if (!fs.existsSync(planPath)) {
84
+ console.error(`gigaplan review: plan file not found: ${planPath}`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ await ensureServerRunning();
89
+ const { status, body } = await requestJson("POST", "/api/sessions", { planPath });
90
+ if (status !== 200) {
91
+ console.error(`gigaplan review: ${body.error ?? "failed to create session"}`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ await open(body.url);
96
+ console.log(`Opened for review: ${body.url}`);
97
+ }
98
+ function verdictTitle(verdict) {
99
+ if (verdict === "approve")
100
+ return "approved";
101
+ if (verdict === "request_changes")
102
+ return "request changes";
103
+ return "comment";
104
+ }
105
+ function formatReviewAsMarkdown(review) {
106
+ const lines = [];
107
+ lines.push(`# Review: ${verdictTitle(review.verdict)}`);
108
+ lines.push("");
109
+ lines.push("## Overall comment");
110
+ lines.push(review.globalComment.trim().length > 0 ? review.globalComment.trim() : "(none)");
111
+ if (review.comments.length > 0) {
112
+ lines.push("");
113
+ lines.push("## Comments");
114
+ for (const comment of review.comments) {
115
+ const breadcrumb = comment.headingBreadcrumb.join(" > ") || comment.excerpt || comment.blockId;
116
+ lines.push(`### On "${breadcrumb}"`);
117
+ if (comment.stale) {
118
+ lines.push("_(content at this location changed since this comment was left)_");
119
+ }
120
+ lines.push(comment.body);
121
+ lines.push("");
122
+ }
123
+ }
124
+ return lines.join("\n").trimEnd() + "\n";
125
+ }
126
+ async function cmdPoll(rawPath) {
127
+ if (!rawPath) {
128
+ console.error("gigaplan poll: missing <path-to-plan.md>");
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ const planPath = canonicalPlanPath(rawPath);
133
+ if (!readServerLock()) {
134
+ console.error("gigaplan poll: no gigaplan server is running; run `gigaplan review <path>` first");
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ const sessionKey = encodeURIComponent(planPath);
139
+ const since = getSession(planPath)?.lastPolledReviewId ?? 0;
140
+ for (;;) {
141
+ const { status, body } = await requestJson("GET", `/api/sessions/${sessionKey}/poll?since=${since}`, undefined, LONG_POLL_TIMEOUT_MS + 5000);
142
+ if (status === 204)
143
+ continue;
144
+ bumpLastPolledReviewId(planPath, body.reviewId);
145
+ process.stdout.write(formatReviewAsMarkdown(body));
146
+ return;
147
+ }
148
+ }
149
+ async function cmdEnd(rawPath) {
150
+ if (!rawPath) {
151
+ console.error("gigaplan end: missing <path-to-plan.md>");
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ const planPath = canonicalPlanPath(rawPath);
156
+ const sessionKey = encodeURIComponent(planPath);
157
+ const { status } = await requestJson("POST", `/api/sessions/${sessionKey}/end`, {});
158
+ if (status !== 200) {
159
+ console.error(`gigaplan end: no session found for ${planPath}`);
160
+ process.exitCode = 1;
161
+ return;
162
+ }
163
+ console.log(`Review session ended for ${planPath}`);
164
+ }
165
+ export function main() {
166
+ const [, , command, arg] = process.argv;
167
+ const run = async () => {
168
+ switch (command) {
169
+ case "review":
170
+ await cmdReview(arg);
171
+ break;
172
+ case "poll":
173
+ await cmdPoll(arg);
174
+ break;
175
+ case "end":
176
+ await cmdEnd(arg);
177
+ break;
178
+ default:
179
+ printUsage();
180
+ if (command)
181
+ process.exitCode = 1;
182
+ }
183
+ };
184
+ run().catch((err) => {
185
+ console.error(String(err instanceof Error ? err.message : err));
186
+ process.exitCode = 1;
187
+ });
188
+ }
@@ -0,0 +1,196 @@
1
+ import * as crypto from "node:crypto";
2
+ import MarkdownIt from "markdown-it";
3
+ const md = new MarkdownIt({ html: false, linkify: true });
4
+ function isOpen(type) {
5
+ return type.endsWith("_open");
6
+ }
7
+ function isClose(type) {
8
+ return type.endsWith("_close");
9
+ }
10
+ function groupTopLevelBlocks(tokens) {
11
+ const groups = [];
12
+ let current = [];
13
+ let depth = 0;
14
+ for (const token of tokens) {
15
+ current.push(token);
16
+ if (isOpen(token.type)) {
17
+ depth++;
18
+ }
19
+ else if (isClose(token.type)) {
20
+ depth--;
21
+ }
22
+ if (depth === 0) {
23
+ groups.push(current);
24
+ current = [];
25
+ }
26
+ }
27
+ if (current.length > 0)
28
+ groups.push(current);
29
+ return groups;
30
+ }
31
+ function blockType(group) {
32
+ const first = group[0];
33
+ const raw = isOpen(first.type) ? first.type.slice(0, -"_open".length) : first.type;
34
+ // Indented (4-space) code blocks render the same as fenced ones; treat them alike.
35
+ return raw === "code_block" ? "fence" : raw;
36
+ }
37
+ const CHECKLIST_RE = /^\[([ xX])\]\s+(.*)$/s;
38
+ function splitListIntoItemGroups(group) {
39
+ // Exclude the outer list_open/list_close tokens; groupTopLevelBlocks is
40
+ // depth-relative, so it happily regroups the inner tokens by list_item.
41
+ return groupTopLevelBlocks(group.slice(1, group.length - 1));
42
+ }
43
+ // markdown-it's renderer includes the <li>...</li> wrapper itself; the client
44
+ // re-wraps each item in its own bullet/ordinal/checkbox markup instead of a
45
+ // real <ul>/<ol>, and a bare <li> outside a list container still gets the
46
+ // browser's default disc marker — so the wrapper has to come off here.
47
+ function stripListItemWrapper(html) {
48
+ return html.replace(/^\s*<li[^>]*>\s*/, "").replace(/\s*<\/li>\s*$/, "");
49
+ }
50
+ function plainText(group) {
51
+ const parts = [];
52
+ for (const token of group) {
53
+ if (token.type === "inline" && token.children) {
54
+ for (const child of token.children) {
55
+ if (child.type === "text" || child.type === "code_inline") {
56
+ parts.push(child.content);
57
+ }
58
+ }
59
+ }
60
+ else if (token.type === "fence" || token.type === "code_block") {
61
+ parts.push(token.content);
62
+ }
63
+ }
64
+ return parts.join(" ");
65
+ }
66
+ function normalize(text) {
67
+ return text.trim().replace(/\s+/g, " ");
68
+ }
69
+ function hashOf(text) {
70
+ return crypto.createHash("sha256").update(normalize(text)).digest("hex").slice(0, 6);
71
+ }
72
+ function excerptOf(text) {
73
+ const normalized = normalize(text);
74
+ return normalized.length > 80 ? `${normalized.slice(0, 80)}…` : normalized;
75
+ }
76
+ const HEADING_TAG_LEVEL = {
77
+ h1: 1,
78
+ h2: 2,
79
+ h3: 3,
80
+ h4: 4,
81
+ h5: 5,
82
+ h6: 6,
83
+ };
84
+ export function parseBlocks(source) {
85
+ const tokens = md.parse(source, {});
86
+ const groups = groupTopLevelBlocks(tokens);
87
+ const blocks = [];
88
+ const headingStack = [];
89
+ let nextId = 1;
90
+ for (const group of groups) {
91
+ const type = blockType(group);
92
+ if (type === "bullet_list" || type === "ordered_list") {
93
+ const listId = `l${nextId}`;
94
+ const listKind = type === "ordered_list" ? "ordered" : "bullet";
95
+ const startAttr = group[0].attrGet?.("start");
96
+ let ordinal = startAttr ? Number(startAttr) : 1;
97
+ const ancestorBreadcrumb = headingStack.map((h) => h.text);
98
+ for (const itemGroup of splitListIntoItemGroups(group)) {
99
+ const rawText = plainText(itemGroup);
100
+ const checklistMatch = CHECKLIST_RE.exec(rawText.trim());
101
+ const checklist = checklistMatch !== null;
102
+ const text = checklist ? checklistMatch[2] : rawText;
103
+ let html = stripListItemWrapper(md.renderer.render(itemGroup, md.options, {}));
104
+ if (checklist) {
105
+ html = html.replace(/\[[ xX]\]\s*/, "");
106
+ }
107
+ blocks.push({
108
+ id: `b${nextId++}`,
109
+ hash: hashOf(text || html),
110
+ type: "list_item",
111
+ headingBreadcrumb: ancestorBreadcrumb,
112
+ html,
113
+ excerpt: excerptOf(text),
114
+ listId,
115
+ listKind,
116
+ checklist,
117
+ ...(listKind === "ordered" ? { ordinal: ordinal++ } : {}),
118
+ });
119
+ }
120
+ continue;
121
+ }
122
+ const text = plainText(group);
123
+ const html = md.renderer.render(group, md.options, {});
124
+ const first = group[0];
125
+ const headingLevel = first.type === "heading_open" ? HEADING_TAG_LEVEL[first.tag] : undefined;
126
+ if (headingLevel !== undefined) {
127
+ while (headingStack.length > 0 &&
128
+ headingStack[headingStack.length - 1].level >= headingLevel) {
129
+ headingStack.pop();
130
+ }
131
+ }
132
+ const ancestorBreadcrumb = headingStack.map((h) => h.text);
133
+ const headingBreadcrumb = headingLevel !== undefined ? [...ancestorBreadcrumb, normalize(text)] : ancestorBreadcrumb;
134
+ blocks.push({
135
+ id: `b${nextId++}`,
136
+ hash: hashOf(text || html),
137
+ type: type,
138
+ headingBreadcrumb,
139
+ html,
140
+ excerpt: excerptOf(text),
141
+ ...(headingLevel !== undefined ? { headingLevel } : {}),
142
+ });
143
+ if (headingLevel !== undefined) {
144
+ headingStack.push({ level: headingLevel, text: normalize(text) });
145
+ }
146
+ }
147
+ return blocks;
148
+ }
149
+ export function reconcileBlocks(oldBlocks, freshBlocks) {
150
+ const oldByHash = new Map();
151
+ for (const block of oldBlocks) {
152
+ const bucket = oldByHash.get(block.hash) ?? [];
153
+ bucket.push(block);
154
+ oldByHash.set(block.hash, bucket);
155
+ }
156
+ const claimedOldIds = new Set();
157
+ const claimedNewIndexes = new Set();
158
+ const result = new Array(freshBlocks.length);
159
+ // Pass 1: exact content match (block unchanged, possibly moved).
160
+ freshBlocks.forEach((fresh, index) => {
161
+ const candidates = oldByHash.get(fresh.hash);
162
+ if (candidates && candidates.length > 0) {
163
+ const old = candidates.shift();
164
+ claimedOldIds.add(old.id);
165
+ claimedNewIndexes.add(index);
166
+ result[index] = { ...fresh, id: old.id };
167
+ }
168
+ });
169
+ const unclaimedOld = oldBlocks.filter((b) => !claimedOldIds.has(b.id));
170
+ const unclaimedNewIndexes = freshBlocks
171
+ .map((_, index) => index)
172
+ .filter((index) => !claimedNewIndexes.has(index));
173
+ // Pass 2: positional match among what's left (block edited in place).
174
+ const stale = [];
175
+ const pairCount = Math.min(unclaimedOld.length, unclaimedNewIndexes.length);
176
+ for (let k = 0; k < pairCount; k++) {
177
+ const old = unclaimedOld[k];
178
+ const newIndex = unclaimedNewIndexes[k];
179
+ result[newIndex] = { ...freshBlocks[newIndex], id: old.id };
180
+ stale.push({ id: old.id, previousExcerpt: old.excerpt });
181
+ }
182
+ // Leftover new blocks are pure insertions; give them ids past any existing max.
183
+ const maxIdNum = Math.max(0, ...oldBlocks.map((b) => Number(b.id.slice(1)) || 0));
184
+ let nextId = maxIdNum + 1;
185
+ for (let k = pairCount; k < unclaimedNewIndexes.length; k++) {
186
+ const newIndex = unclaimedNewIndexes[k];
187
+ result[newIndex] = { ...freshBlocks[newIndex], id: `b${nextId++}` };
188
+ }
189
+ // Leftover old blocks were deleted; keep their comments addressable as orphaned.
190
+ const orphaned = unclaimedOld.slice(pairCount).map((b) => ({
191
+ id: b.id,
192
+ excerpt: b.excerpt,
193
+ headingBreadcrumb: b.headingBreadcrumb,
194
+ }));
195
+ return { blocks: result, stale, orphaned };
196
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,13 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ export const STATE_DIR = path.join(os.homedir(), ".gigaplan");
4
+ export const STATE_FILE = path.join(STATE_DIR, "state.json");
5
+ export const SERVER_LOCK_FILE = path.join(STATE_DIR, "server.json");
6
+ export const DEFAULT_PORT = 4873;
7
+ export const PORT = Number(process.env.GIGAPLAN_PORT) || DEFAULT_PORT;
8
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
9
+ export const IDLE_TIMEOUT_MS = Number(process.env.GIGAPLAN_IDLE_TIMEOUT_MS) || DEFAULT_IDLE_TIMEOUT_MS;
10
+ export const LONG_POLL_TIMEOUT_MS = 60_000;
11
+ export function canonicalPlanPath(inputPath) {
12
+ return path.resolve(process.cwd(), inputPath);
13
+ }
package/dist/render.js ADDED
@@ -0,0 +1,48 @@
1
+ import * as path from "node:path";
2
+ function escapeHtml(text) {
3
+ return text
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;");
8
+ }
9
+ // Guards against a code fence or comment containing a literal `</script>` sequence
10
+ // from prematurely closing the embedded JSON island (see gigaplan/project-artifact
11
+ // precedent: escape `<` as < inside any JSON blob placed inside a <script> tag).
12
+ function embedJson(data) {
13
+ return JSON.stringify(data).replace(/</g, "\\u003c");
14
+ }
15
+ function planTitle(planPath, blocks) {
16
+ const firstHeading = blocks.find((b) => b.headingLevel === 1);
17
+ if (firstHeading)
18
+ return firstHeading.headingBreadcrumb[firstHeading.headingBreadcrumb.length - 1];
19
+ return path.basename(planPath);
20
+ }
21
+ export function renderSessionPage(sessionKey, planPath, blocks) {
22
+ const title = escapeHtml(planTitle(planPath, blocks));
23
+ const initialData = embedJson({ sessionKey, planPath, blocks });
24
+ // client/chrome.ts builds the entire visible page (top bar, sections, sidebar,
25
+ // finish panel) from the JSON island below — this shell only supplies the
26
+ // theme tokens, fonts, and mount point.
27
+ return `<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
32
+ <title>${title} · gigaplan</title>
33
+ <link rel="stylesheet" href="/public/chrome.css" />
34
+ <script>
35
+ (function () {
36
+ var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
37
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
38
+ })();
39
+ </script>
40
+ </head>
41
+ <body>
42
+ <div id="gp-app" class="gp-app"></div>
43
+ <script type="application/json" id="gigaplan-data">${initialData}</script>
44
+ <script type="module" src="/public/chrome.js"></script>
45
+ </body>
46
+ </html>
47
+ `;
48
+ }
@@ -0,0 +1,84 @@
1
+ const runtimes = new Map();
2
+ function getRuntime(planPath) {
3
+ let runtime = runtimes.get(planPath);
4
+ if (!runtime) {
5
+ runtime = {
6
+ blocks: [],
7
+ reviews: [],
8
+ nextReviewId: 0,
9
+ waiters: [],
10
+ lastReconciliation: { stale: new Map(), orphaned: [] },
11
+ };
12
+ runtimes.set(planPath, runtime);
13
+ }
14
+ return runtime;
15
+ }
16
+ export function setBlocks(planPath, blocks) {
17
+ getRuntime(planPath).blocks = blocks;
18
+ }
19
+ export function getBlocks(planPath) {
20
+ return getRuntime(planPath).blocks;
21
+ }
22
+ export function applyReconciliation(planPath, result) {
23
+ const runtime = getRuntime(planPath);
24
+ runtime.blocks = result.blocks;
25
+ runtime.lastReconciliation = {
26
+ stale: new Map(result.stale.map((s) => [s.id, s.previousExcerpt])),
27
+ orphaned: result.orphaned,
28
+ };
29
+ }
30
+ export function getLastReconciliation(planPath) {
31
+ return getRuntime(planPath).lastReconciliation;
32
+ }
33
+ export function submitReview(planPath, verdict, globalComment, comments) {
34
+ const runtime = getRuntime(planPath);
35
+ const blockById = new Map(runtime.blocks.map((b) => [b.id, b]));
36
+ const staleIds = runtime.lastReconciliation.stale;
37
+ const enriched = comments.map((c) => {
38
+ const block = blockById.get(c.blockId);
39
+ return {
40
+ blockId: c.blockId,
41
+ headingBreadcrumb: block?.headingBreadcrumb ?? [],
42
+ excerpt: block?.excerpt ?? "",
43
+ body: c.body,
44
+ stale: staleIds.has(c.blockId),
45
+ };
46
+ });
47
+ const review = {
48
+ reviewId: ++runtime.nextReviewId,
49
+ verdict,
50
+ globalComment,
51
+ comments: enriched,
52
+ submittedAt: new Date().toISOString(),
53
+ };
54
+ runtime.reviews.push(review);
55
+ runtime.lastReconciliation = { stale: new Map(), orphaned: [] };
56
+ const waiters = runtime.waiters.splice(0, runtime.waiters.length);
57
+ for (const waiter of waiters) {
58
+ clearTimeout(waiter.timer);
59
+ waiter.resolve(review);
60
+ }
61
+ return review;
62
+ }
63
+ export function latestReview(planPath) {
64
+ const runtime = getRuntime(planPath);
65
+ return runtime.reviews[runtime.reviews.length - 1];
66
+ }
67
+ export function pollReview(planPath, sinceReviewId, timeoutMs) {
68
+ const runtime = getRuntime(planPath);
69
+ const existing = runtime.reviews.find((r) => r.reviewId > sinceReviewId);
70
+ if (existing)
71
+ return Promise.resolve(existing);
72
+ return new Promise((resolve) => {
73
+ const timer = setTimeout(() => {
74
+ const index = runtime.waiters.findIndex((w) => w.resolve === resolveOnce);
75
+ if (index !== -1)
76
+ runtime.waiters.splice(index, 1);
77
+ resolve(null);
78
+ }, timeoutMs);
79
+ function resolveOnce(review) {
80
+ resolve(review);
81
+ }
82
+ runtime.waiters.push({ resolve: resolveOnce, timer });
83
+ });
84
+ }