trekoon 0.4.4 → 0.4.6
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 +41 -35
- package/.agents/skills/trekoon/reference/execution.md +75 -60
- package/.agents/skills/trekoon/reference/harness-primitives.md +41 -1
- package/.agents/skills/trekoon/reference/planning.md +37 -67
- package/README.md +28 -0
- package/docs/ai-agents.md +8 -0
- package/docs/commands.md +19 -6
- package/docs/machine-contracts.md +1 -0
- package/docs/quickstart.md +4 -0
- package/package.json +1 -1
- package/src/board/assets/state/actions.js +32 -10
- package/src/board/assets/state/api.js +234 -35
- package/src/board/assets/state/utils.js +18 -0
- package/src/board/routes.ts +27 -14
- package/src/board/snapshot.ts +9 -19
- package/src/board/wal-watcher.ts +637 -74
- package/src/commands/epic.ts +4 -4
- package/src/commands/help.ts +18 -6
- package/src/commands/quickstart.ts +5 -2
- package/src/commands/session.ts +161 -1
- package/src/commands/subtask.ts +2 -2
- package/src/commands/suggest.ts +1 -1
- package/src/commands/task.ts +2 -2
- package/src/domain/mutation-service.ts +83 -9
- package/src/domain/tracker-domain.ts +109 -6
- package/src/io/output.ts +1 -1
- package/src/storage/database.ts +67 -2
- package/src/storage/migrations.ts +149 -2
- package/src/storage/schema.ts +6 -1
- package/src/sync/event-writes.ts +24 -2
- package/.agents/skills/trekoon/reference/execution-with-team.md +0 -161
package/src/commands/epic.ts
CHANGED
|
@@ -462,10 +462,10 @@ function parseExpandTaskSpecs(rawSpecs: readonly string[]): { specs: CompactTask
|
|
|
462
462
|
};
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
if (parsed.fields.length !== 4) {
|
|
465
|
+
if (parsed.fields.length !== 3 && parsed.fields.length !== 4) {
|
|
466
466
|
return {
|
|
467
467
|
specs: [],
|
|
468
|
-
error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
468
|
+
error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description> or <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
469
469
|
option: "task",
|
|
470
470
|
index,
|
|
471
471
|
rawSpec,
|
|
@@ -558,10 +558,10 @@ function parseExpandSubtaskSpecs(rawSpecs: readonly string[]): { specs: CompactS
|
|
|
558
558
|
};
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
if (parsed.fields.length !== 5) {
|
|
561
|
+
if (parsed.fields.length !== 4 && parsed.fields.length !== 5) {
|
|
562
562
|
return {
|
|
563
563
|
specs: [],
|
|
564
|
-
error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
564
|
+
error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description> or <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
565
565
|
option: "subtask",
|
|
566
566
|
index,
|
|
567
567
|
rawSpec,
|
package/src/commands/help.ts
CHANGED
|
@@ -59,7 +59,8 @@ const QUICKSTART_HELP = [
|
|
|
59
59
|
" 1. trekoon --toon session",
|
|
60
60
|
" 2. Stop if diagnostics report recoveryRequired or a tracked/ignored mismatch",
|
|
61
61
|
" 3. If behind: trekoon --toon sync pull --from main",
|
|
62
|
-
" 4. Claim work: trekoon --toon task
|
|
62
|
+
" 4. Claim work: trekoon --toon task claim <task-id> --owner <TODO_OWNER>",
|
|
63
|
+
" (atomic CAS; safe for parallel agents. Check data.claimed.)",
|
|
63
64
|
" 5. Finish: trekoon --toon task done <task-id>",
|
|
64
65
|
"",
|
|
65
66
|
"Manual bootstrap (if you need step-by-step):",
|
|
@@ -121,8 +122,10 @@ const EPIC_HELP = [
|
|
|
121
122
|
"",
|
|
122
123
|
"Expand:",
|
|
123
124
|
" trekoon --toon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
|
|
124
|
-
" --task <temp-key>|<title>|<description
|
|
125
|
-
|
|
125
|
+
" --task <temp-key>|<title>|<description> (status defaults to todo)",
|
|
126
|
+
" --task <temp-key>|<title>|<description>|<status> (explicit status)",
|
|
127
|
+
` --subtask <parent-ref>|<temp-key>|<title>|<description> (status defaults to todo) (${"@"}<temp-key> for new parents)`,
|
|
128
|
+
` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (explicit status)`,
|
|
126
129
|
` --dep <source-ref>|<depends-on-ref> (refs can be IDs or ${"@"}<temp-key>)`,
|
|
127
130
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
128
131
|
"",
|
|
@@ -182,7 +185,8 @@ const TASK_HELP = [
|
|
|
182
185
|
"",
|
|
183
186
|
"Create-many:",
|
|
184
187
|
" trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
|
|
185
|
-
" --task <temp-key>|<title>|<description
|
|
188
|
+
" --task <temp-key>|<title>|<description> (status defaults to todo)",
|
|
189
|
+
" --task <temp-key>|<title>|<description>|<status> (explicit status)",
|
|
186
190
|
" Multiple --task flags are applied in order.",
|
|
187
191
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
188
192
|
"",
|
|
@@ -239,7 +243,8 @@ const SUBTASK_HELP = [
|
|
|
239
243
|
"",
|
|
240
244
|
"Create-many:",
|
|
241
245
|
" trekoon subtask create-many [<task-id>] [--task <task-id>] --subtask <spec> [--subtask <spec> ...]",
|
|
242
|
-
" --subtask <temp-key>|<title>|<description
|
|
246
|
+
" --subtask <temp-key>|<title>|<description> (status defaults to todo)",
|
|
247
|
+
" --subtask <temp-key>|<title>|<description>|<status> (explicit status)",
|
|
243
248
|
" Positional <task-id> and --task can be combined only when equal.",
|
|
244
249
|
" Multiple --subtask flags are applied in order.",
|
|
245
250
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
@@ -328,6 +333,8 @@ const MIGRATE_HELP = [
|
|
|
328
333
|
"",
|
|
329
334
|
"Notes:",
|
|
330
335
|
" Migrations 0004, 0005, and 0006 are irreversible (ALTER TABLE / data cleanup).",
|
|
336
|
+
" Migration 0012 drops only its new indexes on rollback; the deduplicated dependency",
|
|
337
|
+
" edges it removed cannot be restored without a backup.",
|
|
331
338
|
" Rolling back below those versions errors with code migration_down_unsupported.",
|
|
332
339
|
" Take a backup first; restore by copying the backup over .trekoon/trekoon.db.",
|
|
333
340
|
"",
|
|
@@ -391,7 +398,7 @@ const SYNC_HELP = [
|
|
|
391
398
|
].join("\n");
|
|
392
399
|
|
|
393
400
|
const SESSION_HELP = [
|
|
394
|
-
"Usage: trekoon session [--epic <epic-id>] [--json|--toon]",
|
|
401
|
+
"Usage: trekoon session [--epic <epic-id>] [--item <id>] [--json|--toon]",
|
|
395
402
|
"",
|
|
396
403
|
"One-call agent orientation. Opens the DB once and returns:",
|
|
397
404
|
" - diagnostics: storageMode, recoveryRequired, recoveryStatus",
|
|
@@ -402,6 +409,10 @@ const SESSION_HELP = [
|
|
|
402
409
|
"",
|
|
403
410
|
"Options:",
|
|
404
411
|
" --epic <epic-id> Scope readiness to a specific epic.",
|
|
412
|
+
" --item <id> Resolve any epic/task/subtask id in one call. Returns",
|
|
413
|
+
" item: { kind, parentEpicId, entity, readiness, suggestedNext }.",
|
|
414
|
+
" Replaces the legacy epic-show || task-show || subtask-show cascade.",
|
|
415
|
+
" Mutually exclusive with --epic; passing both yields invalid_input.",
|
|
405
416
|
"",
|
|
406
417
|
"Output modes:",
|
|
407
418
|
" human Multi-section summary (default in TTY)",
|
|
@@ -412,6 +423,7 @@ const SESSION_HELP = [
|
|
|
412
423
|
" trekoon session",
|
|
413
424
|
" trekoon --toon session",
|
|
414
425
|
" trekoon --toon session --epic <epic-id>",
|
|
426
|
+
" trekoon --toon session --item <id>",
|
|
415
427
|
" trekoon --json session",
|
|
416
428
|
].join("\n");
|
|
417
429
|
|
|
@@ -38,11 +38,14 @@ const QUICKSTART_TEXT = [
|
|
|
38
38
|
"",
|
|
39
39
|
"3) Execution loop",
|
|
40
40
|
" 1. Start session: trekoon --toon session [--epic <epic-id>]",
|
|
41
|
-
" 2. Claim work: trekoon --toon task
|
|
41
|
+
" 2. Claim work: trekoon --toon task claim <task-id> --owner <TODO_OWNER>",
|
|
42
|
+
" (atomic CAS; safe for parallel agents. Check data.claimed.)",
|
|
42
43
|
" 3. Log progress: trekoon --toon task update <task-id> --append \"Done with implementation\"",
|
|
43
44
|
" 4. Finish: trekoon --toon task done <task-id>",
|
|
44
45
|
" Returns the next ready task, its deps, and full payload.",
|
|
45
46
|
" 5. Or report block: trekoon --toon task update <task-id> --append \"Blocked: <reason>\" --status blocked",
|
|
47
|
+
" Non-claim path (mark progress without taking ownership):",
|
|
48
|
+
" trekoon --toon task update <task-id> --status in_progress",
|
|
46
49
|
"",
|
|
47
50
|
"4) Orientation and suggestions",
|
|
48
51
|
" Next-action suggestions: trekoon --toon suggest [--epic <epic-id>]",
|
|
@@ -130,7 +133,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
130
133
|
],
|
|
131
134
|
executionLoop: [
|
|
132
135
|
"trekoon --toon session",
|
|
133
|
-
"trekoon --toon task
|
|
136
|
+
"trekoon --toon task claim <task-id> --owner <TODO_OWNER>",
|
|
134
137
|
"trekoon --toon task update <task-id> --append \"Completed implementation\"",
|
|
135
138
|
"trekoon --toon task done <task-id>",
|
|
136
139
|
],
|
package/src/commands/session.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
-
import { okResult } from "../io/output";
|
|
7
|
+
import { failResult, 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
10
|
import { type GitContextSnapshot } from "../sync/types";
|
|
@@ -46,6 +46,23 @@ interface SessionResult {
|
|
|
46
46
|
readonly readiness: SessionReadiness;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
type ItemKind = "epic" | "task" | "subtask";
|
|
50
|
+
|
|
51
|
+
interface ItemEnvelope {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly kind: ItemKind;
|
|
54
|
+
readonly parentEpicId: string;
|
|
55
|
+
readonly entity: unknown;
|
|
56
|
+
readonly readiness: SessionReadiness;
|
|
57
|
+
readonly suggestedNext: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ItemSessionResult {
|
|
61
|
+
readonly diagnostics: SessionResult["diagnostics"];
|
|
62
|
+
readonly sync: SessionResult["sync"];
|
|
63
|
+
readonly item: ItemEnvelope;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
|
|
50
67
|
function formatSessionHuman(result: SessionResult): string {
|
|
51
68
|
const lines: string[] = [];
|
|
@@ -98,18 +115,161 @@ function formatSessionHuman(result: SessionResult): string {
|
|
|
98
115
|
return lines.join("\n");
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
function formatItemHuman(result: ItemSessionResult): string {
|
|
119
|
+
const lines: string[] = [];
|
|
120
|
+
|
|
121
|
+
lines.push("=== Session ===");
|
|
122
|
+
lines.push(`Storage mode: ${result.diagnostics.storageMode}`);
|
|
123
|
+
lines.push(`Recovery required: ${result.diagnostics.recoveryRequired}`);
|
|
124
|
+
lines.push(`Recovery status: ${result.diagnostics.recoveryStatus}`);
|
|
125
|
+
|
|
126
|
+
lines.push("");
|
|
127
|
+
lines.push("=== Sync ===");
|
|
128
|
+
lines.push(`Source branch: ${DEFAULT_SOURCE_BRANCH}`);
|
|
129
|
+
lines.push(`Ahead: ${result.sync.ahead}`);
|
|
130
|
+
lines.push(`Behind: ${result.sync.behind}`);
|
|
131
|
+
lines.push(`Pending conflicts: ${result.sync.pendingConflicts}`);
|
|
132
|
+
lines.push(`Branch: ${result.sync.git.branchName ?? "(detached)"}`);
|
|
133
|
+
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push("=== Item ===");
|
|
136
|
+
lines.push(`${result.item.id} | kind=${result.item.kind} | epic=${result.item.parentEpicId}`);
|
|
137
|
+
|
|
138
|
+
lines.push("");
|
|
139
|
+
lines.push("=== Readiness (epic-scoped) ===");
|
|
140
|
+
lines.push(`Ready: ${result.item.readiness.readyCount}`);
|
|
141
|
+
lines.push(`Blocked: ${result.item.readiness.blockedCount}`);
|
|
142
|
+
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push("=== Suggested Next ===");
|
|
145
|
+
lines.push(result.item.suggestedNext);
|
|
146
|
+
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveItem(
|
|
151
|
+
domain: TrackerDomain,
|
|
152
|
+
id: string,
|
|
153
|
+
): { kind: ItemKind; parentEpicId: string; entity: unknown } | null {
|
|
154
|
+
const epic = domain.getEpic(id);
|
|
155
|
+
if (epic !== null) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "epic",
|
|
158
|
+
parentEpicId: epic.id,
|
|
159
|
+
entity: domain.buildEpicTreeDetailed(epic.id),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const task = domain.getTask(id);
|
|
164
|
+
if (task !== null) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "task",
|
|
167
|
+
parentEpicId: task.epicId,
|
|
168
|
+
entity: domain.buildTaskTreeDetailed(task.id),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const subtask = domain.getSubtask(id);
|
|
173
|
+
if (subtask !== null) {
|
|
174
|
+
const parentTask = domain.getTask(subtask.taskId);
|
|
175
|
+
const parentEpicId = parentTask?.epicId ?? "";
|
|
176
|
+
return {
|
|
177
|
+
kind: "subtask",
|
|
178
|
+
parentEpicId,
|
|
179
|
+
entity: subtask,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function suggestNextCommand(kind: ItemKind, id: string, parentEpicId: string): string {
|
|
187
|
+
switch (kind) {
|
|
188
|
+
case "epic":
|
|
189
|
+
return `trekoon --toon epic progress ${id}`;
|
|
190
|
+
case "task":
|
|
191
|
+
return `trekoon --toon task claim ${id} --owner <TODO_OWNER>`;
|
|
192
|
+
case "subtask":
|
|
193
|
+
return `trekoon --toon session --epic ${parentEpicId}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
101
197
|
export async function runSession(context: CliContext): Promise<CliResult> {
|
|
102
198
|
let database: TrekoonDatabase | undefined;
|
|
103
199
|
|
|
104
200
|
try {
|
|
105
201
|
const parsed = parseArgs(context.args);
|
|
106
202
|
const epicId: string | undefined = readOption(parsed.options, "epic");
|
|
203
|
+
const itemId: string | undefined = readOption(parsed.options, "item");
|
|
204
|
+
|
|
205
|
+
if (epicId !== undefined && itemId !== undefined) {
|
|
206
|
+
return failResult({
|
|
207
|
+
command: "session",
|
|
208
|
+
human: "--epic and --item are mutually exclusive. Pass --epic to scope readiness by epic, or --item to resolve a specific entity.",
|
|
209
|
+
data: { code: "invalid_input", providedFlags: ["--epic", "--item"] },
|
|
210
|
+
error: {
|
|
211
|
+
code: "invalid_input",
|
|
212
|
+
message: "--epic and --item are mutually exclusive. Pass --epic to scope readiness by epic, or --item to resolve a specific entity.",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
107
216
|
|
|
108
217
|
database = openTrekoonDatabase(context.cwd);
|
|
109
218
|
const diagnostics = database.diagnostics;
|
|
110
219
|
|
|
111
220
|
const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
|
|
112
221
|
const domain = new TrackerDomain(database.db);
|
|
222
|
+
|
|
223
|
+
if (itemId !== undefined) {
|
|
224
|
+
const resolved = resolveItem(domain, itemId);
|
|
225
|
+
if (resolved === null) {
|
|
226
|
+
return failResult({
|
|
227
|
+
command: "session",
|
|
228
|
+
human: `No epic, task, or subtask matches id ${itemId}`,
|
|
229
|
+
data: { code: "not_found", id: itemId },
|
|
230
|
+
error: {
|
|
231
|
+
code: "not_found",
|
|
232
|
+
message: `No epic, task, or subtask matches id ${itemId}`,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const scopedReadiness = resolved.parentEpicId.length > 0
|
|
238
|
+
? buildTaskReadiness(domain, resolved.parentEpicId)
|
|
239
|
+
: buildTaskReadiness(domain, undefined);
|
|
240
|
+
|
|
241
|
+
const result: ItemSessionResult = {
|
|
242
|
+
diagnostics: {
|
|
243
|
+
storageMode: diagnostics.storageMode,
|
|
244
|
+
recoveryRequired: diagnostics.recoveryRequired,
|
|
245
|
+
recoveryStatus: diagnostics.recoveryStatus,
|
|
246
|
+
},
|
|
247
|
+
sync: {
|
|
248
|
+
ahead: syncSummary.ahead,
|
|
249
|
+
behind: syncSummary.behind,
|
|
250
|
+
pendingConflicts: syncSummary.pendingConflicts,
|
|
251
|
+
git: syncSummary.git,
|
|
252
|
+
},
|
|
253
|
+
item: {
|
|
254
|
+
id: itemId,
|
|
255
|
+
kind: resolved.kind,
|
|
256
|
+
parentEpicId: resolved.parentEpicId,
|
|
257
|
+
entity: resolved.entity,
|
|
258
|
+
readiness: {
|
|
259
|
+
readyCount: scopedReadiness.summary.readyCount,
|
|
260
|
+
blockedCount: scopedReadiness.summary.blockedCount,
|
|
261
|
+
},
|
|
262
|
+
suggestedNext: suggestNextCommand(resolved.kind, itemId, resolved.parentEpicId),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return okResult({
|
|
267
|
+
command: "session",
|
|
268
|
+
human: formatItemHuman(result),
|
|
269
|
+
data: result,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
113
273
|
const readiness = buildTaskReadiness(domain, epicId);
|
|
114
274
|
const topCandidate = readiness.candidates[0] ?? null;
|
|
115
275
|
|
package/src/commands/subtask.ts
CHANGED
|
@@ -286,10 +286,10 @@ function parseSubtaskCreateManySpecs(parentTaskId: string, rawSpecs: readonly st
|
|
|
286
286
|
};
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
if (parsed.fields.length !== 4) {
|
|
289
|
+
if (parsed.fields.length !== 3 && parsed.fields.length !== 4) {
|
|
290
290
|
return {
|
|
291
291
|
specs: [],
|
|
292
|
-
error: failBatchSpec("subtask.create-many", `Subtask specs must use <temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
292
|
+
error: failBatchSpec("subtask.create-many", `Subtask specs must use <temp-key>|<title>|<description> or <temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
293
293
|
option: "subtask",
|
|
294
294
|
index,
|
|
295
295
|
rawSpec,
|
package/src/commands/suggest.ts
CHANGED
|
@@ -125,7 +125,7 @@ function buildSuggestions(
|
|
|
125
125
|
suggestions.push({
|
|
126
126
|
priority: suggestions.length + 1,
|
|
127
127
|
action: `claim task ${topReady.task.id}`,
|
|
128
|
-
command: `trekoon --toon task
|
|
128
|
+
command: `trekoon --toon task claim ${topReady.task.id} --owner <TODO_OWNER>`,
|
|
129
129
|
reason: `Highest priority ready task: ${topReady.task.title}`,
|
|
130
130
|
category: "execution",
|
|
131
131
|
});
|
package/src/commands/task.ts
CHANGED
|
@@ -375,10 +375,10 @@ function parseTaskCreateManySpecs(rawSpecs: readonly string[]): { specs: Compact
|
|
|
375
375
|
};
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
if (parsed.fields.length !== 4) {
|
|
378
|
+
if (parsed.fields.length !== 3 && parsed.fields.length !== 4) {
|
|
379
379
|
return {
|
|
380
380
|
specs: [],
|
|
381
|
-
error: failBatchSpec("task.create-many", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
381
|
+
error: failBatchSpec("task.create-many", `Task specs must use <temp-key>|<title>|<description> or <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
382
382
|
option: "task",
|
|
383
383
|
index,
|
|
384
384
|
rawSpec,
|
|
@@ -434,7 +434,32 @@ export class MutationService {
|
|
|
434
434
|
? this.#domain.listSubtasksByTaskIds(taskIds)
|
|
435
435
|
: new Map<string, readonly SubtaskRecord[]>();
|
|
436
436
|
|
|
437
|
+
// Collect every dependency touching the cascaded tasks/subtasks BEFORE
|
|
438
|
+
// the epic delete fires. The dependencies table has no FK to
|
|
439
|
+
// tasks/subtasks so SQLite ON DELETE CASCADE leaves these rows
|
|
440
|
+
// orphaned — we must clean them up here AND emit canonical
|
|
441
|
+
// `dependency.removed` events so the WAL watcher's event-cursor path
|
|
442
|
+
// can surface deletedDependencyIds to peer boards without falling
|
|
443
|
+
// back to a full-snapshot rebuild.
|
|
444
|
+
const cascadeNodeIds: string[] = [];
|
|
445
|
+
for (const task of tasks) {
|
|
446
|
+
cascadeNodeIds.push(task.id);
|
|
447
|
+
const subtasks = subtasksByTaskId.get(task.id) ?? [];
|
|
448
|
+
for (const subtask of subtasks) {
|
|
449
|
+
cascadeNodeIds.push(subtask.id);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const cascadedDependencies = cascadeNodeIds.length > 0
|
|
453
|
+
? this.#domain.listDependenciesTouchingNodes(cascadeNodeIds)
|
|
454
|
+
: [];
|
|
455
|
+
|
|
437
456
|
this.#domain.deleteEpic(id);
|
|
457
|
+
// Explicitly delete the dependency rows that touched the cascaded
|
|
458
|
+
// nodes. domain.deleteEpic relies on SQLite CASCADE for tasks/subtasks
|
|
459
|
+
// but does not (and cannot) reach the dependencies table.
|
|
460
|
+
if (cascadedDependencies.length > 0) {
|
|
461
|
+
this.#domain.removeDependenciesByIds(cascadedDependencies.map((dependency) => dependency.id));
|
|
462
|
+
}
|
|
438
463
|
|
|
439
464
|
const epicDeleteEventId = this.#emitEpicDeleted(id);
|
|
440
465
|
|
|
@@ -450,6 +475,26 @@ export class MutationService {
|
|
|
450
475
|
this.#emitSubtaskDeleted(subtask.id, { taskId: task.id, sourceEventId: taskDeleteEventId });
|
|
451
476
|
}
|
|
452
477
|
}
|
|
478
|
+
|
|
479
|
+
// Emit dependency.removed for each cascaded dep. Stamp them with the
|
|
480
|
+
// epic-delete event id so peer worktrees can suppress the per-dep
|
|
481
|
+
// conflict the same way cascaded task.deleted events are suppressed
|
|
482
|
+
// behind a pending epic-level conflict.
|
|
483
|
+
for (const dependency of cascadedDependencies) {
|
|
484
|
+
this.#appendEntityEvent(
|
|
485
|
+
"dependency",
|
|
486
|
+
this.#dependencyEventEntityId(dependency),
|
|
487
|
+
ENTITY_OPERATIONS.dependency.removed,
|
|
488
|
+
this.#dependencyEventFields({
|
|
489
|
+
dependencyId: dependency.id,
|
|
490
|
+
sourceId: dependency.sourceId,
|
|
491
|
+
sourceKind: dependency.sourceKind,
|
|
492
|
+
dependsOnId: dependency.dependsOnId,
|
|
493
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
494
|
+
sourceEventId: epicDeleteEventId,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
453
498
|
});
|
|
454
499
|
}
|
|
455
500
|
|
|
@@ -955,8 +1000,15 @@ export class MutationService {
|
|
|
955
1000
|
deleteTask(id: string): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } {
|
|
956
1001
|
return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
|
|
957
1002
|
const plan = this.#domain.planTaskDeletion(id);
|
|
1003
|
+
// Capture the parent epic id BEFORE delete so the canonical task.deleted
|
|
1004
|
+
// event can fan-in to the parent epic in the WAL watcher event-cursor
|
|
1005
|
+
// path. Without this the watcher cannot reconstruct the parent epic
|
|
1006
|
+
// change (taskIds / counts / searchText) from the event stream alone
|
|
1007
|
+
// for non-cascade deletes.
|
|
1008
|
+
const existing = this.#domain.getTaskOrThrow(id);
|
|
1009
|
+
const parentEpicId = existing.epicId;
|
|
958
1010
|
this.#domain.deleteTask(id);
|
|
959
|
-
const taskDeleteEventId = this.#emitTaskDeleted(id);
|
|
1011
|
+
const taskDeleteEventId = this.#emitTaskDeleted(id, { epicId: parentEpicId });
|
|
960
1012
|
|
|
961
1013
|
for (const subtaskId of plan.subtaskIds) {
|
|
962
1014
|
this.#emitSubtaskDeleted(subtaskId, { taskId: id, sourceEventId: taskDeleteEventId });
|
|
@@ -1104,8 +1156,16 @@ export class MutationService {
|
|
|
1104
1156
|
deleteSubtask(id: string): { deletedDependencyIds: string[] } {
|
|
1105
1157
|
return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
|
|
1106
1158
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
|
|
1159
|
+
// Capture the parent task id BEFORE delete so the canonical
|
|
1160
|
+
// subtask.deleted event can fan-in to the parent task in the WAL
|
|
1161
|
+
// watcher event-cursor path. Without this the watcher cannot
|
|
1162
|
+
// reconstruct the parent task change (subtasks list / searchText /
|
|
1163
|
+
// counts) from the event stream — domain.getSubtask(id) returns null
|
|
1164
|
+
// post-delete.
|
|
1165
|
+
const existing = this.#domain.getSubtaskOrThrow(id);
|
|
1166
|
+
const parentTaskId = existing.taskId;
|
|
1107
1167
|
this.#domain.deleteSubtask(id);
|
|
1108
|
-
const subtaskDeleteEventId = this.#emitSubtaskDeleted(id);
|
|
1168
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(id, { taskId: parentTaskId });
|
|
1109
1169
|
for (const dependency of touchingDependencies) {
|
|
1110
1170
|
this.#appendEntityEvent(
|
|
1111
1171
|
"dependency",
|
|
@@ -1143,7 +1203,11 @@ export class MutationService {
|
|
|
1143
1203
|
const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
|
|
1144
1204
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
|
|
1145
1205
|
this.#domain.deleteSubtask(input.id);
|
|
1146
|
-
|
|
1206
|
+
// Emit task_id on the canonical subtask.deleted event so the WAL
|
|
1207
|
+
// watcher's event-cursor path can fan-in the parent task without a
|
|
1208
|
+
// post-delete domain lookup (which returns null). Same field shape as
|
|
1209
|
+
// the cascade path's emitter call, minus source_event_id.
|
|
1210
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(input.id, { taskId: existingSubtask.taskId });
|
|
1147
1211
|
for (const dependency of touchingDependencies) {
|
|
1148
1212
|
this.#appendEntityEvent(
|
|
1149
1213
|
"dependency",
|
|
@@ -1480,9 +1544,15 @@ export class MutationService {
|
|
|
1480
1544
|
|
|
1481
1545
|
#emitTaskDeleted(
|
|
1482
1546
|
taskId: string,
|
|
1483
|
-
|
|
1547
|
+
options?: { epicId?: string | undefined; sourceEventId?: string | undefined } | undefined,
|
|
1484
1548
|
): string {
|
|
1485
|
-
const fields: Record<string, unknown> =
|
|
1549
|
+
const fields: Record<string, unknown> = {};
|
|
1550
|
+
if (options?.epicId) {
|
|
1551
|
+
fields.epic_id = options.epicId;
|
|
1552
|
+
}
|
|
1553
|
+
if (options?.sourceEventId) {
|
|
1554
|
+
fields.source_event_id = options.sourceEventId;
|
|
1555
|
+
}
|
|
1486
1556
|
return this.#appendEntityEvent("task", taskId, ENTITY_OPERATIONS.task.deleted, fields);
|
|
1487
1557
|
}
|
|
1488
1558
|
|
|
@@ -1507,11 +1577,15 @@ export class MutationService {
|
|
|
1507
1577
|
|
|
1508
1578
|
#emitSubtaskDeleted(
|
|
1509
1579
|
subtaskId: string,
|
|
1510
|
-
|
|
1580
|
+
options?: { taskId?: string | undefined; sourceEventId?: string | undefined } | undefined,
|
|
1511
1581
|
): string {
|
|
1512
|
-
const fields: Record<string, unknown> =
|
|
1513
|
-
|
|
1514
|
-
|
|
1582
|
+
const fields: Record<string, unknown> = {};
|
|
1583
|
+
if (options?.taskId) {
|
|
1584
|
+
fields.task_id = options.taskId;
|
|
1585
|
+
}
|
|
1586
|
+
if (options?.sourceEventId) {
|
|
1587
|
+
fields.source_event_id = options.sourceEventId;
|
|
1588
|
+
}
|
|
1515
1589
|
return this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, fields);
|
|
1516
1590
|
}
|
|
1517
1591
|
|
|
@@ -826,6 +826,44 @@ export class TrackerDomain {
|
|
|
826
826
|
return rows.map(mapDependency);
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Multi-node variant of {@link listDependenciesTouchingNode}. Returns every
|
|
831
|
+
* dependency row whose `source_id` OR `depends_on_id` is in the supplied
|
|
832
|
+
* set, deduped and ordered by (created_at, id).
|
|
833
|
+
*
|
|
834
|
+
* Used by epic-cascade delete to gather touching dependencies for the
|
|
835
|
+
* union of an epic's tasks and subtasks in a single chunked query rather
|
|
836
|
+
* than N per-node calls. Mirrors `planTaskDeletion`'s SQL shape.
|
|
837
|
+
*
|
|
838
|
+
* Callers do NOT need to pre-validate node existence — orphaned dep rows
|
|
839
|
+
* are surfaced as-is so the caller can clean them up. An empty input
|
|
840
|
+
* returns an empty array without touching the database.
|
|
841
|
+
*/
|
|
842
|
+
listDependenciesTouchingNodes(nodeIds: readonly string[]): readonly DependencyRecord[] {
|
|
843
|
+
if (nodeIds.length === 0) {
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
const normalizedIds: string[] = nodeIds.map((nodeId) => assertNonEmpty("nodeId", nodeId));
|
|
847
|
+
const dependencyRows: DependencyRow[] = [];
|
|
848
|
+
const nodeIdChunks = chunkValues(normalizedIds, Math.floor(SQLITE_MAX_VARIABLES / 2));
|
|
849
|
+
for (const nodeIdChunk of nodeIdChunks) {
|
|
850
|
+
const placeholders = nodeIdChunk.map(() => "?").join(", ");
|
|
851
|
+
const rows = this.#db
|
|
852
|
+
.query(
|
|
853
|
+
`SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at
|
|
854
|
+
FROM dependencies
|
|
855
|
+
WHERE source_id IN (${placeholders}) OR depends_on_id IN (${placeholders})
|
|
856
|
+
ORDER BY created_at ASC, id ASC;`,
|
|
857
|
+
)
|
|
858
|
+
.all(...nodeIdChunk, ...nodeIdChunk) as DependencyRow[];
|
|
859
|
+
dependencyRows.push(...rows);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return [...new Map(dependencyRows.map((row) => [row.id, row])).values()]
|
|
863
|
+
.sort((left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id))
|
|
864
|
+
.map(mapDependency);
|
|
865
|
+
}
|
|
866
|
+
|
|
829
867
|
planTaskDeletion(taskId: string): TaskDeletionPlan {
|
|
830
868
|
const normalizedTaskId: string = assertNonEmpty("taskId", taskId);
|
|
831
869
|
this.getTaskOrThrow(normalizedTaskId);
|
|
@@ -1146,7 +1184,13 @@ export class TrackerDomain {
|
|
|
1146
1184
|
}
|
|
1147
1185
|
|
|
1148
1186
|
const dependencies: DependencyRecord[] = [];
|
|
1149
|
-
const
|
|
1187
|
+
const newRows: Array<{
|
|
1188
|
+
id: string;
|
|
1189
|
+
sourceId: string;
|
|
1190
|
+
sourceKind: string;
|
|
1191
|
+
dependsOnId: string;
|
|
1192
|
+
dependsOnKind: string;
|
|
1193
|
+
}> = [];
|
|
1150
1194
|
const batchNow: number = Date.now();
|
|
1151
1195
|
|
|
1152
1196
|
for (const spec of resolvedSpecs) {
|
|
@@ -1157,15 +1201,38 @@ export class TrackerDomain {
|
|
|
1157
1201
|
continue;
|
|
1158
1202
|
}
|
|
1159
1203
|
|
|
1160
|
-
|
|
1204
|
+
newRows.push({
|
|
1205
|
+
id: randomUUID(),
|
|
1206
|
+
sourceId: spec.sourceId,
|
|
1207
|
+
sourceKind: spec.sourceKind,
|
|
1208
|
+
dependsOnId: spec.dependsOnId,
|
|
1209
|
+
dependsOnKind: spec.dependsOnKind,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1161
1212
|
|
|
1213
|
+
// Chunked multi-row INSERT. Each row binds 7 parameters (the trailing
|
|
1214
|
+
// `version` literal stays in the VALUES tuple to mirror the createTaskBatch
|
|
1215
|
+
// shape). Cap the chunk size by the global SQLite bound-parameter limit so
|
|
1216
|
+
// a 200-edge batch produces ~ceil(200/142) = 2 statements rather than 200.
|
|
1217
|
+
// Per-edge canonical `dependency.added` events are still emitted upstream
|
|
1218
|
+
// (mutation-service) — this only collapses the SQL writes, never the
|
|
1219
|
+
// event-row contract.
|
|
1220
|
+
const DEP_COLS_PER_ROW = 7; // id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at (version is literal 1)
|
|
1221
|
+
const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / DEP_COLS_PER_ROW);
|
|
1222
|
+
const newIds: string[] = newRows.map((row) => row.id);
|
|
1223
|
+
|
|
1224
|
+
for (let offset = 0; offset < newRows.length; offset += WRITE_CHUNK_SIZE) {
|
|
1225
|
+
const chunk = newRows.slice(offset, offset + WRITE_CHUNK_SIZE);
|
|
1226
|
+
const placeholders: string = chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, 1)").join(", ");
|
|
1227
|
+
const params: Array<string | number> = [];
|
|
1228
|
+
for (const row of chunk) {
|
|
1229
|
+
params.push(row.id, row.sourceId, row.sourceKind, row.dependsOnId, row.dependsOnKind, batchNow, batchNow);
|
|
1230
|
+
}
|
|
1162
1231
|
this.#db
|
|
1163
1232
|
.query(
|
|
1164
|
-
|
|
1233
|
+
`INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES ${placeholders};`,
|
|
1165
1234
|
)
|
|
1166
|
-
.run(
|
|
1167
|
-
|
|
1168
|
-
newIds.push(id);
|
|
1235
|
+
.run(...params);
|
|
1169
1236
|
}
|
|
1170
1237
|
|
|
1171
1238
|
// Batch-fetch all newly inserted dependencies instead of one getDependencyOrThrow per row.
|
|
@@ -1204,6 +1271,42 @@ export class TrackerDomain {
|
|
|
1204
1271
|
return result.changes;
|
|
1205
1272
|
}
|
|
1206
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* Bulk delete dependency rows by primary key, chunked to respect
|
|
1276
|
+
* {@link SQLITE_MAX_VARIABLES}. Used by the epic-cascade deletion path:
|
|
1277
|
+
* SQLite ON DELETE CASCADE covers tasks/subtasks but NOT the dependencies
|
|
1278
|
+
* table (no FK exists) — without this, dep rows touching cascaded
|
|
1279
|
+
* tasks/subtasks are left orphaned after `deleteEpic`.
|
|
1280
|
+
*
|
|
1281
|
+
* Returns the total number of rows actually deleted across all chunks.
|
|
1282
|
+
* Empty input is a no-op and returns 0 without touching the database.
|
|
1283
|
+
*/
|
|
1284
|
+
removeDependenciesByIds(ids: readonly string[]): number {
|
|
1285
|
+
if (ids.length === 0) {
|
|
1286
|
+
return 0;
|
|
1287
|
+
}
|
|
1288
|
+
this.#assertInTransaction("removeDependenciesByIds");
|
|
1289
|
+
const normalized: string[] = ids.map((id) => assertNonEmpty("id", id));
|
|
1290
|
+
let totalChanges = 0;
|
|
1291
|
+
for (const chunk of chunkValues(normalized, SQLITE_MAX_VARIABLES)) {
|
|
1292
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
1293
|
+
const result = this.#db
|
|
1294
|
+
.query(`DELETE FROM dependencies WHERE id IN (${placeholders});`)
|
|
1295
|
+
.run(...chunk);
|
|
1296
|
+
totalChanges += result.changes;
|
|
1297
|
+
}
|
|
1298
|
+
return totalChanges;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
getDependency(id: string): DependencyRecord | null {
|
|
1302
|
+
const row = this.#db
|
|
1303
|
+
.query(
|
|
1304
|
+
"SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE id = ?;",
|
|
1305
|
+
)
|
|
1306
|
+
.get(id) as DependencyRow | null;
|
|
1307
|
+
return row ? mapDependency(row) : null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1207
1310
|
listDependencies(sourceId: string): readonly DependencyRecord[] {
|
|
1208
1311
|
const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
|
|
1209
1312
|
this.resolveNodeKind(normalizedSourceId);
|
package/src/io/output.ts
CHANGED
|
@@ -144,7 +144,7 @@ export function toToonEnvelope(result: CliResult, options: RenderOptions = {}):
|
|
|
144
144
|
ok: result.ok,
|
|
145
145
|
command,
|
|
146
146
|
data: result.data,
|
|
147
|
-
...(compact ? {
|
|
147
|
+
...(!compact ? { metadata: createContractMetadata(result, compatibilityMode) } : {}),
|
|
148
148
|
...(result.error ? { error: result.error } : {}),
|
|
149
149
|
...(result.meta ? { meta: result.meta } : {}),
|
|
150
150
|
};
|