@delete101020/aidlc 4.0.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.
Files changed (93) hide show
  1. package/AUTOMATION.md +148 -0
  2. package/README.md +684 -0
  3. package/dist/assets/annotate-artifact.skill.md +121 -0
  4. package/dist/assets/epic-context.skill.md +55 -0
  5. package/dist/bundle.js +49470 -0
  6. package/dist/templates/ainative/agents/native-engineer.md +66 -0
  7. package/dist/templates/ainative/agents/native-operator.md +71 -0
  8. package/dist/templates/ainative/agents/native-originator.md +56 -0
  9. package/dist/templates/ainative/agents/native-product-owner.md +68 -0
  10. package/dist/templates/ainative/agents/native-reviewer.md +65 -0
  11. package/dist/templates/ainative/agents/native-verifier.md +58 -0
  12. package/dist/templates/ainative/artifacts/build-plan.md +56 -0
  13. package/dist/templates/ainative/artifacts/implement.md +55 -0
  14. package/dist/templates/ainative/artifacts/intent.md +50 -0
  15. package/dist/templates/ainative/artifacts/maintain.md +85 -0
  16. package/dist/templates/ainative/artifacts/review.md +68 -0
  17. package/dist/templates/ainative/artifacts/spec.md +69 -0
  18. package/dist/templates/ainative/artifacts/verify.md +55 -0
  19. package/dist/templates/ainative/skills/native-build-plan.md +74 -0
  20. package/dist/templates/ainative/skills/native-implement.md +62 -0
  21. package/dist/templates/ainative/skills/native-intent.md +50 -0
  22. package/dist/templates/ainative/skills/native-maintain.md +94 -0
  23. package/dist/templates/ainative/skills/native-review.md +82 -0
  24. package/dist/templates/ainative/skills/native-spec.md +83 -0
  25. package/dist/templates/ainative/skills/native-verify.md +60 -0
  26. package/dist/templates/sdlc/agents/designer.md +87 -0
  27. package/dist/templates/sdlc/agents/developer.md +178 -0
  28. package/dist/templates/sdlc/agents/example-with-skills.md +59 -0
  29. package/dist/templates/sdlc/agents/orchestrator.md +186 -0
  30. package/dist/templates/sdlc/agents/po.md +115 -0
  31. package/dist/templates/sdlc/agents/qa.md +141 -0
  32. package/dist/templates/sdlc/agents/tech-lead.md +124 -0
  33. package/dist/templates/sdlc/artifacts/design.md +130 -0
  34. package/dist/templates/sdlc/artifacts/execute-test.md +64 -0
  35. package/dist/templates/sdlc/artifacts/implement.backend.md +96 -0
  36. package/dist/templates/sdlc/artifacts/implement.md +70 -0
  37. package/dist/templates/sdlc/artifacts/implement.web-react.md +109 -0
  38. package/dist/templates/sdlc/artifacts/implement.web.md +99 -0
  39. package/dist/templates/sdlc/artifacts/plan.md +97 -0
  40. package/dist/templates/sdlc/artifacts/prototype.md +141 -0
  41. package/dist/templates/sdlc/artifacts/test-cases.md +47 -0
  42. package/dist/templates/sdlc/artifacts/test-plan.md +100 -0
  43. package/dist/templates/sdlc/artifacts/unit-test.md +32 -0
  44. package/dist/templates/sdlc/profiles/agile-lite.yaml +29 -0
  45. package/dist/templates/sdlc/profiles/hybrid.yaml +43 -0
  46. package/dist/templates/sdlc/profiles/iso-ieee.yaml +67 -0
  47. package/dist/templates/sdlc/profiles/none.yaml +21 -0
  48. package/dist/templates/sdlc/skills/advance-epic.md +77 -0
  49. package/dist/templates/sdlc/skills/analyze-requirements.md +279 -0
  50. package/dist/templates/sdlc/skills/discovery-gate.md +118 -0
  51. package/dist/templates/sdlc/skills/execute-test.md +74 -0
  52. package/dist/templates/sdlc/skills/generate-test-cases.md +77 -0
  53. package/dist/templates/sdlc/skills/implement.md +74 -0
  54. package/dist/templates/sdlc/skills/prd.md +114 -0
  55. package/dist/templates/sdlc/skills/prototype.md +128 -0
  56. package/dist/templates/sdlc/skills/tech-design.md +134 -0
  57. package/dist/templates/sdlc/skills/test-plan.md +115 -0
  58. package/dist/templates/sdlc/skills/test-report.md +38 -0
  59. package/dist/templates/sdlc/skills/unit-test.md +43 -0
  60. package/dist/templates/sdlc/validators/ci.mjs +49 -0
  61. package/dist/templates/sdlc/validators/traceability.mjs +244 -0
  62. package/dist/templates/speckit/agents/analyst.md +42 -0
  63. package/dist/templates/speckit/agents/speckit-developer.md +36 -0
  64. package/dist/templates/speckit/agents/speckit-qa.md +38 -0
  65. package/dist/templates/speckit/agents/speckit-tech-lead.md +40 -0
  66. package/dist/templates/speckit/artifacts/analyze.md +48 -0
  67. package/dist/templates/speckit/artifacts/clarify.md +64 -0
  68. package/dist/templates/speckit/artifacts/plan.md +57 -0
  69. package/dist/templates/speckit/artifacts/specify.md +64 -0
  70. package/dist/templates/speckit/artifacts/tasks.md +33 -0
  71. package/dist/templates/speckit/skills/analyze.md +50 -0
  72. package/dist/templates/speckit/skills/clarify.md +31 -0
  73. package/dist/templates/speckit/skills/plan.md +56 -0
  74. package/dist/templates/speckit/skills/specify.md +52 -0
  75. package/dist/templates/speckit/skills/speckit-implement.md +33 -0
  76. package/dist/templates/speckit/skills/tasks.md +38 -0
  77. package/dist/tools/epic-memory-hook.mjs +78 -0
  78. package/dist/tools/epic-memory.mjs +130 -0
  79. package/dist/tools/md-to-html.mjs +478 -0
  80. package/dist/tools/vendor/marked.esm.mjs +2432 -0
  81. package/dist/vendor/annotron/LICENSE +21 -0
  82. package/dist/vendor/annotron/README.md +213 -0
  83. package/dist/vendor/annotron/bin/annotron +467 -0
  84. package/dist/vendor/annotron/commands/annotron.md +11 -0
  85. package/dist/vendor/annotron/hooks/annotron-hook.sh +38 -0
  86. package/dist/vendor/annotron/hooks/hooks.json +50 -0
  87. package/dist/vendor/annotron/package.json +72 -0
  88. package/dist/vendor/annotron/skills/annotron-review/SKILL.md +86 -0
  89. package/dist/vendor/annotron/src/chrome.html +2029 -0
  90. package/dist/vendor/annotron/src/mdRender.js +208 -0
  91. package/dist/vendor/annotron/src/sdk.js +1042 -0
  92. package/dist/vendor/annotron/src/server.js +780 -0
  93. package/package.json +60 -0
