neuvikon 0.2.13 → 0.2.15

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 (3) hide show
  1. package/README.md +28 -1
  2. package/dist/index.mjs +322 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,6 +39,8 @@ as being worked on, by whom, with which tool.
39
39
  | `neuvikon stop` | Stop tracking |
40
40
  | `neuvikon status` | Login, project setup, current ticket |
41
41
  | `neuvikon logout` | Revoke this machine's token and forget it |
42
+ | `neuvikon pages [--ticket EWWQ-1]` | List the ticket's project pages, flattened to plain text |
43
+ | `neuvikon pages update "Title" [--ticket EWWQ-1]` | Create or update a page by title, body on stdin (or `--text`) |
42
44
 
43
45
  `start` also accepts the ticket on stdin, and pulls the reference out of it:
44
46
 
@@ -46,6 +48,32 @@ as being worked on, by whom, with which tool.
46
48
  pbpaste | npx neuvikon start
47
49
  ```
48
50
 
51
+ ## Project pages
52
+
53
+ An agent's most useful output often isn't the ticket, it's what it learned
54
+ doing it. `pages` reads and writes the same project pages a person sees in the
55
+ app — the running knowledge base for that project, not a per-ticket scratchpad:
56
+
57
+ ```sh
58
+ npx neuvikon pages --ticket EWWQ-1
59
+ ```
60
+
61
+ ```sh
62
+ npx neuvikon pages update "Auth flow" --ticket EWWQ-1 <<'EOF'
63
+ Sessions are issued by convex/auth.ts and expire after 30 days.
64
+ See [[Session storage]] for where they're persisted.
65
+ EOF
66
+ ```
67
+
68
+ An update matches an existing page by title (case-insensitive) and overwrites
69
+ it, so writing to the same title again refines that page instead of piling up
70
+ duplicates. `[[Another Title]]` inside the text becomes a real link to that
71
+ page if a page with that title already exists in the project, same as typing
72
+ it in the editor.
73
+
74
+ Both commands fall back to whichever ticket `npx neuvikon start` is already
75
+ tracking in this directory when `--ticket` is omitted.
76
+
49
77
  ## Token usage
50
78
 
51
79
  `start`/`stop` are all an agent can report about itself — no agent knows its own
@@ -97,4 +125,3 @@ pnpm typecheck
97
125
  ```
98
126
 
99
127
  No runtime dependencies, so `npx neuvikon` stays a fast cold start.
100
- # neuvikon-cli
package/dist/index.mjs CHANGED
@@ -3,6 +3,49 @@ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readF
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ //#region src/api.ts
7
+ var ApiError = class extends Error {
8
+ status;
9
+ constructor(message, status) {
10
+ super(message);
11
+ this.status = status;
12
+ this.name = "ApiError";
13
+ }
14
+ };
15
+ /** A request that never reached the server: offline, DNS, TLS, timeout. */
16
+ var NetworkError = class extends Error {
17
+ constructor(cause) {
18
+ super("Could not reach Neuvikon", { cause });
19
+ this.name = "NetworkError";
20
+ }
21
+ };
22
+ const TIMEOUT_MS = 15e3;
23
+ /**
24
+ * The one place an HTTP failure becomes a typed error. Callers decide what a
25
+ * failure means — a hook wrapping the agent's turn wants to swallow it, `login`
26
+ * wants to shout about it.
27
+ */
28
+ async function post(url, path, body) {
29
+ let response;
30
+ try {
31
+ response = await fetch(`${url}${path}`, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify(body),
35
+ signal: AbortSignal.timeout(TIMEOUT_MS)
36
+ });
37
+ } catch (error) {
38
+ throw new NetworkError(error);
39
+ }
40
+ const payload = await response.json().catch(() => ({}));
41
+ const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
42
+ if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
43
+ return payload;
44
+ }
45
+ function normalizeUrl(url) {
46
+ return url.trim().replace(/\/+$/, "");
47
+ }
48
+ //#endregion
6
49
  //#region src/config.ts
