pi-goala 0.2.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/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +25 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +27 -0
- package/docs/architecture.md +144 -0
- package/docs/configuration.md +146 -0
- package/docs/evaluation.md +220 -0
- package/docs/memory.md +112 -0
- package/docs/security.md +54 -0
- package/eval/README.md +90 -0
- package/eval/fixtures/window/README.md +6 -0
- package/eval/fixtures/window/package.json +8 -0
- package/eval/fixtures/window/src/limit.js +28 -0
- package/eval/fixtures/window/src/window.js +6 -0
- package/eval/fixtures/window/test/window.test.js +13 -0
- package/eval/fixtures/window-source-prd.md +17 -0
- package/eval/results/2026-07-25-verifier-grounded-memory.json +75 -0
- package/eval/results/2026-07-26-authoritative-source.json +56 -0
- package/eval/rpc-goal-runner.mjs +257 -0
- package/eval/seed-window-memory.ts +75 -0
- package/eval/window-hidden-check.mjs +23 -0
- package/extensions/goala/config.ts +190 -0
- package/extensions/goala/context.ts +153 -0
- package/extensions/goala/index.ts +931 -0
- package/extensions/goala/memory.ts +639 -0
- package/extensions/goala/policy.ts +167 -0
- package/extensions/goala/presenters.ts +161 -0
- package/extensions/goala/recovery.ts +209 -0
- package/extensions/goala/session.ts +88 -0
- package/extensions/goala/sources.ts +256 -0
- package/extensions/goala/tools.ts +623 -0
- package/extensions/goala/workflow.ts +265 -0
- package/package.json +65 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
statSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import {
|
|
8
|
+
isAbsolute,
|
|
9
|
+
relative,
|
|
10
|
+
resolve,
|
|
11
|
+
sep,
|
|
12
|
+
} from "node:path";
|
|
13
|
+
|
|
14
|
+
export const MAX_GOAL_SOURCES = 8;
|
|
15
|
+
export const MAX_GOAL_SOURCE_BYTES = 1_000_000;
|
|
16
|
+
|
|
17
|
+
export interface GoalSource {
|
|
18
|
+
path: string;
|
|
19
|
+
sha256: string;
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GoalRequest {
|
|
24
|
+
objective: string;
|
|
25
|
+
sourcePaths: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GoalSourceDrift {
|
|
29
|
+
path: string;
|
|
30
|
+
status: "changed" | "missing";
|
|
31
|
+
detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tokenizeOptions(input: string): string[] {
|
|
35
|
+
const tokens: string[] = [];
|
|
36
|
+
let token = "";
|
|
37
|
+
let quote: "'" | "\"" | undefined;
|
|
38
|
+
let escaping = false;
|
|
39
|
+
|
|
40
|
+
for (const character of input) {
|
|
41
|
+
if (escaping) {
|
|
42
|
+
token += character;
|
|
43
|
+
escaping = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (character === "\\") {
|
|
47
|
+
escaping = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (quote) {
|
|
51
|
+
if (character === quote) quote = undefined;
|
|
52
|
+
else token += character;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (character === "'" || character === "\"") {
|
|
56
|
+
quote = character;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (/\s/.test(character)) {
|
|
60
|
+
if (token) {
|
|
61
|
+
tokens.push(token);
|
|
62
|
+
token = "";
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
token += character;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (escaping) token += "\\";
|
|
70
|
+
if (quote) throw new Error("Source path has an unclosed quote.");
|
|
71
|
+
if (token) tokens.push(token);
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function goalSeparator(input: string): number | undefined {
|
|
76
|
+
let quote: "'" | "\"" | undefined;
|
|
77
|
+
let escaping = false;
|
|
78
|
+
for (let index = 0; index < input.length - 1; index++) {
|
|
79
|
+
const character = input[index];
|
|
80
|
+
if (escaping) {
|
|
81
|
+
escaping = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (character === "\\") {
|
|
85
|
+
escaping = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (quote) {
|
|
89
|
+
if (character === quote) quote = undefined;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (character === "'" || character === "\"") {
|
|
93
|
+
quote = character;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
character === "-" &&
|
|
98
|
+
input[index + 1] === "-" &&
|
|
99
|
+
index > 0 &&
|
|
100
|
+
/\s/.test(input[index - 1]) &&
|
|
101
|
+
/\s/.test(input[index + 2] ?? "")
|
|
102
|
+
) return index;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseGoalRequest(input: string): GoalRequest {
|
|
107
|
+
const trimmed = input.trim();
|
|
108
|
+
if (!/^--source(?:=|\s)/.test(trimmed)) {
|
|
109
|
+
return { objective: trimmed, sourcePaths: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const separator = goalSeparator(trimmed);
|
|
113
|
+
if (!separator) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"Source-backed goals require `--` before the objective: /goal --source docs/PRD.md -- <objective>",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const objective = trimmed.slice(separator + 2).trim();
|
|
119
|
+
if (!objective) throw new Error("A goal objective is required after `--`.");
|
|
120
|
+
|
|
121
|
+
const tokens = tokenizeOptions(trimmed.slice(0, separator));
|
|
122
|
+
const sourcePaths: string[] = [];
|
|
123
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
124
|
+
const token = tokens[index];
|
|
125
|
+
if (token === "--source") {
|
|
126
|
+
const path = tokens[++index];
|
|
127
|
+
if (!path) throw new Error("Each --source option requires a file path.");
|
|
128
|
+
sourcePaths.push(path);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (token.startsWith("--source=")) {
|
|
132
|
+
const path = token.slice("--source=".length);
|
|
133
|
+
if (!path) throw new Error("Each --source option requires a file path.");
|
|
134
|
+
sourcePaths.push(path);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Unknown goal option: ${token}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sourcePaths.length === 0) throw new Error("At least one --source path is required.");
|
|
141
|
+
if (sourcePaths.length > MAX_GOAL_SOURCES) {
|
|
142
|
+
throw new Error(`A goal can reference at most ${MAX_GOAL_SOURCES} source documents.`);
|
|
143
|
+
}
|
|
144
|
+
return { objective, sourcePaths };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveGoalSources(cwd: string, sourcePaths: string[]): GoalSource[] {
|
|
148
|
+
const root = realpathSync(cwd);
|
|
149
|
+
const sources: GoalSource[] = [];
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
|
|
152
|
+
for (const requestedPath of sourcePaths) {
|
|
153
|
+
if (requestedPath.length > 1000) throw new Error("A source path exceeds 1,000 characters.");
|
|
154
|
+
let sourcePath: string;
|
|
155
|
+
try {
|
|
156
|
+
sourcePath = realpathSync(
|
|
157
|
+
isAbsolute(requestedPath) ? requestedPath : resolve(root, requestedPath),
|
|
158
|
+
);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`Goal source does not exist: ${requestedPath}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const projectPath = relative(root, sourcePath);
|
|
164
|
+
if (
|
|
165
|
+
projectPath === "" ||
|
|
166
|
+
projectPath === ".." ||
|
|
167
|
+
projectPath.startsWith(`..${sep}`) ||
|
|
168
|
+
isAbsolute(projectPath)
|
|
169
|
+
) {
|
|
170
|
+
throw new Error(`Goal sources must be files inside the current project: ${requestedPath}`);
|
|
171
|
+
}
|
|
172
|
+
if (seen.has(sourcePath)) continue;
|
|
173
|
+
|
|
174
|
+
const stats = statSync(sourcePath);
|
|
175
|
+
if (!stats.isFile()) throw new Error(`Goal source is not a regular file: ${requestedPath}`);
|
|
176
|
+
if (stats.size > MAX_GOAL_SOURCE_BYTES) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Goal source exceeds ${MAX_GOAL_SOURCE_BYTES.toLocaleString()} bytes: ${requestedPath}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const content = readFileSync(sourcePath);
|
|
182
|
+
try {
|
|
183
|
+
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(`Goal source must be UTF-8 text: ${requestedPath}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
seen.add(sourcePath);
|
|
189
|
+
sources.push({
|
|
190
|
+
path: projectPath.split(sep).join("/"),
|
|
191
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
192
|
+
bytes: content.byteLength,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return sources;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function inspectGoalSources(
|
|
199
|
+
cwd: string,
|
|
200
|
+
sources: GoalSource[],
|
|
201
|
+
): GoalSourceDrift[] {
|
|
202
|
+
const root = realpathSync(cwd);
|
|
203
|
+
return sources.flatMap((source): GoalSourceDrift[] => {
|
|
204
|
+
try {
|
|
205
|
+
const currentPath = realpathSync(resolve(root, source.path));
|
|
206
|
+
const projectPath = relative(root, currentPath);
|
|
207
|
+
if (
|
|
208
|
+
projectPath === ".." ||
|
|
209
|
+
projectPath.startsWith(`..${sep}`) ||
|
|
210
|
+
isAbsolute(projectPath)
|
|
211
|
+
) {
|
|
212
|
+
return [{
|
|
213
|
+
path: source.path,
|
|
214
|
+
status: "missing" as const,
|
|
215
|
+
detail: "the path no longer resolves inside the current project",
|
|
216
|
+
}];
|
|
217
|
+
}
|
|
218
|
+
const stats = statSync(currentPath);
|
|
219
|
+
if (!stats.isFile()) {
|
|
220
|
+
return [{
|
|
221
|
+
path: source.path,
|
|
222
|
+
status: "missing" as const,
|
|
223
|
+
detail: "the captured source is no longer a regular file",
|
|
224
|
+
}];
|
|
225
|
+
}
|
|
226
|
+
if (stats.size > MAX_GOAL_SOURCE_BYTES) {
|
|
227
|
+
return [{
|
|
228
|
+
path: source.path,
|
|
229
|
+
status: "changed" as const,
|
|
230
|
+
detail: `the current file exceeds ${MAX_GOAL_SOURCE_BYTES.toLocaleString()} bytes`,
|
|
231
|
+
}];
|
|
232
|
+
}
|
|
233
|
+
const content = readFileSync(currentPath);
|
|
234
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
235
|
+
if (sha256 === source.sha256) return [];
|
|
236
|
+
return [{
|
|
237
|
+
path: source.path,
|
|
238
|
+
status: "changed" as const,
|
|
239
|
+
detail: `captured ${source.sha256.slice(0, 12)}, current ${sha256.slice(0, 12)}`,
|
|
240
|
+
}];
|
|
241
|
+
} catch {
|
|
242
|
+
return [{
|
|
243
|
+
path: source.path,
|
|
244
|
+
status: "missing" as const,
|
|
245
|
+
detail: "the captured source cannot be read",
|
|
246
|
+
}];
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatSourceDrift(drift: GoalSourceDrift[]): string {
|
|
252
|
+
if (drift.length === 0) return "";
|
|
253
|
+
return `AUTHORITATIVE SOURCE DRIFT
|
|
254
|
+
${drift.map((source) => `- ${source.path}: ${source.status} — ${source.detail}`).join("\n")}
|
|
255
|
+
Stop and surface this discrepancy. Do not silently reinterpret the approved acceptance contract.`;
|
|
256
|
+
}
|