@@ -0,0 +1,780 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
6
+ import { execFile } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { fileURLToPath } from 'url';
9
+ import { renderMarkdown } from './mdRender.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const execFileP = promisify(execFile);
13
+
14
+ // A source Markdown artifact is rendered to HTML on the fly; the .md stays the
15
+ // source of truth and is what the Save button writes back to.
16
+ const isMarkdown = (f) => /\.(md|markdown)$/i.test(f);
17
+
18
+ // --- Reviewer identity ---------------------------------------------------------
19
+ // Who is leaving comments? Prefer the GitHub login (via `gh`), then the git
20
+ // user.name/email, then the OS user@hostname. Computed once and cached — this is
21
+ // a single-user local tool. PATH is augmented so `gh`/`git` resolve even when
22
+ // the server was spawned from a GUI (Dock) with a minimal PATH.
23
+ let _identityPromise = null;
24
+ function identityEnv() {
25
+ const home = process.env.HOME || '';
26
+ const extra = ['/opt/homebrew/bin', '/usr/local/bin', `${home}/.local/bin`].filter(Boolean).join(':');
27
+ return { ...process.env, PATH: `${process.env.PATH || ''}:${extra}` };
28
+ }
29
+ async function runId(cmd, args, cwd) {
30
+ try {
31
+ const { stdout } = await execFileP(cmd, args, { cwd, timeout: 2500, env: identityEnv() });
32
+ return stdout.toString().trim();
33
+ } catch { return ''; }
34
+ }
35
+ async function computeIdentity(cwd) {
36
+ const [gitName, gitEmail, login] = await Promise.all([
37
+ runId('git', ['config', 'user.name'], cwd),
38
+ runId('git', ['config', 'user.email'], cwd),
39
+ runId('gh', ['api', 'user', '--jq', '.login'], cwd),
40
+ ]);
41
+ let user = '', host = '';
42
+ try { user = os.userInfo().username; } catch {}
43
+ try { host = os.hostname().replace(/\.local$/, ''); } catch {}
44
+ const name = login || gitName || user || 'Reviewer';
45
+ const userHost = user && host ? `${user}@${host}` : (host || user);
46
+ const detail = login ? (gitEmail || userHost) : (gitEmail || userHost);
47
+ return { name, detail, login, gitName, gitEmail, user, host };
48
+ }
49
+ function getIdentity(cwd) {
50
+ if (!_identityPromise) _identityPromise = computeIdentity(cwd);
51
+ return _identityPromise;
52
+ }
53
+
54
+ const PORT = parseInt(process.env.ANNOTRON_PORT || '7321', 10);
55
+ const HOST = process.env.ANNOTRON_HOST || '127.0.0.1';
56
+
57
+ // --- State ---
58
+ const sessions = new Map(); // filePath -> { hash, watchers, sseClients, pollWaiters, agentReplies }
59
+ const allowList = new Set(); // registered canonical paths
60
+
61
+ function getSession(file) {
62
+ if (!sessions.has(file)) {
63
+ sessions.set(file, {
64
+ hash: null,
65
+ sseClients: new Set(),
66
+ pollWaiters: [],
67
+ pendingFeedback: null,
68
+ finalized: false,
69
+ cancelRequested: false,
70
+ working: false,
71
+ remoteApprove: false, // route permission prompts to the browser
72
+ autoAllowTools: new Set(), // tools the user chose "allow always" for
73
+ permWaiters: new Map(), // requestId -> resolve(decisionObj|null)
74
+ pendingPerms: new Map(), // requestId -> {tool, summary} (for reconnecting clients)
75
+ });
76
+ }
77
+ return sessions.get(file);
78
+ }
79
+
80
+ // --- File watcher ---
81
+ const watchIntervals = new Map();
82
+
83
+ function watchFile(file) {
84
+ if (watchIntervals.has(file)) return;
85
+ let lastHash = hashFile(file);
86
+ const iv = setInterval(() => {
87
+ const h = hashFile(file);
88
+ if (h && h !== lastHash) {
89
+ lastHash = h;
90
+ const sess = sessions.get(file);
91
+ if (!sess) return;
92
+ // Only trigger reload if not a programmatic finalize write
93
+ if (sess._suppressReload) { sess._suppressReload = false; return; }
94
+ broadcastSSE(file, 'reload', '{}');
95
+ }
96
+ }, 800);
97
+ watchIntervals.set(file, iv);
98
+ }
99
+
100
+ function hashFile(file) {
101
+ try {
102
+ const buf = fs.readFileSync(file);
103
+ return crypto.createHash('sha256').update(buf).digest('hex');
104
+ } catch { return null; }
105
+ }
106
+
107
+ function sidecarPath(file) {
108
+ const ext = path.extname(file);
109
+ return file.slice(0, -ext.length) + '.annotron.json';
110
+ }
111
+
112
+ function readSidecar(file) {
113
+ try {
114
+ const raw = fs.readFileSync(sidecarPath(file), 'utf8');
115
+ return JSON.parse(raw);
116
+ } catch { return { version: 1, annotations: [], rounds: [] }; }
117
+ }
118
+
119
+ function writeSidecar(file, data) {
120
+ fs.writeFileSync(sidecarPath(file), JSON.stringify(data, null, 2), 'utf8');
121
+ }
122
+
123
+ // --- SSE broadcast ---
124
+ function broadcastSSE(file, event, data) {
125
+ const sess = sessions.get(file);
126
+ if (!sess) return;
127
+ const msg = `event: ${event}\ndata: ${data}\n\n`;
128
+ for (const res of sess.sseClients) {
129
+ try { res.write(msg); } catch {}
130
+ }
131
+ }
132
+
133
+ // --- Poll wake ---
134
+ function wakePoll(file, payload) {
135
+ const sess = sessions.get(file);
136
+ if (!sess) return;
137
+ for (const resolve of sess.pollWaiters) resolve(payload);
138
+ sess.pollWaiters = [];
139
+ }
140
+
141
+ // --- Hook helpers ---
142
+ // Correlate an incoming hook event (which only knows cwd) to the annotron
143
+ // session it belongs to. Prefer a session that is actively working; if several,
144
+ // disambiguate by cwd containment.
145
+ function findActiveSession(cwd) {
146
+ const working = [];
147
+ for (const [f, s] of sessions) if (s.working) working.push([f, s]);
148
+ if (working.length === 0) return null;
149
+ if (working.length === 1) return working[0];
150
+ const norm = cwd ? cwd.replace(/\/+$/, '') + '/' : '';
151
+ const match = norm && working.find(([f]) => f.startsWith(norm));
152
+ return match || working[0];
153
+ }
154
+
155
+ // Like findActiveSession but also matches an idle (non-working) session under
156
+ // cwd — used for status events (idle/permission notifications) that arrive when
157
+ // the agent is between rounds.
158
+ function findSessionByCwd(cwd) {
159
+ const active = findActiveSession(cwd);
160
+ if (active) return active;
161
+ const norm = cwd ? cwd.replace(/\/+$/, '') + '/' : '';
162
+ let best = null;
163
+ for (const [f, s] of sessions) {
164
+ if (!norm || f.startsWith(norm)) best = [f, s];
165
+ }
166
+ return best;
167
+ }
168
+
169
+ // Turn a tool call into a short, CLI-like activity line.
170
+ function formatActivity(tool, input) {
171
+ input = input || {};
172
+ const base = p => (typeof p === 'string' ? p.split('/').pop() : '');
173
+ const clip = (s, n) => (typeof s === 'string' ? (s.length > n ? s.slice(0, n) + '…' : s) : '');
174
+ switch (tool) {
175
+ case 'Read': return `Read ${base(input.file_path)}`;
176
+ case 'Edit':
177
+ case 'MultiEdit': return `Edit ${base(input.file_path)}`;
178
+ case 'Write': return `Write ${base(input.file_path)}`;
179
+ case 'NotebookEdit': return `Edit ${base(input.notebook_path || input.file_path)}`;
180
+ case 'Bash': return `Bash: ${clip(input.command, 70)}`;
181
+ case 'Grep': return `Search "${clip(input.pattern, 40)}"`;
182
+ case 'Glob': return `Find ${clip(input.pattern, 40)}`;
183
+ case 'WebFetch': return `Fetch ${clip(input.url, 50)}`;
184
+ case 'WebSearch': return `Web search "${clip(input.query, 40)}"`;
185
+ case 'Task': return `Delegate: ${clip(input.description, 40)}`;
186
+ default: return tool || 'Working…';
187
+ }
188
+ }
189
+
190
+ // Permission-decision JSON the PreToolUse hook echoes back to Claude Code.
191
+ function permJSON(decision, reason) {
192
+ return JSON.stringify({
193
+ hookSpecificOutput: {
194
+ hookEventName: 'PreToolUse',
195
+ permissionDecision: decision,
196
+ ...(reason ? { permissionDecisionReason: reason } : {}),
197
+ },
198
+ });
199
+ }
200
+
201
+ // --- SDK injection ---
202
+ const SDK_PATH = path.join(__dirname, 'sdk.js');
203
+ const CHROME_PATH = path.join(__dirname, 'chrome.html');
204
+ const CHROME_HTML = fs.readFileSync(CHROME_PATH, 'utf8');
205
+
206
+ function injectSDK(html) {
207
+ const sdkJs = fs.readFileSync(SDK_PATH, 'utf8');
208
+ const tag = `<script data-annotron="1">\n${sdkJs}\n</script>`;
209
+ if (html.includes('</body>')) return html.replace('</body>', tag + '\n</body>');
210
+ return html + '\n' + tag;
211
+ }
212
+
213
+ // --- Request helpers ---
214
+ function readBody(req) {
215
+ return new Promise((resolve, reject) => {
216
+ let data = '';
217
+ req.on('data', c => data += c);
218
+ req.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch { resolve({}); } });
219
+ req.on('error', reject);
220
+ });
221
+ }
222
+
223
+ function send(res, status, body, ct = 'application/json') {
224
+ const s = typeof body === 'string' ? body : JSON.stringify(body);
225
+ res.writeHead(status, { 'Content-Type': ct, 'Access-Control-Allow-Origin': '*' });
226
+ res.end(s);
227
+ }
228
+
229
+ // --- Router ---
230
+ async function handler(req, res) {
231
+ const u = new URL(req.url, `http://${req.headers.host}`);
232
+ const method = req.method.toUpperCase();
233
+ const pathname = u.pathname;
234
+
235
+ // CORS preflight
236
+ if (method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' }); res.end(); return; }
237
+
238
+ if (pathname === '/health' && method === 'GET') {
239
+ return send(res, 200, { ok: true });
240
+ }
241
+
242
+ if (pathname === '/session' && method === 'POST') {
243
+ const { file } = await readBody(req);
244
+ if (!file) return send(res, 400, { error: 'missing file' });
245
+ const abs = path.resolve(file);
246
+ if (!fs.existsSync(abs)) return send(res, 404, { error: 'file not found' });
247
+ if (!abs.endsWith('.html') && !isMarkdown(abs)) return send(res, 400, { error: 'must be .html or .md' });
248
+ allowList.add(abs);
249
+ getSession(abs);
250
+ watchFile(abs);
251
+ return send(res, 200, { ok: true, file: abs });
252
+ }
253
+
254
+ if (pathname === '/' && method === 'GET') {
255
+ const html = fs.readFileSync(CHROME_PATH, 'utf8');
256
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
257
+ res.end(html);
258
+ return;
259
+ }
260
+
261
+ if (pathname === '/sdk.js' && method === 'GET') {
262
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' });
263
+ res.end(fs.readFileSync(SDK_PATH, 'utf8'));
264
+ return;
265
+ }
266
+
267
+ if (pathname === '/artifact' && method === 'GET') {
268
+ const file = u.searchParams.get('file');
269
+ if (!file) return send(res, 400, { error: 'missing file' });
270
+ const abs = path.resolve(file);
271
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
272
+ let raw;
273
+ try { raw = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
274
+ // Markdown source is rendered to HTML (with merslim diagrams) on the fly;
275
+ // HTML artifacts are served as-is. Either way the SDK is injected.
276
+ let html;
277
+ if (isMarkdown(abs)) {
278
+ try { html = await renderMarkdown(raw, { title: path.basename(abs) }); }
279
+ catch (e) { return send(res, 500, { error: 'markdown render failed: ' + e.message }); }
280
+ } else {
281
+ html = raw;
282
+ }
283
+ const injected = injectSDK(html);
284
+ // Never cache: the artifact changes as the agent edits it, and the injected
285
+ // SDK changes across versions — a cached copy would serve a stale preview
286
+ // (and stale annotation overlays) even after a reload.
287
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
288
+ res.end(injected);
289
+ return;
290
+ }
291
+
292
+ // Raw Markdown source (for the in-browser editor pane, Markdown mode only).
293
+ if (pathname === '/source' && method === 'GET') {
294
+ const file = u.searchParams.get('file');
295
+ if (!file) return send(res, 400, { error: 'missing file' });
296
+ const abs = path.resolve(file);
297
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
298
+ if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
299
+ let text;
300
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
301
+ return send(res, 200, { file: abs, markdown: text });
302
+ }
303
+
304
+ // Save the edited Markdown back to the .md source. The file watcher then
305
+ // fires a reload, re-rendering the preview (diagrams included).
306
+ if (pathname === '/save-md' && method === 'POST') {
307
+ const body = await readBody(req);
308
+ const file = body.file;
309
+ if (!file || typeof body.markdown !== 'string') return send(res, 400, { error: 'missing file or markdown' });
310
+ const abs = path.resolve(file);
311
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
312
+ if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
313
+ try { fs.writeFileSync(abs, body.markdown, 'utf8'); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
314
+ return send(res, 200, { ok: true });
315
+ }
316
+
317
+ // Inline edit: replace one exact run of text in the .md source with new text
318
+ // (empty newText = delete). Used by the "Edit" affordance on a text selection
319
+ // in Annotate mode — a direct, agent-free tweak. The client only offers Edit
320
+ // when it can pin a single occurrence, and passes that `index`; the server
321
+ // re-verifies the slice still equals `oldText` (falls back to a unique search
322
+ // if the file shifted underneath) before writing, so it never edits blind.
323
+ if (pathname === '/edit-text' && method === 'POST') {
324
+ const body = await readBody(req);
325
+ const { file, oldText, index } = body;
326
+ const newText = typeof body.newText === 'string' ? body.newText : '';
327
+ if (!file || typeof oldText !== 'string' || !oldText) return send(res, 400, { error: 'missing file or oldText' });
328
+ const abs = path.resolve(file);
329
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
330
+ if (!isMarkdown(abs)) return send(res, 400, { error: 'not a markdown file' });
331
+ let md;
332
+ try { md = fs.readFileSync(abs, 'utf8'); } catch { return send(res, 404, { error: 'not found' }); }
333
+ // Prefer the client-provided index (verified), else a unique match.
334
+ let at = -1;
335
+ if (Number.isInteger(index) && md.slice(index, index + oldText.length) === oldText) {
336
+ at = index;
337
+ } else {
338
+ const first = md.indexOf(oldText);
339
+ if (first !== -1 && md.indexOf(oldText, first + 1) === -1) at = first; // unique only
340
+ }
341
+ if (at === -1) return send(res, 409, { error: 'text not found or ambiguous — file changed' });
342
+ const updated = md.slice(0, at) + newText + md.slice(at + oldText.length);
343
+ try { fs.writeFileSync(abs, updated, 'utf8'); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
344
+ return send(res, 200, { ok: true });
345
+ }
346
+
347
+ if (pathname === '/annotations' && method === 'GET') {
348
+ const file = u.searchParams.get('file');
349
+ if (!file) return send(res, 400, { error: 'missing file' });
350
+ const abs = path.resolve(file);
351
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
352
+ return send(res, 200, readSidecar(abs));
353
+ }
354
+
355
+ // Who is reviewing? Used by the browser to label comment authors.
356
+ if (pathname === '/whoami' && method === 'GET') {
357
+ const file = u.searchParams.get('file');
358
+ const cwd = file ? path.dirname(path.resolve(file)) : process.cwd();
359
+ return send(res, 200, await getIdentity(cwd));
360
+ }
361
+
362
+ if (pathname === '/annotations' && method === 'POST') {
363
+ const body = await readBody(req);
364
+ const file = body.file;
365
+ if (!file) return send(res, 400, { error: 'missing file' });
366
+ const abs = path.resolve(file);
367
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
368
+ const current = readSidecar(abs);
369
+ current.annotations = body.annotations || current.annotations;
370
+ if (body.rounds) current.rounds = body.rounds;
371
+ writeSidecar(abs, current);
372
+ return send(res, 200, { ok: true });
373
+ }
374
+
375
+ if (pathname === '/feedback' && method === 'POST') {
376
+ const body = await readBody(req);
377
+ const file = body.file;
378
+ if (!file) return send(res, 400, { error: 'missing file' });
379
+ const abs = path.resolve(file);
380
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
381
+ // Persist annotations to sidecar, stamped with the reviewer's identity.
382
+ if (body.items && body.items.length > 0) {
383
+ const who = await getIdentity(path.dirname(abs));
384
+ const now = () => new Date().toISOString();
385
+ const humanMsg = (message) => ({ role: 'human', author: who.name, authorDetail: who.detail, message, timestamp: now() });
386
+ const sidecar = readSidecar(abs);
387
+ for (const item of body.items) {
388
+ const existing = item.id ? sidecar.annotations.find(a => a.id === item.id) : null;
389
+ if (existing) {
390
+ // Add new message to existing thread
391
+ if (!existing.thread) existing.thread = [];
392
+ // A retry re-sends the original instruction (already in the thread) —
393
+ // just clear the failed flag; don't append a duplicate human message.
394
+ if (item.retry) delete existing.applyFailed;
395
+ else if (item.note) existing.thread.push(humanMsg(item.note));
396
+ if (item.kind === 'text') {
397
+ existing.textStart = Number.isInteger(item.textStart) ? item.textStart : existing.textStart ?? null;
398
+ existing.textEnd = Number.isInteger(item.textEnd) ? item.textEnd : existing.textEnd ?? null;
399
+ existing.textPrefix = item.textPrefix || existing.textPrefix || null;
400
+ existing.textSuffix = item.textSuffix || existing.textSuffix || null;
401
+ }
402
+ } else {
403
+ // New annotation
404
+ const ann = {
405
+ id: item.id || ('ann_' + Math.random().toString(36).slice(2, 9)),
406
+ kind: item.kind,
407
+ selector: item.selector || null,
408
+ label: item.label || null,
409
+ text: item.text || null,
410
+ textStart: Number.isInteger(item.textStart) ? item.textStart : null,
411
+ textEnd: Number.isInteger(item.textEnd) ? item.textEnd : null,
412
+ textPrefix: item.textPrefix || null,
413
+ textSuffix: item.textSuffix || null,
414
+ author: who.name,
415
+ authorDetail: who.detail,
416
+ thread: item.note ? [humanMsg(item.note)] : [],
417
+ createdAt: now(),
418
+ status: 'open',
419
+ };
420
+ item.id = ann.id;
421
+ sidecar.annotations.push(ann);
422
+ }
423
+ }
424
+ writeSidecar(abs, sidecar);
425
+ }
426
+ const sess = getSession(abs);
427
+ sess.pendingFeedback = body;
428
+ sess.finalized = false;
429
+ sess.cancelRequested = false;
430
+ sess.selectedModel = body.model || null;
431
+ broadcastSSE(abs, 'agent-thinking', '{}');
432
+ wakePoll(abs, { feedback: body, finalized: false });
433
+ return send(res, 200, { ok: true });
434
+ }
435
+
436
+ if (pathname === '/poll' && method === 'GET') {
437
+ const file = u.searchParams.get('file');
438
+ if (!file) return send(res, 400, { error: 'missing file' });
439
+ const abs = path.resolve(file);
440
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
441
+ const sess = getSession(abs);
442
+
443
+ // If there's already feedback waiting, return it immediately
444
+ if (sess.pendingFeedback) {
445
+ const payload = { feedback: sess.pendingFeedback, finalized: sess.finalized };
446
+ sess.pendingFeedback = null;
447
+ sess.working = true; // agent is now processing a round
448
+ return send(res, 200, payload);
449
+ }
450
+ if (sess.finalized) {
451
+ sess.finalized = false;
452
+ sess.working = false;
453
+ return send(res, 200, { finalized: true });
454
+ }
455
+
456
+ // Long-poll
457
+ const timeout = setTimeout(() => {
458
+ sess.pollWaiters = sess.pollWaiters.filter(r => r !== resolve);
459
+ send(res, 200, { feedback: null, finalized: false });
460
+ }, 25000);
461
+
462
+ let resolve;
463
+ const p = new Promise(r => { resolve = r; });
464
+ sess.pollWaiters.push(resolve);
465
+ const payload = await p;
466
+ clearTimeout(timeout);
467
+ sess.pendingFeedback = null;
468
+ if (payload && payload.feedback) sess.working = true; // handed a new round to the agent
469
+ else if (payload && payload.finalized) sess.working = false;
470
+ return send(res, 200, payload);
471
+ }
472
+
473
+ if (pathname === '/finalize' && method === 'POST') {
474
+ const body = await readBody(req);
475
+ const file = body.file;
476
+ if (!file || !body.html) return send(res, 400, { error: 'missing file or html' });
477
+ const abs = path.resolve(file);
478
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
479
+ // Never overwrite a Markdown source with rendered HTML — use /save-md.
480
+ if (isMarkdown(abs)) return send(res, 400, { error: 'markdown source: use Save (writes .md), not Finalize' });
481
+ const sess = getSession(abs);
482
+ sess._suppressReload = true;
483
+ fs.writeFileSync(abs, body.html, 'utf8');
484
+ sess.finalized = true;
485
+ wakePoll(abs, { finalized: true });
486
+ return send(res, 200, { ok: true });
487
+ }
488
+
489
+ if (pathname === '/done' && method === 'POST') {
490
+ // Mark the review finished: no file is written (Save/Finalize already did
491
+ // that) — this just ends the agent's poll loop cleanly so it can exit, and
492
+ // tells the browser the session is over. Callers usually follow with /stop.
493
+ const body = await readBody(req);
494
+ const file = body.file;
495
+ if (!file) return send(res, 400, { error: 'missing file' });
496
+ const abs = path.resolve(file);
497
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
498
+ const sess = getSession(abs);
499
+ sess.finalized = true;
500
+ sess.working = false;
501
+ wakePoll(abs, { finalized: true });
502
+ broadcastSSE(abs, 'session-done', '{}');
503
+ return send(res, 200, { ok: true });
504
+ }
505
+
506
+ if (pathname === '/agent-reply' && method === 'POST') {
507
+ const body = await readBody(req);
508
+ const file = body.file;
509
+ if (!file || !body.message) return send(res, 400, { error: 'missing file or message' });
510
+ const abs = path.resolve(file);
511
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
512
+ // Agent is reporting back → the round is done, it's no longer actively working
513
+ getSession(abs).working = false;
514
+ // Persist reply into sidecar thread if annotationId given
515
+ if (body.annotationId) {
516
+ const sidecar = readSidecar(abs);
517
+ const ann = sidecar.annotations.find(a => a.id === body.annotationId);
518
+ if (ann) {
519
+ if (!ann.thread) ann.thread = [];
520
+ const now = new Date().toISOString();
521
+ ann.thread.push({ role: 'agent', message: body.message, timestamp: now });
522
+ // A comment the agent successfully acted on is considered resolved — the
523
+ // UI moves it to the History tab, timestamped so the reviewer can track
524
+ // it. A failed apply (body.failed) stays open and is flagged so the UI
525
+ // can red-border it and offer a Retry.
526
+ if (!body.failed) {
527
+ ann.status = 'resolved';
528
+ ann.resolvedAt = now;
529
+ delete ann.applyFailed;
530
+ } else {
531
+ ann.applyFailed = true;
532
+ }
533
+ writeSidecar(abs, sidecar);
534
+ }
535
+ }
536
+ broadcastSSE(abs, 'agent-reply', JSON.stringify({ message: body.message, annotationId: body.annotationId || null, failed: !!body.failed }));
537
+ return send(res, 200, { ok: true });
538
+ }
539
+
540
+ if (pathname === '/upload' && method === 'POST') {
541
+ const body = await readBody(req);
542
+ const file = body.file;
543
+ if (!file || !body.dataUrl) return send(res, 400, { error: 'missing file or dataUrl' });
544
+ const abs = path.resolve(file);
545
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
546
+ const m = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]+)$/.exec(body.dataUrl);
547
+ if (!m) return send(res, 400, { error: 'invalid image data' });
548
+ const mime = m[1];
549
+ let buf;
550
+ try { buf = Buffer.from(m[2], 'base64'); } catch { return send(res, 400, { error: 'invalid base64' }); }
551
+ const MAX = 20 * 1024 * 1024;
552
+ if (buf.length > MAX) return send(res, 413, { error: 'image too large (max 20MB)' });
553
+ const extByMime = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' };
554
+ const ext = extByMime[mime] || 'png';
555
+ const dir = path.join(path.dirname(abs), '.annotron-uploads');
556
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
557
+ const rawName = (body.name || 'image').replace(/\.[^.]*$/, '');
558
+ const safeBase = rawName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60) || 'image';
559
+ const fname = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}_${safeBase}.${ext}`;
560
+ const dest = path.join(dir, fname);
561
+ try { fs.writeFileSync(dest, buf); } catch (e) { return send(res, 500, { error: 'write failed: ' + e.message }); }
562
+ return send(res, 200, { ok: true, path: dest, name: body.name || fname });
563
+ }
564
+
565
+ if (pathname === '/agent-progress' && method === 'POST') {
566
+ const body = await readBody(req);
567
+ const file = body.file;
568
+ if (!file) return send(res, 400, { error: 'missing file' });
569
+ const abs = path.resolve(file);
570
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
571
+ broadcastSSE(abs, 'agent-progress', JSON.stringify({ step: body.step || '', done: !!body.done }));
572
+ return send(res, 200, { ok: true });
573
+ }
574
+
575
+ if (pathname === '/agent-metrics' && method === 'POST') {
576
+ const body = await readBody(req);
577
+ const file = body.file;
578
+ if (!file) return send(res, 400, { error: 'missing file' });
579
+ const abs = path.resolve(file);
580
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
581
+ const metrics = {
582
+ model: body.model || undefined,
583
+ tokensUsed: body.tokensUsed || undefined,
584
+ tokensMax: body.tokensMax || undefined,
585
+ contextUsed: body.contextUsed || undefined,
586
+ contextMax: body.contextMax || undefined,
587
+ };
588
+ Object.keys(metrics).forEach(k => metrics[k] === undefined && delete metrics[k]);
589
+ broadcastSSE(abs, 'agent-metrics', JSON.stringify(metrics));
590
+ return send(res, 200, { ok: true });
591
+ }
592
+
593
+ if (pathname === '/cancel' && method === 'POST') {
594
+ const body = await readBody(req);
595
+ const file = body.file;
596
+ if (!file) return send(res, 400, { error: 'missing file' });
597
+ const abs = path.resolve(file);
598
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
599
+ const sess = getSession(abs);
600
+ sess.cancelRequested = true;
601
+ broadcastSSE(abs, 'agent-cancelled', '{}');
602
+ return send(res, 200, { ok: true });
603
+ }
604
+
605
+ if (pathname === '/cancelled' && method === 'GET') {
606
+ const file = u.searchParams.get('file');
607
+ if (!file) return send(res, 400, { error: 'missing file' });
608
+ const abs = path.resolve(file);
609
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
610
+ const sess = getSession(abs);
611
+ return send(res, 200, { cancelled: !!sess.cancelRequested });
612
+ }
613
+
614
+ // Fileless check used by the `annotron check` CLI fallback: is any in-flight
615
+ // (actively-working) session currently under a cancel request?
616
+ if (pathname === '/cancel-check' && method === 'GET') {
617
+ for (const [f, s] of sessions) {
618
+ if (s.working && s.cancelRequested) return send(res, 200, { cancelled: true, file: f });
619
+ }
620
+ return send(res, 200, { cancelled: false });
621
+ }
622
+
623
+ // ── Hook ingest (Claude Code hooks POST their raw stdin JSON here) ──────────
624
+ // PreToolUse: mirror the activity, enforce cancel, and (if remote-approve is
625
+ // on) gate the tool on a browser Allow/Deny. Returns the exact JSON the hook
626
+ // echoes back to Claude Code, or an empty body meaning "defer to normal flow".
627
+ if (pathname === '/hook/pretool' && method === 'POST') {
628
+ const body = await readBody(req);
629
+ const tool = body.tool_name || body.tool || '';
630
+ const input = body.tool_input || body.input || {};
631
+ // Never gate/mirror annotron's OWN CLI calls — the agent must be able to
632
+ // reply/poll/check even while cancelled or while remote-approve is on
633
+ // (gating them would deadlock the poll). Detect the real command string
634
+ // here (server-side JSON parse) rather than fuzzy-matching raw stdin.
635
+ const cmd = typeof input.command === 'string' ? input.command : '';
636
+ if (tool === 'Bash' && /annotron["']?\s+(poll|progress|check|stop|help)\b/.test(cmd)) {
637
+ return send(res, 200, '');
638
+ }
639
+ const found = findActiveSession(body.cwd);
640
+ if (!found) return send(res, 200, ''); // no active review → normal flow
641
+ const [file, sess] = found;
642
+ broadcastSSE(file, 'agent-progress', JSON.stringify({ step: formatActivity(tool, input) }));
643
+
644
+ if (sess.cancelRequested) {
645
+ return send(res, 200, permJSON('deny',
646
+ 'annotron: the user cancelled the current review. Stop immediately — do not run further tools. Reply on the artifact (annotron poll <file> --reply "Stopped — cancelled by user.") then wait for new feedback.'));
647
+ }
648
+ if (!sess.remoteApprove) return send(res, 200, '');
649
+ if (sess.autoAllowTools.has(tool)) return send(res, 200, permJSON('allow'));
650
+
651
+ const requestId = 'perm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
652
+ const summary = formatActivity(tool, input);
653
+ sess.pendingPerms.set(requestId, { tool, summary });
654
+ broadcastSSE(file, 'permission-request', JSON.stringify({ requestId, tool, summary }));
655
+ const decision = await new Promise(resolve => {
656
+ sess.permWaiters.set(requestId, resolve);
657
+ setTimeout(() => {
658
+ if (sess.permWaiters.has(requestId)) { sess.permWaiters.delete(requestId); resolve(null); }
659
+ }, 170000);
660
+ });
661
+ sess.pendingPerms.delete(requestId);
662
+ broadcastSSE(file, 'permission-resolved', JSON.stringify({ requestId }));
663
+ if (!decision) {
664
+ return send(res, 200, permJSON('ask',
665
+ 'annotron: no response from the review UI in time — falling back to the terminal prompt.'));
666
+ }
667
+ if (decision.decision === 'allow' && decision.always) sess.autoAllowTools.add(tool);
668
+ if (decision.decision === 'deny') {
669
+ return send(res, 200, permJSON('deny', decision.reason || 'Denied by the reviewer in annotron.'));
670
+ }
671
+ return send(res, 200, permJSON('allow'));
672
+ }
673
+
674
+ // PostToolUse: mark the current activity step done.
675
+ if (pathname === '/hook/posttool' && method === 'POST') {
676
+ const body = await readBody(req);
677
+ const found = findActiveSession(body.cwd);
678
+ if (found) broadcastSSE(found[0], 'agent-progress-done', JSON.stringify({ tool: body.tool_name || '' }));
679
+ return send(res, 200, { ok: true });
680
+ }
681
+
682
+ // Notification: reflect "waiting for permission / idle" in the browser.
683
+ if (pathname === '/hook/notify' && method === 'POST') {
684
+ const body = await readBody(req);
685
+ const found = findSessionByCwd(body.cwd);
686
+ const type = body.notification_type || body.type || '';
687
+ if (found) broadcastSSE(found[0], 'agent-status', JSON.stringify({ type }));
688
+ return send(res, 200, { ok: true });
689
+ }
690
+
691
+ // Stop: the turn finished → agent is idle, waiting for the next feedback.
692
+ if (pathname === '/hook/stop' && method === 'POST') {
693
+ const body = await readBody(req);
694
+ const found = findSessionByCwd(body.cwd);
695
+ if (found) broadcastSSE(found[0], 'agent-status', JSON.stringify({ type: 'idle' }));
696
+ return send(res, 200, { ok: true });
697
+ }
698
+
699
+ // ── Remote permission control (from the browser) ────────────────────────────
700
+ if (pathname === '/permission/decision' && method === 'POST') {
701
+ const body = await readBody(req);
702
+ const file = body.file;
703
+ if (!file) return send(res, 400, { error: 'missing file' });
704
+ const abs = path.resolve(file);
705
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
706
+ const sess = getSession(abs);
707
+ const resolve = sess.permWaiters.get(body.requestId);
708
+ if (resolve) {
709
+ sess.permWaiters.delete(body.requestId);
710
+ resolve({
711
+ decision: body.decision === 'deny' ? 'deny' : 'allow',
712
+ always: !!body.always,
713
+ reason: body.reason || '',
714
+ });
715
+ }
716
+ return send(res, 200, { ok: true });
717
+ }
718
+
719
+ if (pathname === '/permission/mode' && method === 'POST') {
720
+ const body = await readBody(req);
721
+ const file = body.file;
722
+ if (!file) return send(res, 400, { error: 'missing file' });
723
+ const abs = path.resolve(file);
724
+ if (!allowList.has(abs)) return send(res, 403, { error: 'not registered' });
725
+ const sess = getSession(abs);
726
+ sess.remoteApprove = !!body.enabled;
727
+ if (!sess.remoteApprove) {
728
+ // Turning off: release anything currently waiting so nothing hangs.
729
+ for (const resolve of sess.permWaiters.values()) resolve(null);
730
+ sess.permWaiters.clear();
731
+ sess.pendingPerms.clear();
732
+ }
733
+ if (body.clearAutoAllow) sess.autoAllowTools.clear();
734
+ broadcastSSE(abs, 'permission-mode', JSON.stringify({ enabled: sess.remoteApprove }));
735
+ return send(res, 200, { ok: true, enabled: sess.remoteApprove });
736
+ }
737
+
738
+ if (pathname === '/events' && method === 'GET') {
739
+ const file = u.searchParams.get('file');
740
+ if (!file) return send(res, 400, { error: 'missing file' });
741
+ const abs = path.resolve(file);
742
+ // Allow events even if not in allowList (browser may connect before register)
743
+ res.writeHead(200, {
744
+ 'Content-Type': 'text/event-stream',
745
+ 'Cache-Control': 'no-cache',
746
+ 'Connection': 'keep-alive',
747
+ 'Access-Control-Allow-Origin': '*',
748
+ });
749
+ res.write(': connected\n\n');
750
+ const sess = getSession(abs);
751
+ sess.sseClients.add(res);
752
+ req.on('close', () => { sess.sseClients.delete(res); });
753
+ // Keep-alive ping
754
+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { clearInterval(ping); } }, 20000);
755
+ req.on('close', () => clearInterval(ping));
756
+ return;
757
+ }
758
+
759
+ if (pathname === '/stop' && method === 'POST') {
760
+ send(res, 200, { ok: true });
761
+ setTimeout(() => process.exit(0), 100);
762
+ return;
763
+ }
764
+
765
+ send(res, 404, { error: 'not found' });
766
+ }
767
+
768
+ const server = http.createServer(handler);
769
+ server.on('error', (err) => {
770
+ if (err.code === 'EADDRINUSE') {
771
+ // Another server already owns the port — exit quietly
772
+ process.exit(0);
773
+ }
774
+ console.error('[annotron server]', err.message);
775
+ process.exit(1);
776
+ });
777
+
778
+ server.listen(PORT, HOST, () => {
779
+ console.log(`annotron server listening on http://${HOST}:${PORT}`);
780
+ });