trekoon 0.2.7 → 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.
@@ -0,0 +1,277 @@
1
+ import { TrackerDomain } from "../domain/tracker-domain";
2
+ import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "../domain/types";
3
+
4
+ interface SearchFields {
5
+ readonly title: string;
6
+ readonly description: string;
7
+ readonly text: string;
8
+ }
9
+
10
+ interface StatusCounts {
11
+ readonly total: number;
12
+ readonly todo: number;
13
+ readonly blocked: number;
14
+ readonly inProgress: number;
15
+ readonly done: number;
16
+ readonly other: number;
17
+ }
18
+
19
+ export interface BoardSnapshotEpic {
20
+ readonly id: string;
21
+ readonly title: string;
22
+ readonly description: string;
23
+ readonly status: string;
24
+ readonly createdAt: number;
25
+ readonly updatedAt: number;
26
+ readonly taskIds: readonly string[];
27
+ readonly counts: {
28
+ readonly tasks: StatusCounts;
29
+ readonly subtasks: StatusCounts;
30
+ };
31
+ readonly search: SearchFields;
32
+ }
33
+
34
+ export interface BoardSnapshotTask {
35
+ readonly id: string;
36
+ readonly epicId: string;
37
+ readonly title: string;
38
+ readonly description: string;
39
+ readonly status: string;
40
+ readonly createdAt: number;
41
+ readonly updatedAt: number;
42
+ readonly subtaskIds: readonly string[];
43
+ readonly dependencyIds: readonly string[];
44
+ readonly dependentIds: readonly string[];
45
+ readonly counts: {
46
+ readonly subtasks: StatusCounts;
47
+ readonly dependencies: number;
48
+ readonly dependents: number;
49
+ };
50
+ readonly search: SearchFields;
51
+ }
52
+
53
+ export interface BoardSnapshotSubtask {
54
+ readonly id: string;
55
+ readonly taskId: string;
56
+ readonly title: string;
57
+ readonly description: string;
58
+ readonly status: string;
59
+ readonly createdAt: number;
60
+ readonly updatedAt: number;
61
+ readonly dependencyIds: readonly string[];
62
+ readonly dependentIds: readonly string[];
63
+ readonly counts: {
64
+ readonly dependencies: number;
65
+ readonly dependents: number;
66
+ };
67
+ readonly search: SearchFields;
68
+ }
69
+
70
+ export interface BoardSnapshotDependency {
71
+ readonly id: string;
72
+ readonly sourceId: string;
73
+ readonly sourceKind: "task" | "subtask";
74
+ readonly dependsOnId: string;
75
+ readonly dependsOnKind: "task" | "subtask";
76
+ readonly createdAt: number;
77
+ readonly updatedAt: number;
78
+ }
79
+
80
+ export interface BoardSnapshot {
81
+ readonly generatedAt: number;
82
+ readonly epics: readonly BoardSnapshotEpic[];
83
+ readonly tasks: readonly BoardSnapshotTask[];
84
+ readonly subtasks: readonly BoardSnapshotSubtask[];
85
+ readonly dependencies: readonly BoardSnapshotDependency[];
86
+ readonly counts: {
87
+ readonly epics: StatusCounts;
88
+ readonly tasks: StatusCounts;
89
+ readonly subtasks: StatusCounts;
90
+ readonly dependencies: number;
91
+ };
92
+ }
93
+
94
+ function normalizeStatusBucket(status: string): keyof Omit<StatusCounts, "total"> {
95
+ if (status === "todo") {
96
+ return "todo";
97
+ }
98
+
99
+ if (status === "blocked") {
100
+ return "blocked";
101
+ }
102
+
103
+ if (status === "in_progress" || status === "in-progress") {
104
+ return "inProgress";
105
+ }
106
+
107
+ if (status === "done") {
108
+ return "done";
109
+ }
110
+
111
+ return "other";
112
+ }
113
+
114
+ function countStatuses(records: readonly { readonly status: string }[]): StatusCounts {
115
+ const counts: {
116
+ total: number;
117
+ todo: number;
118
+ blocked: number;
119
+ inProgress: number;
120
+ done: number;
121
+ other: number;
122
+ } = {
123
+ total: records.length,
124
+ todo: 0,
125
+ blocked: 0,
126
+ inProgress: 0,
127
+ done: 0,
128
+ other: 0,
129
+ };
130
+
131
+ for (const record of records) {
132
+ const bucket = normalizeStatusBucket(record.status);
133
+ counts[bucket] += 1;
134
+ }
135
+
136
+ return counts;
137
+ }
138
+
139
+ function buildSearchFields(title: string, description: string): SearchFields {
140
+ return {
141
+ title,
142
+ description,
143
+ text: `${title}\n${description}`.trim(),
144
+ };
145
+ }
146
+
147
+ function mapDependency(record: DependencyRecord): BoardSnapshotDependency {
148
+ return {
149
+ id: record.id,
150
+ sourceId: record.sourceId,
151
+ sourceKind: record.sourceKind,
152
+ dependsOnId: record.dependsOnId,
153
+ dependsOnKind: record.dependsOnKind,
154
+ createdAt: record.createdAt,
155
+ updatedAt: record.updatedAt,
156
+ };
157
+ }
158
+
159
+ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
160
+ const generatedAt: number = Date.now();
161
+ const epics: readonly EpicRecord[] = domain.listEpics();
162
+ const tasks: readonly TaskRecord[] = domain.listTasks();
163
+ const subtasks: readonly SubtaskRecord[] = domain.listSubtasks();
164
+ const dependencies: BoardSnapshotDependency[] = [];
165
+
166
+ const tasksByEpic = new Map<string, TaskRecord[]>();
167
+ for (const task of tasks) {
168
+ const existing = tasksByEpic.get(task.epicId) ?? [];
169
+ existing.push(task);
170
+ tasksByEpic.set(task.epicId, existing);
171
+ }
172
+
173
+ const subtasksByTask = new Map<string, SubtaskRecord[]>();
174
+ for (const subtask of subtasks) {
175
+ const existing = subtasksByTask.get(subtask.taskId) ?? [];
176
+ existing.push(subtask);
177
+ subtasksByTask.set(subtask.taskId, existing);
178
+ }
179
+
180
+ const dependencyIdsBySource = new Map<string, string[]>();
181
+ const dependentIdsByTarget = new Map<string, string[]>();
182
+ for (const task of tasks) {
183
+ for (const dependency of domain.listDependencies(task.id)) {
184
+ dependencies.push(mapDependency(dependency));
185
+ const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
186
+ sourceIds.push(dependency.id);
187
+ dependencyIdsBySource.set(dependency.sourceId, sourceIds);
188
+ const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
189
+ dependentIds.push(dependency.id);
190
+ dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
191
+ }
192
+ }
193
+
194
+ for (const subtask of subtasks) {
195
+ for (const dependency of domain.listDependencies(subtask.id)) {
196
+ dependencies.push(mapDependency(dependency));
197
+ const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
198
+ sourceIds.push(dependency.id);
199
+ dependencyIdsBySource.set(dependency.sourceId, sourceIds);
200
+ const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
201
+ dependentIds.push(dependency.id);
202
+ dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
203
+ }
204
+ }
205
+
206
+ return {
207
+ generatedAt,
208
+ epics: epics.map((epic) => {
209
+ const epicTasks = tasksByEpic.get(epic.id) ?? [];
210
+ const epicSubtasks = epicTasks.flatMap((task) => subtasksByTask.get(task.id) ?? []);
211
+ return {
212
+ id: epic.id,
213
+ title: epic.title,
214
+ description: epic.description,
215
+ status: epic.status,
216
+ createdAt: epic.createdAt,
217
+ updatedAt: epic.updatedAt,
218
+ taskIds: epicTasks.map((task) => task.id),
219
+ counts: {
220
+ tasks: countStatuses(epicTasks),
221
+ subtasks: countStatuses(epicSubtasks),
222
+ },
223
+ search: buildSearchFields(epic.title, epic.description),
224
+ };
225
+ }),
226
+ tasks: tasks.map((task) => {
227
+ const taskSubtasks = subtasksByTask.get(task.id) ?? [];
228
+ const dependencyIds = dependencyIdsBySource.get(task.id) ?? [];
229
+ const dependentIds = dependentIdsByTarget.get(task.id) ?? [];
230
+ return {
231
+ id: task.id,
232
+ epicId: task.epicId,
233
+ title: task.title,
234
+ description: task.description,
235
+ status: task.status,
236
+ createdAt: task.createdAt,
237
+ updatedAt: task.updatedAt,
238
+ subtaskIds: taskSubtasks.map((subtask) => subtask.id),
239
+ dependencyIds,
240
+ dependentIds,
241
+ counts: {
242
+ subtasks: countStatuses(taskSubtasks),
243
+ dependencies: dependencyIds.length,
244
+ dependents: dependentIds.length,
245
+ },
246
+ search: buildSearchFields(task.title, task.description),
247
+ };
248
+ }),
249
+ subtasks: subtasks.map((subtask) => {
250
+ const dependencyIds = dependencyIdsBySource.get(subtask.id) ?? [];
251
+ const dependentIds = dependentIdsByTarget.get(subtask.id) ?? [];
252
+ return {
253
+ id: subtask.id,
254
+ taskId: subtask.taskId,
255
+ title: subtask.title,
256
+ description: subtask.description,
257
+ status: subtask.status,
258
+ createdAt: subtask.createdAt,
259
+ updatedAt: subtask.updatedAt,
260
+ dependencyIds,
261
+ dependentIds,
262
+ counts: {
263
+ dependencies: dependencyIds.length,
264
+ dependents: dependentIds.length,
265
+ },
266
+ search: buildSearchFields(subtask.title, subtask.description),
267
+ };
268
+ }),
269
+ dependencies,
270
+ counts: {
271
+ epics: countStatuses(epics),
272
+ tasks: countStatuses(tasks),
273
+ subtasks: countStatuses(subtasks),
274
+ dependencies: dependencies.length,
275
+ },
276
+ };
277
+ }
@@ -0,0 +1,43 @@
1
+ export const BOARD_ASSET_CONTRACT_VERSION = "1.0.0";
2
+ export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
3
+
4
+ export interface BoardAssetManifest {
5
+ readonly contractVersion: string;
6
+ readonly assetVersion: string;
7
+ readonly entryFile: string;
8
+ readonly files: readonly string[];
9
+ readonly assetDigest: string;
10
+ }
11
+
12
+ export interface BoardAssetPaths {
13
+ readonly sourceRoot: string;
14
+ readonly runtimeRoot: string;
15
+ readonly entryFile: string;
16
+ readonly manifestFile: string;
17
+ }
18
+
19
+ export type BoardInstallAction = "installed" | "reinstalled" | "updated" | "unchanged";
20
+
21
+ export interface BoardInstallResult {
22
+ readonly action: BoardInstallAction;
23
+ readonly paths: BoardAssetPaths;
24
+ readonly manifest: BoardAssetManifest;
25
+ }
26
+
27
+ export interface EnsureBoardInstalledOptions {
28
+ readonly workingDirectory?: string;
29
+ readonly assetVersion?: string;
30
+ readonly bundledAssetRoot?: string;
31
+ }
32
+
33
+ export class BoardInstallError extends Error {
34
+ readonly code: string;
35
+ readonly details: Record<string, unknown>;
36
+
37
+ constructor(code: string, message: string, details: Record<string, unknown> = {}) {
38
+ super(message);
39
+ this.name = "BoardInstallError";
40
+ this.code = code;
41
+ this.details = details;
42
+ }
43
+ }
@@ -0,0 +1,158 @@
1
+ import { parseArgs, readUnexpectedPositionals } from "./arg-parser";
2
+
3
+ import { ensureBoardInstalled, updateBoardInstallation } from "../board/install";
4
+ import { openBoardInBrowser, type OpenBrowserResult } from "../board/open-browser";
5
+ import { startBoardServer, type BoardServerInfo } from "../board/server";
6
+ import { BoardInstallError, type EnsureBoardInstalledOptions } from "../board/types";
7
+ import { failResult, okResult } from "../io/output";
8
+ import { type CliContext, type CliResult } from "../runtime/command-types";
9
+
10
+ type EnsureBoardInstalledFn = (options: EnsureBoardInstalledOptions) => ReturnType<typeof ensureBoardInstalled>;
11
+ type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
12
+ type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
13
+
14
+ let ensureInstalledImpl: EnsureBoardInstalledFn = ensureBoardInstalled;
15
+ let updateInstalledImpl: EnsureBoardInstalledFn = updateBoardInstallation;
16
+ let startBoardServerImpl: StartBoardServerFn = (options) => startBoardServer(options);
17
+ let openBoardInBrowserImpl: OpenBoardInBrowserFn = openBoardInBrowser;
18
+
19
+ function usageResult(): CliResult {
20
+ return failResult({
21
+ command: "board",
22
+ human: "Usage: trekoon board <open|update>",
23
+ data: {},
24
+ error: {
25
+ code: "invalid_subcommand",
26
+ message: "Invalid board subcommand",
27
+ },
28
+ });
29
+ }
30
+
31
+ function boardInstallOptions(context: CliContext): EnsureBoardInstalledOptions {
32
+ const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
33
+ return {
34
+ workingDirectory: context.cwd,
35
+ ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
36
+ };
37
+ }
38
+
39
+ function boardInstallFailure(command: string, error: BoardInstallError): CliResult {
40
+ return failResult({
41
+ command,
42
+ human: error.message,
43
+ data: {
44
+ code: error.code,
45
+ ...error.details,
46
+ },
47
+ error: {
48
+ code: error.code,
49
+ message: error.message,
50
+ },
51
+ });
52
+ }
53
+
54
+ export function setBoardCommandHooksForTests(hooks: {
55
+ ensureInstalled?: EnsureBoardInstalledFn;
56
+ updateInstalled?: EnsureBoardInstalledFn;
57
+ startBoardServer?: StartBoardServerFn;
58
+ openBoardInBrowser?: OpenBoardInBrowserFn;
59
+ } | null): void {
60
+ ensureInstalledImpl = hooks?.ensureInstalled ?? ensureBoardInstalled;
61
+ updateInstalledImpl = hooks?.updateInstalled ?? updateBoardInstallation;
62
+ startBoardServerImpl = hooks?.startBoardServer ?? ((options) => startBoardServer(options));
63
+ openBoardInBrowserImpl = hooks?.openBoardInBrowser ?? openBoardInBrowser;
64
+ }
65
+
66
+ export async function runBoard(context: CliContext): Promise<CliResult> {
67
+ const parsed = parseArgs(context.args);
68
+ const subcommand: string | undefined = parsed.positional[0];
69
+
70
+ if (parsed.options.size > 0 || parsed.flags.size > 0) {
71
+ return failResult({
72
+ command: subcommand ? `board.${subcommand}` : "board",
73
+ human: "Board commands do not accept options yet.",
74
+ data: {
75
+ options: [...parsed.providedOptions].map((option) => `--${option}`),
76
+ },
77
+ error: {
78
+ code: "invalid_input",
79
+ message: "Board commands do not accept options",
80
+ },
81
+ });
82
+ }
83
+
84
+ if (!subcommand) {
85
+ return usageResult();
86
+ }
87
+
88
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
89
+ if (unexpectedPositionals.length > 0) {
90
+ return failResult({
91
+ command: `board.${subcommand}`,
92
+ human: `Unexpected positional arguments: ${unexpectedPositionals.join(", ")}.`,
93
+ data: {
94
+ unexpectedPositionals,
95
+ },
96
+ error: {
97
+ code: "invalid_input",
98
+ message: "Unexpected positional arguments",
99
+ },
100
+ });
101
+ }
102
+
103
+ try {
104
+ switch (subcommand) {
105
+ case "update": {
106
+ const install = updateInstalledImpl(boardInstallOptions(context));
107
+ return okResult({
108
+ command: "board.update",
109
+ human: `Board assets ${install.action} at ${install.paths.runtimeRoot}`,
110
+ data: {
111
+ action: install.action,
112
+ paths: install.paths,
113
+ manifest: install.manifest,
114
+ },
115
+ });
116
+ }
117
+ case "open": {
118
+ const install = ensureInstalledImpl(boardInstallOptions(context));
119
+ const server = startBoardServerImpl({ cwd: context.cwd });
120
+ const launch = await openBoardInBrowserImpl(server.url);
121
+ return okResult({
122
+ command: "board.open",
123
+ human: [
124
+ `Board ready at ${server.url}`,
125
+ launch.launched
126
+ ? `Browser launched with ${launch.command}`
127
+ : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
128
+ `Open manually if needed: ${server.fallbackUrl}`,
129
+ ].join("\n"),
130
+ data: {
131
+ install: {
132
+ action: install.action,
133
+ paths: install.paths,
134
+ manifest: install.manifest,
135
+ },
136
+ server: {
137
+ origin: server.origin,
138
+ url: server.url,
139
+ fallbackUrl: server.fallbackUrl,
140
+ hostname: server.hostname,
141
+ port: server.port,
142
+ token: server.token,
143
+ },
144
+ launch,
145
+ },
146
+ });
147
+ }
148
+ default:
149
+ return usageResult();
150
+ }
151
+ } catch (error: unknown) {
152
+ if (error instanceof BoardInstallError) {
153
+ return boardInstallFailure(`board.${subcommand}`, error);
154
+ }
155
+
156
+ throw error;
157
+ }
158
+ }
@@ -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
  "",
