neuvikon 0.2.7 → 0.2.8

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 (2) hide show
  1. package/dist/index.mjs +293 -241
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,11 +1,284 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
3
  import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ //#region src/config.ts
7
+ /**
8
+ * The two deployments this CLI knows about. The browser origin is kept here
9
+ * rather than read back from the server, because the API answers on a Convex
10
+ * domain while approval happens on the web app's — one is not derivable from
11
+ * the other, and guessing wrong sends people to a page that does not exist.
12
+ */
13
+ const DEPLOYMENTS = {
14
+ production: {
15
+ api: "https://flexible-bird-801.eu-west-1.convex.site",
16
+ web: "https://www.neuvikon.space"
17
+ },
18
+ development: {
19
+ api: "https://calm-sturgeon-831.eu-west-1.convex.site",
20
+ web: "http://localhost:3000"
21
+ }
22
+ };
23
+ /**
24
+ * `--dev` targets the development deployment; NEUVIKON_URL overrides the API
25
+ * for a self-hosted install, in which case the web origin is unknowable and
26
+ * falls back to the matching default.
27
+ */
28
+ function resolveDeployment(flags) {
29
+ const base = flags.has("dev") ? DEPLOYMENTS.development : DEPLOYMENTS.production;
30
+ const override = flags.get("url") || process.env.NEUVIKON_URL;
31
+ return override ? {
32
+ api: override.trim().replace(/\/+$/, ""),
33
+ web: base.web
34
+ } : base;
35
+ }
36
+ const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
37
+ const CONFIG_DIR = join(homedir(), ".neuvikon");
38
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
39
+ /**
40
+ * Somewhere a browser cannot open — CI, a container, a remote box — there is no
41
+ * way to complete a device login, so a token handed in through the environment
42
+ * stands in for one. It wins over the config file: an explicit env var is the
43
+ * more specific instruction.
44
+ */
45
+ function credentialsFromEnv() {
46
+ const token = process.env.NEUVIKON_TOKEN;
47
+ if (!token) return null;
48
+ return {
49
+ token,
50
+ url: DEFAULT_SITE_URL
51
+ };
52
+ }
53
+ function readCredentials() {
54
+ const fromEnv = credentialsFromEnv();
55
+ if (fromEnv) return fromEnv;
56
+ let raw;
57
+ try {
58
+ raw = readFileSync(CONFIG_PATH, "utf8");
59
+ } catch {
60
+ return null;
61
+ }
62
+ try {
63
+ const parsed = JSON.parse(raw);
64
+ if (typeof parsed !== "object" || parsed === null) return null;
65
+ const { token, url } = parsed;
66
+ if (typeof token !== "string" || !token) return null;
67
+ if (typeof url !== "string" || !url) return null;
68
+ return parsed;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * A token on disk is only as safe as its permissions, so directory and file are
75
+ * locked to the owner on every write, not just on first create — an earlier
76
+ * version may have left them readable.
77
+ */
78
+ function writeCredentials(credentials) {
79
+ mkdirSync(CONFIG_DIR, {
80
+ recursive: true,
81
+ mode: 448
82
+ });
83
+ chmodSync(CONFIG_DIR, 448);
84
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
85
+ chmodSync(CONFIG_PATH, 384);
86
+ }
87
+ function clearCredentials() {
88
+ try {
89
+ rmSync(CONFIG_PATH);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ //#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
+ //#region src/session.ts
140
+ /**
141
+ * Distinguishes concurrent agents working in the *same* checkout — two Claude
142
+ * Code panes both running `claude` in this directory get two session files,
143
+ * because each one's process tree carries its own CLAUDE_CODE_SESSION_ID. A
144
+ * tool that doesn't set it (or a person typing the command by hand) falls
145
+ * back to the pre-multi-agent behavior of one session per directory.
146
+ */
147
+ function instanceKey() {
148
+ return process.env.CLAUDE_CODE_SESSION_ID?.trim() || null;
149
+ }
150
+ /**
151
+ * Scoped to the working directory rather than the machine: two checkouts driven
152
+ * in parallel are two sessions, which is what running two agents means. Within
153
+ * one checkout, `instanceKey()` further scopes it per agent so two panes in the
154
+ * same directory don't stop each other's session out from under them.
155
+ */
156
+ function sessionPath() {
157
+ const key = instanceKey();
158
+ const filename = key ? `.neuvikon-session.${key}` : ".neuvikon-session";
159
+ return join(process.cwd(), filename);
160
+ }
161
+ function readSession() {
162
+ let raw;
163
+ try {
164
+ raw = readFileSync(sessionPath(), "utf8");
165
+ } catch {
166
+ return null;
167
+ }
168
+ try {
169
+ const parsed = JSON.parse(raw);
170
+ if (typeof parsed !== "object" || parsed === null) return null;
171
+ const { sessionKey, ticket } = parsed;
172
+ if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
173
+ return parsed;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ function writeSession(session) {
179
+ writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
180
+ }
181
+ function clearSession() {
182
+ try {
183
+ rmSync(sessionPath());
184
+ } catch {}
185
+ }
186
+ //#endregion
187
+ //#region src/ticket.ts
188
+ /**
189
+ * Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
190
+ * server's — it pulls *candidates* out of arbitrary pasted prose and lets the
191
+ * server decide which one is a real ticket in the caller's organization, so the
192
+ * authoritative rule lives in exactly one place.
193
+ */
194
+ const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
195
+ /** Every distinct ref-shaped token in `text`, in the order it appears. */
196
+ function extractTicketRefs(text) {
197
+ const seen = /* @__PURE__ */ new Set();
198
+ for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
199
+ if (/^\d+$/.test(match[1])) continue;
200
+ seen.add(`${match[1]}-${match[2]}`);
201
+ }
202
+ return [...seen];
203
+ }
204
+ /** Reads piped input, if any. Returns "" when stdin is a terminal. */
205
+ async function readStdin() {
206
+ if (process.stdin.isTTY) return "";
207
+ const chunks = [];
208
+ for await (const chunk of process.stdin) chunks.push(chunk);
209
+ return Buffer.concat(chunks).toString("utf8");
210
+ }
211
+ //#endregion
212
+ //#region src/ui.ts
213
+ /**
214
+ * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
215
+ * command's stdout stays a clean, pipeable value.
216
+ */
217
+ const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
218
+ function paint(code, text) {
219
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
220
+ }
221
+ const dim = (text) => paint("2", text);
222
+ const bold = (text) => paint("1", text);
223
+ const green = (text) => paint("32", text);
224
+ const red = (text) => paint("31", text);
225
+ const cyan = (text) => paint("36", text);
226
+ function info(message) {
227
+ process.stderr.write(`${message}\n`);
228
+ }
229
+ function success(message) {
230
+ process.stderr.write(`${green("✓")} ${message}\n`);
231
+ }
232
+ function warn(message) {
233
+ process.stderr.write(`${red("!")} ${message}\n`);
234
+ }
235
+ /** Ends the process. Every user-facing failure funnels through here. */
236
+ function fail(message, hint) {
237
+ process.stderr.write(`${red("✗")} ${message}\n`);
238
+ if (hint) process.stderr.write(` ${dim(hint)}\n`);
239
+ process.exit(1);
240
+ }
241
+ //#endregion
242
+ //#region src/commands/hook-start.ts
243
+ /**
244
+ * The `UserPromptSubmit` counterpart to `start`. Unlike `start`, this runs on
245
+ * *every* prompt regardless of whether it mentions a ticket, so it must never
246
+ * fail the hook and block the user's message — no credentials, no ticket ref,
247
+ * or a dead network are all just "nothing to do here", not errors.
248
+ */
249
+ async function hookStart() {
250
+ try {
251
+ const credentials = readCredentials();
252
+ if (!credentials) return;
253
+ const [ref] = extractTicketRefs(await readStdin());
254
+ if (!ref) return;
255
+ const existing = readSession();
256
+ if (existing?.ticket === ref) return;
257
+ const result = await post(credentials.url, "/api/agent/events", {
258
+ token: credentials.token,
259
+ event: "start",
260
+ ticketRef: ref,
261
+ tool: "claude-code"
262
+ });
263
+ if (existing) await post(credentials.url, "/api/agent/events", {
264
+ token: credentials.token,
265
+ event: "stop",
266
+ sessionKey: existing.sessionKey
267
+ }).catch(() => void 0);
268
+ writeSession({
269
+ sessionKey: result.sessionKey,
270
+ ticket: ref,
271
+ startedAt: Date.now()
272
+ });
273
+ success(`Tracking work on ${bold(ref)}`);
274
+ } catch {}
275
+ }
276
+ //#endregion
6
277
  //#region src/agents/hooks.ts
7
278
  const REPORT_COMMAND = "npx neuvikon report";
279
+ const HEARTBEAT_COMMAND = "npx neuvikon report --heartbeat";
8
280
  const STOP_COMMAND = "npx neuvikon stop";
281
+ const HOOK_START_COMMAND = "npx neuvikon hook-start";
9
282
  function hasCommand(groups, command) {
10
283
  return (groups ?? []).some((group) => group.hooks.some((hook) => hook.command === command));
11
284
  }
@@ -21,8 +294,20 @@ function withCommand(groups, command) {
21
294
  * whose transcript exposes usage, per `usage.ts`. `Stop` fires after every
22
295
  * turn, exactly when `report` should read what the transcript gained since
23
296
  * last time; `SessionEnd` is the backstop for `stop` when nobody remembered
24
- * to run it by hand. Without this, `start`/`stop` are the only events an
25
- * agent ever sends and token usage never leaves zero.
297
+ * to run it by hand.
298
+ *
299
+ * `UserPromptSubmit` covers `start`: relying on the agent to notice a ticket
300
+ * ref in the prompt and remember to run `start` itself is exactly the failure
301
+ * mode reported in NS-31 — an LLM that's mid-instruction-following skips it
302
+ * often enough to be unreliable bookkeeping. The hook makes it deterministic
303
+ * for Claude Code; other tools still fall back to the AGENTS.md instruction.
304
+ *
305
+ * `Notification` fires when Claude Code is blocked mid-turn — a permission
306
+ * prompt, a question, waiting on the user — which is exactly when `Stop`
307
+ * does *not* fire, since the turn hasn't finished. Without a heartbeat there
308
+ * too, a session that's genuinely just waiting on a person reads as stale on
309
+ * the board after `AGENT_SESSION_STALE_MS`, indistinguishable from one that
310
+ * actually stopped (NS-32).
26
311
  */
27
312
  function installClaudeHooks(root) {
28
313
  const relativePath = join(".claude", "settings.json");
@@ -39,7 +324,7 @@ function installClaudeHooks(root) {
39
324
  };
40
325
  }
41
326
  const hooks = settings.hooks ?? {};
42
- if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND)) return {
327
+ if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.Notification, HEARTBEAT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND) && hasCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)) return {
43
328
  path: relativePath,
44
329
  action: "unchanged"
45
330
  };
@@ -48,7 +333,9 @@ function installClaudeHooks(root) {
48
333
  hooks: {
49
334
  ...hooks,
50
335
  Stop: withCommand(hooks.Stop, REPORT_COMMAND),
51
- SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND)
336
+ Notification: withCommand(hooks.Notification, HEARTBEAT_COMMAND),
337
+ SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND),
338
+ UserPromptSubmit: withCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)
52
339
  }
