trekoon 0.3.0 → 0.3.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 (43) hide show
  1. package/.agents/skills/trekoon/SKILL.md +274 -26
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +213 -0
  3. package/.agents/skills/trekoon/reference/execution.md +210 -0
  4. package/.agents/skills/trekoon/reference/planning.md +244 -0
  5. package/README.md +24 -10
  6. package/docs/ai-agents.md +108 -30
  7. package/docs/commands.md +81 -5
  8. package/docs/machine-contracts.md +120 -0
  9. package/docs/plans/r1-unified-skill-rewrite.md +290 -0
  10. package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
  11. package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
  12. package/docs/quickstart.md +31 -0
  13. package/package.json +2 -2
  14. package/src/board/assets/app.js +5 -0
  15. package/src/board/assets/components/EpicsOverview.js +13 -0
  16. package/src/board/assets/components/Workspace.js +27 -12
  17. package/src/board/assets/components/helpers.js +3 -2
  18. package/src/board/assets/runtime/delegation.js +69 -1
  19. package/src/board/assets/state/actions.js +27 -1
  20. package/src/board/assets/state/store.js +37 -8
  21. package/src/board/assets/state/utils.js +42 -0
  22. package/src/board/assets/styles/board.css +68 -0
  23. package/src/board/routes.ts +2 -0
  24. package/src/commands/epic.ts +74 -3
  25. package/src/commands/session.ts +7 -75
  26. package/src/commands/skills.ts +39 -32
  27. package/src/commands/subtask.ts +7 -5
  28. package/src/commands/suggest.ts +283 -0
  29. package/src/commands/sync-helpers.ts +75 -0
  30. package/src/commands/task-readiness.ts +8 -20
  31. package/src/commands/task.ts +59 -3
  32. package/src/domain/mutation-service.ts +69 -42
  33. package/src/domain/tracker-domain.ts +151 -22
  34. package/src/domain/types.ts +12 -0
  35. package/src/index.ts +1 -1
  36. package/src/io/output.ts +4 -2
  37. package/src/runtime/cli-shell.ts +26 -3
  38. package/src/runtime/command-types.ts +1 -1
  39. package/src/storage/database.ts +43 -1
  40. package/src/storage/events-retention.ts +57 -8
  41. package/src/storage/migrations.ts +58 -3
  42. package/src/sync/service.ts +101 -24
  43. package/src/sync/types.ts +1 -0
@@ -32,10 +32,13 @@ import {
32
32
  type SubtaskRecord,
33
33
  type TaskTreeDetailed,
34
34
  type TaskRecord,
35
+ VALID_STATUSES,
36
+ VALID_TRANSITIONS,
37
+ type ValidStatus,
35
38
  } from "./types";
36
39
 
37
40
  const DEFAULT_STATUS = "todo";
38
- const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "in-progress", "done"]);
41
+ const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
39
42
 
