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.
@@ -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,
@@ -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 update <task-id> --status in_progress",
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>|<status>",
125
- ` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (${"@"}<temp-key> for new parents)`,
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>|<status>",
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>|<status>",
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 update <task-id> --status in_progress",
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 update <task-id> --status in_progress",
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
  ],
@@ -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
 
@@ -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,
@@ -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 update ${topReady.task.id} --status in_progress`,
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
  });
@@ -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
- const subtaskDeleteEventId = this.#emitSubtaskDeleted(input.id);
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
- cascade?: { sourceEventId: string } | undefined,
1547
+ options?: { epicId?: string | undefined; sourceEventId?: string | undefined } | undefined,
1484
1548
  ): string {
1485
- const fields: Record<string, unknown> = cascade ? { source_event_id: cascade.sourceEventId } : {};
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
- cascade?: { taskId: string; sourceEventId: string } | undefined,
1580
+ options?: { taskId?: string | undefined; sourceEventId?: string | undefined } | undefined,
1511
1581
  ): string {
1512
- const fields: Record<string, unknown> = cascade
1513
- ? { task_id: cascade.taskId, source_event_id: cascade.sourceEventId }
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 newIds: string[] = [];
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
- const id: string = randomUUID();
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
- "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
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(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, batchNow, batchNow);
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 ? {} : { metadata: createContractMetadata(result, compatibilityMode) }),
147
+ ...(!compact ? { metadata: createContractMetadata(result, compatibilityMode) } : {}),
148
148
  ...(result.error ? { error: result.error } : {}),
149
149
  ...(result.meta ? { meta: result.meta } : {}),
150
150
  };