trekoon 0.2.6 → 0.2.8

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 (40) hide show
  1. package/README.md +102 -708
  2. package/docs/ai-agents.md +198 -0
  3. package/docs/commands.md +226 -0
  4. package/docs/machine-contracts.md +253 -0
  5. package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
  6. package/docs/quickstart.md +207 -0
  7. package/package.json +3 -1
  8. package/src/board/assets/app.js +1498 -0
  9. package/src/board/assets/components/AppShell.js +17 -0
  10. package/src/board/assets/components/BoardTopbar.js +78 -0
  11. package/src/board/assets/components/ClampedText.js +31 -0
  12. package/src/board/assets/components/EpicRow.js +62 -0
  13. package/src/board/assets/components/EpicsOverview.js +43 -0
  14. package/src/board/assets/components/WorkspaceHeader.js +70 -0
  15. package/src/board/assets/components/assetMap.js +65 -0
  16. package/src/board/assets/index.html +76 -0
  17. package/src/board/assets/main.js +27 -0
  18. package/src/board/assets/manifest.json +12 -0
  19. package/src/board/assets/state/actions.js +334 -0
  20. package/src/board/assets/state/api.js +126 -0
  21. package/src/board/assets/state/store.js +172 -0
  22. package/src/board/assets/styles/board.css +1127 -0
  23. package/src/board/assets/utils/dom.js +308 -0
  24. package/src/board/install.ts +196 -0
  25. package/src/board/open-browser.ts +131 -0
  26. package/src/board/routes.ts +299 -0
  27. package/src/board/server.ts +184 -0
  28. package/src/board/snapshot.ts +277 -0
  29. package/src/board/types.ts +43 -0
  30. package/src/commands/board.ts +158 -0
  31. package/src/commands/epic.ts +104 -3
  32. package/src/commands/help.ts +52 -13
  33. package/src/commands/init.ts +29 -0
  34. package/src/commands/subtask.ts +78 -1
  35. package/src/commands/task.ts +113 -7
  36. package/src/domain/mutation-service.ts +116 -0
  37. package/src/domain/tracker-domain.ts +261 -5
  38. package/src/domain/types.ts +51 -0
  39. package/src/runtime/cli-shell.ts +5 -0
  40. package/src/storage/path.ts +36 -0