53
340
  };
54
341
  mkdirSync(dirname(path), { recursive: true });
@@ -212,126 +499,6 @@ function isInstalled(root) {
212
499
  return false;
213
500
  }
214
501
  //#endregion
215
- //#region src/config.ts
216
- /**
217
- * The two deployments this CLI knows about. The browser origin is kept here
218
- * rather than read back from the server, because the API answers on a Convex
219
- * domain while approval happens on the web app's — one is not derivable from
220
- * the other, and guessing wrong sends people to a page that does not exist.
221
- */
222
- const DEPLOYMENTS = {
223
- production: {
224
- api: "https://flexible-bird-801.eu-west-1.convex.site",
225
- web: "https://www.neuvikon.space"
226
- },
227
- development: {
228
- api: "https://calm-sturgeon-831.eu-west-1.convex.site",
229
- web: "http://localhost:3000"
230
- }
231
- };
232
- /**
233
- * `--dev` targets the development deployment; NEUVIKON_URL overrides the API
234
- * for a self-hosted install, in which case the web origin is unknowable and
235
- * falls back to the matching default.
236
- */
237
- function resolveDeployment(flags) {
238
- const base = flags.has("dev") ? DEPLOYMENTS.development : DEPLOYMENTS.production;
239
- const override = flags.get("url") || process.env.NEUVIKON_URL;
240
- return override ? {
241
- api: override.trim().replace(/\/+$/, ""),
242
- web: base.web
243
- } : base;
244
- }
245
- const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
246
- const CONFIG_DIR = join(homedir(), ".neuvikon");
247
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
248
- /**
249
- * Somewhere a browser cannot open — CI, a container, a remote box — there is no
250
- * way to complete a device login, so a token handed in through the environment
251
- * stands in for one. It wins over the config file: an explicit env var is the
252
- * more specific instruction.
253
- */
254
- function credentialsFromEnv() {
255
- const token = process.env.NEUVIKON_TOKEN;
256
- if (!token) return null;
257
- return {
258
- token,
259
- url: DEFAULT_SITE_URL
260
- };
261
- }
262
- function readCredentials() {
263
- const fromEnv = credentialsFromEnv();
264
- if (fromEnv) return fromEnv;
265
- let raw;
266
- try {
267
- raw = readFileSync(CONFIG_PATH, "utf8");
268
- } catch {
269
- return null;
270
- }
271
- try {
272
- const parsed = JSON.parse(raw);
273
- if (typeof parsed !== "object" || parsed === null) return null;
274
- const { token, url } = parsed;
275
- if (typeof token !== "string" || !token) return null;
276
- if (typeof url !== "string" || !url) return null;
277
- return parsed;
278
- } catch {
279
- return null;
280
- }
281
- }
282
- /**
283
- * A token on disk is only as safe as its permissions, so directory and file are
284
- * locked to the owner on every write, not just on first create — an earlier
285
- * version may have left them readable.
286
- */
287
- function writeCredentials(credentials) {
288
- mkdirSync(CONFIG_DIR, {
289
- recursive: true,
290
- mode: 448
291
- });
292
- chmodSync(CONFIG_DIR, 448);
293
- writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
294
- chmodSync(CONFIG_PATH, 384);
295
- }
296
- function clearCredentials() {
297
- try {
298
- rmSync(CONFIG_PATH);
299
- return true;
300
- } catch {
301
- return false;
302
- }
303
- }
304
- //#endregion
305
- //#region src/ui.ts
306
- /**
307
- * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
308
- * command's stdout stays a clean, pipeable value.
309
- */
310
- const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
311
- function paint(code, text) {
312
- return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
313
- }
314
- const dim = (text) => paint("2", text);
315
- const bold = (text) => paint("1", text);
316
- const green = (text) => paint("32", text);
317
- const red = (text) => paint("31", text);
318
- const cyan = (text) => paint("36", text);
319
- function info(message) {
320
- process.stderr.write(`${message}\n`);
321
- }
322
- function success(message) {
323
- process.stderr.write(`${green("✓")} ${message}\n`);
324
- }
325
- function warn(message) {
326
- process.stderr.write(`${red("!")} ${message}\n`);
327
- }
328
- /** Ends the process. Every user-facing failure funnels through here. */
329
- function fail(message, hint) {
330
- process.stderr.write(`${red("✗")} ${message}\n`);
331
- if (hint) process.stderr.write(` ${dim(hint)}\n`);
332
- process.exit(1);
333
- }
334
- //#endregion
335
502
  //#region src/commands/init.ts
