trekoon 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -721
- package/docs/ai-agents.md +198 -0
- package/docs/commands.md +126 -0
- package/docs/machine-contracts.md +253 -0
- package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
- package/docs/quickstart.md +134 -0
- package/package.json +2 -1
- package/src/commands/epic.ts +104 -3
- package/src/commands/help.ts +31 -13
- package/src/commands/skills.ts +12 -4
- package/src/commands/subtask.ts +78 -1
- package/src/commands/task.ts +113 -7
- package/src/domain/mutation-service.ts +76 -0
- package/src/domain/tracker-domain.ts +250 -2
- package/src/domain/types.ts +51 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
Use this guide for the shortest path from an empty repo to an active Trekoon
|
|
4
|
+
workflow.
|
|
5
|
+
|
|
6
|
+
## Understand the storage model first
|
|
7
|
+
|
|
8
|
+
Trekoon is local-first, but inside git repos and worktrees it is **repo-shared**.
|
|
9
|
+
Every worktree for the same repository resolves to one shared `.trekoon`
|
|
10
|
+
directory and one shared `.trekoon/trekoon.db` database.
|
|
11
|
+
|
|
12
|
+
- `worktreeRoot` identifies the current checkout.
|
|
13
|
+
- `sharedStorageRoot` identifies the repository root that owns `.trekoon`.
|
|
14
|
+
- `databaseFile` points at the shared SQLite database.
|
|
15
|
+
- `.trekoon` stays gitignored because the DB is operational state, not source
|
|
16
|
+
code.
|
|
17
|
+
|
|
18
|
+
Outside git repos, Trekoon falls back to the current working directory.
|
|
19
|
+
|
|
20
|
+
## Initialize
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
trekoon init
|
|
24
|
+
trekoon --version
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If an agent is driving the workflow, use the machine-readable form:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
trekoon --toon init
|
|
31
|
+
trekoon --toon sync status
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Bootstrap rules:
|
|
35
|
+
|
|
36
|
+
- Run `trekoon --toon init` once per repository to create or re-bootstrap the
|
|
37
|
+
shared storage root.
|
|
38
|
+
- Run `trekoon --toon sync status` before agent work to inspect diagnostics.
|
|
39
|
+
- If diagnostics report `recoveryRequired`, a tracked or ignored mismatch, or an
|
|
40
|
+
ambiguous recovery path, stop and repair setup before continuing.
|
|
41
|
+
|
|
42
|
+
## Create an epic, task, and subtask
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
trekoon epic create --title "Agent backlog stabilization" --description "Track stabilization work" --status todo
|
|
46
|
+
trekoon task create --title "Implement sync status" --description "Add status reporting" --epic <epic-id> --status todo
|
|
47
|
+
trekoon subtask create --task <task-id> --title "Add cursor model" --status todo
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Useful follow-up reads:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
trekoon task list
|
|
54
|
+
trekoon task list --status done
|
|
55
|
+
trekoon task list --limit 25
|
|
56
|
+
trekoon task list --all --view compact
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Prefer one-shot planning when the graph is already known
|
|
60
|
+
|
|
61
|
+
If you already know the epic tree, create the epic, tasks, subtasks, and
|
|
62
|
+
dependencies in one call:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
trekoon epic create \
|
|
66
|
+
--title "Batch command rollout" \
|
|
67
|
+
--description "Ship one-shot planning workflows" \
|
|
68
|
+
--task "task-a|First task|First description|todo" \
|
|
69
|
+
--task "task-b|Second task|Second description|todo" \
|
|
70
|
+
--subtask "@task-a|sub-a|First subtask|Subtask description|todo" \
|
|
71
|
+
--dep "@task-b|@task-a" \
|
|
72
|
+
--dep "@sub-a|@task-a"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use this when:
|
|
76
|
+
|
|
77
|
+
- the epic does not exist yet
|
|
78
|
+
- later records need to reference earlier records with `@temp-key`
|
|
79
|
+
- you want one atomic create step and one machine response with mappings and
|
|
80
|
+
counts
|
|
81
|
+
|
|
82
|
+
## Add dependencies
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
trekoon dep add <task-id> <depends-on-id>
|
|
86
|
+
trekoon dep list <task-id>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Use batch commands for larger updates
|
|
90
|
+
|
|
91
|
+
When one call needs to create or link multiple records, prefer the transactional
|
|
92
|
+
batch commands:
|
|
93
|
+
|
|
94
|
+
| Need | Command |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Create multiple tasks under one epic | `trekoon task create-many --epic <epic-id> --task ...` |
|
|
97
|
+
| Create multiple subtasks under one task | `trekoon subtask create-many <task-id> --subtask ...` |
|
|
98
|
+
| Add multiple dependency edges | `trekoon dep add-many --dep ...` |
|
|
99
|
+
| Expand an existing epic with linked records | `trekoon epic expand <epic-id> ...` |
|
|
100
|
+
|
|
101
|
+
These commands validate the whole batch before applying changes, so a bad input
|
|
102
|
+
fails the whole operation instead of leaving partial state behind.
|
|
103
|
+
|
|
104
|
+
## Close or reopen a whole tree in one update
|
|
105
|
+
|
|
106
|
+
When you need to manually finish or reopen an entire epic or task tree, use the
|
|
107
|
+
positional-ID cascade form of `update --all`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
trekoon epic update <epic-id> --all --status done
|
|
111
|
+
trekoon epic update <epic-id> --all --status todo
|
|
112
|
+
trekoon task update <task-id> --all --status done
|
|
113
|
+
trekoon task update <task-id> --all --status todo
|
|
114
|
+
trekoon subtask update <subtask-id> --all --status done
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Rules:
|
|
118
|
+
|
|
119
|
+
- `epic update <id> --all --status done|todo` updates the epic and all
|
|
120
|
+
descendants atomically
|
|
121
|
+
- `task update <id> --all --status done|todo` updates the task and all
|
|
122
|
+
descendant subtasks atomically
|
|
123
|
+
- `subtask update <id> --all --status done|todo` is accepted for consistency,
|
|
124
|
+
but it only updates that one subtask
|
|
125
|
+
- Positional-ID cascade mode is status-only; do not combine it with `--append`,
|
|
126
|
+
`--description`, `--title`, or `--ids`
|
|
127
|
+
- If any epic/task descendant is blocked by an unresolved external dependency,
|
|
128
|
+
the whole cascade fails with no partial writes
|
|
129
|
+
|
|
130
|
+
## What to read next
|
|
131
|
+
|
|
132
|
+
- [Command reference](commands.md)
|
|
133
|
+
- [AI agents and the Trekoon skill](ai-agents.md)
|
|
134
|
+
- [Machine contracts](machine-contracts.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trekoon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "AI-first local issue tracker CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
+
"docs",
|
|
12
13
|
"src",
|
|
13
14
|
".agents/skills/trekoon/SKILL.md",
|
|
14
15
|
"README.md",
|
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({
|
package/src/commands/help.ts
CHANGED
|
@@ -131,14 +131,20 @@ const EPIC_HELP = [
|
|
|
131
131
|
" - --preview and --apply are mutually exclusive",
|
|
132
132
|
"",
|
|
133
133
|
"Update behavior:",
|
|
134
|
-
"
|
|
135
|
-
" --all
|
|
136
|
-
"
|
|
137
|
-
"
|
|
134
|
+
" Repo-wide bulk mode:",
|
|
135
|
+
" trekoon epic update --all --append <text> [--status <status>]",
|
|
136
|
+
" trekoon epic update --ids <csv> --append <text> [--status <status>]",
|
|
137
|
+
" - preserves existing per-row bulk behavior; not one atomic multi-row update",
|
|
138
|
+
" Descendant cascade mode:",
|
|
139
|
+
" trekoon epic update <epic-id> --all --status done|todo",
|
|
140
|
+
" - cascades atomically through descendant tasks and subtasks",
|
|
141
|
+
" - blocked descendants abort the whole update",
|
|
142
|
+
" - cascade mode supports only --status done|todo",
|
|
143
|
+
" - do not combine positional id + --all with --ids, --append, --description, or --title",
|
|
138
144
|
].join("\n");
|
|
139
145
|
|
|
140
146
|
const TASK_HELP = [
|
|
141
|
-
"Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete> [options]",
|
|
147
|
+
"Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete> [options]",
|
|
142
148
|
"",
|
|
143
149
|
"Create-many behavior:",
|
|
144
150
|
" trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
|
|
@@ -188,10 +194,16 @@ const TASK_HELP = [
|
|
|
188
194
|
" - --preview and --apply are mutually exclusive",
|
|
189
195
|
"",
|
|
190
196
|
"Update behavior:",
|
|
191
|
-
"
|
|
192
|
-
" --all
|
|
193
|
-
"
|
|
194
|
-
"
|
|
197
|
+
" Repo-wide bulk mode:",
|
|
198
|
+
" trekoon task update --all --append <text> [--status <status>]",
|
|
199
|
+
" trekoon task update --ids <csv> --append <text> [--status <status>]",
|
|
200
|
+
" - preserves existing per-row bulk behavior; not one atomic multi-row update",
|
|
201
|
+
" Descendant cascade mode:",
|
|
202
|
+
" trekoon task update <task-id> --all --status done|todo",
|
|
203
|
+
" - cascades atomically through descendant subtasks",
|
|
204
|
+
" - blocked descendants abort the whole update",
|
|
205
|
+
" - cascade mode supports only --status done|todo",
|
|
206
|
+
" - do not combine positional id + --all with --ids, --append, --description, or --title",
|
|
195
207
|
].join("\n");
|
|
196
208
|
|
|
197
209
|
const SUBTASK_HELP = [
|
|
@@ -228,10 +240,16 @@ const SUBTASK_HELP = [
|
|
|
228
240
|
" - --preview and --apply are mutually exclusive",
|
|
229
241
|
"",
|
|
230
242
|
"Update behavior:",
|
|
231
|
-
"
|
|
232
|
-
" --all
|
|
233
|
-
"
|
|
234
|
-
"
|
|
243
|
+
" Repo-wide bulk mode:",
|
|
244
|
+
" trekoon subtask update --all --append <text> [--status <status>]",
|
|
245
|
+
" trekoon subtask update --ids <csv> --append <text> [--status <status>]",
|
|
246
|
+
" - preserves existing per-row bulk behavior; not one atomic multi-row update",
|
|
247
|
+
" Positional-id cascade syntax:",
|
|
248
|
+
" trekoon subtask update <subtask-id> --all --status done|todo",
|
|
249
|
+
" - accepted for contract consistency",
|
|
250
|
+
" - behaves like a normal single-subtask status update",
|
|
251
|
+
" - positional id + --all supports only --status done|todo",
|
|
252
|
+
" - do not combine positional id + --all with --ids, --append, --description, or --title",
|
|
235
253
|
].join("\n");
|
|
236
254
|
|
|
237
255
|
const DEP_HELP = [
|
package/src/commands/skills.ts
CHANGED
|
@@ -215,6 +215,11 @@ function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
|
|
|
215
215
|
return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
function toRelativeSymlinkTarget(linkPath: string, targetPath: string): string {
|
|
219
|
+
const relativeTarget: string = relative(dirname(linkPath), resolve(targetPath));
|
|
220
|
+
return relativeTarget === "" ? "." : relativeTarget;
|
|
221
|
+
}
|
|
222
|
+
|
|
218
223
|
function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
|
|
219
224
|
if (editor === "opencode") {
|
|
220
225
|
return join(cwd, ".opencode");
|
|
@@ -279,13 +284,15 @@ function replaceOrCreateSymlink(
|
|
|
279
284
|
repoRoot: string,
|
|
280
285
|
allowOutsideRepo: boolean,
|
|
281
286
|
): CliResult | null {
|
|
287
|
+
const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, targetPath);
|
|
288
|
+
|
|
282
289
|
if (!existsSync(linkPath)) {
|
|
283
290
|
mkdirSync(dirname(linkPath), { recursive: true });
|
|
284
291
|
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
285
292
|
if (boundaryFailure) {
|
|
286
293
|
return boundaryFailure;
|
|
287
294
|
}
|
|
288
|
-
symlinkSync(
|
|
295
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
289
296
|
return null;
|
|
290
297
|
}
|
|
291
298
|
|
|
@@ -331,7 +338,7 @@ function replaceOrCreateSymlink(
|
|
|
331
338
|
if (boundaryFailure) {
|
|
332
339
|
return boundaryFailure;
|
|
333
340
|
}
|
|
334
|
-
symlinkSync(
|
|
341
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
335
342
|
return null;
|
|
336
343
|
}
|
|
337
344
|
|
|
@@ -489,6 +496,7 @@ function updateEditorLink(
|
|
|
489
496
|
): UpdateLinkEntry {
|
|
490
497
|
const linkPath: string = resolveDefaultLinkPath(cwd, editor);
|
|
491
498
|
const expectedTarget: string = resolve(installedDir);
|
|
499
|
+
const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, expectedTarget);
|
|
492
500
|
const editorConfigDir: string = resolveEditorConfigDir(cwd, editor);
|
|
493
501
|
|
|
494
502
|
if (!existsSync(editorConfigDir)) {
|
|
@@ -504,7 +512,7 @@ function updateEditorLink(
|
|
|
504
512
|
|
|
505
513
|
if (!existsSync(linkPath)) {
|
|
506
514
|
mkdirSync(dirname(linkPath), { recursive: true });
|
|
507
|
-
symlinkSync(
|
|
515
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
508
516
|
return {
|
|
509
517
|
editor,
|
|
510
518
|
linkPath,
|
|
@@ -542,7 +550,7 @@ function updateEditorLink(
|
|
|
542
550
|
}
|
|
543
551
|
|
|
544
552
|
rmSync(linkPath, { force: true });
|
|
545
|
-
symlinkSync(
|
|
553
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
546
554
|
return {
|
|
547
555
|
editor,
|
|
548
556
|
linkPath,
|
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({
|