trekoon 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/trekoon/SKILL.md +274 -26
- package/.agents/skills/trekoon/reference/execution-with-team.md +213 -0
- package/.agents/skills/trekoon/reference/execution.md +210 -0
- package/.agents/skills/trekoon/reference/planning.md +244 -0
- package/README.md +24 -10
- package/docs/ai-agents.md +108 -30
- 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 +2 -2
- package/src/board/assets/app.js +5 -0
- package/src/board/assets/components/EpicsOverview.js +13 -0
- package/src/board/assets/components/Workspace.js +27 -12
- package/src/board/assets/components/helpers.js +3 -2
- package/src/board/assets/runtime/delegation.js +69 -1
- package/src/board/assets/state/actions.js +27 -1
- package/src/board/assets/state/store.js +37 -8
- package/src/board/assets/state/utils.js +42 -0
- package/src/board/assets/styles/board.css +68 -0
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/skills.ts +39 -32
- 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
package/src/commands/session.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { parseArgs, readOption } from "./arg-parser";
|
|
3
2
|
import { unexpectedFailureResult } from "./error-utils";
|
|
3
|
+
import { DEFAULT_SOURCE_BRANCH, resolveSyncStatus } from "./sync-helpers";
|
|
4
4
|
import { buildTaskReadiness, type DependencyBlocker } from "./task-readiness";
|
|
5
5
|
|
|
6
6
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
7
7
|
import { okResult } from "../io/output";
|
|
8
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
9
9
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
10
|
-
import {
|
|
11
|
-
import { persistGitContext, resolveGitContext } from "../sync/git-context";
|
|
12
|
-
import { type GitContextSnapshot, type SyncStatusSummary } from "../sync/types";
|
|
13
|
-
|
|
14
|
-
const DEFAULT_SOURCE_BRANCH = "main";
|
|
10
|
+
import { type GitContextSnapshot } from "../sync/types";
|
|
15
11
|
|
|
16
12
|
interface SessionReadiness {
|
|
17
13
|
readonly readyCount: number;
|
|
@@ -50,73 +46,6 @@ interface SessionResult {
|
|
|
50
46
|
readonly readiness: SessionReadiness;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
function countAheadLocal(db: Database, currentBranch: string | null, sourceBranch: string): number {
|
|
54
|
-
if (!currentBranch || currentBranch === sourceBranch) {
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const row = db
|
|
59
|
-
.query(
|
|
60
|
-
`
|
|
61
|
-
SELECT COUNT(*) AS count
|
|
62
|
-
FROM events
|
|
63
|
-
WHERE git_branch = @branch;
|
|
64
|
-
`,
|
|
65
|
-
)
|
|
66
|
-
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
67
|
-
|
|
68
|
-
return row?.count ?? 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function countPendingConflictsLocal(db: Database): number {
|
|
72
|
-
const row = db
|
|
73
|
-
.query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
|
|
74
|
-
.get() as { count: number } | null;
|
|
75
|
-
|
|
76
|
-
return row?.count ?? 0;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function loadCursorLocal(
|
|
80
|
-
db: Database,
|
|
81
|
-
worktreePath: string,
|
|
82
|
-
sourceBranch: string,
|
|
83
|
-
): { cursor_token: string } | null {
|
|
84
|
-
return db
|
|
85
|
-
.query(
|
|
86
|
-
`
|
|
87
|
-
SELECT cursor_token
|
|
88
|
-
FROM sync_cursors
|
|
89
|
-
WHERE owner_scope = 'worktree'
|
|
90
|
-
AND owner_worktree_path = ?
|
|
91
|
-
AND source_branch = ?
|
|
92
|
-
LIMIT 1;
|
|
93
|
-
`,
|
|
94
|
-
)
|
|
95
|
-
.get(worktreePath, sourceBranch) as { cursor_token: string } | null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function resolveSyncStatus(
|
|
99
|
-
database: TrekoonDatabase,
|
|
100
|
-
cwd: string,
|
|
101
|
-
sourceBranch: string,
|
|
102
|
-
): SyncStatusSummary {
|
|
103
|
-
const git: GitContextSnapshot = resolveGitContext(cwd);
|
|
104
|
-
persistGitContext(database.db, git);
|
|
105
|
-
|
|
106
|
-
const cursor = loadCursorLocal(database.db, git.worktreePath, sourceBranch);
|
|
107
|
-
const cursorToken: string = cursor?.cursor_token ?? "0:";
|
|
108
|
-
const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
sourceBranch,
|
|
112
|
-
ahead: countAheadLocal(database.db, git.branchName, sourceBranch),
|
|
113
|
-
behind: onSourceBranch ? 0 : countBranchEventsSince(database.db, sourceBranch, cursorToken),
|
|
114
|
-
pendingConflicts: countPendingConflictsLocal(database.db),
|
|
115
|
-
sameBranch: onSourceBranch,
|
|
116
|
-
git,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
49
|
|
|
121
50
|
function formatSessionHuman(result: SessionResult): string {
|
|
122
51
|
const lines: string[] = [];
|
|
@@ -173,12 +102,15 @@ export async function runSession(context: CliContext): Promise<CliResult> {
|
|
|
173
102
|
let database: TrekoonDatabase | undefined;
|
|
174
103
|
|
|
175
104
|
try {
|
|
105
|
+
const parsed = parseArgs(context.args);
|
|
106
|
+
const epicId: string | undefined = readOption(parsed.options, "epic");
|
|
107
|
+
|
|
176
108
|
database = openTrekoonDatabase(context.cwd);
|
|
177
109
|
const diagnostics = database.diagnostics;
|
|
178
110
|
|
|
179
111
|
const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
|
|
180
112
|
const domain = new TrackerDomain(database.db);
|
|
181
|
-
const readiness = buildTaskReadiness(domain,
|
|
113
|
+
const readiness = buildTaskReadiness(domain, epicId);
|
|
182
114
|
const topCandidate = readiness.candidates[0] ?? null;
|
|
183
115
|
|
|
184
116
|
let nextTask: NextCandidate | null = null;
|
package/src/commands/skills.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
@@ -79,6 +79,10 @@ function resolveBundledSkillFilePath(): string {
|
|
|
79
79
|
return fileURLToPath(new URL("../../.agents/skills/trekoon/SKILL.md", import.meta.url));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function resolveBundledSkillDirPath(): string {
|
|
83
|
+
return fileURLToPath(new URL("../../.agents/skills/trekoon", import.meta.url));
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
function toAbsolutePath(cwd: string, pathValue: string): string {
|
|
83
87
|
if (isAbsolute(pathValue)) {
|
|
84
88
|
return pathValue;
|
|
@@ -234,6 +238,7 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
|
|
|
234
238
|
|
|
235
239
|
function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
|
|
236
240
|
const sourcePath: string = resolveBundledSkillFilePath();
|
|
241
|
+
const sourceDir: string = resolveBundledSkillDirPath();
|
|
237
242
|
if (!existsSync(sourcePath)) {
|
|
238
243
|
return failResult({
|
|
239
244
|
command: "skills.install",
|
|
@@ -255,6 +260,12 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
|
|
|
255
260
|
try {
|
|
256
261
|
mkdirSync(installedDir, { recursive: true });
|
|
257
262
|
copyFileSync(sourcePath, installedPath);
|
|
263
|
+
// Copy reference guides if they exist in the bundled source.
|
|
264
|
+
const sourceRefDir: string = join(sourceDir, "reference");
|
|
265
|
+
if (existsSync(sourceRefDir)) {
|
|
266
|
+
const installedRefDir: string = join(installedDir, "reference");
|
|
267
|
+
cpSync(sourceRefDir, installedRefDir, { recursive: true });
|
|
268
|
+
}
|
|
258
269
|
} catch (error: unknown) {
|
|
259
270
|
const message = error instanceof Error ? error.message : "Unknown skills install failure";
|
|
260
271
|
return failResult({
|
|
@@ -298,39 +309,28 @@ function replaceOrCreateSymlink(
|
|
|
298
309
|
|
|
299
310
|
const existing = lstatSync(linkPath);
|
|
300
311
|
if (!existing.isSymbolicLink()) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
error: {
|
|
310
|
-
code: "path_conflict",
|
|
311
|
-
message: "Symlink destination exists as a non-link path",
|
|
312
|
-
},
|
|
313
|
-
});
|
|
312
|
+
// Replace stale directory or file with symlink to the canonical location.
|
|
313
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
314
|
+
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
315
|
+
if (boundaryFailure) {
|
|
316
|
+
return boundaryFailure;
|
|
317
|
+
}
|
|
318
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
319
|
+
return null;
|
|
314
320
|
}
|
|
315
321
|
|
|
316
322
|
const existingRawTarget: string = readlinkSync(linkPath);
|
|
317
323
|
const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
|
|
318
324
|
const expectedTarget: string = resolve(targetPath);
|
|
319
325
|
if (existingAbsoluteTarget !== expectedTarget) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
},
|
|
329
|
-
error: {
|
|
330
|
-
code: "path_conflict",
|
|
331
|
-
message: "Symlink destination points to a different target",
|
|
332
|
-
},
|
|
333
|
-
});
|
|
326
|
+
// Replace symlink pointing to a different target.
|
|
327
|
+
rmSync(linkPath, { force: true });
|
|
328
|
+
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
329
|
+
if (boundaryFailure) {
|
|
330
|
+
return boundaryFailure;
|
|
331
|
+
}
|
|
332
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
333
|
+
return null;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
rmSync(linkPath, { force: true });
|
|
@@ -525,12 +525,16 @@ function updateEditorLink(
|
|
|
525
525
|
|
|
526
526
|
const entry = lstatSync(linkPath);
|
|
527
527
|
if (!entry.isSymbolicLink()) {
|
|
528
|
+
// Replace stale directory or file with symlink to the canonical location.
|
|
529
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
530
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
531
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
528
532
|
return {
|
|
529
533
|
editor,
|
|
530
534
|
linkPath,
|
|
531
535
|
expectedTarget,
|
|
532
|
-
action: "
|
|
533
|
-
conflictCode:
|
|
536
|
+
action: "refreshed",
|
|
537
|
+
conflictCode: null,
|
|
534
538
|
existingTarget: null,
|
|
535
539
|
};
|
|
536
540
|
}
|
|
@@ -539,12 +543,15 @@ function updateEditorLink(
|
|
|
539
543
|
const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
|
|
540
544
|
|
|
541
545
|
if (existingTarget !== expectedTarget) {
|
|
546
|
+
// Replace symlink pointing to a different target.
|
|
547
|
+
rmSync(linkPath, { force: true });
|
|
548
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
542
549
|
return {
|
|
543
550
|
editor,
|
|
544
551
|
linkPath,
|
|
545
552
|
expectedTarget,
|
|
546
|
-
action: "
|
|
547
|
-
conflictCode:
|
|
553
|
+
action: "refreshed",
|
|
554
|
+
conflictCode: null,
|
|
548
555
|
existingTarget,
|
|
549
556
|
};
|
|
550
557
|
}
|
package/src/commands/subtask.ts
CHANGED
|
@@ -32,13 +32,13 @@ function formatSubtask(subtask: SubtaskRecord): string {
|
|
|
32
32
|
|
|
33
33
|
const VIEW_MODES = ["table", "compact"] as const;
|
|
34
34
|
const DEFAULT_SUBTASK_LIST_LIMIT = 10;
|
|
35
|
-
const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "
|
|
35
|
+
const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "todo"] as const;
|
|
36
36
|
const CREATE_OPTIONS = ["task", "t", "title", "description", "d", "status", "s"] as const;
|
|
37
37
|
const LIST_OPTIONS = ["task", "t", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
|
|
38
38
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
39
39
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
40
40
|
const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
|
|
41
|
-
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title"] as const;
|
|
41
|
+
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "owner"] as const;
|
|
42
42
|
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
43
43
|
|
|
44
44
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
@@ -113,7 +113,7 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function subtaskStatusPriority(status: string): number {
|
|
116
|
-
if (status === "in_progress"
|
|
116
|
+
if (status === "in_progress") {
|
|
117
117
|
return 0;
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -788,7 +788,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
788
788
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
789
789
|
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
790
790
|
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
791
|
-
readMissingOptionValue(parsed.missingOptionValues, "status", "s")
|
|
791
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
792
|
+
readMissingOptionValue(parsed.missingOptionValues, "owner");
|
|
792
793
|
if (missingUpdateOption !== undefined) {
|
|
793
794
|
return failMissingOptionValue("subtask.update", missingUpdateOption);
|
|
794
795
|
}
|
|
@@ -801,6 +802,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
801
802
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
802
803
|
const append: string | undefined = readOption(parsed.options, "append");
|
|
803
804
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
805
|
+
const owner: string | undefined = readOption(parsed.options, "owner");
|
|
804
806
|
|
|
805
807
|
if (updateAll && ids.length > 0) {
|
|
806
808
|
return failResult({
|
|
@@ -923,7 +925,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
923
925
|
append === undefined
|
|
924
926
|
? description
|
|
925
927
|
: appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
|
|
926
|
-
const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status });
|
|
928
|
+
const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status, owner });
|
|
927
929
|
|
|
928
930
|
return okResult({
|
|
929
931
|
command: "subtask.update",
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { parseArgs, readOption } from "./arg-parser";
|
|
2
|
+
import { unexpectedFailureResult } from "./error-utils";
|
|
3
|
+
import { resolveSyncStatus } from "./sync-helpers";
|
|
4
|
+
import { buildTaskReadiness, type TaskReadinessResult } from "./task-readiness";
|
|
5
|
+
|
|
6
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
7
|
+
import { VALID_TRANSITIONS, type EpicRecord, type ValidStatus } from "../domain/types";
|
|
8
|
+
import { okResult } from "../io/output";
|
|
9
|
+
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
10
|
+
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
11
|
+
import type { SyncStatusSummary } from "../sync/types";
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_SOURCE_BRANCH } from "./sync-helpers";
|
|
14
|
+
|
|
15
|
+
const MAX_SUGGESTIONS = 3;
|
|
16
|
+
|
|
17
|
+
type SuggestionCategory = "recovery" | "sync" | "execution" | "planning";
|
|
18
|
+
|
|
19
|
+
interface Suggestion {
|
|
20
|
+
readonly priority: number;
|
|
21
|
+
readonly action: string;
|
|
22
|
+
readonly command: string;
|
|
23
|
+
readonly reason: string;
|
|
24
|
+
readonly category: SuggestionCategory;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SuggestContext {
|
|
28
|
+
readonly totalEpics: number;
|
|
29
|
+
readonly activeEpic: string | null;
|
|
30
|
+
readonly readyTasks: number;
|
|
31
|
+
readonly blockedTasks: number;
|
|
32
|
+
readonly inProgressTasks: number;
|
|
33
|
+
readonly syncBehind: number;
|
|
34
|
+
readonly pendingConflicts: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SuggestResult {
|
|
38
|
+
readonly suggestions: readonly Suggestion[];
|
|
39
|
+
readonly context: SuggestContext;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveActiveEpic(domain: TrackerDomain, epicId: string | undefined): EpicRecord | null {
|
|
43
|
+
if (epicId !== undefined) {
|
|
44
|
+
return domain.getEpic(epicId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const epics = domain.listEpics();
|
|
48
|
+
const inProgress = epics.find((epic) => epic.status === "in_progress");
|
|
49
|
+
if (inProgress) {
|
|
50
|
+
return inProgress;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const todo = epics.find((epic) => epic.status === "todo");
|
|
54
|
+
return todo ?? epics[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findInProgressTasks(readiness: TaskReadinessResult): { count: number; first: { id: string; title: string } | null } {
|
|
58
|
+
let count = 0;
|
|
59
|
+
let first: { id: string; title: string } | null = null;
|
|
60
|
+
|
|
61
|
+
for (const list of [readiness.candidates, readiness.blocked]) {
|
|
62
|
+
for (const candidate of list) {
|
|
63
|
+
if (candidate.task.status === "in_progress") {
|
|
64
|
+
count += 1;
|
|
65
|
+
if (first === null) {
|
|
66
|
+
first = { id: candidate.task.id, title: candidate.task.title };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { count, first };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildSuggestions(
|
|
76
|
+
recoveryRequired: boolean,
|
|
77
|
+
syncSummary: SyncStatusSummary,
|
|
78
|
+
readiness: TaskReadinessResult,
|
|
79
|
+
epics: readonly EpicRecord[],
|
|
80
|
+
activeEpic: EpicRecord | null,
|
|
81
|
+
): readonly Suggestion[] {
|
|
82
|
+
const suggestions: Suggestion[] = [];
|
|
83
|
+
|
|
84
|
+
// Priority 1: Recovery required
|
|
85
|
+
if (recoveryRequired) {
|
|
86
|
+
suggestions.push({
|
|
87
|
+
priority: suggestions.length + 1,
|
|
88
|
+
action: "init",
|
|
89
|
+
command: "trekoon --toon init",
|
|
90
|
+
reason: "Storage needs repair — run init to recover",
|
|
91
|
+
category: "recovery",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Priority 2: Pending conflicts
|
|
96
|
+
if (suggestions.length < MAX_SUGGESTIONS && syncSummary.pendingConflicts > 0) {
|
|
97
|
+
suggestions.push({
|
|
98
|
+
priority: suggestions.length + 1,
|
|
99
|
+
action: "sync conflicts",
|
|
100
|
+
command: "trekoon --toon sync conflicts",
|
|
101
|
+
reason: `${syncSummary.pendingConflicts} unresolved sync conflict${syncSummary.pendingConflicts === 1 ? "" : "s"} blocking accurate state`,
|
|
102
|
+
category: "sync",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Priority 3: Sync behind
|
|
107
|
+
if (suggestions.length < MAX_SUGGESTIONS && syncSummary.behind > 0) {
|
|
108
|
+
suggestions.push({
|
|
109
|
+
priority: suggestions.length + 1,
|
|
110
|
+
action: `sync pull --from ${syncSummary.sourceBranch}`,
|
|
111
|
+
command: `trekoon --toon sync pull --from ${syncSummary.sourceBranch}`,
|
|
112
|
+
reason: `${syncSummary.behind} event${syncSummary.behind === 1 ? "" : "s"} behind ${syncSummary.sourceBranch} branch`,
|
|
113
|
+
category: "sync",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Priority 4: In-progress tasks exist
|
|
118
|
+
const { first: inProgressTask } = findInProgressTasks(readiness);
|
|
119
|
+
if (suggestions.length < MAX_SUGGESTIONS && inProgressTask !== null) {
|
|
120
|
+
suggestions.push({
|
|
121
|
+
priority: suggestions.length + 1,
|
|
122
|
+
action: `continue task ${inProgressTask.id}`,
|
|
123
|
+
command: `trekoon --toon task show ${inProgressTask.id}`,
|
|
124
|
+
reason: `In-progress task: ${inProgressTask.title}`,
|
|
125
|
+
category: "execution",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Priority 5: Ready tasks available
|
|
130
|
+
const topReady = readiness.candidates.find((c) => c.task.status !== "in_progress");
|
|
131
|
+
if (suggestions.length < MAX_SUGGESTIONS && topReady) {
|
|
132
|
+
suggestions.push({
|
|
133
|
+
priority: suggestions.length + 1,
|
|
134
|
+
action: `claim task ${topReady.task.id}`,
|
|
135
|
+
command: `trekoon --toon task update ${topReady.task.id} --status in_progress`,
|
|
136
|
+
reason: `Highest priority ready task: ${topReady.task.title}`,
|
|
137
|
+
category: "execution",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Priority 6: All tasks blocked
|
|
142
|
+
if (
|
|
143
|
+
suggestions.length < MAX_SUGGESTIONS
|
|
144
|
+
&& readiness.summary.totalOpenTasks > 0
|
|
145
|
+
&& readiness.summary.readyCount === 0
|
|
146
|
+
&& inProgressTask === null
|
|
147
|
+
) {
|
|
148
|
+
const blockerCount = readiness.summary.unresolvedDependencyCount;
|
|
149
|
+
suggestions.push({
|
|
150
|
+
priority: suggestions.length + 1,
|
|
151
|
+
action: "review blocked tasks",
|
|
152
|
+
command: "trekoon --toon task ready",
|
|
153
|
+
reason: `All ${readiness.summary.blockedCount} open task${readiness.summary.blockedCount === 1 ? " is" : "s are"} blocked by ${blockerCount} unresolved dependenc${blockerCount === 1 ? "y" : "ies"}`,
|
|
154
|
+
category: "planning",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Priority 7: All tasks done (epic still open)
|
|
159
|
+
if (
|
|
160
|
+
suggestions.length < MAX_SUGGESTIONS
|
|
161
|
+
&& activeEpic !== null
|
|
162
|
+
&& readiness.summary.totalOpenTasks === 0
|
|
163
|
+
&& activeEpic.status !== "done"
|
|
164
|
+
) {
|
|
165
|
+
const epicStatus = activeEpic.status as ValidStatus;
|
|
166
|
+
const validTargets = VALID_TRANSITIONS.get(epicStatus);
|
|
167
|
+
const canTransitionToDone = validTargets?.has("done") === true;
|
|
168
|
+
|
|
169
|
+
if (canTransitionToDone) {
|
|
170
|
+
suggestions.push({
|
|
171
|
+
priority: suggestions.length + 1,
|
|
172
|
+
action: `mark epic ${activeEpic.id} done`,
|
|
173
|
+
command: `trekoon --toon epic update ${activeEpic.id} --status done`,
|
|
174
|
+
reason: `All tasks complete — mark epic "${activeEpic.title}" as done`,
|
|
175
|
+
category: "planning",
|
|
176
|
+
});
|
|
177
|
+
} else if (validTargets?.has("in_progress") === true) {
|
|
178
|
+
suggestions.push({
|
|
179
|
+
priority: suggestions.length + 1,
|
|
180
|
+
action: `advance epic ${activeEpic.id} to in_progress`,
|
|
181
|
+
command: `trekoon --toon epic update ${activeEpic.id} --status in_progress`,
|
|
182
|
+
reason: `All tasks complete — advance epic "${activeEpic.title}" to in_progress first (then mark done)`,
|
|
183
|
+
category: "planning",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Priority 8: No epics exist
|
|
189
|
+
if (suggestions.length < MAX_SUGGESTIONS && epics.length === 0) {
|
|
190
|
+
suggestions.push({
|
|
191
|
+
priority: suggestions.length + 1,
|
|
192
|
+
action: "quickstart",
|
|
193
|
+
command: "trekoon --toon quickstart",
|
|
194
|
+
reason: "No epics found — create your first epic with quickstart",
|
|
195
|
+
category: "planning",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return suggestions.slice(0, MAX_SUGGESTIONS);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function formatSuggestHuman(result: SuggestResult): string {
|
|
203
|
+
const lines: string[] = [];
|
|
204
|
+
|
|
205
|
+
if (result.suggestions.length === 0) {
|
|
206
|
+
lines.push("No suggestions — tracker state looks good.");
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push("=== Suggestions ===");
|
|
211
|
+
for (const suggestion of result.suggestions) {
|
|
212
|
+
lines.push(`${suggestion.priority}. [${suggestion.category}] ${suggestion.action}`);
|
|
213
|
+
lines.push(` Reason: ${suggestion.reason}`);
|
|
214
|
+
lines.push(` Command: ${suggestion.command}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lines.push("");
|
|
218
|
+
lines.push("=== Context ===");
|
|
219
|
+
lines.push(`Epics: ${result.context.totalEpics}`);
|
|
220
|
+
if (result.context.activeEpic !== null) {
|
|
221
|
+
lines.push(`Active epic: ${result.context.activeEpic}`);
|
|
222
|
+
}
|
|
223
|
+
lines.push(`Ready tasks: ${result.context.readyTasks}`);
|
|
224
|
+
lines.push(`Blocked tasks: ${result.context.blockedTasks}`);
|
|
225
|
+
lines.push(`In-progress tasks: ${result.context.inProgressTasks}`);
|
|
226
|
+
lines.push(`Sync behind: ${result.context.syncBehind}`);
|
|
227
|
+
lines.push(`Pending conflicts: ${result.context.pendingConflicts}`);
|
|
228
|
+
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function runSuggest(context: CliContext): Promise<CliResult> {
|
|
233
|
+
let database: TrekoonDatabase | undefined;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const parsed = parseArgs(context.args);
|
|
237
|
+
const epicId: string | undefined = readOption(parsed.options, "epic");
|
|
238
|
+
|
|
239
|
+
database = openTrekoonDatabase(context.cwd);
|
|
240
|
+
const diagnostics = database.diagnostics;
|
|
241
|
+
|
|
242
|
+
const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
|
|
243
|
+
const domain = new TrackerDomain(database.db);
|
|
244
|
+
const epics = domain.listEpics();
|
|
245
|
+
const activeEpic = resolveActiveEpic(domain, epicId);
|
|
246
|
+
|
|
247
|
+
const readiness = buildTaskReadiness(domain, epicId ?? activeEpic?.id);
|
|
248
|
+
|
|
249
|
+
const suggestions = buildSuggestions(
|
|
250
|
+
diagnostics.recoveryRequired,
|
|
251
|
+
syncSummary,
|
|
252
|
+
readiness,
|
|
253
|
+
epics,
|
|
254
|
+
activeEpic,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const result: SuggestResult = {
|
|
258
|
+
suggestions,
|
|
259
|
+
context: {
|
|
260
|
+
totalEpics: epics.length,
|
|
261
|
+
activeEpic: activeEpic?.id ?? null,
|
|
262
|
+
readyTasks: readiness.summary.readyCount,
|
|
263
|
+
blockedTasks: readiness.summary.blockedCount,
|
|
264
|
+
inProgressTasks: findInProgressTasks(readiness).count,
|
|
265
|
+
syncBehind: syncSummary.behind,
|
|
266
|
+
pendingConflicts: syncSummary.pendingConflicts,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return okResult({
|
|
271
|
+
command: "suggest",
|
|
272
|
+
human: formatSuggestHuman(result),
|
|
273
|
+
data: result,
|
|
274
|
+
});
|
|
275
|
+
} catch (error: unknown) {
|
|
276
|
+
return unexpectedFailureResult(error, {
|
|
277
|
+
command: "suggest",
|
|
278
|
+
human: "Unexpected suggest command failure",
|
|
279
|
+
});
|
|
280
|
+
} finally {
|
|
281
|
+
database?.close();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import { countBranchEventsSince } from "../sync/branch-db";
|
|
4
|
+
import { persistGitContext, resolveGitContext } from "../sync/git-context";
|
|
5
|
+
import type { GitContextSnapshot, SyncStatusSummary } from "../sync/types";
|
|
6
|
+
import type { TrekoonDatabase } from "../storage/database";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SOURCE_BRANCH = "main";
|
|
9
|
+
|
|
10
|
+
export function countAheadLocal(db: Database, currentBranch: string | null, sourceBranch: string): number {
|
|
11
|
+
if (!currentBranch || currentBranch === sourceBranch) {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const row = db
|
|
16
|
+
.query(
|
|
17
|
+
`
|
|
18
|
+
SELECT COUNT(*) AS count
|
|
19
|
+
FROM events
|
|
20
|
+
WHERE git_branch = @branch;
|
|
21
|
+
`,
|
|
22
|
+
)
|
|
23
|
+
.get({ "@branch": currentBranch }) as { count: number } | null;
|
|
24
|
+
|
|
25
|
+
return row?.count ?? 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function countPendingConflictsLocal(db: Database): number {
|
|
29
|
+
const row = db
|
|
30
|
+
.query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
|
|
31
|
+
.get() as { count: number } | null;
|
|
32
|
+
|
|
33
|
+
return row?.count ?? 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadCursorLocal(
|
|
37
|
+
db: Database,
|
|
38
|
+
worktreePath: string,
|
|
39
|
+
sourceBranch: string,
|
|
40
|
+
): { cursor_token: string } | null {
|
|
41
|
+
return db
|
|
42
|
+
.query(
|
|
43
|
+
`
|
|
44
|
+
SELECT cursor_token
|
|
45
|
+
FROM sync_cursors
|
|
46
|
+
WHERE owner_scope = 'worktree'
|
|
47
|
+
AND owner_worktree_path = ?
|
|
48
|
+
AND source_branch = ?
|
|
49
|
+
LIMIT 1;
|
|
50
|
+
`,
|
|
51
|
+
)
|
|
52
|
+
.get(worktreePath, sourceBranch) as { cursor_token: string } | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveSyncStatus(
|
|
56
|
+
database: TrekoonDatabase,
|
|
57
|
+
cwd: string,
|
|
58
|
+
sourceBranch: string,
|
|
59
|
+
): SyncStatusSummary {
|
|
60
|
+
const git: GitContextSnapshot = resolveGitContext(cwd);
|
|
61
|
+
persistGitContext(database.db, git);
|
|
62
|
+
|
|
63
|
+
const cursor = loadCursorLocal(database.db, git.worktreePath, sourceBranch);
|
|
64
|
+
const cursorToken: string = cursor?.cursor_token ?? "0:";
|
|
65
|
+
const onSourceBranch: boolean = git.branchName !== null && git.branchName === sourceBranch;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
sourceBranch,
|
|
69
|
+
ahead: countAheadLocal(database.db, git.branchName, sourceBranch),
|
|
70
|
+
behind: onSourceBranch ? 0 : countBranchEventsSince(database.db, sourceBranch, cursorToken),
|
|
71
|
+
pendingConflicts: countPendingConflictsLocal(database.db),
|
|
72
|
+
sameBranch: onSourceBranch,
|
|
73
|
+
git,
|
|
74
|
+
};
|
|
75
|
+
}
|