trekoon 0.4.5 → 0.4.7
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 +37 -36
- package/.agents/skills/trekoon/reference/execution.md +73 -69
- package/.agents/skills/trekoon/reference/harness-primitives.md +37 -0
- package/.agents/skills/trekoon/reference/planning.md +66 -72
- package/README.md +28 -0
- package/docs/commands.md +38 -6
- package/docs/machine-contracts.md +1 -0
- package/docs/quickstart.md +6 -2
- 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 +6 -6
- package/src/commands/help.ts +20 -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 -170
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,
|
|
@@ -776,7 +776,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
776
776
|
|
|
777
777
|
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
778
778
|
if (duplicateTempKey !== null) {
|
|
779
|
-
return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
779
|
+
return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs. Temp keys share one flat namespace per epic create — prefix subtask temp keys with the parent task key (e.g. sub-<task-key>-tests) to disambiguate.`, {
|
|
780
780
|
tempKey: duplicateTempKey,
|
|
781
781
|
});
|
|
782
782
|
}
|
|
@@ -849,7 +849,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
849
849
|
|
|
850
850
|
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
851
851
|
if (duplicateTempKey !== null) {
|
|
852
|
-
return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
852
|
+
return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs. Temp keys share one flat namespace per epic expand — prefix subtask temp keys with the parent task key (e.g. sub-<task-key>-tests) to disambiguate.`, {
|
|
853
853
|
tempKey: duplicateTempKey,
|
|
854
854
|
});
|
|
855
855
|
}
|
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,9 +122,13 @@ 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)`,
|
|
129
|
+
" Temp keys in --task and --subtask share one namespace per command. Prefix subtask keys with parent task key.",
|
|
126
130
|
` --dep <source-ref>|<depends-on-ref> (refs can be IDs or ${"@"}<temp-key>)`,
|
|
131
|
+
" Escape literal | inside field values (\\|); bare | is a field separator and will silently corrupt records.",
|
|
127
132
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
128
133
|
"",
|
|
129
134
|
"List:",
|
|
@@ -182,7 +187,8 @@ const TASK_HELP = [
|
|
|
182
187
|
"",
|
|
183
188
|
"Create-many:",
|
|
184
189
|
" trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
|
|
185
|
-
" --task <temp-key>|<title>|<description
|
|
190
|
+
" --task <temp-key>|<title>|<description> (status defaults to todo)",
|
|
191
|
+
" --task <temp-key>|<title>|<description>|<status> (explicit status)",
|
|
186
192
|
" Multiple --task flags are applied in order.",
|
|
187
193
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
188
194
|
"",
|
|
@@ -239,7 +245,8 @@ const SUBTASK_HELP = [
|
|
|
239
245
|
"",
|
|
240
246
|
"Create-many:",
|
|
241
247
|
" trekoon subtask create-many [<task-id>] [--task <task-id>] --subtask <spec> [--subtask <spec> ...]",
|
|
242
|
-
" --subtask <temp-key>|<title>|<description
|
|
248
|
+
" --subtask <temp-key>|<title>|<description> (status defaults to todo)",
|
|
249
|
+
" --subtask <temp-key>|<title>|<description>|<status> (explicit status)",
|
|
243
250
|
" Positional <task-id> and --task can be combined only when equal.",
|
|
244
251
|
" Multiple --subtask flags are applied in order.",
|
|
245
252
|
" Escapes in compact specs: \\| for |, \\\\ for \\, \\n, \\r, \\t",
|
|
@@ -328,6 +335,8 @@ const MIGRATE_HELP = [
|
|
|
328
335
|
"",
|
|
329
336
|
"Notes:",
|
|
330
337
|
" Migrations 0004, 0005, and 0006 are irreversible (ALTER TABLE / data cleanup).",
|
|
338
|
+
" Migration 0012 drops only its new indexes on rollback; the deduplicated dependency",
|
|
339
|
+
" edges it removed cannot be restored without a backup.",
|
|
331
340
|
" Rolling back below those versions errors with code migration_down_unsupported.",
|
|
332
341
|
" Take a backup first; restore by copying the backup over .trekoon/trekoon.db.",
|
|
333
342
|
"",
|
|
@@ -391,7 +400,7 @@ const SYNC_HELP = [
|
|
|
391
400
|
].join("\n");
|
|
392
401
|
|
|
393
402
|
const SESSION_HELP = [
|
|
394
|
-
"Usage: trekoon session [--epic <epic-id>] [--json|--toon]",
|
|
403
|
+
"Usage: trekoon session [--epic <epic-id>] [--item <id>] [--json|--toon]",
|
|
395
404
|
"",
|
|
396
405
|
"One-call agent orientation. Opens the DB once and returns:",
|
|
397
406
|
" - diagnostics: storageMode, recoveryRequired, recoveryStatus",
|
|
@@ -402,6 +411,10 @@ const SESSION_HELP = [
|
|
|
402
411
|
"",
|
|
403
412
|
"Options:",
|
|
404
413
|
" --epic <epic-id> Scope readiness to a specific epic.",
|
|
414
|
+
" --item <id> Resolve any epic/task/subtask id in one call. Returns",
|
|
415
|
+
" item: { kind, parentEpicId, entity, readiness, suggestedNext }.",
|
|
416
|
+
" Replaces the legacy epic-show || task-show || subtask-show cascade.",
|
|
417
|
+
" Mutually exclusive with --epic; passing both yields invalid_input.",
|
|
405
418
|
"",
|
|
406
419
|
"Output modes:",
|
|
407
420
|
" human Multi-section summary (default in TTY)",
|
|
@@ -412,6 +425,7 @@ const SESSION_HELP = [
|
|
|
412
425
|
" trekoon session",
|
|
413
426
|
" trekoon --toon session",
|
|
414
427
|
" trekoon --toon session --epic <epic-id>",
|
|
428
|
+
" trekoon --toon session --item <id>",
|
|
415
429
|
" trekoon --json session",
|
|
416
430
|
].join("\n");
|
|
417
431
|
|
|
@@ -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
|
|