40
43
  interface EpicRow {
41
44
  id: string;
@@ -48,10 +51,12 @@ interface EpicRow {
48
51
 
49
52
  interface TaskRow extends EpicRow {
50
53
  epic_id: string;
54
+ owner: string | null;
51
55
  }
52
56
 
53
57
  interface SubtaskRow extends EpicRow {
54
58
  task_id: string;
59
+ owner: string | null;
55
60
  }
56
61
 
57
62
  interface DependencyRow {
@@ -146,6 +151,45 @@ function normalizeSubtaskDescription(value: string | undefined): string {
146
151
  return value.trim();
147
152
  }
148
153
 
154
+ function isValidStatus(status: string): status is ValidStatus {
155
+ return (VALID_STATUSES as readonly string[]).includes(status);
156
+ }
157
+
158
+ export function validateStatusTransition(fromStatus: string, toStatus: string, entityKind: string, entityId: string): void {
159
+ if (fromStatus === toStatus) {
160
+ return;
161
+ }
162
+
163
+ if (!isValidStatus(toStatus)) {
164
+ throw new DomainError({
165
+ code: "status_transition_invalid",
166
+ message: `invalid status '${toStatus}' for ${entityKind} ${entityId}; allowed statuses: ${VALID_STATUSES.join(", ")}`,
167
+ details: { entity: entityKind, id: entityId, fromStatus, toStatus, allowedStatuses: [...VALID_STATUSES] },
168
+ });
169
+ }
170
+
171
+ if (!isValidStatus(fromStatus)) {
172
+ // Legacy/custom status from pre-0.3.1 data; allow transition to any valid
173
+ // status so existing databases can migrate forward without manual fixups.
174
+ return;
175
+ }
176
+
177
+ const allowed = VALID_TRANSITIONS.get(fromStatus);
178
+ if (!allowed || !allowed.has(toStatus)) {
179
+ throw new DomainError({
180
+ code: "status_transition_invalid",
181
+ message: `cannot transition ${entityKind} ${entityId} from '${fromStatus}' to '${toStatus}'`,
182
+ details: {
183
+ entity: entityKind,
184
+ id: entityId,
185
+ fromStatus,
186
+ toStatus,
187
+ allowedTransitions: allowed ? [...allowed] : [],
188
+ },
189
+ });
190
+ }
191
+ }
192
+
149
193
  function mapEpic(row: EpicRow): EpicRecord {
150
194
  return {
151
195
  id: row.id,
@@ -164,6 +208,7 @@ function mapTask(row: TaskRow): TaskRecord {
164
208
  title: row.title,
165
209
  description: row.description,
166
210
  status: row.status,
211
+ owner: row.owner ?? null,
167
212
  createdAt: row.created_at,
168
213
  updatedAt: row.updated_at,
169
214
  };
@@ -176,6 +221,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
176
221
  title: row.title,
177
222
  description: row.description,
178
223
  status: row.status,
224
+ owner: row.owner ?? null,
179
225
  createdAt: row.created_at,
180
226
  updatedAt: row.updated_at,
181
227
  };
@@ -376,21 +422,21 @@ export class TrackerDomain {
376
422
  this.getEpicOrThrow(epicId);
377
423
  const rows = this.#db
378
424
  .query(
379
- "SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
425
+ "SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
380
426
  )
381
427
  .all(epicId) as TaskRow[];
382
428
  return rows.map(mapTask);
383
429
  }
384
430
 
385
431
  const rows = this.#db
386
- .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
432
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
387
433
  .all() as TaskRow[];
388
434
  return rows.map(mapTask);
389
435
  }
390
436
 
391
437
  getTask(id: string): TaskRecord | null {
392
438
  const row = this.#db
393
- .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?;")
439
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id = ?;")
394
440
  .get(id) as TaskRow | null;
395
441
  return row ? mapTask(row) : null;
396
442
  }
@@ -410,19 +456,20 @@ export class TrackerDomain {
410
456
 
411
457
  updateTask(
412
458
  id: string,
413
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
459
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
414
460
  ): TaskRecord {
415
461
  const existing: TaskRecord = this.getTaskOrThrow(id);
416
462
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
417
463
  const nextDescription: string =
418
464
  input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
419
465
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
466
+ const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
420
467
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
421
468
  const now: number = Date.now();
422
469
 
423
470
  this.#db
424
- .query("UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
425
- .run(nextTitle, nextDescription, nextStatus, now, id);
471
+ .query("UPDATE tasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
472
+ .run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
426
473
 
427
474
  return this.getTaskOrThrow(id);
428
475
  }
@@ -541,7 +588,7 @@ export class TrackerDomain {
541
588
  this.getTaskOrThrow(taskId);
542
589
  const rows = this.#db
543
590
  .query(
544
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
591
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
545
592
  )
546
593
  .all(taskId) as SubtaskRow[];
547
594
  return rows.map(mapSubtask);
@@ -549,15 +596,25 @@ export class TrackerDomain {
549
596
 
550
597
  const rows = this.#db
551
598
  .query(
552
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
599
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
553
600
  )
554
601
  .all() as SubtaskRow[];
555
602
  return rows.map(mapSubtask);
556
603
  }
557
604
 
605
+ getOpenSubtasks(taskId: string): readonly SubtaskRecord[] {
606
+ this.getTaskOrThrow(taskId);
607
+ const rows = this.#db
608
+ .query(
609
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? AND status != 'done' ORDER BY created_at ASC, id ASC;",
610
+ )
611
+ .all(taskId) as SubtaskRow[];
612
+ return rows.map(mapSubtask);
613
+ }
614
+
558
615
  getSubtask(id: string): SubtaskRecord | null {
559
616
  const row = this.#db
560
- .query("SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE id = ?;")
617
+ .query("SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id = ?;")
561
618
  .get(id) as SubtaskRow | null;
562
619
  return row ? mapSubtask(row) : null;
563
620
  }
@@ -577,19 +634,20 @@ export class TrackerDomain {
577
634
 
578
635
  updateSubtask(
579
636
  id: string,
580
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
637
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
581
638
  ): SubtaskRecord {
582
639
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
583
640
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
584
641
  const nextDescription: string =
585
642
  input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
586
643
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
644
+ const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
587
645
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
588
646
  const now: number = Date.now();
589
647
 
590
648
  this.#db
591
- .query("UPDATE subtasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
592
- .run(nextTitle, nextDescription, nextStatus, now, id);
649
+ .query("UPDATE subtasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
650
+ .run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
593
651
 
594
652
  return this.getSubtaskOrThrow(id);
595
653
  }
@@ -605,7 +663,7 @@ export class TrackerDomain {
605
663
  const taskIds = new Set(tasks.map((task) => task.id));
606
664
  const subtasks = this.#db
607
665
  .query(
608
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
666
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
609
667
  )
610
668
  .all(epicId) as SubtaskRow[];
611
669
 
@@ -921,6 +979,65 @@ export class TrackerDomain {
921
979
  return rows.map(mapDependency);
922
980
  }
923
981
 
982
+ /**
983
+ * Resolves dependency statuses for multiple tasks using a single prepared
984
+ * statement executed once per task ID. This avoids the previous N+1 pattern
985
+ * where each task required separate getTaskOrThrow/getSubtaskOrThrow calls
986
+ * per dependency.
987
+ */
988
+ batchResolveDependencyStatuses(
989
+ taskIds: readonly string[],
990
+ ): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
991
+ const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
992
+
993
+ if (taskIds.length === 0) {
994
+ return result;
995
+ }
996
+
997
+ // Use a static parameterised query per task ID rather than interpolating
998
+ // a dynamic IN-list into the SQL string. This is consistent with every
999
+ // other query in TrackerDomain and avoids any placeholder-count confusion.
1000
+ const stmt = this.#db.query(
1001
+ `SELECT d.source_id, d.depends_on_id, d.depends_on_kind, COALESCE(t.status, s.status) AS dep_status
1002
+ FROM dependencies d
1003
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1004
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1005
+ WHERE d.source_id = ?
1006
+ ORDER BY d.created_at ASC, d.id ASC;`,
1007
+ );
1008
+
1009
+ for (const taskId of taskIds) {
1010
+ const entry = { totalDependencies: 0, blockers: [] as Array<{ id: string; kind: "task" | "subtask"; status: string }> };
1011
+ result.set(taskId, entry);
1012
+
1013
+ const rows = stmt.all(taskId) as Array<{
1014
+ source_id: string;
1015
+ depends_on_id: string;
1016
+ depends_on_kind: "task" | "subtask";
1017
+ dep_status: string | null;
1018
+ }>;
1019
+
1020
+ for (const row of rows) {
1021
+ entry.totalDependencies += 1;
1022
+
1023
+ // Skip orphaned dependency rows (target deleted).
1024
+ if (row.dep_status === null) {
1025
+ continue;
1026
+ }
1027
+
1028
+ if (row.dep_status !== "done") {
1029
+ entry.blockers.push({
1030
+ id: row.depends_on_id,
1031
+ kind: row.depends_on_kind,
1032
+ status: row.dep_status,
1033
+ });
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ return result;
1039
+ }
1040
+
924
1041
  listReverseDependencies(nodeId: string): readonly ReverseDependencyNode[] {
925
1042
  const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
926
1043
  this.resolveNodeKind(normalizedNodeId);
@@ -1492,10 +1609,17 @@ export class TrackerDomain {
1492
1609
  }
1493
1610
 
1494
1611
  for (const dependency of this.listDependencies(change.id)) {
1495
- const dependencyStatus =
1612
+ const dependencyNode =
1496
1613
  dependency.dependsOnKind === "task"
1497
- ? this.getTaskOrThrow(dependency.dependsOnId).status
1498
- : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1614
+ ? this.getTask(dependency.dependsOnId)
1615
+ : this.getSubtask(dependency.dependsOnId);
1616
+
1617
+ // Skip orphaned dependency rows where the referenced node no longer exists.
1618
+ if (!dependencyNode) {
1619
+ continue;
1620
+ }
1621
+
1622
+ const dependencyStatus = dependencyNode.status;
1499
1623
  const inScope = scopeIdSet.has(dependency.dependsOnId);
1500
1624
  const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
1501
1625
  if (dependencyStatus === "done" || willCascade) {
@@ -1560,19 +1684,24 @@ export class TrackerDomain {
1560
1684
  const unresolved: UnresolvedDependencyBlocker[] = [];
1561
1685
 
1562
1686
  for (const dependency of dependencies) {
1563
- const dependencyStatus =
1687
+ const dependencyNode =
1564
1688
  dependency.dependsOnKind === "task"
1565
- ? this.getTaskOrThrow(dependency.dependsOnId).status
1566
- : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1689
+ ? this.getTask(dependency.dependsOnId)
1690
+ : this.getSubtask(dependency.dependsOnId);
1691
+
1692
+ // Skip orphaned dependency rows where the referenced node no longer exists.
1693
+ if (!dependencyNode) {
1694
+ continue;
1695
+ }
1567
1696
 
1568
- if (dependencyStatus === "done") {
1697
+ if (dependencyNode.status === "done") {
1569
1698
  continue;
1570
1699
  }
1571
1700
 
1572
1701
  unresolved.push({
1573
1702
  id: dependency.dependsOnId,
1574
1703
  kind: dependency.dependsOnKind,
1575
- status: dependencyStatus,
1704
+ status: dependencyNode.status,
1576
1705
  });
1577
1706
  }
1578
1707
 
@@ -1,5 +1,15 @@
1
1
  export type NodeKind = "epic" | "task" | "subtask";
2
2
 
3
+ export const VALID_STATUSES = ["todo", "in_progress", "done", "blocked"] as const;
4
+ export type ValidStatus = (typeof VALID_STATUSES)[number];
5
+
6
+ export const VALID_TRANSITIONS: ReadonlyMap<ValidStatus, ReadonlySet<ValidStatus>> = new Map<ValidStatus, ReadonlySet<ValidStatus>>([
7
+ ["todo", new Set<ValidStatus>(["in_progress", "blocked"])],
8
+ ["in_progress", new Set<ValidStatus>(["done", "blocked"])],
9
+ ["blocked", new Set<ValidStatus>(["in_progress", "todo"])],
10
+ ["done", new Set<ValidStatus>(["in_progress"])],
11
+ ]);
12
+
3
13
  export const COMPACT_TEMP_KEY_PREFIX = "@";
4
14
 
5
15
  export type CompactTempKey = string;
@@ -95,6 +105,7 @@ export interface TaskRecord {
95
105
  readonly title: string;
96
106
  readonly description: string;
97
107
  readonly status: string;
108
+ readonly owner: string | null;
98
109
  readonly createdAt: number;
99
110
  readonly updatedAt: number;
100
111
  }
@@ -105,6 +116,7 @@ export interface SubtaskRecord {
105
116
  readonly title: string;
106
117
  readonly description: string;
107
118
  readonly status: string;
119
+ readonly owner: string | null;
108
120
  readonly createdAt: number;
109
121
  readonly updatedAt: number;
110
122
  }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { executeShell, parseInvocation, renderShellResult } from "./runtime/cli-
5
5
  export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
6
6
  const parsed = parseInvocation(argv);
7
7
  const result = await executeShell(parsed);
8
- const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode);
8
+ const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode, { compact: parsed.compact });
9
9
 
10
10
  if (result.ok) {
11
11
  process.stdout.write(`${rendered}\n`);
package/src/io/output.ts CHANGED
@@ -13,8 +13,9 @@ const CONTRACT_VERSION = "1.0.0";
13
13
  const COMPATIBILITY_DEPRECATED_SINCE = "0.1.8";
14
14
  const COMPATIBILITY_REMOVAL_AFTER = "2026-09-30";
15
15
 
16
- interface RenderOptions {
16
+ export interface RenderOptions {
17
17
  readonly compatibilityMode?: CompatibilityMode | null;
18
+ readonly compact?: boolean;
18
19
  }
19
20
 
20
21
  function toLegacySyncCommandId(command: string): string {
@@ -136,13 +137,14 @@ export function failResult(input: ResultInput & { readonly error: ToonError }):
136
137
 
137
138
  export function toToonEnvelope(result: CliResult, options: RenderOptions = {}): ToonEnvelope {
138
139
  const compatibilityMode: CompatibilityMode | null = options.compatibilityMode ?? null;
140
+ const compact: boolean = options.compact ?? false;
139
141
  const command: string = resolveCompatibilityCommand(result.command, compatibilityMode);
140
142
 
141
143
  return {
142
144
  ok: result.ok,
143
145
  command,
144
146
  data: result.data,
145
- metadata: createContractMetadata(result, compatibilityMode),
147
+ ...(compact ? {} : { metadata: createContractMetadata(result, compatibilityMode) }),
146
148
  ...(result.error ? { error: result.error } : {}),
147
149
  ...(result.meta ? { meta: result.meta } : {}),
148
150
  };
@@ -8,11 +8,12 @@ import { runMigrate } from "../commands/migrate";
8
8
  import { runQuickstart } from "../commands/quickstart";
9
9
  import { runSession } from "../commands/session";
10
10
  import { runSkills } from "../commands/skills";
11
+ import { runSuggest } from "../commands/suggest";
11
12
  import { runSubtask } from "../commands/subtask";
12
13
  import { runSync } from "../commands/sync";
13
14
  import { runTask } from "../commands/task";
14
15
  import { runWipe } from "../commands/wipe";
15
- import { failResult, okResult, renderResult } from "../io/output";
16
+ import { failResult, okResult, renderResult, type RenderOptions } from "../io/output";
16
17
  import { resolveStorageResolutionDiagnostics } from "../storage/database";
17
18
  import { type CliContext, type CliResult, type CompatibilityMode, type OutputMode } from "./command-types";
18
19
  import { CLI_VERSION } from "./version";
@@ -32,11 +33,13 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
32
33
  "migrate",
33
34
  "sync",
34
35
  "skills",
36
+ "suggest",
35
37
  "wipe",
36
38
  ];
37
39
 
38
40
  export interface ParsedInvocation {
39
41
  readonly mode: OutputMode;
42
+ readonly compact: boolean;
40
43
  readonly compatibilityMode: CompatibilityMode | null;
41
44
  readonly compatibilityModeRaw: string | null;
42
45
  readonly compatibilityModeMissingValue: boolean;
@@ -53,6 +56,7 @@ export interface ParseInvocationOptions {
53
56
  export function parseInvocation(argv: readonly string[], options: ParseInvocationOptions = {}): ParsedInvocation {
54
57
  const stdoutIsTTY: boolean = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
55
58
  let explicitMode: OutputMode | null = null;
59
+ let compact = false;
56
60
  let compatibilityModeRaw: string | null = null;
57
61
  let compatibilityModeMissingValue = false;
58
62
  let wantsHelp = false;
@@ -75,6 +79,11 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
75
79
  continue;
76
80
  }
77
81
 
82
+ if (token === "--compact") {
83
+ compact = true;
84
+ continue;
85
+ }
86
+
78
87
  if (token === "--help" || token === "-h") {
79
88
  wantsHelp = true;
80
89
  continue;
@@ -105,6 +114,7 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
105
114
 
106
115
  return {
107
116
  mode: explicitMode ?? (stdoutIsTTY ? "human" : "json"),
117
+ compact,
108
118
  compatibilityMode,
109
119
  compatibilityModeRaw,
110
120
  compatibilityModeMissingValue,
@@ -115,13 +125,23 @@ export function parseInvocation(argv: readonly string[], options: ParseInvocatio
115
125
  };
116
126
  }
117
127
 
118
- export function renderShellResult(result: CliResult, mode: OutputMode, compatibilityMode: CompatibilityMode | null = null): string {
128
+ export function renderShellResult(
129
+ result: CliResult,
130
+ mode: OutputMode,
131
+ compatibilityMode: CompatibilityMode | null = null,
132
+ options: { compact?: boolean } = {},
133
+ ): string {
119
134
  const effectiveCompatibilityMode: CompatibilityMode | null =
120
135
  compatibilityMode === "legacy-sync-command-ids" && result.command.startsWith("sync.")
121
136
  ? compatibilityMode
122
137
  : null;
123
138
 
124
- return renderResult(result, mode, { compatibilityMode: effectiveCompatibilityMode });
139
+ const renderOptions: RenderOptions = {
140
+ compatibilityMode: effectiveCompatibilityMode,
141
+ compact: options.compact ?? false,
142
+ };
143
+
144
+ return renderResult(result, mode, renderOptions);
125
145
  }
126
146
 
127
147
  function isStringArray(value: unknown): value is string[] {
@@ -375,6 +395,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
375
395
  case "skills":
376
396
  result = await runSkills(context);
377
397
  break;
398
+ case "suggest":
399
+ result = await runSuggest(context);
400
+ break;
378
401
  default:
379
402
  result = failResult({
380
403
  command: "shell",
@@ -32,7 +32,7 @@ export interface ToonEnvelope {
32
32
  readonly ok: boolean;
33
33
  readonly command: string;
34
34
  readonly data: unknown;
35
- readonly metadata: ContractMetadata;
35
+ readonly metadata?: ContractMetadata;
36
36
  readonly error?: ToonError;
37
37
  readonly meta?: Record<string, unknown>;
38
38
  }
@@ -98,6 +98,48 @@ export function resolveStorageResolutionDiagnostics(
98
98
  }
99
99
  }
100
100
 
101
+ /** Default connection-level busy_timeout applied at open time. */
102
+ const DEFAULT_BUSY_TIMEOUT_MS = 15000;
103
+
104
+ /**
105
+ * Maximum time (ms) to wait when acquiring the write lock via BEGIN IMMEDIATE.
106
+ * Kept below the default bun test timeout so that lock-contention surfaces as
107
+ * a SQLITE_BUSY error rather than a test-level timeout.
108
+ */
109
+ const WRITE_LOCK_BUSY_TIMEOUT_MS = 3000;
110
+
111
+ /**
112
+ * Execute a write transaction using BEGIN IMMEDIATE to acquire a reserved lock
113
+ * up-front, avoiding SQLITE_BUSY errors that occur when a deferred transaction
114
+ * is promoted to a write lock after readers have already started.
115
+ *
116
+ * A shorter busy_timeout is applied while acquiring the lock so callers receive
117
+ * a prompt SQLITE_BUSY error instead of blocking for the full connection-level
118
+ * timeout. The connection-level timeout is restored before returning.
119
+ */
120
+ export function writeTransaction<T>(db: Database, fn: (db: Database) => T): T {
121
+ db.exec(`PRAGMA busy_timeout = ${WRITE_LOCK_BUSY_TIMEOUT_MS};`);
122
+ try {
123
+ db.exec("BEGIN IMMEDIATE;");
124
+ } catch (error) {
125
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
126
+ throw error;
127
+ }
128
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
129
+ try {
130
+ const result: T = fn(db);
131
+ db.exec("COMMIT;");
132
+ return result;
133
+ } catch (error) {
134
+ try {
135
+ db.exec("ROLLBACK;");
136
+ } catch {
137
+ /* best-effort rollback — propagate the original error */
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+
101
143
  export function openTrekoonDatabase(
102
144
  workingDirectory: string = process.cwd(),
103
145
  options: OpenTrekoonDatabaseOptions = {},
@@ -110,7 +152,7 @@ export function openTrekoonDatabase(
110
152
 
111
153
  const db: Database = new Database(paths.databaseFile, { create: true });
112
154
 
113
- db.exec("PRAGMA busy_timeout = 5000;");
155
+ db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS};`);
114
156
  db.exec("PRAGMA journal_mode = WAL;");
115
157
  db.exec("PRAGMA foreign_keys = ON;");
116
158
 
@@ -1,5 +1,7 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
+ import { writeTransaction } from "./database";
4
+
3
5
  export const DEFAULT_EVENT_RETENTION_DAYS = 90;
4
6
  const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
5
7
 
@@ -18,6 +20,7 @@ export interface EventPruneSummary {
18
20
  readonly candidateCount: number;
19
21
  readonly archivedCount: number;
20
22
  readonly deletedCount: number;
23
+ readonly staleCursorCount: number;
21
24
  }
22
25
 
23
26
  function ensureArchiveTable(db: Database): void {
@@ -53,27 +56,72 @@ function countCandidates(db: Database, cutoffTimestamp: number): number {
53
56
  return row?.count ?? 0;
54
57
  }
55
58
 
59
+ function oldestCursorTimestamp(db: Database): number | null {
60
+ const row = db
61
+ .query("SELECT MIN(last_event_at) AS oldest FROM sync_cursors WHERE last_event_at IS NOT NULL;")
62
+ .get() as { oldest: number | null } | null;
63
+
64
+ return row?.oldest ?? null;
65
+ }
66
+
67
+ function countStaleCursors(db: Database): number {
68
+ // A cursor is stale when its last_event_at references a timestamp
69
+ // that has no corresponding event remaining in the events table.
70
+ // We detect this by checking if the oldest event in the table is
71
+ // newer than the cursor's last_event_at.
72
+ const oldestEventRow = db
73
+ .query("SELECT MIN(created_at) AS oldest FROM events;")
74
+ .get() as { oldest: number | null } | null;
75
+
76
+ const oldestEventAt: number | null = oldestEventRow?.oldest ?? null;
77
+
78
+ if (oldestEventAt === null) {
79
+ // No events at all — any cursor with a last_event_at is stale.
80
+ const row = db
81
+ .query("SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL;")
82
+ .get() as { count: number } | null;
83
+ return row?.count ?? 0;
84
+ }
85
+
86
+ const row = db
87
+ .query(
88
+ "SELECT COUNT(*) AS count FROM sync_cursors WHERE last_event_at IS NOT NULL AND last_event_at < ?;",
89
+ )
90
+ .get(oldestEventAt) as { count: number } | null;
91
+
92
+ return row?.count ?? 0;
93
+ }
94
+
56
95
  export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
57
96
  const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
58
97
  const dryRun: boolean = options.dryRun ?? false;
59
98
  const archive: boolean = options.archive ?? false;
60
99
  const now: number = options.now ?? Date.now();
61
- const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
62
- const candidateCount: number = countCandidates(db, cutoffTimestamp);
100
+ const retentionCutoff: number = now - retentionDays * DAY_IN_MILLISECONDS;
101
+
102
+ // Guard: never prune events that a sync cursor still references.
103
+ // The effective cutoff is the earlier of the retention cutoff and
104
+ // the oldest cursor timestamp — so cursors always have replayable history.
105
+ const oldest: number | null = oldestCursorTimestamp(db);
106
+ const effectiveCutoff: number = oldest !== null ? Math.min(retentionCutoff, oldest) : retentionCutoff;
107
+
108
+ const candidateCount: number = countCandidates(db, effectiveCutoff);
109
+ const staleCursors: number = countStaleCursors(db);
63
110
 
64
111
  if (dryRun || candidateCount === 0) {
65
112
  return {
66
113
  retentionDays,
67
- cutoffTimestamp,
114
+ cutoffTimestamp: effectiveCutoff,
68
115
  dryRun,
69
116
  archive,
70
117
  candidateCount,
71
118
  archivedCount: 0,
72
119
  deletedCount: 0,
120
+ staleCursorCount: staleCursors,
73
121
  };
74
122
  }
75
123
 
76
- return db.transaction((): EventPruneSummary => {
124
+ return writeTransaction(db, (): EventPruneSummary => {
77
125
  let archivedCount = 0;
78
126
 
79
127
  if (archive) {
@@ -118,21 +166,22 @@ export function pruneEvents(db: Database, options: EventPruneOptions = {}): Even
118
166
  version = excluded.version;
119
167
  `,
120
168
  )
121
- .run(cutoffTimestamp);
169
+ .run(effectiveCutoff);
122
170
 
123
171
  archivedCount = archived.changes;
124
172
  }
125
173
 
126
- const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(cutoffTimestamp);
174
+ const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(effectiveCutoff);
127
175
 
128
176
  return {
129
177
  retentionDays,
130
- cutoffTimestamp,
178
+ cutoffTimestamp: effectiveCutoff,
131
179
  dryRun,
132
180
  archive,
133
181
  candidateCount,
134
182
  archivedCount,
135
183
  deletedCount: deleted.changes,
184
+ staleCursorCount: staleCursors,
136
185
  };
137
- })();
186
+ });
138
187
  }