trekoon 0.3.7 → 0.3.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.
@@ -121,7 +121,7 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
121
121
  return okResult({
122
122
  command: "board.open",
123
123
  human: [
124
- `Board ready at ${server.url}`,
124
+ `Board ready at ${server.fallbackUrl}`,
125
125
  launch.launched
126
126
  ? `Browser launched with ${launch.command}`
127
127
  : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
@@ -4,6 +4,16 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
4
4
  const QUICKSTART_TEXT = [
5
5
  "Trekoon quickstart",
6
6
  "",
7
+ "Human workflow:",
8
+ " 1. Gather context through discussion, brainstorming, or research.",
9
+ " 2. Run: trekoon plan <goal>",
10
+ " Use this when you want Trekoon to turn the goal into an execution-ready epic.",
11
+ " 3. Run: trekoon <epic-id>",
12
+ " Use this to inspect the epic, next ready work, and blockers before execution.",
13
+ " 4. Run: trekoon <epic-id> execute",
14
+ " Use this when you want the agent to keep working until the epic is done,",
15
+ " all remaining work is blocked, or it needs your input.",
16
+ "",
7
17
  "Agents: always use --toon on every command.",
8
18
  "Aligned with: .agents/skills/trekoon/SKILL.md",
9
19
  "",
@@ -1,7 +1,7 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
3
  import { writeTransaction } from "../storage/database";
4
- import { appendEventWithGitContext, withTransactionEventContext } from "../sync/event-writes";
4
+ import { appendEventWithGitContext, prepareEventWriteContext, withTransactionEventContext } from "../sync/event-writes";
5
5
  import { ENTITY_OPERATIONS } from "./mutation-operations";
6
6
  import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
7
7
  import {
@@ -49,6 +49,8 @@ type AtomicIdempotentMutationResult =
49
49
  | AtomicIdempotencyReplayResult
50
50
  | AtomicIdempotencyCompletedResult;
51
51
 
52
+ const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
53
+
52
54
  function countMatches(value: string, searchText: string): number {
53
55
  if (searchText.length === 0) {
54
56
  return 0;
@@ -128,7 +130,49 @@ export class MutationService {
128
130
  }
129
131
 
130
132
  #writeTransaction<T>(fn: () => T): T {
131
- return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, this.#cwd, fn));
133
+ const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
134
+ return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
135
+ }
136
+
137
+ #dependencyEventEntityId(input: {
138
+ sourceId: string;
139
+ sourceKind: string;
140
+ dependsOnId: string;
141
+ dependsOnKind: string;
142
+ }): string {
143
+ return `${input.sourceKind}:${input.sourceId}->${input.dependsOnKind}:${input.dependsOnId}`;
144
+ }
145
+
146
+ #dependencyEventFields(input: {
147
+ dependencyId?: string | undefined;
148
+ sourceId: string;
149
+ sourceKind?: string | undefined;
150
+ dependsOnId: string;
151
+ dependsOnKind?: string | undefined;
152
+ sourceEventId?: string | undefined;
153
+ }): Record<string, string> {
154
+ const fields: Record<string, string> = {
155
+ source_id: input.sourceId,
156
+ depends_on_id: input.dependsOnId,
157
+ };
158
+
159
+ if (input.dependencyId) {
160
+ fields.dependency_id = input.dependencyId;
161
+ }
162
+
163
+ if (input.sourceKind) {
164
+ fields.source_kind = input.sourceKind;
165
+ }
166
+
167
+ if (input.dependsOnKind) {
168
+ fields.depends_on_kind = input.dependsOnKind;
169
+ }
170
+
171
+ if (input.sourceEventId) {
172
+ fields.source_event_id = input.sourceEventId;
173
+ }
174
+
175
+ return fields;
132
176
  }
133
177
 
134
178
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
@@ -185,12 +229,18 @@ export class MutationService {
185
229
  }
186
230
 
187
231
  for (const dependency of created.dependencies) {
188
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
189
- source_id: dependency.sourceId,
190
- source_kind: dependency.sourceKind,
191
- depends_on_id: dependency.dependsOnId,
192
- depends_on_kind: dependency.dependsOnKind,
193
- });
232
+ this.#appendEntityEvent(
233
+ "dependency",
234
+ this.#dependencyEventEntityId(dependency),
235
+ ENTITY_OPERATIONS.dependency.added,
236
+ this.#dependencyEventFields({
237
+ dependencyId: dependency.id,
238
+ sourceId: dependency.sourceId,
239
+ sourceKind: dependency.sourceKind,
240
+ dependsOnId: dependency.dependsOnId,
241
+ dependsOnKind: dependency.dependsOnKind,
242
+ }),
243
+ );
194
244
  }
