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.
@@ -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
+ }
@@ -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
+ }