@sov3rain/nota 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # nota
2
+
3
+ A local annotation tool for AI agent implementation plans.
4
+
5
+ `nota` opens a Markdown plan in a browser-based editor where you can highlight text and leave comments using [CriticMarkup](https://github.com/CriticMarkup/CriticMarkup-toolkit). The agent reads those comments back and adjusts the plan or implementation accordingly.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g nota
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Annotate a plan
16
+
17
+ ```bash
18
+ nota path/to/plan.md
19
+ ```
20
+
21
+ Opens the file in a local web UI. Highlight any text to leave a comment. When you're done, click **Done** — the agent reads the annotated file and processes your feedback.
22
+
23
+ Pass `--no-open` to start the server without opening the browser automatically:
24
+
25
+ ```bash
26
+ nota --no-open path/to/plan.md
27
+ ```
28
+
29
+ ### Set up agent instructions
30
+
31
+ ```bash
32
+ nota init # all agents (Claude, Codex, Cursor)
33
+ nota init claude # Claude only
34
+ nota init codex # Codex only
35
+ nota init cursor # Cursor only
36
+ ```
37
+
38
+ Writes the annotation workflow instructions into `CLAUDE.md`, `AGENTS.md`, or `.cursor/rules/nota.mdc`. Safe to re-run — existing files are updated in place, not overwritten.
39
+
40
+ ## How it works
41
+
42
+ 1. The agent creates a plan as `.agent-plans/<date>-<topic>.md` and runs `nota` on it
43
+ 2. You annotate the plan in the browser
44
+ 3. Feedback is stored as CriticMarkup inline in the file:
45
+ ```
46
+ {==highlighted text==}{>>your comment<<}
47
+ ```
48
+ 4. The agent reads the annotations and updates the plan or proceeds with implementation
49
+
50
+ ## License
51
+
52
+ MIT
package/bin/nota.mjs ADDED
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createServer } from 'node:http';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { createReadStream } from 'node:fs';
6
+ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
7
+ import { dirname, extname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
12
+ const MIME_TYPES = {
13
+ '.css': 'text/css; charset=utf-8',
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.json': 'application/json; charset=utf-8',
17
+ '.svg': 'image/svg+xml',
18
+ };
19
+
20
+ const args = process.argv.slice(2);
21
+ const command = args[0];
22
+ const wantsHelp = args.includes('--help') || args.includes('-h');
23
+ const shouldOpenBrowser = !args.includes('--no-open');
24
+ const targetFile = args.find((arg) => !arg.startsWith('-'));
25
+
26
+ if (command === 'init') {
27
+ if (wantsHelp) {
28
+ printUsage();
29
+ process.exit(0);
30
+ }
31
+
32
+ await initAgentInstructions(args[1] ?? 'all');
33
+ process.exit(0);
34
+ }
35
+
36
+ if (wantsHelp || !targetFile) {
37
+ printUsage();
38
+ process.exit(wantsHelp ? 0 : 1);
39
+ }
40
+
41
+ const documentPath = resolve(process.cwd(), targetFile);
42
+ const distDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
43
+ const session = randomBytes(24).toString('hex');
44
+
45
+ await ensureReadableFile(documentPath);
46
+ await ensureBuildExists(distDir);
47
+
48
+ const server = createServer((request, response) => {
49
+ handleRequest(request, response).catch((error) => {
50
+ console.error(error);
51
+ sendJson(response, 500, { error: 'Internal server error' });
52
+ });
53
+ });
54
+
55
+ server.listen(0, '127.0.0.1', () => {
56
+ const address = server.address();
57
+ const port = typeof address === 'object' && address ? address.port : 0;
58
+ const url = `http://127.0.0.1:${port}/?session=${session}`;
59
+
60
+ console.log(`Plan Annotation: ${documentPath}`);
61
+ console.log(`Open: ${url}`);
62
+ if (shouldOpenBrowser) {
63
+ openBrowser(url);
64
+ }
65
+ });
66
+
67
+ process.on('SIGINT', () => {
68
+ server.close(() => process.exit(0));
69
+ });
70
+
71
+ async function handleRequest(request, response) {
72
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
73
+ const apiHandler = getApiHandler(url.pathname, request.method);
74
+
75
+ if (apiHandler) {
76
+ await apiHandler(request, response, url);
77
+ return;
78
+ }
79
+
80
+ await serveStaticFile(url.pathname, response);
81
+ }
82
+
83
+ async function handleDocumentApi(request, response, url) {
84
+ if (!isAuthorized(request, url)) {
85
+ sendJson(response, 403, { error: 'Forbidden' });
86
+ return;
87
+ }
88
+
89
+ if (request.method === 'GET') {
90
+ await sendDocument(response);
91
+ return;
92
+ }
93
+
94
+ if (request.method === 'PUT') {
95
+ await saveDocument(request, response);
96
+ return;
97
+ }
98
+
99
+ response.writeHead(405, { Allow: 'GET, PUT' });
100
+ response.end();
101
+ }
102
+
103
+ async function serveStaticFile(pathname, response) {
104
+ const candidate = resolveStaticPath(pathname);
105
+
106
+ if (!candidate) {
107
+ sendForbiddenStaticFile(response);
108
+ return;
109
+ }
110
+
111
+ const fileStat = await getStaticFileStat(candidate);
112
+
113
+ if (!fileStat) {
114
+ sendNotFound(response);
115
+ return;
116
+ }
117
+
118
+ response.writeHead(200, {
119
+ 'Content-Type': MIME_TYPES[extname(candidate)] ?? 'application/octet-stream',
120
+ });
121
+ createReadStream(candidate).pipe(response);
122
+ }
123
+
124
+ function getApiHandler(pathname, method) {
125
+ const route = `${method} ${pathname}`;
126
+
127
+ return (
128
+ {
129
+ 'GET /api/document': handleDocumentApi,
130
+ 'PUT /api/document': handleDocumentApi,
131
+ 'POST /api/shutdown': handleShutdownApi,
132
+ }[route] ?? null
133
+ );
134
+ }
135
+
136
+ function handleShutdownApi(request, response, url) {
137
+ if (!isAuthorized(request, url)) {
138
+ sendJson(response, 403, { error: 'Forbidden' });
139
+ return;
140
+ }
141
+
142
+ sendJson(response, 200, { ok: true });
143
+ setTimeout(() => process.exit(0), 100);
144
+ }
145
+
146
+ async function sendDocument(response) {
147
+ sendJson(response, 200, {
148
+ filename: documentPath.split(/[\\/]/).pop() ?? 'document.md',
149
+ markdown: await readFile(documentPath, 'utf8'),
150
+ });
151
+ }
152
+
153
+ async function saveDocument(request, response) {
154
+ const body = await readRequestBody(request);
155
+ const parsed = JSON.parse(body);
156
+
157
+ if (!parsed || typeof parsed.markdown !== 'string') {
158
+ sendJson(response, 400, { error: 'Expected markdown string' });
159
+ return;
160
+ }
161
+
162
+ await writeFile(documentPath, parsed.markdown, 'utf8');
163
+ sendJson(response, 200, { ok: true });
164
+ }
165
+
166
+ function resolveStaticPath(pathname) {
167
+ const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
168
+ const candidate = resolve(distDir, relativePath);
169
+
170
+ return isInsideDirectory(candidate, distDir) ? candidate : null;
171
+ }
172
+
173
+ function isInsideDirectory(candidate, directory) {
174
+ return (
175
+ candidate === directory ||
176
+ candidate.startsWith(`${directory}\\`) ||
177
+ candidate.startsWith(`${directory}/`)
178
+ );
179
+ }
180
+
181
+ async function getStaticFileStat(filePath) {
182
+ try {
183
+ const fileStat = await stat(filePath);
184
+ return fileStat.isFile() ? fileStat : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function sendForbiddenStaticFile(response) {
191
+ response.writeHead(403);
192
+ response.end();
193
+ }
194
+
195
+ function sendNotFound(response) {
196
+ response.writeHead(404);
197
+ response.end('Not found');
198
+ }
199
+
200
+ function isAuthorized(request, url) {
201
+ return (
202
+ request.headers['x-nota-session'] === session || url.searchParams.get('session') === session
203
+ );
204
+ }
205
+
206
+ function readRequestBody(request) {
207
+ return new Promise((resolveBody, rejectBody) => {
208
+ let body = '';
209
+
210
+ request.setEncoding('utf8');
211
+ request.on('data', (chunk) => {
212
+ body += chunk;
213
+
214
+ if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
215
+ rejectBody(new Error('Request body too large'));
216
+ request.destroy();
217
+ }
218
+ });
219
+ request.on('end', () => resolveBody(body));
220
+ request.on('error', rejectBody);
221
+ });
222
+ }
223
+
224
+ function sendJson(response, statusCode, payload) {
225
+ response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
226
+ response.end(JSON.stringify(payload));
227
+ }
228
+
229
+ async function ensureReadableFile(filePath) {
230
+ try {
231
+ const fileStat = await stat(filePath);
232
+ if (!fileStat.isFile()) throw new Error('Not a file');
233
+ await access(filePath);
234
+ } catch {
235
+ console.error(`Cannot read markdown file: ${filePath}`);
236
+ process.exit(1);
237
+ }
238
+ }
239
+
240
+ async function ensureBuildExists(buildDir) {
241
+ try {
242
+ await access(join(buildDir, 'index.html'));
243
+ } catch {
244
+ console.error('Missing dist/index.html. Run `npm run build` before using the CLI from source.');
245
+ process.exit(1);
246
+ }
247
+ }
248
+
249
+ function openBrowser(url) {
250
+ const platform = process.platform;
251
+ const command = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
252
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
253
+ const child = spawn(command, args, {
254
+ detached: true,
255
+ stdio: 'ignore',
256
+ windowsHide: true,
257
+ });
258
+
259
+ child.unref();
260
+ }
261
+
262
+ function printUsage() {
263
+ console.log(`Usage:
264
+ nota [--no-open] <path/to/plan.md>
265
+ nota init [codex|claude|cursor|all]`);
266
+ }
267
+
268
+ async function initAgentInstructions(target) {
269
+ const targets = getAgentTargets(target);
270
+
271
+ if (!targets) {
272
+ console.error('Unknown agent target. Expected codex, claude, cursor, or all.');
273
+ process.exit(1);
274
+ }
275
+
276
+ await mkdir(resolve(process.cwd(), '.agent-plans'), { recursive: true });
277
+ await writeFile(resolve(process.cwd(), '.agent-plans', '.gitkeep'), '', { flag: 'a' });
278
+
279
+ for (const agent of targets) {
280
+ await writeAgentInstructions(agent);
281
+ }
282
+
283
+ console.log(`Plan Annotation instructions installed for: ${targets.join(', ')}`);
284
+ console.log('Plans directory ready: .agent-plans/');
285
+ }
286
+
287
+ function getAgentTargets(target) {
288
+ const allTargets = ['codex', 'claude', 'cursor'];
289
+
290
+ if (target === 'all') return allTargets;
291
+ if (allTargets.includes(target)) return [target];
292
+
293
+ return null;
294
+ }
295
+
296
+ async function writeAgentInstructions(agent) {
297
+ const filePath = resolve(process.cwd(), getAgentInstructionPath(agent));
298
+ const block = getAgentInstructionBlock(agent);
299
+
300
+ await mkdir(dirname(filePath), { recursive: true });
301
+ await upsertMarkedBlock(filePath, block);
302
+ console.log(`Updated ${filePath}`);
303
+ }
304
+
305
+ function getAgentInstructionPath(agent) {
306
+ if (agent === 'codex') return 'AGENTS.md';
307
+ if (agent === 'claude') return 'CLAUDE.md';
308
+
309
+ return join('.cursor', 'rules', 'nota.mdc');
310
+ }
311
+
312
+ function getAgentInstructionBlock() {
313
+ return `<!-- nota:start -->
314
+ ## Plan Annotation Workflow
315
+
316
+ When the user asks for an implementation plan, create a Markdown file in:
317
+
318
+ \`\`\`text
319
+ .agent-plans/<date>-<topic>.md
320
+ \`\`\`
321
+
322
+ Then launch the local annotation tool.
323
+
324
+ On Windows/PowerShell, use the npm \`.cmd\` shim explicitly:
325
+
326
+ \`\`\`powershell
327
+ nota.cmd .agent-plans/<file>.md
328
+ \`\`\`
329
+
330
+ Do not use \`Start-Process -FilePath nota\` on Windows/PowerShell; it can resolve to the extensionless npm shim and trigger the Windows "open with" dialog instead of running the CLI.
331
+
332
+ On macOS/Linux:
333
+
334
+ \`\`\`bash
335
+ nota .agent-plans/<file>.md
336
+ \`\`\`
337
+
338
+ Tell the user that the plan is open for annotation. Do not continue with implementation until the user says they are done annotating.
339
+
340
+ After the user is done, read the same Markdown file again. User feedback is stored as CriticMarkup:
341
+
342
+ \`\`\`md
343
+ {==text being discussed==}{>>user comment<<}
344
+ \`\`\`
345
+
346
+ Treat those comments as direct user feedback. Update the plan or implementation accordingly.
347
+
348
+ Once all annotations have been processed, remove every CriticMarkup annotation from the file, as well as the global annotations block if present:
349
+
350
+ \`\`\`md
351
+ <!-- nota:globals
352
+ [...]
353
+ -->
354
+ \`\`\`
355
+
356
+ Leave only the plain Markdown content.
357
+
358
+ If the \`nota\` command is unavailable, tell the user to install it globally:
359
+
360
+ \`\`\`bash
361
+ npm install -g nota
362
+ \`\`\`
363
+ <!-- nota:end -->
364
+ `;
365
+ }
366
+
367
+ async function upsertMarkedBlock(filePath, block) {
368
+ const startMarker = '<!-- nota:start -->';
369
+ const endMarker = '<!-- nota:end -->';
370
+ const existing = await readOptionalFile(filePath);
371
+ const start = existing.indexOf(startMarker);
372
+ const end = existing.indexOf(endMarker);
373
+
374
+ if (start !== -1 && end !== -1 && end > start) {
375
+ const before = existing.slice(0, start).trimEnd();
376
+ const after = existing.slice(end + endMarker.length).trimStart();
377
+ await writeFile(filePath, joinTextBlocks(before, block.trimEnd(), after), 'utf8');
378
+ return;
379
+ }
380
+
381
+ await writeFile(filePath, joinTextBlocks(existing.trimEnd(), block.trimEnd()), 'utf8');
382
+ }
383
+
384
+ async function readOptionalFile(filePath) {
385
+ try {
386
+ return await readFile(filePath, 'utf8');
387
+ } catch {
388
+ return '';
389
+ }
390
+ }
391
+
392
+ function joinTextBlocks(...blocks) {
393
+ return `${blocks.filter(Boolean).join('\n\n')}\n`;
394
+ }
@@ -0,0 +1 @@
1
+ :root{color:#18202f;font-synthesis:none;text-rendering:optimizelegibility;background:#f4f6f8;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{min-width:320px;height:100vh;margin:0;overflow:hidden}button{color:#18202f;cursor:pointer;font:inherit;background:#fff;border:1px solid #c6ccd6;border-radius:6px;min-height:38px;padding:0 14px;font-weight:650}button:hover:not(:disabled){border-color:#7a8799}button:disabled{cursor:not-allowed;opacity:.55}button.primary{color:#fff;background:#215f88;border-color:#215f88}button.done{color:#fff;background:#1a6b3c;border-color:#1a6b3c}button.done:hover:not(:disabled){background:#145230;border-color:#145230}.app-shell{flex-direction:column;height:100vh;display:flex;overflow:hidden}.topbar{background:#fff;border-bottom:1px solid #d9dee7;justify-content:space-between;align-items:center;gap:24px;padding:18px 24px;display:flex}.eyebrow{color:#647084;text-transform:uppercase;margin:0 0 4px;font-size:12px;font-weight:750}h1,h2{margin:0}h1{font-size:24px}h2{font-size:16px}.file-actions{align-items:center;gap:10px;display:flex}.copy-plan-button{min-width:132px}.hidden-input{display:none}.save-status{color:#647084;white-space:nowrap;font-size:13px;font-weight:650}.workspace{flex:1;grid-template-columns:minmax(0,1fr) minmax(280px,360px);min-height:0;display:grid;overflow:hidden}.editor-pane,.comment-pane{min-width:0;min-height:0;padding:20px}.editor-pane{border-right:1px solid #d9dee7;flex-direction:column;display:flex}.editor-frame{background:#fff;border:1px solid #d9dee7;border-radius:8px;flex:1;min-height:0;overflow:auto}.tiptap{outline:none;min-height:100%;padding:28px}.tiptap>:first-child{margin-top:0}.tiptap p{line-height:1.65}.tiptap code{color:#202b3c;background:#eef2f6;border-radius:4px;padding:2px 4px;font-family:Cascadia Code,SFMono-Regular,Consolas,Liberation Mono,monospace;font-size:.9em}.tiptap pre{color:#f5f7fb;background:#18202f;border:1px solid #2d3748;border-radius:8px;margin:18px 0;padding:14px 16px;line-height:1.6;overflow-x:auto}.tiptap pre code{color:inherit;white-space:pre;background:0 0;border-radius:0;padding:0;font-size:13px;display:block}.tiptap mark[data-comment]{cursor:text;background:#fff0a6;border-bottom:2px solid #d29a1f;border-radius:3px;padding:1px 2px}.tiptap .pending-annotation{background:#d8ebff;border-bottom:2px solid #3978b8;border-radius:3px;padding:1px 2px}.tiptap pre mark[data-comment],.tiptap pre .pending-annotation{color:#18202f}.comment-pane{background:#fbfcfd;flex-direction:column;gap:16px;display:flex;overflow:hidden}.comment-pane-header{justify-content:space-between;align-items:center;display:flex}.comment-pane-actions{align-items:center;gap:8px;display:flex}.comment-pane-header span{color:#48556a;text-align:center;background:#e7ecf2;border-radius:999px;min-width:28px;padding:4px 8px;font-size:12px;font-weight:700}.small-button{min-height:32px;padding:0 10px;font-size:13px}.comment-list{flex-direction:column;flex:1;gap:12px;min-height:0;padding-right:2px;display:flex;overflow:auto}.comment-item{background:#fff;border:1px solid #d9dee7;border-radius:8px;flex-direction:column;gap:10px;padding:12px;display:flex}.comment-item.active{border-color:#215f88;box-shadow:0 0 0 3px #215f8824}.comment-item-header{align-items:flex-start;gap:8px;display:flex}.comment-target{color:#3d3320;text-align:left;text-overflow:ellipsis;white-space:nowrap;background:#f7f2d0;border:0;flex:1;min-width:0;min-height:0;padding:8px;font-size:13px;font-weight:700;line-height:1.35;display:block;overflow:hidden}.global-comment-target{color:#25455f;background:#e8f1f8}.delete-comment-button{color:#8f2d2d;flex:none;min-height:30px;padding:0 8px;font-size:12px}.delete-comment-button:hover:not(:disabled){border-color:#b24545}textarea{color:#18202f;font:inherit;resize:vertical;border:1px solid #c6ccd6;border-radius:8px;width:100%;min-height:96px;padding:12px;line-height:1.5}textarea:focus{border-color:#215f88;outline:3px solid #215f882e}.empty-state{color:#647084;margin:0;line-height:1.5}.modal-backdrop{z-index:20;background:#121a2775;justify-content:center;align-items:center;padding:20px;display:flex;position:fixed;inset:0}.modal{background:#fff;border-radius:8px;flex-direction:column;gap:14px;width:min(100%,520px);max-width:520px;padding:20px;display:flex;box-shadow:0 24px 80px #121a2747}.modal textarea{min-height:180px}.modal-actions{justify-content:flex-end;gap:10px;display:flex}@media (width<=800px){.topbar{flex-direction:column;align-items:flex-start}.workspace{grid-template-rows:minmax(0,1fr) minmax(220px,36vh);grid-template-columns:1fr}.editor-pane{border-right:0}.comment-pane{border-top:1px solid #d9dee7}}