opencode-planpilot 0.1.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.
package/src/cli.ts ADDED
@@ -0,0 +1,1483 @@
1
+ #!/usr/bin/env bun
2
+ import fs from "fs"
3
+ import { openDatabase, resolvePlanMarkdownPath, ensureParentDir } from "./lib/db"
4
+ import { PlanpilotApp } from "./lib/app"
5
+ import {
6
+ createEmptyStatusChanges,
7
+ statusChangesEmpty,
8
+ type GoalStatus,
9
+ type PlanOrder,
10
+ type PlanStatus,
11
+ type StepExecutor,
12
+ type StepStatus,
13
+ } from "./lib/models"
14
+ import { AppError, invalidInput } from "./lib/errors"
15
+ import { ensureNonEmpty, projectMatchesPath, resolveMaybeRealpath } from "./lib/util"
16
+ import { formatGoalDetail, formatPlanDetail, formatPlanMarkdown, formatStepDetail } from "./lib/format"
17
+
18
+ const CWD_FLAG = "--cwd"
19
+ const SESSION_ID_FLAG = "--session-id"
20
+ const DEFAULT_PAGE = 1
21
+ const DEFAULT_LIMIT = 20
22
+
23
+ export type CliIO = {
24
+ log: (...args: any[]) => void
25
+ error: (...args: any[]) => void
26
+ }
27
+
28
+ const defaultIO: CliIO = {
29
+ log: (...args) => {
30
+ console.log(...args)
31
+ },
32
+ error: (...args) => {
33
+ console.error(...args)
34
+ },
35
+ }
36
+
37
+ let currentIO: CliIO = defaultIO
38
+
39
+ function log(...args: any[]) {
40
+ currentIO.log(...args)
41
+ }
42
+
43
+ function error(...args: any[]) {
44
+ currentIO.error(...args)
45
+ }
46
+
47
+ async function withIO<T>(io: CliIO, fn: () => Promise<T> | T): Promise<T> {
48
+ const prev = currentIO
49
+ currentIO = io
50
+ try {
51
+ return await fn()
52
+ } finally {
53
+ currentIO = prev
54
+ }
55
+ }
56
+
57
+ export function formatCliError(err: unknown): string {
58
+ if (err instanceof AppError) {
59
+ return `Error: ${err.toDisplayString()}`
60
+ }
61
+ if (err instanceof Error) {
62
+ return `Error: ${err.message}`
63
+ }
64
+ return `Error: ${String(err)}`
65
+ }
66
+
67
+ export async function runCLI(argv: string[] = process.argv.slice(2), io: CliIO = defaultIO) {
68
+ return withIO(io, async () => {
69
+ const { cwd, cwdFlagPresent, sessionId, remaining } = parseGlobalArgs(argv)
70
+
71
+ if (!remaining.length) {
72
+ throw invalidInput("missing command")
73
+ }
74
+
75
+ const [section, subcommand, ...args] = remaining
76
+
77
+ const resolvedSessionId = resolveSessionId(sessionId)
78
+
79
+ const db = openDatabase()
80
+ const resolvedCwd = cwd ? resolveMaybeRealpath(cwd) : undefined
81
+ const app = new PlanpilotApp(db, resolvedSessionId, resolvedCwd)
82
+
83
+ let planIds: number[] = []
84
+ let shouldSync = false
85
+
86
+ switch (section) {
87
+ case "plan": {
88
+ const result = await handlePlan(app, subcommand, args, { cwd, cwdFlagPresent })
89
+ planIds = result.planIds
90
+ shouldSync = result.shouldSync
91
+ break
92
+ }
93
+ case "step": {
94
+ const result = await handleStep(app, subcommand, args)
95
+ planIds = result.planIds
96
+ shouldSync = result.shouldSync
97
+ break
98
+ }
99
+ case "goal": {
100
+ const result = await handleGoal(app, subcommand, args)
101
+ planIds = result.planIds
102
+ shouldSync = result.shouldSync
103
+ break
104
+ }
105
+ default:
106
+ throw invalidInput(`unknown command: ${section}`)
107
+ }
108
+
109
+ if (shouldSync) {
110
+ syncPlanMarkdown(app, planIds)
111
+ }
112
+ })
113
+ }
114
+
115
+ function parseGlobalArgs(argv: string[]) {
116
+ let cwd: string | undefined
117
+ let sessionId: string | undefined
118
+ let cwdFlagPresent = false
119
+ const remaining: string[] = []
120
+
121
+ for (let i = 0; i < argv.length; i += 1) {
122
+ const token = argv[i]
123
+ if (token === CWD_FLAG) {
124
+ cwdFlagPresent = true
125
+ cwd = argv[i + 1]
126
+ i += 1
127
+ continue
128
+ }
129
+ if (token === SESSION_ID_FLAG) {
130
+ sessionId = argv[i + 1]
131
+ i += 1
132
+ continue
133
+ }
134
+ remaining.push(token)
135
+ }
136
+
137
+ return { cwd, cwdFlagPresent, sessionId, remaining }
138
+ }
139
+
140
+ function resolveSessionId(sessionId: string | undefined): string {
141
+ if (!sessionId) {
142
+ throw invalidInput(`${SESSION_ID_FLAG} is required`)
143
+ }
144
+ const trimmed = sessionId.trim()
145
+ if (!trimmed) {
146
+ throw invalidInput(`${SESSION_ID_FLAG} is empty`)
147
+ }
148
+ return trimmed
149
+ }
150
+
151
+ function requireCwd(cwdFlagPresent: boolean, cwd: string | undefined): string {
152
+ if (!cwdFlagPresent) {
153
+ throw invalidInput(`${CWD_FLAG} is required`)
154
+ }
155
+ if (!cwd || !cwd.trim()) {
156
+ throw invalidInput(`${CWD_FLAG} is empty`)
157
+ }
158
+ return cwd
159
+ }
160
+
161
+ async function handlePlan(
162
+ app: PlanpilotApp,
163
+ subcommand: string | undefined,
164
+ args: string[],
165
+ context: { cwd: string | undefined; cwdFlagPresent: boolean },
166
+ ) {
167
+ switch (subcommand) {
168
+ case "add":
169
+ return { planIds: handlePlanAdd(app, args), shouldSync: true }
170
+ case "add-tree":
171
+ return { planIds: handlePlanAddTree(app, args), shouldSync: true }
172
+ case "list":
173
+ return { planIds: handlePlanList(app, args, context), shouldSync: false }
174
+ case "count":
175
+ return { planIds: handlePlanCount(app, args, context), shouldSync: false }
176
+ case "search":
177
+ return { planIds: handlePlanSearch(app, args, context), shouldSync: false }
178
+ case "show":
179
+ return { planIds: handlePlanShow(app, args), shouldSync: false }
180
+ case "export":
181
+ return { planIds: handlePlanExport(app, args), shouldSync: false }
182
+ case "comment":
183
+ return { planIds: handlePlanComment(app, args), shouldSync: true }
184
+ case "update":
185
+ return { planIds: handlePlanUpdate(app, args), shouldSync: true }
186
+ case "done":
187
+ return { planIds: handlePlanDone(app, args), shouldSync: true }
188
+ case "remove":
189
+ return { planIds: handlePlanRemove(app, args), shouldSync: true }
190
+ case "activate":
191
+ return { planIds: handlePlanActivate(app, args), shouldSync: true }
192
+ case "show-active":
193
+ return { planIds: handlePlanActive(app), shouldSync: false }
194
+ case "deactivate":
195
+ return { planIds: handlePlanDeactivate(app), shouldSync: true }
196
+ default:
197
+ throw invalidInput(`unknown plan command: ${subcommand ?? ""}`)
198
+ }
199
+ }
200
+
201
+ async function handleStep(app: PlanpilotApp, subcommand: string | undefined, args: string[]) {
202
+ switch (subcommand) {
203
+ case "add":
204
+ return { planIds: handleStepAdd(app, args), shouldSync: true }
205
+ case "add-tree":
206
+ return { planIds: handleStepAddTree(app, args), shouldSync: true }
207
+ case "list":
208
+ return { planIds: handleStepList(app, args), shouldSync: false }
209
+ case "count":
210
+ return { planIds: handleStepCount(app, args), shouldSync: false }
211
+ case "show":
212
+ return { planIds: handleStepShow(app, args), shouldSync: false }
213
+ case "show-next":
214
+ return { planIds: handleStepShowNext(app), shouldSync: false }
215
+ case "comment":
216
+ return { planIds: handleStepComment(app, args), shouldSync: true }
217
+ case "update":
218
+ return { planIds: handleStepUpdate(app, args), shouldSync: true }
219
+ case "done":
220
+ return { planIds: handleStepDone(app, args), shouldSync: true }
221
+ case "move":
222
+ return { planIds: handleStepMove(app, args), shouldSync: true }
223
+ case "remove":
224
+ return { planIds: handleStepRemove(app, args), shouldSync: true }
225
+ default:
226
+ throw invalidInput(`unknown step command: ${subcommand ?? ""}`)
227
+ }
228
+ }
229
+
230
+ async function handleGoal(app: PlanpilotApp, subcommand: string | undefined, args: string[]) {
231
+ switch (subcommand) {
232
+ case "add":
233
+ return { planIds: handleGoalAdd(app, args), shouldSync: true }
234
+ case "list":
235
+ return { planIds: handleGoalList(app, args), shouldSync: false }
236
+ case "count":
237
+ return { planIds: handleGoalCount(app, args), shouldSync: false }
238
+ case "show":
239
+ return { planIds: handleGoalShow(app, args), shouldSync: false }
240
+ case "comment":
241
+ return { planIds: handleGoalComment(app, args), shouldSync: true }
242
+ case "update":
243
+ return { planIds: handleGoalUpdate(app, args), shouldSync: true }
244
+ case "done":
245
+ return { planIds: handleGoalDone(app, args), shouldSync: true }
246
+ case "remove":
247
+ return { planIds: handleGoalRemove(app, args), shouldSync: true }
248
+ default:
249
+ throw invalidInput(`unknown goal command: ${subcommand ?? ""}`)
250
+ }
251
+ }
252
+
253
+ function handlePlanAdd(app: PlanpilotApp, args: string[]): number[] {
254
+ const [title, content] = args
255
+ if (!title || content === undefined) {
256
+ throw invalidInput("plan add requires <title> <content>")
257
+ }
258
+ ensureNonEmpty("plan content", content)
259
+ const plan = app.addPlan({ title, content })
260
+ log(`Created plan ID: ${plan.id}: ${plan.title}`)
261
+ return [plan.id]
262
+ }
263
+
264
+ function handlePlanAddTree(app: PlanpilotApp, args: string[]): number[] {
265
+ const [title, content, ...rest] = args
266
+ if (!title || content === undefined) {
267
+ throw invalidInput("plan add-tree requires <title> <content> and at least one --step")
268
+ }
269
+ ensureNonEmpty("plan title", title)
270
+ ensureNonEmpty("plan content", content)
271
+ const specs = parsePlanAddTreeSteps(rest)
272
+ if (!specs.length) {
273
+ throw invalidInput("plan add-tree requires at least one --step")
274
+ }
275
+ const steps = specs.map((spec) => ({
276
+ content: spec.content,
277
+ executor: spec.executor ?? "ai",
278
+ goals: spec.goals ?? [],
279
+ }))
280
+ const result = app.addPlanTree({ title, content }, steps)
281
+ log(`Created plan ID: ${result.plan.id}: ${result.plan.title} (steps: ${result.stepCount}, goals: ${result.goalCount})`)
282
+ app.setActivePlan(result.plan.id, false)
283
+ log(`Active plan set to ${result.plan.id}: ${result.plan.title}`)
284
+ return [result.plan.id]
285
+ }
286
+
287
+ function handlePlanList(
288
+ app: PlanpilotApp,
289
+ args: string[],
290
+ context: { cwd: string | undefined; cwdFlagPresent: boolean },
291
+ ): number[] {
292
+ const { options, positionals } = parseOptions(args)
293
+ if (positionals.length) {
294
+ throw invalidInput(`plan list unexpected argument: ${positionals.join(" ")}`)
295
+ }
296
+ if (options.search && options.search.length) {
297
+ throw invalidInput("plan list does not accept --search")
298
+ }
299
+ const allowed = new Set(["scope", "status", "limit", "page", "order", "desc", "search"])
300
+ for (const key of Object.keys(options)) {
301
+ if (!allowed.has(key)) {
302
+ throw invalidInput(`plan list does not support --${key}`)
303
+ }
304
+ }
305
+
306
+ const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
307
+ const cwd = requireCwd(context.cwdFlagPresent, context.cwd)
308
+
309
+ const order = options.order ? parsePlanOrder(options.order) : "updated"
310
+ const desc = options.desc ?? true
311
+ let plans = app.listPlans(order, desc)
312
+ if (!plans.length) {
313
+ log("No plans found.")
314
+ return []
315
+ }
316
+ plans = plans.filter((plan) => (desiredStatus ? plan.status === desiredStatus : true))
317
+ const scope = parseScope(options.scope)
318
+ if (scope === "project") {
319
+ const cwdValue = resolveMaybeRealpath(cwd)
320
+ plans = plans.filter((plan) => plan.last_cwd && projectMatchesPath(plan.last_cwd, cwdValue))
321
+ }
322
+ if (!plans.length) {
323
+ log("No plans found.")
324
+ return []
325
+ }
326
+ const pagination = resolvePagination(options, { limit: DEFAULT_LIMIT, page: DEFAULT_PAGE })
327
+ const total = plans.length
328
+ if (total === 0) {
329
+ log("No plans found.")
330
+ return []
331
+ }
332
+ const totalPages = Math.ceil(total / pagination.limit)
333
+ if (pagination.page > totalPages) {
334
+ log(`Page ${pagination.page} exceeds total pages ${totalPages}.`)
335
+ return []
336
+ }
337
+ const start = pagination.offset
338
+ const end = start + pagination.limit
339
+ plans = plans.slice(start, end)
340
+ const details = app.getPlanDetails(plans)
341
+ printPlanList(details)
342
+ logPageFooter(pagination.page, pagination.limit)
343
+ return []
344
+ }
345
+
346
+ function handlePlanCount(
347
+ app: PlanpilotApp,
348
+ args: string[],
349
+ context: { cwd: string | undefined; cwdFlagPresent: boolean },
350
+ ): number[] {
351
+ const { options, positionals } = parseOptions(args)
352
+ if (positionals.length) {
353
+ throw invalidInput(`plan count unexpected argument: ${positionals.join(" ")}`)
354
+ }
355
+ if (options.search && options.search.length) {
356
+ throw invalidInput("plan count does not accept --search")
357
+ }
358
+ const allowed = new Set(["scope", "status", "search"])
359
+ for (const key of Object.keys(options)) {
360
+ if (!allowed.has(key)) {
361
+ throw invalidInput(`plan count does not support --${key}`)
362
+ }
363
+ }
364
+
365
+ const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
366
+ const cwd = requireCwd(context.cwdFlagPresent, context.cwd)
367
+
368
+ let plans = app.listPlans()
369
+ if (!plans.length) {
370
+ log("Total: 0")
371
+ return []
372
+ }
373
+ plans = plans.filter((plan) => (desiredStatus ? plan.status === desiredStatus : true))
374
+ const scope = parseScope(options.scope)
375
+ if (scope === "project") {
376
+ const cwdValue = resolveMaybeRealpath(cwd)
377
+ plans = plans.filter((plan) => plan.last_cwd && projectMatchesPath(plan.last_cwd, cwdValue))
378
+ }
379
+ log(`Total: ${plans.length}`)
380
+ return []
381
+ }
382
+
383
+ function handlePlanSearch(
384
+ app: PlanpilotApp,
385
+ args: string[],
386
+ context: { cwd: string | undefined; cwdFlagPresent: boolean },
387
+ ): number[] {
388
+ const { options, positionals } = parseOptions(args)
389
+ if (positionals.length) {
390
+ throw invalidInput(`plan search unexpected argument: ${positionals.join(" ")}`)
391
+ }
392
+ if (options.search && !options.search.length) {
393
+ throw invalidInput("plan search requires at least one --search")
394
+ }
395
+ const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
396
+ const cwd = requireCwd(context.cwdFlagPresent, context.cwd)
397
+
398
+ const order = options.order ? parsePlanOrder(options.order) : "updated"
399
+ const desc = options.desc ?? true
400
+ let plans = app.listPlans(order, desc)
401
+ if (!plans.length) {
402
+ log("No plans found.")
403
+ return []
404
+ }
405
+ plans = plans.filter((plan) => (desiredStatus ? plan.status === desiredStatus : true))
406
+ const scope = parseScope(options.scope)
407
+ if (scope === "project") {
408
+ const cwdValue = resolveMaybeRealpath(cwd)
409
+ plans = plans.filter((plan) => plan.last_cwd && projectMatchesPath(plan.last_cwd, cwdValue))
410
+ }
411
+ if (!plans.length) {
412
+ log("No plans found.")
413
+ return []
414
+ }
415
+
416
+ const details = app.getPlanDetails(plans)
417
+ const query = new PlanSearchQuery(options.search, options.searchMode, options.searchField, options.matchCase)
418
+ const filtered = details.filter((detail) => planMatchesSearch(detail, query))
419
+ if (!filtered.length) {
420
+ log("No plans found.")
421
+ return []
422
+ }
423
+ const pagination = resolvePagination(options, { limit: DEFAULT_LIMIT, page: DEFAULT_PAGE })
424
+ const totalPages = Math.ceil(filtered.length / pagination.limit)
425
+ if (pagination.page > totalPages) {
426
+ log(`Page ${pagination.page} exceeds total pages ${totalPages}.`)
427
+ return []
428
+ }
429
+ const start = pagination.offset
430
+ const end = start + pagination.limit
431
+ const paged = filtered.slice(start, end)
432
+ printPlanList(paged)
433
+ logPageFooter(pagination.page, pagination.limit)
434
+ return []
435
+ }
436
+
437
+ function handlePlanShow(app: PlanpilotApp, args: string[]): number[] {
438
+ const id = parseIdArg(args, "plan show")
439
+ const detail = app.getPlanDetail(id)
440
+ log(formatPlanDetail(detail.plan, detail.steps, detail.goals))
441
+ return []
442
+ }
443
+
444
+ function handlePlanExport(app: PlanpilotApp, args: string[]): number[] {
445
+ if (args.length < 2) {
446
+ throw invalidInput("plan export requires <id> <path>")
447
+ }
448
+ const id = parseNumber(args[0], "plan id")
449
+ const filePath = args[1]
450
+ const detail = app.getPlanDetail(id)
451
+ const active = app.getActivePlan()
452
+ const isActive = active?.plan_id === detail.plan.id
453
+ const activatedAt = isActive ? active?.updated_at ?? null : null
454
+ ensureParentDir(filePath)
455
+ const markdown = formatPlanMarkdown(isActive, activatedAt ?? null, detail.plan, detail.steps, detail.goals)
456
+ fs.writeFileSync(filePath, markdown, "utf8")
457
+ log(`Exported plan ID: ${detail.plan.id} to ${filePath}`)
458
+ return []
459
+ }
460
+
461
+ function handlePlanComment(app: PlanpilotApp, args: string[]): number[] {
462
+ const entries = parseCommentPairs("plan", args)
463
+ const planIds = app.commentPlans(entries)
464
+ if (planIds.length === 1) {
465
+ log(`Updated plan comment for plan ID: ${planIds[0]}.`)
466
+ } else {
467
+ log(`Updated plan comments for ${planIds.length} plans.`)
468
+ }
469
+ return planIds
470
+ }
471
+
472
+ function handlePlanUpdate(app: PlanpilotApp, args: string[]): number[] {
473
+ if (!args.length) {
474
+ throw invalidInput("plan update requires <id>")
475
+ }
476
+ const id = parseNumber(args[0], "plan id")
477
+ const { options } = parseOptions(args.slice(1))
478
+ if (options.content !== undefined) {
479
+ ensureNonEmpty("plan content", options.content)
480
+ }
481
+ const changes = {
482
+ title: options.title,
483
+ content: options.content,
484
+ status: options.status ? parsePlanStatus(options.status) : undefined,
485
+ comment: options.comment,
486
+ }
487
+ const result = app.updatePlanWithActiveClear(id, changes)
488
+ log(`Updated plan ID: ${result.plan.id}: ${result.plan.title}`)
489
+ if (result.cleared) {
490
+ log("Active plan deactivated because plan is done.")
491
+ }
492
+ if (result.plan.status === "done") {
493
+ notifyPlanCompleted(result.plan.id)
494
+ }
495
+ return [result.plan.id]
496
+ }
497
+
498
+ function handlePlanDone(app: PlanpilotApp, args: string[]): number[] {
499
+ const id = parseIdArg(args, "plan done")
500
+ const result = app.updatePlanWithActiveClear(id, { status: "done" })
501
+ log(`Plan ID: ${result.plan.id} marked done.`)
502
+ if (result.cleared) {
503
+ log("Active plan deactivated because plan is done.")
504
+ }
505
+ if (result.plan.status === "done") {
506
+ notifyPlanCompleted(result.plan.id)
507
+ }
508
+ return [result.plan.id]
509
+ }
510
+
511
+ function handlePlanRemove(app: PlanpilotApp, args: string[]): number[] {
512
+ const id = parseIdArg(args, "plan remove")
513
+ app.deletePlan(id)
514
+ log(`Plan ID: ${id} removed.`)
515
+ return []
516
+ }
517
+
518
+ function handlePlanActivate(app: PlanpilotApp, args: string[]): number[] {
519
+ if (!args.length) {
520
+ throw invalidInput("plan activate requires <id>")
521
+ }
522
+ const id = parseNumber(args[0], "plan id")
523
+ const force = args.slice(1).includes("--force")
524
+ const plan = app.getPlan(id)
525
+ if (plan.status === "done") {
526
+ throw invalidInput("cannot activate plan; plan is done")
527
+ }
528
+ app.setActivePlan(id, force)
529
+ log(`Active plan set to ${plan.id}: ${plan.title}`)
530
+ return [plan.id]
531
+ }
532
+
533
+ function handlePlanActive(app: PlanpilotApp): number[] {
534
+ const active = app.getActivePlan()
535
+ if (!active) {
536
+ log("No active plan.")
537
+ return []
538
+ }
539
+ try {
540
+ const detail = app.getPlanDetail(active.plan_id)
541
+ log(formatPlanDetail(detail.plan, detail.steps, detail.goals))
542
+ return []
543
+ } catch (err) {
544
+ if (err instanceof AppError && err.kind === "NotFound") {
545
+ app.clearActivePlan()
546
+ log(`Active plan ID: ${active.plan_id} not found.`)
547
+ return []
548
+ }
549
+ throw err
550
+ }
551
+ }
552
+
553
+ function handlePlanDeactivate(app: PlanpilotApp): number[] {
554
+ const active = app.getActivePlan()
555
+ app.clearActivePlan()
556
+ log("Active plan deactivated.")
557
+ return active ? [active.plan_id] : []
558
+ }
559
+
560
+ function handleStepAdd(app: PlanpilotApp, args: string[]): number[] {
561
+ if (args.length < 2) {
562
+ throw invalidInput("step add requires <plan_id> <content...>")
563
+ }
564
+ const planId = parseNumber(args[0], "plan id")
565
+ const parsed = parseStepAddArgs(args.slice(1))
566
+ if (!parsed.contents.length) {
567
+ throw invalidInput("no contents provided")
568
+ }
569
+ if (parsed.at !== undefined && parsed.at === 0) {
570
+ throw invalidInput("position starts at 1")
571
+ }
572
+ parsed.contents.forEach((content) => ensureNonEmpty("step content", content))
573
+
574
+ const result = app.addStepsBatch(planId, parsed.contents, "todo", parsed.executor ?? "ai", parsed.at)
575
+ if (result.steps.length === 1) {
576
+ log(`Created step ID: ${result.steps[0].id} for plan ID: ${result.steps[0].plan_id}`)
577
+ } else {
578
+ log(`Created ${result.steps.length} steps for plan ID: ${planId}`)
579
+ }
580
+ printStatusChanges(result.changes)
581
+ return [planId]
582
+ }
583
+
584
+ function handleStepAddTree(app: PlanpilotApp, args: string[]): number[] {
585
+ if (args.length < 2) {
586
+ throw invalidInput("step add-tree requires <plan_id> <content>")
587
+ }
588
+ const planId = parseNumber(args[0], "plan id")
589
+ const content = args[1]
590
+ const parsed = parseStepAddTreeArgs(args.slice(2))
591
+ ensureNonEmpty("step content", content)
592
+ parsed.goals.forEach((goal) => ensureNonEmpty("goal content", goal))
593
+ const executor = parsed.executor ?? "ai"
594
+
595
+ const result = app.addStepTree(planId, content, executor, parsed.goals)
596
+ log(`Created step ID: ${result.step.id} for plan ID: ${result.step.plan_id} (goals: ${result.goals.length})`)
597
+ printStatusChanges(result.changes)
598
+ notifyAfterStepChanges(app, result.changes)
599
+ notifyPlansCompleted(app, result.changes)
600
+ return [planId]
601
+ }
602
+
603
+ function handleStepList(app: PlanpilotApp, args: string[]): number[] {
604
+ if (!args.length) {
605
+ throw invalidInput("step list requires <plan_id>")
606
+ }
607
+ const planId = parseNumber(args[0], "plan id")
608
+ const { options } = parseOptions(args.slice(1))
609
+ const allowed = new Set(["status", "executor", "limit", "page"])
610
+ for (const key of Object.keys(options)) {
611
+ if (key === "search" && Array.isArray(options.search) && options.search.length === 0) continue
612
+ if (!allowed.has(key)) {
613
+ throw invalidInput(`step list does not support --${key}`)
614
+ }
615
+ }
616
+ const status = parseStepStatusFilter(options.status)
617
+ const pagination = resolvePagination(options, { limit: DEFAULT_LIMIT, page: DEFAULT_PAGE })
618
+ const countQuery = {
619
+ status,
620
+ executor: options.executor ? parseStepExecutor(options.executor) : undefined,
621
+ limit: undefined,
622
+ offset: undefined,
623
+ order: undefined,
624
+ desc: undefined,
625
+ }
626
+ const total = app.countSteps(planId, countQuery)
627
+ if (total === 0) {
628
+ log(`No steps found for plan ID: ${planId}.`)
629
+ return []
630
+ }
631
+ const totalPages = Math.ceil(total / pagination.limit)
632
+ if (pagination.page > totalPages) {
633
+ log(`Page ${pagination.page} exceeds total pages ${totalPages} for plan ID: ${planId}.`)
634
+ return []
635
+ }
636
+
637
+ const query = {
638
+ status,
639
+ executor: options.executor ? parseStepExecutor(options.executor) : undefined,
640
+ limit: pagination.limit,
641
+ offset: pagination.offset,
642
+ order: "order" as const,
643
+ desc: false,
644
+ }
645
+
646
+ const steps = app.listStepsFiltered(planId, query)
647
+ const details = app.getStepsDetail(steps)
648
+ printStepList(details)
649
+ logPageFooter(pagination.page, pagination.limit)
650
+ return []
651
+ }
652
+
653
+ function handleStepCount(app: PlanpilotApp, args: string[]): number[] {
654
+ if (!args.length) {
655
+ throw invalidInput("step count requires <plan_id>")
656
+ }
657
+ const planId = parseNumber(args[0], "plan id")
658
+ const { options } = parseOptions(args.slice(1))
659
+ const allowed = new Set(["status", "executor"])
660
+ for (const key of Object.keys(options)) {
661
+ if (key === "search" && Array.isArray(options.search) && options.search.length === 0) continue
662
+ if (!allowed.has(key)) {
663
+ throw invalidInput(`step count does not support --${key}`)
664
+ }
665
+ }
666
+ const status = parseStepStatusFilter(options.status)
667
+ const query = {
668
+ status,
669
+ executor: options.executor ? parseStepExecutor(options.executor) : undefined,
670
+ limit: undefined,
671
+ offset: undefined,
672
+ order: undefined,
673
+ desc: undefined,
674
+ }
675
+ const total = app.countSteps(planId, query)
676
+ log(`Total: ${total}`)
677
+ return []
678
+ }
679
+
680
+ function handleStepShow(app: PlanpilotApp, args: string[]): number[] {
681
+ const id = parseIdArg(args, "step show")
682
+ const detail = app.getStepDetail(id)
683
+ log(formatStepDetail(detail.step, detail.goals))
684
+ return []
685
+ }
686
+
687
+ function handleStepShowNext(app: PlanpilotApp): number[] {
688
+ const active = app.getActivePlan()
689
+ if (!active) {
690
+ log("No active plan.")
691
+ return []
692
+ }
693
+ const next = app.nextStep(active.plan_id)
694
+ if (!next) {
695
+ log("No pending step.")
696
+ return []
697
+ }
698
+ const goals = app.goalsForStep(next.id)
699
+ log(formatStepDetail(next, goals))
700
+ return []
701
+ }
702
+
703
+ function handleStepUpdate(app: PlanpilotApp, args: string[]): number[] {
704
+ if (!args.length) {
705
+ throw invalidInput("step update requires <id>")
706
+ }
707
+ const id = parseNumber(args[0], "step id")
708
+ const { options } = parseOptions(args.slice(1))
709
+ if (options.content !== undefined) {
710
+ ensureNonEmpty("step content", options.content)
711
+ }
712
+ const status = options.status ? parseStepStatus(options.status) : undefined
713
+ const result = app.updateStep(id, {
714
+ content: options.content,
715
+ status,
716
+ executor: options.executor ? parseStepExecutor(options.executor) : undefined,
717
+ comment: options.comment,
718
+ })
719
+ log(`Updated step ID: ${result.step.id}.`)
720
+ printStatusChanges(result.changes)
721
+ if (status === "done" && result.step.status === "done") {
722
+ notifyNextStepForPlan(app, result.step.plan_id)
723
+ }
724
+ notifyPlansCompleted(app, result.changes)
725
+ return [result.step.plan_id]
726
+ }
727
+
728
+ function handleStepComment(app: PlanpilotApp, args: string[]): number[] {
729
+ const entries = parseCommentPairs("step", args)
730
+ const planIds = app.commentSteps(entries)
731
+ if (planIds.length === 1) {
732
+ log(`Updated step comments for plan ID: ${planIds[0]}.`)
733
+ } else {
734
+ log(`Updated step comments for ${planIds.length} plans.`)
735
+ }
736
+ return planIds
737
+ }
738
+
739
+ function handleStepDone(app: PlanpilotApp, args: string[]): number[] {
740
+ if (!args.length) {
741
+ throw invalidInput("step done requires <id>")
742
+ }
743
+ const id = parseNumber(args[0], "step id")
744
+ const allGoals = args.slice(1).includes("--all-goals")
745
+ const result = app.setStepDoneWithGoals(id, allGoals)
746
+ log(`Step ID: ${result.step.id} marked done.`)
747
+ printStatusChanges(result.changes)
748
+ notifyNextStepForPlan(app, result.step.plan_id)
749
+ notifyPlansCompleted(app, result.changes)
750
+ return [result.step.plan_id]
751
+ }
752
+
753
+ function handleStepMove(app: PlanpilotApp, args: string[]): number[] {
754
+ if (!args.length) {
755
+ throw invalidInput("step move requires <id> --to <pos>")
756
+ }
757
+ const id = parseNumber(args[0], "step id")
758
+ const toIndex = args.indexOf("--to")
759
+ if (toIndex === -1 || toIndex === args.length - 1) {
760
+ throw invalidInput("step move requires --to <pos>")
761
+ }
762
+ const to = parseNumber(args[toIndex + 1], "position")
763
+ if (to === 0) {
764
+ throw invalidInput("position starts at 1")
765
+ }
766
+ const steps = app.moveStep(id, to)
767
+ log(`Reordered steps for plan ID: ${steps[0].plan_id}:`)
768
+ const details = app.getStepsDetail(steps)
769
+ printStepList(details)
770
+ return [steps[0].plan_id]
771
+ }
772
+
773
+ function handleStepRemove(app: PlanpilotApp, args: string[]): number[] {
774
+ if (!args.length) {
775
+ throw invalidInput("no step ids provided")
776
+ }
777
+ const ids = args.map((arg) => parseNumber(arg, "step id"))
778
+ const planIds = app.planIdsForSteps(ids)
779
+ const result = app.deleteSteps(ids)
780
+ if (ids.length === 1) {
781
+ log(`Step ID: ${ids[0]} removed.`)
782
+ } else {
783
+ log(`Removed ${result.deleted} steps.`)
784
+ }
785
+ printStatusChanges(result.changes)
786
+ return planIds
787
+ }
788
+
789
+ function handleGoalAdd(app: PlanpilotApp, args: string[]): number[] {
790
+ if (args.length < 2) {
791
+ throw invalidInput("goal add requires <step_id> <content...>")
792
+ }
793
+ const stepId = parseNumber(args[0], "step id")
794
+ const contents = args.slice(1)
795
+ if (!contents.length) {
796
+ throw invalidInput("no contents provided")
797
+ }
798
+ contents.forEach((content) => ensureNonEmpty("goal content", content))
799
+ const result = app.addGoalsBatch(stepId, contents, "todo")
800
+ if (result.goals.length === 1) {
801
+ log(`Created goal ID: ${result.goals[0].id} for step ID: ${result.goals[0].step_id}`)
802
+ } else {
803
+ log(`Created ${result.goals.length} goals for step ID: ${stepId}`)
804
+ }
805
+ printStatusChanges(result.changes)
806
+ notifyAfterStepChanges(app, result.changes)
807
+ notifyPlansCompleted(app, result.changes)
808
+ const step = app.getStep(stepId)
809
+ return [step.plan_id]
810
+ }
811
+
812
+ function handleGoalList(app: PlanpilotApp, args: string[]): number[] {
813
+ if (!args.length) {
814
+ throw invalidInput("goal list requires <step_id>")
815
+ }
816
+ const stepId = parseNumber(args[0], "step id")
817
+ const { options } = parseOptions(args.slice(1))
818
+ const status = parseGoalStatusFilter(options.status)
819
+ const pagination = resolvePagination(options, { limit: DEFAULT_LIMIT, page: DEFAULT_PAGE })
820
+ const countQuery = {
821
+ status,
822
+ limit: undefined,
823
+ offset: undefined,
824
+ }
825
+ const total = app.countGoals(stepId, countQuery)
826
+ if (total === 0) {
827
+ log(`No goals found for step ID: ${stepId}.`)
828
+ return []
829
+ }
830
+ const totalPages = Math.ceil(total / pagination.limit)
831
+ if (pagination.page > totalPages) {
832
+ log(`Page ${pagination.page} exceeds total pages ${totalPages} for step ID: ${stepId}.`)
833
+ return []
834
+ }
835
+
836
+ const query = {
837
+ status,
838
+ limit: pagination.limit,
839
+ offset: pagination.offset,
840
+ }
841
+
842
+ const goals = app.listGoalsFiltered(stepId, query)
843
+ printGoalList(goals)
844
+ logPageFooter(pagination.page, pagination.limit)
845
+ return []
846
+ }
847
+
848
+ function handleGoalCount(app: PlanpilotApp, args: string[]): number[] {
849
+ if (!args.length) {
850
+ throw invalidInput("goal count requires <step_id>")
851
+ }
852
+ const stepId = parseNumber(args[0], "step id")
853
+ const { options } = parseOptions(args.slice(1))
854
+ const status = parseGoalStatusFilter(options.status)
855
+ const query = {
856
+ status,
857
+ limit: undefined,
858
+ offset: undefined,
859
+ }
860
+ const total = app.countGoals(stepId, query)
861
+ log(`Total: ${total}`)
862
+ return []
863
+ }
864
+
865
+ function handleGoalShow(app: PlanpilotApp, args: string[]): number[] {
866
+ const id = parseIdArg(args, "goal show")
867
+ const detail = app.getGoalDetail(id)
868
+ log(formatGoalDetail(detail.goal, detail.step))
869
+ return []
870
+ }
871
+
872
+ function handleGoalComment(app: PlanpilotApp, args: string[]): number[] {
873
+ const entries = parseCommentPairs("goal", args)
874
+ const planIds = app.commentGoals(entries)
875
+ if (planIds.length === 1) {
876
+ log(`Updated goal comments for plan ID: ${planIds[0]}.`)
877
+ } else {
878
+ log(`Updated goal comments for ${planIds.length} plans.`)
879
+ }
880
+ return planIds
881
+ }
882
+
883
+ function handleGoalUpdate(app: PlanpilotApp, args: string[]): number[] {
884
+ if (!args.length) {
885
+ throw invalidInput("goal update requires <id>")
886
+ }
887
+ const id = parseNumber(args[0], "goal id")
888
+ const { options } = parseOptions(args.slice(1))
889
+ if (options.content !== undefined) {
890
+ ensureNonEmpty("goal content", options.content)
891
+ }
892
+ const result = app.updateGoal(id, {
893
+ content: options.content,
894
+ status: options.status ? parseGoalStatus(options.status) : undefined,
895
+ comment: options.comment,
896
+ })
897
+ log(`Updated goal ${result.goal.id}.`)
898
+ printStatusChanges(result.changes)
899
+ notifyAfterStepChanges(app, result.changes)
900
+ notifyPlansCompleted(app, result.changes)
901
+ const step = app.getStep(result.goal.step_id)
902
+ return [step.plan_id]
903
+ }
904
+
905
+ function handleGoalDone(app: PlanpilotApp, args: string[]): number[] {
906
+ if (!args.length) {
907
+ throw invalidInput("goal done requires <id>")
908
+ }
909
+ const ids = args.map((arg) => parseNumber(arg, "goal id"))
910
+ if (ids.length === 1) {
911
+ const result = app.setGoalStatus(ids[0], "done")
912
+ log(`Goal ID: ${result.goal.id} marked done.`)
913
+ printStatusChanges(result.changes)
914
+ notifyAfterStepChanges(app, result.changes)
915
+ notifyPlansCompleted(app, result.changes)
916
+ const step = app.getStep(result.goal.step_id)
917
+ return [step.plan_id]
918
+ }
919
+ const planIds = app.planIdsForGoals(ids)
920
+ const result = app.setGoalsStatus(ids, "done")
921
+ log(`Goals marked done: ${result.updated}.`)
922
+ printStatusChanges(result.changes)
923
+ notifyAfterStepChanges(app, result.changes)
924
+ notifyPlansCompleted(app, result.changes)
925
+ return planIds
926
+ }
927
+
928
+ function handleGoalRemove(app: PlanpilotApp, args: string[]): number[] {
929
+ if (!args.length) {
930
+ throw invalidInput("no goal ids provided")
931
+ }
932
+ const ids = args.map((arg) => parseNumber(arg, "goal id"))
933
+ const planIds = app.planIdsForGoals(ids)
934
+ const result = app.deleteGoals(ids)
935
+ if (ids.length === 1) {
936
+ log(`Goal ID: ${ids[0]} removed.`)
937
+ } else {
938
+ log(`Removed ${result.deleted} goals.`)
939
+ }
940
+ printStatusChanges(result.changes)
941
+ notifyAfterStepChanges(app, result.changes)
942
+ notifyPlansCompleted(app, result.changes)
943
+ return planIds
944
+ }
945
+
946
+ function parseIdArg(args: string[], label: string): number {
947
+ if (!args.length) {
948
+ throw invalidInput(`${label} requires <id>`)
949
+ }
950
+ return parseNumber(args[0], "id")
951
+ }
952
+
953
+ function parseNumber(value: string, label: string): number {
954
+ const num = Number(value)
955
+ if (!Number.isFinite(num) || !Number.isInteger(num)) {
956
+ throw invalidInput(`${label} '${value}' is invalid`)
957
+ }
958
+ return num
959
+ }
960
+
961
+ function parseOptions(args: string[]) {
962
+ const options: Record<string, any> = { search: [] }
963
+ const positionals: string[] = []
964
+ let i = 0
965
+ while (i < args.length) {
966
+ const token = args[i]
967
+ if (!token.startsWith("--")) {
968
+ positionals.push(token)
969
+ i += 1
970
+ continue
971
+ }
972
+ switch (token) {
973
+ case "--scope":
974
+ options.scope = expectValue(args, i, token)
975
+ i += 2
976
+ break
977
+ case "--match-case":
978
+ options.matchCase = true
979
+ i += 1
980
+ break
981
+ case "--desc":
982
+ options.desc = true
983
+ i += 1
984
+ break
985
+ case "--all-goals":
986
+ options.allGoals = true
987
+ i += 1
988
+ break
989
+ case "--force":
990
+ options.force = true
991
+ i += 1
992
+ break
993
+ case "--search":
994
+ options.search.push(expectValue(args, i, token))
995
+ i += 2
996
+ break
997
+ case "--search-mode":
998
+ options.searchMode = expectValue(args, i, token)
999
+ i += 2
1000
+ break
1001
+ case "--search-field":
1002
+ options.searchField = expectValue(args, i, token)
1003
+ i += 2
1004
+ break
1005
+ case "--title":
1006
+ options.title = expectValue(args, i, token)
1007
+ i += 2
1008
+ break
1009
+ case "--content":
1010
+ options.content = expectValue(args, i, token)
1011
+ i += 2
1012
+ break
1013
+ case "--status":
1014
+ options.status = expectValue(args, i, token)
1015
+ i += 2
1016
+ break
1017
+ case "--comment":
1018
+ options.comment = expectValue(args, i, token)
1019
+ i += 2
1020
+ break
1021
+ case "--executor":
1022
+ options.executor = expectValue(args, i, token)
1023
+ i += 2
1024
+ break
1025
+ case "--limit":
1026
+ options.limit = expectValue(args, i, token)
1027
+ i += 2
1028
+ break
1029
+ case "--page":
1030
+ options.page = expectValue(args, i, token)
1031
+ i += 2
1032
+ break
1033
+ case "--order":
1034
+ options.order = expectValue(args, i, token)
1035
+ i += 2
1036
+ break
1037
+ case "--to":
1038
+ options.to = expectValue(args, i, token)
1039
+ i += 2
1040
+ break
1041
+ case "--goal":
1042
+ if (!options.goals) options.goals = []
1043
+ options.goals.push(expectValue(args, i, token))
1044
+ i += 2
1045
+ break
1046
+ default:
1047
+ throw invalidInput(`unexpected argument: ${token}`)
1048
+ }
1049
+ }
1050
+ return { options, positionals }
1051
+ }
1052
+
1053
+ function expectValue(args: string[], index: number, token: string): string {
1054
+ const value = args[index + 1]
1055
+ if (value === undefined) {
1056
+ throw invalidInput(`${token} requires a value`)
1057
+ }
1058
+ return value
1059
+ }
1060
+
1061
+ function parsePlanStatus(value: string): PlanStatus {
1062
+ const normalized = value.trim().toLowerCase()
1063
+ if (normalized === "todo" || normalized === "done") return normalized
1064
+ throw invalidInput(`invalid status '${value}', expected todo|done`)
1065
+ }
1066
+
1067
+ function parsePlanStatusFilter(value?: string): PlanStatus | null {
1068
+ if (!value) return null
1069
+ const normalized = value.trim().toLowerCase()
1070
+ if (normalized === "all") return null
1071
+ return parsePlanStatus(normalized)
1072
+ }
1073
+
1074
+ function parseStepStatus(value: string): StepStatus {
1075
+ return parsePlanStatus(value) as StepStatus
1076
+ }
1077
+
1078
+ function parseGoalStatus(value: string): GoalStatus {
1079
+ return parsePlanStatus(value) as GoalStatus
1080
+ }
1081
+
1082
+ function parseStepStatusFilter(value?: string): StepStatus | null {
1083
+ if (!value) return null
1084
+ const normalized = value.trim().toLowerCase()
1085
+ if (normalized === "all") return null
1086
+ return parseStepStatus(normalized)
1087
+ }
1088
+
1089
+ function parseGoalStatusFilter(value?: string): GoalStatus | null {
1090
+ if (!value) return null
1091
+ const normalized = value.trim().toLowerCase()
1092
+ if (normalized === "all") return null
1093
+ return parseGoalStatus(normalized)
1094
+ }
1095
+
1096
+ function parseScope(value?: string): "project" | "all" {
1097
+ if (!value) return "project"
1098
+ const normalized = value.trim().toLowerCase()
1099
+ if (normalized === "project" || normalized === "all") return normalized as "project" | "all"
1100
+ throw invalidInput(`invalid scope '${value}', expected project|all`)
1101
+ }
1102
+
1103
+ function parsePlanOrder(value: string): PlanOrder {
1104
+ const normalized = value.trim().toLowerCase()
1105
+ if (normalized === "id" || normalized === "title" || normalized === "created" || normalized === "updated") {
1106
+ return normalized as PlanOrder
1107
+ }
1108
+ throw invalidInput(`invalid order '${value}', expected id|title|created|updated`)
1109
+ }
1110
+
1111
+ function parseStepExecutor(value: string): StepExecutor {
1112
+ const normalized = value.trim().toLowerCase()
1113
+ if (normalized === "ai" || normalized === "human") return normalized
1114
+ throw invalidInput(`invalid executor '${value}', expected ai|human`)
1115
+ }
1116
+
1117
+ function resolvePagination(
1118
+ options: Record<string, any>,
1119
+ defaults: { limit: number; page: number },
1120
+ ): { limit: number; page: number; offset: number } {
1121
+ if (defaults.limit <= 0 || defaults.page < 1) {
1122
+ throw invalidInput("invalid default pagination configuration")
1123
+ }
1124
+
1125
+ const limit = options.limit !== undefined ? parseNumber(options.limit, "limit") : defaults.limit
1126
+ if (limit <= 0) {
1127
+ throw invalidInput("limit must be >= 1")
1128
+ }
1129
+
1130
+ const page = options.page !== undefined ? parseNumber(options.page, "page") : defaults.page
1131
+ if (page < 1) {
1132
+ throw invalidInput("page must be >= 1")
1133
+ }
1134
+
1135
+ return { limit, page, offset: (page - 1) * limit }
1136
+ }
1137
+
1138
+ type PlanSearchMode = "any" | "all"
1139
+ type PlanSearchField = "plan" | "title" | "content" | "comment" | "steps" | "goals" | "all"
1140
+
1141
+ class PlanSearchQuery {
1142
+ terms: string[]
1143
+ mode: PlanSearchMode
1144
+ field: PlanSearchField
1145
+ matchCase: boolean
1146
+
1147
+ constructor(rawTerms: string[] = [], searchMode?: string, searchField?: string, matchCase?: boolean) {
1148
+ let terms = rawTerms.map((term) => term.trim()).filter((term) => term.length > 0)
1149
+ const caseSensitive = !!matchCase
1150
+ if (!caseSensitive) {
1151
+ terms = terms.map((term) => term.toLowerCase())
1152
+ }
1153
+ this.terms = terms
1154
+ this.mode = parseSearchMode(searchMode)
1155
+ this.field = parseSearchField(searchField)
1156
+ this.matchCase = caseSensitive
1157
+ }
1158
+
1159
+ hasTerms() {
1160
+ return this.terms.length > 0
1161
+ }
1162
+ }
1163
+
1164
+ function parseSearchMode(value?: string): PlanSearchMode {
1165
+ if (!value) return "all"
1166
+ const normalized = value.trim().toLowerCase()
1167
+ if (normalized === "any" || normalized === "all") return normalized
1168
+ throw invalidInput(`invalid search mode '${value}', expected any|all`)
1169
+ }
1170
+
1171
+ function parseSearchField(value?: string): PlanSearchField {
1172
+ if (!value) return "plan"
1173
+ const normalized = value.trim().toLowerCase()
1174
+ if (["plan", "title", "content", "comment", "steps", "goals", "all"].includes(normalized)) {
1175
+ return normalized as PlanSearchField
1176
+ }
1177
+ throw invalidInput(
1178
+ `invalid search field '${value}', expected plan|title|content|comment|steps|goals|all`
1179
+ )
1180
+ }
1181
+
1182
+ function planMatchesSearch(detail: ReturnType<PlanpilotApp["getPlanDetail"]>, search: PlanSearchQuery): boolean {
1183
+ const haystacks: string[] = []
1184
+ const addValue = (value: string) => {
1185
+ haystacks.push(search.matchCase ? value : value.toLowerCase())
1186
+ }
1187
+
1188
+ const includePlan = search.field === "plan" || search.field === "all"
1189
+ const includeTitle = search.field === "title" || includePlan || search.field === "all"
1190
+ const includeContent = search.field === "content" || includePlan || search.field === "all"
1191
+ const includeComment = search.field === "comment" || includePlan || search.field === "all"
1192
+ const includeSteps = search.field === "steps" || search.field === "all"
1193
+ const includeGoals = search.field === "goals" || search.field === "all"
1194
+
1195
+ if (includePlan || includeTitle) addValue(detail.plan.title)
1196
+ if (includePlan || includeContent) addValue(detail.plan.content)
1197
+ if (includePlan || includeComment) {
1198
+ if (detail.plan.comment) addValue(detail.plan.comment)
1199
+ }
1200
+ if (includeSteps) {
1201
+ detail.steps.forEach((step) => addValue(step.content))
1202
+ }
1203
+ if (includeGoals) {
1204
+ detail.goals.forEach((goalList) => goalList.forEach((goal) => addValue(goal.content)))
1205
+ }
1206
+
1207
+ if (!haystacks.length) return false
1208
+ if (search.mode === "any") {
1209
+ return search.terms.some((term) => haystacks.some((value) => value.includes(term)))
1210
+ }
1211
+ return search.terms.every((term) => haystacks.some((value) => value.includes(term)))
1212
+ }
1213
+
1214
+ function parsePlanAddTreeSteps(args: string[]) {
1215
+ if (!args.length) {
1216
+ throw invalidInput("plan add-tree requires at least one --step")
1217
+ }
1218
+
1219
+ const steps: Array<{ content: string; executor?: StepExecutor; goals?: string[] }> = []
1220
+ let current: { content: string; executor?: StepExecutor; goals: string[] } | null = null
1221
+ let i = 0
1222
+ while (i < args.length) {
1223
+ const token = args[i]
1224
+ if (token === "--") {
1225
+ i += 1
1226
+ continue
1227
+ }
1228
+ if (token === "--step") {
1229
+ const value = args[i + 1]
1230
+ if (value === undefined) {
1231
+ throw invalidInput("plan add-tree --step requires a value")
1232
+ }
1233
+ if (!value.trim()) {
1234
+ throw invalidInput("plan add-tree --step cannot be empty")
1235
+ }
1236
+ if (current) {
1237
+ steps.push({ content: current.content, executor: current.executor, goals: current.goals.length ? current.goals : undefined })
1238
+ }
1239
+ current = { content: value, goals: [] }
1240
+ i += 2
1241
+ continue
1242
+ }
1243
+ if (token === "--executor") {
1244
+ const value = args[i + 1]
1245
+ if (value === undefined) {
1246
+ throw invalidInput("plan add-tree --executor requires a value")
1247
+ }
1248
+ if (!current) {
1249
+ throw invalidInput("plan add-tree --executor must follow a --step")
1250
+ }
1251
+ current.executor = parseStepExecutor(value)
1252
+ i += 2
1253
+ continue
1254
+ }
1255
+ if (token === "--goal") {
1256
+ const value = args[i + 1]
1257
+ if (value === undefined) {
1258
+ throw invalidInput("plan add-tree --goal requires a value")
1259
+ }
1260
+ if (!current) {
1261
+ throw invalidInput("plan add-tree --goal must follow a --step")
1262
+ }
1263
+ current.goals.push(value)
1264
+ i += 2
1265
+ continue
1266
+ }
1267
+ throw invalidInput(`plan add-tree unexpected argument: ${token}`)
1268
+ }
1269
+
1270
+ if (current) {
1271
+ steps.push({ content: current.content, executor: current.executor, goals: current.goals.length ? current.goals : undefined })
1272
+ }
1273
+
1274
+ if (!steps.length) {
1275
+ throw invalidInput("plan add-tree requires at least one --step")
1276
+ }
1277
+ return steps
1278
+ }
1279
+
1280
+ function parseCommentPairs(kind: string, pairs: string[]): Array<[number, string]> {
1281
+ if (!pairs.length) {
1282
+ throw invalidInput(`${kind} comment requires <id> <comment> pairs`)
1283
+ }
1284
+ if (pairs.length % 2 !== 0) {
1285
+ throw invalidInput(`${kind} comment expects <id> <comment> pairs`)
1286
+ }
1287
+ const entries: Array<[number, string]> = []
1288
+ for (let i = 0; i < pairs.length; i += 2) {
1289
+ const idValue = pairs[i]
1290
+ const comment = pairs[i + 1]
1291
+ const id = parseNumber(idValue, `${kind} comment id`)
1292
+ ensureNonEmpty("comment", comment)
1293
+ entries.push([id, comment])
1294
+ }
1295
+ return entries
1296
+ }
1297
+
1298
+ function parseStepAddArgs(args: string[]) {
1299
+ const contents: string[] = []
1300
+ let executor: StepExecutor | undefined
1301
+ let at: number | undefined
1302
+ let i = 0
1303
+ while (i < args.length) {
1304
+ const token = args[i]
1305
+ if (token === "--executor") {
1306
+ const value = expectValue(args, i, token)
1307
+ executor = parseStepExecutor(value)
1308
+ i += 2
1309
+ continue
1310
+ }
1311
+ if (token === "--at") {
1312
+ const value = expectValue(args, i, token)
1313
+ at = parseNumber(value, "position")
1314
+ i += 2
1315
+ continue
1316
+ }
1317
+ if (token.startsWith("--")) {
1318
+ throw invalidInput(`unexpected argument: ${token}`)
1319
+ }
1320
+ contents.push(token)
1321
+ i += 1
1322
+ }
1323
+ return { contents, executor, at }
1324
+ }
1325
+
1326
+ function parseStepAddTreeArgs(args: string[]) {
1327
+ let executor: StepExecutor | undefined
1328
+ const goals: string[] = []
1329
+ let i = 0
1330
+ while (i < args.length) {
1331
+ const token = args[i]
1332
+ if (token === "--executor") {
1333
+ const value = expectValue(args, i, token)
1334
+ executor = parseStepExecutor(value)
1335
+ i += 2
1336
+ continue
1337
+ }
1338
+ if (token === "--goal") {
1339
+ const value = expectValue(args, i, token)
1340
+ goals.push(value)
1341
+ i += 2
1342
+ continue
1343
+ }
1344
+ throw invalidInput(`unexpected argument: ${token}`)
1345
+ }
1346
+ return { executor, goals }
1347
+ }
1348
+
1349
+ function printStatusChanges(changes: ReturnType<typeof createEmptyStatusChanges>) {
1350
+ if (statusChangesEmpty(changes)) return
1351
+ log("Auto status updates:")
1352
+ changes.steps.forEach((change) => {
1353
+ log(`- Step ID: ${change.step_id} status auto-updated from ${change.from} to ${change.to} (${change.reason}).`)
1354
+ })
1355
+ changes.plans.forEach((change) => {
1356
+ log(`- Plan ID: ${change.plan_id} status auto-updated from ${change.from} to ${change.to} (${change.reason}).`)
1357
+ })
1358
+ changes.active_plans_cleared.forEach((change) => {
1359
+ log(`- Active plan deactivated for plan ID: ${change.plan_id} (${change.reason}).`)
1360
+ })
1361
+ }
1362
+
1363
+ function notifyAfterStepChanges(app: PlanpilotApp, changes: ReturnType<typeof createEmptyStatusChanges>) {
1364
+ const planIds = new Set<number>()
1365
+ changes.steps.forEach((change) => {
1366
+ if (change.to === "done") {
1367
+ const step = app.getStep(change.step_id)
1368
+ planIds.add(step.plan_id)
1369
+ }
1370
+ })
1371
+ planIds.forEach((planId) => notifyNextStepForPlan(app, planId))
1372
+ }
1373
+
1374
+ function notifyPlansCompleted(app: PlanpilotApp, changes: ReturnType<typeof createEmptyStatusChanges>) {
1375
+ const planIds = new Set<number>()
1376
+ changes.plans.forEach((change) => {
1377
+ if (change.to === "done") planIds.add(change.plan_id)
1378
+ })
1379
+ planIds.forEach((planId) => {
1380
+ const plan = app.getPlan(planId)
1381
+ if (plan.status === "done") {
1382
+ notifyPlanCompleted(plan.id)
1383
+ }
1384
+ })
1385
+ }
1386
+
1387
+ function notifyPlanCompleted(planId: number) {
1388
+ log(`Plan ID: ${planId} is complete. Summarize the completed results to the user, then end this turn.`)
1389
+ }
1390
+
1391
+ function notifyNextStepForPlan(app: PlanpilotApp, planId: number) {
1392
+ const next = app.nextStep(planId)
1393
+ if (!next) return
1394
+ if (next.executor === "ai") {
1395
+ log(`Next step is assigned to ai (step ID: ${next.id}). Please end this turn so Planpilot can surface it.`)
1396
+ return
1397
+ }
1398
+ const goals = app.goalsForStep(next.id)
1399
+ log("Next step requires human action:")
1400
+ log(formatStepDetail(next, goals))
1401
+ log(
1402
+ "Tell the user to complete the above step and goals. Confirm each goal when done, then end this turn."
1403
+ )
1404
+ }
1405
+
1406
+ const COL_ID = 6
1407
+ const COL_STAT = 6
1408
+ const COL_STEPS = 9
1409
+ const COL_ORDER = 5
1410
+ const COL_EXEC = 6
1411
+ const COL_GOALS = 9
1412
+ const COL_TEXT = 30
1413
+
1414
+ function printPlanList(details: ReturnType<PlanpilotApp["getPlanDetails"]>) {
1415
+ log(`${pad("ID", COL_ID)} ${pad("STAT", COL_STAT)} ${pad("STEPS", COL_STEPS)} ${pad("TITLE", COL_TEXT)} COMMENT`)
1416
+ details.forEach((detail) => {
1417
+ const total = detail.steps.length
1418
+ const done = detail.steps.filter((step) => step.status === "done").length
1419
+ log(
1420
+ `${pad(String(detail.plan.id), COL_ID)} ${pad(detail.plan.status, COL_STAT)} ${pad(`${done}/${total}`, COL_STEPS)} ${pad(detail.plan.title, COL_TEXT)} ${detail.plan.comment ?? ""}`
1421
+ )
1422
+ })
1423
+ }
1424
+
1425
+ function printStepList(details: ReturnType<PlanpilotApp["getStepsDetail"]>) {
1426
+ log(
1427
+ `${pad("ID", COL_ID)} ${pad("STAT", COL_STAT)} ${pad("ORD", COL_ORDER)} ${pad("EXEC", COL_EXEC)} ${pad("GOALS", COL_GOALS)} ${pad("CONTENT", COL_TEXT)} COMMENT`
1428
+ )
1429
+ details.forEach((detail) => {
1430
+ const total = detail.goals.length
1431
+ const done = detail.goals.filter((goal) => goal.status === "done").length
1432
+ log(
1433
+ `${pad(String(detail.step.id), COL_ID)} ${pad(detail.step.status, COL_STAT)} ${pad(String(detail.step.sort_order), COL_ORDER)} ${pad(detail.step.executor, COL_EXEC)} ${pad(`${done}/${total}`, COL_GOALS)} ${pad(detail.step.content, COL_TEXT)} ${detail.step.comment ?? ""}`
1434
+ )
1435
+ })
1436
+ }
1437
+
1438
+ function printGoalList(goals: ReturnType<PlanpilotApp["listGoalsFiltered"]>) {
1439
+ log(`${pad("ID", COL_ID)} ${pad("STAT", COL_STAT)} ${pad("CONTENT", COL_TEXT)} COMMENT`)
1440
+ goals.forEach((goal) => {
1441
+ log(`${pad(String(goal.id), COL_ID)} ${pad(goal.status, COL_STAT)} ${pad(goal.content, COL_TEXT)} ${goal.comment ?? ""}`)
1442
+ })
1443
+ }
1444
+
1445
+ function logPageFooter(page: number, limit: number) {
1446
+ log(`Page ${page} / Limit ${limit}`)
1447
+ }
1448
+
1449
+ function pad(value: string, width: number) {
1450
+ if (value.length >= width) return value
1451
+ return value.padEnd(width, " ")
1452
+ }
1453
+
1454
+ function syncPlanMarkdown(app: PlanpilotApp, planIds: number[]) {
1455
+ if (!planIds.length) return
1456
+ const unique = Array.from(new Set(planIds))
1457
+ const active = app.getActivePlan()
1458
+ const activeId = active?.plan_id
1459
+ const activeUpdated = active?.updated_at ?? null
1460
+
1461
+ unique.forEach((planId) => {
1462
+ let detail
1463
+ try {
1464
+ detail = app.getPlanDetail(planId)
1465
+ } catch (err) {
1466
+ if (err instanceof AppError && err.kind === "NotFound") return
1467
+ throw err
1468
+ }
1469
+ const isActive = activeId === planId
1470
+ const activatedAt = isActive ? activeUpdated : null
1471
+ const mdPath = resolvePlanMarkdownPath(planId)
1472
+ ensureParentDir(mdPath)
1473
+ const markdown = formatPlanMarkdown(isActive, activatedAt, detail.plan, detail.steps, detail.goals)
1474
+ fs.writeFileSync(mdPath, markdown, "utf8")
1475
+ })
1476
+ }
1477
+
1478
+ if (import.meta.main) {
1479
+ runCLI(process.argv.slice(2), defaultIO).catch((err) => {
1480
+ error(formatCliError(err))
1481
+ process.exit(1)
1482
+ })
1483
+ }