336
503
  /**
337
504
  * Separate from `login` because instructions are per-project and credentials
@@ -345,49 +512,6 @@ function init() {
345
512
  if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
346
513
  }
347
514
  //#endregion
348
- //#region src/api.ts
349
- var ApiError = class extends Error {
350
- status;
351
- constructor(message, status) {
352
- super(message);
353
- this.status = status;
354
- this.name = "ApiError";
355
- }
356
- };
357
- /** A request that never reached the server: offline, DNS, TLS, timeout. */
358
- var NetworkError = class extends Error {
359
- constructor(cause) {
360
- super("Could not reach Neuvikon", { cause });
361
- this.name = "NetworkError";
362
- }
363
- };
364
- const TIMEOUT_MS = 15e3;
365
- /**
366
- * The one place an HTTP failure becomes a typed error. Callers decide what a
367
- * failure means — a hook wrapping the agent's turn wants to swallow it, `login`
368
- * wants to shout about it.
369
- */
370
- async function post(url, path, body) {
371
- let response;
372
- try {
373
- response = await fetch(`${url}${path}`, {
374
- method: "POST",
375
- headers: { "content-type": "application/json" },
376
- body: JSON.stringify(body),
377
- signal: AbortSignal.timeout(TIMEOUT_MS)
378
- });
379
- } catch (error) {
380
- throw new NetworkError(error);
381
- }
382
- const payload = await response.json().catch(() => ({}));
383
- const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
384
- if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
385
- return payload;
386
- }
387
- function normalizeUrl(url) {
388
- return url.trim().replace(/\/+$/, "");
389
- }
390
- //#endregion
391
515
  //#region src/browser.ts
