trekoon 0.1.9 → 0.2.0

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.
@@ -295,7 +295,80 @@ Rules:
295
295
  - In bulk mode, do not pass a positional ID.
296
296
  - Bulk update supports `--append` and/or `--status`.
297
297
 
298
- ## 7) Setup/install/init (if `trekoon` is unavailable)
298
+ ## 7) Scoped search/replace recipes for agents
299
+
300
+ Use scoped search/replace instead of repeated `show` scans when you need to
301
+ locate or migrate repeated text inside one issue tree.
302
+
303
+ ```bash
304
+ trekoon epic search <epic-id> "path/to/somewhere" --toon
305
+ trekoon task search <task-id> "path/to/somewhere" --toon
306
+ trekoon subtask search <subtask-id> "path/to/somewhere" --toon
307
+
308
+ trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --toon
309
+ trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply --toon
310
+ ```
311
+
312
+ Guardrails:
313
+
314
+ - Use `search` first when you only need to confirm whether the text exists.
315
+ - Use preview `replace` next to confirm the exact candidate set.
316
+ - Use `--apply` only after preview matches the intended scope.
317
+ - Prefer the narrowest root that satisfies the task: `subtask` → `task` →
318
+ `epic`.
319
+ - Keep prompts deterministic: literal search text, explicit IDs, no regex
320
+ assumptions.
321
+
322
+ Agent contract for epic-scoped replace:
323
+
324
+ - Exact search command:
325
+ `trekoon epic search <epic-id> "path/to/somewhere" --toon`
326
+ - Exact replace command:
327
+ `trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --toon`
328
+ - Apply command:
329
+ `trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply --toon`
330
+ - Epic scope includes the epic title/description plus every task and subtask
331
+ title/description in that epic tree.
332
+
333
+ Compact TOON fields to expect:
334
+
335
+ ```text
336
+ ok: true
337
+ command: epic.search
338
+ data:
339
+ scope: epic
340
+ query: { search, fields[], mode: preview }
341
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
342
+ summary: { matchedEntities, matchedFields, totalMatches }
343
+ metadata:
344
+ contractVersion: 1.0.0
345
+ requestId: req-<id>
346
+ ```
347
+
348
+ ```text
349
+ ok: true
350
+ command: epic.replace
351
+ data:
352
+ scope: epic
353
+ query: { search, replace, fields[], mode: preview|apply }
354
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
355
+ summary: { matchedEntities, matchedFields, totalMatches, mode }
356
+ metadata:
357
+ contractVersion: 1.0.0
358
+ requestId: req-<id>
359
+ ```
360
+
361
+ Background behavior to assume:
362
+
363
+ - Scope traversal is deterministic: epic first, then descendant tasks, then
364
+ descendant subtasks.
365
+ - Field traversal is deterministic: `title` before `description`.
366
+ - Preview reads and summarizes candidates without mutation.
367
+ - `--apply` reuses the same scoped traversal, mutates only rows with real text
368
+ changes, and returns matched rows with `query.mode` and `summary.mode` set
369
+ to `"apply"`.
370
+
371
+ ## 8) Setup/install/init (if `trekoon` is unavailable)
299
372
 
300
373
  1. Install Trekoon (or make sure it is on `PATH`).
301
374
  2. In the target repository/worktree, initialize tracker state:
@@ -309,7 +382,7 @@ trekoon init --toon
309
382
 
310
383
  If `.trekoon/trekoon.db` is missing, initialize before any create/update commands.
311
384
 
312
- ## 8) Safety
385
+ ## 9) Safety
313
386
 
314
387
  - Never edit `.trekoon/trekoon.db` directly.
315
388
  - `trekoon wipe --yes --toon` is prohibited unless the user explicitly confirms they want a destructive wipe.
package/README.md CHANGED
@@ -48,9 +48,9 @@ npm i -g trekoon
48
48
  - `trekoon init`
49
49
  - `trekoon help [command]`
50
50
  - `trekoon quickstart`
51
- - `trekoon epic <create|list|show|update|delete>`
52
- - `trekoon task <create|list|show|ready|next|update|delete>`
53
- - `trekoon subtask <create|list|update|delete>`
51
+ - `trekoon epic <create|list|show|search|replace|update|delete>`
52
+ - `trekoon task <create|list|show|ready|next|search|replace|update|delete>`
53
+ - `trekoon subtask <create|list|search|replace|update|delete>`
54
54
  - `trekoon dep <add|remove|list|reverse>`
55
55
  - `trekoon events prune [--dry-run] [--archive] [--retention-days <n>]`
