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/LICENSE +21 -0
- package/README.md +31 -0
- package/docs/planpilot.md +219 -0
- package/package.json +62 -0
- package/src/cli.ts +1483 -0
- package/src/index.ts +140 -0
- package/src/lib/app.ts +1072 -0
- package/src/lib/argv.ts +58 -0
- package/src/lib/db.ts +108 -0
- package/src/lib/errors.ts +36 -0
- package/src/lib/format.ts +239 -0
- package/src/lib/instructions.ts +26 -0
- package/src/lib/models.ts +144 -0
- package/src/lib/util.ts +76 -0
package/src/lib/argv.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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/db.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import { Database } from "bun:sqlite"
|
|
5
|
+
import { xdgConfig } from "xdg-basedir"
|
|
6
|
+
|
|
7
|
+
export type DatabaseConnection = Database
|
|
8
|
+
|
|
9
|
+
let cachedDb: Database | null = null
|
|
10
|
+
|
|
11
|
+
export function resolveConfigRoot(): string {
|
|
12
|
+
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
13
|
+
return process.env.OPENCODE_CONFIG_DIR
|
|
14
|
+
}
|
|
15
|
+
const base = xdgConfig ?? path.join(os.homedir(), ".config")
|
|
16
|
+
return path.join(base, "opencode")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolvePlanpilotDir(): string {
|
|
20
|
+
const override = process.env.OPENCODE_PLANPILOT_DIR || process.env.OPENCODE_PLANPILOT_HOME
|
|
21
|
+
if (override && override.trim()) return override
|
|
22
|
+
return path.join(resolveConfigRoot(), ".planpilot")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveDbPath(): string {
|
|
26
|
+
return path.join(resolvePlanpilotDir(), "planpilot.db")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolvePlanMarkdownDir(): string {
|
|
30
|
+
return path.join(resolvePlanpilotDir(), "plans")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolvePlanMarkdownPath(planId: number): string {
|
|
34
|
+
return path.join(resolvePlanMarkdownDir(), `plan_${planId}.md`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ensureParentDir(filePath: string) {
|
|
38
|
+
const dir = path.dirname(filePath)
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openDatabase(): Database {
|
|
43
|
+
if (cachedDb) return cachedDb
|
|
44
|
+
const dbPath = resolveDbPath()
|
|
45
|
+
ensureParentDir(dbPath)
|
|
46
|
+
const db = new Database(dbPath)
|
|
47
|
+
db.exec("PRAGMA foreign_keys = ON;")
|
|
48
|
+
ensureSchema(db)
|
|
49
|
+
cachedDb = db
|
|
50
|
+
return db
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ensureSchema(db: Database) {
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS plans (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
title TEXT NOT NULL,
|
|
58
|
+
content TEXT NOT NULL,
|
|
59
|
+
status TEXT NOT NULL,
|
|
60
|
+
comment TEXT,
|
|
61
|
+
last_session_id TEXT,
|
|
62
|
+
last_cwd TEXT,
|
|
63
|
+
created_at INTEGER NOT NULL,
|
|
64
|
+
updated_at INTEGER NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
plan_id INTEGER NOT NULL,
|
|
69
|
+
content TEXT NOT NULL,
|
|
70
|
+
status TEXT NOT NULL,
|
|
71
|
+
executor TEXT NOT NULL,
|
|
72
|
+
sort_order INTEGER NOT NULL,
|
|
73
|
+
comment TEXT,
|
|
74
|
+
created_at INTEGER NOT NULL,
|
|
75
|
+
updated_at INTEGER NOT NULL,
|
|
76
|
+
FOREIGN KEY(plan_id) REFERENCES plans(id) ON DELETE CASCADE
|
|
77
|
+
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS goals (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
step_id INTEGER NOT NULL,
|
|
81
|
+
content TEXT NOT NULL,
|
|
82
|
+
status TEXT NOT NULL,
|
|
83
|
+
comment TEXT,
|
|
84
|
+
created_at INTEGER NOT NULL,
|
|
85
|
+
updated_at INTEGER NOT NULL,
|
|
86
|
+
FOREIGN KEY(step_id) REFERENCES steps(id) ON DELETE CASCADE
|
|
87
|
+
);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS active_plan (
|
|
89
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
+
session_id TEXT NOT NULL,
|
|
91
|
+
plan_id INTEGER NOT NULL,
|
|
92
|
+
updated_at INTEGER NOT NULL,
|
|
93
|
+
UNIQUE(session_id),
|
|
94
|
+
UNIQUE(plan_id),
|
|
95
|
+
FOREIGN KEY(plan_id) REFERENCES plans(id) ON DELETE CASCADE
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_steps_plan_order ON steps(plan_id, sort_order);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_goals_step ON goals(step_id);
|
|
99
|
+
`)
|
|
100
|
+
|
|
101
|
+
const columns = db
|
|
102
|
+
.prepare("PRAGMA table_info(plans)")
|
|
103
|
+
.all()
|
|
104
|
+
.map((row: any) => row.name as string)
|
|
105
|
+
if (!columns.includes("last_cwd")) {
|
|
106
|
+
db.exec("ALTER TABLE plans ADD COLUMN last_cwd TEXT")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type AppErrorKind = "InvalidInput" | "NotFound" | "Db" | "Io" | "Json"
|
|
2
|
+
|
|
3
|
+
export class AppError extends Error {
|
|
4
|
+
public readonly kind: AppErrorKind
|
|
5
|
+
public readonly detail: string
|
|
6
|
+
|
|
7
|
+
constructor(kind: AppErrorKind, detail: string) {
|
|
8
|
+
super(detail)
|
|
9
|
+
this.kind = kind
|
|
10
|
+
this.detail = detail
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
toDisplayString(): string {
|
|
14
|
+
const label = this.kind === "InvalidInput" ? "Invalid input" : this.kind === "NotFound" ? "Not found" : null
|
|
15
|
+
if (!label) {
|
|
16
|
+
return this.detail
|
|
17
|
+
}
|
|
18
|
+
if (this.detail.includes("\n")) {
|
|
19
|
+
return `${label}:\n${this.detail}`
|
|
20
|
+
}
|
|
21
|
+
return `${label}: ${this.detail}`
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function invalidInput(message: string): AppError {
|
|
26
|
+
return new AppError("InvalidInput", message)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function notFound(message: string): AppError {
|
|
30
|
+
return new AppError("NotFound", message)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function wrapDbError(message: string, err: unknown): AppError {
|
|
34
|
+
const detail = err instanceof Error ? `${message}: ${err.message}` : message
|
|
35
|
+
return new AppError("Db", detail)
|
|
36
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { GoalRow, PlanRow, StepRow, PlanDetail } from "./models"
|
|
2
|
+
import { formatDateTimeUTC } from "./util"
|
|
3
|
+
|
|
4
|
+
function hasText(value?: string | null) {
|
|
5
|
+
return value !== undefined && value !== null && value.trim().length > 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatStepDetail(step: StepRow, goals: GoalRow[]): string {
|
|
9
|
+
let output = ""
|
|
10
|
+
output += `Step ID: ${step.id}\n`
|
|
11
|
+
output += `Plan ID: ${step.plan_id}\n`
|
|
12
|
+
output += `Status: ${step.status}\n`
|
|
13
|
+
output += `Executor: ${step.executor}\n`
|
|
14
|
+
output += `Content: ${step.content}\n`
|
|
15
|
+
if (hasText(step.comment)) {
|
|
16
|
+
output += `Comment: ${step.comment ?? ""}\n`
|
|
17
|
+
}
|
|
18
|
+
output += `Created: ${formatDateTimeUTC(step.created_at)}\n`
|
|
19
|
+
output += `Updated: ${formatDateTimeUTC(step.updated_at)}\n`
|
|
20
|
+
output += "\n"
|
|
21
|
+
if (!goals.length) {
|
|
22
|
+
output += "Goals: (none)"
|
|
23
|
+
return output.trimEnd()
|
|
24
|
+
}
|
|
25
|
+
output += "Goals:\n"
|
|
26
|
+
for (const goal of goals) {
|
|
27
|
+
output += `- [${goal.status}] ${goal.content} (goal id ${goal.id})\n`
|
|
28
|
+
if (hasText(goal.comment)) {
|
|
29
|
+
output += ` Comment: ${goal.comment ?? ""}\n`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return output.trimEnd()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatGoalDetail(goal: GoalRow, step: StepRow): string {
|
|
36
|
+
let output = ""
|
|
37
|
+
output += `Goal ID: ${goal.id}\n`
|
|
38
|
+
output += `Step ID: ${goal.step_id}\n`
|
|
39
|
+
output += `Plan ID: ${step.plan_id}\n`
|
|
40
|
+
output += `Status: ${goal.status}\n`
|
|
41
|
+
output += `Content: ${goal.content}\n`
|
|
42
|
+
if (hasText(goal.comment)) {
|
|
43
|
+
output += `Comment: ${goal.comment ?? ""}\n`
|
|
44
|
+
}
|
|
45
|
+
output += `Created: ${formatDateTimeUTC(goal.created_at)}\n`
|
|
46
|
+
output += `Updated: ${formatDateTimeUTC(goal.updated_at)}\n`
|
|
47
|
+
output += "\n"
|
|
48
|
+
output += `Step Status: ${step.status}\n`
|
|
49
|
+
output += `Step Executor: ${step.executor}\n`
|
|
50
|
+
output += `Step Content: ${step.content}\n`
|
|
51
|
+
if (hasText(step.comment)) {
|
|
52
|
+
output += `Step Comment: ${step.comment ?? ""}\n`
|
|
53
|
+
}
|
|
54
|
+
return output.trimEnd()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatPlanDetail(plan: PlanRow, steps: StepRow[], goals: Map<number, GoalRow[]>): string {
|
|
58
|
+
let output = ""
|
|
59
|
+
output += `Plan ID: ${plan.id}\n`
|
|
60
|
+
output += `Title: ${plan.title}\n`
|
|
61
|
+
output += `Status: ${plan.status}\n`
|
|
62
|
+
output += `Content: ${plan.content}\n`
|
|
63
|
+
if (hasText(plan.comment)) {
|
|
64
|
+
output += `Comment: ${plan.comment ?? ""}\n`
|
|
65
|
+
}
|
|
66
|
+
output += `Created: ${formatDateTimeUTC(plan.created_at)}\n`
|
|
67
|
+
output += `Updated: ${formatDateTimeUTC(plan.updated_at)}\n`
|
|
68
|
+
output += "\n"
|
|
69
|
+
if (!steps.length) {
|
|
70
|
+
output += "Steps: (none)"
|
|
71
|
+
return output.trimEnd()
|
|
72
|
+
}
|
|
73
|
+
output += "Steps:\n"
|
|
74
|
+
for (const step of steps) {
|
|
75
|
+
const stepGoals = goals.get(step.id) ?? []
|
|
76
|
+
if (stepGoals.length) {
|
|
77
|
+
const done = stepGoals.filter((goal) => goal.status === "done").length
|
|
78
|
+
output += `- [${step.status}] ${step.content} (step id ${step.id}, exec ${step.executor}, goals ${done}/${stepGoals.length})\n`
|
|
79
|
+
} else {
|
|
80
|
+
output += `- [${step.status}] ${step.content} (step id ${step.id}, exec ${step.executor})\n`
|
|
81
|
+
}
|
|
82
|
+
if (hasText(step.comment)) {
|
|
83
|
+
output += ` Comment: ${step.comment ?? ""}\n`
|
|
84
|
+
}
|
|
85
|
+
if (stepGoals.length) {
|
|
86
|
+
for (const goal of stepGoals) {
|
|
87
|
+
output += ` - [${goal.status}] ${goal.content} (goal id ${goal.id})\n`
|
|
88
|
+
if (hasText(goal.comment)) {
|
|
89
|
+
output += ` Comment: ${goal.comment ?? ""}\n`
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return output.trimEnd()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatPlanMarkdown(
|
|
98
|
+
active: boolean,
|
|
99
|
+
activeUpdated: number | null,
|
|
100
|
+
plan: PlanRow,
|
|
101
|
+
steps: StepRow[],
|
|
102
|
+
goals: Map<number, GoalRow[]>,
|
|
103
|
+
): string {
|
|
104
|
+
const lines: string[] = []
|
|
105
|
+
|
|
106
|
+
const checkbox = (status: string) => (status === "done" ? "x" : " ")
|
|
107
|
+
|
|
108
|
+
const collapseHeading = (text: string) => {
|
|
109
|
+
const normalized = text.replace(/\r\n/g, "\n")
|
|
110
|
+
const parts = normalized
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => line.trim())
|
|
113
|
+
.filter((line) => line.length > 0)
|
|
114
|
+
if (!parts.length) return "(untitled)"
|
|
115
|
+
return parts.join(" / ")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const splitTaskText = (text: string): [string, string[]] => {
|
|
119
|
+
const normalized = text.replace(/\r\n/g, "\n")
|
|
120
|
+
const rawLines = normalized.split("\n")
|
|
121
|
+
if (!rawLines.length) return ["(empty)", []]
|
|
122
|
+
let firstIdx = -1
|
|
123
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
124
|
+
if (rawLines[i].trim()) {
|
|
125
|
+
firstIdx = i
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (firstIdx === -1) return ["(empty)", []]
|
|
130
|
+
return [rawLines[firstIdx], rawLines.slice(firstIdx + 1)]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pushLine = (indent: number, text: string) => {
|
|
134
|
+
lines.push(`${" ".repeat(indent)}${text}`)
|
|
135
|
+
}
|
|
136
|
+
const pushBlank = (indent: number) => {
|
|
137
|
+
lines.push(indent === 0 ? "" : " ".repeat(indent))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pushLine(0, "# Plan")
|
|
141
|
+
pushBlank(0)
|
|
142
|
+
pushLine(0, `## Plan: ${collapseHeading(plan.title)}`)
|
|
143
|
+
pushBlank(0)
|
|
144
|
+
pushLine(0, `- **Active:** \`${active ? "true" : "false"}\``)
|
|
145
|
+
pushLine(0, `- **Plan ID:** \`${plan.id}\``)
|
|
146
|
+
pushLine(0, `- **Status:** \`${plan.status}\``)
|
|
147
|
+
if (hasText(plan.comment)) {
|
|
148
|
+
pushLine(0, `- **Comment:** ${plan.comment ?? ""}`)
|
|
149
|
+
}
|
|
150
|
+
if (activeUpdated) {
|
|
151
|
+
pushLine(0, `- **Activated:** ${formatDateTimeUTC(activeUpdated)}`)
|
|
152
|
+
}
|
|
153
|
+
pushLine(0, `- **Created:** ${formatDateTimeUTC(plan.created_at)}`)
|
|
154
|
+
pushLine(0, `- **Updated:** ${formatDateTimeUTC(plan.updated_at)}`)
|
|
155
|
+
const stepsDone = steps.filter((step) => step.status === "done").length
|
|
156
|
+
pushLine(0, `- **Steps:** ${stepsDone}/${steps.length}`)
|
|
157
|
+
pushBlank(0)
|
|
158
|
+
|
|
159
|
+
pushLine(0, "### Plan Content")
|
|
160
|
+
pushBlank(0)
|
|
161
|
+
if (!plan.content.trim()) {
|
|
162
|
+
pushLine(0, "*No content*")
|
|
163
|
+
} else {
|
|
164
|
+
const normalized = plan.content.replace(/\r\n/g, "\n")
|
|
165
|
+
for (const line of normalized.split("\n")) {
|
|
166
|
+
if (!line.length) {
|
|
167
|
+
pushLine(0, ">")
|
|
168
|
+
} else {
|
|
169
|
+
pushLine(0, `> ${line}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
pushBlank(0)
|
|
174
|
+
|
|
175
|
+
pushLine(0, "### Steps")
|
|
176
|
+
pushBlank(0)
|
|
177
|
+
if (!steps.length) {
|
|
178
|
+
pushLine(0, "*No steps*")
|
|
179
|
+
return lines.join("\n").trimEnd()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
steps.forEach((step, idx) => {
|
|
183
|
+
const [firstLine, restLines] = splitTaskText(step.content)
|
|
184
|
+
pushLine(0, `- [${checkbox(step.status)}] **${firstLine}** *(id: ${step.id}, exec: ${step.executor}, order: ${step.sort_order})*`)
|
|
185
|
+
|
|
186
|
+
let hasRest = false
|
|
187
|
+
for (const line of restLines) {
|
|
188
|
+
if (!line.trim()) continue
|
|
189
|
+
if (!hasRest) {
|
|
190
|
+
pushBlank(2)
|
|
191
|
+
hasRest = true
|
|
192
|
+
} else {
|
|
193
|
+
pushBlank(2)
|
|
194
|
+
}
|
|
195
|
+
pushLine(2, line)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
pushBlank(2)
|
|
199
|
+
pushLine(2, `- Created: ${formatDateTimeUTC(step.created_at)}`)
|
|
200
|
+
pushLine(2, `- Updated: ${formatDateTimeUTC(step.updated_at)}`)
|
|
201
|
+
if (hasText(step.comment)) {
|
|
202
|
+
pushLine(2, `- Comment: ${step.comment ?? ""}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stepGoals = goals.get(step.id)
|
|
206
|
+
if (stepGoals && stepGoals.length) {
|
|
207
|
+
const done = stepGoals.filter((goal) => goal.status === "done").length
|
|
208
|
+
pushLine(2, `- Goals: ${done}/${stepGoals.length}`)
|
|
209
|
+
for (const goal of stepGoals) {
|
|
210
|
+
const [goalFirst, goalRest] = splitTaskText(goal.content)
|
|
211
|
+
pushBlank(2)
|
|
212
|
+
pushLine(2, `- [${checkbox(goal.status)}] ${goalFirst} *(id: ${goal.id})*`)
|
|
213
|
+
for (const line of goalRest) {
|
|
214
|
+
if (!line.trim()) continue
|
|
215
|
+
pushBlank(4)
|
|
216
|
+
pushLine(4, line)
|
|
217
|
+
}
|
|
218
|
+
if (hasText(goal.comment)) {
|
|
219
|
+
pushBlank(4)
|
|
220
|
+
pushLine(4, `Comment: ${goal.comment ?? ""}`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
pushLine(2, "- Goals: 0/0")
|
|
225
|
+
pushBlank(2)
|
|
226
|
+
pushLine(2, "- (none)")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (idx + 1 < steps.length) {
|
|
230
|
+
pushBlank(0)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
return lines.join("\n").trimEnd()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function planDetailToMarkdown(detail: PlanDetail, active: boolean, activeUpdated: number | null): string {
|
|
238
|
+
return formatPlanMarkdown(active, activeUpdated, detail.plan, detail.steps, detail.goals)
|
|
239
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type PlanStatus = "todo" | "done"
|
|
2
|
+
export type StepStatus = "todo" | "done"
|
|
3
|
+
export type GoalStatus = "todo" | "done"
|
|
4
|
+
export type StepExecutor = "ai" | "human"
|
|
5
|
+
|
|
6
|
+
export interface PlanRow {
|
|
7
|
+
id: number
|
|
8
|
+
title: string
|
|
9
|
+
content: string
|
|
10
|
+
status: PlanStatus
|
|
11
|
+
comment: string | null
|
|
12
|
+
last_session_id: string | null
|
|
13
|
+
last_cwd: string | null
|
|
14
|
+
created_at: number
|
|
15
|
+
updated_at: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StepRow {
|
|
19
|
+
id: number
|
|
20
|
+
plan_id: number
|
|
21
|
+
content: string
|
|
22
|
+
status: StepStatus
|
|
23
|
+
executor: StepExecutor
|
|
24
|
+
sort_order: number
|
|
25
|
+
comment: string | null
|
|
26
|
+
created_at: number
|
|
27
|
+
updated_at: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GoalRow {
|
|
31
|
+
id: number
|
|
32
|
+
step_id: number
|
|
33
|
+
content: string
|
|
34
|
+
status: GoalStatus
|
|
35
|
+
comment: string | null
|
|
36
|
+
created_at: number
|
|
37
|
+
updated_at: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ActivePlanRow {
|
|
41
|
+
id: number
|
|
42
|
+
session_id: string
|
|
43
|
+
plan_id: number
|
|
44
|
+
updated_at: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PlanDetail {
|
|
48
|
+
plan: PlanRow
|
|
49
|
+
steps: StepRow[]
|
|
50
|
+
goals: Map<number, GoalRow[]>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StepDetail {
|
|
54
|
+
step: StepRow
|
|
55
|
+
goals: GoalRow[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GoalDetail {
|
|
59
|
+
goal: GoalRow
|
|
60
|
+
step: StepRow
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StepInput {
|
|
64
|
+
content: string
|
|
65
|
+
executor: StepExecutor
|
|
66
|
+
goals: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StepStatusChange {
|
|
70
|
+
step_id: number
|
|
71
|
+
from: string
|
|
72
|
+
to: string
|
|
73
|
+
reason: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PlanStatusChange {
|
|
77
|
+
plan_id: number
|
|
78
|
+
from: string
|
|
79
|
+
to: string
|
|
80
|
+
reason: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ActivePlanCleared {
|
|
84
|
+
plan_id: number
|
|
85
|
+
reason: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface StatusChanges {
|
|
89
|
+
steps: StepStatusChange[]
|
|
90
|
+
plans: PlanStatusChange[]
|
|
91
|
+
active_plans_cleared: ActivePlanCleared[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PlanChanges {
|
|
95
|
+
title?: string
|
|
96
|
+
content?: string
|
|
97
|
+
status?: PlanStatus
|
|
98
|
+
comment?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StepChanges {
|
|
102
|
+
content?: string
|
|
103
|
+
status?: StepStatus
|
|
104
|
+
executor?: StepExecutor
|
|
105
|
+
comment?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface GoalChanges {
|
|
109
|
+
content?: string
|
|
110
|
+
status?: GoalStatus
|
|
111
|
+
comment?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type PlanOrder = "id" | "title" | "created" | "updated"
|
|
115
|
+
export type StepOrder = "order" | "id" | "created" | "updated"
|
|
116
|
+
|
|
117
|
+
export interface StepQuery {
|
|
118
|
+
status?: StepStatus | null
|
|
119
|
+
executor?: StepExecutor | null
|
|
120
|
+
limit?: number
|
|
121
|
+
offset?: number
|
|
122
|
+
order?: StepOrder
|
|
123
|
+
desc?: boolean
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface GoalQuery {
|
|
127
|
+
status?: GoalStatus | null
|
|
128
|
+
limit?: number
|
|
129
|
+
offset?: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createEmptyStatusChanges(): StatusChanges {
|
|
133
|
+
return { steps: [], plans: [], active_plans_cleared: [] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mergeStatusChanges(target: StatusChanges, other: StatusChanges) {
|
|
137
|
+
target.steps.push(...other.steps)
|
|
138
|
+
target.plans.push(...other.plans)
|
|
139
|
+
target.active_plans_cleared.push(...other.active_plans_cleared)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function statusChangesEmpty(changes: StatusChanges) {
|
|
143
|
+
return !changes.steps.length && !changes.plans.length && !changes.active_plans_cleared.length
|
|
144
|
+
}
|
package/src/lib/util.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { invalidInput } from "./errors"
|
|
4
|
+
|
|
5
|
+
export function ensureNonEmpty(label: string, value: string) {
|
|
6
|
+
if (value.trim().length === 0) {
|
|
7
|
+
throw invalidInput(`${label} cannot be empty`)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDateTimeUTC(timestamp: number): string {
|
|
12
|
+
const date = new Date(timestamp)
|
|
13
|
+
const yyyy = date.getUTCFullYear()
|
|
14
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0")
|
|
15
|
+
const dd = String(date.getUTCDate()).padStart(2, "0")
|
|
16
|
+
const hh = String(date.getUTCHours()).padStart(2, "0")
|
|
17
|
+
const min = String(date.getUTCMinutes()).padStart(2, "0")
|
|
18
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${min}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function uniqueIds(ids: number[]): number[] {
|
|
22
|
+
const seen = new Set<number>()
|
|
23
|
+
const unique: number[] = []
|
|
24
|
+
for (const id of ids) {
|
|
25
|
+
if (!seen.has(id)) {
|
|
26
|
+
seen.add(id)
|
|
27
|
+
unique.push(id)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return unique
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function joinIds(ids: number[]): string {
|
|
34
|
+
return ids.map((id) => String(id)).join(", ")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeCommentEntries(entries: Array<[number, string]>): Array<[number, string]> {
|
|
38
|
+
const seen = new Map<number, number>()
|
|
39
|
+
const ordered: Array<[number, string]> = []
|
|
40
|
+
for (const [id, comment] of entries) {
|
|
41
|
+
const idx = seen.get(id)
|
|
42
|
+
if (idx !== undefined) {
|
|
43
|
+
ordered[idx][1] = comment
|
|
44
|
+
} else {
|
|
45
|
+
seen.set(id, ordered.length)
|
|
46
|
+
ordered.push([id, comment])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return ordered
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveMaybeRealpath(value: string): string {
|
|
53
|
+
try {
|
|
54
|
+
return fs.realpathSync.native(value)
|
|
55
|
+
} catch {
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizePath(value: string): string {
|
|
61
|
+
let resolved = resolveMaybeRealpath(value)
|
|
62
|
+
resolved = path.resolve(resolved)
|
|
63
|
+
if (process.platform === "win32") {
|
|
64
|
+
resolved = resolved.toLowerCase()
|
|
65
|
+
}
|
|
66
|
+
return resolved.replace(/[\\/]+$/, "")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function projectMatchesPath(project: string, current: string): boolean {
|
|
70
|
+
const projectNorm = normalizePath(project)
|
|
71
|
+
const currentNorm = normalizePath(current)
|
|
72
|
+
if (projectNorm === currentNorm) return true
|
|
73
|
+
if (currentNorm.startsWith(projectNorm + path.sep)) return true
|
|
74
|
+
if (projectNorm.startsWith(currentNorm + path.sep)) return true
|
|
75
|
+
return false
|
|
76
|
+
}
|