trekoon 0.2.9 → 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 +18 -15
- package/docs/ai-agents.md +49 -4
- package/docs/commands.md +90 -16
- 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 +41 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +1 -0
- package/src/board/assets/components/EpicRow.js +21 -6
- package/src/board/assets/components/EpicsOverview.js +5 -1
- package/src/board/assets/components/Notice.js +19 -12
- package/src/board/assets/components/Workspace.js +16 -5
- package/src/board/assets/components/helpers.js +17 -0
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +33 -0
- package/src/board/assets/state/actions.js +68 -0
- package/src/board/assets/state/store.js +1 -0
- package/src/board/assets/styles/board.css +156 -36
- 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
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
2
2
|
import { type TaskRecord } from "../domain/types";
|
|
3
3
|
|
|
4
|
-
export const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "
|
|
4
|
+
export const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "todo"] as const;
|
|
5
5
|
export const READY_REASON_READY = "all_dependencies_done";
|
|
6
6
|
export const READY_REASON_BLOCKED = "blocked_by_dependencies";
|
|
7
7
|
|
|
@@ -49,7 +49,7 @@ export interface TaskReadinessResult {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function taskStatusPriority(status: string): number {
|
|
52
|
-
if (status === "in_progress"
|
|
52
|
+
if (status === "in_progress") {
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -63,25 +63,13 @@ export function taskStatusPriority(status: string): number {
|
|
|
63
63
|
export function buildTaskReadiness(domain: TrackerDomain, epicId: string | undefined): TaskReadinessResult {
|
|
64
64
|
const openStatuses = new Set<string>(DEFAULT_OPEN_TASK_STATUSES);
|
|
65
65
|
const openTasks = domain.listTasks(epicId).filter((task) => openStatuses.has(task.status));
|
|
66
|
+
const taskIds = openTasks.map((task) => task.id);
|
|
67
|
+
const depMap = domain.batchResolveDependencyStatuses(taskIds);
|
|
68
|
+
|
|
66
69
|
const assessed = openTasks
|
|
67
70
|
.map((task) => {
|
|
68
|
-
const blockers:
|
|
69
|
-
const
|
|
70
|
-
for (const dependency of dependencies) {
|
|
71
|
-
const dependencyStatus =
|
|
72
|
-
dependency.dependsOnKind === "task"
|
|
73
|
-
? domain.getTaskOrThrow(dependency.dependsOnId).status
|
|
74
|
-
: domain.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
75
|
-
|
|
76
|
-
if (dependencyStatus !== "done") {
|
|
77
|
-
blockers.push({
|
|
78
|
-
id: dependency.dependsOnId,
|
|
79
|
-
kind: dependency.dependsOnKind,
|
|
80
|
-
status: dependencyStatus,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
71
|
+
const resolved = depMap.get(task.id) ?? { totalDependencies: 0, blockers: [] };
|
|
72
|
+
const blockers: DependencyBlocker[] = resolved.blockers;
|
|
85
73
|
const blockerCount = blockers.length;
|
|
86
74
|
const readinessReason: ReadyReason = blockerCount === 0 ? READY_REASON_READY : READY_REASON_BLOCKED;
|
|
87
75
|
return {
|
|
@@ -91,7 +79,7 @@ export function buildTaskReadiness(domain: TrackerDomain, epicId: string | undef
|
|
|
91
79
|
reason: readinessReason,
|
|
92
80
|
},
|
|
93
81
|
blockerSummary: {
|
|
94
|
-
totalDependencies:
|
|
82
|
+
totalDependencies: resolved.totalDependencies,
|
|
95
83
|
blockedByCount: blockerCount,
|
|
96
84
|
blockedBy: blockers,
|
|
97
85
|
},
|
package/src/commands/task.ts
CHANGED
|
@@ -46,7 +46,7 @@ const SHOW_OPTIONS = ["view", "all"] as const;
|
|
|
46
46
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
47
47
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
48
48
|
const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
|
|
49
|
-
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
|
|
49
|
+
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t", "owner"] as const;
|
|
50
50
|
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
51
51
|
|
|
52
52
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
@@ -1071,7 +1071,8 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1071
1071
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
1072
1072
|
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
1073
1073
|
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
1074
|
-
readMissingOptionValue(parsed.missingOptionValues, "status", "s")
|
|
1074
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
1075
|
+
readMissingOptionValue(parsed.missingOptionValues, "owner");
|
|
1075
1076
|
if (missingUpdateOption !== undefined) {
|
|
1076
1077
|
return failMissingOptionValue("task.update", missingUpdateOption);
|
|
1077
1078
|
}
|
|
@@ -1084,6 +1085,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1084
1085
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
1085
1086
|
const append: string | undefined = readOption(parsed.options, "append");
|
|
1086
1087
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
1088
|
+
const owner: string | undefined = readOption(parsed.options, "owner");
|
|
1087
1089
|
|
|
1088
1090
|
if (updateAll && ids.length > 0) {
|
|
1089
1091
|
return failResult({
|
|
@@ -1210,7 +1212,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1210
1212
|
append === undefined
|
|
1211
1213
|
? description
|
|
1212
1214
|
: appendLine(domain.getTaskOrThrow(taskId).description, append);
|
|
1213
|
-
const task = mutations.updateTask(taskId, { title, description: nextDescription, status });
|
|
1215
|
+
const task = mutations.updateTask(taskId, { title, description: nextDescription, status, owner });
|
|
1214
1216
|
|
|
1215
1217
|
return okResult({
|
|
1216
1218
|
command: "task.update",
|
|
@@ -1257,10 +1259,50 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1257
1259
|
});
|
|
1258
1260
|
}
|
|
1259
1261
|
|
|
1262
|
+
// Check for open subtasks (lenient: warn but allow completion)
|
|
1263
|
+
const openSubtasks = domain.getOpenSubtasks(taskId);
|
|
1264
|
+
const openSubtaskCount = openSubtasks.length;
|
|
1265
|
+
const openSubtaskIds = openSubtasks.map((s) => s.id);
|
|
1266
|
+
|
|
1267
|
+
// Snapshot blocked reverse deps before marking done (lightweight: no full readiness rebuild).
|
|
1268
|
+
// Only direct task-level reverse deps are tracked here; subtask reverse deps are excluded
|
|
1269
|
+
// because subtasks are children within a task, not independent workflow items.
|
|
1270
|
+
const reverseDeps = domain.listReverseDependencies(taskId);
|
|
1271
|
+
const directRevDepTaskIds = reverseDeps
|
|
1272
|
+
.filter((rd) => rd.isDirect && rd.kind === "task")
|
|
1273
|
+
.map((rd) => rd.id);
|
|
1274
|
+
const preDepStatuses = domain.batchResolveDependencyStatuses(directRevDepTaskIds);
|
|
1275
|
+
const preBlockedIds = new Set(
|
|
1276
|
+
directRevDepTaskIds.filter((id) => {
|
|
1277
|
+
const resolved = preDepStatuses.get(id);
|
|
1278
|
+
return resolved !== undefined && resolved.blockers.length > 0;
|
|
1279
|
+
}),
|
|
1280
|
+
);
|
|
1281
|
+
|
|
1282
|
+
// Auto-transition through in_progress when current status is todo or blocked.
|
|
1283
|
+
// Note: this emits two sync events (→in_progress, →done) because each
|
|
1284
|
+
// updateTask call appends its own event. This is intentional — the status
|
|
1285
|
+
// machine requires the intermediate step, and event consumers should treat
|
|
1286
|
+
// a rapid in_progress→done pair from `task done` as a single logical completion.
|
|
1287
|
+
if (existingTask.status === "todo" || existingTask.status === "blocked") {
|
|
1288
|
+
mutations.updateTask(taskId, { status: "in_progress" });
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1260
1291
|
const completed = mutations.updateTask(taskId, { status: "done" });
|
|
1261
1292
|
const readiness = buildTaskReadiness(domain, completed.epicId);
|
|
1262
1293
|
const nextCandidate = readiness.candidates[0] ?? null;
|
|
1263
1294
|
|
|
1295
|
+
// Diff: tasks that were blocked before but are now ready
|
|
1296
|
+
const unblockedTasks = readiness.candidates
|
|
1297
|
+
.filter((item) => preBlockedIds.has(item.task.id))
|
|
1298
|
+
.map((item) => ({
|
|
1299
|
+
id: item.task.id,
|
|
1300
|
+
kind: "task" as const,
|
|
1301
|
+
title: item.task.title,
|
|
1302
|
+
status: item.task.status,
|
|
1303
|
+
wasBlockedBy: [taskId],
|
|
1304
|
+
}));
|
|
1305
|
+
|
|
1264
1306
|
const nextTree = nextCandidate !== null ? domain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
|
|
1265
1307
|
const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
|
|
1266
1308
|
|
|
@@ -1269,7 +1311,17 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1269
1311
|
blockedCount: readiness.summary.blockedCount,
|
|
1270
1312
|
};
|
|
1271
1313
|
|
|
1314
|
+
const subtaskWarning = openSubtaskCount > 0
|
|
1315
|
+
? `Warning: ${openSubtaskCount} subtask(s) still open.`
|
|
1316
|
+
: null;
|
|
1317
|
+
|
|
1272
1318
|
let human = `Task ${completed.title} marked done.`;
|
|
1319
|
+
if (subtaskWarning !== null) {
|
|
1320
|
+
human += `\n${subtaskWarning}`;
|
|
1321
|
+
}
|
|
1322
|
+
if (unblockedTasks.length > 0) {
|
|
1323
|
+
human += `\nUnblocked: ${unblockedTasks.map((t) => t.title).join(", ")}`;
|
|
1324
|
+
}
|
|
1273
1325
|
if (nextTree !== null && nextCandidate !== null) {
|
|
1274
1326
|
human += `\nNext: ${formatTask(nextCandidate.task)}`;
|
|
1275
1327
|
}
|
|
@@ -1280,6 +1332,10 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1280
1332
|
human,
|
|
1281
1333
|
data: {
|
|
1282
1334
|
completed,
|
|
1335
|
+
openSubtaskCount,
|
|
1336
|
+
openSubtaskIds,
|
|
1337
|
+
warning: subtaskWarning,
|
|
1338
|
+
unblocked: unblockedTasks,
|
|
1283
1339
|
next: nextTree,
|
|
1284
1340
|
nextDeps,
|
|
1285
1341
|
readiness: readinessStats,
|