trekoon 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -47,6 +47,7 @@ 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
49
  const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t", "owner"] as const;
50
+ const CLAIM_OPTIONS = ["owner"] as const;
50
51
  const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
51
52
 
52
53
  function parseIdsOption(rawIds: string | undefined): string[] {
@@ -1179,10 +1180,9 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1179
1180
 
1180
1181
  const targets = updateAll ? [...domain.listTasks()] : ids.map((id) => domain.getTaskOrThrow(id));
1181
1182
  const tasks = targets.map((target) =>
1182
- mutations.updateTask(target.id, {
1183
- status,
1184
- description: append === undefined ? undefined : appendLine(target.description, append),
1185
- }),
1183
+ append !== undefined
1184
+ ? mutations.appendToTaskDescription({ taskId: target.id, append, status })
1185
+ : mutations.updateTask(target.id, { status }),
1186
1186
  );
1187
1187
 
1188
1188
  return okResult({
@@ -1208,11 +1208,10 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1208
1208
  });
1209
1209
  }
1210
1210
 
1211
- const nextDescription =
1212
- append === undefined
1213
- ? description
1214
- : appendLine(domain.getTaskOrThrow(taskId).description, append);
1215
- const task = mutations.updateTask(taskId, { title, description: nextDescription, status, owner });
1211
+ const task =
1212
+ append !== undefined
1213
+ ? mutations.appendToTaskDescription({ taskId, append, status, owner })
1214
+ : mutations.updateTask(taskId, { title, description, status, owner });
1216
1215
 
1217
1216
  return okResult({
1218
1217
  command: "task.update",
@@ -1247,98 +1246,148 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1247
1246
  });
1248
1247
  }
1249
1248
 
1250
- if (existingTask.status === "done") {
1251
- return failResult({
1252
- command: "task.done",
1253
- human: "Task is already done",
1254
- data: { code: "already_done", id: taskId },
1255
- error: {
1256
- code: "already_done",
1257
- message: "Task is already done",
1258
- },
1259
- });
1260
- }
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
-
1291
- const completed = mutations.updateTask(taskId, { status: "done" });
1292
- const readiness = buildTaskReadiness(domain, completed.epicId);
1293
- const nextCandidate = readiness.candidates[0] ?? null;
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
-
1306
- const nextTree = nextCandidate !== null ? domain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
1307
- const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
1308
-
1309
- const readinessStats = {
1310
- readyCount: readiness.summary.readyCount,
1311
- blockedCount: readiness.summary.blockedCount,
1312
- };
1249
+ // Note: the redundant `if (existingTask.status === "done")` pre-check
1250
+ // was removed in the cr-expert hardening pass — `markTaskDoneAtomically`
1251
+ // raises `already_done` itself inside the same transaction that would
1252
+ // perform the flip, eliminating a race window between this read and
1253
+ // the atomic write. The thrown DomainError propagates to the outer
1254
+ // catch and is rendered with the same `code: "already_done"` payload
1255
+ // by `unexpectedFailureResult`.
1256
+
1257
+ // Single-transaction atomic completion: the entire flow (status flip,
1258
+ // event emission, unblocked-diff snapshot) runs inside ONE
1259
+ // `BEGIN IMMEDIATE`/`COMMIT` pair. Bypasses the public transition
1260
+ // checker — this is the documented allowed exception (see
1261
+ // MutationService.markTaskDoneAtomically and docs/machine-contracts.md).
1262
+ // A crash mid-flight rolls the row back to its original status; the
1263
+ // task is never observable in a phantom `in_progress` state.
1264
+ const snapshot = mutations.markTaskDoneAtomically({
1265
+ taskId,
1266
+ computeSnapshot: ({ domain: txDomain, completed, preBlockedReverseDepIds }) => {
1267
+ const openSubtasks = txDomain.getOpenSubtasks(taskId);
1268
+ const readiness = buildTaskReadiness(txDomain, completed.epicId);
1269
+ const nextCandidate = readiness.candidates[0] ?? null;
1270
+
1271
+ const preBlockedIds = new Set<string>(preBlockedReverseDepIds);
1272
+ const unblockedTasks = readiness.candidates
1273
+ .filter((item) => preBlockedIds.has(item.task.id))
1274
+ .map((item) => ({
1275
+ id: item.task.id,
1276
+ kind: "task" as const,
1277
+ title: item.task.title,
1278
+ status: item.task.status,
1279
+ wasBlockedBy: [taskId],
1280
+ }));
1281
+
1282
+ const nextTree = nextCandidate !== null ? txDomain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
1283
+ const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
1284
+
1285
+ return {
1286
+ completed,
1287
+ openSubtaskCount: openSubtasks.length,
1288
+ openSubtaskIds: openSubtasks.map((s) => s.id),
1289
+ unblocked: unblockedTasks,
1290
+ next: nextTree,
1291
+ nextCandidate,
1292
+ nextDeps,
1293
+ readiness: {
1294
+ readyCount: readiness.summary.readyCount,
1295
+ blockedCount: readiness.summary.blockedCount,
1296
+ },
1297
+ };
1298
+ },
1299
+ });
1313
1300
 