@@ -394,6 +414,7 @@ const SKILLS_HELP = [
394
414
 
395
415
  const COMMAND_HELP: Record<string, string> = {
396
416
  init: INIT_HELP,
417
+ board: BOARD_HELP,
397
418
  quickstart: QUICKSTART_HELP,
398
419
  session: SESSION_HELP,
399
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",
@@ -18,6 +18,7 @@ import {
18
18
  type SearchField,
19
19
  type SearchNode,
20
20
  type SearchSummary,
21
+ type StatusCascadeBlocker,
21
22
  type StatusCascadePlan,
22
23
  type SubtaskRecord,
23
24
  type TaskRecord,
@@ -401,6 +402,45 @@ export class MutationService {
401
402
  })();
402
403
  }
403
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
+
404
444
  previewEpicReplacement(
405
445
  epicId: string,
406
446
  searchText: string,
@@ -138,6 +138,14 @@ function normalizeStatus(value: string | undefined): string {
138
138
  return assertNonEmpty("status", value);
139
139
  }
140
140
 
141
+ function normalizeSubtaskDescription(value: string | undefined): string {
142
+ if (value === undefined) {
143
+ return "";
144
+ }
145
+
146
+ return value.trim();
147
+ }
148
+
141
149
  function mapEpic(row: EpicRow): EpicRecord {
142
150
  return {
143
151
  id: row.id,
@@ -431,7 +439,7 @@ export class TrackerDomain {
431
439
  const id: string = randomUUID();
432
440
  const taskId: string = assertNonEmpty("taskId", input.taskId);
433
441
  const title: string = assertNonEmpty("title", input.title);
434
- const description: string = input.description === undefined ? "" : assertNonEmpty("description", input.description);
442
+ const description: string = normalizeSubtaskDescription(input.description);
435
443
  const status: string = normalizeStatus(input.status);
436
444
 
437
445
  this.getTaskOrThrow(taskId);
@@ -457,7 +465,7 @@ export class TrackerDomain {
457
465
  tempKey: assertNonEmpty("tempKey", spec.tempKey),
458
466
  taskId,
459
467
  title: assertNonEmpty("title", spec.title),
460
- description: spec.description === undefined ? "" : assertNonEmpty("description", spec.description),
468
+ description: normalizeSubtaskDescription(spec.description),
461
469
  status: normalizeStatus(spec.status),
462
470
  };
463
471
  });
@@ -574,7 +582,7 @@ export class TrackerDomain {
574
582
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
575
583
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
576
584
  const nextDescription: string =
577
- input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
585
+ input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
578
586
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
579
587
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
580
588
  const now: number = Date.now();
@@ -1,3 +1,4 @@
1
+ import { runBoard } from "../commands/board";
1
2
  import { runHelp } from "../commands/help";
2
3
  import { runDep } from "../commands/dep";
3
4
  import { runEpic } from "../commands/epic";
@@ -19,6 +20,7 @@ import { resolveStoragePaths } from "../storage/path";
19
20
 
20
21
  const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
21
22
  "help",
23
+ "board",
22
24
  "init",
23
25
  "quickstart",
24
26
  "session",
@@ -334,6 +336,9 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
334
336
  case "help":
335
337
  result = await runHelp(context);
336
338
  break;
339
+ case "board":
340
+ result = await runBoard(context);
341
+ break;
337
342
  case "init":
338
343
  result = await runInit(context);
339
344
  break;