trekoon 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -13,6 +13,7 @@ const ROOT_HELP = [
13
13
  " --json Structured JSON output",
14
14
  " --toon TOON-encoded output (preferred for agents)",
15
15
  " --compat <mode> Machine compatibility mode",
16
+ " --daemon (experimental) Route the call through trekoon serve over a Unix socket",
16
17
  " --help Show help for root or a command",
17
18
  " --version Print CLI version",
18
19
  "",
@@ -27,12 +28,13 @@ const ROOT_HELP = [
27
28
  " subtask Create, list, update, and search subtasks",
28
29
  " dep Manage dependency edges between tasks and subtasks",
29
30
  " events Prune old sync event log rows",
30
- " migrate Check schema version or roll back migrations",
31
+ " migrate Check schema version, roll back migrations, or snapshot a backup",
31
32
  " session Agent orientation (diagnostics + sync + next task)",
32
33
  " suggest Priority-ranked next-action suggestions",
33
34
  " sync Pull events and resolve conflicts across branches",
34
35
  " skills Install, link, or update the Trekoon skill",
35
36
  " update Alias for skills update",
37
+ " serve (experimental) Run a long-lived daemon over a Unix socket",
36
38
  ].join("\n");
37
39
 
38
40
  const INIT_HELP = [
@@ -94,11 +96,18 @@ const BOARD_HELP = [
94
96
  " and open the browser. Returns the board URL and a fallback URL.",
95
97
  " update Refresh board runtime assets only. No server, no browser.",
96
98
  "",
99
+ "Token visibility:",
100
+ " By default the board token is redacted from machine output (shown as ****).",
101
+ " Pass --reveal-token to print the raw token value.",
102
+ " trekoon board open --reveal-token",
103
+ " Treat the token like a password: it grants full board access over loopback.",
104
+ "",
97
105
  "Environment:",
98
106
  " TREKOON_BOARD_ASSET_ROOT Override the bundled asset source (tests/dev only).",
99
107
  "",
100
108
  "Examples:",
101
109
  " trekoon board open",
110
+ " trekoon board open --reveal-token",
102
111
  " trekoon --json board update",
103
112
  ].join("\n");
104
113
 
@@ -170,7 +179,7 @@ const EPIC_HELP = [
170
179
  ].join("\n");
171
180
 
172
181
  const TASK_HELP = [
173
- "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete> [options]",
182
+ "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim> [options]",
174
183
  "",
175
184
  "Create-many:",
176
185
  " trekoon task create-many --epic <epic-id> --task <spec> [--task <spec> ...]",
@@ -216,10 +225,18 @@ const TASK_HELP = [
216
225
  " Cascades atomically through descendant subtasks.",
217
226
  " Blocked descendants abort the whole update. Only --status done|todo is supported.",
218
227
  " Don't combine positional ID + --all with --ids, --append, --description, or --title.",
228
+ "",
229
+ "Claim:",
230
+ " trekoon task claim <task-id> --owner <owner>",
231
+ " Atomically claim a task using SQL compare-and-swap.",
232
+ " Sets status=in_progress and owner=<owner> only when the task is todo or blocked",
233
+ " and the owner field is NULL or already set to <owner>.",
234
+ " Returns claimed (true|false), currentOwner, currentStatus, and the full task record on success.",
235
+ " Two concurrent claim calls return exactly one claimed=true.",
219
236
  ].join("\n");
220
237
 
221
238
  const SUBTASK_HELP = [
222
- "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete> [options]",
239
+ "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete|claim> [options]",
223
240
  "",
224
241
  "Create-many:",
225
242
  " trekoon subtask create-many [<task-id>] [--task <task-id>] --subtask <spec> [--subtask <spec> ...]",
@@ -250,6 +267,14 @@ const SUBTASK_HELP = [
250
267
  " trekoon subtask update <subtask-id> --all --status done|todo",
251
268
  " Accepted for consistency, but just updates the one subtask (no descendants).",
252
269
  " Don't combine positional ID + --all with --ids, --append, --description, or --title.",
270
+ "",
271
+ "Claim:",
272
+ " trekoon subtask claim <subtask-id> --owner <owner>",
273
+ " Atomically claim a subtask using SQL compare-and-swap.",
274
+ " Sets status=in_progress and owner=<owner> only when the subtask is todo or blocked",
275
+ " and the owner field is NULL or already set to <owner>.",
276
+ " Returns claimed (true|false), currentOwner, currentStatus, and the full subtask record on success.",
277
+ " Two concurrent claim calls return exactly one claimed=true.",
253
278
  ].join("\n");
254
279
 
255
280
  const DEP_HELP = [
@@ -292,16 +317,27 @@ const EVENTS_HELP = [
292
317
  ].join("\n");
293
318
 
294
319
  const MIGRATE_HELP = [
295
- "Usage: trekoon migrate <status|rollback> [--to-version <n>]",
320
+ "Usage: trekoon migrate <status|rollback|backup> [--to-version <n>] [--retain <n>]",
296
321
  "",
297
322
  "Subcommands:",
298
323
  " status Show current schema version, latest version, and pending count.",
299
324
  " rollback [--to-version <n>] Roll back migrations. Defaults to one version back.",
325
+ " backup [--retain <n>] Snapshot .trekoon/trekoon.db to a timestamped sibling file",
326
+ " before any manual migration recovery.",
327
+ " --retain keeps the last n timestamped backups",
328
+ " (default 10); older siblings are pruned.",
329
+ "",
330
+ "Notes:",
331
+ " Migrations 0004, 0005, and 0006 are irreversible (ALTER TABLE / data cleanup).",
332
+ " Rolling back below those versions errors with code migration_down_unsupported.",
333
+ " Take a backup first; restore by copying the backup over .trekoon/trekoon.db.",
300
334
  "",
301
335
  "Examples:",
302
336
  " trekoon migrate status",
303
337
  " trekoon migrate rollback",
304
338
  " trekoon migrate rollback --to-version 1",
339
+ " trekoon migrate backup",
340
+ " trekoon migrate backup --retain 5",
305
341
  ].join("\n");
306
342
 
307
343
  const SYNC_HELP = [
@@ -441,6 +477,34 @@ const SKILLS_HELP = [
441
477
  " trekoon update",
442
478
  ].join("\n");
443
479
 
480
+ const SERVE_HELP = [
481
+ "Usage: trekoon serve [--json|--toon]",
482
+ "",
483
+ "Status: EXPERIMENTAL spike. Not on by default. The default one-shot CLI",
484
+ "behavior is unchanged when this command is not running.",
485
+ "",
486
+ "Starts a foreground Trekoon daemon on a Unix-domain socket inside the",
487
+ "shared .trekoon directory. The daemon holds the SQLite connection and the",
488
+ "CLI shell in memory, so subsequent invocations skip Bun startup, module",
489
+ "load, and database open.",
490
+ "",
491
+ "Activate the client side with one of:",
492
+ " TREKOON_DAEMON=1 trekoon session",
493
+ " trekoon --daemon session",
494
+ "If the socket is missing or unreachable, the client transparently falls",
495
+ "back to the in-process one-shot path.",
496
+ "",
497
+ "Security:",
498
+ " - Socket file mode is 0o600.",
499
+ " - Parent .trekoon directory is forced to 0o700.",
500
+ " - Stale sockets from prior crashes are cleaned up on start.",
501
+ " - On Ctrl-C / SIGTERM the socket is unlinked.",
502
+ "",
503
+ "Examples:",
504
+ " trekoon serve",
505
+ " trekoon --daemon session",
506
+ ].join("\n");
507
+
444
508
  const COMMAND_HELP: Record<string, string> = {
445
509
  init: INIT_HELP,
446
510
  board: BOARD_HELP,
@@ -457,6 +521,7 @@ const COMMAND_HELP: Record<string, string> = {
457
521
  suggest: SUGGEST_HELP,
458
522
  skills: SKILLS_HELP,
459
523
  update: "Usage: trekoon update [--json|--toon]\n\nAlias for: trekoon skills update\n\nProbes and repairs all installed global and local skill symlinks.",
524
+ serve: SERVE_HELP,
460
525
  help: "Usage: trekoon help [command] [--json|--toon]",
461
526
  };
462
527
 
@@ -1,12 +1,18 @@
1
- import { parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
1
+ import { findUnknownOption, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
2
2
  import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
3
3
 
4
+ import { DomainError } from "../domain/types";
4
5
  import { failResult, okResult } from "../io/output";
5
6
  import { type CliContext, type CliResult } from "../runtime/command-types";
7
+ import { createMigrationBackup, DEFAULT_BACKUP_RETENTION } from "../storage/backup";
6
8
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
7
9
  import { describeMigrations, rollbackDatabase } from "../storage/migrations";
8
10
 
9
- const MIGRATE_USAGE = "Usage: trekoon migrate <status|rollback> [--to-version <n>]";
11
+ const MIGRATE_USAGE = "Usage: trekoon migrate <status|rollback|backup> [--to-version <n>] [--retain <n>]";
12
+
13
+ const STATUS_OPTIONS: readonly string[] = [];
14
+ const ROLLBACK_OPTIONS: readonly string[] = ["to-version"];
15
+ const BACKUP_OPTIONS: readonly string[] = ["retain"];
10
16
 
11
17
  function usage(message: string): CliResult {
12
18
  return failResult({
@@ -20,6 +26,19 @@ function usage(message: string): CliResult {
20
26
  });
21
27
  }
22
28
 
29
+ function unknownOptionResult(command: string, option: string): CliResult {
30
+ const message = `Unknown option --${option}.`;
31
+ return failResult({
32
+ command,
33
+ human: message,
34
+ data: { option: `--${option}` },
35
+ error: {
36
+ code: "unknown_option",
37
+ message,
38
+ },
39
+ });
40
+ }
41
+
23
42
  function parseVersion(rawValue: string | undefined): number | null {
24
43
  if (rawValue === undefined) {
25
44
  return null;
@@ -40,19 +59,107 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
40
59
  return usage("Missing migrate subcommand.");
41
60
  }
42
61
 
43
- const missingOption = readMissingOptionValue(parsed.missingOptionValues, "to-version");
44
- if (missingOption !== undefined) {
45
- return failResult({
46
- command: "migrate",
47
- human: `Option --${missingOption} requires a value.`,
48
- data: {
49
- option: missingOption,
50
- },
51
- error: {
52
- code: "invalid_input",
53
- message: `Option --${missingOption} requires a value.`,
54
- },
55
- });
62
+ for (const optionName of ["to-version", "retain"] as const) {
63
+ const missingOption = readMissingOptionValue(parsed.missingOptionValues, optionName);
64
+ if (missingOption !== undefined) {
65
+ return failResult({
66
+ command: "migrate",
67
+ human: `Option --${missingOption} requires a value.`,
68
+ data: {
69
+ option: missingOption,
70
+ },
71
+ error: {
72
+ code: "invalid_input",
73
+ message: `Option --${missingOption} requires a value.`,
74
+ },
75
+ });
76
+ }
77
+ }
78
+
79
+ // The backup subcommand never opens the live DB through the standard
80
+ // openTrekoonDatabase pathway: backups must work even when the DB is
81
+ // in a partially-migrated or otherwise diagnostic-blocked state.
82
+ if (subcommand === "backup") {
83
+ const unknown = findUnknownOption(parsed, BACKUP_OPTIONS);
84
+ if (unknown !== undefined) {
85
+ return unknownOptionResult("migrate.backup", unknown);
86
+ }
87
+
88
+ const retainRaw: string | undefined = readOption(parsed.options, "retain");
89
+ let retain: number = DEFAULT_BACKUP_RETENTION;
90
+ if (retainRaw !== undefined) {
91
+ if (!/^\d+$/.test(retainRaw)) {
92
+ return failResult({
93
+ command: "migrate.backup",
94
+ human: "--retain must be a positive integer.",
95
+ data: { option: "retain" },
96
+ error: {
97
+ code: "invalid_input",
98
+ message: "--retain must be a positive integer.",
99
+ },
100
+ });
101
+ }
102
+
103
+ const parsedRetain: number = Number.parseInt(retainRaw, 10);
104
+ if (parsedRetain < 1) {
105
+ return failResult({
106
+ command: "migrate.backup",
107
+ human: "--retain must be at least 1 (the new backup itself).",
108
+ data: { option: "retain" },
109
+ error: {
110
+ code: "invalid_input",
111
+ message: "--retain must be at least 1 (the new backup itself).",
112
+ },
113
+ });
114
+ }
115
+
116
+ retain = parsedRetain;
117
+ }
118
+
119
+ try {
120
+ const result = createMigrationBackup({ cwd: context.cwd, retain });
121
+ return okResult({
122
+ command: "migrate.backup",
123
+ human: [
124
+ `Backed up Trekoon database to ${result.backupPath}`,
125
+ `Bytes: ${result.bytes}`,
126
+ `Schema version at backup: ${result.migrationVersion} of ${result.latestVersion}`,
127
+ `Retained backups: ${result.retainedCount} (pruned ${result.prunedPaths.length})`,
128
+ ].join("\n"),
129
+ data: {
130
+ backupPath: result.backupPath,
131
+ bytes: result.bytes,
132
+ migrationVersion: result.migrationVersion,
133
+ latestVersion: result.latestVersion,
134
+ timestamp: result.timestamp,
135
+ retain,
136
+ retainedCount: result.retainedCount,
137
+ prunedPaths: result.prunedPaths,
138
+ },
139
+ });
140
+ } catch (error: unknown) {
141
+ if (error instanceof DomainError) {
142
+ return failResult({
143
+ command: "migrate.backup",
144
+ human: error.message,
145
+ data: { code: error.code, ...(error.details ?? {}) },
146
+ error: { code: error.code, message: error.message },
147
+ });
148
+ }
149
+
150
+ const busyFailure = sqliteBusyFailure("migrate.backup", error);
151
+ if (busyFailure !== null) {
152
+ return busyFailure;
153
+ }
154
+
155
+ const message = safeErrorMessage(error, "Unknown backup failure.");
156
+ return failResult({
157
+ command: "migrate.backup",
158
+ human: message,
159
+ data: { reason: "backup_failed" },
160
+ error: { code: "backup_failed", message },
161
+ });
162
+ }
56
163
  }
57
164
 
58
165
  let storage: TrekoonDatabase | undefined;
@@ -60,6 +167,11 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
60
167
  try {
61
168
  storage = openTrekoonDatabase(context.cwd, { autoMigrate: false });
62
169
  if (subcommand === "status") {
170
+ const unknown = findUnknownOption(parsed, STATUS_OPTIONS);
171
+ if (unknown !== undefined) {
172
+ return unknownOptionResult("migrate.status", unknown);
173
+ }
174
+
63
175
  const status = describeMigrations(storage.db);
64
176
 
65
177
  return okResult({
@@ -74,6 +186,11 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
74
186
  }
75
187
 
76
188
  if (subcommand === "rollback") {
189
+ const unknown = findUnknownOption(parsed, ROLLBACK_OPTIONS);
190
+ if (unknown !== undefined) {
191
+ return unknownOptionResult("migrate.rollback", unknown);
192
+ }
193
+
77
194
  const status = describeMigrations(storage.db);
78
195
  const parsedVersion: number | null = parseVersion(readOption(parsed.options, "to-version"));
79
196
 
@@ -92,16 +209,28 @@ export async function runMigrate(context: CliContext): Promise<CliResult> {
92
209
  }
93
210
 
94
211
  const targetVersion: number = parsedVersion ?? Math.max(0, status.currentVersion - 1);
95
- const summary = rollbackDatabase(storage.db, targetVersion);
212
+ try {
213
+ const summary = rollbackDatabase(storage.db, targetVersion);
96
214
 
97
- return okResult({
98
- command: "migrate.rollback",
99
- human: [
100
- `Rolled back ${summary.rolledBack} migration(s).`,
101
- `From version ${summary.fromVersion} to ${summary.toVersion}.`,
102
- ].join("\n"),
103
- data: summary,
104
- });
215
+ return okResult({
216
+ command: "migrate.rollback",
217
+ human: [
218
+ `Rolled back ${summary.rolledBack} migration(s).`,
219
+ `From version ${summary.fromVersion} to ${summary.toVersion}.`,
220
+ ].join("\n"),
221
+ data: summary,
222
+ });
223
+ } catch (error: unknown) {
224
+ if (error instanceof DomainError) {
225
+ return failResult({
226
+ command: "migrate.rollback",
227
+ human: error.message,
228
+ data: { code: error.code, ...(error.details ?? {}) },
229
+ error: { code: error.code, message: error.message },
230
+ });
231
+ }
232
+ throw error;
233
+ }
105
234
  }
106
235
 
107
236
  return usage(`Unknown migrate subcommand '${subcommand}'.`);
@@ -57,9 +57,11 @@ const QUICKSTART_TEXT = [
57
57
  " Filtered list: trekoon --toon task list --status in_progress,todo --limit 20",
58
58
  " Paginate: trekoon --toon task list --cursor <n>",
59
59
  " Bulk update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
60
+ " Atomic claim: trekoon --toon task claim <task-id> --owner <owner>",
60
61
  " Ready queue: trekoon --toon task ready [--limit <n>] [--epic <id>]",
61
62
  " Next candidate: trekoon --toon task next [--epic <id>]",
62
63
  " Export epic to MD: trekoon --toon epic export <epic-id> [--path <path>] [--overwrite]",
64
+ " Snapshot the DB: trekoon --toon migrate backup",
63
65
  "",
64
66
  "6) List and view defaults",
65
67
  " Default scope: open work (in_progress, todo), limit 10.",
@@ -153,7 +155,9 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
153
155
  "trekoon --toon task list --status in_progress,todo --limit 20",
154
156
  "trekoon --toon task list --cursor <n>",
155
157
  "trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
158
+ "trekoon --toon task claim <task-id> --owner <owner>",
156
159
  "trekoon --toon epic export <epic-id>",
160
+ "trekoon --toon migrate backup",
157
161
  ],
158
162
  machineExamples: [
159
163
  "trekoon --toon quickstart",
@@ -162,6 +166,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
162
166
  "trekoon --toon suggest",
163
167
  "trekoon --toon epic progress <epic-id>",
164
168
  "trekoon --toon task done <task-id>",
169
+ "trekoon --toon task claim <task-id> --owner <owner>",
165
170
  "trekoon --toon task show <task-id> --all",
166
171
  "trekoon --toon epic show <epic-id> --all",
167
172
  "trekoon --toon sync status",
@@ -169,6 +174,8 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
169
174
  "trekoon --toon task next",
170
175
  "trekoon --toon dep reverse <task-or-subtask-id>",
171
176
  "trekoon --toon epic export <epic-id>",
177
+ "trekoon --toon migrate status",
178
+ "trekoon --toon migrate backup",
172
179
  ],
173
180
  wipeWarning: {
174
181
  command: "trekoon wipe --yes",
@@ -39,6 +39,7 @@ const SEARCH_OPTIONS = ["fields", "preview"] as const;
39
39
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
40
40
  const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
41
41
  const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "owner"] as const;
42
+ const CLAIM_OPTIONS = ["owner"] as const;
42
43
  const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
43
44
 
44
45
  function parseIdsOption(rawIds: string | undefined): string[] {
@@ -892,10 +893,9 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
892
893
 
893
894
  const targets = updateAll ? [...domain.listSubtasks()] : ids.map((id) => domain.getSubtaskOrThrow(id));
894
895
  const subtasks = targets.map((target) =>
895
- mutations.updateSubtask(target.id, {
896
- status,
897
- description: append === undefined ? undefined : appendLine(target.description, append),
898
- }),
896
+ append !== undefined
897
+ ? mutations.appendToSubtaskDescription({ subtaskId: target.id, append, status })
898
+ : mutations.updateSubtask(target.id, { status }),
899
899
  );
900
900
 
901
901
  return okResult({
@@ -921,11 +921,10 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
921
921
  });
922
922
  }
923
923
 
924
- const nextDescription =
925
- append === undefined
926
- ? description
927
- : appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
928
- const subtask = mutations.updateSubtask(subtaskId, { title, description: nextDescription, status, owner });
924
+ const subtask =
925
+ append !== undefined
926
+ ? mutations.appendToSubtaskDescription({ subtaskId, append, status, owner })
927
+ : mutations.updateSubtask(subtaskId, { title, description, status, owner });
929
928
 
930
929
  return okResult({
931
930
  command: "subtask.update",
@@ -933,6 +932,68 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
933
932
  data: { subtask },
934
933
  });
935
934
  }
935
+ case "claim": {
936
+ const claimUnknownOption = findUnknownOption(parsed, CLAIM_OPTIONS);
937
+ if (claimUnknownOption !== undefined) {
938
+ return unknownOption("subtask.claim", claimUnknownOption, CLAIM_OPTIONS);
939
+ }
940
+
941
+ const missingClaimOption = readMissingOptionValue(parsed.missingOptionValues, "owner");
942
+ if (missingClaimOption !== undefined) {
943
+ return failMissingOptionValue("subtask.claim", missingClaimOption);
944
+ }
945
+
946
+ const subtaskId: string = parsed.positional[1] ?? "";
947
+ if (subtaskId.length === 0) {
948
+ return failResult({
949
+ command: "subtask.claim",
950
+ human: "Provide a subtask id. Usage: trekoon subtask claim <id> --owner <owner>",
951
+ data: { code: "invalid_input" },
952
+ error: {
953
+ code: "invalid_input",
954
+ message: "Missing subtask id",
955
+ },
956
+ });
957
+ }
958
+
959
+ const owner: string | undefined = readOption(parsed.options, "owner");
960
+ if (owner === undefined || owner.trim().length === 0) {
961
+ return failResult({
962
+ command: "subtask.claim",
963
+ human: "--owner is required. Usage: trekoon subtask claim <id> --owner <owner>",
964
+ data: { code: "invalid_input", option: "owner" },
965
+ error: {
966
+ code: "invalid_input",
967
+ message: "Missing required option --owner",
968
+ },
969
+ });
970
+ }
971
+
972
+ const claimResult = mutations.claimSubtask({ subtaskId, owner });
973
+
974
+ if (claimResult.claimed) {
975
+ return okResult({
976
+ command: "subtask.claim",
977
+ human: `Claimed subtask ${subtaskId} for ${owner}`,
978
+ data: {
979
+ claimed: true,
980
+ currentOwner: claimResult.currentOwner,
981
+ currentStatus: claimResult.currentStatus,
982
+ subtask: claimResult.subtask,
983
+ },
984
+ });
985
+ }
986
+
987
+ return okResult({
988
+ command: "subtask.claim",
989
+ human: `Subtask ${subtaskId} not claimed: status=${claimResult.currentStatus}, owner=${claimResult.currentOwner ?? "none"}`,
990
+ data: {
991
+ claimed: false,
992
+ currentOwner: claimResult.currentOwner,
993
+ currentStatus: claimResult.currentStatus,
994
+ },
995
+ });
996
+ }
936
997
  case "delete": {
937
998
  const subtaskId: string = parsed.positional[1] ?? "";
938
999
  const result = mutations.deleteSubtask(subtaskId);
@@ -946,7 +1007,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
946
1007
  default:
947
1008
  return failResult({
948
1009
  command: "subtask",
949
- human: "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete>",
1010
+ human: "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete|claim>",
950
1011
  data: {
951
1012
  args: context.args,
952
1013
  },
@@ -44,14 +44,7 @@ function resolveActiveEpic(domain: TrackerDomain, epicId: string | undefined): E
44
44
  return domain.getEpic(epicId);
45
45
  }
46
46
 
47
- const epics = domain.listEpics();
48
- const inProgress = epics.find((epic) => epic.status === "in_progress");
49
- if (inProgress) {
50
- return inProgress;
51
- }
52
-
53
- const todo = epics.find((epic) => epic.status === "todo");
54
- return todo ?? epics[0] ?? null;
47
+ return domain.findActiveEpic();
55
48
  }
56
49
 
57
50
  function findInProgressTasks(readiness: TaskReadinessResult): { count: number; first: { id: string; title: string } | null } {
@@ -76,7 +69,7 @@ function buildSuggestions(
76
69
  recoveryRequired: boolean,
77
70
  syncSummary: SyncStatusSummary,
78
71
  readiness: TaskReadinessResult,
79
- epics: readonly EpicRecord[],
72
+ epicCount: number,
80
73
  activeEpic: EpicRecord | null,
81
74
  ): readonly Suggestion[] {
82
75
  const suggestions: Suggestion[] = [];
@@ -186,7 +179,7 @@ function buildSuggestions(
186
179
  }
187
180
 
188
181
  // Priority 8: No epics exist
189
- if (suggestions.length < MAX_SUGGESTIONS && epics.length === 0) {
182
+ if (suggestions.length < MAX_SUGGESTIONS && epicCount === 0) {
190
183
  suggestions.push({
191
184
  priority: suggestions.length + 1,
192
185
  action: "quickstart",
@@ -241,7 +234,7 @@ export async function runSuggest(context: CliContext): Promise<CliResult> {
241
234
 
242
235
  const syncSummary = resolveSyncStatus(database, context.cwd, DEFAULT_SOURCE_BRANCH);
243
236
  const domain = new TrackerDomain(database.db);
244
- const epics = domain.listEpics();
237
+ const epicCount = domain.countEpics();
245
238
  const activeEpic = resolveActiveEpic(domain, epicId);
246
239
 
247
240
  const readiness = buildTaskReadiness(domain, epicId ?? activeEpic?.id);
@@ -250,14 +243,14 @@ export async function runSuggest(context: CliContext): Promise<CliResult> {
250
243
  diagnostics.recoveryRequired,
251
244
  syncSummary,
252
245
  readiness,
253
- epics,
246
+ epicCount,
254
247
  activeEpic,
255
248
  );
256
249
 
257
250
  const result: SuggestResult = {
258
251
  suggestions,
259
252
  context: {
260
- totalEpics: epics.length,
253
+ totalEpics: epicCount,
261
254
  activeEpic: activeEpic?.id ?? null,
262
255
  readyTasks: readiness.summary.readyCount,
263
256
  blockedTasks: readiness.summary.blockedCount,