trekoon 0.4.1 → 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 -1
  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 +45 -13
  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 +4 -0
  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 +42 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +79 -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
@@ -0,0 +1,412 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ import { DEPENDENCY_GATED_STATUSES } from "./dependency-rules";
4
+ import {
5
+ type DependencyNodeKind,
6
+ type DependencyRecord,
7
+ DomainError,
8
+ type EpicTreeDetailed,
9
+ type StatusCascadeBlocker,
10
+ type StatusCascadeChange,
11
+ type StatusCascadePlan,
12
+ type StatusCascadeRootKind,
13
+ type StatusCascadeScopeNode,
14
+ type TaskTreeDetailed,
15
+ } from "./types";
16
+
17
+ const CASCADE_BLOCKER_SQLITE_MAX_VARIABLES = 999;
18
+
19
+ /**
20
+ * Row shape returned by {@link CascadePlannerReader.loadDependencyTargetStatuses}.
21
+ *
22
+ * Mirrors the columns produced by the chunked
23
+ * `dependencies LEFT JOIN tasks LEFT JOIN subtasks` query that previously
24
+ * lived inline inside `tracker-domain.ts`. The reader implementation is
25
+ * responsible for ordering rows by `(created_at ASC, id ASC)` so blocker
26
+ * sequencing remains stable across chunked fetches.
27
+ */
28
+ export interface CascadeDependencyTargetStatusRow {
29
+ readonly sourceId: string;
30
+ readonly sourceKind: DependencyNodeKind;
31
+ readonly dependsOnId: string;
32
+ readonly dependsOnKind: DependencyNodeKind;
33
+ /** `null` when the referenced node has been deleted (orphaned edge). */
34
+ readonly dependsOnStatus: string | null;
35
+ }
36
+
37
+ /**
38
+ * Read-only domain projection consumed by the cascade planner.
39
+ *
40
+ * The planner is pure: every database read is funnelled through this
41
+ * interface. The `tracker-domain.ts` adapter supplies the concrete
42
+ * implementation that calls back into existing domain methods / SQL.
43
+ */
44
+ export interface CascadePlannerReader {
45
+ buildEpicTreeDetailed(epicId: string): EpicTreeDetailed;
46
+ buildTaskTreeDetailed(taskId: string): TaskTreeDetailed;
47
+ listDependenciesBySourceIds(sourceIds: readonly string[]): Map<string, readonly DependencyRecord[]>;
48
+ loadDependencyTargetStatuses(sourceIds: readonly string[]): readonly CascadeDependencyTargetStatusRow[];
49
+ }
50
+
51
+ function assertNonEmpty(field: string, value: string | undefined | null): string {
52
+ const normalized: string = (value ?? "").trim();
53
+ if (!normalized) {
54
+ throw new DomainError({
55
+ code: "invalid_input",
56
+ message: `${field} must be a non-empty string`,
57
+ details: { field },
58
+ });
59
+ }
60
+
61
+ return normalized;
62
+ }
63
+
64
+ /**
65
+ * Pure cascade planner. Given a reader projection of the domain and a
66
+ * cascade root + target status, computes the deterministic plan that
67
+ * `MutationService` consumes.
68
+ *
69
+ * Behaviour MUST remain byte-identical to the previous in-line
70
+ * implementation: scope ordering, change ordering (topological for the
71
+ * `done` target), blocker filtering, and counts feed directly into
72
+ * cascade event payloads.
73
+ */
74
+ export function planStatusCascade(
75
+ reader: CascadePlannerReader,
76
+ rootKind: StatusCascadeRootKind,
77
+ rootId: string,
78
+ targetStatus: string,
79
+ ): StatusCascadePlan {
80
+ const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
81
+ const scope = collectStatusCascadeScope(reader, rootKind, rootId);
82
+ const scopeIdSet = new Set(scope.map((node) => node.id));
83
+ const orderedChanges = orderStatusCascadeChanges(reader, scope, normalizedTargetStatus);
84
+ const changedIds = orderedChanges.map((change) => change.id);
85
+ const changedIdSet = new Set(changedIds);
86
+ const unchangedIds = scope
87
+ .filter((node) => !changedIdSet.has(node.id))
88
+ .map((node) => node.id);
89
+ const blockers = collectStatusCascadeBlockers(
90
+ reader,
91
+ orderedChanges,
92
+ scopeIdSet,
93
+ changedIdSet,
94
+ normalizedTargetStatus,
95
+ );
96
+
97
+ return {
98
+ rootKind,
99
+ rootId,
100
+ targetStatus: normalizedTargetStatus,
101
+ atomic: true,
102
+ scope,
103
+ orderedChanges,
104
+ changedIds,
105
+ unchangedIds,
106
+ blockers,
107
+ counts: {
108
+ scope: scope.length,
109
+ changed: orderedChanges.length,
110
+ unchanged: unchangedIds.length,
111
+ blockers: blockers.length,
112
+ changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
113
+ changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
114
+ changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
115
+ },
116
+ };
117
+ }
118
+
119
+ export function collectStatusCascadeScope(
120
+ reader: CascadePlannerReader,
121
+ rootKind: StatusCascadeRootKind,
122
+ rootId: string,
123
+ ): StatusCascadeScopeNode[] {
124
+ if (rootKind === "task") {
125
+ const tree = reader.buildTaskTreeDetailed(rootId);
126
+ return [
127
+ {
128
+ kind: "task",
129
+ id: tree.id,
130
+ parentId: tree.epicId,
131
+ status: tree.status,
132
+ },
133
+ ...tree.subtasks.map((subtask) => ({
134
+ kind: "subtask" as const,
135
+ id: subtask.id,
136
+ parentId: subtask.taskId,
137
+ status: subtask.status,
138
+ })),
139
+ ];
140
+ }
141
+
142
+ const tree = reader.buildEpicTreeDetailed(rootId);
143
+ return [
144
+ {
145
+ kind: "epic",
146
+ id: tree.id,
147
+ status: tree.status,
148
+ },
149
+ ...tree.tasks.flatMap((task) => [
150
+ {
151
+ kind: "task" as const,
152
+ id: task.id,
153
+ parentId: task.epicId,
154
+ status: task.status,
155
+ },
156
+ ...task.subtasks.map((subtask) => ({
157
+ kind: "subtask" as const,
158
+ id: subtask.id,
159
+ parentId: subtask.taskId,
160
+ status: subtask.status,
161
+ })),
162
+ ]),
163
+ ];
164
+ }
165
+
166
+ export function orderStatusCascadeChanges(
167
+ reader: CascadePlannerReader,
168
+ scope: readonly StatusCascadeScopeNode[],
169
+ targetStatus: string,
170
+ ): StatusCascadeChange[] {
171
+ const changes = scope
172
+ .filter((node) => node.status !== targetStatus)
173
+ .map((node) => {
174
+ const change: StatusCascadeChange = {
175
+ kind: node.kind,
176
+ id: node.id,
177
+ previousStatus: node.status,
178
+ nextStatus: targetStatus,
179
+ ...(node.parentId === undefined ? {} : { parentId: node.parentId }),
180
+ };
181
+ return change;
182
+ });
183
+
184
+ if (targetStatus !== "done") {
185
+ return changes;
186
+ }
187
+
188
+ return topologicallyOrderDoneCascadeChanges(reader, changes);
189
+ }
190
+
191
+ export function topologicallyOrderDoneCascadeChanges(
192
+ reader: CascadePlannerReader,
193
+ changes: readonly StatusCascadeChange[],
194
+ ): StatusCascadeChange[] {
195
+ const indexById = new Map<string, number>();
196
+ const changeById = new Map<string, StatusCascadeChange>();
197
+ const dependencyTargetsBySource = new Map<string, Set<string>>();
198
+ const dependents = new Map<string, Set<string>>();
199
+ const indegree = new Map<string, number>();
200
+ const dependencyMap = reader.listDependenciesBySourceIds(
201
+ changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
202
+ );
203
+
204
+ changes.forEach((change, index) => {
205
+ indexById.set(change.id, index);
206
+ changeById.set(change.id, change);
207
+ indegree.set(change.id, 0);
208
+
209
+ if (change.kind !== "task" && change.kind !== "subtask") {
210
+ return;
211
+ }
212
+
213
+ const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
214
+ dependencyTargetsBySource.set(change.id, dependencyTargets);
215
+ });
216
+
217
+ const addEdge = (fromId: string, toId: string): void => {
218
+ if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
219
+ return;
220
+ }
221
+
222
+ const neighbors = dependents.get(fromId) ?? new Set<string>();
223
+ if (neighbors.has(toId)) {
224
+ return;
225
+ }
226
+
227
+ neighbors.add(toId);
228
+ dependents.set(fromId, neighbors);
229
+ indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
230
+ };
231
+
232
+ for (const change of changes) {
233
+ const dependencyTargets = dependencyTargetsBySource.get(change.id);
234
+
235
+ if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
236
+ addEdge(change.id, change.parentId);
237
+ }
238
+
239
+ if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
240
+ addEdge(change.id, change.parentId);
241
+ }
242
+
243
+ if (change.kind !== "task" && change.kind !== "subtask") {
244
+ continue;
245
+ }
246
+
247
+ for (const dependencyTargetId of dependencyTargets ?? []) {
248
+ addEdge(dependencyTargetId, change.id);
249
+ }
250
+ }
251
+
252
+ const ordered: StatusCascadeChange[] = [];
253
+ const ready = changes
254
+ .filter((change) => (indegree.get(change.id) ?? 0) === 0)
255
+ .sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
256
+
257
+ // Kahn-style topo sort. We re-sort `ready` after every push so the
258
+ // iteration is deterministic on the original change index, but that
259
+ // turns the loop into O(n^2 log n) in the worst case (System Hardening
260
+ // 0.4.2, finding 34). Cascades currently fan out to at most a few dozen
261
+ // entities so this is a non-issue in practice; keep the simple
262
+ // array-based queue for readability.
263
+ // TODO(perf): swap `ready` for a min-heap keyed on `indexById` if a
264
+ // future benchmark shows cascades on large epics (>1k changes) hot in
265
+ // a profile.
266
+ while (ready.length > 0) {
267
+ const next = ready.shift();
268
+ if (next === undefined) {
269
+ continue;
270
+ }
271
+
272
+ ordered.push(next);
273
+ for (const dependentId of dependents.get(next.id) ?? []) {
274
+ const remaining = (indegree.get(dependentId) ?? 0) - 1;
275
+ indegree.set(dependentId, remaining);
276
+ if (remaining !== 0) {
277
+ continue;
278
+ }
279
+
280
+ const dependent = changeById.get(dependentId);
281
+ if (dependent === undefined) {
282
+ continue;
283
+ }
284
+
285
+ ready.push(dependent);
286
+ ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
287
+ }
288
+ }
289
+
290
+ if (ordered.length !== changes.length) {
291
+ throw new DomainError({
292
+ code: "invalid_dependency",
293
+ message: "unable to determine dependency-safe cascade order",
294
+ details: {
295
+ changedIds: changes.map((change) => change.id),
296
+ },
297
+ });
298
+ }
299
+
300
+ return ordered;
301
+ }
302
+
303
+ export function collectStatusCascadeBlockers(
304
+ reader: CascadePlannerReader,
305
+ changes: readonly StatusCascadeChange[],
306
+ scopeIdSet: ReadonlySet<string>,
307
+ changedIdSet: ReadonlySet<string>,
308
+ targetStatus: string,
309
+ ): StatusCascadeBlocker[] {
310
+ if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
311
+ return [];
312
+ }
313
+
314
+ // Collect all dependency-eligible change IDs upfront.
315
+ const eligibleIds: string[] = [];
316
+ for (const change of changes) {
317
+ if (change.kind === "task" || change.kind === "subtask") {
318
+ eligibleIds.push(change.id);
319
+ }
320
+ }
321
+
322
+ if (eligibleIds.length === 0) {
323
+ return [];
324
+ }
325
+
326
+ const rows = reader.loadDependencyTargetStatuses(eligibleIds);
327
+ const blockers: StatusCascadeBlocker[] = [];
328
+
329
+ for (const row of rows) {
330
+ // Skip orphaned dependency rows where the referenced node no longer exists.
331
+ if (row.dependsOnStatus === null) {
332
+ continue;
333
+ }
334
+
335
+ const inScope = scopeIdSet.has(row.dependsOnId);
336
+ const willCascade = targetStatus === "done" && changedIdSet.has(row.dependsOnId);
337
+ if (row.dependsOnStatus === "done" || willCascade) {
338
+ continue;
339
+ }
340
+
341
+ blockers.push({
342
+ sourceId: row.sourceId,
343
+ sourceKind: row.sourceKind,
344
+ dependsOnId: row.dependsOnId,
345
+ dependsOnKind: row.dependsOnKind,
346
+ dependsOnStatus: row.dependsOnStatus,
347
+ inScope,
348
+ willCascade,
349
+ });
350
+ }
351
+
352
+ return blockers.sort(
353
+ (left, right) =>
354
+ left.sourceId.localeCompare(right.sourceId) ||
355
+ left.dependsOnId.localeCompare(right.dependsOnId) ||
356
+ left.dependsOnKind.localeCompare(right.dependsOnKind),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Default SQL implementation of {@link CascadePlannerReader.loadDependencyTargetStatuses}.
362
+ *
363
+ * Issues chunked `WHERE source_id IN (...)` joins so the `?` parameter count
364
+ * never exceeds SQLite's binding cap, preserving the deterministic
365
+ * `(created_at ASC, id ASC)` row order the blocker pass relies on.
366
+ */
367
+ export function loadCascadeDependencyTargetStatuses(
368
+ db: Database,
369
+ sourceIds: readonly string[],
370
+ ): readonly CascadeDependencyTargetStatusRow[] {
371
+ if (sourceIds.length === 0) {
372
+ return [];
373
+ }
374
+
375
+ type DepStatusRow = {
376
+ source_id: string;
377
+ source_kind: DependencyNodeKind;
378
+ depends_on_id: string;
379
+ depends_on_kind: DependencyNodeKind;
380
+ dep_status: string | null;
381
+ };
382
+
383
+ const collected: CascadeDependencyTargetStatusRow[] = [];
384
+
385
+ for (let offset = 0; offset < sourceIds.length; offset += CASCADE_BLOCKER_SQLITE_MAX_VARIABLES) {
386
+ const chunkIds = sourceIds.slice(offset, offset + CASCADE_BLOCKER_SQLITE_MAX_VARIABLES);
387
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
388
+ const rows = db
389
+ .query(
390
+ `SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
391
+ COALESCE(t.status, s.status) AS dep_status
392
+ FROM dependencies d
393
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
394
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
395
+ WHERE d.source_id IN (${inPlaceholders})
396
+ ORDER BY d.created_at ASC, d.id ASC;`,
397
+ )
398
+ .all(...chunkIds) as DepStatusRow[];
399
+
400
+ for (const row of rows) {
401
+ collected.push({
402
+ sourceId: row.source_id,
403
+ sourceKind: row.source_kind,
404
+ dependsOnId: row.depends_on_id,
405
+ dependsOnKind: row.depends_on_kind,
406
+ dependsOnStatus: row.dep_status,
407
+ });
408
+ }
409
+ }
410
+
411
+ return collected;
412
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Statuses for which transitioning a node requires upstream dependencies to be
3
+ * resolved. Originally duplicated as a private const in both `cascade-planner`
4
+ * and `tracker-domain`; centralized here so a single edit updates every gating
5
+ * site at once.
6
+ *
7
+ * If you add a new gated status (e.g. another forward-progress state), add it
8
+ * here only — do NOT redeclare this Set in another module. The cr-expert audit
9
+ * called out the duplication; keeping a single source-of-truth prevents future
10
+ * drift between gating callsites.
11
+ */
12
+ export const DEPENDENCY_GATED_STATUSES: ReadonlySet<string> = new Set<string>([
13
+ "in_progress",
14
+ "done",
15
+ ]);