opencode-planpilot 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-planpilot",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Planpilot plugin for OpenCode",
5
5
  "type": "module",
6
6
  "repository": {
package/src/command.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import { AppError, invalidInput } from "./lib/errors"
14
14
  import { ensureNonEmpty, projectMatchesPath, resolveMaybeRealpath } from "./lib/util"
15
15
  import { formatGoalDetail, formatPlanDetail, formatPlanMarkdown, formatStepDetail } from "./lib/format"
16
+ import { PLANPILOT_HELP_TEXT } from "./prompt"
16
17
 
17
18
  const DEFAULT_PAGE = 1
18
19
  const DEFAULT_LIMIT = 20
@@ -72,6 +73,14 @@ export async function runCommand(argv: string[], context: CommandContext, io: Co
72
73
  let shouldSync = false
73
74
 
74
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
+ }
75
84
  case "plan": {
76
85
  const result = await handlePlan(app, subcommand, args, { cwd: context.cwd })
77
86
  planIds = result.planIds
@@ -234,6 +243,11 @@ function handlePlanAddTree(app: PlanpilotApp, args: string[]): number[] {
234
243
  log(`Created plan ID: ${result.plan.id}: ${result.plan.title} (steps: ${result.stepCount}, goals: ${result.goalCount})`)
235
244
  app.setActivePlan(result.plan.id, false)
236
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))
237
251
  return [result.plan.id]
238
252
  }
239
253
 
@@ -1464,4 +1478,3 @@ function syncPlanMarkdown(app: PlanpilotApp, planIds: number[]) {
1464
1478
  fs.writeFileSync(mdPath, markdown, "utf8")
1465
1479
  })
1466
1480
  }
1467
-
package/src/index.ts CHANGED
@@ -5,62 +5,14 @@ import { openDatabase } from "./lib/db"
5
5
  import { invalidInput } from "./lib/errors"
6
6
  import { formatStepDetail } from "./lib/format"
7
7
  import { parseWaitFromComment } from "./lib/util"
8
-
9
- const PLANPILOT_TOOL_DESCRIPTION = [
10
- "Planpilot planner for plan workflows.",
11
- "Hints: 1. Model is plan/step/goal with ai/human executors and status auto-propagation upward (goals -> steps -> plan). 2. Keep comments short and decision-focused. 3. Add human steps only when AI cannot act. 4. Use `step wait` when ending a reply while waiting on external tasks.",
12
- "",
13
- "Usage:",
14
- "- argv is tokenized: [section, subcommand, ...args]",
15
- "- section: plan | step | goal",
16
- "",
17
- "Plan commands:",
18
- "- plan add <title> <content>",
19
- "- plan add-tree <title> <content> --step <content> [--executor ai|human] [--goal <content>]... [--step ...]...",
20
- "- plan list [--scope project|all] [--status todo|done|all] [--limit N] [--page N] [--order id|title|created|updated] [--desc]",
21
- "- plan count [--scope project|all] [--status todo|done|all]",
22
- "- 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]",
23
- "- plan show <id>",
24
- "- plan export <id> <path>",
25
- "- plan comment <id> <comment> [<id> <comment> ...]",
26
- "- plan update <id> [--title <title>] [--content <content>] [--status todo|done] [--comment <comment>]",
27
- "- plan done <id>",
28
- "- plan remove <id>",
29
- "- plan activate <id> [--force]",
30
- "- plan show-active",
31
- "- plan deactivate",
32
- "",
33
- "Step commands:",
34
- "- step add <plan_id> <content...> [--executor ai|human] [--at <pos>]",
35
- "- step add-tree <plan_id> <content> [--executor ai|human] [--goal <content> ...]",
36
- "- step list <plan_id> [--status todo|done|all] [--executor ai|human] [--limit N] [--page N]",
37
- "- step count <plan_id> [--status todo|done|all] [--executor ai|human]",
38
- "- step show <id>",
39
- "- step show-next",
40
- "- step wait <id> --delay <ms> [--reason <text>]",
41
- "- step wait <id> --clear",
42
- "- step comment <id> <comment> [<id> <comment> ...]",
43
- "- step update <id> [--content <content>] [--status todo|done] [--executor ai|human] [--comment <comment>]",
44
- "- step done <id> [--all-goals]",
45
- "- step move <id> --to <pos>",
46
- "- step remove <id...>",
47
- "",
48
- "Goal commands:",
49
- "- goal add <step_id> <content...>",
50
- "- goal list <step_id> [--status todo|done|all] [--limit N] [--page N]",
51
- "- goal count <step_id> [--status todo|done|all]",
52
- "- goal show <id>",
53
- "- goal comment <id> <comment> [<id> <comment> ...]",
54
- "- goal update <id> [--content <content>] [--status todo|done] [--comment <comment>]",
55
- "- goal done <id...>",
56
- "- goal remove <id...>",
57
- ].join("\n")
8
+ import { PLANPILOT_SYSTEM_INJECTION, PLANPILOT_TOOL_DESCRIPTION, formatPlanpilotAutoContinueMessage } from "./prompt"
58
9
 
