trekoon 0.2.6 → 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 (40) hide show
  1. package/README.md +102 -708
  2. package/docs/ai-agents.md +198 -0
  3. package/docs/commands.md +226 -0
  4. package/docs/machine-contracts.md +253 -0
  5. package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
  6. package/docs/quickstart.md +207 -0
  7. package/package.json +3 -1
  8. package/src/board/assets/app.js +1498 -0
  9. package/src/board/assets/components/AppShell.js +17 -0
  10. package/src/board/assets/components/BoardTopbar.js +78 -0
  11. package/src/board/assets/components/ClampedText.js +31 -0
  12. package/src/board/assets/components/EpicRow.js +62 -0
  13. package/src/board/assets/components/EpicsOverview.js +43 -0
  14. package/src/board/assets/components/WorkspaceHeader.js +70 -0
  15. package/src/board/assets/components/assetMap.js +65 -0
  16. package/src/board/assets/index.html +76 -0
  17. package/src/board/assets/main.js +27 -0
  18. package/src/board/assets/manifest.json +12 -0
  19. package/src/board/assets/state/actions.js +334 -0
  20. package/src/board/assets/state/api.js +126 -0
  21. package/src/board/assets/state/store.js +172 -0
  22. package/src/board/assets/styles/board.css +1127 -0
  23. package/src/board/assets/utils/dom.js +308 -0
  24. package/src/board/install.ts +196 -0
  25. package/src/board/open-browser.ts +131 -0
  26. package/src/board/routes.ts +299 -0
  27. package/src/board/server.ts +184 -0
  28. package/src/board/snapshot.ts +277 -0
  29. package/src/board/types.ts +43 -0
  30. package/src/commands/board.ts +158 -0
  31. package/src/commands/epic.ts +104 -3
  32. package/src/commands/help.ts +52 -13
  33. package/src/commands/init.ts +29 -0
  34. package/src/commands/subtask.ts +78 -1
  35. package/src/commands/task.ts +113 -7
  36. package/src/domain/mutation-service.ts +116 -0
  37. package/src/domain/tracker-domain.ts +261 -5
  38. package/src/domain/types.ts +51 -0
  39. package/src/runtime/cli-shell.ts +5 -0
  40. package/src/storage/path.ts +36 -0
