pi-plan-task 1.0.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/README.md +100 -0
- package/extensions/ask-question.test.ts +91 -0
- package/extensions/ask-question.ts +145 -0
- package/extensions/bash-guard.test.ts +17 -0
- package/extensions/bash-guard.ts +94 -0
- package/extensions/config.ts +49 -0
- package/extensions/files.test.ts +59 -0
- package/extensions/files.ts +50 -0
- package/extensions/framing.test.ts +85 -0
- package/extensions/framing.ts +79 -0
- package/extensions/index.ts +549 -0
- package/extensions/parse.ts +88 -0
- package/extensions/paths.ts +39 -0
- package/extensions/plan-input.test.ts +142 -0
- package/extensions/plan-input.ts +162 -0
- package/extensions/planning-and-task-breakdown.md +287 -0
- package/extensions/planning-method.test.ts +20 -0
- package/extensions/planning-method.ts +38 -0
- package/extensions/prompts.test.ts +70 -0
- package/extensions/prompts.ts +87 -0
- package/extensions/types.ts +26 -0
- package/package.json +18 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { TaskItem } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const NUMBERED_ITEM = /^[-*]\s+\[([ xX])\]\s+(\d+)\.\s+(.+)$/;
|
|
4
|
+
const PLAIN_ITEM = /^[-*]\s+\[([ xX])\]\s+(.+)$/;
|
|
5
|
+
const TASK_HEADING = /^##\s+Task\s+(\d+)\b/i;
|
|
6
|
+
|
|
7
|
+
function splitChecklist(raw: string): { checklist: string; rest: string } {
|
|
8
|
+
const lines = raw.split(/\r?\n/);
|
|
9
|
+
const index = lines.findIndex((line) => TASK_HEADING.test(line));
|
|
10
|
+
if (index < 0) return { checklist: raw, rest: "" };
|
|
11
|
+
return {
|
|
12
|
+
checklist: lines.slice(0, index).join("\n"),
|
|
13
|
+
rest: lines.slice(index).join("\n"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractBody(raw: string, id: number): string {
|
|
18
|
+
const lines = raw.split(/\r?\n/);
|
|
19
|
+
const start = lines.findIndex((line) => {
|
|
20
|
+
const match = line.match(TASK_HEADING);
|
|
21
|
+
return match?.[1] === String(id);
|
|
22
|
+
});
|
|
23
|
+
if (start < 0) return "";
|
|
24
|
+
let end = lines.length;
|
|
25
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
26
|
+
const match = lines[i]?.match(TASK_HEADING);
|
|
27
|
+
if (match && match[1] !== String(id)) {
|
|
28
|
+
end = i;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return lines.slice(start, end).join("\n").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseTaskMarkdown(raw: string): TaskItem[] {
|
|
36
|
+
const { checklist } = splitChecklist(raw);
|
|
37
|
+
const numbered: TaskItem[] = [];
|
|
38
|
+
for (const line of checklist.split(/\r?\n/)) {
|
|
39
|
+
const match = line.match(NUMBERED_ITEM);
|
|
40
|
+
if (!match) continue;
|
|
41
|
+
const id = Number(match[2]);
|
|
42
|
+
if (!Number.isFinite(id)) continue;
|
|
43
|
+
numbered.push({
|
|
44
|
+
id,
|
|
45
|
+
title: match[3].trim(),
|
|
46
|
+
done: match[1].toLowerCase() === "x",
|
|
47
|
+
body: extractBody(raw, id),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (numbered.length > 0) return numbered;
|
|
51
|
+
|
|
52
|
+
const plain: TaskItem[] = [];
|
|
53
|
+
for (const line of checklist.split(/\r?\n/)) {
|
|
54
|
+
const match = line.match(PLAIN_ITEM);
|
|
55
|
+
if (!match) continue;
|
|
56
|
+
const id = plain.length + 1;
|
|
57
|
+
plain.push({
|
|
58
|
+
id,
|
|
59
|
+
title: match[2].trim(),
|
|
60
|
+
done: match[1].toLowerCase() === "x",
|
|
61
|
+
body: extractBody(raw, id),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return plain;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function markTaskDoneInMarkdown(raw: string, id: number): string {
|
|
68
|
+
const lines = raw.split(/\r?\n/);
|
|
69
|
+
let remaining = id;
|
|
70
|
+
let changed = false;
|
|
71
|
+
const next = lines.map((line) => {
|
|
72
|
+
const numbered = line.match(NUMBERED_ITEM);
|
|
73
|
+
if (numbered && Number(numbered[2]) === id && numbered[1] !== "x" && numbered[1] !== "X") {
|
|
74
|
+
changed = true;
|
|
75
|
+
return line.replace(/\[ \]/, "[x]");
|
|
76
|
+
}
|
|
77
|
+
const plain = line.match(PLAIN_ITEM);
|
|
78
|
+
if (!numbered && plain) {
|
|
79
|
+
remaining -= 1;
|
|
80
|
+
if (remaining === 0 && plain[1] !== "x" && plain[1] !== "X") {
|
|
81
|
+
changed = true;
|
|
82
|
+
return line.replace(/\[ \]/, "[x]");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return line;
|
|
86
|
+
});
|
|
87
|
+
return changed ? next.join("\n") : raw;
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { CONFIG_FILE_NAME, PLAN_DIR_NAME, PLAN_FILE_NAME, TASK_FILE_NAME } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export function planDir(cwd: string): string {
|
|
6
|
+
return resolve(cwd, PLAN_DIR_NAME);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function planFilePath(cwd: string): string {
|
|
10
|
+
return resolve(cwd, PLAN_DIR_NAME, PLAN_FILE_NAME);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function taskFilePath(cwd: string): string {
|
|
14
|
+
return resolve(cwd, PLAN_DIR_NAME, TASK_FILE_NAME);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function globalConfigPath(): string {
|
|
18
|
+
return resolve(getAgentDir(), CONFIG_FILE_NAME);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function projectConfigPath(cwd: string): string {
|
|
22
|
+
return resolve(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function samePath(a: string, b: string): boolean {
|
|
26
|
+
const left = resolve(a);
|
|
27
|
+
const right = resolve(b);
|
|
28
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isPlanArtifactPath(cwd: string, filePath: string): boolean {
|
|
32
|
+
const absolute = resolve(cwd, stripAtPrefix(filePath));
|
|
33
|
+
return samePath(absolute, planFilePath(cwd)) || samePath(absolute, taskFilePath(cwd));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stripAtPrefix(filePath: string): string {
|
|
37
|
+
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
isExplicitFileRef,
|
|
8
|
+
loadPlanSource,
|
|
9
|
+
parsePlanArgs,
|
|
10
|
+
type PathKind,
|
|
11
|
+
resolveUserPath,
|
|
12
|
+
tokenizePlanArgs,
|
|
13
|
+
} from "./plan-input.ts";
|
|
14
|
+
|
|
15
|
+
const cwd = process.platform === "win32" ? "D:\\proj" : "/proj";
|
|
16
|
+
|
|
17
|
+
function kinds(entries: Record<string, PathKind>): (path: string) => PathKind {
|
|
18
|
+
return (path) => entries[path] ?? "none";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("tokenizePlanArgs", () => {
|
|
22
|
+
it("splits on whitespace and unwraps quotes", () => {
|
|
23
|
+
assert.deepEqual(tokenizePlanArgs(`docs/spec.md focus on auth`), ["docs/spec.md", "focus", "on", "auth"]);
|
|
24
|
+
assert.deepEqual(tokenizePlanArgs(`"my spec.md" extra`), ["my spec.md", "extra"]);
|
|
25
|
+
assert.deepEqual(tokenizePlanArgs(`'my spec.md'`), ["my spec.md"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("isExplicitFileRef", () => {
|
|
30
|
+
it("treats path-like and spec-extension tokens as file refs", () => {
|
|
31
|
+
assert.equal(isExplicitFileRef("spec.md"), true);
|
|
32
|
+
assert.equal(isExplicitFileRef("./notes.txt"), true);
|
|
33
|
+
assert.equal(isExplicitFileRef("@docs/spec.md"), true);
|
|
34
|
+
assert.equal(isExplicitFileRef("src/auth"), false);
|
|
35
|
+
assert.equal(isExplicitFileRef("Add"), false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("parsePlanArgs", () => {
|
|
40
|
+
it("treats empty args as empty", () => {
|
|
41
|
+
assert.deepEqual(parsePlanArgs(" ", cwd, kinds({})), { kind: "empty" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("treats free text as a prompt", () => {
|
|
45
|
+
assert.deepEqual(parsePlanArgs("Add login with OAuth", cwd, kinds({})), {
|
|
46
|
+
kind: "prompt",
|
|
47
|
+
prompt: "Add login with OAuth",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats an existing first token as a spec file", () => {
|
|
52
|
+
const spec = resolveUserPath(cwd, "docs/spec.md");
|
|
53
|
+
assert.deepEqual(parsePlanArgs("docs/spec.md focus on the callback", cwd, kinds({ [spec]: "file" })), {
|
|
54
|
+
kind: "file",
|
|
55
|
+
displayPath: "docs/spec.md",
|
|
56
|
+
resolvedPath: spec,
|
|
57
|
+
notes: "focus on the callback",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("treats an unquoted path with spaces as a file when the whole string exists", () => {
|
|
62
|
+
const spec = resolveUserPath(cwd, "my spec.md");
|
|
63
|
+
assert.deepEqual(parsePlanArgs("my spec.md", cwd, kinds({ [spec]: "file" })), {
|
|
64
|
+
kind: "file",
|
|
65
|
+
displayPath: "my spec.md",
|
|
66
|
+
resolvedPath: spec,
|
|
67
|
+
notes: "",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("accepts @-prefixed and quoted paths", () => {
|
|
72
|
+
const spec = resolveUserPath(cwd, "docs/spec.md");
|
|
73
|
+
assert.deepEqual(parsePlanArgs("@docs/spec.md", cwd, kinds({ [spec]: "file" })), {
|
|
74
|
+
kind: "file",
|
|
75
|
+
displayPath: "docs/spec.md",
|
|
76
|
+
resolvedPath: spec,
|
|
77
|
+
notes: "",
|
|
78
|
+
});
|
|
79
|
+
assert.deepEqual(parsePlanArgs(`"docs/spec.md" extra`, cwd, kinds({ [spec]: "file" })), {
|
|
80
|
+
kind: "file",
|
|
81
|
+
displayPath: "docs/spec.md",
|
|
82
|
+
resolvedPath: spec,
|
|
83
|
+
notes: "extra",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("errors when an explicit file ref is missing or a directory", () => {
|
|
88
|
+
const missing = resolveUserPath(cwd, "./gone.md");
|
|
89
|
+
const folder = resolveUserPath(cwd, "docs");
|
|
90
|
+
assert.deepEqual(parsePlanArgs("./gone.md", cwd, kinds({})), {
|
|
91
|
+
kind: "missing",
|
|
92
|
+
displayPath: "./gone.md",
|
|
93
|
+
resolvedPath: missing,
|
|
94
|
+
});
|
|
95
|
+
assert.deepEqual(parsePlanArgs("docs", cwd, kinds({ [folder]: "dir" })), {
|
|
96
|
+
kind: "prompt",
|
|
97
|
+
prompt: "docs",
|
|
98
|
+
});
|
|
99
|
+
assert.deepEqual(parsePlanArgs("./docs", cwd, kinds({ [folder]: "dir" })), {
|
|
100
|
+
kind: "directory",
|
|
101
|
+
displayPath: "./docs",
|
|
102
|
+
resolvedPath: folder,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps path-like text as a prompt when the file does not exist", () => {
|
|
107
|
+
assert.deepEqual(parsePlanArgs("src/auth should use JWT", cwd, kinds({})), {
|
|
108
|
+
kind: "prompt",
|
|
109
|
+
prompt: "src/auth should use JWT",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("loadPlanSource", () => {
|
|
115
|
+
it("inlines text from an existing spec file", async () => {
|
|
116
|
+
const root = await mkdtemp(join(tmpdir(), "pi-plan-task-"));
|
|
117
|
+
const spec = join(root, "spec.md");
|
|
118
|
+
await writeFile(spec, "# Auth\n\nAdd login.\n", "utf8");
|
|
119
|
+
const loaded = await loadPlanSource("spec.md extra notes", root);
|
|
120
|
+
assert.equal(loaded.ok, true);
|
|
121
|
+
if (!loaded.ok) return;
|
|
122
|
+
assert.deepEqual(loaded.source, {
|
|
123
|
+
kind: "file",
|
|
124
|
+
displayPath: "spec.md",
|
|
125
|
+
resolvedPath: resolve(root, "spec.md"),
|
|
126
|
+
notes: "extra notes",
|
|
127
|
+
content: "# Auth\n\nAdd login.\n",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects a missing explicit file", async () => {
|
|
132
|
+
const loaded = await loadPlanSource("./missing.md", cwd, kinds({}));
|
|
133
|
+
assert.deepEqual(loaded, { ok: false, error: "File not found: ./missing.md" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects a directory that was given as an explicit path", async () => {
|
|
137
|
+
const root = await mkdtemp(join(tmpdir(), "pi-plan-task-"));
|
|
138
|
+
await mkdir(join(root, "docs"));
|
|
139
|
+
const loaded = await loadPlanSource("./docs", root);
|
|
140
|
+
assert.deepEqual(loaded, { ok: false, error: "Path is a directory: ./docs" });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
function stripAtPrefix(filePath: string): string {
|
|
7
|
+
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MAX_INLINE_SPEC_CHARS = 100_000;
|
|
11
|
+
|
|
12
|
+
const SPEC_EXT = /\.(md|markdown|txt|rst|adoc|org|html?)$/i;
|
|
13
|
+
|
|
14
|
+
export type PathKind = "file" | "dir" | "none";
|
|
15
|
+
|
|
16
|
+
export type PlanSource =
|
|
17
|
+
| { kind: "empty" }
|
|
18
|
+
| { kind: "prompt"; prompt: string }
|
|
19
|
+
| {
|
|
20
|
+
kind: "file";
|
|
21
|
+
displayPath: string;
|
|
22
|
+
resolvedPath: string;
|
|
23
|
+
notes: string;
|
|
24
|
+
content?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ParsedPlanArgs =
|
|
28
|
+
| { kind: "empty" }
|
|
29
|
+
| { kind: "prompt"; prompt: string }
|
|
30
|
+
| { kind: "file"; displayPath: string; resolvedPath: string; notes: string }
|
|
31
|
+
| { kind: "missing"; displayPath: string; resolvedPath: string }
|
|
32
|
+
| { kind: "directory"; displayPath: string; resolvedPath: string };
|
|
33
|
+
|
|
34
|
+
export const EMPTY_PLAN_SOURCE: PlanSource = { kind: "empty" };
|
|
35
|
+
|
|
36
|
+
export function tokenizePlanArgs(input: string): string[] {
|
|
37
|
+
const tokens: string[] = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
let quote: '"' | "'" | null = null;
|
|
40
|
+
for (const char of input) {
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (char === quote) {
|
|
43
|
+
quote = null;
|
|
44
|
+
} else {
|
|
45
|
+
current += char;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === '"' || char === "'") {
|
|
50
|
+
quote = char;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (/\s/.test(char)) {
|
|
54
|
+
if (current) {
|
|
55
|
+
tokens.push(current);
|
|
56
|
+
current = "";
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
current += char;
|
|
61
|
+
}
|
|
62
|
+
if (current) tokens.push(current);
|
|
63
|
+
return tokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isExplicitFileRef(token: string): boolean {
|
|
67
|
+
if (token.startsWith("@")) return true;
|
|
68
|
+
const raw = stripAtPrefix(token);
|
|
69
|
+
if (raw.startsWith("./") || raw.startsWith(".\\") || raw.startsWith("../") || raw.startsWith("..\\")) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (raw.startsWith("~")) return true;
|
|
73
|
+
if (raw.startsWith("/") || raw.startsWith("\\\\")) return true;
|
|
74
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) return true;
|
|
75
|
+
return SPEC_EXT.test(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveUserPath(cwd: string, raw: string): string {
|
|
79
|
+
const path = stripAtPrefix(raw);
|
|
80
|
+
if (path === "~") return homedir();
|
|
81
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
82
|
+
return resolve(homedir(), path.slice(2));
|
|
83
|
+
}
|
|
84
|
+
return resolve(cwd, path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function pathKind(path: string): PathKind {
|
|
88
|
+
try {
|
|
89
|
+
const info = statSync(path);
|
|
90
|
+
if (info.isFile()) return "file";
|
|
91
|
+
if (info.isDirectory()) return "dir";
|
|
92
|
+
return "none";
|
|
93
|
+
} catch {
|
|
94
|
+
return "none";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parsePlanArgs(
|
|
99
|
+
args: string,
|
|
100
|
+
cwd: string,
|
|
101
|
+
kindOf: (path: string) => PathKind = pathKind,
|
|
102
|
+
): ParsedPlanArgs {
|
|
103
|
+
const trimmed = args.trim();
|
|
104
|
+
if (!trimmed) return { kind: "empty" };
|
|
105
|
+
|
|
106
|
+
const wholePath = resolveUserPath(cwd, trimmed);
|
|
107
|
+
if (kindOf(wholePath) === "file") {
|
|
108
|
+
return { kind: "file", displayPath: stripAtPrefix(trimmed), resolvedPath: wholePath, notes: "" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tokens = tokenizePlanArgs(trimmed);
|
|
112
|
+
if (tokens.length === 0) return { kind: "empty" };
|
|
113
|
+
|
|
114
|
+
const first = tokens[0] ?? "";
|
|
115
|
+
const resolvedPath = resolveUserPath(cwd, first);
|
|
116
|
+
const kind = kindOf(resolvedPath);
|
|
117
|
+
const displayPath = stripAtPrefix(first);
|
|
118
|
+
const notes = tokens.slice(1).join(" ");
|
|
119
|
+
|
|
120
|
+
if (kind === "file") {
|
|
121
|
+
return { kind: "file", displayPath, resolvedPath, notes };
|
|
122
|
+
}
|
|
123
|
+
if (isExplicitFileRef(first)) {
|
|
124
|
+
if (kind === "dir") return { kind: "directory", displayPath, resolvedPath };
|
|
125
|
+
return { kind: "missing", displayPath, resolvedPath };
|
|
126
|
+
}
|
|
127
|
+
return { kind: "prompt", prompt: tokens.join(" ") };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadPlanSource(
|
|
131
|
+
args: string,
|
|
132
|
+
cwd: string,
|
|
133
|
+
kindOf: (path: string) => PathKind = pathKind,
|
|
134
|
+
): Promise<{ ok: true; source: PlanSource } | { ok: false; error: string }> {
|
|
135
|
+
const parsed = parsePlanArgs(args, cwd, kindOf);
|
|
136
|
+
if (parsed.kind === "missing") {
|
|
137
|
+
return { ok: false, error: `File not found: ${parsed.displayPath}` };
|
|
138
|
+
}
|
|
139
|
+
if (parsed.kind === "directory") {
|
|
140
|
+
return { ok: false, error: `Path is a directory: ${parsed.displayPath}` };
|
|
141
|
+
}
|
|
142
|
+
if (parsed.kind === "empty" || parsed.kind === "prompt") {
|
|
143
|
+
return { ok: true, source: parsed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readFile(parsed.resolvedPath, "utf8");
|
|
148
|
+
if (raw.includes("\0")) {
|
|
149
|
+
return { ok: false, error: `Not a text file: ${parsed.displayPath}` };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
source: {
|
|
154
|
+
...parsed,
|
|
155
|
+
content: raw.length <= MAX_INLINE_SPEC_CHARS ? raw : undefined,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
160
|
+
return { ok: false, error: `Could not read ${parsed.displayPath}: ${message}` };
|
|
161
|
+
}
|
|
162
|
+
}
|