59
10
  export const PlanpilotPlugin: Plugin = async (ctx) => {
60
11
  const inFlight = new Set<string>()
61
12
  const skipNextAuto = new Set<string>()
62
13
  const lastIdleAt = new Map<string, number>()
63
14
  const waitTimers = new Map<string, ReturnType<typeof setTimeout>>()
15
+ const runSeq = new Map<string, number>()
64
16
 
65
17
  const clearWaitTimer = (sessionID: string) => {
66
18
  const existing = waitTimers.get(sessionID)
@@ -85,6 +37,16 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
85
37
  }
86
38
  }
87
39
 
40
+ const logDebug = async (message: string, extra?: Record<string, any>) => {
41
+ await log("debug", message, extra)
42
+ }
43
+
44
+ const nextRun = (sessionID: string) => {
45
+ const next = (runSeq.get(sessionID) ?? 0) + 1
46
+ runSeq.set(sessionID, next)
47
+ return next
48
+ }
49
+
88
50
  type SessionMessage = {
89
51
  info?: {
90
52
  role?: string
@@ -162,32 +124,76 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
162
124
  aborted,
163
125
  ready,
164
126
  missingUser: false,
127
+ assistantFinish: finish,
128
+ assistantErrorName: typeof error === "object" && error ? (error as any).name : undefined,
165
129
  }
166
130
  }
167
131
 