392
516
  /**
393
517
  * Best-effort. The URL is always printed too, so a headless box, an SSH session
@@ -465,54 +589,6 @@ async function login(flags) {
465
589
  }
466
590
  }
467
591
  //#endregion
468
- //#region src/session.ts
469
- /**
470
- * Distinguishes concurrent agents working in the *same* checkout — two Claude
471
- * Code panes both running `claude` in this directory get two session files,
472
- * because each one's process tree carries its own CLAUDE_CODE_SESSION_ID. A
473
- * tool that doesn't set it (or a person typing the command by hand) falls
474
- * back to the pre-multi-agent behavior of one session per directory.
475
- */
476
- function instanceKey() {
477
- return process.env.CLAUDE_CODE_SESSION_ID?.trim() || null;
478
- }
479
- /**
480
- * Scoped to the working directory rather than the machine: two checkouts driven
481
- * in parallel are two sessions, which is what running two agents means. Within
482
- * one checkout, `instanceKey()` further scopes it per agent so two panes in the
483
- * same directory don't stop each other's session out from under them.
484
- */
485
- function sessionPath() {
486
- const key = instanceKey();
487
- const filename = key ? `.neuvikon-session.${key}` : ".neuvikon-session";
488
- return join(process.cwd(), filename);
489
- }
490
- function readSession() {
491
- let raw;
492
- try {
493
- raw = readFileSync(sessionPath(), "utf8");
494
- } catch {
495
- return null;
496
- }
497
- try {
498
- const parsed = JSON.parse(raw);
499
- if (typeof parsed !== "object" || parsed === null) return null;
500
- const { sessionKey, ticket } = parsed;
501
- if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
502
- return parsed;
503
- } catch {
504
- return null;
505
- }
506
- }
507
- function writeSession(session) {
508
- writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
509
- }
510
- function clearSession() {
511
- try {
512
- rmSync(sessionPath());
513
- } catch {}
514
- }
515
- //#endregion
516
592
  //#region src/commands/logout.ts
