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
package/src/commands/help.ts
CHANGED
|
@@ -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
|
-
"
|
|
135
|
-
" --all
|
|
136
|
-
"
|
|
137
|
-
"
|
|
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
|
-
"
|
|
192
|
-
" --all
|
|
193
|
-
"
|
|
194
|
-
"
|
|
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
|
-
"
|
|
232
|
-
" --all
|
|
233
|
-
"
|
|
234
|
-
"
|
|
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,
|
package/src/commands/init.ts
CHANGED
|
@@ -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",
|
package/src/commands/subtask.ts
CHANGED
|
@@ -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
|
|
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({
|
package/src/commands/task.ts
CHANGED
|
@@ -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
|
|
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,
|