195
245
 
196
246
  return {
@@ -293,12 +343,18 @@ export class MutationService {
293
343
  }
294
344
 
295
345
  for (const dependency of created.dependencies) {
296
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
297
- source_id: dependency.sourceId,
298
- source_kind: dependency.sourceKind,
299
- depends_on_id: dependency.dependsOnId,
300
- depends_on_kind: dependency.dependsOnKind,
301
- });
346
+ this.#appendEntityEvent(
347
+ "dependency",
348
+ this.#dependencyEventEntityId(dependency),
349
+ ENTITY_OPERATIONS.dependency.added,
350
+ this.#dependencyEventFields({
351
+ dependencyId: dependency.id,
352
+ sourceId: dependency.sourceId,
353
+ sourceKind: dependency.sourceKind,
354
+ dependsOnId: dependency.dependsOnId,
355
+ dependsOnKind: dependency.dependsOnKind,
356
+ }),
357
+ );
302
358
  }
303
359
 
304
360
  return created;
@@ -349,16 +405,19 @@ export class MutationService {
349
405
  }
350
406
 
351
407
  for (const dependency of plan.touchingDependencies) {
352
- this.#appendEntityEvent(
353
- "dependency",
354
- `${dependency.sourceId}->${dependency.dependsOnId}`,
355
- ENTITY_OPERATIONS.dependency.removed,
356
- {
357
- source_id: dependency.sourceId,
358
- depends_on_id: dependency.dependsOnId,
359
- source_event_id: taskDeleteEventId,
360
- },
361
- );
408
+ this.#appendEntityEvent(
409
+ "dependency",
410
+ this.#dependencyEventEntityId(dependency),
411
+ ENTITY_OPERATIONS.dependency.removed,
412
+ this.#dependencyEventFields({
413
+ dependencyId: dependency.id,
414
+ sourceId: dependency.sourceId,
415
+ sourceKind: dependency.sourceKind,
416
+ dependsOnId: dependency.dependsOnId,
417
+ dependsOnKind: dependency.dependsOnKind,
418
+ sourceEventId: taskDeleteEventId,
419
+ }),
420
+ );
362
421
  }
363
422
 
364
423
  return {
@@ -454,13 +513,16 @@ export class MutationService {
454
513
  for (const dependency of touchingDependencies) {
455
514
  this.#appendEntityEvent(
456
515
  "dependency",
457
- `${dependency.sourceId}->${dependency.dependsOnId}`,
516
+ this.#dependencyEventEntityId(dependency),
458
517
  ENTITY_OPERATIONS.dependency.removed,
459
- {
460
- source_id: dependency.sourceId,
461
- depends_on_id: dependency.dependsOnId,
462
- source_event_id: subtaskDeleteEventId,
463
- },
518
+ this.#dependencyEventFields({
519
+ dependencyId: dependency.id,
520
+ sourceId: dependency.sourceId,
521
+ sourceKind: dependency.sourceKind,
522
+ dependsOnId: dependency.dependsOnId,
523
+ dependsOnKind: dependency.dependsOnKind,
524
+ sourceEventId: subtaskDeleteEventId,
525
+ }),
464
526
  );
465
527
  }