@@ -21,6 +21,7 @@ const ROOT_HELP = [
21
21
  " init Initialize repo-shared .trekoon storage and DB",
22
22
  " quickstart Show shared-storage bootstrap + AI execution loop",
23
23
  " wipe Remove repo-shared Trekoon state (requires --yes)",
24
+ " board Local board asset and browser commands",
24
25
  " epic Epic lifecycle commands",
25
26
  " task Task lifecycle commands",
26
27
  " subtask Subtask lifecycle commands",
@@ -83,6 +84,25 @@ const WIPE_HELP = [
83
84
  " trekoon wipe --yes",
84
85
  ].join("\n");
85
86
 
87
+ const BOARD_HELP = [
88
+ "Usage: trekoon board <open|update>",
89
+ "",
90
+ "Subcommands:",
91
+ " open",
92
+ " Ensure board assets are installed, start a 127.0.0.1 board server,",
93
+ " and launch the browser. Machine output includes server URL, fallback URL,",
94
+ " and launch metadata.",
95
+ " update",
96
+ " Refresh board runtime assets only. Does not start the server or open a browser.",
97
+ "",
98
+ "Environment overrides:",
99
+ " TREKOON_BOARD_ASSET_ROOT Optional asset source override for tests and local development.",
100
+ "",
101
+ "Examples:",
102
+ " trekoon board open",
103
+ " trekoon --json board update",
104
+ ].join("\n");
105
+
86
106
  const EPIC_HELP = [
87
107
  "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete> [options]",
88
108
  "",
@@ -131,14 +151,20 @@ const EPIC_HELP = [
131
151
  " - --preview and --apply are mutually exclusive",
132
152
  "",
133
153
  "Update behavior:",
134
- " Bulk target flags:",
135
- " --all | --ids <csv>",
136
- " Bulk fields:",
137
- " --append <text> and/or --status <status>",
154
+ " Repo-wide bulk mode:",
155
+ " trekoon epic update --all --append <text> [--status <status>]",
156
+ " trekoon epic update --ids <csv> --append <text> [--status <status>]",
157
+ " - preserves existing per-row bulk behavior; not one atomic multi-row update",
158
+ " Descendant cascade mode:",
159
+ " trekoon epic update <epic-id> --all --status done|todo",
160
+ " - cascades atomically through descendant tasks and subtasks",
161
+ " - blocked descendants abort the whole update",
162
+ " - cascade mode supports only --status done|todo",
163
+ " - do not combine positional id + --all with --ids, --append, --description, or --title",
138
164
  ].join("\n");
139
165
 
140
166
  const TASK_HELP = [
141
- "Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete> [options]",
167
+ "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete> [options]",
142
168
  "",
143
169
  "Create-many behavior:",
144
170
  " trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
@@ -188,10 +214,16 @@ const TASK_HELP = [
188
214
  " - --preview and --apply are mutually exclusive",
189
215
  "",
190
216
  "Update behavior:",
191
- " Bulk target flags:",
192
- " --all | --ids <csv>",
193
- " Bulk fields:",
194
- " --append <text> and/or --status <status>",
217
+ " Repo-wide bulk mode:",
218
+ " trekoon task update --all --append <text> [--status <status>]",
219
+ " trekoon task update --ids <csv> --append <text> [--status <status>]",
220
+ " - preserves existing per-row bulk behavior; not one atomic multi-row update",
221
+ " Descendant cascade mode:",
222
+ " trekoon task update <task-id> --all --status done|todo",
223
+ " - cascades atomically through descendant subtasks",
224
+ " - blocked descendants abort the whole update",
225
+ " - cascade mode supports only --status done|todo",
226
+ " - do not combine positional id + --all with --ids, --append, --description, or --title",
195
227
  ].join("\n");
196
228
 
197
229
  const SUBTASK_HELP = [
@@ -228,10 +260,16 @@ const SUBTASK_HELP = [
228
260
  " - --preview and --apply are mutually exclusive",
229
261
  "",
230
262
  "Update behavior:",
231
- " Bulk target flags:",
232
- " --all | --ids <csv>",
233
- " Bulk fields:",
234
- " --append <text> and/or --status <status>",
263
+ " Repo-wide bulk mode:",
264
+ " trekoon subtask update --all --append <text> [--status <status>]",
265
+ " trekoon subtask update --ids <csv> --append <text> [--status <status>]",
266
+ " - preserves existing per-row bulk behavior; not one atomic multi-row update",
267
+ " Positional-id cascade syntax:",
268
+ " trekoon subtask update <subtask-id> --all --status done|todo",
269
+ " - accepted for contract consistency",
270
+ " - behaves like a normal single-subtask status update",
271
+ " - positional id + --all supports only --status done|todo",
272
+ " - do not combine positional id + --all with --ids, --append, --description, or --title",
235
273
  ].join("\n");
236
274
 
237
275
  const DEP_HELP = [
@@ -376,6 +414,7 @@ const SKILLS_HELP = [
376
414
 
377
415
  const COMMAND_HELP: Record<string, string> = {
378
416
  init: INIT_HELP,
417
+ board: BOARD_HELP,
379
418
  quickstart: QUICKSTART_HELP,
380
419
  session: SESSION_HELP,
381
420
  wipe: WIPE_HELP,
@@ -1,5 +1,7 @@
1
1
  import { unexpectedFailureResult } from "./error-utils";
2
2
 
3
+ import { ensureBoardInstalled } from "../board/install";
4
+ import { BoardInstallError } from "../board/types";
3
5
  import { DomainError } from "../domain/types";
4
6
  import { failResult, okResult } from "../io/output";
5
7
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -80,6 +82,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
80
82
  try {
81
83
  database = openTrekoonDatabase(context.cwd);
82
84
  const diagnostics = database.diagnostics;
85
+ const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
86
+ const board = ensureBoardInstalled({
87
+ workingDirectory: context.cwd,
88
+ ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
89
+ });
83
90
  const humanLines: string[] = [
84
91
  "Trekoon initialized.",
85
92
  `Storage mode: ${diagnostics.storageMode}`,
@@ -87,6 +94,8 @@ export async function runInit(context: CliContext): Promise<CliResult> {
87
94
  `Shared storage root: ${diagnostics.sharedStorageRoot}`,
88
95
  `Storage directory: ${database.paths.storageDir}`,
89
96
  `Database file: ${database.paths.databaseFile}`,
97
+ `Board assets: ${board.action}`,
98
+ `Board runtime root: ${board.paths.runtimeRoot}`,
90
99
  ...buildRecoverySummary(database),
91
100
  ];
92
101
 
@@ -101,6 +110,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
101
110
  sharedStorageRoot: diagnostics.sharedStorageRoot,
102
111
  storageDir: database.paths.storageDir,
103
112
  databaseFile: database.paths.databaseFile,
113
+ board: {
114
+ action: board.action,
115
+ paths: board.paths,
116
+ manifest: board.manifest,
117
+ },
104
118
  legacyStateDetected: diagnostics.legacyStateDetected,
105
119
  recoveryRequired: diagnostics.recoveryRequired,
106
120
  recoveryStatus: diagnostics.recoveryStatus,
@@ -120,6 +134,21 @@ export async function runInit(context: CliContext): Promise<CliResult> {
120
134
  }
121
135
  }
122
136
 
137
+ if (error instanceof BoardInstallError) {
138
+ return failResult({
139
+ command: "init",
140
+ human: error.message,
141
+ data: {
142
+ code: error.code,
143
+ ...error.details,
144
+ },
145
+ error: {
146
+ code: error.code,
147
+ message: error.message,
148
+ },
149
+ });
150
+ }
151
+
123
152
  return unexpectedFailureResult(error, {
124
153
  command: "init",
125
154
  human: "Unexpected init command failure",
@@ -33,9 +33,13 @@ function formatSubtask(subtask: SubtaskRecord): string {
33
33
  const VIEW_MODES = ["table", "compact"] as const;
34
34
  const DEFAULT_SUBTASK_LIST_LIMIT = 10;
35
35
  const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
36
+ const CREATE_OPTIONS = ["task", "t", "title", "description", "d", "status", "s"] as const;
37
+ const LIST_OPTIONS = ["task", "t", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
36
38
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
37
39
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
38
40
  const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
41
+ const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title"] as const;
42
+ const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
39
43
 
40
44
  function parseIdsOption(rawIds: string | undefined): string[] {
41
45
  if (rawIds === undefined) {
@@ -168,6 +172,25 @@ function appendLine(existing: string, line: string): string {
168
172
  return existing.length > 0 ? `${existing}\n${line}` : line;
169
173
  }
170
174
 
175
+ function isStatusCascadeUpdateStatus(status: string | undefined): status is (typeof STATUS_CASCADE_UPDATE_STATUSES)[number] {
176
+ return status === "done" || status === "todo";
177
+ }
178
+
179
+ function failCascadeStatusUpdate(command: string, entityLabel: string, data: Record<string, unknown>): CliResult {
180
+ return failResult({
181
+ command,
182
+ human: `${entityLabel} cascade mode requires --status done or --status todo and does not support --append, --description, or --title.`,
183
+ data: {
184
+ code: "invalid_input",
185
+ ...data,
186
+ },
187
+ error: {
188
+ code: "invalid_input",
189
+ message: `${entityLabel} cascade mode requires status-only done/todo input`,
190
+ },
191
+ });
192
+ }
193
+
171
194
  function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
172
195
  return formatHumanTable(
173
196
  ["ID", "TASK", "TITLE", "STATUS"],
@@ -359,6 +382,16 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
359
382
 
360
383
  switch (subcommand) {
361
384
  case "create": {
385
+ const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
386
+ if (createUnknownOption !== undefined) {
387
+ return unknownOption("subtask.create", createUnknownOption, CREATE_OPTIONS);
388
+ }
389
+
390
+ const unexpectedCreatePositionals = readUnexpectedPositionals(parsed, 3);
391
+ if (unexpectedCreatePositionals.length > 0) {
392
+ return failUnexpectedPositionals("subtask.create", unexpectedCreatePositionals);
393
+ }
394
+
362
395
  const missingCreateOption =
363
396
  readMissingOptionValue(parsed.missingOptionValues, "task", "t") ??
364
397
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
@@ -447,6 +480,16 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
447
480
  });
448
481
  }
449
482
  case "list": {
483
+ const listUnknownOption = findUnknownOption(parsed, LIST_OPTIONS);
484
+ if (listUnknownOption !== undefined) {
485
+ return unknownOption("subtask.list", listUnknownOption, LIST_OPTIONS);
486
+ }
487
+
488
+ const unexpectedListPositionals = readUnexpectedPositionals(parsed, 2);
489
+ if (unexpectedListPositionals.length > 0) {
490
+ return failUnexpectedPositionals("subtask.list", unexpectedListPositionals);
491
+ }
492
+
450
493
  const missingListOption =
451
494
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
452
495
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
@@ -731,6 +774,16 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
731
774
  });
732
775
  }
733
776
  case "update": {
777
+ const updateUnknownOption = findUnknownOption(parsed, UPDATE_OPTIONS);
778
+ if (updateUnknownOption !== undefined) {
779
+ return unknownOption("subtask.update", updateUnknownOption, UPDATE_OPTIONS);
780
+ }
781
+
782
+ const unexpectedUpdatePositionals = readUnexpectedPositionals(parsed, 2);
783
+ if (unexpectedUpdatePositionals.length > 0) {
784
+ return failUnexpectedPositionals("subtask.update", unexpectedUpdatePositionals);
785
+ }
786
+
734
787
  const missingUpdateOption =
735
788
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
736
789
  readMissingOptionValue(parsed.missingOptionValues, "append") ??
@@ -773,7 +826,31 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
773
826
  });
774
827
  }
775
828
 
776
- const hasBulkTarget = updateAll || ids.length > 0;
829
+ const cascadeMode = updateAll && subtaskId.length > 0;
830
+ if (cascadeMode) {
831
+ if (title !== undefined || description !== undefined || append !== undefined || !isStatusCascadeUpdateStatus(status)) {
832
+ return failCascadeStatusUpdate("subtask.update", "Subtask", {
833
+ id: subtaskId,
834
+ status,
835
+ allowedStatuses: [...STATUS_CASCADE_UPDATE_STATUSES],
836
+ fields: {
837
+ title: title !== undefined,
838
+ description: description !== undefined,
839
+ append: append !== undefined,
840
+ },
841
+ });
842
+ }
843
+
844
+ const subtask = mutations.updateSubtask(subtaskId, { status });
845
+
846
+ return okResult({
847
+ command: "subtask.update",
848
+ human: `Updated subtask ${formatSubtask(subtask)}`,
849
+ data: { subtask },
850
+ });
851
+ }
852
+
853
+ const hasBulkTarget = (updateAll && subtaskId.length === 0) || ids.length > 0;
777
854
  if (hasBulkTarget) {
778
855
  if (subtaskId.length > 0) {
779
856
  return failResult({
@@ -20,19 +20,14 @@ import { unexpectedFailureResult } from "./error-utils";
20
20
  import {
21
21
  buildTaskReadiness,
22
22
  DEFAULT_OPEN_TASK_STATUSES,
23
- type DependencyBlocker,
24
- READY_REASON_BLOCKED,
25
- READY_REASON_READY,
26
- type ReadyReason,
27
23
  taskStatusPriority,
28
24
  type TaskReadinessResult,
29
- type TaskReadinessSummary,
30
25
  type TaskReadyCandidate,
31
26
  } from "./task-readiness";
32
27
 
33
28
  import { MutationService } from "../domain/mutation-service";
34
29
  import { TrackerDomain } from "../domain/tracker-domain";
35
- import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type TaskRecord } from "../domain/types";
30
+ import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type StatusCascadePlan, type TaskRecord } from "../domain/types";
36
31
  import { formatHumanTable } from "../io/human-table";
37
32
  import { failResult, okResult } from "../io/output";
38
33
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -45,9 +40,14 @@ function formatTask(task: TaskRecord): string {
45
40
  const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
46
41
  const LIST_VIEW_MODES = ["table", "compact"] as const;
47
42
  const DEFAULT_TASK_LIST_LIMIT = 10;
43
+ const CREATE_OPTIONS = ["epic", "e", "title", "t", "description", "d", "status", "s"] as const;
44
+ const LIST_OPTIONS = ["epic", "e", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
45
+ const SHOW_OPTIONS = ["view", "all"] as const;
48
46
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
49
47
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
50
48
  const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
49
+ const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
50
+ const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
51
51
 
52
52
  function parseIdsOption(rawIds: string | undefined): string[] {
53
53
  if (rawIds === undefined) {
@@ -185,6 +185,44 @@ function appendLine(existing: string, line: string): string {
185
185
  return existing.length > 0 ? `${existing}\n${line}` : line;
186
186
  }
187
187
 
188
+ function isStatusCascadeUpdateStatus(status: string | undefined): status is (typeof STATUS_CASCADE_UPDATE_STATUSES)[number] {
189
+ return status === "done" || status === "todo";
190
+ }
191
+
192
+ function buildStatusCascadeData(plan: StatusCascadePlan): Record<string, unknown> {
193
+ return {
194
+ mode: "descendants",
195
+ root: {
196
+ kind: plan.rootKind,
197
+ id: plan.rootId,
198
+ },
199
+ targetStatus: plan.targetStatus,
200
+ atomic: plan.atomic,
201
+ changedIds: plan.changedIds,
202
+ unchangedIds: plan.unchangedIds,
203
+ counts: plan.counts,
204
+ };
205
+ }
206
+
207
+ function formatStatusCascadeHuman(entityLabel: string, plan: StatusCascadePlan): string {
208
+ return `Cascade updated ${entityLabel} ${plan.rootId} to ${plan.targetStatus} (${plan.counts.changed} changed, ${plan.counts.unchanged} unchanged; tasks=${plan.counts.changedTasks}, subtasks=${plan.counts.changedSubtasks})`;
209
+ }
210
+
211
+ function failCascadeStatusUpdate(command: string, entityLabel: string, data: Record<string, unknown>): CliResult {
212
+ return failResult({
213
+ command,
214
+ human: `${entityLabel} descendant cascade requires --status done or --status todo and does not support --append, --description, or --title.`,
215
+ data: {
216
+ code: "invalid_input",
217
+ ...data,
218
+ },
219
+ error: {
220
+ code: "invalid_input",
221
+ message: `${entityLabel} descendant cascade requires status-only done/todo mode`,
222
+ },
223
+ });
224
+ }
225
+
188
226
  function formatTaskListTable(tasks: readonly TaskRecord[]): string {
189
227
  const rows = tasks.map((task) => [task.id, task.epicId, task.title, task.status]);
190
228
  return formatHumanTable(["ID", "EPIC", "TITLE", "STATUS"], rows, { wrapColumns: [2] });
@@ -426,6 +464,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
426
464
 
427
465
  switch (subcommand) {
428
466
  case "create": {
467
+ const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
468
+ if (createUnknownOption !== undefined) {
469
+ return unknownOption("task.create", createUnknownOption, CREATE_OPTIONS);
470
+ }
471
+
472
+ const unexpectedCreatePositionals = readUnexpectedPositionals(parsed, 1);
473
+ if (unexpectedCreatePositionals.length > 0) {
474
+ return failUnexpectedPositionals("task.create", unexpectedCreatePositionals);
475
+ }
476
+
429
477
  const missingCreateOption =
430
478
  readMissingOptionValue(parsed.missingOptionValues, "epic", "e") ??
431
479
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
@@ -502,6 +550,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
502
550
  });
503
551
  }
504
552
  case "list": {
553
+ const listUnknownOption = findUnknownOption(parsed, LIST_OPTIONS);
554
+ if (listUnknownOption !== undefined) {
555
+ return unknownOption("task.list", listUnknownOption, LIST_OPTIONS);
556
+ }
557
+
558
+ const unexpectedListPositionals = readUnexpectedPositionals(parsed, 1);
559
+ if (unexpectedListPositionals.length > 0) {
560
+ return failUnexpectedPositionals("task.list", unexpectedListPositionals);
561
+ }
562
+
505
563
  const missingListOption =
506
564
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
507
565
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
@@ -660,6 +718,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
660
718
  });
661
719
  }
662
720
  case "show": {
721
+ const showUnknownOption = findUnknownOption(parsed, SHOW_OPTIONS);
722
+ if (showUnknownOption !== undefined) {
723
+ return unknownOption("task.show", showUnknownOption, SHOW_OPTIONS);
724
+ }
725
+
726
+ const unexpectedShowPositionals = readUnexpectedPositionals(parsed, 2);
727
+ if (unexpectedShowPositionals.length > 0) {
728
+ return failUnexpectedPositionals("task.show", unexpectedShowPositionals);
729
+ }
730
+
663
731
  const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
664
732
  if (missingShowOption !== undefined) {
665
733
  return failMissingOptionValue("task.show", missingShowOption);
@@ -989,6 +1057,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
989
1057
  });
990
1058
  }
991
1059
  case "update": {
1060
+ const updateUnknownOption = findUnknownOption(parsed, UPDATE_OPTIONS);
1061
+ if (updateUnknownOption !== undefined) {
1062
+ return unknownOption("task.update", updateUnknownOption, UPDATE_OPTIONS);
1063
+ }
1064
+
1065
+ const unexpectedUpdatePositionals = readUnexpectedPositionals(parsed, 2);
1066
+ if (unexpectedUpdatePositionals.length > 0) {
1067
+ return failUnexpectedPositionals("task.update", unexpectedUpdatePositionals);
1068
+ }
1069
+
992
1070
  const missingUpdateOption =
993
1071
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
994
1072
  readMissingOptionValue(parsed.missingOptionValues, "append") ??
@@ -1031,7 +1109,35 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1031
1109
  });
1032
1110
  }
1033
1111
 
1034
- const hasBulkTarget = updateAll || ids.length > 0;
1112
+ const cascadeMode = updateAll && taskId.length > 0;
1113
+ if (cascadeMode) {
1114
+ if (title !== undefined || description !== undefined || append !== undefined || !isStatusCascadeUpdateStatus(status)) {
1115
+ return failCascadeStatusUpdate("task.update", "Task", {
1116
+ id: taskId,
1117
+ status,
1118
+ allowedStatuses: [...STATUS_CASCADE_UPDATE_STATUSES],
1119
+ fields: {
1120
+ title: title !== undefined,
1121
+ description: description !== undefined,
1122
+ append: append !== undefined,
1123
+ },
1124
+ });
1125
+ }
1126
+
1127
+ const cascade = mutations.updateTaskStatusCascade(taskId, status);
1128
+ const task = domain.getTaskOrThrow(taskId);
1129
+
1130
+ return okResult({
1131
+ command: "task.update",
1132
+ human: formatStatusCascadeHuman("task", cascade),
1133
+ data: {
1134
+ task,
1135
+ cascade: buildStatusCascadeData(cascade),
1136
+ },
1137
+ });
1138
+ }
1139
+
1140
+ const hasBulkTarget = (updateAll && taskId.length === 0) || ids.length > 0;
1035
1141
  if (hasBulkTarget) {
1036
1142
  if (taskId.length > 0) {
1037
1143
  return failResult({
@@ -18,8 +18,11 @@ import {
18
18
  type SearchField,
19
19
  type SearchNode,
20
20
  type SearchSummary,
21
+ type StatusCascadeBlocker,
22
+ type StatusCascadePlan,
21
23
  type SubtaskRecord,
22
24
  type TaskRecord,
25
+ DomainError,
23
26
  } from "./types";
24
27
 
25
28
  function countMatches(value: string, searchText: string): number {
@@ -187,6 +190,15 @@ export class MutationService {
187
190
  })();
188
191
  }
189
192
 
193
+ updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
194
+ return this.#db.transaction((): StatusCascadePlan => {
195
+ const plan = this.#domain.planStatusCascade("epic", id, status);
196
+ this.#assertCascadeNotBlocked(plan);
197
+ this.#applyStatusCascadePlan(plan);
198
+ return plan;
199
+ })();
200
+ }
201
+
190
202
  deleteEpic(id: string): void {
191
203
  this.#db.transaction((): void => {
192
204
  this.#domain.deleteEpic(id);
@@ -277,6 +289,15 @@ export class MutationService {
277
289
  })();
278
290
  }
279
291
 
292
+ updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
293
+ return this.#db.transaction((): StatusCascadePlan => {
294
+ const plan = this.#domain.planStatusCascade("task", id, status);
295
+ this.#assertCascadeNotBlocked(plan);
296
+ this.#applyStatusCascadePlan(plan);
297
+ return plan;
298
+ })();
299
+ }
300
+
280
301
  deleteTask(id: string): void {
281
302
  this.#db.transaction((): void => {
282
303
  this.#domain.deleteTask(id);
@@ -381,6 +402,45 @@ export class MutationService {
381
402
  })();
382
403
  }
383
404
 
405
+ describeError(error: unknown): string | undefined {
406
+ if (!(error instanceof DomainError) || error.code !== "dependency_blocked") {
407
+ return undefined;
408
+ }
409
+
410
+ const details = error.details as Record<string, unknown> | undefined;
411
+ const unresolvedDependencies = Array.isArray(details?.unresolvedDependencies)
412
+ ? details.unresolvedDependencies
413
+ : [];
414
+ if (unresolvedDependencies.length > 0) {
415
+ const blockers = unresolvedDependencies
416
+ .map((dependency) => {
417
+ if (!dependency || typeof dependency !== "object") {
418
+ return null;
419
+ }
420
+
421
+ const id = typeof dependency.id === "string" ? dependency.id : "unknown";
422
+ const kind = typeof dependency.kind === "string" ? dependency.kind : "dependency";
423
+ const status = typeof dependency.status === "string" ? dependency.status : "unknown";
424
+ return `${kind} ${id} is still ${status}`;
425
+ })
426
+ .filter((value): value is string => value !== null);
427
+
428
+ if (blockers.length > 0) {
429
+ return `Resolve dependencies first: ${blockers.join("; ")}.`;
430
+ }
431
+ }
432
+
433
+ const cascadeBlockers = Array.isArray(details?.blockers) ? details.blockers as StatusCascadeBlocker[] : [];
434
+ if (cascadeBlockers.length > 0) {
435
+ const blockers = cascadeBlockers.map((blocker) =>
436
+ `${blocker.sourceKind} ${blocker.sourceId} is blocked by ${blocker.dependsOnKind} ${blocker.dependsOnId} (${blocker.dependsOnStatus})`
437
+ );
438
+ return `Resolve dependencies first: ${blockers.join("; ")}.`;
439
+ }
440
+
441
+ return undefined;
442
+ }
443
+
384
444
  previewEpicReplacement(
385
445
  epicId: string,
386
446
  searchText: string,
@@ -458,6 +518,62 @@ export class MutationService {
458
518
  return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
459
519
  }
460
520
 
521
+ #assertCascadeNotBlocked(plan: StatusCascadePlan): void {
522
+ if (plan.blockers.length === 0) {
523
+ return;
524
+ }
525
+
526
+ throw new DomainError({
527
+ code: "dependency_blocked",
528
+ message: `${plan.rootKind} cascade cannot transition to ${plan.targetStatus} while dependencies are unresolved`,
529
+ details: {
530
+ entity: plan.rootKind,
531
+ id: plan.rootId,
532
+ status: plan.targetStatus,
533
+ atomic: plan.atomic,
534
+ changedIds: plan.changedIds,
535
+ unchangedIds: plan.unchangedIds,
536
+ blockerCount: plan.blockers.length,
537
+ blockers: plan.blockers,
538
+ blockedNodeIds: [...new Set(plan.blockers.map((blocker) => blocker.sourceId))],
539
+ unresolvedDependencyIds: [...new Set(plan.blockers.map((blocker) => blocker.dependsOnId))],
540
+ },
541
+ });
542
+ }
543
+
544
+ #applyStatusCascadePlan(plan: StatusCascadePlan): void {
545
+ for (const change of plan.orderedChanges) {
546
+ if (change.kind === "epic") {
547
+ const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
548
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
549
+ title: epic.title,
550
+ description: epic.description,
551
+ status: epic.status,
552
+ });
553
+ continue;
554
+ }
555
+
556
+ if (change.kind === "task") {
557
+ const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
558
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
559
+ epic_id: task.epicId,
560
+ title: task.title,
561
+ description: task.description,
562
+ status: task.status,
563
+ });
564
+ continue;
565
+ }
566
+
567
+ const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
568
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
569
+ task_id: subtask.taskId,
570
+ title: subtask.title,
571
+ description: subtask.description,
572
+ status: subtask.status,
573
+ });
574
+ }
575
+ }
576
+
461
577
  #applyScopeReplacement(
462
578
  nodes: readonly SearchNode[],
463
579
  searchText: string,