opencode-planpilot 0.2.3 → 0.3.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.
@@ -0,0 +1,436 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import { resolvePlanpilotDir } from "./db"
4
+
5
+ export type KeywordRule = {
6
+ any: string[]
7
+ all: string[]
8
+ none: string[]
9
+ matchCase: boolean
10
+ }
11
+
12
+ export type EventRule = {
13
+ enabled: boolean
14
+ force: boolean
15
+ keywords: KeywordRule
16
+ }
17
+
18
+ export type SessionErrorRule = EventRule & {
19
+ errorNames: string[]
20
+ statusCodes: number[]
21
+ retryableOnly: boolean
22
+ }
23
+
24
+ export type SessionRetryRule = EventRule & {
25
+ attemptAtLeast: number
26
+ }
27
+
28
+ export type SendRetryConfig = {
29
+ enabled: boolean
30
+ maxAttempts: number
31
+ delaysMs: number[]
32
+ }
33
+
34
+ export type AutoContinueConfig = {
35
+ sendRetry: SendRetryConfig
36
+ onSessionError: SessionErrorRule
37
+ onSessionRetry: SessionRetryRule
38
+ onPermissionAsked: EventRule
39
+ onPermissionRejected: EventRule
40
+ onQuestionAsked: EventRule
41
+ onQuestionRejected: EventRule
42
+ }
43
+
44
+ export type RuntimeConfig = {
45
+ paused: boolean
46
+ }
47
+
48
+ export type PlanpilotConfig = {
49
+ autoContinue: AutoContinueConfig
50
+ runtime: RuntimeConfig
51
+ }
52
+
53
+ export type LoadedPlanpilotConfig = {
54
+ path: string
55
+ loadedFromFile: boolean
56
+ config: PlanpilotConfig
57
+ loadError?: string
58
+ }
59
+
60
+ const DEFAULT_KEYWORDS: KeywordRule = {
61
+ any: [],
62
+ all: [],
63
+ none: [],
64
+ matchCase: false,
65
+ }
66
+
67
+ const DEFAULT_EVENT_RULE: EventRule = {
68
+ enabled: false,
69
+ force: false,
70
+ keywords: DEFAULT_KEYWORDS,
71
+ }
72
+
73
+ const DEFAULT_SESSION_ERROR_RULE: SessionErrorRule = {
74
+ enabled: false,
75
+ force: true,
76
+ keywords: DEFAULT_KEYWORDS,
77
+ errorNames: [],
78
+ statusCodes: [],
79
+ retryableOnly: false,
80
+ }
81
+
82
+ const DEFAULT_SESSION_RETRY_RULE: SessionRetryRule = {
83
+ enabled: false,
84
+ force: false,
85
+ keywords: DEFAULT_KEYWORDS,
86
+ attemptAtLeast: 1,
87
+ }
88
+
89
+ const DEFAULT_SEND_RETRY: SendRetryConfig = {
90
+ enabled: true,
91
+ maxAttempts: 3,
92
+ delaysMs: [1500, 5000, 15000],
93
+ }
94
+
95
+ export const DEFAULT_PLANPILOT_CONFIG: PlanpilotConfig = {
96
+ autoContinue: {
97
+ sendRetry: DEFAULT_SEND_RETRY,
98
+ onSessionError: DEFAULT_SESSION_ERROR_RULE,
99
+ onSessionRetry: DEFAULT_SESSION_RETRY_RULE,
100
+ onPermissionAsked: DEFAULT_EVENT_RULE,
101
+ onPermissionRejected: {
102
+ ...DEFAULT_EVENT_RULE,
103
+ force: true,
104
+ },
105
+ onQuestionAsked: DEFAULT_EVENT_RULE,
106
+ onQuestionRejected: {
107
+ ...DEFAULT_EVENT_RULE,
108
+ force: true,
109
+ },
110
+ },
111
+ runtime: {
112
+ paused: false,
113
+ },
114
+ }
115
+
116
+ export function resolvePlanpilotConfigPath(): string {
117
+ const override = process.env.OPENCODE_PLANPILOT_CONFIG
118
+ if (override && override.trim()) {
119
+ const value = override.trim()
120
+ return path.isAbsolute(value) ? value : path.resolve(value)
121
+ }
122
+ return path.join(resolvePlanpilotDir(), "config.json")
123
+ }
124
+
125
+ type RawKeywordRule = {
126
+ any?: unknown
127
+ all?: unknown
128
+ none?: unknown
129
+ matchCase?: unknown
130
+ }
131
+
132
+ type RawEventRule = {
133
+ enabled?: unknown
134
+ force?: unknown
135
+ keywords?: RawKeywordRule
136
+ }
137
+
138
+ type RawSessionErrorRule = RawEventRule & {
139
+ errorNames?: unknown
140
+ statusCodes?: unknown
141
+ retryableOnly?: unknown
142
+ }
143
+
144
+ type RawSessionRetryRule = RawEventRule & {
145
+ attemptAtLeast?: unknown
146
+ }
147
+
148
+ type RawSendRetryConfig = {
149
+ enabled?: unknown
150
+ maxAttempts?: unknown
151
+ delaysMs?: unknown
152
+ }
153
+
154
+ type RawAutoContinueConfig = {
155
+ sendRetry?: RawSendRetryConfig
156
+ onSessionError?: RawSessionErrorRule
157
+ onSessionRetry?: RawSessionRetryRule
158
+ onPermissionAsked?: RawEventRule
159
+ onPermissionRejected?: RawEventRule
160
+ onQuestionAsked?: RawEventRule
161
+ onQuestionRejected?: RawEventRule
162
+ }
163
+
164
+ type RawRuntimeConfig = {
165
+ paused?: unknown
166
+ }
167
+
168
+ type RawPlanpilotConfig = {
169
+ autoContinue?: RawAutoContinueConfig
170
+ runtime?: RawRuntimeConfig
171
+ }
172
+
173
+ function cloneDefaultConfig(): PlanpilotConfig {
174
+ return {
175
+ autoContinue: {
176
+ sendRetry: {
177
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.sendRetry.enabled,
178
+ maxAttempts: DEFAULT_PLANPILOT_CONFIG.autoContinue.sendRetry.maxAttempts,
179
+ delaysMs: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.sendRetry.delaysMs],
180
+ },
181
+ onSessionError: {
182
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.enabled,
183
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.force,
184
+ keywords: {
185
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.keywords.any],
186
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.keywords.all],
187
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.keywords.none],
188
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.keywords.matchCase,
189
+ },
190
+ errorNames: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.errorNames],
191
+ statusCodes: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.statusCodes],
192
+ retryableOnly: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError.retryableOnly,
193
+ },
194
+ onSessionRetry: {
195
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.enabled,
196
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.force,
197
+ keywords: {
198
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.keywords.any],
199
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.keywords.all],
200
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.keywords.none],
201
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.keywords.matchCase,
202
+ },
203
+ attemptAtLeast: DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry.attemptAtLeast,
204
+ },
205
+ onPermissionAsked: {
206
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.enabled,
207
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.force,
208
+ keywords: {
209
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.keywords.any],
210
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.keywords.all],
211
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.keywords.none],
212
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked.keywords.matchCase,
213
+ },
214
+ },
215
+ onPermissionRejected: {
216
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.enabled,
217
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.force,
218
+ keywords: {
219
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.keywords.any],
220
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.keywords.all],
221
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.keywords.none],
222
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected.keywords.matchCase,
223
+ },
224
+ },
225
+ onQuestionAsked: {
226
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.enabled,
227
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.force,
228
+ keywords: {
229
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.keywords.any],
230
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.keywords.all],
231
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.keywords.none],
232
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked.keywords.matchCase,
233
+ },
234
+ },
235
+ onQuestionRejected: {
236
+ enabled: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.enabled,
237
+ force: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.force,
238
+ keywords: {
239
+ any: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.keywords.any],
240
+ all: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.keywords.all],
241
+ none: [...DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.keywords.none],
242
+ matchCase: DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected.keywords.matchCase,
243
+ },
244
+ },
245
+ },
246
+ runtime: {
247
+ paused: DEFAULT_PLANPILOT_CONFIG.runtime.paused,
248
+ },
249
+ }
250
+ }
251
+
252
+ function parseBoolean(value: unknown, fallback: boolean): boolean {
253
+ if (typeof value === "boolean") return value
254
+ return fallback
255
+ }
256
+
257
+ function parseStringArray(value: unknown): string[] {
258
+ if (!Array.isArray(value)) return []
259
+ const parsed = value
260
+ .filter((item): item is string => typeof item === "string")
261
+ .map((item) => item.trim())
262
+ .filter((item) => item.length > 0)
263
+ return Array.from(new Set(parsed))
264
+ }
265
+
266
+ function parseNumberArray(value: unknown): number[] {
267
+ if (!Array.isArray(value)) return []
268
+ const parsed = value
269
+ .map((item) => (typeof item === "number" ? item : Number.NaN))
270
+ .filter((item) => Number.isFinite(item))
271
+ .map((item) => Math.trunc(item))
272
+ return Array.from(new Set(parsed))
273
+ }
274
+
275
+ function parsePositiveInt(value: unknown, fallback: number): number {
276
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback
277
+ const parsed = Math.trunc(value)
278
+ return parsed > 0 ? parsed : fallback
279
+ }
280
+
281
+ function parsePositiveNumberArray(value: unknown, fallback: number[]): number[] {
282
+ if (!Array.isArray(value)) return fallback
283
+ const parsed = value
284
+ .map((item) => (typeof item === "number" ? item : Number.NaN))
285
+ .filter((item) => Number.isFinite(item))
286
+ .map((item) => Math.trunc(item))
287
+ .filter((item) => item > 0)
288
+ if (!parsed.length) return fallback
289
+ return Array.from(new Set(parsed))
290
+ }
291
+
292
+ function parseKeywordRule(value: RawKeywordRule | undefined, fallback: KeywordRule): KeywordRule {
293
+ return {
294
+ any: parseStringArray(value?.any),
295
+ all: parseStringArray(value?.all),
296
+ none: parseStringArray(value?.none),
297
+ matchCase: parseBoolean(value?.matchCase, fallback.matchCase),
298
+ }
299
+ }
300
+
301
+ function parseEventRule(value: RawEventRule | undefined, fallback: EventRule): EventRule {
302
+ return {
303
+ enabled: parseBoolean(value?.enabled, fallback.enabled),
304
+ force: parseBoolean(value?.force, fallback.force),
305
+ keywords: parseKeywordRule(value?.keywords, fallback.keywords),
306
+ }
307
+ }
308
+
309
+ function parseSessionErrorRule(value: RawSessionErrorRule | undefined, fallback: SessionErrorRule): SessionErrorRule {
310
+ const base = parseEventRule(value, fallback)
311
+ return {
312
+ ...base,
313
+ errorNames: parseStringArray(value?.errorNames),
314
+ statusCodes: parseNumberArray(value?.statusCodes),
315
+ retryableOnly: parseBoolean(value?.retryableOnly, fallback.retryableOnly),
316
+ }
317
+ }
318
+
319
+ function parseSessionRetryRule(value: RawSessionRetryRule | undefined, fallback: SessionRetryRule): SessionRetryRule {
320
+ const base = parseEventRule(value, fallback)
321
+ const rawAttempt = typeof value?.attemptAtLeast === "number" ? Math.trunc(value.attemptAtLeast) : fallback.attemptAtLeast
322
+ return {
323
+ ...base,
324
+ attemptAtLeast: rawAttempt > 0 ? rawAttempt : fallback.attemptAtLeast,
325
+ }
326
+ }
327
+
328
+ function parseSendRetryConfig(value: RawSendRetryConfig | undefined, fallback: SendRetryConfig): SendRetryConfig {
329
+ return {
330
+ enabled: parseBoolean(value?.enabled, fallback.enabled),
331
+ maxAttempts: parsePositiveInt(value?.maxAttempts, fallback.maxAttempts),
332
+ delaysMs: parsePositiveNumberArray(value?.delaysMs, fallback.delaysMs),
333
+ }
334
+ }
335
+
336
+ function parseConfig(raw: RawPlanpilotConfig): PlanpilotConfig {
337
+ return {
338
+ autoContinue: {
339
+ sendRetry: parseSendRetryConfig(raw.autoContinue?.sendRetry, DEFAULT_PLANPILOT_CONFIG.autoContinue.sendRetry),
340
+ onSessionError: parseSessionErrorRule(
341
+ raw.autoContinue?.onSessionError,
342
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionError,
343
+ ),
344
+ onSessionRetry: parseSessionRetryRule(
345
+ raw.autoContinue?.onSessionRetry,
346
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onSessionRetry,
347
+ ),
348
+ onPermissionAsked: parseEventRule(
349
+ raw.autoContinue?.onPermissionAsked,
350
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionAsked,
351
+ ),
352
+ onPermissionRejected: parseEventRule(
353
+ raw.autoContinue?.onPermissionRejected,
354
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onPermissionRejected,
355
+ ),
356
+ onQuestionAsked: parseEventRule(
357
+ raw.autoContinue?.onQuestionAsked,
358
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionAsked,
359
+ ),
360
+ onQuestionRejected: parseEventRule(
361
+ raw.autoContinue?.onQuestionRejected,
362
+ DEFAULT_PLANPILOT_CONFIG.autoContinue.onQuestionRejected,
363
+ ),
364
+ },
365
+ runtime: {
366
+ paused: parseBoolean(raw.runtime?.paused, DEFAULT_PLANPILOT_CONFIG.runtime.paused),
367
+ },
368
+ }
369
+ }
370
+
371
+ export function normalizePlanpilotConfig(raw: unknown): PlanpilotConfig {
372
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
373
+ return cloneDefaultConfig()
374
+ }
375
+ return parseConfig(raw as RawPlanpilotConfig)
376
+ }
377
+
378
+ export function savePlanpilotConfig(config: PlanpilotConfig): LoadedPlanpilotConfig {
379
+ const filePath = resolvePlanpilotConfigPath()
380
+ const normalized = normalizePlanpilotConfig(config)
381
+ const parentDir = path.dirname(filePath)
382
+ fs.mkdirSync(parentDir, { recursive: true })
383
+ fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8")
384
+ return {
385
+ path: filePath,
386
+ loadedFromFile: true,
387
+ config: normalized,
388
+ }
389
+ }
390
+
391
+ export function loadPlanpilotConfig(): LoadedPlanpilotConfig {
392
+ const filePath = resolvePlanpilotConfigPath()
393
+ try {
394
+ if (!fs.existsSync(filePath)) {
395
+ return {
396
+ path: filePath,
397
+ loadedFromFile: false,
398
+ config: cloneDefaultConfig(),
399
+ }
400
+ }
401
+ const text = fs.readFileSync(filePath, "utf8")
402
+ const parsed = JSON.parse(text) as unknown
403
+ return {
404
+ path: filePath,
405
+ loadedFromFile: true,
406
+ config: normalizePlanpilotConfig(parsed),
407
+ }
408
+ } catch (error) {
409
+ const loadError = error instanceof Error ? error.message : String(error)
410
+ return {
411
+ path: filePath,
412
+ loadedFromFile: false,
413
+ config: cloneDefaultConfig(),
414
+ loadError,
415
+ }
416
+ }
417
+ }
418
+
419
+ export function matchesKeywords(text: string, rule: KeywordRule): boolean {
420
+ const source = rule.matchCase ? text : text.toLowerCase()
421
+ const normalize = (value: string) => (rule.matchCase ? value : value.toLowerCase())
422
+ const any = rule.any.map(normalize)
423
+ const all = rule.all.map(normalize)
424
+ const none = rule.none.map(normalize)
425
+
426
+ if (any.length > 0 && !any.some((term) => source.includes(term))) {
427
+ return false
428
+ }
429
+ if (!all.every((term) => source.includes(term))) {
430
+ return false
431
+ }
432
+ if (none.some((term) => source.includes(term))) {
433
+ return false
434
+ }
435
+ return true
436
+ }
package/src/prompt.ts CHANGED
@@ -91,11 +91,17 @@ export const PLANPILOT_TOOL_DESCRIPTION = [
91
91
  export const PLANPILOT_SYSTEM_INJECTION =
92
92
  "If the task is multi-step or complex, must use the `planpilot` plan tool. For full usage + rules, run: planpilot help."
93
93
 
94
- export function formatPlanpilotAutoContinueMessage(input: { timestamp: string; stepDetail: string }): string {
94
+ export function formatPlanpilotAutoContinueMessage(input: {
95
+ timestamp: string
96
+ stepDetail: string
97
+ triggerDetail?: string
98
+ }): string {
95
99
  const detail = (input.stepDetail ?? "").trimEnd()
100
+ const trigger = (input.triggerDetail ?? "").trim()
96
101
  return [
97
102
  `Planpilot @ ${input.timestamp}`,
98
103
  "This message was automatically sent by the Planpilot tool because the next pending step executor is ai.",
104
+ ...(trigger ? [`Trigger context: ${trigger}`] : []),
99
105
  "For full usage + rules, run: planpilot help",
100
106
  "Next step details:",
101
107
  detail,