466
528
  return {
@@ -489,13 +551,16 @@ export class MutationService {
489
551
  for (const dependency of touchingDependencies) {
490
552
  this.#appendEntityEvent(
491
553
  "dependency",
492
- `${dependency.sourceId}->${dependency.dependsOnId}`,
554
+ this.#dependencyEventEntityId(dependency),
493
555
  ENTITY_OPERATIONS.dependency.removed,
494
- {
495
- source_id: dependency.sourceId,
496
- depends_on_id: dependency.dependsOnId,
497
- source_event_id: subtaskDeleteEventId,
498
- },
556
+ this.#dependencyEventFields({
557
+ dependencyId: dependency.id,
558
+ sourceId: dependency.sourceId,
559
+ sourceKind: dependency.sourceKind,
560
+ dependsOnId: dependency.dependsOnId,
561
+ dependsOnKind: dependency.dependsOnKind,
562
+ sourceEventId: subtaskDeleteEventId,
563
+ }),
499
564
  );
500
565
  }
501
566
 
@@ -516,12 +581,18 @@ export class MutationService {
516
581
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
517
582
  return this.#writeTransaction((): DependencyRecord => {
518
583
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
519
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
520
- source_id: dependency.sourceId,
521
- source_kind: dependency.sourceKind,
522
- depends_on_id: dependency.dependsOnId,
523
- depends_on_kind: dependency.dependsOnKind,
524
- });
584
+ this.#appendEntityEvent(
585
+ "dependency",
586
+ this.#dependencyEventEntityId(dependency),
587
+ ENTITY_OPERATIONS.dependency.added,
588
+ this.#dependencyEventFields({
589
+ dependencyId: dependency.id,
590
+ sourceId: dependency.sourceId,
591
+ sourceKind: dependency.sourceKind,
592
+ dependsOnId: dependency.dependsOnId,
593
+ dependsOnKind: dependency.dependsOnKind,
594
+ }),
595
+ );
525
596
  return dependency;
526
597
  });
527
598
  }
@@ -534,12 +605,18 @@ export class MutationService {
534
605
  }): AtomicIdempotentMutationResult {
535
606
  return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
536
607
  const dependency = this.#domain.addDependency(input.sourceId, input.dependsOnId);
537
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
538
- source_id: dependency.sourceId,
539
- source_kind: dependency.sourceKind,
540
- depends_on_id: dependency.dependsOnId,
541
- depends_on_kind: dependency.dependsOnKind,
542
- });
608
+ this.#appendEntityEvent(
609
+ "dependency",
610
+ this.#dependencyEventEntityId(dependency),
611
+ ENTITY_OPERATIONS.dependency.added,
612
+ this.#dependencyEventFields({
613
+ dependencyId: dependency.id,
614
+ sourceId: dependency.sourceId,
615
+ sourceKind: dependency.sourceKind,
616
+ dependsOnId: dependency.dependsOnId,
617
+ dependsOnKind: dependency.dependsOnKind,
618
+ }),
619
+ );
543
620
  return {
544
621
  state: "completed",
545
622
  status: 201,
@@ -552,12 +629,18 @@ export class MutationService {
552
629
  return this.#writeTransaction((): CompactDependencyBatchAddResult => {
553
630
  const created = this.#domain.addDependencyBatch(input);
554
631
  for (const dependency of created.dependencies) {
555
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
556
- source_id: dependency.sourceId,
557
- source_kind: dependency.sourceKind,
558
- depends_on_id: dependency.dependsOnId,
559
- depends_on_kind: dependency.dependsOnKind,
560
- });
632
+ this.#appendEntityEvent(
633
+ "dependency",
634
+ this.#dependencyEventEntityId(dependency),
635
+ ENTITY_OPERATIONS.dependency.added,
636
+ this.#dependencyEventFields({
637
+ dependencyId: dependency.id,
638
+ sourceId: dependency.sourceId,
639
+ sourceKind: dependency.sourceKind,
640
+ dependsOnId: dependency.dependsOnId,
641
+ dependsOnKind: dependency.dependsOnKind,
642
+ }),
643
+ );
561
644
  }
562
645
  return created;
563
646
  });
