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.
- package/README.md +102 -708
- package/docs/ai-agents.md +198 -0
- package/docs/commands.md +226 -0
- package/docs/machine-contracts.md +253 -0
- package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
- package/docs/quickstart.md +207 -0
- package/package.json +3 -1
- package/src/board/assets/app.js +1498 -0
- package/src/board/assets/components/AppShell.js +17 -0
- package/src/board/assets/components/BoardTopbar.js +78 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/EpicRow.js +62 -0
- package/src/board/assets/components/EpicsOverview.js +43 -0
- package/src/board/assets/components/WorkspaceHeader.js +70 -0
- package/src/board/assets/components/assetMap.js +65 -0
- package/src/board/assets/index.html +76 -0
- package/src/board/assets/main.js +27 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/state/actions.js +334 -0
- package/src/board/assets/state/api.js +126 -0
- package/src/board/assets/state/store.js +172 -0
- package/src/board/assets/styles/board.css +1127 -0
- package/src/board/assets/utils/dom.js +308 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +299 -0
- package/src/board/server.ts +184 -0
- package/src/board/snapshot.ts +277 -0
- package/src/board/types.ts +43 -0
- package/src/commands/board.ts +158 -0
- package/src/commands/epic.ts +104 -3
- package/src/commands/help.ts +52 -13
- package/src/commands/init.ts +29 -0
- package/src/commands/subtask.ts +78 -1
- package/src/commands/task.ts +113 -7
- package/src/domain/mutation-service.ts +116 -0
- package/src/domain/tracker-domain.ts +261 -5
- package/src/domain/types.ts +51 -0
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -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
|
+
}
|
package/src/commands/epic.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type CompactTaskSpec,
|
|
31
31
|
type EpicRecord,
|
|
32
32
|
type SearchEntityMatch,
|
|
33
|
+
type StatusCascadePlan,
|
|
33
34
|
} from "../domain/types";
|
|
34
35
|
import { formatHumanTable } from "../io/human-table";
|
|
35
36
|
import { failResult, okResult } from "../io/output";
|
|
@@ -45,9 +46,13 @@ const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
|
45
46
|
const DEFAULT_LIST_LIMIT = 10;
|
|
46
47
|
const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
47
48
|
const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
|
|
49
|
+
const LIST_OPTIONS = ["status", "s", "limit", "l", "cursor", "all", "view"] as const;
|
|
50
|
+
const SHOW_OPTIONS = ["view", "all"] as const;
|
|
48
51
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
49
52
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
50
53
|
const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
|
|
54
|
+
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
|
|
55
|
+
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
51
56
|
|
|
52
57
|
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
53
58
|
if (rawStatuses === undefined) {
|
|
@@ -218,6 +223,44 @@ function appendLine(existing: string, line: string): string {
|
|
|
218
223
|
return existing.length > 0 ? `${existing}\n${line}` : line;
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
function isStatusCascadeUpdateStatus(status: string | undefined): status is (typeof STATUS_CASCADE_UPDATE_STATUSES)[number] {
|
|
227
|
+
return status === "done" || status === "todo";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildStatusCascadeData(plan: StatusCascadePlan): Record<string, unknown> {
|
|
231
|
+
return {
|
|
232
|
+
mode: "descendants",
|
|
233
|
+
root: {
|
|
234
|
+
kind: plan.rootKind,
|
|
235
|
+
id: plan.rootId,
|
|
236
|
+
},
|
|
237
|
+
targetStatus: plan.targetStatus,
|
|
238
|
+
atomic: plan.atomic,
|
|
239
|
+
changedIds: plan.changedIds,
|
|
240
|
+
unchangedIds: plan.unchangedIds,
|
|
241
|
+
counts: plan.counts,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatStatusCascadeHuman(entityLabel: string, plan: StatusCascadePlan): string {
|
|
246
|
+
return `Cascade updated ${entityLabel} ${plan.rootId} to ${plan.targetStatus} (${plan.counts.changed} changed, ${plan.counts.unchanged} unchanged; epics=${plan.counts.changedEpics}, tasks=${plan.counts.changedTasks}, subtasks=${plan.counts.changedSubtasks})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function failCascadeStatusUpdate(command: string, entityLabel: string, data: Record<string, unknown>): CliResult {
|
|
250
|
+
return failResult({
|
|
251
|
+
command,
|
|
252
|
+
human: `${entityLabel} descendant cascade requires --status done or --status todo and does not support --append, --description, or --title.`,
|
|
253
|
+
data: {
|
|
254
|
+
code: "invalid_input",
|
|
255
|
+
...data,
|
|
256
|
+
},
|
|
257
|
+
error: {
|
|
258
|
+
code: "invalid_input",
|
|
259
|
+
message: `${entityLabel} descendant cascade requires status-only done/todo mode`,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
221
264
|
function formatEpicListTable(epics: readonly EpicRecord[]): string {
|
|
222
265
|
const rows = epics.map((epic) => [epic.id, epic.title, epic.status]);
|
|
223
266
|
return formatHumanTable(["ID", "TITLE", "STATUS"], rows, { wrapColumns: [1] });
|
|
@@ -826,6 +869,16 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
826
869
|
});
|
|
827
870
|
}
|
|
828
871
|
case "list": {
|
|
872
|
+
const listUnknownOption = findUnknownOption(parsed, LIST_OPTIONS);
|
|
873
|
+
if (listUnknownOption !== undefined) {
|
|
874
|
+
return unknownOption("epic.list", listUnknownOption, LIST_OPTIONS);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const unexpectedListPositionals = readUnexpectedPositionals(parsed, 1);
|
|
878
|
+
if (unexpectedListPositionals.length > 0) {
|
|
879
|
+
return failUnexpectedPositionals("epic.list", unexpectedListPositionals);
|
|
880
|
+
}
|
|
881
|
+
|
|
829
882
|
const missingListOption =
|
|
830
883
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
831
884
|
readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
|
|
@@ -836,8 +889,8 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
836
889
|
}
|
|
837
890
|
|
|
838
891
|
const includeAll: boolean = hasFlag(parsed.flags, "all");
|
|
839
|
-
const rawStatuses: string | undefined = readOption(parsed.options, "status");
|
|
840
|
-
const rawLimit: string | undefined = readOption(parsed.options, "limit");
|
|
892
|
+
const rawStatuses: string | undefined = readOption(parsed.options, "status", "s");
|
|
893
|
+
const rawLimit: string | undefined = readOption(parsed.options, "limit", "l");
|
|
841
894
|
const rawCursor: string | undefined = readOption(parsed.options, "cursor");
|
|
842
895
|
const rawView: string | undefined = readOption(parsed.options, "view");
|
|
843
896
|
const view = readEnumOption(parsed.options, VIEW_MODES, "view");
|
|
@@ -939,6 +992,16 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
939
992
|
});
|
|
940
993
|
}
|
|
941
994
|
case "show": {
|
|
995
|
+
const showUnknownOption = findUnknownOption(parsed, SHOW_OPTIONS);
|
|
996
|
+
if (showUnknownOption !== undefined) {
|
|
997
|
+
return unknownOption("epic.show", showUnknownOption, SHOW_OPTIONS);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const unexpectedShowPositionals = readUnexpectedPositionals(parsed, 2);
|
|
1001
|
+
if (unexpectedShowPositionals.length > 0) {
|
|
1002
|
+
return failUnexpectedPositionals("epic.show", unexpectedShowPositionals);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
942
1005
|
const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
|
|
943
1006
|
if (missingShowOption !== undefined) {
|
|
944
1007
|
return failMissingOptionValue("epic.show", missingShowOption);
|
|
@@ -1167,6 +1230,16 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
1167
1230
|
});
|
|
1168
1231
|
}
|
|
1169
1232
|
case "update": {
|
|
1233
|
+
const updateUnknownOption = findUnknownOption(parsed, UPDATE_OPTIONS);
|
|
1234
|
+
if (updateUnknownOption !== undefined) {
|
|
1235
|
+
return unknownOption("epic.update", updateUnknownOption, UPDATE_OPTIONS);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const unexpectedUpdatePositionals = readUnexpectedPositionals(parsed, 2);
|
|
1239
|
+
if (unexpectedUpdatePositionals.length > 0) {
|
|
1240
|
+
return failUnexpectedPositionals("epic.update", unexpectedUpdatePositionals);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1170
1243
|
const missingUpdateOption =
|
|
1171
1244
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
1172
1245
|
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
@@ -1209,7 +1282,35 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
1209
1282
|
});
|
|
1210
1283
|
}
|
|
1211
1284
|
|
|
1212
|
-
const
|
|
1285
|
+
const cascadeMode = updateAll && epicId.length > 0;
|
|
1286
|
+
if (cascadeMode) {
|
|
1287
|
+
if (title !== undefined || description !== undefined || append !== undefined || !isStatusCascadeUpdateStatus(status)) {
|
|
1288
|
+
return failCascadeStatusUpdate("epic.update", "Epic", {
|
|
1289
|
+
id: epicId,
|
|
1290
|
+
status,
|
|
1291
|
+
allowedStatuses: [...STATUS_CASCADE_UPDATE_STATUSES],
|
|
1292
|
+
fields: {
|
|
1293
|
+
title: title !== undefined,
|
|
1294
|
+
description: description !== undefined,
|
|
1295
|
+
append: append !== undefined,
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const cascade = mutations.updateEpicStatusCascade(epicId, status);
|
|
1301
|
+
const epic = domain.getEpicOrThrow(epicId);
|
|
1302
|
+
|
|
1303
|
+
return okResult({
|
|
1304
|
+
command: "epic.update",
|
|
1305
|
+
human: formatStatusCascadeHuman("epic", cascade),
|
|
1306
|
+
data: {
|
|
1307
|
+
epic,
|
|
1308
|
+
cascade: buildStatusCascadeData(cascade),
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const hasBulkTarget = (updateAll && epicId.length === 0) || ids.length > 0;
|
|
1213
1314
|
if (hasBulkTarget) {
|
|
1214
1315
|
if (epicId.length > 0) {
|
|
1215
1316
|
return failResult({
|