7
50
  /**
8
51
  * The two deployments this CLI knows about. The browser origin is kept here
@@ -93,49 +136,6 @@ function clearCredentials() {
93
136
  }
94
137
  }
95
138
  //#endregion
96
- //#region src/api.ts
97
- var ApiError = class extends Error {
98
- status;
99
- constructor(message, status) {
100
- super(message);
101
- this.status = status;
102
- this.name = "ApiError";
103
- }
104
- };
105
- /** A request that never reached the server: offline, DNS, TLS, timeout. */
106
- var NetworkError = class extends Error {
107
- constructor(cause) {
108
- super("Could not reach Neuvikon", { cause });
109
- this.name = "NetworkError";
110
- }
111
- };
112
- const TIMEOUT_MS = 15e3;
113
- /**
114
- * The one place an HTTP failure becomes a typed error. Callers decide what a
115
- * failure means — a hook wrapping the agent's turn wants to swallow it, `login`
116
- * wants to shout about it.
117
- */
118
- async function post(url, path, body) {
119
- let response;
120
- try {
121
- response = await fetch(`${url}${path}`, {
122
- method: "POST",
123
- headers: { "content-type": "application/json" },
124
- body: JSON.stringify(body),
125
- signal: AbortSignal.timeout(TIMEOUT_MS)
126
- });
127
- } catch (error) {
128
- throw new NetworkError(error);
129
- }
130
- const payload = await response.json().catch(() => ({}));
131
- const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
132
- if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
133
- return payload;
134
- }
135
- function normalizeUrl(url) {
136
- return url.trim().replace(/\/+$/, "");
137
- }
138
- //#endregion
139
139
  //#region src/session.ts