@@ -565,12 +648,22 @@ export class MutationService {
565
648
 
566
649
  removeDependency(sourceId: string, dependsOnId: string): number {
567
650
  return this.#writeTransaction((): number => {
651
+ const existingDependency = this.#domain.listDependencies(sourceId)
652
+ .find((dependency) => dependency.dependsOnId === dependsOnId);
568
653
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
569
654
  if (removed > 0) {
570
- this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
571
- source_id: sourceId,
572
- depends_on_id: dependsOnId,
573
- });
655
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
656
+ sourceId,
657
+ sourceKind: existingDependency?.sourceKind ?? "task",
658
+ dependsOnId,
659
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
660
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
661
+ dependencyId: existingDependency?.id,
662
+ sourceId,
663
+ sourceKind: existingDependency?.sourceKind,
664
+ dependsOnId,
665
+ dependsOnKind: existingDependency?.dependsOnKind,
666
+ }));
574
667
  }
575
668
  return removed;
576
669
  });
@@ -589,9 +682,10 @@ export class MutationService {
589
682
  }) => Record<string, unknown>;
590
683
  }): AtomicIdempotentMutationResult {
591
684
  return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
592
- const existingDependencyIds = this.#domain.listDependencies(input.sourceId)
593
- .filter((dependency) => dependency.dependsOnId === input.dependsOnId)
594
- .map((dependency) => dependency.id);
685
+ const existingDependencies = this.#domain.listDependencies(input.sourceId)
686
+ .filter((dependency) => dependency.dependsOnId === input.dependsOnId);
687
+ const existingDependencyIds = existingDependencies.map((dependency) => dependency.id);
688
+ const existingDependency = existingDependencies[0];
595
689
  const removed = this.#domain.removeDependency(input.sourceId, input.dependsOnId);
596
690
  if (removed === 0) {
597
691
  throw new DomainError({
@@ -603,10 +697,18 @@ export class MutationService {
603
697
  },
604
698
  });
605
699
  }
606
- this.#appendEntityEvent("dependency", `${input.sourceId}->${input.dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
607
- source_id: input.sourceId,
608
- depends_on_id: input.dependsOnId,
609
- });
700
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
701
+ sourceId: input.sourceId,
702
+ sourceKind: existingDependency?.sourceKind ?? "task",
703
+ dependsOnId: input.dependsOnId,
704
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
705
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
706
+ dependencyId: existingDependency?.id,
707
+ sourceId: input.sourceId,
708
+ sourceKind: existingDependency?.sourceKind,
709
+ dependsOnId: input.dependsOnId,
710
+ dependsOnKind: existingDependency?.dependsOnKind,
711
+ }));
610
712
  return {
611
713
  state: "completed",
612
714
  status: 200,
@@ -741,6 +843,7 @@ export class MutationService {
741
843
  mutate: () => AtomicIdempotencyCompletedResult,
742
844
  ): AtomicIdempotentMutationResult {
743
845
  return this.#writeTransaction((): AtomicIdempotentMutationResult => {
846
+ this.#pruneExpiredIdempotencyKeys();
744
847
  const inserted = this.#db.query(
745
848
  `
746
849
  INSERT INTO board_idempotency_keys (
@@ -816,6 +919,17 @@ export class MutationService {
816
919
  });
817
920
  }
818
921
 
922
+ #pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
923
+ const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
924
+ this.#db.query(
925
+ `
926
+ DELETE FROM board_idempotency_keys
927
+ WHERE state = 'completed'
928
+ AND created_at < ?;
929
+ `,
930
+ ).run(cutoff);
931
+ }
932
+
819
933
  #previewScopeReplacement(
820
934
  nodes: readonly SearchNode[],
821
935
  searchText: string,
@@ -165,6 +165,20 @@ function normalizeSubtaskDescription(value: string | undefined): string {
165
165
  return value.trim();
166
166
  }
167
167
 
168
+ function normalizeOwner(value: string | null | undefined): string | null | undefined {
169
+ if (value === undefined) {
170
+ return undefined;
171
+ }
172
+
173
+ if (value === null) {
174
+ return null;
175
+ }
176
+
177
+ const normalized = value.trim()
178
+ ;
179
+ return normalized.length > 0 ? normalized : null;
180
+ }
181
+
168
182
  function isValidStatus(status: string): status is ValidStatus {
169
183
  return (VALID_STATUSES as readonly string[]).includes(status);
170
184
  }
@@ -526,7 +540,7 @@ export class TrackerDomain {
526
540
  const nextDescription: string =
527
541
  input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
528
542
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
529
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
543
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
530
544
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
531
545
  const now: number = Date.now();
532
546
 
@@ -777,7 +791,7 @@ export class TrackerDomain {
777
791
  const nextDescription: string =
778
792
  input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
779
793
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
780
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
794
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
781
795
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
782
796
  const now: number = Date.now();
783
797
 
@@ -105,6 +105,14 @@ const BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
105
105
  "DROP TABLE IF EXISTS board_idempotency_keys;",
106
106
  ];
107
107
 
108
+ const BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS: readonly string[] = [
109
+ "CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);",
110
+ ];
111
+
112
+ const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
113
+ "DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
114
+ ];
115
+
108
116
  function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
109
117
  const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
110
118
  return columns.some((column) => column.name === columnName);
@@ -359,6 +367,20 @@ const MIGRATIONS: readonly Migration[] = [
359
367
  }
360
368
  },
361
369
  },
370
+ {
371
+ version: 10,
372
+ name: "0010_board_idempotency_retention_index",
373
+ up(db: Database): void {
374
+ for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS) {
375
+ db.exec(statement);
376
+ }
377
+ },
378
+ down(db: Database): void {
379
+ for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS) {
380
+ db.exec(statement);
381
+ }
382
+ },
383
+ },
362
384
  ];
363
385
 
364
386
  function migrationTableExists(db: Database): boolean {
@@ -557,6 +579,11 @@ export function migrateDatabase(db: Database): void {
557
579
  ensureMigrationTable(db);
558
580
  ensureMigrationVersionColumn(db);
559
581
 
582
+ // Backfill the legacy board_idempotency_keys.state column before running
583
+ // any migrations so that later migrations (e.g. 0010's state-scoped index)
584
+ // can assume the column exists on databases whose 0009 predates it.
585
+ migrateBoardIdempotencyState(db);
586
+
560
587
  const version: number = currentVersion(db);
561
588
 
562
589
  for (const migration of MIGRATIONS) {
@@ -567,8 +594,6 @@ export function migrateDatabase(db: Database): void {
567
594
  migration.up(db);
568
595
  recordMigration(db, migration);
569
596
  }
570
-
571
- migrateBoardIdempotencyState(db);
572
597
  });
573
598
  }
574
599
 
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 2;
1
+ export const SCHEMA_VERSION = 3;
2
2
 
3
3
  export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
4
4
  `PRAGMA foreign_keys = ON;`,
@@ -137,4 +137,5 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
137
137
  `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
138
138
  `CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
139
139
  `CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
140
+ `CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
140
141
  ];
@@ -38,18 +38,22 @@ export function nextEventTimestamp(db: Database): number {
38
38
  return Math.max(now, latestEvent.created_at + 1);
39
39
  }
40
40
 
41
- export function withTransactionEventContext<T>(db: Database, cwd: string, fn: () => T): T {
42
- const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
43
- if (existingContext) {
44
- return fn();
45
- }
46
-
41
+ export function prepareEventWriteContext(db: Database, cwd: string): EventWriteContext {
47
42
  const nextTimestamp: number = nextEventTimestamp(db);
48
43
  const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
49
- const context: EventWriteContext = {
44
+
45
+ return {
50
46
  git,
51
47
  nextTimestamp,
52
48
  };
49
+ }
50
+
51
+ export function withTransactionEventContext<T>(db: Database, context: EventWriteContext, fn: () => T): T {
52
+ const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
53
+ if (existingContext) {
54
+ return fn();
55
+ }
56
+
53
57
  transactionEventContexts.set(db, context);
54
58
 
55
59
  try {