@@ -12,6 +12,7 @@ import {
12
12
  type CompactTaskBatchCreateResult,
13
13
  type CompactTaskSpec,
14
14
  type DependencyRecord,
15
+ type DependencyNodeKind,
15
16
  DomainError,
16
17
  type EpicTreeDetailed,
17
18
  type EpicRecord,
@@ -23,6 +24,11 @@ import {
23
24
  type SearchFieldMatch,
24
25
  type SearchNode,
25
26
  type SearchSummary,
27
+ type StatusCascadeBlocker,
28
+ type StatusCascadeChange,
29
+ type StatusCascadePlan,
30
+ type StatusCascadeRootKind,
31
+ type StatusCascadeScopeNode,
26
32
  type SubtaskRecord,
27
33
  type TaskTreeDetailed,
28
34
  type TaskRecord,
@@ -66,7 +72,7 @@ interface ReverseDependencyRow {
66
72
 
67
73
  interface UnresolvedDependencyBlocker {
68
74
  readonly id: string;
69
- readonly kind: "task" | "subtask";
75
+ readonly kind: DependencyNodeKind;
70
76
  readonly status: string;
71
77
  }
72
78
 
@@ -132,6 +138,14 @@ function normalizeStatus(value: string | undefined): string {
132
138
  return assertNonEmpty("status", value);
133
139
  }
134
140
 
141
+ function normalizeSubtaskDescription(value: string | undefined): string {
142
+ if (value === undefined) {
143
+ return "";
144
+ }
145
+
146
+ return value.trim();
147
+ }
148
+
135
149
  function mapEpic(row: EpicRow): EpicRecord {
136
150
  return {
137
151
  id: row.id,
@@ -425,7 +439,7 @@ export class TrackerDomain {
425
439
  const id: string = randomUUID();
426
440
  const taskId: string = assertNonEmpty("taskId", input.taskId);
427
441
  const title: string = assertNonEmpty("title", input.title);
428
- const description: string = input.description === undefined ? "" : assertNonEmpty("description", input.description);
442
+ const description: string = normalizeSubtaskDescription(input.description);
429
443
  const status: string = normalizeStatus(input.status);
430
444
 
431
445
  this.getTaskOrThrow(taskId);
@@ -451,7 +465,7 @@ export class TrackerDomain {
451
465
  tempKey: assertNonEmpty("tempKey", spec.tempKey),
452
466
  taskId,
453
467
  title: assertNonEmpty("title", spec.title),
454
- description: spec.description === undefined ? "" : assertNonEmpty("description", spec.description),
468
+ description: normalizeSubtaskDescription(spec.description),
455
469
  status: normalizeStatus(spec.status),
456
470
  };
457
471
  });
@@ -568,7 +582,7 @@ export class TrackerDomain {
568
582
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
569
583
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
570
584
  const nextDescription: string =
571
- input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
585
+ input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
572
586
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
573
587
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
574
588
  const now: number = Date.now();
@@ -656,6 +670,40 @@ export class TrackerDomain {
656
670
  };
657
671
  }
658
672
 
673
+ planStatusCascade(rootKind: StatusCascadeRootKind, rootId: string, targetStatus: string): StatusCascadePlan {
674
+ const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
675
+ const scope = this.#collectStatusCascadeScope(rootKind, rootId);
676
+ const scopeIdSet = new Set(scope.map((node) => node.id));
677
+ const orderedChanges = this.#orderStatusCascadeChanges(scope, normalizedTargetStatus);
678
+ const changedIds = orderedChanges.map((change) => change.id);
679
+ const changedIdSet = new Set(changedIds);
680
+ const unchangedIds = scope
681
+ .filter((node) => !changedIdSet.has(node.id))
682
+ .map((node) => node.id);
683
+ const blockers = this.#collectStatusCascadeBlockers(orderedChanges, scopeIdSet, changedIdSet, normalizedTargetStatus);
684
+
685
+ return {
686
+ rootKind,
687
+ rootId,
688
+ targetStatus: normalizedTargetStatus,
689
+ atomic: true,
690
+ scope,
691
+ orderedChanges,
692
+ changedIds,
693
+ unchangedIds,
694
+ blockers,
695
+ counts: {
696
+ scope: scope.length,
697
+ changed: orderedChanges.length,
698
+ unchanged: unchangedIds.length,
699
+ blockers: blockers.length,
700
+ changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
701
+ changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
702
+ changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
703
+ },
704
+ };
705
+ }
706
+
659
707
  collectEpicSearchScope(epicId: string): readonly SearchNode[] {
660
708
  const tree = this.buildEpicTreeDetailed(epicId);
661
709
 
@@ -1266,9 +1314,217 @@ export class TrackerDomain {
1266
1314
  return row !== null;
1267
1315
  }
1268
1316
 
1317
+ #collectStatusCascadeScope(rootKind: StatusCascadeRootKind, rootId: string): StatusCascadeScopeNode[] {
1318
+ if (rootKind === "task") {
1319
+ const tree = this.buildTaskTreeDetailed(rootId);
1320
+ return [
1321
+ {
1322
+ kind: "task",
1323
+ id: tree.id,
1324
+ parentId: tree.epicId,
1325
+ status: tree.status,
1326
+ },
1327
+ ...tree.subtasks.map((subtask) => ({
1328
+ kind: "subtask" as const,
1329
+ id: subtask.id,
1330
+ parentId: subtask.taskId,
1331
+ status: subtask.status,
1332
+ })),
1333
+ ];
1334
+ }
1335
+
1336
+ const tree = this.buildEpicTreeDetailed(rootId);
1337
+ return [
1338
+ {
1339
+ kind: "epic",
1340
+ id: tree.id,
1341
+ status: tree.status,
1342
+ },
1343
+ ...tree.tasks.flatMap((task) => [
1344
+ {
1345
+ kind: "task" as const,
1346
+ id: task.id,
1347
+ parentId: task.epicId,
1348
+ status: task.status,
1349
+ },
1350
+ ...task.subtasks.map((subtask) => ({
1351
+ kind: "subtask" as const,
1352
+ id: subtask.id,
1353
+ parentId: subtask.taskId,
1354
+ status: subtask.status,
1355
+ })),
1356
+ ]),
1357
+ ];
1358
+ }
1359
+
1360
+ #orderStatusCascadeChanges(scope: readonly StatusCascadeScopeNode[], targetStatus: string): StatusCascadeChange[] {
1361
+ const changes = scope
1362
+ .filter((node) => node.status !== targetStatus)
1363
+ .map((node) => {
1364
+ const change: StatusCascadeChange = {
1365
+ kind: node.kind,
1366
+ id: node.id,
1367
+ previousStatus: node.status,
1368
+ nextStatus: targetStatus,
1369
+ ...(node.parentId === undefined ? {} : { parentId: node.parentId }),
1370
+ };
1371
+ return change;
1372
+ });
1373
+
1374
+ if (targetStatus !== "done") {
1375
+ return changes;
1376
+ }
1377
+
1378
+ return this.#topologicallyOrderDoneCascadeChanges(changes);
1379
+ }
1380
+
1381
+ #topologicallyOrderDoneCascadeChanges(changes: readonly StatusCascadeChange[]): StatusCascadeChange[] {
1382
+ const indexById = new Map<string, number>();
1383
+ const changeById = new Map<string, StatusCascadeChange>();
1384
+ const dependencyTargetsBySource = new Map<string, Set<string>>();
1385
+ const dependents = new Map<string, Set<string>>();
1386
+ const indegree = new Map<string, number>();
1387
+
1388
+ changes.forEach((change, index) => {
1389
+ indexById.set(change.id, index);
1390
+ changeById.set(change.id, change);
1391
+ indegree.set(change.id, 0);
1392
+
1393
+ if (change.kind !== "task" && change.kind !== "subtask") {
1394
+ return;
1395
+ }
1396
+
1397
+ const dependencyTargets = new Set(this.listDependencies(change.id).map((dependency) => dependency.dependsOnId));
1398
+ dependencyTargetsBySource.set(change.id, dependencyTargets);
1399
+ });
1400
+
1401
+ const addEdge = (fromId: string, toId: string): void => {
1402
+ if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
1403
+ return;
1404
+ }
1405
+
1406
+ const neighbors = dependents.get(fromId) ?? new Set<string>();
1407
+ if (neighbors.has(toId)) {
1408
+ return;
1409
+ }
1410
+
1411
+ neighbors.add(toId);
1412
+ dependents.set(fromId, neighbors);
1413
+ indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
1414
+ };
1415
+
1416
+ for (const change of changes) {
1417
+ const dependencyTargets = dependencyTargetsBySource.get(change.id);
1418
+
1419
+ if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1420
+ addEdge(change.id, change.parentId);
1421
+ }
1422
+
1423
+ if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1424
+ addEdge(change.id, change.parentId);
1425
+ }
1426
+
1427
+ if (change.kind !== "task" && change.kind !== "subtask") {
1428
+ continue;
1429
+ }
1430
+
1431
+ for (const dependencyTargetId of dependencyTargets ?? []) {
1432
+ addEdge(dependencyTargetId, change.id);
1433
+ }
1434
+ }
1435
+
1436
+ const ordered: StatusCascadeChange[] = [];
1437
+ const ready = changes
1438
+ .filter((change) => (indegree.get(change.id) ?? 0) === 0)
1439
+ .sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1440
+
1441
+ while (ready.length > 0) {
1442
+ const next = ready.shift();
1443
+ if (next === undefined) {
1444
+ continue;
1445
+ }
1446
+
1447
+ ordered.push(next);
1448
+ for (const dependentId of dependents.get(next.id) ?? []) {
1449
+ const remaining = (indegree.get(dependentId) ?? 0) - 1;
1450
+ indegree.set(dependentId, remaining);
1451
+ if (remaining !== 0) {
1452
+ continue;
1453
+ }
1454
+
1455
+ const dependent = changeById.get(dependentId);
1456
+ if (dependent === undefined) {
1457
+ continue;
1458
+ }
1459
+
1460
+ ready.push(dependent);
1461
+ ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1462
+ }
1463
+ }
1464
+
1465
+ if (ordered.length !== changes.length) {
1466
+ throw new DomainError({
1467
+ code: "invalid_dependency",
1468
+ message: "unable to determine dependency-safe cascade order",
1469
+ details: {
1470
+ changedIds: changes.map((change) => change.id),
1471
+ },
1472
+ });
1473
+ }
1474
+
1475
+ return ordered;
1476
+ }
1477
+
1478
+ #collectStatusCascadeBlockers(
1479
+ changes: readonly StatusCascadeChange[],
1480
+ scopeIdSet: ReadonlySet<string>,
1481
+ changedIdSet: ReadonlySet<string>,
1482
+ targetStatus: string,
1483
+ ): StatusCascadeBlocker[] {
1484
+ if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
1485
+ return [];
1486
+ }
1487
+
1488
+ const blockers: StatusCascadeBlocker[] = [];
1489
+ for (const change of changes) {
1490
+ if (change.kind !== "task" && change.kind !== "subtask") {
1491
+ continue;
1492
+ }
1493
+
1494
+ for (const dependency of this.listDependencies(change.id)) {
1495
+ const dependencyStatus =
1496
+ dependency.dependsOnKind === "task"
1497
+ ? this.getTaskOrThrow(dependency.dependsOnId).status
1498
+ : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1499
+ const inScope = scopeIdSet.has(dependency.dependsOnId);
1500
+ const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
1501
+ if (dependencyStatus === "done" || willCascade) {
1502
+ continue;
1503
+ }
1504
+
1505
+ blockers.push({
1506
+ sourceId: dependency.sourceId,
1507
+ sourceKind: dependency.sourceKind,
1508
+ dependsOnId: dependency.dependsOnId,
1509
+ dependsOnKind: dependency.dependsOnKind,
1510
+ dependsOnStatus: dependencyStatus,
1511
+ inScope,
1512
+ willCascade,
1513
+ });
1514
+ }
1515
+ }
1516
+
1517
+ return blockers.sort(
1518
+ (left, right) =>
1519
+ left.sourceId.localeCompare(right.sourceId) ||
1520
+ left.dependsOnId.localeCompare(right.dependsOnId) ||
1521
+ left.dependsOnKind.localeCompare(right.dependsOnKind),
1522
+ );
1523
+ }
1524
+
1269
1525
  private assertNoUnresolvedDependenciesForStatusTransition(
1270
1526
  id: string,
1271
- kind: "task" | "subtask",
1527
+ kind: DependencyNodeKind,
1272
1528
  existingStatus: string,
1273
1529
  nextStatus: string,
1274
1530
  ): void {
@@ -192,6 +192,57 @@ export interface SearchNode {
192
192
  readonly description: string;
193
193
  }
194
194
 
195
+ export type StatusCascadeRootKind = Extract<NodeKind, "epic" | "task">;
196
+ export type DependencyNodeKind = Extract<NodeKind, "task" | "subtask">;
197
+
198
+ export interface StatusCascadeScopeNode {
199
+ readonly kind: NodeKind;
200
+ readonly id: string;
201
+ readonly parentId?: string;
202
+ readonly status: string;
203
+ }
204
+
205
+ export interface StatusCascadeChange {
206
+ readonly kind: NodeKind;
207
+ readonly id: string;
208
+ readonly parentId?: string;
209
+ readonly previousStatus: string;
210
+ readonly nextStatus: string;
211
+ }
212
+
213
+ export interface StatusCascadeBlocker {
214
+ readonly sourceId: string;
215
+ readonly sourceKind: DependencyNodeKind;
216
+ readonly dependsOnId: string;
217
+ readonly dependsOnKind: DependencyNodeKind;
218
+ readonly dependsOnStatus: string;
219
+ readonly inScope: boolean;
220
+ readonly willCascade: boolean;
221
+ }
222
+
223
+ export interface StatusCascadeCounts {
224
+ readonly scope: number;
225
+ readonly changed: number;
226
+ readonly unchanged: number;
227
+ readonly blockers: number;
228
+ readonly changedEpics: number;
229
+ readonly changedTasks: number;
230
+ readonly changedSubtasks: number;
231
+ }
232
+
233
+ export interface StatusCascadePlan {
234
+ readonly rootKind: StatusCascadeRootKind;
235
+ readonly rootId: string;
236
+ readonly targetStatus: string;
237
+ readonly atomic: true;
238
+ readonly scope: ReadonlyArray<StatusCascadeScopeNode>;
239
+ readonly orderedChanges: ReadonlyArray<StatusCascadeChange>;
240
+ readonly changedIds: ReadonlyArray<string>;
241
+ readonly unchangedIds: ReadonlyArray<string>;
242
+ readonly blockers: ReadonlyArray<StatusCascadeBlocker>;
243
+ readonly counts: StatusCascadeCounts;
244
+ }
245
+
195
246
  export interface DomainErrorShape {
196
247
  readonly code: string;
197
248
  readonly message: string;
@@ -1,3 +1,4 @@
1
+ import { runBoard } from "../commands/board";
1
2
  import { runHelp } from "../commands/help";
2
3
  import { runDep } from "../commands/dep";
3
4
  import { runEpic } from "../commands/epic";
@@ -19,6 +20,7 @@ import { resolveStoragePaths } from "../storage/path";
19
20
 
20
21
  const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
21
22
  "help",
23
+ "board",
22
24
  "init",
23
25
  "quickstart",
24
26
  "session",
@@ -334,6 +336,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
334
336
  case "help":
335
337
  result = await runHelp(context);
336
338
  break;
339
+ case "board":
340
+ result = await runBoard(context);
341
+ break;
337
342
  case "init":
338
343
  result = await runInit(context);
339
344
  break;
@@ -4,6 +4,9 @@ import { resolve } from "node:path";
4
4
 
5
5
  export const TREKOON_STORAGE_DIRNAME = ".trekoon";
6
6
  export const TREKOON_DATABASE_FILENAME = "trekoon.db";
7
+ export const TREKOON_BOARD_DIRNAME = "board";
8
+ export const TREKOON_BOARD_ENTRY_FILENAME = "index.html";
9
+ export const TREKOON_BOARD_MANIFEST_FILENAME = "manifest.json";
7
10
 
8
11
  export function resolveLegacyWorktreeStorageDir(worktreeRoot: string): string {
9
12
  return resolve(worktreeRoot, TREKOON_STORAGE_DIRNAME);
@@ -15,6 +18,18 @@ export function resolveLegacyWorktreeDatabaseFile(worktreeRoot: string): string
15
18
 
16
19
  export type StorageMode = "cwd" | "git_common_dir";
17
20
 
21
+ export function resolveBoardStorageDir(storageDir: string): string {
22
+ return resolve(storageDir, TREKOON_BOARD_DIRNAME);
23
+ }
24
+
25
+ export function resolveBoardEntryFile(storageDir: string): string {
26
+ return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_ENTRY_FILENAME);
27
+ }
28
+
29
+ export function resolveBoardManifestFile(storageDir: string): string {
30
+ return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_MANIFEST_FILENAME);
31
+ }
32
+
18
33
  export interface StoragePaths {
19
34
  readonly invocationCwd: string;
20
35
  readonly storageMode: StorageMode;
@@ -23,6 +38,9 @@ export interface StoragePaths {
23
38
  readonly sharedStorageRoot: string;
24
39
  readonly storageDir: string;
25
40
  readonly databaseFile: string;
41
+ readonly boardDir: string;
42
+ readonly boardEntryFile: string;
43
+ readonly boardManifestFile: string;
26
44
  readonly diagnostics: StoragePathDiagnostics;
27
45
  }
28
46
 
@@ -35,6 +53,9 @@ export interface StoragePathIssue {
35
53
  readonly worktreeRoot: string;
36
54
  readonly sharedStorageRoot: string;
37
55
  readonly databaseFile: string;
56
+ readonly boardDir: string;
57
+ readonly boardEntryFile: string;
58
+ readonly boardManifestFile: string;
38
59
  }
39
60
 
40
61
  export interface StoragePathDiagnostics {
@@ -44,6 +65,9 @@ export interface StoragePathDiagnostics {
44
65
  readonly worktreeRoot: string;
45
66
  readonly sharedStorageRoot: string;
46
67
  readonly databaseFile: string;
68
+ readonly boardDir: string;
69
+ readonly boardEntryFile: string;
70
+ readonly boardManifestFile: string;
47
71
  readonly warnings: readonly StoragePathIssue[];
48
72
  readonly errors: readonly StoragePathIssue[];
49
73
  }
@@ -76,6 +100,9 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
76
100
  const sharedStorageRoot: string = repoCommonDir ? realpathSync(resolve(repoCommonDir, "..")) : invocationCwd;
77
101
  const storageDir: string = resolve(sharedStorageRoot, TREKOON_STORAGE_DIRNAME);
78
102
  const databaseFile: string = resolve(storageDir, TREKOON_DATABASE_FILENAME);
103
+ const boardDir: string = resolveBoardStorageDir(storageDir);
104
+ const boardEntryFile: string = resolveBoardEntryFile(storageDir);
105
+ const boardManifestFile: string = resolveBoardManifestFile(storageDir);
79
106
  const warnings: StoragePathIssue[] = [];
80
107
 
81
108
  const createIssue = (code: string, message: string): StoragePathIssue => ({
@@ -87,6 +114,9 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
87
114
  worktreeRoot,
88
115
  sharedStorageRoot,
89
116
  databaseFile,
117
+ boardDir,
118
+ boardEntryFile,
119
+ boardManifestFile,
90
120
  });
91
121
 
92
122
  if (invocationCwd !== worktreeRoot) {
@@ -111,6 +141,9 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
111
141
  worktreeRoot,
112
142
  sharedStorageRoot,
113
143
  databaseFile,
144
+ boardDir,
145
+ boardEntryFile,
146
+ boardManifestFile,
114
147
  warnings,
115
148
  errors: [],
116
149
  };
@@ -123,6 +156,9 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
123
156
  sharedStorageRoot,
124
157
  storageDir,
125
158
  databaseFile,
159
+ boardDir,
160
+ boardEntryFile,
161
+ boardManifestFile,
126
162
  diagnostics,
127
163
  };
128
164
  }