trekoon 0.3.0 → 0.3.1
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.
- package/.agents/skills/trekoon/SKILL.md +162 -26
- package/README.md +4 -1
- package/docs/ai-agents.md +49 -4
- package/docs/commands.md +81 -5
- package/docs/machine-contracts.md +120 -0
- package/docs/plans/r1-unified-skill-rewrite.md +290 -0
- package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
- package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
- package/docs/quickstart.md +31 -0
- package/package.json +1 -1
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/subtask.ts +7 -5
- package/src/commands/suggest.ts +283 -0
- package/src/commands/sync-helpers.ts +75 -0
- package/src/commands/task-readiness.ts +8 -20
- package/src/commands/task.ts +59 -3
- package/src/domain/mutation-service.ts +69 -42
- package/src/domain/tracker-domain.ts +151 -22
- package/src/domain/types.ts +12 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +4 -2
- package/src/runtime/cli-shell.ts +26 -3
- package/src/runtime/command-types.ts +1 -1
- package/src/storage/database.ts +43 -1
- package/src/storage/events-retention.ts +57 -8
- package/src/storage/migrations.ts +58 -3
- package/src/sync/service.ts +101 -24
- 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", "
|
|
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
|
|
1612
|
+
const dependencyNode =
|
|
1496
1613
|
dependency.dependsOnKind === "task"
|
|
1497
|
-
? this.
|
|
1498
|
-
: this.
|
|
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
|
|
1687
|
+
const dependencyNode =
|
|
1564
1688
|
dependency.dependsOnKind === "task"
|
|
1565
|
-
? this.
|
|
1566
|
-
: this.
|
|
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 (
|
|
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:
|
|
1704
|
+
status: dependencyNode.status,
|
|
1576
1705
|
});
|
|
1577
1706
|
}
|
|
1578
1707
|
|
package/src/domain/types.ts
CHANGED
|
@@ -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
|
};
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
35
|
+
readonly metadata?: ContractMetadata;
|
|
36
36
|
readonly error?: ToonError;
|
|
37
37
|
readonly meta?: Record<string, unknown>;
|
|
38
38
|
}
|
package/src/storage/database.ts
CHANGED
|
@@ -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(
|
|
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
|
|
62
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
}
|