1314
- const subtaskWarning = openSubtaskCount > 0
1315
- ? `Warning: ${openSubtaskCount} subtask(s) still open.`
1301
+ const subtaskWarning = snapshot.openSubtaskCount > 0
1302
+ ? `Warning: ${snapshot.openSubtaskCount} subtask(s) still open.`
1316
1303
  : null;
1317
1304
 
1318
- let human = `Task ${completed.title} marked done.`;
1305
+ let human = `Task ${snapshot.completed.title} marked done.`;
1319
1306
  if (subtaskWarning !== null) {
1320
1307
  human += `\n${subtaskWarning}`;
1321
1308
  }
1322
- if (unblockedTasks.length > 0) {
1323
- human += `\nUnblocked: ${unblockedTasks.map((t) => t.title).join(", ")}`;
1309
+ if (snapshot.unblocked.length > 0) {
1310
+ human += `\nUnblocked: ${snapshot.unblocked.map((t) => t.title).join(", ")}`;
1324
1311
  }
1325
- if (nextTree !== null && nextCandidate !== null) {
1326
- human += `\nNext: ${formatTask(nextCandidate.task)}`;
1312
+ if (snapshot.next !== null && snapshot.nextCandidate !== null) {
1313
+ human += `\nNext: ${formatTask(snapshot.nextCandidate.task)}`;
1327
1314
  }
1328
- human += `\nReadiness: ready=${readinessStats.readyCount}, blocked=${readinessStats.blockedCount}.`;
1315
+ human += `\nReadiness: ready=${snapshot.readiness.readyCount}, blocked=${snapshot.readiness.blockedCount}.`;
1329
1316
 
1330
1317
  return okResult({
1331
1318
  command: "task.done",
1332
1319
  human,
1333
1320
  data: {
1334
- completed,
1335
- openSubtaskCount,
1336
- openSubtaskIds,
1321
+ completed: snapshot.completed,
1322
+ openSubtaskCount: snapshot.openSubtaskCount,
1323
+ openSubtaskIds: snapshot.openSubtaskIds,
1337
1324
  warning: subtaskWarning,
1338
- unblocked: unblockedTasks,
1339
- next: nextTree,
1340
- nextDeps,
1341
- readiness: readinessStats,
1325
+ unblocked: snapshot.unblocked,
1326
+ next: snapshot.next,
1327
+ nextDeps: snapshot.nextDeps,
1328
+ readiness: snapshot.readiness,
1329
+ },
1330
+ });
1331
+ }
1332
+ case "claim": {
1333
+ const claimUnknownOption = findUnknownOption(parsed, CLAIM_OPTIONS);
1334
+ if (claimUnknownOption !== undefined) {
1335
+ return unknownOption("task.claim", claimUnknownOption, CLAIM_OPTIONS);
1336
+ }
1337
+
1338
+ const missingClaimOption = readMissingOptionValue(parsed.missingOptionValues, "owner");
1339
+ if (missingClaimOption !== undefined) {
1340
+ return failMissingOptionValue("task.claim", missingClaimOption);
1341
+ }
1342
+
1343
+ const taskId: string = parsed.positional[1] ?? "";
1344
+ if (taskId.length === 0) {
1345
+ return failResult({
1346
+ command: "task.claim",
1347
+ human: "Provide a task id. Usage: trekoon task claim <id> --owner <owner>",
1348
+ data: { code: "invalid_input" },
1349
+ error: {
1350
+ code: "invalid_input",
1351
+ message: "Missing task id",
1352
+ },
1353
+ });
1354
+ }
1355
+
1356
+ const owner: string | undefined = readOption(parsed.options, "owner");
1357
+ if (owner === undefined || owner.trim().length === 0) {
1358
+ return failResult({
1359
+ command: "task.claim",
1360
+ human: "--owner is required. Usage: trekoon task claim <id> --owner <owner>",
1361
+ data: { code: "invalid_input", option: "owner" },
1362
+ error: {
1363
+ code: "invalid_input",
1364
+ message: "Missing required option --owner",
1365
+ },
1366
+ });
1367
+ }
1368
+
1369
+ const claimResult = mutations.claimTask({ taskId, owner });
1370
+
1371
+ if (claimResult.claimed) {
1372
+ return okResult({
1373
+ command: "task.claim",
1374
+ human: `Claimed task ${taskId} for ${owner}`,
1375
+ data: {
1376
+ claimed: true,
1377
+ currentOwner: claimResult.currentOwner,
1378
+ currentStatus: claimResult.currentStatus,
1379
+ task: claimResult.task,
1380
+ },
1381
+ });
1382
+ }
1383
+
1384
+ return okResult({
1385
+ command: "task.claim",
1386
+ human: `Task ${taskId} not claimed: status=${claimResult.currentStatus}, owner=${claimResult.currentOwner ?? "none"}`,
1387
+ data: {
1388
+ claimed: false,
1389
+ currentOwner: claimResult.currentOwner,
1390
+ currentStatus: claimResult.currentStatus,
1342
1391
  },
1343
1392
  });
1344
1393
  }
@@ -1355,7 +1404,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1355
1404
  default:
1356
1405
  return failResult({
1357
1406
  command: "task",
1358
- human: "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete>",
1407
+ human: "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim>",
1359
1408
  data: {
1360
1409
  args: context.args,
1361
1410
  },
@@ -0,0 +1,329 @@
1
+ import {
2
+ type CompactDependencySpec,
3
+ type CompactEntityRef,
4
+ type CompactSubtaskSpec,
5
+ type DependencyRecord,
6
+ DomainError,
7
+ type SubtaskRecord,
8
+ type TaskRecord,
9
+ } from "./types";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Shared interfaces
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface ResolvedDependencyBatchSpec {
16
+ readonly index: number;
17
+ readonly sourceId: string;
18
+ readonly sourceKind: "task" | "subtask";
19
+ readonly dependsOnId: string;
20
+ readonly dependsOnKind: "task" | "subtask";
21
+ }
22
+
23
+ export interface DependencyBatchValidationIssue {
24
+ readonly index: number;
25
+ readonly type: "missing_id" | "duplicate" | "cycle";
26
+ readonly sourceId: string;
27
+ readonly dependsOnId: string;
28
+ readonly details: Record<string, unknown>;
29
+ }
30
+
31
+ export interface DependencyBatchResolution {
32
+ readonly spec?: ResolvedDependencyBatchSpec;
33
+ readonly issues: readonly DependencyBatchValidationIssue[];
34
+ }
35
+
36
+ export interface ResolvedCompactEntity {
37
+ readonly id: string;
38
+ readonly kind: "task" | "subtask";
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Reader interface — the only DB-touching surface the callers must supply
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface BatchValidationReader {
46
+ getTask(id: string): TaskRecord | null;
47
+ getSubtask(id: string): SubtaskRecord | null;
48
+ getDependencyByEdge(sourceId: string, dependsOnId: string): DependencyRecord | null;
49
+ buildDependencyAdjacency(): Map<string, Set<string>>;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function assertNonEmptyLocal(field: string, value: string | undefined | null): string {
57
+ const normalized: string = (value ?? "").trim();
58
+ if (!normalized) {
59
+ throw new DomainError({
60
+ code: "invalid_input",
61
+ message: `${field} must be a non-empty string`,
62
+ details: { field },
63
+ });
64
+ }
65
+ return normalized;
66
+ }
67
+
68
+ function resolveNodeKindLocal(id: string, reader: BatchValidationReader): "task" | "subtask" {
69
+ if (reader.getTask(id)) return "task";
70
+ if (reader.getSubtask(id)) return "subtask";
71
+ throw new DomainError({
72
+ code: "not_found",
73
+ message: `node not found: ${id}`,
74
+ details: { id, expectedKinds: ["task", "subtask"] },
75
+ });
76
+ }
77
+
78
+ function resolveDependencyBatchId(
79
+ reference: CompactEntityRef,
80
+ field: "source" | "dependsOn",
81
+ index: number,
82
+ reader: BatchValidationReader,
83
+ ): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
84
+ if (reference.kind === "temp_key") {
85
+ return {
86
+ issues: [
87
+ {
88
+ index,
89
+ type: "missing_id",
90
+ sourceId: field === "source" ? `@${reference.tempKey}` : "",
91
+ dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
92
+ details: {
93
+ field,
94
+ tempKey: reference.tempKey,
95
+ message: `Unresolved temp key @${reference.tempKey}`,
96
+ },
97
+ },
98
+ ],
99
+ };
100
+ }
101
+
102
+ const id = assertNonEmptyLocal(field === "source" ? "sourceId" : "dependsOnId", reference.id);
103
+ const task = reader.getTask(id);
104
+ const subtask = reader.getSubtask(id);
105
+ if (!task && !subtask) {
106
+ return {
107
+ issues: [
108
+ {
109
+ index,
110
+ type: "missing_id",
111
+ sourceId: field === "source" ? id : "",
112
+ dependsOnId: field === "dependsOn" ? id : "",
113
+ details: {
114
+ field,
115
+ id,
116
+ message: `Node not found: ${id}`,
117
+ },
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ return { id, issues: [] };
124
+ }
125
+
126
+ function wouldCreateCycleInAdjacency(
127
+ adjacency: ReadonlyMap<string, ReadonlySet<string>>,
128
+ sourceId: string,
129
+ dependsOnId: string,
130
+ ): boolean {
131
+ const visited = new Set<string>();
132
+ const queue: string[] = [dependsOnId];
133
+
134
+ while (queue.length > 0) {
135
+ const current = queue.shift();
136
+ if (current === undefined || visited.has(current)) continue;
137
+ if (current === sourceId) return true;
138
+ visited.add(current);
139
+ const neighbors = adjacency.get(current);
140
+ if (neighbors === undefined) continue;
141
+ for (const neighbor of neighbors) {
142
+ if (!visited.has(neighbor)) queue.push(neighbor);
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ function resolveEpicExpandEntityRef(
150
+ reference: CompactEntityRef,
151
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
152
+ option: "subtask" | "dep",
153
+ index: number,
154
+ field: "parent" | "source" | "dependsOn",
155
+ reader: BatchValidationReader,
156
+ ): ResolvedCompactEntity {
157
+ if (reference.kind === "temp_key") {
158
+ const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
159
+ if (mapping === undefined) {
160
+ throw new DomainError({
161
+ code: "invalid_input",
162
+ message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
163
+ details: { index, field, tempKey: reference.tempKey, option },
164
+ });
165
+ }
166
+ return { id: mapping.id, kind: mapping.kind };
167
+ }
168
+
169
+ const id = assertNonEmptyLocal(field === "parent" ? "taskId" : `${field}Id`, reference.id);
170
+ return { id, kind: resolveNodeKindLocal(id, reader) };
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Exported pure functions
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export function resolveDependencyBatchSpec(
178
+ index: number,
179
+ spec: CompactDependencySpec,
180
+ reader: BatchValidationReader,
181
+ ): DependencyBatchResolution {
182
+ const sourceResolution = resolveDependencyBatchId(spec.source, "source", index, reader);
183
+ const dependsOnResolution = resolveDependencyBatchId(spec.dependsOn, "dependsOn", index, reader);
184
+ const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
185
+ const sourceId = sourceResolution.id;
186
+ const dependsOnId = dependsOnResolution.id;
187
+
188
+ if (sourceId === undefined || dependsOnId === undefined) {
189
+ return { issues };
190
+ }
191
+
192
+ if (sourceId === dependsOnId) {
193
+ return {
194
+ issues: [
195
+ ...issues,
196
+ {
197
+ index,
198
+ type: "cycle",
199
+ sourceId,
200
+ dependsOnId,
201
+ details: { sourceId, dependsOnId, reason: "self_reference" },
202
+ },
203
+ ],
204
+ };
205
+ }
206
+
207
+ return {
208
+ spec: {
209
+ index,
210
+ sourceId,
211
+ sourceKind: resolveNodeKindLocal(sourceId, reader),
212
+ dependsOnId,
213
+ dependsOnKind: resolveNodeKindLocal(dependsOnId, reader),
214
+ },
215
+ issues,
216
+ };
217
+ }
218
+
219
+ export function resolveEpicExpandSubtaskSpecs(
220
+ specs: readonly CompactSubtaskSpec[],
221
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
222
+ reader: BatchValidationReader,
223
+ ): CompactSubtaskSpec[] {
224
+ return specs.map((spec, index) => {
225
+ const parent = resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent", reader);
226
+ if (parent.kind !== "task") {
227
+ throw new DomainError({
228
+ code: "invalid_input",
229
+ message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
230
+ details: { index, field: "parent", kind: parent.kind, id: parent.id },
231
+ });
232
+ }
233
+ return { ...spec, parent: { kind: "id", id: parent.id } };
234
+ });
235
+ }
236
+
237
+ export function resolveEpicExpandDependencySpecs(
238
+ specs: readonly CompactDependencySpec[],
239
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
240
+ reader: BatchValidationReader,
241
+ ): CompactDependencySpec[] {
242
+ return specs.map((spec, index) => ({
243
+ source: {
244
+ kind: "id",
245
+ id: resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source", reader).id,
246
+ },
247
+ dependsOn: {
248
+ kind: "id",
249
+ id: resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn", reader).id,
250
+ },
251
+ }));
252
+ }
253
+
254
+ export function collectDependencyBatchIssues(
255
+ specs: readonly ResolvedDependencyBatchSpec[],
256
+ reader: BatchValidationReader,
257
+ ): DependencyBatchValidationIssue[] {
258
+ const issues: DependencyBatchValidationIssue[] = [];
259
+ const seenEdges = new Map<string, number>();
260
+ const adjacency = reader.buildDependencyAdjacency();
261
+
262
+ for (const spec of specs) {
263
+ const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
264
+ const existingIndex = seenEdges.get(edgeKey);
265
+ if (existingIndex !== undefined) {
266
+ issues.push({
267
+ index: spec.index,
268
+ type: "duplicate",
269
+ sourceId: spec.sourceId,
270
+ dependsOnId: spec.dependsOnId,
271
+ details: {
272
+ sourceId: spec.sourceId,
273
+ dependsOnId: spec.dependsOnId,
274
+ firstIndex: existingIndex,
275
+ duplicateIndex: spec.index,
276
+ duplicateKind: "batch",
277
+ },
278
+ });
279
+ continue;
280
+ }
281
+
282
+ if (reader.getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
283
+ issues.push({
284
+ index: spec.index,
285
+ type: "duplicate",
286
+ sourceId: spec.sourceId,
287
+ dependsOnId: spec.dependsOnId,
288
+ details: {
289
+ sourceId: spec.sourceId,
290
+ dependsOnId: spec.dependsOnId,
291
+ duplicateKind: "existing",
292
+ },
293
+ });
294
+ continue;
295
+ }
296
+
297
+
298
+
299
+ if (wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
300
+ issues.push({
301
+ index: spec.index,
302
+ type: "cycle",
303
+ sourceId: spec.sourceId,
304
+ dependsOnId: spec.dependsOnId,
305
+ details: { sourceId: spec.sourceId, dependsOnId: spec.dependsOnId },
306
+ });
307
+ continue;
308
+ }
309
+
310
+ const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
311
+ nextNeighbors.add(spec.dependsOnId);
312
+ adjacency.set(spec.sourceId, nextNeighbors);
313
+ seenEdges.set(edgeKey, spec.index);
314
+ }
315
+
316
+ return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
317
+ }
318
+
319
+ export function buildDependencyAdjacency(
320
+ rows: ReadonlyArray<{ source_id: string; depends_on_id: string }>,
321
+ ): Map<string, Set<string>> {
322
+ const adjacency = new Map<string, Set<string>>();
323
+ for (const row of rows) {
324
+ const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
325
+ neighbors.add(row.depends_on_id);
326
+ adjacency.set(row.source_id, neighbors);
327
+ }
328
+ return adjacency;
329
+ }