trekoon 0.4.0 → 0.4.2

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 (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -2,12 +2,12 @@ import { randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, resolve } from "node:path";
4
4
 
5
+ import { createBoardEventBus, type BoardEventBus } from "./event-bus";
5
6
  import { createBoardApiHandler } from "./routes";
6
- import { buildBoardSnapshot } from "./snapshot";
7
+ import { startWalWatcher, type WalWatcher } from "./wal-watcher";
7
8
 
8
9
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
9
10
  import { resolveStoragePaths } from "../storage/path";
10
- import { TrackerDomain } from "../domain/tracker-domain";
11
11
 
12
12
  const CONTENT_TYPES: Record<string, string> = {
13
13
  ".css": "text/css; charset=utf-8",
@@ -93,6 +93,35 @@ function buildBoardSessionCookie(token: string): string {
93
93
  return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
94
94
  }
95
95
 
96
+ function readBoardSessionCookie(request: Request): string | null {
97
+ const rawCookie = request.headers.get("cookie");
98
+ if (!rawCookie) {
99
+ return null;
100
+ }
101
+
102
+ for (const part of rawCookie.split(";")) {
103
+ const [name, ...valueParts] = part.split("=");
104
+ if (name?.trim() !== "trekoon_board_session") {
105
+ continue;
106
+ }
107
+
108
+ const value = valueParts.join("=").trim();
109
+ return value.length > 0 ? decodeURIComponent(value) : null;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ function isAuthenticatedBoardRequest(request: Request, url: URL, token: string): boolean {
116
+ const queryToken = url.searchParams.get("token");
117
+ if (queryToken && queryToken === token) {
118
+ return true;
119
+ }
120
+
121
+ const cookieToken = readBoardSessionCookie(request);
122
+ return cookieToken !== null && cookieToken === token;
123
+ }
124
+
96
125
  function serializeInlineJson(value: unknown): string {
97
126
  return JSON.stringify(value)
98
127
  .replace(/</g, "\\u003c")
@@ -102,11 +131,12 @@ function serializeInlineJson(value: unknown): string {
102
131
  .replace(/\u2029/g, "\\u2029");
103
132
  }
104
133
 
105
- function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
106
- const domain = new TrackerDomain(database.db);
134
+ function buildBoardBootstrapPayload(_database: TrekoonDatabase, token: string): string {
135
+ // Only the auth token is inlined; the snapshot is fetched client-side via
136
+ // /api/snapshot to keep index.html small and avoid disclosing data even
137
+ // briefly through the HTML response cache.
107
138
  return serializeInlineJson({
108
139
  token,
109
- snapshot: buildBoardSnapshot(domain),
110
140
  });
111
141
  }
112
142
 
@@ -127,27 +157,56 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
127
157
  const boardRoot: string = paths.boardDir;
128
158
  const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
129
159
  const token: string = options.token ?? randomBytes(18).toString("hex");
160
+ const eventBus: BoardEventBus = createBoardEventBus();
161
+ const walWatcher: WalWatcher = startWalWatcher({
162
+ db: database.db,
163
+ databaseFile: database.paths.databaseFile,
164
+ eventBus,
165
+ });
130
166
  const apiHandler = createBoardApiHandler({
131
167
  db: database.db,
132
168
  cwd,
133
169
  token,
170
+ eventBus,
134
171
  });
135
172
 
136
173
  const serveBoard = (port: number) =>
137
174
  Bun.serve({
138
175
  hostname: "127.0.0.1",
139
176
  port,
177
+ idleTimeout: 0,
140
178
  fetch(request: Request): Promise<Response> | Response {
141
179
  const url = new URL(request.url);
142
180
  if (url.pathname.startsWith("/api/")) {
143
181
  return apiHandler(request);
144
182
  }
145
183
 
184
+ const isAuthenticated = isAuthenticatedBoardRequest(request, url, token);
146
185
  const responseHeaders: Record<string, string> = {
147
186
  "cache-control": "no-store",
148
187
  };
149
- if ((url.searchParams.get("token") ?? "") === token) {
188
+ const queryTokenMatched = (url.searchParams.get("token") ?? "") === token;
189
+ if (isAuthenticated && queryTokenMatched) {
150
190
  responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
191
+
192
+ // Token revoke on rotation (P1 finding 8): once we've installed the
193
+ // session cookie, redirect to the same URL with the `token=` query
194
+ // param stripped. This keeps the browser's address bar, history,
195
+ // and Referer headers free of the secret on the very first
196
+ // navigation, severing the leakage surface that an open URL bar
197
+ // would otherwise expose. The cookie carries auth from here on.
198
+ const redirectUrl = new URL(url);
199
+ redirectUrl.searchParams.delete("token");
200
+ // Preserve the relative location so the redirect works regardless
201
+ // of how the client reached us (loopback IP vs. localhost name).
202
+ const location = `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
203
+ return new Response(null, {
204
+ status: 302,
205
+ headers: {
206
+ ...responseHeaders,
207
+ location: location.length > 0 ? location : "/",
208
+ },
209
+ });
151
210
  }
152
211
 
153
212
  const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
@@ -157,9 +216,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
157
216
  return new Response("Board assets are not installed", { status: 500 });
158
217
  }
159
218
 
160
- const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
219
+ const rawHtml = readFileSync(fallbackPath, "utf8");
220
+ const html = isAuthenticated
221
+ ? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
222
+ : rawHtml;
161
223
 
162
224
  return new Response(html, {
225
+ status: isAuthenticated ? 200 : 401,
163
226
  headers: {
164
227
  ...responseHeaders,
165
228
  "content-type": "text/html; charset=utf-8",
@@ -168,8 +231,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
168
231
  }
169
232
 
170
233
  if (assetPath.endsWith("/index.html")) {
171
- const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
234
+ const rawHtml = readFileSync(assetPath, "utf8");
235
+ const html = isAuthenticated
236
+ ? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
237
+ : rawHtml;
172
238
  return new Response(html, {
239
+ status: isAuthenticated ? 200 : 401,
173
240
  headers: {
174
241
  ...responseHeaders,
175
242
  "content-type": "text/html; charset=utf-8",
@@ -231,6 +298,8 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
231
298
  hostname: "127.0.0.1",
232
299
  port,
233
300
  stop(): void {
301
+ walWatcher.close();
302
+ eventBus.close();
234
303
  server.stop(true);
235
304
  database.close();
236
305
  },
@@ -0,0 +1,302 @@
1
+ /**
2
+ * WAL watcher.
3
+ *
4
+ * Watches the SQLite WAL sidecar (`<dbfile>-wal`) for mtime changes so
5
+ * mutations issued by another process (e.g. `trekoon task update` running in
6
+ * a different shell) are picked up and pushed to SSE subscribers.
7
+ *
8
+ * The watcher is intentionally _decoupled_ from the in-process MutationService
9
+ * event path: in-process writes already publish their own deltas via the
10
+ * route handler, so we treat WAL mtime changes as a hint that "something
11
+ * changed somewhere" and reconcile by comparing a fresh snapshot against the
12
+ * last snapshot we broadcast. Only entities that actually differ end up in
13
+ * the published delta.
14
+ */
15
+
16
+ import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
17
+ import { dirname, basename } from "node:path";
18
+
19
+ import { type Database } from "bun:sqlite";
20
+
21
+ import { TrackerDomain } from "../domain/tracker-domain";
22
+ import { type BoardEventBus } from "./event-bus";
23
+ import { buildBoardSnapshot, type BoardSnapshot } from "./snapshot";
24
+
25
+ interface CollectionDiff {
26
+ readonly upserted: unknown[];
27
+ readonly deletedIds: string[];
28
+ }
29
+
30
+ function recordId(value: unknown): string | null {
31
+ if (!value || typeof value !== "object") {
32
+ return null;
33
+ }
34
+ const id = (value as { id?: unknown }).id;
35
+ return typeof id === "string" && id.length > 0 ? id : null;
36
+ }
37
+
38
+ /**
39
+ * Extract the (version, updatedAt) tuple used to detect content changes.
40
+ *
41
+ * `updatedAt` is bumped on every domain write; `version` is incremented in
42
+ * lockstep at the SQLite layer. Comparing the tuple lets us bail out cheaply
43
+ * when neither has moved, avoiding the JSON.stringify hot path that fires on
44
+ * every WAL tick — including ticks where only non-content shape changed
45
+ * (e.g. dependency reordering of an unrelated record produced an array
46
+ * identity change but no semantic delta for the entity in question).
47
+ */
48
+ function recordChangeKey(value: unknown): { version: number | null; updatedAt: number | null } {
49
+ if (!value || typeof value !== "object") {
50
+ return { version: null, updatedAt: null };
51
+ }
52
+ const versionRaw = (value as { version?: unknown }).version;
53
+ const updatedAtRaw = (value as { updatedAt?: unknown }).updatedAt;
54
+ return {
55
+ version: typeof versionRaw === "number" ? versionRaw : null,
56
+ updatedAt: typeof updatedAtRaw === "number" ? updatedAtRaw : null,
57
+ };
58
+ }
59
+
60
+ function changeKeyEqual(
61
+ a: { version: number | null; updatedAt: number | null },
62
+ b: { version: number | null; updatedAt: number | null },
63
+ ): boolean {
64
+ return a.version === b.version && a.updatedAt === b.updatedAt;
65
+ }
66
+
67
+ function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
68
+ const previousIndex = new Map<string, unknown>();
69
+ for (const record of previous ?? []) {
70
+ const id = recordId(record);
71
+ if (id !== null) {
72
+ previousIndex.set(id, record);
73
+ }
74
+ }
75
+
76
+ const upserted: unknown[] = [];
77
+ const seen = new Set<string>();
78
+ for (const record of current ?? []) {
79
+ const id = recordId(record);
80
+ if (id === null) {
81
+ continue;
82
+ }
83
+
84
+ seen.add(id);
85
+ const previousRecord = previousIndex.get(id);
86
+ if (!previousRecord) {
87
+ upserted.push(record);
88
+ continue;
89
+ }
90
+ // Tuple compare on (version, updatedAt) — both move in lockstep on every
91
+ // domain write. Equal tuple → no content change → skip the upsert.
92
+ if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
93
+ upserted.push(record);
94
+ }
95
+ }
96
+
97
+ const deletedIds: string[] = [];
98
+ for (const id of previousIndex.keys()) {
99
+ if (!seen.has(id)) {
100
+ deletedIds.push(id);
101
+ }
102
+ }
103
+
104
+ return { upserted, deletedIds };
105
+ }
106
+
107
+ export interface WalWatcherOptions {
108
+ readonly db: Database;
109
+ readonly databaseFile: string;
110
+ readonly eventBus: BoardEventBus;
111
+ /**
112
+ * How long to coalesce successive mtime change events. Defaults to 150ms
113
+ * which is small enough to feel real-time and large enough to absorb the
114
+ * burst of write events that SQLite emits within a single transaction.
115
+ */
116
+ readonly debounceMs?: number;
117
+ /**
118
+ * Log every Nth reconcile failure at warn level. Defaults to 5.
119
+ */
120
+ readonly logEveryNthFailure?: number;
121
+ /**
122
+ * Optional logger override; defaults to `console.warn`. Used by tests to
123
+ * assert failure-counter behavior without polluting stderr.
124
+ */
125
+ readonly logger?: (message: string, error: unknown) => void;
126
+ /**
127
+ * Optional snapshot builder override. Defaults to {@link buildBoardSnapshot}.
128
+ * Tests inject a throwing or stubbed builder to exercise failure paths.
129
+ */
130
+ readonly buildSnapshot?: (domain: TrackerDomain) => BoardSnapshot;
131
+ }
132
+
133
+ export interface WalWatcher {
134
+ /**
135
+ * Force a reconciliation outside the normal mtime-driven path. Useful for
136
+ * tests and for kicking the watcher after a manual external change.
137
+ */
138
+ reconcile(): void;
139
+ /**
140
+ * Total number of reconcile failures since the watcher started. Exposed for
141
+ * tests and operators; the watcher itself never throws.
142
+ */
143
+ readonly failureCount: () => number;
144
+ close(): void;
145
+ }
146
+
147
+ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
148
+ const debounceMs = options.debounceMs ?? 150;
149
+ const logEveryNthFailure = Math.max(1, options.logEveryNthFailure ?? 5);
150
+ const logger = options.logger ?? ((message: string, error: unknown): void => {
151
+ // eslint-disable-next-line no-console
152
+ console.warn(message, error);
153
+ });
154
+ const walFile = `${options.databaseFile}-wal`;
155
+ const watchDir = dirname(options.databaseFile);
156
+ const dbBaseName = basename(options.databaseFile);
157
+
158
+ // Hoist TrackerDomain construction out of reconcile: build once and reuse
159
+ // across ticks. The domain is a thin wrapper over the bun:sqlite Database
160
+ // handle and holds prepared-statement caches — recreating it per tick burns
161
+ // CPU on large boards. The handle stays valid for the lifetime of the
162
+ // server, so re-binding is unnecessary.
163
+ const domain = new TrackerDomain(options.db);
164
+ const buildSnapshot = options.buildSnapshot ?? buildBoardSnapshot;
165
+
166
+ let lastSnapshot = buildSnapshot(domain);
167
+
168
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
169
+ let closed = false;
170
+ let failures = 0;
171
+
172
+ function reconcile(): void {
173
+ if (closed) {
174
+ return;
175
+ }
176
+
177
+ try {
178
+ const fresh = buildSnapshot(domain);
179
+
180
+ const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
181
+ const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
182
+ const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
183
+ const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
184
+
185
+ const hasChanges =
186
+ epicsDiff.upserted.length > 0 || epicsDiff.deletedIds.length > 0 ||
187
+ tasksDiff.upserted.length > 0 || tasksDiff.deletedIds.length > 0 ||
188
+ subtasksDiff.upserted.length > 0 || subtasksDiff.deletedIds.length > 0 ||
189
+ dependenciesDiff.upserted.length > 0 || dependenciesDiff.deletedIds.length > 0;
190
+
191
+ lastSnapshot = fresh;
192
+
193
+ if (!hasChanges) {
194
+ return;
195
+ }
196
+
197
+ options.eventBus.publishSnapshotDelta({
198
+ generatedAt: Date.now(),
199
+ source: "wal-watcher",
200
+ epics: epicsDiff.upserted,
201
+ tasks: tasksDiff.upserted,
202
+ subtasks: subtasksDiff.upserted,
203
+ dependencies: dependenciesDiff.upserted,
204
+ deletedEpicIds: epicsDiff.deletedIds,
205
+ deletedTaskIds: tasksDiff.deletedIds,
206
+ deletedSubtaskIds: subtasksDiff.deletedIds,
207
+ deletedDependencyIds: dependenciesDiff.deletedIds,
208
+ });
209
+ } catch (error) {
210
+ // Reconciliation must never crash the server. Errors here usually mean
211
+ // the database is mid-write or a downstream snapshot builder threw; the
212
+ // next mtime tick will retry. Log every Nth failure to keep operators
213
+ // informed without flooding stderr on persistent faults.
214
+ failures += 1;
215
+ if (failures % logEveryNthFailure === 0) {
216
+ logger(`wal-watcher: reconcile failed (${failures} total failures)`, error);
217
+ }
218
+ }
219
+ }
220
+
221
+ function scheduleReconcile(): void {
222
+ if (closed) {
223
+ return;
224
+ }
225
+ if (debounceTimer) {
226
+ clearTimeout(debounceTimer);
227
+ }
228
+ debounceTimer = setTimeout(() => {
229
+ debounceTimer = null;
230
+ reconcile();
231
+ }, debounceMs);
232
+ }
233
+
234
+ // Track WAL mtime when available, so we only react to actual changes rather
235
+ // than spurious watcher fires (e.g. atime-only updates on some filesystems).
236
+ let lastWalMtime: number = readMtime(walFile);
237
+
238
+ function readMtime(path: string): number {
239
+ if (!existsSync(path)) {
240
+ return 0;
241
+ }
242
+ try {
243
+ return statSync(path).mtimeMs;
244
+ } catch {
245
+ return 0;
246
+ }
247
+ }
248
+
249
+ function maybeScheduleReconcile(): void {
250
+ const currentMtime = readMtime(walFile);
251
+ // mtime can equal 0 when the WAL was just checkpointed and removed; treat
252
+ // any change (including transitions to/from 0) as worth reconciling.
253
+ if (currentMtime !== lastWalMtime) {
254
+ lastWalMtime = currentMtime;
255
+ scheduleReconcile();
256
+ }
257
+ }
258
+
259
+ // We watch the directory rather than the WAL file directly because the WAL
260
+ // file can be unlinked (e.g. on checkpoint) which invalidates a direct
261
+ // file watch on some platforms.
262
+ let watcher: FSWatcher | null = null;
263
+ try {
264
+ watcher = watch(watchDir, (_eventType, filename) => {
265
+ if (closed) {
266
+ return;
267
+ }
268
+ if (typeof filename === "string" && filename !== `${dbBaseName}-wal` && filename !== dbBaseName) {
269
+ return;
270
+ }
271
+ maybeScheduleReconcile();
272
+ });
273
+ watcher.on("error", () => {
274
+ // Best-effort; ignore transient watcher errors.
275
+ });
276
+ } catch {
277
+ // Filesystem watch is best-effort. If it cannot be set up (e.g. read-only
278
+ // filesystem), the watcher silently degrades and only `reconcile()` calls
279
+ // will publish deltas.
280
+ watcher = null;
281
+ }
282
+
283
+ return {
284
+ reconcile,
285
+ failureCount: (): number => failures,
286
+ close(): void {
287
+ closed = true;
288
+ if (debounceTimer) {
289
+ clearTimeout(debounceTimer);
290
+ debounceTimer = null;
291
+ }
292
+ if (watcher) {
293
+ try {
294
+ watcher.close();
295
+ } catch {
296
+ // Already closed.
297
+ }
298
+ watcher = null;
299
+ }
300
+ },
301
+ };
302
+ }
@@ -7,6 +7,17 @@ import { BoardInstallError, type EnsureBoardInstalledOptions } from "../board/ty
7
7
  import { failResult, okResult } from "../io/output";
8
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
10
+ // Per-subcommand allow-lists. Mirrors the pattern used in epic/task/subtask
11
+ // command modules: each subcommand declares the set of flags (and options)
12
+ // it accepts; everything else is rejected up-front.
13
+ const OPEN_FLAGS = ["reveal-token"] as const;
14
+ const UPDATE_FLAGS: readonly string[] = [];
15
+
16
+ const FLAGS_BY_SUBCOMMAND: Readonly<Record<string, readonly string[]>> = {
17
+ open: OPEN_FLAGS,
18
+ update: UPDATE_FLAGS,
19
+ };
20
+
10
21
  type EnsureBoardInstalledFn = (options: EnsureBoardInstalledOptions) => ReturnType<typeof ensureBoardInstalled>;
11
22
  type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
12
23
  type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
@@ -67,7 +78,15 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
67
78
  const parsed = parseArgs(context.args);
68
79
  const subcommand: string | undefined = parsed.positional[0];
69
80
 
70
- if (parsed.options.size > 0 || parsed.flags.size > 0) {
81
+ const revealToken: boolean = parsed.flags.has("reveal-token");
82
+ const allowedFlags = new Set<string>(
83
+ subcommand !== undefined && subcommand in FLAGS_BY_SUBCOMMAND
84
+ ? FLAGS_BY_SUBCOMMAND[subcommand]
85
+ : [],
86
+ );
87
+ const disallowedFlags = [...parsed.flags].filter((flag) => !allowedFlags.has(flag));
88
+
89
+ if (parsed.options.size > 0 || disallowedFlags.length > 0) {
71
90
  return failResult({
72
91
  command: subcommand ? `board.${subcommand}` : "board",
73
92
  human: "Board commands do not accept options yet.",
@@ -118,30 +137,46 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
118
137
  const install = ensureInstalledImpl(boardInstallOptions(context));
119
138
  const server = startBoardServerImpl({ cwd: context.cwd });
120
139
  const launch = await openBoardInBrowserImpl(server.url);
140
+ const humanLines: string[] = [
141
+ `Board ready at ${server.fallbackUrl}`,
142
+ launch.launched
143
+ ? `Browser launched with ${launch.command}`
144
+ : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
145
+ `Open manually if needed: ${server.fallbackUrl}`,
146
+ ];
147
+ if (revealToken) {
148
+ humanLines.push(
149
+ `Tokenized URL (do not share, grants full board access): ${server.url}`,
150
+ );
151
+ }
152
+ const serverData: Record<string, unknown> = {
153
+ origin: server.origin,
154
+ fallbackUrl: server.fallbackUrl,
155
+ hostname: server.hostname,
156
+ port: server.port,
157
+ };
158
+ const launchData: Record<string, unknown> = {
159
+ launched: launch.launched,
160
+ command: launch.command,
161
+ errorMessage: launch.errorMessage,
162
+ };
163
+ if (revealToken) {
164
+ serverData.url = server.url;
165
+ serverData.token = server.token;
166
+ launchData.url = launch.url;
167
+ launchData.args = launch.args;
168
+ }
121
169
  return okResult({
122
170
  command: "board.open",
123
- human: [
124
- `Board ready at ${server.fallbackUrl}`,
125
- launch.launched
126
- ? `Browser launched with ${launch.command}`
127
- : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
128
- `Open manually if needed: ${server.fallbackUrl}`,
129
- ].join("\n"),
171
+ human: humanLines.join("\n"),
130
172
  data: {
131
173
  install: {
132
174
  action: install.action,
133
175
  paths: install.paths,
134
176
  manifest: install.manifest,
135
177
  },
136
- server: {
137
- origin: server.origin,
138
- url: server.url,
139
- fallbackUrl: server.fallbackUrl,
140
- hostname: server.hostname,
141
- port: server.port,
142
- token: server.token,
143
- },
144
- launch,
178
+ server: serverData,
179
+ launch: launchData,
145
180
  },
146
181
  });
147
182
  }
@@ -1357,10 +1357,9 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1357
1357
 
1358
1358
  const targets = updateAll ? [...domain.listEpics()] : ids.map((id) => domain.getEpicOrThrow(id));
1359
1359
  const epics = targets.map((target) =>
1360
- mutations.updateEpic(target.id, {
1361
- status,
1362
- description: append === undefined ? undefined : appendLine(target.description, append),
1363
- }),
1360
+ append !== undefined
1361
+ ? mutations.appendToEpicDescription({ epicId: target.id, append, status })
1362
+ : mutations.updateEpic(target.id, { status }),
1364
1363
  );
1365
1364
 
1366
1365
  return okResult({
@@ -1386,11 +1385,10 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1386
1385
  });
1387
1386
  }
1388
1387
 
1389
- const nextDescription =
1390
- append === undefined
1391
- ? description
1392
- : appendLine(domain.getEpicOrThrow(epicId).description, append);
1393
- const epic = mutations.updateEpic(epicId, { title, description: nextDescription, status });
1388
+ const epic =
1389
+ append !== undefined
1390
+ ? mutations.appendToEpicDescription({ epicId, append, status })
1391
+ : mutations.updateEpic(epicId, { title, description, status });
1394
1392
 
1395
1393
  return okResult({
1396
1394
  command: "epic.update",
@@ -29,8 +29,46 @@ function readErrorMessage(error: unknown): string | null {
29
29
  return null;
30
30
  }
31
31
 
32
+ // Keys whose values must never appear in surfaced error output.
33
+ // Handles formats: key=val, key: val, key="val", 'key':'val', "key":"val",
34
+ // Authorization: Bearer val, Authorization: Basic val.
35
+ const SENSITIVE_KEY_PATTERN =
36
+ /(["']?)(token|secret|password|bearer|authorization|api[_-]?key|client[_-]?secret|private[_-]?key|cookie|session[_-]?id)(["']?\s*[:=]\s*(?:Bearer\s+|Basic\s+)?["']?)([^\s"',;&\]}{)<>]+)/giu;
37
+
38
+ // Tag-style sensitive values: <key>value</key>.
39
+ const SENSITIVE_TAG_PATTERN =
40
+ /(<\s*(token|secret|password|bearer|authorization|api[_-]?key|client[_-]?secret|private[_-]?key|cookie|session[_-]?id)\s*>)([^<]+)/giu;
41
+
42
+ // Standalone "Bearer xyz" / "Basic xyz" anywhere in the message.
43
+ // SENSITIVE_KEY_PATTERN runs first and consumes Authorization: Bearer/Basic forms; this
44
+ // catches bare occurrences that remain (e.g. "got Bearer eyJ..." or "auth: Basic dXNl...").
45
+ const STANDALONE_AUTH_SCHEME_PATTERN = /\b(Bearer|Basic)\s+([A-Za-z0-9._\-+/=]+)/giu;
46
+
47
+ // JWT shape heuristic: three base64url segments separated by dots, each starting
48
+ // with a base64url-encoded JSON header/payload/signature. The first two segments
49
+ // of any JWT begin with "eyJ" because they encode JSON objects (`{"...`).
50
+ // Catches bare JWTs that slip past the keyed and Bearer/Basic patterns above
51
+ // (e.g. raw token pasted into an error message without an "Authorization:" prefix).
52
+ const JWT_PATTERN = /\beyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/gu;
53
+
54
+ export function redactSensitive(input: string): string {
55
+ const keyRedacted = input.replace(
56
+ SENSITIVE_KEY_PATTERN,
57
+ (_match, open, key, sep) => `${open}${key}${sep}REDACTED`,
58
+ );
59
+ const tagRedacted = keyRedacted.replace(
60
+ SENSITIVE_TAG_PATTERN,
61
+ (_match, openTag) => `${openTag}REDACTED`,
62
+ );
63
+ const authRedacted = tagRedacted.replace(
64
+ STANDALONE_AUTH_SCHEME_PATTERN,
65
+ (_match, scheme) => `${scheme} REDACTED`,
66
+ );
67
+ return authRedacted.replace(JWT_PATTERN, "REDACTED");
68
+ }
69
+
32
70
  function sanitizeErrorMessage(message: string): string {
33
- const normalized = message.replace(/\s+/gu, " ").trim();
71
+ const normalized = redactSensitive(message.replace(/\s+/gu, " ").trim());
34
72
  if (normalized.length <= 240) {
35
73
  return normalized;
36
74
  }
@@ -109,3 +147,18 @@ export function safeErrorMessage(error: unknown, fallback: string): string {
109
147
  const message = readErrorMessage(error);
110
148
  return message === null ? fallback : sanitizeErrorMessage(message);
111
149
  }
150
+
151
+ /**
152
+ * Redact a stack trace before logging. Routes the input through
153
+ * `redactSensitive` (the canonical secret-stripping pass) so absolute paths
154
+ * and any inline credentials are scrubbed. The function is intentionally
155
+ * shallow — additional heuristics (e.g. JWT shape detection) live in
156
+ * `redactSensitive` itself so future contributors only need to extend that
157
+ * single regex pipeline.
158
+ */
159
+ export function redactStack(stack: string | undefined): string {
160
+ if (typeof stack !== "string" || stack.length === 0) {
161
+ return "";
162
+ }
163
+ return redactSensitive(stack);
164
+ }