56
56
  - `trekoon migrate <status|rollback> [--to-version <n>]`
@@ -186,7 +186,88 @@ trekoon --json epic show <epic-id>
186
186
  trekoon --json task show <task-id>
187
187
  ```
188
188
 
189
- ### 6) Sync workflow for worktrees
189
+ ### 6) Scoped search for repeated text
190
+
191
+ Use scoped search before manual tree reads when you need to locate repeated
192
+ paths, labels, or migration targets.
193
+
194
+ ```bash
195
+ trekoon --toon epic search <epic-id> "path/to/somewhere"
196
+ trekoon --toon task search <task-id> "path/to/somewhere"
197
+ trekoon --toon subtask search <subtask-id> "path/to/somewhere"
198
+ ```
199
+
200
+ Scope rules:
201
+
202
+ - `epic search` scans the epic title/description plus every task and subtask
203
+ title/description in that epic tree.
204
+ - `task search` scans the task title/description plus descendant subtask
205
+ title/description.
206
+ - `subtask search` scans only that subtask's title/description.
207
+ - Add `--fields title`, `--fields description`, or
208
+ `--fields title,description` when you need a narrower scan.
209
+
210
+ ### 7) Preview first, then apply scoped replace
211
+
212
+ Use search first to confirm the scope, then run replace in preview mode, and
213
+ only use `--apply` after the preview matches the intended migration.
214
+
215
+ ```bash
216
+ trekoon --toon epic search <epic-id> "path/to/somewhere"
217
+ trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path"
218
+ trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply
219
+ ```
220
+
221
+ Use this loop for low-risk agent workflows:
222
+
223
+ 1. `search` when you need the smallest possible read before deciding whether a
224
+ migration is needed.
225
+ 2. preview `replace` to verify the exact candidate set and changed fields.
226
+ 3. `replace --apply` only after the preview output matches the intended scope.
227
+
228
+ Epic-scoped replace applies across the epic title/description and every task and
229
+ subtask title/description in that epic tree.
230
+
231
+ Compact TOON expectations for agents:
232
+
233
+ ```text
234
+ ok: true
235
+ command: epic.search
236
+ data:
237
+ scope: epic
238
+ query: { search, fields[], mode: preview }
239
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
240
+ summary: { matchedEntities, matchedFields, totalMatches }
241
+ metadata:
242
+ contractVersion: 1.0.0
243
+ requestId: req-<id>
244
+ ```
245
+
246
+ ```text
247
+ ok: true
248
+ command: epic.replace
249
+ data:
250
+ scope: epic
251
+ query: { search, replace, fields[], mode: preview|apply }
252
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
253
+ summary: { matchedEntities, matchedFields, totalMatches, mode }
254
+ metadata:
255
+ contractVersion: 1.0.0
256
+ requestId: req-<id>
257
+ ```
258
+
259
+ Background behavior:
260
+
261
+ - `epic search` and preview `epic replace` traverse the epic first, then
262
+ descendant tasks, then descendant subtasks.
263
+ - Within each record, Trekoon checks `title` before `description` so output stays
264
+ deterministic and low-token.
265
+ - Preview reports the candidate set without mutating records.
266
+ - `--apply` reuses the same scoped traversal, updates only rows with real text
267
+ changes, and returns the matched rows with `query.mode` and `summary.mode`
268
+ set to `"apply"`.
269
+
270
+ ### 8) Sync workflow for worktrees
190
271
 
191
272
  - Run `trekoon sync status` at session start and before PR/merge.
192
273
  - Run `trekoon sync pull --from main` before merge to align tracker state.
@@ -230,7 +311,7 @@ Behavior:
230
311
  - Migration path: remove `--compat legacy-sync-command-ids` and consume dotted
231
312
  command IDs directly.
232
313
 
233
- ### 7) Install project-local Trekoon skill for agents
314
+ ### 9) Install project-local Trekoon skill for agents
234
315
 
235
316
  `trekoon skills install` always writes the bundled skill file under the current
236
317
  working directory at:
@@ -292,7 +373,7 @@ This produces:
292
373
 
293
374
  Trekoon does not mutate global editor config directories.
294
375
 
295
- ### 8) Pre-merge checklist
376
+ ### 10) Pre-merge checklist
296
377
 
297
378
  - [ ] `trekoon sync status` shows no unresolved conflicts
298
379
  - [ ] done tasks/subtasks are marked completed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,6 +6,21 @@ export interface ParsedArgs {
6
6
  readonly providedOptions: readonly string[];
7
7
  }
8
8
 
9
+ export const SEARCH_REPLACE_FIELDS = ["title", "description"] as const;
10
+
11
+ export type SearchReplaceField = (typeof SEARCH_REPLACE_FIELDS)[number];
12
+
13
+ export interface ParsedCsvEnumOption<T extends string> {
14
+ readonly values: readonly T[];
15
+ readonly invalidValues: readonly string[];
16
+ readonly empty: boolean;
17
+ }
18
+
19
+ export interface PreviewApplyModeSelection {
20
+ readonly mode: "preview" | "apply";
21
+ readonly conflict: boolean;
22
+ }
23
+
9
24
  const LONG_PREFIX = "--";
10
25
 
11
26
  export function parseArgs(args: readonly string[]): ParsedArgs {
@@ -96,6 +111,73 @@ export function parseStrictNonNegativeInt(rawValue: string | undefined): number
96
111
  return parsed;
97
112
  }
98
113
 
114
+ export function parseCsvOption(rawValue: string | undefined): string[] | undefined {
115
+ if (rawValue === undefined) {
116
+ return undefined;
117
+ }
118
+
119
+ return rawValue
120
+ .split(",")
121
+ .map((value) => value.trim())
122
+ .filter((value) => value.length > 0);
123
+ }
124
+
125
+ export function parseCsvEnumOption<const T extends readonly string[]>(
126
+ rawValue: string | undefined,
127
+ allowed: T,
128
+ ): ParsedCsvEnumOption<T[number]> {
129
+ const values = parseCsvOption(rawValue);
130
+ if (values === undefined) {
131
+ return {
132
+ values: [...allowed],
133
+ invalidValues: [],
134
+ empty: false,
135
+ };
136
+ }
137
+
138
+ if (values.length === 0) {
139
+ return {
140
+ values: [...allowed],
141
+ invalidValues: [],
142
+ empty: true,
143
+ };
144
+ }
145
+
146
+ const allowedValues = new Set<string>(allowed);
147
+ const validValues: T[number][] = [];
148
+ const invalidValues: string[] = [];
149
+
150
+ for (const value of values) {
151
+ if (!allowedValues.has(value)) {
152
+ invalidValues.push(value);
153
+ continue;
154
+ }
155
+
156
+ if (!validValues.includes(value as T[number])) {
157
+ validValues.push(value as T[number]);
158
+ }
159
+ }
160
+
161
+ return {
162
+ values: validValues.length > 0 ? validValues : [...allowed],
163
+ invalidValues,
164
+ empty: false,
165
+ };
166
+ }
167
+
168
+ export function resolvePreviewApplyMode(
169
+ flags: ReadonlySet<string>,
170
+ previewKey = "preview",
171
+ applyKey = "apply",
172
+ ): PreviewApplyModeSelection {
173
+ const preview = flags.has(previewKey);
174
+ const apply = flags.has(applyKey);
175
+ return {
176
+ mode: apply ? "apply" : "preview",
177
+ conflict: preview && apply,
178
+ };
179
+ }
180
+
99
181
  function levenshteinDistance(source: string, target: string): number {
100
182
  const sourceLength = source.length;
101
183
  const targetLength = target.length;
@@ -1,16 +1,21 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
3
5
  parseArgs,
6
+ parseCsvEnumOption,
4
7
  parseStrictNonNegativeInt,
5
8
  parseStrictPositiveInt,
6
9
  readEnumOption,
7
10
  readMissingOptionValue,
8
11
  readOption,
12
+ resolvePreviewApplyMode,
13
+ suggestOptions,
9
14
  } from "./arg-parser";
10
15
 
11
16
  import { MutationService } from "../domain/mutation-service";
12
17
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type EpicRecord } from "../domain/types";
18
+ import { DomainError, type EpicRecord, type SearchEntityMatch } from "../domain/types";
14
19
  import { formatHumanTable } from "../io/human-table";
15
20
  import { failResult, okResult } from "../io/output";
16
21
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -24,6 +29,8 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
24
29
  const LIST_VIEW_MODES = ["table", "compact"] as const;
25
30
  const DEFAULT_LIST_LIMIT = 10;
26
31
  const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
32
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
33
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
27
34
 
28
35
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
29
36
  if (rawStatuses === undefined) {
@@ -36,6 +43,55 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
36
43
  .filter((value) => value.length > 0);
37
44
  }
38
45
 
46
+ function prefixedOptions(options: readonly string[]): string[] {
47
+ return options.map((option) => `--${option}`);
48
+ }
49
+
50
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
51
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
52
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
53
+ return failResult({
54
+ command,
55
+ human: `Unknown option --${option}.${suggestionMessage}`,
56
+ data: {
57
+ option: `--${option}`,
58
+ allowedOptions: prefixedOptions(allowedOptions),
59
+ suggestions,
60
+ },
61
+ error: {
62
+ code: "unknown_option",
63
+ message: `Unknown option --${option}`,
64
+ },
65
+ });
66
+ }
67
+
68
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
69
+ return failResult({
70
+ command,
71
+ human,
72
+ data,
73
+ error: {
74
+ code: "invalid_input",
75
+ message,
76
+ },
77
+ });
78
+ }
79
+
80
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
81
+ if (matches.length === 0) {
82
+ return emptyMessage;
83
+ }
84
+
85
+ return matches
86
+ .map(
87
+ (match) =>
88
+ `${match.kind} ${match.id}: ${match.fields
89
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
90
+ .join(", ")}`,
91
+ )
92
+ .join("\n");
93
+ }
94
+
39
95
  function getStatusPriority(status: string): number {
40
96
  if (status === "in_progress" || status === "in-progress") {
41
97
  return 0;
@@ -520,6 +576,134 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
520
576
  }),
521
577
  });
522
578
  }
579
+ case "search": {
580
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
581
+ if (searchUnknownOption !== undefined) {
582
+ return unknownOption("epic.search", searchUnknownOption, SEARCH_OPTIONS);
583
+ }
584
+
585
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
586
+ if (missingSearchOption !== undefined) {
587
+ return failMissingOptionValue("epic.search", missingSearchOption);
588
+ }
589
+
590
+ const epicId: string = parsed.positional[1] ?? "";
591
+ const searchText: string = parsed.positional[2] ?? "";
592
+ if (epicId.length === 0 || searchText.trim().length === 0) {
593
+ return invalidSearchInput(
594
+ "epic.search",
595
+ "Usage: trekoon epic search <epic-id> \"search text\" [--fields <csv>] [--preview]",
596
+ "Missing search target",
597
+ {
598
+ epicId,
599
+ },
600
+ );
601
+ }
602
+
603
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
604
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
605
+ return invalidSearchInput("epic.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
606
+ fields: readOption(parsed.options, "fields"),
607
+ invalidFields: parsedFields.invalidValues,
608
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
609
+ });
610
+ }
611
+
612
+ const { matches, summary } = domain.searchEpicScope(epicId, searchText, parsedFields.values);
613
+
614
+ return okResult({
615
+ command: "epic.search",
616
+ human: formatSearchHuman(matches, "No matches found."),
617
+ data: {
618
+ scope: {
619
+ kind: "epic",
620
+ id: epicId,
621
+ },
622
+ query: {
623
+ search: searchText,
624
+ fields: parsedFields.values,
625
+ mode: "preview",
626
+ },
627
+ summary,
628
+ matches,
629
+ },
630
+ });
631
+ }
632
+ case "replace": {
633
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
634
+ if (replaceUnknownOption !== undefined) {
635
+ return unknownOption("epic.replace", replaceUnknownOption, REPLACE_OPTIONS);
636
+ }
637
+
638
+ const missingReplaceOption =
639
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
640
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
641
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
642
+ if (missingReplaceOption !== undefined) {
643
+ return failMissingOptionValue("epic.replace", missingReplaceOption);
644
+ }
645
+
646
+ const epicId: string = parsed.positional[1] ?? "";
647
+ const searchText = readOption(parsed.options, "search") ?? "";
648
+ const replacementText = readOption(parsed.options, "replace") ?? "";
649
+ if (epicId.length === 0 || searchText.trim().length === 0) {
650
+ return invalidSearchInput(
651
+ "epic.replace",
652
+ "Usage: trekoon epic replace <epic-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
653
+ "Missing replace target",
654
+ {
655
+ epicId,
656
+ search: searchText,
657
+ },
658
+ );
659
+ }
660
+
661
+ const rawFields = readOption(parsed.options, "fields");
662
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
663
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
664
+ return invalidSearchInput("epic.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
665
+ fields: rawFields,
666
+ invalidFields: parsedFields.invalidValues,
667
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
668
+ });
669
+ }
670
+
671
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
672
+ if (previewMode.conflict) {
673
+ return invalidSearchInput("epic.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
674
+ flags: ["preview", "apply"],
675
+ });
676
+ }
677
+
678
+ const replacementSummary = previewMode.mode === "apply"
679
+ ? mutations.applyEpicReplacement(epicId, searchText, replacementText, parsedFields.values)
680
+ : mutations.previewEpicReplacement(epicId, searchText, replacementText, parsedFields.values);
681
+ const { matches, summary: matchSummary } = replacementSummary;
682
+
683
+ const summary = {
684
+ ...matchSummary,
685
+ mode: previewMode.mode,
686
+ };
687
+
688
+ return okResult({
689
+ command: "epic.replace",
690
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
691
+ data: {
692
+ scope: {
693
+ kind: "epic",
694
+ id: epicId,
695
+ },
696
+ query: {
697
+ search: searchText,
698
+ replace: replacementText,
699
+ fields: parsedFields.values,
700
+ mode: previewMode.mode,
701
+ },
702
+ summary,
703
+ matches,
704
+ },
705
+ });
706
+ }
523
707
  case "update": {
524
708
  const missingUpdateOption =
525
709
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -657,7 +841,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
657
841
  default:
658
842
  return failResult({
659
843
  command: "epic",
660
- human: "Usage: trekoon epic <create|list|show|update|delete>",
844
+ human: "Usage: trekoon epic <create|list|show|search|replace|update|delete>",
661
845
  data: {
662
846
  args: context.args,
663
847
  },
@@ -74,7 +74,7 @@ const WIPE_HELP = [
74
74
  ].join("\n");
75
75
 
76
76
  const EPIC_HELP = [
77
- "Usage: trekoon epic <create|list|show|update|delete> [options]",
77
+ "Usage: trekoon epic <create|list|show|search|replace|update|delete> [options]",
78
78
  "",
79
79
  "List behavior:",
80
80
  " Defaults:",
@@ -97,6 +97,16 @@ const EPIC_HELP = [
97
97
  " Machine default:",
98
98
  " - With --all, machine modes default to detail",
99
99
  "",
100
+ "Search/Replace behavior:",
101
+ " search:",
102
+ " - trekoon epic search <epic-id> \"search text\"",
103
+ " - Options: --fields title|description|title,description, --preview",
104
+ " - Scope: epic title/description + descendant task/subtask title/description",
105
+ " replace:",
106
+ " - trekoon epic replace <epic-id> --search \"text\" --replace \"text\"",
107
+ " - Preview is default; use --apply to mutate",
108
+ " - --preview and --apply are mutually exclusive",
109
+ "",
100
110
  "Update behavior:",
101
111
  " Bulk target flags:",
102
112
  " --all | --ids <csv>",
@@ -105,7 +115,7 @@ const EPIC_HELP = [
105
115
  ].join("\n");
106
116
 
107
117
  const TASK_HELP = [
108
- "Usage: trekoon task <create|list|show|ready|next|update|delete> [options]",
118
+ "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete> [options]",
109
119
  "",
110
120
  "List behavior:",
111
121
  " Defaults:",
@@ -137,6 +147,16 @@ const TASK_HELP = [
137
147
  " - Returns top ready candidate",
138
148
  " - Option: --epic <id>",
139
149
  "",
150
+ "Search/Replace behavior:",
151
+ " search:",
152
+ " - trekoon task search <task-id> \"search text\"",
153
+ " - Options: --fields title|description|title,description, --preview",
154
+ " - Scope: task title/description + descendant subtask title/description",
155
+ " replace:",
156
+ " - trekoon task replace <task-id> --search \"text\" --replace \"text\"",
157
+ " - Preview is default; use --apply to mutate",
158
+ " - --preview and --apply are mutually exclusive",
159
+ "",
140
160
  "Update behavior:",
141
161
  " Bulk target flags:",
142
162
  " --all | --ids <csv>",
@@ -145,7 +165,7 @@ const TASK_HELP = [
145
165
  ].join("\n");
146
166
 
147
167
  const SUBTASK_HELP = [
148
- "Usage: trekoon subtask <create|list|update|delete> [options]",
168
+ "Usage: trekoon subtask <create|list|search|replace|update|delete> [options]",
149
169
  "",
150
170
  "List behavior:",
151
171
  " Defaults:",
@@ -159,6 +179,16 @@ const SUBTASK_HELP = [
159
179
  " Constraints:",
160
180
  " - --all is mutually exclusive with --status, --limit, and --cursor",
161
181
  "",
182
+ "Search/Replace behavior:",
183
+ " search:",
184
+ " - trekoon subtask search <subtask-id> \"search text\"",
185
+ " - Options: --fields title|description|title,description, --preview",
186
+ " - Scope: subtask title/description only",
187
+ " replace:",
188
+ " - trekoon subtask replace <subtask-id> --search \"text\" --replace \"text\"",
189
+ " - Preview is default; use --apply to mutate",
190
+ " - --preview and --apply are mutually exclusive",
191
+ "",
162
192
  "Update behavior:",
163
193
  " Bulk target flags:",
164
194
  " --all | --ids <csv>",