517
593
  /**
518
594
  * Deleting the local file is not enough: the token stays valid until the server
@@ -549,31 +625,6 @@ async function logout() {
549
625
  info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
550
626
  }
551
627
  //#endregion
552
- //#region src/ticket.ts
553
- /**
554
- * Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
555
- * server's — it pulls *candidates* out of arbitrary pasted prose and lets the
556
- * server decide which one is a real ticket in the caller's organization, so the
557
- * authoritative rule lives in exactly one place.
558
- */
559
- const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
560
- /** Every distinct ref-shaped token in `text`, in the order it appears. */
561
- function extractTicketRefs(text) {
562
- const seen = /* @__PURE__ */ new Set();
563
- for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
564
- if (/^\d+$/.test(match[1])) continue;
565
- seen.add(`${match[1]}-${match[2]}`);
566
- }
567
- return [...seen];
568
- }
569
- /** Reads piped input, if any. Returns "" when stdin is a terminal. */
570
- async function readStdin() {
571
- if (process.stdin.isTTY) return "";
572
- const chunks = [];
573
- for await (const chunk of process.stdin) chunks.push(chunk);
574
- return Buffer.concat(chunks).toString("utf8");
575
- }
576
- //#endregion
577
628
  //#region src/usage.ts
578
629
  /**
579
630
  * Sums token usage out of a JSONL agent transcript, starting from `offset`.
@@ -890,6 +941,7 @@ async function main() {
890
941
  case "logout": return logout();
891
942
  case "init": return init();
892
943
  case "start": return start(flags, positional);
944
+ case "hook-start": return hookStart();
893
945
  case "report": return report(flags);
894
946
  case "stop": return stop();
895
947
  case "status": return status();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",