168
- const handleSessionIdle = async (sessionID: string) => {
169
- if (inFlight.has(sessionID)) return
170
- if (skipNextAuto.has(sessionID)) {
171
- skipNextAuto.delete(sessionID)
132
+ const handleSessionIdle = async (sessionID: string, source: string) => {
133
+ const now = Date.now()
134
+ const run = nextRun(sessionID)
135
+
136
+ if (inFlight.has(sessionID)) {
137
+ await logDebug("auto-continue skipped: already in-flight", { sessionID, source, run })
172
138
  return
173
139
  }
174
- const lastIdle = lastIdleAt.get(sessionID)
175
- const now = Date.now()
176
- if (lastIdle && now - lastIdle < 1000) return
177
- lastIdleAt.set(sessionID, now)
178
140
  inFlight.add(sessionID)
179
141
  try {
142
+ if (skipNextAuto.has(sessionID)) {
143
+ skipNextAuto.delete(sessionID)
144
+ await logDebug("auto-continue skipped: skipNextAuto", { sessionID, source, run })
145
+ return
146
+ }
147
+
148
+ const lastIdle = lastIdleAt.get(sessionID)
149
+ if (lastIdle && now - lastIdle < 1000) {
150
+ await logDebug("auto-continue skipped: idle debounce", {
151
+ sessionID,
152
+ source,
153
+ run,
154
+ lastIdle,
155
+ now,
156
+ deltaMs: now - lastIdle,
157
+ })
158
+ return
159
+ }
160
+
161
+ lastIdleAt.set(sessionID, now)
162
+
180
163
  const app = new PlanpilotApp(openDatabase(), sessionID)
181
164
  const active = app.getActivePlan()
182
- if (!active) return
165
+ if (!active) {
166
+ clearWaitTimer(sessionID)
167
+ await logDebug("auto-continue skipped: no active plan", { sessionID, source, run })
168
+ return
169
+ }
183
170
  const next = app.nextStep(active.plan_id)
184
- if (!next) return
185
- if (next.executor !== "ai") return
171
+ if (!next) {
172
+ clearWaitTimer(sessionID)
173
+ await logDebug("auto-continue skipped: no pending step", { sessionID, source, run, planId: active.plan_id })
174
+ return
175
+ }
176
+ if (next.executor !== "ai") {
177
+ clearWaitTimer(sessionID)
178
+ await logDebug("auto-continue skipped: next executor is not ai", {
179
+ sessionID,
180
+ source,
181
+ run,
182
+ planId: active.plan_id,
183
+ stepId: next.id,
184
+ executor: next.executor,
185
+ })
186
+ return
187
+ }
188
+
186
189
  const wait = parseWaitFromComment(next.comment)
187
190
  if (wait && wait.until > now) {
188
191
  clearWaitTimer(sessionID)
189
192
  await log("info", "auto-continue delayed by step wait", {
190
193
  sessionID,
194
+ source,
195
+ run,
196
+ planId: active.plan_id,
191
197
  stepId: next.id,
192
198
  until: wait.until,
193
199
  reason: wait.reason,
@@ -195,7 +201,7 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
195
201
  const msUntil = Math.max(0, wait.until - now)
196
202
  const timer = setTimeout(() => {
197
203
  waitTimers.delete(sessionID)
198
- handleSessionIdle(sessionID).catch((err) => {
204
+ handleSessionIdle(sessionID, "wait_timer").catch((err) => {
199
205
  void log("warn", "auto-continue retry failed", {
200
206
  sessionID,
201
207
  error: err instanceof Error ? err.message : String(err),
@@ -211,22 +217,61 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
211
217
 
212
218
  const goals = app.goalsForStep(next.id)
213
219
  const detail = formatStepDetail(next, goals)
214
- if (!detail.trim()) return
220
+ if (!detail.trim()) {
221
+ await log("warn", "auto-continue stopped: empty step detail", {
222
+ sessionID,
223
+ source,
224
+ run,
225
+ planId: active.plan_id,
226
+ stepId: next.id,
227
+ })
228
+ return
229
+ }
215
230
 
216
231
  const autoContext = await resolveAutoContext(sessionID)
217
232
  if (autoContext?.missingUser) {
218
233
  await log("warn", "auto-continue stopped: missing user context", { sessionID })
219
234
  return
220
235
  }
221
- if (autoContext?.aborted || autoContext?.ready === false) return
236
+ if (!autoContext) {
237
+ await logDebug("auto-continue stopped: missing autoContext (no recent messages?)", {
238
+ sessionID,
239
+ source,
240
+ run,
241
+ planId: active.plan_id,
242
+ stepId: next.id,
243
+ })
244
+ return
245
+ }
246
+ if (autoContext.aborted) {
247
+ await logDebug("auto-continue skipped: last assistant aborted", {
248
+ sessionID,
249
+ source,
250
+ run,
251
+ planId: active.plan_id,
252
+ stepId: next.id,
253
+ assistantErrorName: autoContext.assistantErrorName,
254
+ assistantFinish: autoContext.assistantFinish,
255
+ })
256
+ return
257
+ }
258
+ if (autoContext.ready === false) {
259
+ await logDebug("auto-continue skipped: last assistant not ready", {
260
+ sessionID,
261
+ source,
262
+ run,
263
+ planId: active.plan_id,
264
+ stepId: next.id,
265
+ assistantFinish: autoContext.assistantFinish,
266
+ })
267
+ return
268
+ }
222
269
 
223
270
  const timestamp = new Date().toISOString()
224
- const message = `Planpilot plugin auto message @ ${timestamp}
225
- Hints:
226
- - If the next step needs human action, insert a human step before it.
227
- - If you need to wait for something to finish, use the step wait subcommand.
228
- Next step details:
229
- ${detail.trimEnd()}`
271
+ const message = formatPlanpilotAutoContinueMessage({
272
+ timestamp,
273
+ stepDetail: detail,
274
+ })
230
275
 
231
276
  const promptBody: any = {
232
277
  agent: autoContext?.agent ?? undefined,
@@ -237,18 +282,51 @@ ${detail.trimEnd()}`
237
282
  promptBody.variant = autoContext.variant
238
283
  }
239
284
 
285
+ await logDebug("auto-continue sending prompt_async", {
286
+ sessionID,
287
+ source,
288
+ run,
289
+ planId: active.plan_id,
290
+ stepId: next.id,
291
+ agent: promptBody.agent,
292
+ model: promptBody.model,
293
+ variant: promptBody.variant,
294
+ messageChars: message.length,
295
+ })
296
+
240
297
  await ctx.client.session.promptAsync({
241
298
  path: { id: sessionID },
242
299
  body: promptBody,
243
300
  })
301
+
302
+ await log("info", "auto-continue prompt_async accepted", {
303
+ sessionID,
304
+ source,
305
+ run,
306
+ planId: active.plan_id,
307
+ stepId: next.id,
308
+ })
244
309
  } catch (err) {
245
- await log("warn", "failed to auto-continue plan", { error: err instanceof Error ? err.message : String(err) })
310
+ await log("warn", "failed to auto-continue plan", {
311
+ sessionID,
312
+ source,
313
+ error: err instanceof Error ? err.message : String(err),
314
+ stack: err instanceof Error ? err.stack : undefined,
315
+ })
246
316
  } finally {
247
317
  inFlight.delete(sessionID)
248
318
  }
249
319
  }
250
320
 
321
+ await log("info", "planpilot plugin initialized", {
322
+ directory: ctx.directory,
323
+ worktree: ctx.worktree,
324
+ })
325
+
251
326
  return {
327
+ "experimental.chat.system.transform": async (_input, output) => {
328
+ output.system.push(PLANPILOT_SYSTEM_INJECTION)
329
+ },
252
330
  tool: {
253
331
  planpilot: tool({
254
332
  description: PLANPILOT_TOOL_DESCRIPTION,
@@ -285,17 +363,22 @@ ${detail.trimEnd()}`
285
363
  },
286
364
  }),
287
365
  },
288
- "experimental.session.compacting": async ({ sessionID }) => {
366
+ "experimental.session.compacting": async ({ sessionID }, output) => {
289
367
  skipNextAuto.add(sessionID)
290
368
  lastIdleAt.set(sessionID, Date.now())
369
+
370
+ await logDebug("compaction hook: skip next auto-continue", { sessionID })
371
+
372
+ // Compaction runs with tools disabled; inject Planpilot guidance into the continuation summary.
373
+ output.context.push(PLANPILOT_TOOL_DESCRIPTION)
291
374
  },
292
375
  event: async ({ event }) => {
293
376
  if (event.type === "session.idle") {
294
- await handleSessionIdle(event.properties.sessionID)
377
+ await handleSessionIdle(event.properties.sessionID, "session.idle")
295
378
  return
296
379
  }
297
380
  if (event.type === "session.status" && event.properties.status.type === "idle") {
298
- await handleSessionIdle(event.properties.sessionID)
381
+ await handleSessionIdle(event.properties.sessionID, "session.status")
299
382
  }
300
383
  },
301
384
  }
@@ -310,4 +393,3 @@ function containsForbiddenFlags(argv: string[]): boolean {
310
393
  return false
311
394
  })
312
395
  }
313
-
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
+ }