140
140
  /**
141
141
  * Distinguishes concurrent agents working in the *same* checkout — two Claude
@@ -184,6 +184,74 @@ function clearSession() {
184
184
  } catch {}
185
185
  }
186
186
  //#endregion
187
+ //#region src/ui.ts
188
+ /**
189
+ * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
190
+ * command's stdout stays a clean, pipeable value.
191
+ */
192
+ const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
193
+ function paint(code, text) {
194
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
195
+ }
196
+ const dim = (text) => paint("2", text);
197
+ const bold = (text) => paint("1", text);
198
+ const green = (text) => paint("32", text);
199
+ const red = (text) => paint("31", text);
200
+ const cyan = (text) => paint("36", text);
201
+ function info(message) {
202
+ process.stderr.write(`${message}\n`);
203
+ }
204
+ function success(message) {
205
+ process.stderr.write(`${green("✓")} ${message}\n`);
206
+ }
207
+ function warn(message) {
208
+ process.stderr.write(`${red("!")} ${message}\n`);
209
+ }
210
+ /** Ends the process. Every user-facing failure funnels through here. */
211
+ function fail(message, hint) {
212
+ process.stderr.write(`${red("✗")} ${message}\n`);
213
+ if (hint) process.stderr.write(` ${dim(hint)}\n`);
214
+ process.exit(1);
215
+ }
216
+ //#endregion
217
+ //#region src/commands/board.ts
218
+ function requireCredentials$3() {
219
+ const credentials = readCredentials();
220
+ if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
221
+ return credentials;
222
+ }
223
+ function resolveTicketRef$3(flags) {
224
+ const ref = flags.get("ticket") ?? readSession()?.ticket;
225
+ if (!ref) fail("No ticket to look up a board for.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
226
+ return ref;
227
+ }
228
+ /**
229
+ * Shows an agent the real shape of this project's board before it guesses at
230
+ * a column name — boards vary per project, and some have no "done" at all.
231
+ */
232
+ async function board(flags) {
233
+ const credentials = requireCredentials$3();
234
+ const ticketRef = resolveTicketRef$3(flags);
235
+ let result;
236
+ try {
237
+ result = await post(credentials.url, "/api/agent/board", {
238
+ token: credentials.token,
239
+ ticketRef
240
+ });
241
+ } catch (error) {
242
+ if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
243
+ if (error instanceof ApiError) {
244
+ if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
245
+ fail(error.message);
246
+ }
247
+ throw error;
248
+ }
249
+ info(`${ticketRef} is on ${bold(result.currentColumn || "(no column)")}`);
250
+ info(dim("Columns, in board order:"));
251
+ for (const column of result.columns) info(` - ${column.name}${column.isDone ? dim(" (terminal)") : ""}`);
252
+ if (!result.columns.some((column) => column.isDone)) info(dim("No column is marked terminal on this board — use your judgment on what counts as done."));
253
+ }
254
+ //#endregion
187
255
  //#region src/ticket.ts
188
256
  /**
189
257
  * Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
@@ -209,6 +277,40 @@ async function readStdin() {
209
277
  return Buffer.concat(chunks).toString("utf8");
210
278
  }
211
279
  //#endregion
280
+ //#region src/commands/comment.ts
281
+ function requireCredentials$2() {
282
+ const credentials = readCredentials();
283
+ if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
284
+ return credentials;
285
+ }
286
+ function resolveTicketRef$2(flags) {
287
+ const ref = flags.get("ticket") ?? readSession()?.ticket;
288
+ if (!ref) fail("No ticket to comment on.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
289
+ return ref;
290
+ }
291
+ /** A note on a ticket that doesn't move it — e.g. explaining a handoff mid-ticket. */
292
+ async function comment(flags) {
293
+ const credentials = requireCredentials$2();
294
+ const ticketRef = resolveTicketRef$2(flags);
295
+ const text = (flags.get("text") || await readStdin()).trim();
296
+ if (!text) fail("No comment text given.", "Pipe the comment in on stdin, or pass --text.");
297
+ try {
298
+ await post(credentials.url, "/api/agent/comment", {
299
+ token: credentials.token,
300
+ ticketRef,
301
+ comment: text
302
+ });
303
+ } catch (error) {
304
+ if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
305
+ if (error instanceof ApiError) {
306
+ if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
307
+ fail(error.message);
308
+ }
309
+ throw error;
310
+ }
311
+ success(`Commented on ${ticketRef}`);
312
+ }
313
+ //#endregion
212
314
  //#region src/usage.ts
213
315
  /**
214
316
  * Sums token usage out of a JSONL agent transcript, starting from `offset`.
@@ -336,36 +438,6 @@ function claudeTranscriptPath() {
336
438
  }
337
439
  }
338
440
  //#endregion
339
- //#region src/ui.ts
340
- /**
341
- * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
342
- * command's stdout stays a clean, pipeable value.
343
- */
344
- const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
345
- function paint(code, text) {
346
- return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
347
- }
348
- const dim = (text) => paint("2", text);
349
- const bold = (text) => paint("1", text);
350
- const green = (text) => paint("32", text);
351
- const red = (text) => paint("31", text);
352
- const cyan = (text) => paint("36", text);
353
- function info(message) {
354
- process.stderr.write(`${message}\n`);
355
- }
356
- function success(message) {
357
- process.stderr.write(`${green("✓")} ${message}\n`);
358
- }
359
- function warn(message) {
360
- process.stderr.write(`${red("!")} ${message}\n`);
361
- }
362
- /** Ends the process. Every user-facing failure funnels through here. */
363
- function fail(message, hint) {
364
- process.stderr.write(`${red("✗")} ${message}\n`);
365
- if (hint) process.stderr.write(` ${dim(hint)}\n`);
366
- process.exit(1);
367
- }
368
- //#endregion
369
441
  //#region src/commands/report.ts
370
442
  /**
371
443
  * Agent hooks hand their context to the command on stdin as JSON. Reading the
@@ -640,12 +712,61 @@ When the user hands you a Neuvikon ticket — text beginning with a reference li
640
712
 
641
713
  npx neuvikon start --ticket EWWQ-1
642
714
 
643
- When you finish that ticket, or the user moves you to a different one, run:
715
+ Before you start, and any time you need more context, read the ticket's
716
+ project pages — this is the project's accumulated knowledge, not just this
717
+ ticket's:
718
+
719
+ npx neuvikon pages --ticket EWWQ-1
720
+
721
+ When you learn something worth keeping — how a system works, a decision and
722
+ why, anything future work on this project would benefit from — write it back
723
+ so the next agent or teammate has it:
724
+
725
+ npx neuvikon pages update "Some Title" --ticket EWWQ-1 <<'EOF'
726
+ What you learned.
727
+ EOF
728
+
729
+ An update with a title that already exists overwrites that page instead of
730
+ creating a duplicate, so reuse a title for anything you're extending.
731
+ Reference another page by writing its title in double brackets, e.g.
732
+ \`[[Auth Flow]]\` — it becomes a real link if that page already exists.
733
+
734
+ Before you change the ticket's status, look at what columns this project's
735
+ board actually has — they vary per project, and there may be no column
736
+ literally called "Done":
737
+
738
+ npx neuvikon board --ticket EWWQ-1
739
+
740
+ Then decide, based on what's actually true, not on finishing your turn:
741
+
742
+ - The work is really done — merged to main, deployed, whatever "done" means
743
+ for this project — move it to that board's terminal column (the one
744
+ \`board\` marks as terminal), and say why:
745
+
746
+ npx neuvikon move --ticket EWWQ-1 --column "Done" --comment "Merged to main in #142"
747
+
748
+ - The work needs a step before done — a PR is open, it's waiting on review,
749
+ it's blocked — move it to whichever real column matches that, with the
750
+ same reasoning:
751
+
752
+ npx neuvikon move --ticket EWWQ-1 --column "Code Review" --comment "PR up, waiting on review"
753
+
754
+ - If the board has no column that fits, or you're just leaving a note without
755
+ changing where the ticket sits (e.g. handing off mid-ticket), comment
756
+ instead of moving:
757
+
758
+ npx neuvikon comment --ticket EWWQ-1 --text "Handing off — blocked on design"
759
+
760
+ A ticket that isn't actually finished should never land on a terminal column
761
+ just because you're done for now. When you finish that ticket, or the user
762
+ moves you to a different one, also run:
644
763
 
645
764
  npx neuvikon stop
646
765
 
647
- This is bookkeeping that tells the user's team which ticket you are on. You do
648
- not need to narrate these commands, and if one fails, carry on with the actual
766
+ \`stop\` only tells the team you're no longer working on it — it does not move
767
+ the ticket, since stopping and finishing are not the same thing. This is
768
+ bookkeeping that tells the user's team which ticket you are on. You do not
769
+ need to narrate these commands, and if one fails, carry on with the actual
649
770
  work — never let it block you.`;
650
771
  function instructionBlock() {
651
772
  return `${START_MARKER}\n${INSTRUCTION_BODY}\n${END_MARKER}`;
@@ -907,6 +1028,112 @@ async function logout() {
907
1028
  info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
908
1029
  }
909
1030
  //#endregion
1031
+ //#region src/commands/move.ts
1032
+ function requireCredentials$1() {
1033
+ const credentials = readCredentials();
1034
+ if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
1035
+ return credentials;
1036
+ }
1037
+ function resolveTicketRef$1(flags) {
1038
+ const ref = flags.get("ticket") ?? readSession()?.ticket;
1039
+ if (!ref) fail("No ticket to move.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
1040
+ return ref;
1041
+ }
1042
+ /**
1043
+ * Explicit column change, e.g. `npx neuvikon move --column "Code Review" \
1044
+ * --comment "PR up, waiting on review"`. A comment is mandatory: this is the
1045
+ * command that lands a ticket on a done-like column, and it should never do
1046
+ * that silently.
1047
+ */
1048
+ async function move(flags) {
1049
+ const credentials = requireCredentials$1();
1050
+ const ticketRef = resolveTicketRef$1(flags);
1051
+ const column = flags.get("column");
1052
+ if (!column) fail("A target column is required.", `Pass --column "Code Review" — run \`npx neuvikon board\` first if you're not sure what this project calls it.`);
1053
+ const comment = (flags.get("comment") || await readStdin()).trim();
1054
+ if (!comment) fail("A comment is required.", "Pass --comment \"why this ticket is moving\", e.g. \"Merged to main.\"");
1055
+ let result;
1056
+ try {
1057
+ result = await post(credentials.url, "/api/agent/move", {
1058
+ token: credentials.token,
1059
+ ticketRef,
1060
+ column,
1061
+ comment
1062
+ });
1063
+ } catch (error) {
1064
+ if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
1065
+ if (error instanceof ApiError) {
1066
+ if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
1067
+ fail(error.message);
1068
+ }
1069
+ throw error;
1070
+ }
1071
+ success(`Moved ${ticketRef} to ${bold(result.column)}`);
1072
+ }
1073
+ //#endregion
1074
+ //#region src/commands/pages.ts
1075
+ function requireCredentials() {
1076
+ const credentials = readCredentials();
1077
+ if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
1078
+ return credentials;
1079
+ }
1080
+ /** `--ticket` wins; otherwise fall back to whatever this directory is already tracking. */
1081
+ function resolveTicketRef(flags) {
1082
+ const ref = flags.get("ticket") ?? readSession()?.ticket;
1083
+ if (!ref) fail("No ticket to look up pages for.", "Pass --ticket EWWQ-1, or run this where `npx neuvikon start` is already tracking one.");
1084
+ return ref;
1085
+ }
1086
+ /** Both pages subcommands fail the same two ways `start` does; shared so they say so identically. */
1087
+ async function request(credentials, path, body) {
1088
+ try {
1089
+ return await post(credentials.url, path, body);
1090
+ } catch (error) {
1091
+ if (error instanceof NetworkError) fail("Could not reach Neuvikon.");
1092
+ if (error instanceof ApiError) {
1093
+ if (error.status === 401) fail(error.message, "Run `npx neuvikon login` again.");
1094
+ fail(error.message);
1095
+ }
1096
+ throw error;
1097
+ }
1098
+ }
1099
+ async function list(flags) {
1100
+ const credentials = requireCredentials();
1101
+ const ticketRef = resolveTicketRef(flags);
1102
+ const { pages } = await request(credentials, "/api/agent/pages", {
1103
+ token: credentials.token,
1104
+ ticketRef
1105
+ });
1106
+ if (pages.length === 0) {
1107
+ info(dim(`No pages in ${ticketRef}'s project yet.`));
1108
+ return;
1109
+ }
1110
+ const body = pages.map((page) => `# ${page.title}\n\n${page.text}`).join("\n\n---\n\n");
1111
+ process.stdout.write(`${body}\n`);
1112
+ }
1113
+ async function update(flags, positional) {
1114
+ const credentials = requireCredentials();
1115
+ const ticketRef = resolveTicketRef(flags);
1116
+ const title = flags.get("title") ?? positional[0];
1117
+ if (!title) fail("A page title is required.", `Pass --title "Auth flow notes", or give it as the first argument.`);
1118
+ const text = (flags.get("text") || await readStdin()).trim();
1119
+ if (!text) fail("No page text given.", "Pipe the page's body in on stdin, or pass --text.");
1120
+ success(`${(await request(credentials, "/api/agent/pages/upsert", {
1121
+ token: credentials.token,
1122
+ ticketRef,
1123
+ title,
1124
+ text
1125
+ })).created ? "Created" : "Updated"} page ${bold(title)} in ${ticketRef}'s project`);
1126
+ }
1127
+ async function pages(flags, positional) {
1128
+ const [subcommand, ...rest] = positional;
1129
+ switch (subcommand) {
1130
+ case void 0:
1131
+ case "list": return list(flags);
1132
+ case "update": return update(flags, rest);
1133
+ default: fail(`Unknown "pages ${subcommand}".`, "Try `pages` or `pages update`.");
1134
+ }
1135
+ }
1136
+ //#endregion
910
1137
  //#region src/commands/start.ts
911
1138
  const TOOLS = /* @__PURE__ */ new Set([
912
1139
  "claude-code",
@@ -1012,6 +1239,21 @@ ${bold("Usage")}
1012
1239
  npx neuvikon status Show login, project setup and current ticket
1013
1240
  npx neuvikon logout
1014
1241
 
1242
+ ${bold("Board")}
1243
+ npx neuvikon board Show this project's columns and where the ticket sits
1244
+ npx neuvikon move --column "Code Review" --comment "PR up, waiting on review"
1245
+ Move the ticket to a specific column, with why
1246
+ npx neuvikon comment --text "Handing off, blocked on design"
1247
+ Leave a note without changing the column
1248
+ All three accept --ticket EWWQ-1 instead of relying on a tracked session.
1249
+
1250
+ ${bold("Project pages")}
1251
+ npx neuvikon pages List the current ticket's project pages
1252
+ npx neuvikon pages update "Title" < notes.txt
1253
+ Create or update a page by title
1254
+ Reference another page with [[Its Title]], same as in the editor.
1255
+ Both accept --ticket EWWQ-1 instead of relying on a tracked session.
1256
+
1015
1257
  ${bold("Options")}
1016
1258
  --tool <name> claude-code | codex | cursor | other
1017
1259
  --transcript <path> Read token usage from an agent transcript
@@ -1059,6 +1301,10 @@ async function main() {
1059
1301
  case "report": return report(flags);
1060
1302
  case "stop": return stop(flags);
1061
1303
  case "status": return status();
1304
+ case "pages": return pages(flags, positional);
1305
+ case "board": return board(flags);
1306
+ case "move": return move(flags);
1307
+ case "comment": return comment(flags);
1062
1308
  case void 0:
1063
1309
  case "help":
1064
1310
  case "--help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",