opencode-planpilot 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -10
- package/package.json +2 -10
- package/src/{cli.ts → command.ts} +94 -97
- package/src/index.ts +59 -57
- package/src/lib/app.ts +47 -1
- package/src/lib/util.ts +54 -0
- package/src/prompt.ts +104 -0
- package/docs/planpilot.md +0 -220
- package/src/lib/argv.ts +0 -58
- package/src/lib/instructions.ts +0 -26
package/README.md
CHANGED
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
# opencode-planpilot
|
|
2
2
|
|
|
3
|
-
Planpilot
|
|
3
|
+
Planpilot for OpenCode. Provides plan/step/goal workflow with auto-continue for AI steps and a native `planpilot` tool.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
|
-
- Plan/step/goal hierarchy with
|
|
6
|
+
- Plan/step/goal hierarchy with status auto-propagation upward (goals -> steps -> plan)
|
|
7
7
|
- SQLite storage with markdown plan snapshots
|
|
8
|
-
- Native OpenCode tool
|
|
8
|
+
- Native OpenCode tool for plan/step/goal operations
|
|
9
9
|
- Auto-continue on `session.idle` when next step is assigned to `ai`
|
|
10
10
|
|
|
11
|
-
## Requirements
|
|
12
|
-
- Bun runtime (uses `bun:sqlite` at runtime)
|
|
13
|
-
|
|
14
11
|
## Install
|
|
15
12
|
|
|
16
13
|
Add to `opencode.json`:
|
|
17
14
|
|
|
18
15
|
```json
|
|
19
16
|
{
|
|
20
|
-
"$schema": "https://opencode.ai/config.json",
|
|
21
17
|
"plugin": ["opencode-planpilot"]
|
|
22
18
|
}
|
|
23
19
|
```
|
|
24
20
|
|
|
25
21
|
OpenCode installs npm plugins automatically at startup.
|
|
26
22
|
|
|
27
|
-
## Details
|
|
28
|
-
Usage and storage info: `docs/planpilot.md`
|
|
29
|
-
|
|
30
23
|
## License
|
|
31
24
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-planpilot",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Planpilot plugin for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -14,18 +14,10 @@
|
|
|
14
14
|
"packageManager": "bun@1.3.6",
|
|
15
15
|
"main": "./src/index.ts",
|
|
16
16
|
"types": "./src/index.ts",
|
|
17
|
-
"bin": {
|
|
18
|
-
"planpilot": "src/cli.ts",
|
|
19
|
-
"opencode-planpilot": "src/cli.ts"
|
|
20
|
-
},
|
|
21
17
|
"exports": {
|
|
22
18
|
".": {
|
|
23
19
|
"import": "./src/index.ts",
|
|
24
20
|
"types": "./src/index.ts"
|
|
25
|
-
},
|
|
26
|
-
"./cli": {
|
|
27
|
-
"import": "./src/cli.ts",
|
|
28
|
-
"types": "./src/cli.ts"
|
|
29
21
|
}
|
|
30
22
|
},
|
|
31
23
|
"files": [
|
|
@@ -49,7 +41,7 @@
|
|
|
49
41
|
},
|
|
50
42
|
"devDependencies": {
|
|
51
43
|
"@opencode-ai/plugin": "*",
|
|
52
|
-
"@types/node": "^25.0.
|
|
44
|
+
"@types/node": "^25.0.9",
|
|
53
45
|
"bun-types": "^1.3.6",
|
|
54
46
|
"eslint": "^9.39.2",
|
|
55
47
|
"@eslint/js": "^9.39.2",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
1
|
import fs from "fs"
|
|
3
2
|
import { openDatabase, resolvePlanMarkdownPath, ensureParentDir } from "./lib/db"
|
|
4
3
|
import { PlanpilotApp } from "./lib/app"
|
|
@@ -14,37 +13,31 @@ import {
|
|
|
14
13
|
import { AppError, invalidInput } from "./lib/errors"
|
|
15
14
|
import { ensureNonEmpty, projectMatchesPath, resolveMaybeRealpath } from "./lib/util"
|
|
16
15
|
import { formatGoalDetail, formatPlanDetail, formatPlanMarkdown, formatStepDetail } from "./lib/format"
|
|
16
|
+
import { PLANPILOT_HELP_TEXT } from "./prompt"
|
|
17
17
|
|
|
18
|
-
const CWD_FLAG = "--cwd"
|
|
19
|
-
const SESSION_ID_FLAG = "--session-id"
|
|
20
18
|
const DEFAULT_PAGE = 1
|
|
21
19
|
const DEFAULT_LIMIT = 20
|
|
22
20
|
|
|
23
|
-
export type
|
|
21
|
+
export type CommandIO = {
|
|
24
22
|
log: (...args: any[]) => void
|
|
25
|
-
error: (...args: any[]) => void
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
error: (...args) => {
|
|
33
|
-
console.error(...args)
|
|
34
|
-
},
|
|
25
|
+
export type CommandContext = {
|
|
26
|
+
sessionId: string
|
|
27
|
+
cwd: string
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
const noopIO: CommandIO = {
|
|
31
|
+
log: () => {},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let currentIO: CommandIO = noopIO
|
|
38
35
|
|
|
39
36
|
function log(...args: any[]) {
|
|
40
37
|
currentIO.log(...args)
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
function
|
|
44
|
-
currentIO.error(...args)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function withIO<T>(io: CliIO, fn: () => Promise<T> | T): Promise<T> {
|
|
40
|
+
async function withIO<T>(io: CommandIO, fn: () => Promise<T> | T): Promise<T> {
|
|
48
41
|
const prev = currentIO
|
|
49
42
|
currentIO = io
|
|
50
43
|
try {
|
|
@@ -54,7 +47,7 @@ async function withIO<T>(io: CliIO, fn: () => Promise<T> | T): Promise<T> {
|
|
|
54
47
|
}
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
export function
|
|
50
|
+
export function formatCommandError(err: unknown): string {
|
|
58
51
|
if (err instanceof AppError) {
|
|
59
52
|
return `Error: ${err.toDisplayString()}`
|
|
60
53
|
}
|
|
@@ -64,28 +57,32 @@ export function formatCliError(err: unknown): string {
|
|
|
64
57
|
return `Error: ${String(err)}`
|
|
65
58
|
}
|
|
66
59
|
|
|
67
|
-
export async function
|
|
60
|
+
export async function runCommand(argv: string[], context: CommandContext, io: CommandIO = noopIO) {
|
|
68
61
|
return withIO(io, async () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!remaining.length) {
|
|
72
|
-
throw invalidInput("missing command")
|
|
62
|
+
if (!argv.length) {
|
|
63
|
+
throw invalidInput("missing argv")
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
const [section, subcommand, ...args] =
|
|
76
|
-
|
|
77
|
-
const resolvedSessionId = resolveSessionId(sessionId)
|
|
66
|
+
const [section, subcommand, ...args] = argv
|
|
78
67
|
|
|
79
68
|
const db = openDatabase()
|
|
80
|
-
const resolvedCwd =
|
|
81
|
-
const app = new PlanpilotApp(db,
|
|
69
|
+
const resolvedCwd = resolveMaybeRealpath(context.cwd)
|
|
70
|
+
const app = new PlanpilotApp(db, context.sessionId, resolvedCwd)
|
|
82
71
|
|
|
83
72
|
let planIds: number[] = []
|
|
84
73
|
let shouldSync = false
|
|
85
74
|
|
|
86
75
|
switch (section) {
|
|
76
|
+
case "help": {
|
|
77
|
+
if (subcommand !== undefined || args.length) {
|
|
78
|
+
const rest = [subcommand, ...args].filter((x) => x !== undefined)
|
|
79
|
+
throw invalidInput(`help unexpected argument: ${rest.join(" ")}`)
|
|
80
|
+
}
|
|
81
|
+
log(PLANPILOT_HELP_TEXT)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
87
84
|
case "plan": {
|
|
88
|
-
const result = await handlePlan(app, subcommand, args, { cwd
|
|
85
|
+
const result = await handlePlan(app, subcommand, args, { cwd: context.cwd })
|
|
89
86
|
planIds = result.planIds
|
|
90
87
|
shouldSync = result.shouldSync
|
|
91
88
|
break
|
|
@@ -112,57 +109,20 @@ export async function runCLI(argv: string[] = process.argv.slice(2), io: CliIO =
|
|
|
112
109
|
})
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
function
|
|
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
|
-
}
|
|
112
|
+
function requireCwd(cwd: string | undefined): string {
|
|
155
113
|
if (!cwd || !cwd.trim()) {
|
|
156
|
-
throw invalidInput(
|
|
114
|
+
throw invalidInput("cwd is required")
|
|
157
115
|
}
|
|
158
116
|
return cwd
|
|
159
117
|
}
|
|
160
118
|
|
|
119
|
+
export type { CommandContext as PlanpilotCommandContext, CommandIO as PlanpilotCommandIO }
|
|
120
|
+
|
|
161
121
|
async function handlePlan(
|
|
162
122
|
app: PlanpilotApp,
|
|
163
123
|
subcommand: string | undefined,
|
|
164
124
|
args: string[],
|
|
165
|
-
context: { cwd: string | undefined
|
|
125
|
+
context: { cwd: string | undefined },
|
|
166
126
|
) {
|
|
167
127
|
switch (subcommand) {
|
|
168
128
|
case "add":
|
|
@@ -212,6 +172,8 @@ async function handleStep(app: PlanpilotApp, subcommand: string | undefined, arg
|
|
|
212
172
|
return { planIds: handleStepShow(app, args), shouldSync: false }
|
|
213
173
|
case "show-next":
|
|
214
174
|
return { planIds: handleStepShowNext(app), shouldSync: false }
|
|
175
|
+
case "wait":
|
|
176
|
+
return { planIds: handleStepWait(app, args), shouldSync: true }
|
|
215
177
|
case "comment":
|
|
216
178
|
return { planIds: handleStepComment(app, args), shouldSync: true }
|
|
217
179
|
case "update":
|
|
@@ -281,13 +243,18 @@ function handlePlanAddTree(app: PlanpilotApp, args: string[]): number[] {
|
|
|
281
243
|
log(`Created plan ID: ${result.plan.id}: ${result.plan.title} (steps: ${result.stepCount}, goals: ${result.goalCount})`)
|
|
282
244
|
app.setActivePlan(result.plan.id, false)
|
|
283
245
|
log(`Active plan set to ${result.plan.id}: ${result.plan.title}`)
|
|
246
|
+
|
|
247
|
+
// Print full detail so the AI can reference plan/step/goal IDs immediately.
|
|
248
|
+
const detail = app.getPlanDetail(result.plan.id)
|
|
249
|
+
log("")
|
|
250
|
+
log(formatPlanDetail(detail.plan, detail.steps, detail.goals))
|
|
284
251
|
return [result.plan.id]
|
|
285
252
|
}
|
|
286
253
|
|
|
287
254
|
function handlePlanList(
|
|
288
255
|
app: PlanpilotApp,
|
|
289
256
|
args: string[],
|
|
290
|
-
context: { cwd: string | undefined
|
|
257
|
+
context: { cwd: string | undefined },
|
|
291
258
|
): number[] {
|
|
292
259
|
const { options, positionals } = parseOptions(args)
|
|
293
260
|
if (positionals.length) {
|
|
@@ -304,7 +271,7 @@ function handlePlanList(
|
|
|
304
271
|
}
|
|
305
272
|
|
|
306
273
|
const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
|
|
307
|
-
const cwd = requireCwd(context.
|
|
274
|
+
const cwd = requireCwd(context.cwd)
|
|
308
275
|
|
|
309
276
|
const order = options.order ? parsePlanOrder(options.order) : "updated"
|
|
310
277
|
const desc = options.desc ?? true
|
|
@@ -346,7 +313,7 @@ function handlePlanList(
|
|
|
346
313
|
function handlePlanCount(
|
|
347
314
|
app: PlanpilotApp,
|
|
348
315
|
args: string[],
|
|
349
|
-
context: { cwd: string | undefined
|
|
316
|
+
context: { cwd: string | undefined },
|
|
350
317
|
): number[] {
|
|
351
318
|
const { options, positionals } = parseOptions(args)
|
|
352
319
|
if (positionals.length) {
|
|
@@ -363,7 +330,7 @@ function handlePlanCount(
|
|
|
363
330
|
}
|
|
364
331
|
|
|
365
332
|
const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
|
|
366
|
-
const cwd = requireCwd(context.
|
|
333
|
+
const cwd = requireCwd(context.cwd)
|
|
367
334
|
|
|
368
335
|
let plans = app.listPlans()
|
|
369
336
|
if (!plans.length) {
|
|
@@ -383,7 +350,7 @@ function handlePlanCount(
|
|
|
383
350
|
function handlePlanSearch(
|
|
384
351
|
app: PlanpilotApp,
|
|
385
352
|
args: string[],
|
|
386
|
-
context: { cwd: string | undefined
|
|
353
|
+
context: { cwd: string | undefined },
|
|
387
354
|
): number[] {
|
|
388
355
|
const { options, positionals } = parseOptions(args)
|
|
389
356
|
if (positionals.length) {
|
|
@@ -393,7 +360,7 @@ function handlePlanSearch(
|
|
|
393
360
|
throw invalidInput("plan search requires at least one --search")
|
|
394
361
|
}
|
|
395
362
|
const desiredStatus: PlanStatus | null = parsePlanStatusFilter(options.status)
|
|
396
|
-
const cwd = requireCwd(context.
|
|
363
|
+
const cwd = requireCwd(context.cwd)
|
|
397
364
|
|
|
398
365
|
const order = options.order ? parsePlanOrder(options.order) : "updated"
|
|
399
366
|
const desc = options.desc ?? true
|
|
@@ -700,6 +667,30 @@ function handleStepShowNext(app: PlanpilotApp): number[] {
|
|
|
700
667
|
return []
|
|
701
668
|
}
|
|
702
669
|
|
|
670
|
+
function handleStepWait(app: PlanpilotApp, args: string[]): number[] {
|
|
671
|
+
if (!args.length) {
|
|
672
|
+
throw invalidInput("step wait requires <id>")
|
|
673
|
+
}
|
|
674
|
+
const stepId = parseNumber(args[0], "step id")
|
|
675
|
+
const options = parseOptions(args.slice(1)).options
|
|
676
|
+
if (options.clear) {
|
|
677
|
+
const result = app.clearStepWait(stepId)
|
|
678
|
+
log(`Step ID: ${result.step.id} wait cleared.`)
|
|
679
|
+
return [result.step.plan_id]
|
|
680
|
+
}
|
|
681
|
+
if (options.delay === undefined) {
|
|
682
|
+
throw invalidInput("step wait requires --delay <ms> or --clear")
|
|
683
|
+
}
|
|
684
|
+
const delayMs = parseNumber(options.delay, "delay")
|
|
685
|
+
if (delayMs < 0) {
|
|
686
|
+
throw invalidInput("delay must be >= 0")
|
|
687
|
+
}
|
|
688
|
+
const reason = options.reason ? String(options.reason) : undefined
|
|
689
|
+
const result = app.setStepWait(stepId, delayMs, reason)
|
|
690
|
+
log(`Step ID: ${result.step.id} waiting until ${result.until}.`)
|
|
691
|
+
return [result.step.plan_id]
|
|
692
|
+
}
|
|
693
|
+
|
|
703
694
|
function handleStepUpdate(app: PlanpilotApp, args: string[]): number[] {
|
|
704
695
|
if (!args.length) {
|
|
705
696
|
throw invalidInput("step update requires <id>")
|
|
@@ -1034,18 +1025,31 @@ function parseOptions(args: string[]) {
|
|
|
1034
1025
|
options.order = expectValue(args, i, token)
|
|
1035
1026
|
i += 2
|
|
1036
1027
|
break
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1028
|
+
case "--to":
|
|
1029
|
+
options.to = expectValue(args, i, token)
|
|
1030
|
+
i += 2
|
|
1031
|
+
break
|
|
1032
|
+
case "--delay":
|
|
1033
|
+
options.delay = expectValue(args, i, token)
|
|
1034
|
+
i += 2
|
|
1035
|
+
break
|
|
1036
|
+
case "--reason":
|
|
1037
|
+
options.reason = expectValue(args, i, token)
|
|
1038
|
+
i += 2
|
|
1039
|
+
break
|
|
1040
|
+
case "--clear":
|
|
1041
|
+
options.clear = true
|
|
1042
|
+
i += 1
|
|
1043
|
+
break
|
|
1044
|
+
case "--goal":
|
|
1045
|
+
if (!options.goals) options.goals = []
|
|
1046
|
+
options.goals.push(expectValue(args, i, token))
|
|
1047
|
+
i += 2
|
|
1048
|
+
break
|
|
1049
|
+
default:
|
|
1050
|
+
throw invalidInput(`unexpected argument: ${token}`)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1049
1053
|
}
|
|
1050
1054
|
return { options, positionals }
|
|
1051
1055
|
}
|
|
@@ -1474,10 +1478,3 @@ function syncPlanMarkdown(app: PlanpilotApp, planIds: number[]) {
|
|
|
1474
1478
|
fs.writeFileSync(mdPath, markdown, "utf8")
|
|
1475
1479
|
})
|
|
1476
1480
|
}
|
|
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
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { tool, type Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
import {
|
|
2
|
+
import { runCommand, formatCommandError } from "./command"
|
|
3
3
|
import { PlanpilotApp } from "./lib/app"
|
|
4
|
-
import { parseCommandArgs } from "./lib/argv"
|
|
5
4
|
import { openDatabase } from "./lib/db"
|
|
6
5
|
import { invalidInput } from "./lib/errors"
|
|
7
6
|
import { formatStepDetail } from "./lib/format"
|
|
8
|
-
import {
|
|
7
|
+
import { parseWaitFromComment } from "./lib/util"
|
|
8
|
+
import { PLANPILOT_SYSTEM_INJECTION, PLANPILOT_TOOL_DESCRIPTION, formatPlanpilotAutoContinueMessage } from "./prompt"
|
|
9
9
|
|
|
10
10
|
export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
11
11
|
const inFlight = new Set<string>()
|
|
12
12
|
const skipNextAuto = new Set<string>()
|
|
13
13
|
const lastIdleAt = new Map<string, number>()
|
|
14
|
+
const waitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const clearWaitTimer = (sessionID: string) => {
|
|
17
|
+
const existing = waitTimers.get(sessionID)
|
|
18
|
+
if (existing) {
|
|
19
|
+
clearTimeout(existing)
|
|
20
|
+
waitTimers.delete(sessionID)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
21
23
|
|
|
22
24
|
const log = async (level: "debug" | "info" | "warn" | "error", message: string, extra?: Record<string, any>) => {
|
|
23
25
|
try {
|
|
@@ -132,6 +134,32 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
132
134
|
const next = app.nextStep(active.plan_id)
|
|
133
135
|
if (!next) return
|
|
134
136
|
if (next.executor !== "ai") return
|
|
137
|
+
const wait = parseWaitFromComment(next.comment)
|
|
138
|
+
if (wait && wait.until > now) {
|
|
139
|
+
clearWaitTimer(sessionID)
|
|
140
|
+
await log("info", "auto-continue delayed by step wait", {
|
|
141
|
+
sessionID,
|
|
142
|
+
stepId: next.id,
|
|
143
|
+
until: wait.until,
|
|
144
|
+
reason: wait.reason,
|
|
145
|
+
})
|
|
146
|
+
const msUntil = Math.max(0, wait.until - now)
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
waitTimers.delete(sessionID)
|
|
149
|
+
handleSessionIdle(sessionID).catch((err) => {
|
|
150
|
+
void log("warn", "auto-continue retry failed", {
|
|
151
|
+
sessionID,
|
|
152
|
+
error: err instanceof Error ? err.message : String(err),
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
}, msUntil)
|
|
156
|
+
waitTimers.set(sessionID, timer)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!wait) {
|
|
160
|
+
clearWaitTimer(sessionID)
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
const goals = app.goalsForStep(next.id)
|
|
136
164
|
const detail = formatStepDetail(next, goals)
|
|
137
165
|
if (!detail.trim()) return
|
|
@@ -143,12 +171,11 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
143
171
|
}
|
|
144
172
|
if (autoContext?.aborted || autoContext?.ready === false) return
|
|
145
173
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
detail.trimEnd()
|
|
174
|
+
const timestamp = new Date().toISOString()
|
|
175
|
+
const message = formatPlanpilotAutoContinueMessage({
|
|
176
|
+
timestamp,
|
|
177
|
+
stepDetail: detail,
|
|
178
|
+
})
|
|
152
179
|
|
|
153
180
|
const promptBody: any = {
|
|
154
181
|
agent: autoContext?.agent ?? undefined,
|
|
@@ -171,76 +198,51 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
return {
|
|
201
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
202
|
+
output.system.push(PLANPILOT_SYSTEM_INJECTION)
|
|
203
|
+
},
|
|
174
204
|
tool: {
|
|
175
205
|
planpilot: tool({
|
|
176
|
-
description:
|
|
177
|
-
"Planpilot planner. Use for all plan/step/goal operations. Provide either argv (array) or command (string). " +
|
|
178
|
-
"Do not include --session-id/--cwd; they are injected automatically from the current session.",
|
|
206
|
+
description: PLANPILOT_TOOL_DESCRIPTION,
|
|
179
207
|
args: {
|
|
180
|
-
argv: tool.schema.array(tool.schema.string()).
|
|
181
|
-
command: tool.schema.string().min(1),
|
|
208
|
+
argv: tool.schema.array(tool.schema.string()).min(1),
|
|
182
209
|
},
|
|
183
210
|
async execute(args, toolCtx) {
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
} else if (typeof args.command === "string" && args.command.trim()) {
|
|
188
|
-
argv = parseCommandArgs(args.command)
|
|
189
|
-
} else {
|
|
190
|
-
return formatCliError(invalidInput("missing command"))
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const cwd = (ctx.directory ?? "").trim()
|
|
194
|
-
if (!cwd) {
|
|
195
|
-
return formatCliError(invalidInput(`${"--cwd"} is required`))
|
|
211
|
+
const argv = Array.isArray(args.argv) ? args.argv : []
|
|
212
|
+
if (!argv.length) {
|
|
213
|
+
return formatCommandError(invalidInput("missing argv"))
|
|
196
214
|
}
|
|
197
215
|
|
|
198
216
|
if (containsForbiddenFlags(argv)) {
|
|
199
|
-
return
|
|
217
|
+
return formatCommandError(invalidInput("argv cannot include --cwd or --session-id"))
|
|
200
218
|
}
|
|
201
219
|
|
|
202
|
-
const
|
|
203
|
-
if (!
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
if (!finalArgv.includes("--session-id")) {
|
|
207
|
-
finalArgv.unshift("--session-id", toolCtx.sessionID)
|
|
220
|
+
const cwd = (ctx.directory ?? "").trim()
|
|
221
|
+
if (!cwd) {
|
|
222
|
+
return formatCommandError(invalidInput("cwd is required"))
|
|
208
223
|
}
|
|
209
224
|
|
|
210
225
|
const output: string[] = []
|
|
211
226
|
const io = {
|
|
212
227
|
log: (...parts: any[]) => output.push(parts.map(String).join(" ")),
|
|
213
|
-
error: (...parts: any[]) => output.push(parts.map(String).join(" ")),
|
|
214
228
|
}
|
|
215
229
|
|
|
216
230
|
try {
|
|
217
|
-
await
|
|
231
|
+
await runCommand(argv, { sessionId: toolCtx.sessionID, cwd }, io)
|
|
218
232
|
} catch (err) {
|
|
219
|
-
return
|
|
233
|
+
return formatCommandError(err)
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
return output.join("\n").trimEnd()
|
|
223
237
|
},
|
|
224
238
|
}),
|
|
225
239
|
},
|
|
226
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
227
|
-
const instructions = loadPlanpilotInstructions().trim()
|
|
228
|
-
const alreadyInjected = output.system.some((entry) => entry.includes("Planpilot (OpenCode Tool)"))
|
|
229
|
-
if (instructions && !alreadyInjected) {
|
|
230
|
-
output.system.push(instructions)
|
|
231
|
-
}
|
|
232
|
-
const guidanceInjected = output.system.some((entry) => entry.includes("Planpilot guidance:"))
|
|
233
|
-
if (!guidanceInjected) {
|
|
234
|
-
output.system.push(PLANPILOT_GUIDANCE)
|
|
235
|
-
}
|
|
236
|
-
},
|
|
237
240
|
"experimental.session.compacting": async ({ sessionID }, output) => {
|
|
238
|
-
const hasGuidance = output.context.some((entry) => entry.includes("Planpilot guidance:"))
|
|
239
|
-
if (!hasGuidance) {
|
|
240
|
-
output.context.push(PLANPILOT_GUIDANCE)
|
|
241
|
-
}
|
|
242
241
|
skipNextAuto.add(sessionID)
|
|
243
242
|
lastIdleAt.set(sessionID, Date.now())
|
|
243
|
+
|
|
244
|
+
// Compaction runs with tools disabled; inject Planpilot guidance into the continuation summary.
|
|
245
|
+
output.context.push(PLANPILOT_TOOL_DESCRIPTION)
|
|
244
246
|
},
|
|
245
247
|
event: async ({ event }) => {
|
|
246
248
|
if (event.type === "session.idle") {
|
package/src/lib/app.ts
CHANGED
|
@@ -22,7 +22,15 @@ import {
|
|
|
22
22
|
type StepRow,
|
|
23
23
|
type StepStatus,
|
|
24
24
|
} from "./models"
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
ensureNonEmpty,
|
|
27
|
+
joinIds,
|
|
28
|
+
normalizeCommentEntries,
|
|
29
|
+
parseWaitFromComment,
|
|
30
|
+
removeWaitFromComment,
|
|
31
|
+
uniqueIds,
|
|
32
|
+
upsertWaitInComment,
|
|
33
|
+
} from "./util"
|
|
26
34
|
import { invalidInput, notFound } from "./errors"
|
|
27
35
|
import { formatStepDetail } from "./format"
|
|
28
36
|
|
|
@@ -820,6 +828,44 @@ export class PlanpilotApp {
|
|
|
820
828
|
return tx()
|
|
821
829
|
}
|
|
822
830
|
|
|
831
|
+
setStepWait(stepId: number, delayMs: number, reason?: string): { step: StepRow; until: number } {
|
|
832
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
833
|
+
throw invalidInput("delay must be a non-negative number")
|
|
834
|
+
}
|
|
835
|
+
const tx = this.db.transaction(() => {
|
|
836
|
+
const step = this.getStep(stepId)
|
|
837
|
+
const now = Date.now()
|
|
838
|
+
const until = now + Math.trunc(delayMs)
|
|
839
|
+
const comment = upsertWaitInComment(step.comment, until, reason)
|
|
840
|
+
this.db.prepare("UPDATE steps SET comment = ?, updated_at = ? WHERE id = ?").run(comment, now, stepId)
|
|
841
|
+
const updated = this.getStep(stepId)
|
|
842
|
+
this.touchPlan(updated.plan_id)
|
|
843
|
+
return { step: updated, until }
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
return tx()
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
clearStepWait(stepId: number): { step: StepRow } {
|
|
850
|
+
const tx = this.db.transaction(() => {
|
|
851
|
+
const step = this.getStep(stepId)
|
|
852
|
+
const comment = step.comment ? removeWaitFromComment(step.comment) : null
|
|
853
|
+
const now = Date.now()
|
|
854
|
+
this.db.prepare("UPDATE steps SET comment = ?, updated_at = ? WHERE id = ?").run(comment, now, stepId)
|
|
855
|
+
const updated = this.getStep(stepId)
|
|
856
|
+
this.touchPlan(updated.plan_id)
|
|
857
|
+
return { step: updated }
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
return tx()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
getStepWait(stepId: number): { step: StepRow; wait: { until: number; reason?: string } | null } {
|
|
864
|
+
const step = this.getStep(stepId)
|
|
865
|
+
const wait = parseWaitFromComment(step.comment)
|
|
866
|
+
return { step, wait }
|
|
867
|
+
}
|
|
868
|
+
|
|
823
869
|
commentGoals(entries: Array<[number, string]>): number[] {
|
|
824
870
|
const normalized = normalizeCommentEntries(entries)
|
|
825
871
|
if (!normalized.length) return []
|
package/src/lib/util.ts
CHANGED
|
@@ -49,6 +49,60 @@ export function normalizeCommentEntries(entries: Array<[number, string]>): Array
|
|
|
49
49
|
return ordered
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const WAIT_UNTIL_PREFIX = "@wait-until="
|
|
53
|
+
const WAIT_REASON_PREFIX = "@wait-reason="
|
|
54
|
+
|
|
55
|
+
export type WaitComment = {
|
|
56
|
+
until: number
|
|
57
|
+
reason?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseWaitFromComment(comment?: string | null): WaitComment | null {
|
|
61
|
+
if (!comment) return null
|
|
62
|
+
let until: number | null = null
|
|
63
|
+
let reason: string | undefined
|
|
64
|
+
for (const line of comment.split(/\r?\n/)) {
|
|
65
|
+
const trimmed = line.trim()
|
|
66
|
+
if (trimmed.startsWith(WAIT_UNTIL_PREFIX)) {
|
|
67
|
+
const raw = trimmed.slice(WAIT_UNTIL_PREFIX.length).trim()
|
|
68
|
+
const value = Number(raw)
|
|
69
|
+
if (Number.isFinite(value)) {
|
|
70
|
+
until = value
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (trimmed.startsWith(WAIT_REASON_PREFIX)) {
|
|
75
|
+
const raw = trimmed.slice(WAIT_REASON_PREFIX.length).trim()
|
|
76
|
+
if (raw) reason = raw
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (until === null) return null
|
|
80
|
+
return { until, reason }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isWaitLine(line: string): boolean {
|
|
84
|
+
const trimmed = line.trim()
|
|
85
|
+
return trimmed.startsWith(WAIT_UNTIL_PREFIX) || trimmed.startsWith(WAIT_REASON_PREFIX)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function upsertWaitInComment(comment: string | null, until: number, reason?: string): string {
|
|
89
|
+
const lines = comment ? comment.split(/\r?\n/) : []
|
|
90
|
+
const filtered = lines.filter((line) => !isWaitLine(line))
|
|
91
|
+
const waitLines = [`${WAIT_UNTIL_PREFIX}${Math.trunc(until)}`]
|
|
92
|
+
const reasonValue = reason?.trim()
|
|
93
|
+
if (reasonValue) {
|
|
94
|
+
waitLines.push(`${WAIT_REASON_PREFIX}${reasonValue}`)
|
|
95
|
+
}
|
|
96
|
+
return [...waitLines, ...filtered].join("\n").trimEnd()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function removeWaitFromComment(comment?: string | null): string | null {
|
|
100
|
+
if (!comment) return null
|
|
101
|
+
const lines = comment.split(/\r?\n/).filter((line) => !isWaitLine(line))
|
|
102
|
+
const cleaned = lines.join("\n").trimEnd()
|
|
103
|
+
return cleaned.length ? cleaned : null
|
|
104
|
+
}
|
|
105
|
+
|
|
52
106
|
export function resolveMaybeRealpath(value: string): string {
|
|
53
107
|
try {
|
|
54
108
|
return fs.realpathSync.native(value)
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const PLANPILOT_HELP_TEXT = [
|
|
2
|
+
"Planpilot - Plan/Step/Goal auto-continue workflow.",
|
|
3
|
+
"",
|
|
4
|
+
"Model:",
|
|
5
|
+
"- plan -> step -> goal",
|
|
6
|
+
"- step.executor: ai | human",
|
|
7
|
+
"- status rolls up: goals -> steps -> plan",
|
|
8
|
+
"",
|
|
9
|
+
"Rules (important):",
|
|
10
|
+
"- Prefer assigning steps to ai. Use human steps only for actions that require human approval/credentials",
|
|
11
|
+
" or elevated permissions / destructive operations (e.g. GitHub web UI, sudo, deleting data/files).",
|
|
12
|
+
"- One step = one executor. Do NOT mix ai/human work within the same step.",
|
|
13
|
+
" If a person must do something, create a separate human step.",
|
|
14
|
+
"- Keep statuses up to date. Mark goals/steps done as soon as they are completed so roll-up and auto-continue stay correct.",
|
|
15
|
+
"- `step wait` is ONLY for asynchronous, non-blocking external work that is already in progress",
|
|
16
|
+
" (build/test/job/CI/network/remote service).",
|
|
17
|
+
" If you can run something now, do it instead of waiting.",
|
|
18
|
+
" Do NOT use `step wait` to wait for human action.",
|
|
19
|
+
"- Keep comments short and decision-focused.",
|
|
20
|
+
"",
|
|
21
|
+
"Status propagation:",
|
|
22
|
+
"- Step with goals: done iff ALL goals are done; else todo.",
|
|
23
|
+
"- Plan with steps: done iff ALL steps are done; else todo.",
|
|
24
|
+
"- Step with 0 goals: manual status (`step update` / `step done`).",
|
|
25
|
+
"- Plan with 0 steps: manual status (`plan update` / `plan done`).",
|
|
26
|
+
"- When a plan becomes done, it is removed from active plan.",
|
|
27
|
+
"",
|
|
28
|
+
"Auto-continue:",
|
|
29
|
+
"- When the session is idle and an active plan exists:",
|
|
30
|
+
" - if next pending step.executor is ai: Planpilot auto-sends the next step + goals.",
|
|
31
|
+
" - if next pending step.executor is human: no auto-continue.",
|
|
32
|
+
"- Pause while waiting on external systems: `step wait`.",
|
|
33
|
+
"- Stop auto-continue:",
|
|
34
|
+
" - `plan deactivate`, OR",
|
|
35
|
+
" - insert a human step BEFORE the next pending ai step (so the next executor becomes human).",
|
|
36
|
+
"",
|
|
37
|
+
"Invocation:",
|
|
38
|
+
"- argv is tokenized: [section, subcommand, ...args]",
|
|
39
|
+
"- section: help | plan | step | goal",
|
|
40
|
+
"",
|
|
41
|
+
"Commands:",
|
|
42
|
+
"- help",
|
|
43
|
+
"",
|
|
44
|
+
"Plan:",
|
|
45
|
+
"- plan add <title> <content>",
|
|
46
|
+
"- plan add-tree <title> <content> --step <content> [--executor ai|human] [--goal <content>]... [--step ...]...",
|
|
47
|
+
"- plan list [--scope project|all] [--status todo|done|all] [--limit N] [--page N] [--order id|title|created|updated] [--desc]",
|
|
48
|
+
"- plan count [--scope project|all] [--status todo|done|all]",
|
|
49
|
+
"- plan search --search <term> [--search <term> ...] [--search-mode any|all] [--search-field plan|title|content|comment|steps|goals|all] [--match-case] [--scope project|all] [--status todo|done|all] [--limit N] [--page N] [--order id|title|created|updated] [--desc]",
|
|
50
|
+
"- plan show <id>",
|
|
51
|
+
"- plan export <id> <path>",
|
|
52
|
+
"- plan comment <id> <comment> [<id> <comment> ...]",
|
|
53
|
+
"- plan update <id> [--title <title>] [--content <content>] [--status todo|done] [--comment <comment>]",
|
|
54
|
+
"- plan done <id>",
|
|
55
|
+
"- plan remove <id>",
|
|
56
|
+
"- plan activate <id> [--force]",
|
|
57
|
+
"- plan show-active",
|
|
58
|
+
"- plan deactivate",
|
|
59
|
+
"",
|
|
60
|
+
"Step:",
|
|
61
|
+
"- step add <plan_id> <content...> [--executor ai|human] [--at <pos>]",
|
|
62
|
+
"- step add-tree <plan_id> <content> [--executor ai|human] [--goal <content> ...]",
|
|
63
|
+
"- step list <plan_id> [--status todo|done|all] [--executor ai|human] [--limit N] [--page N]",
|
|
64
|
+
"- step count <plan_id> [--status todo|done|all] [--executor ai|human]",
|
|
65
|
+
"- step show <id>",
|
|
66
|
+
"- step show-next",
|
|
67
|
+
"- step wait <id> --delay <ms> [--reason <text>]",
|
|
68
|
+
"- step wait <id> --clear",
|
|
69
|
+
"- step comment <id> <comment> [<id> <comment> ...]",
|
|
70
|
+
"- step update <id> [--content <content>] [--status todo|done] [--executor ai|human] [--comment <comment>]",
|
|
71
|
+
"- step done <id> [--all-goals]",
|
|
72
|
+
"- step move <id> --to <pos>",
|
|
73
|
+
"- step remove <id...>",
|
|
74
|
+
"",
|
|
75
|
+
"Goal:",
|
|
76
|
+
"- goal add <step_id> <content...>",
|
|
77
|
+
"- goal list <step_id> [--status todo|done|all] [--limit N] [--page N]",
|
|
78
|
+
"- goal count <step_id> [--status todo|done|all]",
|
|
79
|
+
"- goal show <id>",
|
|
80
|
+
"- goal comment <id> <comment> [<id> <comment> ...]",
|
|
81
|
+
"- goal update <id> [--content <content>] [--status todo|done] [--comment <comment>]",
|
|
82
|
+
"- goal done <id...>",
|
|
83
|
+
"- goal remove <id...>",
|
|
84
|
+
].join("\n")
|
|
85
|
+
|
|
86
|
+
export const PLANPILOT_TOOL_DESCRIPTION = [
|
|
87
|
+
"Planpilot planner for auto-continue plan workflows.",
|
|
88
|
+
"For multi-step and complex tasks, use Planpilot to structure work into plans/steps/goals.",
|
|
89
|
+
"Run `planpilot help` for full usage + rules.",
|
|
90
|
+
].join("\n")
|
|
91
|
+
|
|
92
|
+
export const PLANPILOT_SYSTEM_INJECTION =
|
|
93
|
+
"If the task is multi-step or complex, must use the `planpilot` plan tool. For full usage + rules, run: planpilot help."
|
|
94
|
+
|
|
95
|
+
export function formatPlanpilotAutoContinueMessage(input: { timestamp: string; stepDetail: string }): string {
|
|
96
|
+
const detail = (input.stepDetail ?? "").trimEnd()
|
|
97
|
+
return [
|
|
98
|
+
`Planpilot @ ${input.timestamp}`,
|
|
99
|
+
"This message was automatically sent by the Planpilot tool because the next pending step executor is ai.",
|
|
100
|
+
"For full usage + rules, run: planpilot help",
|
|
101
|
+
"Next step details:",
|
|
102
|
+
detail,
|
|
103
|
+
].join("\n")
|
|
104
|
+
}
|
package/docs/planpilot.md
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
# Planpilot (OpenCode Tool)
|
|
2
|
-
|
|
3
|
-
## Tool Name
|
|
4
|
-
`planpilot`
|
|
5
|
-
|
|
6
|
-
## What it is
|
|
7
|
-
Planpilot is a planner/tracker for multi-step work. It manages plans, steps, and goals, and automatically rolls up status.
|
|
8
|
-
|
|
9
|
-
## OpenCode Tool Usage (Required)
|
|
10
|
-
Use the custom `planpilot` tool. Do **not** call the CLI via `bash` for planning.
|
|
11
|
-
|
|
12
|
-
### Tool schema
|
|
13
|
-
- `argv?: string[]` - preferred; avoids quoting issues
|
|
14
|
-
- `command?: string` - optional; one command string
|
|
15
|
-
|
|
16
|
-
Rules:
|
|
17
|
-
- Provide either `argv` or `command`.
|
|
18
|
-
- Do **not** pass `--session-id` or `--cwd`; the tool injects them automatically from the current session.
|
|
19
|
-
- If both `argv` and `command` are provided, `argv` is used.
|
|
20
|
-
|
|
21
|
-
Example tool args:
|
|
22
|
-
|
|
23
|
-
```json
|
|
24
|
-
{ "argv": ["plan", "add", "Release v1.2", "Plan description"] }
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
```json
|
|
28
|
-
{ "command": "plan add \"Release v1.2\" \"Plan description\"" }
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
## CLI (Optional)
|
|
32
|
-
A CLI binary named `planpilot` is available for manual use. If you use the CLI directly, you must pass `--session-id` and `--cwd` yourself.
|
|
33
|
-
|
|
34
|
-
## Storage
|
|
35
|
-
- Database: `~/.config/opencode/.planpilot/planpilot.db`
|
|
36
|
-
- Plan snapshots: `~/.config/opencode/.planpilot/plans/`
|
|
37
|
-
- Override base directory: `OPENCODE_PLANPILOT_DIR` or `OPENCODE_PLANPILOT_HOME`
|
|
38
|
-
|
|
39
|
-
## Hierarchy
|
|
40
|
-
- Plan contains steps; step contains goals.
|
|
41
|
-
- Goals are the smallest units of work; steps group goals; plans group steps.
|
|
42
|
-
|
|
43
|
-
## AI Workflow Guidelines
|
|
44
|
-
- Use Planpilot for all planning, status, and progress tracking; do not use built-in plan/todo tools or other methods to track plan/step/goal status.
|
|
45
|
-
- Do not read plan files from disk or follow plan file placeholders; use the planpilot tool for plan/step/goal info.
|
|
46
|
-
- Treat tool output as authoritative. Do not invent IDs; only use IDs shown by `list`/`show`.
|
|
47
|
-
- If the tool is missing or unavailable, ask the user to enable/install the plugin.
|
|
48
|
-
- Record implementation details using Planpilot comments (plan/step/goal `--comment` or `comment` commands). Before starting a step or goal, think through the next actions and capture that context in comments so the plan stays actionable.
|
|
49
|
-
- Before starting a new plan, do deep analysis; then create the plan with clear steps and goals.
|
|
50
|
-
- When creating a plan or step, prefer `add-tree` to define all steps/goals upfront.
|
|
51
|
-
- Prefer assigning steps to `ai`; only assign `human` for truly critical/high-risk items or when passwords, `sudo` access, irreversible git history rewrites, or remote git changes are required. If `human` steps are necessary, batch them, make the step content explicit about what the human must do, and only ask for user input when the next step is assigned to `human`.
|
|
52
|
-
- Adjust plans/steps/goals only when necessary; avoid frequent arbitrary changes.
|
|
53
|
-
- Update status promptly as work completes; mark goals done, and let steps/plans auto-refresh unless a step/plan has no children (then use `step done`/`plan done`).
|
|
54
|
-
- In each reply turn, complete at most one step; do not advance multiple steps in a single response.
|
|
55
|
-
|
|
56
|
-
## Status Management
|
|
57
|
-
- Status values: `todo`, `done`.
|
|
58
|
-
- Goals are manual (`goal done`); steps/plans auto-refresh from child status, and use `step done`/`plan done` only when they have no children (`step done --all-goals` marks all goals done and then marks the step done). Auto status changes print as `Auto status updates:` with reasons.
|
|
59
|
-
- Parent status auto-flips to `todo` on incomplete child work and to `done` when all children are done. If a plan has 0 steps or a step has 0 goals, no auto-flip happens; use `plan done` / `step done` as needed.
|
|
60
|
-
- If the user completed a `human` step, verify/mark each goal and clearly list what remains.
|
|
61
|
-
- When a step becomes `done` and there is another pending step, the CLI will print the next-step instruction: for `ai`, end the turn so Planpilot can surface it; for `human`, show the step detail and tell the user to complete the goals, then end the turn. When a plan becomes `done` (automatic or manual), the CLI will prompt you to summarize completed results and end the turn.
|
|
62
|
-
|
|
63
|
-
## Active Plan Management
|
|
64
|
-
- Use `plan activate` / `plan deactivate` to manage, and no active plan means the plan is paused until reactivated. Plans auto-deactivate on `done` (manual or automatic) or removal.
|
|
65
|
-
- Each plan can be active in only one session at a time; use `plan activate --force` to take over. Default to no `--force`, and if activation fails due to another session, ask the user whether to take over.
|
|
66
|
-
- Use `plan show-active` to know which plan is active and get its details.
|
|
67
|
-
|
|
68
|
-
## Auto-Continue Behavior (OpenCode Plugin)
|
|
69
|
-
- The plugin listens to `session.idle` / `session.status` (idle) events.
|
|
70
|
-
- If there is an active plan and the next pending step is assigned to `ai`, it appends a `Planpilot (auto):` message to the prompt and submits it so the model continues the plan.
|
|
71
|
-
- It does nothing when there is no active plan or when the next step is assigned to `human`.
|
|
72
|
-
|
|
73
|
-
## ID Notes
|
|
74
|
-
- Plan/step/goal IDs are database IDs and may be non-contiguous or not start at 1; always use the actual IDs shown by `list`/`show`.
|
|
75
|
-
|
|
76
|
-
## Commands
|
|
77
|
-
|
|
78
|
-
- IMPORTANT: Do NOT pass `--cwd` or `--session-id` when using the tool. These are injected automatically.
|
|
79
|
-
|
|
80
|
-
### plan
|
|
81
|
-
- Plan data is stored under OpenCode config: `~/.config/opencode/.planpilot/` (overridable via `OPENCODE_PLANPILOT_DIR` or `OPENCODE_PLANPILOT_HOME`).
|
|
82
|
-
- `plan add <title> <content>`: create a plan.
|
|
83
|
-
- Output: `Created plan ID: <id>: <title>`.
|
|
84
|
-
- `plan add-tree <title> <content> --step <content> [--executor ai|human] [--goal <goal> ...] [--step <content> ...]`: create a plan with steps/goals in one command.
|
|
85
|
-
- Output: `Created plan ID: <id>: <title> (steps: <n>, goals: <n>)`.
|
|
86
|
-
- Behavior: the newly created plan is automatically activated for the current session.
|
|
87
|
-
- Repeatable groups: you can repeat the `--step ... [--executor ...] [--goal ...]` group multiple times.
|
|
88
|
-
- Each `--executor` / `--goal` applies to the most recent `--step`.
|
|
89
|
-
- Example (tool args):
|
|
90
|
-
```json
|
|
91
|
-
{"argv":["plan","add-tree","Release v1.2","Plan description","--step","Cut release branch","--executor","human","--goal","Create branch","--goal","Tag base","--step","Build artifacts","--executor","ai","--goal","Build packages"]}
|
|
92
|
-
```
|
|
93
|
-
- Another example (3 steps, some without goals/executor):
|
|
94
|
-
```json
|
|
95
|
-
{"argv":["plan","add-tree","Onboarding","Setup plan","--step","Create accounts","--goal","GitHub","--goal","Slack","--step","Install tooling","--executor","ai","--step","Read handbook"]}
|
|
96
|
-
```
|
|
97
|
-
- `plan list [--scope project|all] [--status todo|done|all] [--limit N] [--page N] [--order id|title|created|updated] [--desc]`: list plans. Default: `--scope project`, `--status all`, `--limit 20`, `--page 1`, order by `updated` desc (most recent first).
|
|
98
|
-
- Output: prints a header line, then one line per plan with `ID STAT STEPS TITLE COMMENT` (`STEPS` is `done/total`); use `plan show` for full details.
|
|
99
|
-
- Output (empty): `No plans found.`
|
|
100
|
-
- Order: defaults to `updated` with most recent first. Use `--order` and `--desc` to override.
|
|
101
|
-
- Pagination: If `--page` is provided without `--limit`, the default limit is used.
|
|
102
|
-
- If `--page` exceeds the total pages, a warning is printed and no rows are returned.
|
|
103
|
-
- Footer: prints `Page N / Limit N` after the list.
|
|
104
|
-
- `plan count [--scope project|all] [--status todo|done|all]`: count plans matching the filters. Output: `Total: <n>`.
|
|
105
|
-
- `plan search --search <term> [--search <term> ...] [--search-mode any|all] [--search-field plan|title|content|comment|steps|goals|all] [--match-case] [--scope project|all] [--status todo|done|all] [--limit N] [--page N] [--order id|title|created|updated] [--desc]`: search plans. Default: `--scope project`, `--status all`, `--limit 20`, `--page 1`, order by `updated` desc.
|
|
106
|
-
- Output: same format as `plan list`.
|
|
107
|
-
- Output (empty): `No plans found.`
|
|
108
|
-
- Advanced search:
|
|
109
|
-
- `--search <term>` (repeatable): filter plans by text (required).
|
|
110
|
-
- `--search-mode any|all`: match any term or require all terms (default: `all`).
|
|
111
|
-
- `--search-field plan|title|content|comment|steps|goals|all` (default: `plan`).
|
|
112
|
-
- `--match-case`: make search case-sensitive.
|
|
113
|
-
- Pagination: If `--page` is provided without `--limit`, the default limit is used.
|
|
114
|
-
- If `--page` exceeds the total pages, a warning is printed and no rows are returned.
|
|
115
|
-
- Footer: prints `Page N / Limit N` after the list.
|
|
116
|
-
- `plan show <id>`: prints plan details and nested steps/goals (includes ids for plan/step/goal).
|
|
117
|
-
- Output: plan header includes `Plan ID: <id>`, `Title`, `Status`, `Content`, `Created`, `Updated`, and `Comment` when present.
|
|
118
|
-
- Output: each step line includes step id and executor; progress (`goals done/total`) is shown only when the step has goals. Each goal line includes goal id.
|
|
119
|
-
- `plan export <id> <path>`: export plan details to a markdown file.
|
|
120
|
-
- Output: `Exported plan ID: <id> to <path>`.
|
|
121
|
-
- `plan update <id> [--title <title>] [--content <content>] [--status todo|done] [--comment <comment>]`: update fields; `--status done` is allowed only when all steps are done or the plan has no steps.
|
|
122
|
-
- Output: `Updated plan ID: <id>: <title>`.
|
|
123
|
-
- Errors: multi-line `Error: Invalid input:` with `cannot mark plan done; next pending step:` on the next line, followed by the same step detail output as `step show`.
|
|
124
|
-
- `plan done <id>`: mark plan done (same rule as `plan update --status done`).
|
|
125
|
-
- Output: `Plan ID: <id> marked done.`
|
|
126
|
-
- Output (active plan): `Active plan deactivated because plan is done.`
|
|
127
|
-
- Errors: multi-line `Error: Invalid input:` with `cannot mark plan done; next pending step:` on the next line, followed by the same step detail output as `step show`.
|
|
128
|
-
- `plan comment <id1> <comment1> [<id2> <comment2> ...]`: add or replace comments for one or more plans.
|
|
129
|
-
- Output (single): `Updated plan comment for plan ID: <id>.`
|
|
130
|
-
- Output (batch): `Updated plan comments for <n> plans.`
|
|
131
|
-
- Each plan comment uses an `<id> <comment>` pair; you can provide multiple pairs in one call.
|
|
132
|
-
- Example:
|
|
133
|
-
```json
|
|
134
|
-
{"argv":["plan","comment","12","high priority","15","waiting on input"]}
|
|
135
|
-
```
|
|
136
|
-
- `plan remove <id>`: remove plan (and its steps/goals).
|
|
137
|
-
- Output: `Plan ID: <id> removed.`
|
|
138
|
-
- `plan activate <id> [--force]`: set the active plan.
|
|
139
|
-
- Output: `Active plan set to <id>: <title>`.
|
|
140
|
-
- `--force` takes over a plan already active in another session.
|
|
141
|
-
- Errors: `Error: Invalid input: cannot activate plan; plan is done`.
|
|
142
|
-
- Errors: `Error: Invalid input: plan id <id> is already active in session <session_id> (use --force to take over)`.
|
|
143
|
-
- `plan show-active`: prints the active plan details (same format as `plan show`).
|
|
144
|
-
- Output: the same plan detail format as `plan show`.
|
|
145
|
-
- Output (empty): `No active plan.`
|
|
146
|
-
- Output (missing): `Active plan ID: <id> not found.`
|
|
147
|
-
- `plan deactivate`: unset the active plan (does not delete any plan).
|
|
148
|
-
- Output: `Active plan deactivated.`
|
|
149
|
-
|
|
150
|
-
### step
|
|
151
|
-
- `step add <plan_id> <content1> [<content2> ...] [--at <pos>] [--executor ai|human]`: add steps.
|
|
152
|
-
- Output (single): `Created step ID: <id> for plan ID: <plan_id>`.
|
|
153
|
-
- Output (batch): `Created <n> steps for plan ID: <plan_id>`.
|
|
154
|
-
- `step add-tree <plan_id> <content> [--executor ai|human] [--goal <goal> ...]`: create one step with goals in one command.
|
|
155
|
-
- Output: `Created step ID: <id> for plan ID: <plan_id> (goals: <n>)`.
|
|
156
|
-
- Example:
|
|
157
|
-
```json
|
|
158
|
-
{"argv":["step","add-tree","1","Draft summary","--executor","ai","--goal","Collect inputs","--goal","Write draft"]}
|
|
159
|
-
```
|
|
160
|
-
- `step list <plan_id> [--status todo|done|all] [--executor ai|human] [--limit N] [--page N]`: list steps. Default: `--status all`, `--limit 20`, `--page 1`. Steps are always returned in their plan order.
|
|
161
|
-
- Output: prints a header line, then one line per step with `ID STAT ORD EXEC GOALS CONTENT COMMENT` (`ORD` is the step order within the plan; `GOALS` is `done/total`); use `step show` for full details.
|
|
162
|
-
- Output (empty): `No steps found for plan ID: <plan_id>.`
|
|
163
|
-
- Pagination: If `--page` is provided without `--limit`, the default limit is used.
|
|
164
|
-
- If `--page` exceeds the total pages, a warning is printed and no rows are returned.
|
|
165
|
-
- Footer: prints `Page N / Limit N` after the list.
|
|
166
|
-
- `step count <plan_id> [--status todo|done|all] [--executor ai|human]`: count steps matching the filters. Output: `Total: <n>`.
|
|
167
|
-
- `step show <id>`: prints a single step with full details and its nested goals (includes ids for step/goal).
|
|
168
|
-
- Output: step header includes `Step ID: <id>`, `Plan ID`, `Status`, `Executor`, `Content`, `Created`, `Updated`, and `Comment` when present.
|
|
169
|
-
- Output: lists all goals with `[status]` and goal id.
|
|
170
|
-
- `step show-next`: show the next pending step for the active plan (same format as `step show`).
|
|
171
|
-
- Output (empty): `No active plan.` or `No pending step.`.
|
|
172
|
-
- `step update <id> [--content <content>] [--status todo|done] [--executor ai|human] [--comment <comment>]`: update fields; `--status done` is allowed only when all goals are done or the step has no goals.
|
|
173
|
-
- Output: `Updated step ID: <id>.`.
|
|
174
|
-
- Errors: `Error: Invalid input: cannot mark step done; next pending goal: <content> (id <id>)`.
|
|
175
|
-
- `step comment <id1> <comment1> [<id2> <comment2> ...]`: add or replace comments for one or more steps.
|
|
176
|
-
- Output (single): `Updated step comments for plan ID: <plan_id>.`
|
|
177
|
-
- Output (batch): `Updated step comments for <n> plans.`
|
|
178
|
-
- Each step comment uses an `<id> <comment>` pair; you can provide multiple pairs in one call.
|
|
179
|
-
- Example:
|
|
180
|
-
```json
|
|
181
|
-
{"argv":["step","comment","45","blocked by API","46","ready to start"]}
|
|
182
|
-
```
|
|
183
|
-
- `step done <id> [--all-goals]`: mark step done (same rule as `step update --status done`). Use `--all-goals` to mark all goals in the step done first, then mark the step done.
|
|
184
|
-
- Output: `Step ID: <id> marked done.`
|
|
185
|
-
- Errors: `Error: Invalid input: cannot mark step done; next pending goal: <content> (id <id>)`.
|
|
186
|
-
- `step move <id> --to <pos>`: reorder and print the same one-line list as `step list`.
|
|
187
|
-
- Output: `Reordered steps for plan ID: <plan_id>:` + list.
|
|
188
|
-
- `step remove <id1> [<id2> ...]`: remove step(s).
|
|
189
|
-
- Output (single): `Step ID: <id> removed.`
|
|
190
|
-
- Output (batch): `Removed <n> steps.`
|
|
191
|
-
- Errors: `Error: Not found: step id(s) not found: <id1>[, <id2> ...]`.
|
|
192
|
-
|
|
193
|
-
### goal
|
|
194
|
-
- `goal add <step_id> <content1> [<content2> ...]`: add goals to a step.
|
|
195
|
-
- Output (single): `Created goal ID: <id> for step ID: <step_id>`.
|
|
196
|
-
- Output (batch): `Created <n> goals for step ID: <step_id>`.
|
|
197
|
-
- `goal list <step_id> [--status todo|done|all] [--limit N] [--page N]`: list goals. Default: `--status all`, `--limit 20`, `--page 1`, order by `updated` desc (most recent first).
|
|
198
|
-
- Output: prints a header line, then one line per goal with `ID STAT CONTENT COMMENT`.
|
|
199
|
-
- Output (empty): `No goals found for step ID: <step_id>.`
|
|
200
|
-
- Pagination: If `--page` is provided without `--limit`, the default limit is used.
|
|
201
|
-
- If `--page` exceeds the total pages, a warning is printed and no rows are returned.
|
|
202
|
-
- Footer: prints `Page N / Limit N` after the list.
|
|
203
|
-
- `goal count <step_id> [--status todo|done|all]`: count goals matching the filters. Output: `Total: <n>`.
|
|
204
|
-
- `goal update <id> [--content <content>] [--status todo|done] [--comment <comment>]`: update fields.
|
|
205
|
-
- Output: `Updated goal <id>.`
|
|
206
|
-
- `goal comment <id1> <comment1> [<id2> <comment2> ...]`: add or replace comments for one or more goals.
|
|
207
|
-
- Output (single): `Updated goal comments for plan ID: <plan_id>.`
|
|
208
|
-
- Output (batch): `Updated goal comments for <n> plans.`
|
|
209
|
-
- Each goal comment uses an `<id> <comment>` pair; you can provide multiple pairs in one call.
|
|
210
|
-
- Example:
|
|
211
|
-
```json
|
|
212
|
-
{"argv":["goal","comment","78","done","81","needs review"]}
|
|
213
|
-
```
|
|
214
|
-
- `goal done <id1> [<id2> ...]`: mark one or more goals done.
|
|
215
|
-
- Output (single): `Goal ID: <id> marked done.`
|
|
216
|
-
- Output (batch): `Goals marked done: <n>.`
|
|
217
|
-
- `goal remove <id1> [<id2> ...]`: remove goal(s).
|
|
218
|
-
- Output (single): `Goal ID: <id> removed.`
|
|
219
|
-
- Output (batch): `Removed <n> goals.`
|
|
220
|
-
- Errors: `Error: Not found: goal id(s) not found: <id1>[, <id2> ...]`.
|
package/src/lib/argv.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { invalidInput } from "./errors"
|
|
2
|
-
|
|
3
|
-
export function parseCommandArgs(input: string): string[] {
|
|
4
|
-
const args: string[] = []
|
|
5
|
-
let current = ""
|
|
6
|
-
let inSingle = false
|
|
7
|
-
let inDouble = false
|
|
8
|
-
let escapeNext = false
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
11
|
-
const ch = input[i]
|
|
12
|
-
|
|
13
|
-
if (escapeNext) {
|
|
14
|
-
current += ch
|
|
15
|
-
escapeNext = false
|
|
16
|
-
continue
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (ch === "\\" && !inSingle) {
|
|
20
|
-
escapeNext = true
|
|
21
|
-
continue
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (ch === "'" && !inDouble) {
|
|
25
|
-
inSingle = !inSingle
|
|
26
|
-
continue
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (ch === '"' && !inSingle) {
|
|
30
|
-
inDouble = !inDouble
|
|
31
|
-
continue
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (!inSingle && !inDouble && /\s/.test(ch)) {
|
|
35
|
-
if (current.length) {
|
|
36
|
-
args.push(current)
|
|
37
|
-
current = ""
|
|
38
|
-
}
|
|
39
|
-
continue
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
current += ch
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (escapeNext) {
|
|
46
|
-
current += "\\"
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (inSingle || inDouble) {
|
|
50
|
-
throw invalidInput("unterminated quote in command")
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (current.length) {
|
|
54
|
-
args.push(current)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return args
|
|
58
|
-
}
|
package/src/lib/instructions.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import fs from "fs"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import { fileURLToPath } from "url"
|
|
4
|
-
|
|
5
|
-
let cached: string | null = null
|
|
6
|
-
|
|
7
|
-
export function loadPlanpilotInstructions(): string {
|
|
8
|
-
if (cached !== null) return cached
|
|
9
|
-
try {
|
|
10
|
-
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
|
|
11
|
-
const candidates = [
|
|
12
|
-
path.resolve(moduleDir, "../../docs/planpilot.md"),
|
|
13
|
-
path.resolve(moduleDir, "../../../docs/planpilot.md"),
|
|
14
|
-
]
|
|
15
|
-
for (const candidate of candidates) {
|
|
16
|
-
if (fs.existsSync(candidate)) {
|
|
17
|
-
cached = fs.readFileSync(candidate, "utf8")
|
|
18
|
-
return cached
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
cached = ""
|
|
22
|
-
} catch {
|
|
23
|
-
cached = ""
|
|
24
|
-
}
|
|
25
|
-
return cached
|
|
26
|
-
}
|