@open-tomato/rafa 0.7.1
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 +202 -0
- package/NOTICE +59 -0
- package/README.md +362 -0
- package/dist/PROMPT.md +28 -0
- package/dist/SKILL.md +547 -0
- package/dist/cli.js +26675 -0
- package/dist/effort/store/index.js +17 -0
- package/dist/index-adgde7x3.js +1101 -0
- package/dist/index-akz06erg.js +866 -0
- package/dist/index-zjxrphjq.js +1755 -0
- package/dist/index.js +9027 -0
- package/dist/plan/index.js +47 -0
- package/dist/plan-prompt.md +65 -0
- package/dist/plans/resolve-ci-install.md +24 -0
- package/dist/plans/resolve-ci-lint.md +24 -0
- package/dist/plans/resolve-conflict-lockfile.md +28 -0
- package/dist/plans/resolve-conflict-manifest.md +28 -0
- package/dist/ports/index.js +1 -0
- package/dist/templates/spec.md +37 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
activeOutput,
|
|
4
|
+
messageOf,
|
|
5
|
+
parseTaskDeclaration,
|
|
6
|
+
readRafaBlocks
|
|
7
|
+
} from "./index-akz06erg.js";
|
|
8
|
+
// src/preflight/prerequisites-md.ts
|
|
9
|
+
import { basename, dirname, join } from "path";
|
|
10
|
+
var SECTION_AUTO_RE = /\[auto\]/i;
|
|
11
|
+
var SECTION_START_RE = /\[start\]/i;
|
|
12
|
+
var SECTION_HUMAN_RE = /\[human\]|manual|human|sign.?off|team/i;
|
|
13
|
+
var ITEM_RE = /^-\s+\[\s*\]\s+/;
|
|
14
|
+
var INLINE_AUTO_RE = /^-\s+\[\s*\]\s+\[auto\]\s+/i;
|
|
15
|
+
var INLINE_HUMAN_RE = /^-\s+\[\s*\]\s+\[human\]\s+/i;
|
|
16
|
+
var INLINE_START_RE = /^-\s+\[\s*\]\s+\[start\]\s+/i;
|
|
17
|
+
var BACKTICK_RE = /`([^`]+)`/;
|
|
18
|
+
var HEADING_MARKS_RE = /^#+/;
|
|
19
|
+
var FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
20
|
+
var NESTED_LIST_ITEM_RE = /^\s+(?:[-*+]|\d+[.)])\s/;
|
|
21
|
+
var PLAN_FILE_RE = /^PLAN-(.+)\.md$/;
|
|
22
|
+
function inferSectionTag(header) {
|
|
23
|
+
if (SECTION_AUTO_RE.test(header))
|
|
24
|
+
return "auto";
|
|
25
|
+
if (SECTION_START_RE.test(header))
|
|
26
|
+
return "start";
|
|
27
|
+
if (SECTION_HUMAN_RE.test(header))
|
|
28
|
+
return "human";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function enterHeading(scope, header) {
|
|
32
|
+
const level = HEADING_MARKS_RE.exec(header)?.[0].length ?? 1;
|
|
33
|
+
const above = Array.from({ length: level - 1 }, (_, index) => scope[index] ?? null);
|
|
34
|
+
return [...above, inferSectionTag(header)];
|
|
35
|
+
}
|
|
36
|
+
function sectionDefault(scope) {
|
|
37
|
+
for (let index = scope.length - 1;index >= 0; index -= 1) {
|
|
38
|
+
const tag = scope[index];
|
|
39
|
+
if (tag)
|
|
40
|
+
return tag;
|
|
41
|
+
}
|
|
42
|
+
return "human";
|
|
43
|
+
}
|
|
44
|
+
function fenceOf(line) {
|
|
45
|
+
return FENCE_RE.exec(line)?.[1] ?? null;
|
|
46
|
+
}
|
|
47
|
+
function closesFence(opened, marker) {
|
|
48
|
+
return marker !== null && marker[0] === opened[0] && marker.length >= opened.length;
|
|
49
|
+
}
|
|
50
|
+
function isContinuation(line) {
|
|
51
|
+
return /^\s/.test(line) && line.trim() !== "" && !NESTED_LIST_ITEM_RE.test(line) && fenceOf(line) === null;
|
|
52
|
+
}
|
|
53
|
+
function continuationOf(lines, start) {
|
|
54
|
+
const following = [];
|
|
55
|
+
for (let index = start;index < lines.length; index += 1) {
|
|
56
|
+
const line = lines[index] ?? "";
|
|
57
|
+
if (!isContinuation(line))
|
|
58
|
+
break;
|
|
59
|
+
following.push(line.trim());
|
|
60
|
+
}
|
|
61
|
+
return following;
|
|
62
|
+
}
|
|
63
|
+
function inlineTag(line) {
|
|
64
|
+
if (INLINE_AUTO_RE.test(line))
|
|
65
|
+
return "auto";
|
|
66
|
+
if (INLINE_START_RE.test(line))
|
|
67
|
+
return "start";
|
|
68
|
+
if (INLINE_HUMAN_RE.test(line))
|
|
69
|
+
return "human";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function stripItemPrefix(line) {
|
|
73
|
+
return line.replace(INLINE_AUTO_RE, "").replace(INLINE_HUMAN_RE, "").replace(INLINE_START_RE, "").replace(ITEM_RE, "").trim();
|
|
74
|
+
}
|
|
75
|
+
function isProbedTag(tag) {
|
|
76
|
+
return tag === "auto" || tag === "start";
|
|
77
|
+
}
|
|
78
|
+
function extractProbe(description) {
|
|
79
|
+
const probe = BACKTICK_RE.exec(description)?.[1]?.trim();
|
|
80
|
+
return probe || null;
|
|
81
|
+
}
|
|
82
|
+
function readItem(lines, lineIndex, inherited) {
|
|
83
|
+
const raw = lines[lineIndex] ?? "";
|
|
84
|
+
const tag = inlineTag(raw) ?? inherited;
|
|
85
|
+
const description = [stripItemPrefix(raw), ...continuationOf(lines, lineIndex + 1)].join(" ");
|
|
86
|
+
const probe = isProbedTag(tag) ? extractProbe(description) : null;
|
|
87
|
+
return Object.freeze({ raw, description, tag, lineIndex, probe });
|
|
88
|
+
}
|
|
89
|
+
function parsePrerequisites(content) {
|
|
90
|
+
const lines = content.split(`
|
|
91
|
+
`);
|
|
92
|
+
const items = [];
|
|
93
|
+
let scope = [];
|
|
94
|
+
let fence = null;
|
|
95
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
96
|
+
const line = lines[index] ?? "";
|
|
97
|
+
const marker = fenceOf(line);
|
|
98
|
+
if (fence !== null) {
|
|
99
|
+
if (closesFence(fence, marker))
|
|
100
|
+
fence = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (marker !== null) {
|
|
104
|
+
fence = marker;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (line.startsWith("#")) {
|
|
108
|
+
scope = enterHeading(scope, line);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ITEM_RE.test(line))
|
|
112
|
+
items.push(readItem(lines, index, sectionDefault(scope)));
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze(items);
|
|
115
|
+
}
|
|
116
|
+
function isProbed(item) {
|
|
117
|
+
return isProbedTag(item.tag) && item.probe !== null;
|
|
118
|
+
}
|
|
119
|
+
function requiredItem(item) {
|
|
120
|
+
return Object.freeze({ kind: "tool", name: item.description, probe: item.probe });
|
|
121
|
+
}
|
|
122
|
+
function requiredItemsTagged(items, tag) {
|
|
123
|
+
const probed = items.filter(isProbed).filter((item) => item.tag === tag);
|
|
124
|
+
return Object.freeze(probed.map(requiredItem));
|
|
125
|
+
}
|
|
126
|
+
function reminderOf(item) {
|
|
127
|
+
return Object.freeze({ description: item.description, tag: item.tag, line: item.lineIndex + 1 });
|
|
128
|
+
}
|
|
129
|
+
function planPrerequisites(content) {
|
|
130
|
+
const items = parsePrerequisites(content);
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
required: requiredItemsTagged(items, "auto"),
|
|
133
|
+
startRequired: requiredItemsTagged(items, "start"),
|
|
134
|
+
reminders: Object.freeze(items.filter((item) => !isProbed(item)).map(reminderOf))
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function mergePlanPrerequisites(settings, content) {
|
|
138
|
+
const plan = planPrerequisites(content ?? "");
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
required: Object.freeze([...settings.prerequisitesRequired, ...plan.required]),
|
|
141
|
+
startRequired: plan.startRequired,
|
|
142
|
+
optional: Object.freeze([...settings.prerequisitesOptional]),
|
|
143
|
+
reminders: plan.reminders
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function prerequisitesPathForPlan(planPath) {
|
|
147
|
+
const stub = PLAN_FILE_RE.exec(basename(planPath))?.[1];
|
|
148
|
+
if (stub === undefined)
|
|
149
|
+
return null;
|
|
150
|
+
return join(dirname(planPath), `PREREQUISITES-${stub}.md`);
|
|
151
|
+
}
|
|
152
|
+
async function readIfPresent(path) {
|
|
153
|
+
try {
|
|
154
|
+
return await Bun.file(path).text();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const isAbsent = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
157
|
+
if (isAbsent)
|
|
158
|
+
return null;
|
|
159
|
+
throw new Error(`${path}: cannot be read (${messageOf(error)})`, { cause: error });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function loadPlanPrerequisites(planPath, settings) {
|
|
163
|
+
const path = prerequisitesPathForPlan(planPath);
|
|
164
|
+
const content = path === null ? null : await readIfPresent(path);
|
|
165
|
+
return mergePlanPrerequisites(settings, content);
|
|
166
|
+
}
|
|
167
|
+
// src/preflight/run.ts
|
|
168
|
+
var PROBE_TIMEOUT_MS = 30000;
|
|
169
|
+
var SHELL = "/bin/sh";
|
|
170
|
+
var PASS = Object.freeze({ outcome: "pass", failure: null });
|
|
171
|
+
function spawnShell(probe, options) {
|
|
172
|
+
return Bun.spawn([SHELL, "-c", probe], {
|
|
173
|
+
cwd: options.cwd,
|
|
174
|
+
env: { ...options.env },
|
|
175
|
+
stdin: "ignore",
|
|
176
|
+
stdout: "ignore",
|
|
177
|
+
stderr: "pipe",
|
|
178
|
+
detached: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function isNoSuchProcess(error) {
|
|
182
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
|
|
183
|
+
}
|
|
184
|
+
function killGroup(proc) {
|
|
185
|
+
try {
|
|
186
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!isNoSuchProcess(error))
|
|
189
|
+
proc.kill("SIGKILL");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function collect(stream) {
|
|
193
|
+
const reader = stream.getReader();
|
|
194
|
+
const read = async () => {
|
|
195
|
+
const chunks = [];
|
|
196
|
+
let next = await reader.read();
|
|
197
|
+
while (!next.done) {
|
|
198
|
+
chunks.push(next.value);
|
|
199
|
+
next = await reader.read();
|
|
200
|
+
}
|
|
201
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
202
|
+
};
|
|
203
|
+
const stop = () => {
|
|
204
|
+
reader.cancel().catch(() => {
|
|
205
|
+
return;
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
return { text: read(), stop };
|
|
209
|
+
}
|
|
210
|
+
async function runShellProbe(probe, options) {
|
|
211
|
+
let proc;
|
|
212
|
+
try {
|
|
213
|
+
proc = spawnShell(probe, options);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const stderr2 = `could not run ${SHELL} in ${options.cwd}: ${messageOf(error)}`;
|
|
216
|
+
return Object.freeze({ exitCode: null, stderr: stderr2, timedOut: false });
|
|
217
|
+
}
|
|
218
|
+
const stderr = collect(proc.stderr);
|
|
219
|
+
let timedOut = false;
|
|
220
|
+
const deadline = setTimeout(() => {
|
|
221
|
+
timedOut = proc.exitCode === null && proc.signalCode === null;
|
|
222
|
+
killGroup(proc);
|
|
223
|
+
stderr.stop();
|
|
224
|
+
}, options.timeoutMs);
|
|
225
|
+
try {
|
|
226
|
+
const exitCode = await proc.exited;
|
|
227
|
+
const text = await stderr.text;
|
|
228
|
+
return Object.freeze({ exitCode, stderr: text, timedOut });
|
|
229
|
+
} finally {
|
|
230
|
+
clearTimeout(deadline);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function secondsOf(timeoutMs) {
|
|
234
|
+
return `${timeoutMs / 1000}s`;
|
|
235
|
+
}
|
|
236
|
+
function firstLineOf(stderr) {
|
|
237
|
+
const line = stderr.split(`
|
|
238
|
+
`).map((part) => part.trim()).find((part) => part !== "");
|
|
239
|
+
return line ?? null;
|
|
240
|
+
}
|
|
241
|
+
function endingOf(run, timeoutMs) {
|
|
242
|
+
if (run.timedOut)
|
|
243
|
+
return `timed out after ${secondsOf(timeoutMs)} and was killed (exit ${run.exitCode})`;
|
|
244
|
+
if (run.exitCode === null)
|
|
245
|
+
return "could not be run";
|
|
246
|
+
return `exited ${run.exitCode}`;
|
|
247
|
+
}
|
|
248
|
+
function probeVerdict(probe, run, timeoutMs) {
|
|
249
|
+
if (run.exitCode === 0 && !run.timedOut)
|
|
250
|
+
return PASS;
|
|
251
|
+
const line = firstLineOf(run.stderr);
|
|
252
|
+
const stated = `probe \`${probe}\` ${endingOf(run, timeoutMs)}`;
|
|
253
|
+
const failure = line === null ? stated : `${stated}: ${line}`;
|
|
254
|
+
const outcome = run.timedOut ? "timeout" : "fail";
|
|
255
|
+
return Object.freeze({ outcome, failure });
|
|
256
|
+
}
|
|
257
|
+
function missing(outcome, what) {
|
|
258
|
+
return Object.freeze({ outcome, failure: `presence check: ${what}` });
|
|
259
|
+
}
|
|
260
|
+
function onPath(command, context) {
|
|
261
|
+
const found = Bun.which(command, { PATH: context.env.PATH ?? "", cwd: context.cwd });
|
|
262
|
+
return found === null ? missing("fail", `${command} is not on PATH`) : PASS;
|
|
263
|
+
}
|
|
264
|
+
function envSet(name, context) {
|
|
265
|
+
const value = context.env[name];
|
|
266
|
+
if (value === undefined)
|
|
267
|
+
return missing("fail", `${name} is not set`);
|
|
268
|
+
if (value === "")
|
|
269
|
+
return missing("fail", `${name} is set but empty`);
|
|
270
|
+
return PASS;
|
|
271
|
+
}
|
|
272
|
+
async function serviceAnswers(url, context) {
|
|
273
|
+
const controller = new AbortController;
|
|
274
|
+
const deadline = setTimeout(() => {
|
|
275
|
+
controller.abort();
|
|
276
|
+
}, context.timeoutMs);
|
|
277
|
+
try {
|
|
278
|
+
await context.request(url, { method: "HEAD", redirect: "manual", signal: controller.signal });
|
|
279
|
+
return PASS;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (controller.signal.aborted) {
|
|
282
|
+
return missing("timeout", `${url} did not answer within ${secondsOf(context.timeoutMs)}`);
|
|
283
|
+
}
|
|
284
|
+
return missing("fail", `${url} did not answer: ${messageOf(error)}`);
|
|
285
|
+
} finally {
|
|
286
|
+
clearTimeout(deadline);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function presenceVerdict(item, context) {
|
|
290
|
+
switch (item.kind) {
|
|
291
|
+
case "tool":
|
|
292
|
+
return onPath(item.name, context);
|
|
293
|
+
case "env":
|
|
294
|
+
return envSet(item.name, context);
|
|
295
|
+
case "service":
|
|
296
|
+
return serviceAnswers(item.name, context);
|
|
297
|
+
case "lsp":
|
|
298
|
+
return onPath(`${item.name}-language-server`, context);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function checkItem(tier, item, context) {
|
|
302
|
+
const started = context.now();
|
|
303
|
+
const verdict = item.probe === null ? await presenceVerdict(item, context) : probeVerdict(item.probe, await context.runProbe(item.probe, context), context.timeoutMs);
|
|
304
|
+
const durationMs = Math.max(0, Math.round(context.now() - started));
|
|
305
|
+
return Object.freeze({ tier, item, outcome: verdict.outcome, durationMs, failure: verdict.failure });
|
|
306
|
+
}
|
|
307
|
+
function labelOf(item) {
|
|
308
|
+
return `${item.kind} ${JSON.stringify(item.name)}`;
|
|
309
|
+
}
|
|
310
|
+
function oneLine(text) {
|
|
311
|
+
return text.replace(/\s+/g, " ").trim();
|
|
312
|
+
}
|
|
313
|
+
function knownMissingLine(item) {
|
|
314
|
+
const reason = item.reason === null ? "" : ` (${oneLine(item.reason)})`;
|
|
315
|
+
return `known-missing: ${oneLine(item.name)}${reason}`;
|
|
316
|
+
}
|
|
317
|
+
function warningOf(check) {
|
|
318
|
+
return `preflight: optional item ${labelOf(check.item)} failed: ${check.failure}; ` + "the run goes on, and each task prompt names it known-missing";
|
|
319
|
+
}
|
|
320
|
+
function haltOf(failed) {
|
|
321
|
+
if (failed.length === 0)
|
|
322
|
+
return null;
|
|
323
|
+
const noun = failed.length === 1 ? "item" : "items";
|
|
324
|
+
const lines = failed.map((check) => ` ${labelOf(check.item)}: ${check.failure}`);
|
|
325
|
+
return [`preflight halted: ${failed.length} required ${noun} failed`, ...lines].join(`
|
|
326
|
+
`);
|
|
327
|
+
}
|
|
328
|
+
function warnThroughActiveOutput(message) {
|
|
329
|
+
activeOutput().warn(message);
|
|
330
|
+
}
|
|
331
|
+
function contextOf(options) {
|
|
332
|
+
return {
|
|
333
|
+
cwd: options.cwd,
|
|
334
|
+
env: options.env ?? process.env,
|
|
335
|
+
timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS,
|
|
336
|
+
runProbe: options.runProbe ?? runShellProbe,
|
|
337
|
+
request: options.request ?? ((url, init) => fetch(url, init)),
|
|
338
|
+
warn: options.warn ?? warnThroughActiveOutput,
|
|
339
|
+
now: options.now ?? (() => performance.now())
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async function runPreflight(items, options) {
|
|
343
|
+
const context = contextOf(options);
|
|
344
|
+
const checks = [];
|
|
345
|
+
const knownMissing = [];
|
|
346
|
+
for (const item of items.required) {
|
|
347
|
+
checks.push(await checkItem("required", item, context));
|
|
348
|
+
}
|
|
349
|
+
for (const item of items.optional) {
|
|
350
|
+
const check = await checkItem("optional", item, context);
|
|
351
|
+
checks.push(check);
|
|
352
|
+
if (check.outcome === "pass")
|
|
353
|
+
continue;
|
|
354
|
+
context.warn(warningOf(check));
|
|
355
|
+
knownMissing.push(knownMissingLine(item));
|
|
356
|
+
}
|
|
357
|
+
const failed = checks.filter((check) => check.tier === "required" && check.outcome !== "pass");
|
|
358
|
+
return Object.freeze({
|
|
359
|
+
checks: Object.freeze(checks),
|
|
360
|
+
halt: haltOf(failed),
|
|
361
|
+
knownMissing: Object.freeze(knownMissing)
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// src/report/parse.ts
|
|
365
|
+
var REPORT_STATUSES = ["done", "blocked"];
|
|
366
|
+
var FINDING_KINDS = ["gotcha", "pattern", "location", "skill-suggestion"];
|
|
367
|
+
var FINDING_SIGNALS = ["loud", "silent"];
|
|
368
|
+
var CHANGE_LEVELS = ["patch", "minor", "major", "none"];
|
|
369
|
+
var REPORT_KIND = "report";
|
|
370
|
+
var REQUIRED = true;
|
|
371
|
+
var OPTIONAL = false;
|
|
372
|
+
var REPORT_KEYS = [
|
|
373
|
+
"status",
|
|
374
|
+
"feedback",
|
|
375
|
+
"findings",
|
|
376
|
+
"skills_used",
|
|
377
|
+
"blockers",
|
|
378
|
+
"out_of_scope_bugs",
|
|
379
|
+
"changes"
|
|
380
|
+
];
|
|
381
|
+
var FINDING_KEYS = ["trigger", "kind", "what", "cause", "resolution", "artifact", "signal"];
|
|
382
|
+
var BLOCKER_KEYS = ["what", "artifact"];
|
|
383
|
+
var BUG_KEYS = ["what", "artifact", "security"];
|
|
384
|
+
var CHANGE_KEYS = ["level", "area", "summary"];
|
|
385
|
+
function isMapping(value) {
|
|
386
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
387
|
+
}
|
|
388
|
+
function isOneOf(choices, value) {
|
|
389
|
+
return choices.includes(value);
|
|
390
|
+
}
|
|
391
|
+
function describeValue(value) {
|
|
392
|
+
if (typeof value === "string")
|
|
393
|
+
return JSON.stringify(value);
|
|
394
|
+
if (value === null)
|
|
395
|
+
return "nothing";
|
|
396
|
+
if (Array.isArray(value))
|
|
397
|
+
return "a list";
|
|
398
|
+
if (typeof value === "object")
|
|
399
|
+
return "a mapping";
|
|
400
|
+
return `the ${typeof value} ${String(value)}`;
|
|
401
|
+
}
|
|
402
|
+
function messageOf2(error) {
|
|
403
|
+
return error instanceof Error ? error.message : String(error);
|
|
404
|
+
}
|
|
405
|
+
function reportIssue(reason, field, text) {
|
|
406
|
+
return { reason, field, text };
|
|
407
|
+
}
|
|
408
|
+
function absent(reason, block, text) {
|
|
409
|
+
return { present: false, reason, block, text };
|
|
410
|
+
}
|
|
411
|
+
function scopeOf(mapping, keys, prefix, issues) {
|
|
412
|
+
const fields = new Map;
|
|
413
|
+
const extras = [];
|
|
414
|
+
for (const [key, value] of Object.entries(mapping)) {
|
|
415
|
+
if (keys.includes(key))
|
|
416
|
+
fields.set(key, value);
|
|
417
|
+
else
|
|
418
|
+
extras.push({ key, value });
|
|
419
|
+
}
|
|
420
|
+
return { fields, extras, prefix, issues };
|
|
421
|
+
}
|
|
422
|
+
function valueOf(scope, key, required) {
|
|
423
|
+
const value = scope.fields.get(key) ?? null;
|
|
424
|
+
if (value === null && required) {
|
|
425
|
+
const field = `${scope.prefix}${key}`;
|
|
426
|
+
scope.issues.push(reportIssue("missing-field", field, `${field} is missing`));
|
|
427
|
+
}
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
function unusable(scope, key, why) {
|
|
431
|
+
const field = `${scope.prefix}${key}`;
|
|
432
|
+
scope.issues.push(reportIssue("unusable-field", field, `${field} ${why}`));
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
function stringProblem(value) {
|
|
436
|
+
if (typeof value !== "string")
|
|
437
|
+
return `is ${describeValue(value)}, not a string; quote it`;
|
|
438
|
+
if (value.trim().length === 0)
|
|
439
|
+
return "is blank";
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
function stringField(scope, key, required) {
|
|
443
|
+
const value = valueOf(scope, key, required);
|
|
444
|
+
if (value === null)
|
|
445
|
+
return null;
|
|
446
|
+
const problem = stringProblem(value);
|
|
447
|
+
return problem === null ? value : unusable(scope, key, problem);
|
|
448
|
+
}
|
|
449
|
+
function choiceField(scope, key, choices, required) {
|
|
450
|
+
const value = valueOf(scope, key, required);
|
|
451
|
+
if (value === null || isOneOf(choices, value))
|
|
452
|
+
return value;
|
|
453
|
+
return unusable(scope, key, `is ${describeValue(value)}, not one of ${choices.join(", ")}`);
|
|
454
|
+
}
|
|
455
|
+
function booleanField(scope, key, required) {
|
|
456
|
+
const value = valueOf(scope, key, required);
|
|
457
|
+
if (value === null || typeof value === "boolean")
|
|
458
|
+
return value;
|
|
459
|
+
return unusable(scope, key, `is ${describeValue(value)}, not true or false; write it unquoted`);
|
|
460
|
+
}
|
|
461
|
+
function listField(scope, key, readEntry) {
|
|
462
|
+
const value = valueOf(scope, key, OPTIONAL);
|
|
463
|
+
if (value === null)
|
|
464
|
+
return [];
|
|
465
|
+
if (!Array.isArray(value)) {
|
|
466
|
+
unusable(scope, key, `is ${describeValue(value)}, not a list`);
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const entries = [];
|
|
470
|
+
const items = value;
|
|
471
|
+
for (const [index, item] of items.entries()) {
|
|
472
|
+
const entry = readEntry(item, `${scope.prefix}${key}[${index}]`, scope.issues);
|
|
473
|
+
if (entry !== null)
|
|
474
|
+
entries.push(entry);
|
|
475
|
+
}
|
|
476
|
+
return entries;
|
|
477
|
+
}
|
|
478
|
+
function entryScope(item, field, keys, issues) {
|
|
479
|
+
if (isMapping(item))
|
|
480
|
+
return scopeOf(item, keys, `${field}.`, issues);
|
|
481
|
+
const text = `${field} is ${describeValue(item)}, not a mapping of ${keys.join(", ")}; dropped`;
|
|
482
|
+
issues.push(reportIssue("unusable-field", field, text));
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
function readSkill(item, field, issues) {
|
|
486
|
+
const problem = stringProblem(item);
|
|
487
|
+
if (problem === null)
|
|
488
|
+
return item;
|
|
489
|
+
issues.push(reportIssue("unusable-field", field, `${field} ${problem}; dropped`));
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
function readFinding(item, field, issues) {
|
|
493
|
+
const scope = entryScope(item, field, FINDING_KEYS, issues);
|
|
494
|
+
if (scope === null)
|
|
495
|
+
return null;
|
|
496
|
+
return {
|
|
497
|
+
trigger: stringField(scope, "trigger", REQUIRED),
|
|
498
|
+
kind: choiceField(scope, "kind", FINDING_KINDS, REQUIRED),
|
|
499
|
+
what: stringField(scope, "what", REQUIRED),
|
|
500
|
+
cause: stringField(scope, "cause", OPTIONAL),
|
|
501
|
+
resolution: stringField(scope, "resolution", OPTIONAL),
|
|
502
|
+
artifact: stringField(scope, "artifact", OPTIONAL),
|
|
503
|
+
signal: choiceField(scope, "signal", FINDING_SIGNALS, REQUIRED),
|
|
504
|
+
extras: scope.extras
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
function readBlocker(item, field, issues) {
|
|
508
|
+
const scope = entryScope(item, field, BLOCKER_KEYS, issues);
|
|
509
|
+
if (scope === null)
|
|
510
|
+
return null;
|
|
511
|
+
return {
|
|
512
|
+
what: stringField(scope, "what", REQUIRED),
|
|
513
|
+
artifact: stringField(scope, "artifact", OPTIONAL),
|
|
514
|
+
extras: scope.extras
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function readBug(item, field, issues) {
|
|
518
|
+
const scope = entryScope(item, field, BUG_KEYS, issues);
|
|
519
|
+
if (scope === null)
|
|
520
|
+
return null;
|
|
521
|
+
return {
|
|
522
|
+
what: stringField(scope, "what", REQUIRED),
|
|
523
|
+
artifact: stringField(scope, "artifact", OPTIONAL),
|
|
524
|
+
security: booleanField(scope, "security", REQUIRED),
|
|
525
|
+
extras: scope.extras
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function readChange(item, field, issues) {
|
|
529
|
+
const scope = entryScope(item, field, CHANGE_KEYS, issues);
|
|
530
|
+
if (scope === null)
|
|
531
|
+
return null;
|
|
532
|
+
return {
|
|
533
|
+
level: choiceField(scope, "level", CHANGE_LEVELS, REQUIRED),
|
|
534
|
+
area: stringField(scope, "area", OPTIONAL),
|
|
535
|
+
summary: stringField(scope, "summary", REQUIRED),
|
|
536
|
+
extras: scope.extras
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function readReport(document, issues) {
|
|
540
|
+
const scope = scopeOf(document, REPORT_KEYS, "", issues);
|
|
541
|
+
return {
|
|
542
|
+
status: choiceField(scope, "status", REPORT_STATUSES, REQUIRED),
|
|
543
|
+
feedback: stringField(scope, "feedback", OPTIONAL),
|
|
544
|
+
findings: listField(scope, "findings", readFinding),
|
|
545
|
+
skillsUsed: listField(scope, "skills_used", readSkill),
|
|
546
|
+
blockers: listField(scope, "blockers", readBlocker),
|
|
547
|
+
outOfScopeBugs: listField(scope, "out_of_scope_bugs", readBug),
|
|
548
|
+
changes: listField(scope, "changes", readChange),
|
|
549
|
+
extras: scope.extras
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function parseReport(output) {
|
|
553
|
+
const reports = readRafaBlocks(output).filter((block2) => block2.kind === REPORT_KIND);
|
|
554
|
+
const block = reports.at(-1);
|
|
555
|
+
if (block === undefined) {
|
|
556
|
+
return absent("no-block", null, "the session output holds no rafa:report block");
|
|
557
|
+
}
|
|
558
|
+
const line = block.span.first;
|
|
559
|
+
if (!block.closed) {
|
|
560
|
+
return absent("unclosed-block", block, `rafa:report block at line ${line} is never closed, so its body may have been cut short ` + "and is not read");
|
|
561
|
+
}
|
|
562
|
+
let document;
|
|
563
|
+
try {
|
|
564
|
+
document = Bun.YAML.parse(block.body);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const text = `rafa:report block at line ${line} is not valid YAML (${messageOf2(error)})`;
|
|
567
|
+
return absent("malformed-block", block, text);
|
|
568
|
+
}
|
|
569
|
+
if (!isMapping(document)) {
|
|
570
|
+
const text = `rafa:report block at line ${line} holds ${describeValue(document)}, ` + "not a mapping of report fields";
|
|
571
|
+
return absent("malformed-block", block, text);
|
|
572
|
+
}
|
|
573
|
+
const issues = [];
|
|
574
|
+
const report = readReport(document, issues);
|
|
575
|
+
return { present: true, report, block, issues };
|
|
576
|
+
}
|
|
577
|
+
// src/utils/plan-stamp.ts
|
|
578
|
+
var STUB_PATTERN = "[A-Za-z0-9._-]+";
|
|
579
|
+
var STAMP_PATTERN = new RegExp(`<!--\\s*ralph:plan=(${STUB_PATTERN})\\s*-->`);
|
|
580
|
+
function isStampableStub(stub) {
|
|
581
|
+
return new RegExp(`^${STUB_PATTERN}$`).test(stub);
|
|
582
|
+
}
|
|
583
|
+
function planStampLine(stub) {
|
|
584
|
+
if (!isStampableStub(stub)) {
|
|
585
|
+
throw new Error(`rafa plan stamp: unusable stub ${JSON.stringify(stub)}`);
|
|
586
|
+
}
|
|
587
|
+
return `<!-- ralph:plan=${stub} -->`;
|
|
588
|
+
}
|
|
589
|
+
function planStubFromPrompt(content) {
|
|
590
|
+
if (typeof content !== "string" || content.length === 0)
|
|
591
|
+
return null;
|
|
592
|
+
const match = STAMP_PATTERN.exec(content);
|
|
593
|
+
const stub = match?.[1];
|
|
594
|
+
return stub !== undefined && isStampableStub(stub) ? stub : null;
|
|
595
|
+
}
|
|
596
|
+
function stampPrompt(stub, prompt) {
|
|
597
|
+
return `${prompt}
|
|
598
|
+
${planStampLine(stub)}`;
|
|
599
|
+
}
|
|
600
|
+
function planStubFromPath(planPath) {
|
|
601
|
+
const base = planPath.split("/").pop() ?? planPath;
|
|
602
|
+
const match = /^PLAN(?:_TRACKER)?-(.+)\.md$/.exec(base);
|
|
603
|
+
const stub = match?.[1];
|
|
604
|
+
return stub !== undefined && isStampableStub(stub) ? stub : null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/utils/tracker.ts
|
|
608
|
+
import fs from "fs";
|
|
609
|
+
var BLOCKER_OPEN = "<!-- blocked: ";
|
|
610
|
+
var BLOCKER_CLOSE = " -->";
|
|
611
|
+
var BLOCKER_GAP = " ";
|
|
612
|
+
var BLOCKER_COMMENT = /\s<!-- blocked: ((?:(?!<!--|-->).)*) -->$/;
|
|
613
|
+
var OPEN_TASK_PREFIX = /^- \[(?: |BLOCKED)\] /;
|
|
614
|
+
var ESCAPE_SEQUENCE = /\\(u[0-9A-Fa-f]{4}|[\s\S])/g;
|
|
615
|
+
var TAB = 9;
|
|
616
|
+
var FIRST_PRINTABLE = 32;
|
|
617
|
+
var ESCAPED_CODE_UNITS = [127, 8232, 8233];
|
|
618
|
+
function isEscapedCodeUnit(code) {
|
|
619
|
+
if (code < FIRST_PRINTABLE)
|
|
620
|
+
return code !== TAB;
|
|
621
|
+
return ESCAPED_CODE_UNITS.includes(code);
|
|
622
|
+
}
|
|
623
|
+
function escapeCharacter(char) {
|
|
624
|
+
if (char === "\\")
|
|
625
|
+
return "\\\\";
|
|
626
|
+
if (char === `
|
|
627
|
+
`)
|
|
628
|
+
return "\\n";
|
|
629
|
+
if (char === "\r")
|
|
630
|
+
return "\\r";
|
|
631
|
+
const code = char.charCodeAt(0);
|
|
632
|
+
return isEscapedCodeUnit(code) ? `\\u${code.toString(16).padStart(4, "0")}` : char;
|
|
633
|
+
}
|
|
634
|
+
function escapeBlockerText(text) {
|
|
635
|
+
return Array.from(text, escapeCharacter).join("").replace(/(?<=--!?)>/g, "\\>").replace(/(?<=<)!(?=--)/g, "\\!");
|
|
636
|
+
}
|
|
637
|
+
function unescapeBlockerText(escaped) {
|
|
638
|
+
return escaped.replace(ESCAPE_SEQUENCE, (_sequence, code) => {
|
|
639
|
+
if (code === "n")
|
|
640
|
+
return `
|
|
641
|
+
`;
|
|
642
|
+
if (code === "r")
|
|
643
|
+
return "\r";
|
|
644
|
+
return code.length === 5 ? String.fromCharCode(Number.parseInt(code.slice(1), 16)) : code;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
function blockerComment(blocker) {
|
|
648
|
+
return `${BLOCKER_OPEN}${escapeBlockerText(blocker)}${BLOCKER_CLOSE}`;
|
|
649
|
+
}
|
|
650
|
+
function findBlockerComment(text) {
|
|
651
|
+
const trimmed = text.trimEnd();
|
|
652
|
+
const match = BLOCKER_COMMENT.exec(trimmed);
|
|
653
|
+
const escaped = match?.[1];
|
|
654
|
+
if (match === null || escaped === undefined)
|
|
655
|
+
return null;
|
|
656
|
+
const before = trimmed.slice(0, match.index).trimEnd();
|
|
657
|
+
return before.length === 0 ? null : { before, escaped };
|
|
658
|
+
}
|
|
659
|
+
function splitBlockerComment(taskText) {
|
|
660
|
+
const found = findBlockerComment(taskText);
|
|
661
|
+
if (found === null)
|
|
662
|
+
return { text: taskText, blocker: null };
|
|
663
|
+
const blocker = unescapeBlockerText(found.escaped);
|
|
664
|
+
return {
|
|
665
|
+
text: found.before,
|
|
666
|
+
blocker: blocker.trim().length === 0 ? null : blocker
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function splitCarriageReturn(line) {
|
|
670
|
+
return line.endsWith("\r") ? { body: line.slice(0, -1), cr: "\r" } : { body: line, cr: "" };
|
|
671
|
+
}
|
|
672
|
+
function withoutBlockerComment(line) {
|
|
673
|
+
const { body, cr } = splitCarriageReturn(line);
|
|
674
|
+
const prefix = OPEN_TASK_PREFIX.exec(body)?.[0];
|
|
675
|
+
if (prefix === undefined)
|
|
676
|
+
return line;
|
|
677
|
+
const found = findBlockerComment(body.slice(prefix.length));
|
|
678
|
+
return found === null ? line : `${prefix}${found.before}${cr}`;
|
|
679
|
+
}
|
|
680
|
+
function closedBlockLines(trackerContent) {
|
|
681
|
+
const inside = new Set;
|
|
682
|
+
for (const block of readRafaBlocks(trackerContent)) {
|
|
683
|
+
if (!block.closed)
|
|
684
|
+
continue;
|
|
685
|
+
for (let line = block.span.first;line <= block.span.last; line += 1) {
|
|
686
|
+
inside.add(line - 1);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return inside;
|
|
690
|
+
}
|
|
691
|
+
function taskInfoOf(capture, lineNum, status) {
|
|
692
|
+
const { text, blocker } = splitBlockerComment(capture.trim());
|
|
693
|
+
return blocker === null ? { task: text, lineNum, status } : { task: text, lineNum, status, blocker };
|
|
694
|
+
}
|
|
695
|
+
function findNextTask(trackerContent) {
|
|
696
|
+
const lines = trackerContent.split(`
|
|
697
|
+
`);
|
|
698
|
+
const inBlock = closedBlockLines(trackerContent);
|
|
699
|
+
for (let i = 0;i < lines.length; i++) {
|
|
700
|
+
if (inBlock.has(i))
|
|
701
|
+
continue;
|
|
702
|
+
const match = lines[i].match(/^- \[BLOCKED\] (.+)/);
|
|
703
|
+
if (match?.[1])
|
|
704
|
+
return taskInfoOf(match[1], i, "blocked");
|
|
705
|
+
}
|
|
706
|
+
for (let i = 0;i < lines.length; i++) {
|
|
707
|
+
if (inBlock.has(i))
|
|
708
|
+
continue;
|
|
709
|
+
const match = lines[i].match(/^- \[ \] (.+)/);
|
|
710
|
+
if (match?.[1])
|
|
711
|
+
return taskInfoOf(match[1], i, "unchecked");
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
function updateTrackerLine(trackerPath, lineNum, newStatus) {
|
|
716
|
+
const lines = fs.readFileSync(trackerPath, "utf8").split(`
|
|
717
|
+
`);
|
|
718
|
+
const line = lines[lineNum];
|
|
719
|
+
if (!line)
|
|
720
|
+
return;
|
|
721
|
+
if (newStatus === "done") {
|
|
722
|
+
lines[lineNum] = withoutBlockerComment(line).replace(/^- \[ \]/, "- [x]").replace(/^- \[BLOCKED\]/, "- [x]");
|
|
723
|
+
} else {
|
|
724
|
+
lines[lineNum] = line.replace(/^- \[ \]/, "- [BLOCKED]");
|
|
725
|
+
}
|
|
726
|
+
fs.writeFileSync(trackerPath, lines.join(`
|
|
727
|
+
`), "utf8");
|
|
728
|
+
}
|
|
729
|
+
function writeTrackerBlocker(trackerPath, lineNum, blocker) {
|
|
730
|
+
const lines = fs.readFileSync(trackerPath, "utf8").split(`
|
|
731
|
+
`);
|
|
732
|
+
const line = lines[lineNum];
|
|
733
|
+
if (line === undefined)
|
|
734
|
+
return false;
|
|
735
|
+
const { body, cr } = splitCarriageReturn(line);
|
|
736
|
+
const prefix = OPEN_TASK_PREFIX.exec(body)?.[0];
|
|
737
|
+
if (prefix === undefined)
|
|
738
|
+
return false;
|
|
739
|
+
const rest = body.slice(prefix.length);
|
|
740
|
+
const text = (findBlockerComment(rest)?.before ?? rest).trimEnd();
|
|
741
|
+
if (text.trim().length === 0)
|
|
742
|
+
return false;
|
|
743
|
+
const comment = blocker.trim().length === 0 ? "" : `${BLOCKER_GAP}${blockerComment(blocker)}`;
|
|
744
|
+
lines[lineNum] = `- [BLOCKED] ${text}${comment}${cr}`;
|
|
745
|
+
fs.writeFileSync(trackerPath, lines.join(`
|
|
746
|
+
`), "utf8");
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
function trackerPathFor(planPath) {
|
|
750
|
+
return planPath.replace(/PLAN(-[^/]*)?\.md$/, (_m, stub) => `PLAN_TRACKER${stub ?? ""}.md`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/plan/parse.ts
|
|
754
|
+
var PLAN_BLOCK_KINDS = ["plan", "context", "stage-context"];
|
|
755
|
+
var PLAN_HEADER_FIELDS = ["stub", "issue", "spec", "release"];
|
|
756
|
+
var PLAN_RELEASE_LEVELS = ["patch", "minor", "major", "none"];
|
|
757
|
+
var TASK_LINES = [
|
|
758
|
+
["unchecked", /^- \[ \] (.+)/],
|
|
759
|
+
["blocked", /^- \[BLOCKED\] (.+)/],
|
|
760
|
+
["done", /^- \[x\] (.+)/]
|
|
761
|
+
];
|
|
762
|
+
var STAGE_HEADING = /^# Stage: (.+)/;
|
|
763
|
+
function isPlanBlockKind(kind) {
|
|
764
|
+
return PLAN_BLOCK_KINDS.includes(kind);
|
|
765
|
+
}
|
|
766
|
+
function isHeaderField(key) {
|
|
767
|
+
return PLAN_HEADER_FIELDS.includes(key);
|
|
768
|
+
}
|
|
769
|
+
function isReleaseLevel(value) {
|
|
770
|
+
return PLAN_RELEASE_LEVELS.includes(value);
|
|
771
|
+
}
|
|
772
|
+
function isMapping2(value) {
|
|
773
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
774
|
+
}
|
|
775
|
+
function describeValue2(value) {
|
|
776
|
+
if (typeof value === "string")
|
|
777
|
+
return JSON.stringify(value);
|
|
778
|
+
if (value === null)
|
|
779
|
+
return "null";
|
|
780
|
+
if (Array.isArray(value))
|
|
781
|
+
return "a list";
|
|
782
|
+
if (typeof value === "object")
|
|
783
|
+
return "a mapping";
|
|
784
|
+
return String(value);
|
|
785
|
+
}
|
|
786
|
+
function messageOf3(error) {
|
|
787
|
+
return error instanceof Error ? error.message : String(error);
|
|
788
|
+
}
|
|
789
|
+
function planIssue(reason, line, text) {
|
|
790
|
+
return { reason, line, text };
|
|
791
|
+
}
|
|
792
|
+
function emptyHeader() {
|
|
793
|
+
return { stub: null, issue: null, spec: null, release: null, extras: [] };
|
|
794
|
+
}
|
|
795
|
+
function readTaskLine(line) {
|
|
796
|
+
for (const [status, pattern] of TASK_LINES) {
|
|
797
|
+
const capture = pattern.exec(line)?.[1];
|
|
798
|
+
if (capture !== undefined)
|
|
799
|
+
return { status, task: splitBlockerComment(capture.trim()).text };
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
function readStageHeading(line) {
|
|
804
|
+
const capture = STAGE_HEADING.exec(line)?.[1];
|
|
805
|
+
return capture === undefined ? null : capture.trim();
|
|
806
|
+
}
|
|
807
|
+
function blockAt(blocks, lineNum) {
|
|
808
|
+
const line = lineNum + 1;
|
|
809
|
+
return blocks.find((block) => block.span.first <= line && line <= block.span.last);
|
|
810
|
+
}
|
|
811
|
+
function taskInBlock(lineNum, status, block) {
|
|
812
|
+
const { first, last } = block.span;
|
|
813
|
+
const dispatcher = block.closed ? "findNextTask skips it" : "findNextTask still dispatches it, the block never being closed";
|
|
814
|
+
return planIssue("task-in-block", lineNum + 1, `${status} task line inside the rafa:${block.kind} block at lines ${first}-${last}: ` + `${dispatcher}, and the model reads it as block body`);
|
|
815
|
+
}
|
|
816
|
+
function planTask(line, lineNum, headings) {
|
|
817
|
+
const { text, declaration } = parseTaskDeclaration(line.task);
|
|
818
|
+
const stage = headings.length > 0 ? headings.length - 1 : null;
|
|
819
|
+
return { task: line.task, lineNum, status: line.status, text, declaration, stage };
|
|
820
|
+
}
|
|
821
|
+
function readChecklist(markdown, blocks) {
|
|
822
|
+
const headings = [];
|
|
823
|
+
const tasks = [];
|
|
824
|
+
const hidden = [];
|
|
825
|
+
const issues = [];
|
|
826
|
+
for (const [lineNum, line] of markdown.split(`
|
|
827
|
+
`).entries()) {
|
|
828
|
+
const taskLine = readTaskLine(line);
|
|
829
|
+
const block = blockAt(blocks, lineNum);
|
|
830
|
+
if (block !== undefined) {
|
|
831
|
+
if (taskLine !== null && taskLine.status !== "done") {
|
|
832
|
+
issues.push(taskInBlock(lineNum, taskLine.status, block));
|
|
833
|
+
if (!block.closed)
|
|
834
|
+
hidden.push(planTask(taskLine, lineNum, headings));
|
|
835
|
+
}
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
const name = readStageHeading(line);
|
|
839
|
+
if (name !== null) {
|
|
840
|
+
headings.push({ name, lineNum });
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (taskLine === null)
|
|
844
|
+
continue;
|
|
845
|
+
tasks.push(planTask(taskLine, lineNum, headings));
|
|
846
|
+
}
|
|
847
|
+
return { headings, tasks, hidden, issues };
|
|
848
|
+
}
|
|
849
|
+
function headingAbove(headings, lineNum) {
|
|
850
|
+
let above = null;
|
|
851
|
+
for (const [index, heading] of headings.entries()) {
|
|
852
|
+
if (heading.lineNum >= lineNum)
|
|
853
|
+
break;
|
|
854
|
+
above = index;
|
|
855
|
+
}
|
|
856
|
+
return above;
|
|
857
|
+
}
|
|
858
|
+
function stageSlot(index) {
|
|
859
|
+
return `stage-context@${index}`;
|
|
860
|
+
}
|
|
861
|
+
function slotOf(block, headings) {
|
|
862
|
+
if (block.kind !== "stage-context")
|
|
863
|
+
return { key: block.kind, scope: "" };
|
|
864
|
+
const index = headingAbove(headings, block.span.first - 1);
|
|
865
|
+
if (index === null)
|
|
866
|
+
return null;
|
|
867
|
+
const name = headings[index]?.name ?? "";
|
|
868
|
+
return { key: stageSlot(index), scope: ` under # Stage: ${name}` };
|
|
869
|
+
}
|
|
870
|
+
function bindBlocks(blocks, headings) {
|
|
871
|
+
const read = new Map;
|
|
872
|
+
const issues = [];
|
|
873
|
+
for (const block of blocks) {
|
|
874
|
+
const line = block.span.first;
|
|
875
|
+
if (!block.closed) {
|
|
876
|
+
issues.push(planIssue("unclosed-block", line, `rafa:${block.kind} block is never closed, so it runs to the end of the document ` + "and nothing after its fence is read"));
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (!isPlanBlockKind(block.kind))
|
|
880
|
+
continue;
|
|
881
|
+
const slot = slotOf(block, headings);
|
|
882
|
+
if (slot === null) {
|
|
883
|
+
issues.push(planIssue("orphan-stage-context", line, "rafa:stage-context block above every # Stage: heading belongs to no stage and is not read"));
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const earlier = read.get(slot.key);
|
|
887
|
+
if (earlier === undefined) {
|
|
888
|
+
read.set(slot.key, block);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
issues.push(planIssue("duplicate-block", line, `rafa:${block.kind} block${slot.scope} repeats the one at line ${earlier.span.first}, ` + "which is the one read"));
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
header: read.get("plan") ?? null,
|
|
895
|
+
context: read.get("context") ?? null,
|
|
896
|
+
stageContexts: headings.map((_, index) => read.get(stageSlot(index))?.body ?? null),
|
|
897
|
+
issues
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function usableField(field, value) {
|
|
901
|
+
if (field === "issue" && typeof value === "number") {
|
|
902
|
+
return Number.isSafeInteger(value) ? String(value) : null;
|
|
903
|
+
}
|
|
904
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
905
|
+
return null;
|
|
906
|
+
if (field === "stub" && !isStampableStub(value))
|
|
907
|
+
return null;
|
|
908
|
+
if (field === "release" && !isReleaseLevel(value))
|
|
909
|
+
return null;
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
function unusableText(field, value) {
|
|
913
|
+
const subject = `rafa:plan ${field}`;
|
|
914
|
+
if (value === null) {
|
|
915
|
+
return `${subject} is empty; a value opening with # after a space is a YAML comment unless quoted`;
|
|
916
|
+
}
|
|
917
|
+
if (field === "release") {
|
|
918
|
+
return `${subject} is ${describeValue2(value)}, not one of ${PLAN_RELEASE_LEVELS.join(", ")}`;
|
|
919
|
+
}
|
|
920
|
+
if (typeof value !== "string") {
|
|
921
|
+
return field === "issue" ? `${subject} is ${describeValue2(value)}, not a string or a whole number; quote it` : `${subject} is ${describeValue2(value)}, not a string; quote it`;
|
|
922
|
+
}
|
|
923
|
+
if (value.trim().length === 0)
|
|
924
|
+
return `${subject} is blank`;
|
|
925
|
+
return `${subject} ${JSON.stringify(value)} is no stub a plan stamp can carry ` + '(letters, digits, ".", "_" and "-")';
|
|
926
|
+
}
|
|
927
|
+
function releaseOf(values) {
|
|
928
|
+
const value = values.get("release");
|
|
929
|
+
if (value === undefined || !isReleaseLevel(value))
|
|
930
|
+
return null;
|
|
931
|
+
return value;
|
|
932
|
+
}
|
|
933
|
+
function readFields(document, line) {
|
|
934
|
+
const values = new Map;
|
|
935
|
+
const extras = [];
|
|
936
|
+
const issues = [];
|
|
937
|
+
for (const [key, value] of Object.entries(document)) {
|
|
938
|
+
if (!isHeaderField(key)) {
|
|
939
|
+
extras.push({ key, value });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const usable = usableField(key, value);
|
|
943
|
+
if (usable === null)
|
|
944
|
+
issues.push(planIssue("unusable-field", line, unusableText(key, value)));
|
|
945
|
+
else
|
|
946
|
+
values.set(key, usable);
|
|
947
|
+
}
|
|
948
|
+
const header = {
|
|
949
|
+
stub: values.get("stub") ?? null,
|
|
950
|
+
issue: values.get("issue") ?? null,
|
|
951
|
+
spec: values.get("spec") ?? null,
|
|
952
|
+
release: releaseOf(values),
|
|
953
|
+
extras
|
|
954
|
+
};
|
|
955
|
+
return { header, issues };
|
|
956
|
+
}
|
|
957
|
+
function readHeader(block) {
|
|
958
|
+
if (block === null)
|
|
959
|
+
return { header: emptyHeader(), issues: [] };
|
|
960
|
+
const line = block.span.first;
|
|
961
|
+
let document;
|
|
962
|
+
try {
|
|
963
|
+
document = Bun.YAML.parse(block.body);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
const text = `rafa:plan block is not valid YAML (${messageOf3(error)})`;
|
|
966
|
+
return { header: emptyHeader(), issues: [planIssue("malformed-header", line, text)] };
|
|
967
|
+
}
|
|
968
|
+
if (document === null)
|
|
969
|
+
return { header: emptyHeader(), issues: [] };
|
|
970
|
+
if (!isMapping2(document)) {
|
|
971
|
+
const text = `rafa:plan block holds ${describeValue2(document)}, not a mapping of fields`;
|
|
972
|
+
return { header: emptyHeader(), issues: [planIssue("malformed-header", line, text)] };
|
|
973
|
+
}
|
|
974
|
+
return readFields(document, line);
|
|
975
|
+
}
|
|
976
|
+
function parsePlan(markdown) {
|
|
977
|
+
const blocks = readRafaBlocks(markdown);
|
|
978
|
+
const checklist = readChecklist(markdown, blocks);
|
|
979
|
+
const bound = bindBlocks(blocks, checklist.headings);
|
|
980
|
+
const reading = readHeader(bound.header);
|
|
981
|
+
const stages = checklist.headings.map((heading, index) => ({
|
|
982
|
+
name: heading.name,
|
|
983
|
+
lineNum: heading.lineNum,
|
|
984
|
+
context: bound.stageContexts[index] ?? null
|
|
985
|
+
}));
|
|
986
|
+
const issues = [...checklist.issues, ...bound.issues, ...reading.issues].sort((a, b) => a.line - b.line);
|
|
987
|
+
return {
|
|
988
|
+
header: reading.header,
|
|
989
|
+
context: bound.context?.body ?? null,
|
|
990
|
+
stages,
|
|
991
|
+
tasks: checklist.tasks,
|
|
992
|
+
hiddenTasks: checklist.hidden,
|
|
993
|
+
blocks,
|
|
994
|
+
issues
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// src/plan/inject.ts
|
|
999
|
+
var PLAN_CONTEXT_HEADING = "## Plan context";
|
|
1000
|
+
var STAGE_HEADING_PREFIX = "## Stage: ";
|
|
1001
|
+
var CHECKLIST_HEADING = "## Checklist";
|
|
1002
|
+
var STAGE_LIST_HEADING = "## Stages";
|
|
1003
|
+
var CURRENT_STAGE_MARK = " (current stage)";
|
|
1004
|
+
var CHECKBOXES = {
|
|
1005
|
+
unchecked: "- [ ]",
|
|
1006
|
+
blocked: "- [BLOCKED]",
|
|
1007
|
+
done: "- [x]"
|
|
1008
|
+
};
|
|
1009
|
+
function fallbackFor(reason, lineNum) {
|
|
1010
|
+
const line = lineNum + 1;
|
|
1011
|
+
const found = reason === "no-task-at-line" ? "no task the plan model reads" : "a different task than the one dispatched";
|
|
1012
|
+
const text = `line ${line} of the plan holds ${found}, so the whole plan is handed over; ` + "the plan may have changed since its tracker was copied, or the line sits inside a rafa:* block";
|
|
1013
|
+
return { found: false, fallback: { reason, line, text } };
|
|
1014
|
+
}
|
|
1015
|
+
function locate(model, wanted) {
|
|
1016
|
+
const task = model.tasks.find((candidate) => candidate.lineNum === wanted.lineNum);
|
|
1017
|
+
if (task === undefined)
|
|
1018
|
+
return fallbackFor("no-task-at-line", wanted.lineNum);
|
|
1019
|
+
if (task.task !== wanted.task)
|
|
1020
|
+
return fallbackFor("task-text-differs", wanted.lineNum);
|
|
1021
|
+
return { found: true, task };
|
|
1022
|
+
}
|
|
1023
|
+
function contextBody(body) {
|
|
1024
|
+
if (body === null || body.trim().length === 0)
|
|
1025
|
+
return null;
|
|
1026
|
+
return body.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, "");
|
|
1027
|
+
}
|
|
1028
|
+
function section(heading, ...parts) {
|
|
1029
|
+
return [heading, ...parts].filter((part) => part !== null).join(`
|
|
1030
|
+
|
|
1031
|
+
`);
|
|
1032
|
+
}
|
|
1033
|
+
function renderDocument(sections) {
|
|
1034
|
+
const present = sections.filter((part) => part !== null);
|
|
1035
|
+
return `${present.join(`
|
|
1036
|
+
|
|
1037
|
+
`)}
|
|
1038
|
+
`;
|
|
1039
|
+
}
|
|
1040
|
+
function shownStatus(task, dispatched) {
|
|
1041
|
+
if (task.lineNum < dispatched.lineNum)
|
|
1042
|
+
return "done";
|
|
1043
|
+
if (task.lineNum === dispatched.lineNum)
|
|
1044
|
+
return "unchecked";
|
|
1045
|
+
return task.status;
|
|
1046
|
+
}
|
|
1047
|
+
function taskLine(status, task) {
|
|
1048
|
+
return `${CHECKBOXES[status]} ${task.text}`;
|
|
1049
|
+
}
|
|
1050
|
+
function planContextSection(model) {
|
|
1051
|
+
const body = contextBody(model.context);
|
|
1052
|
+
return body === null ? null : section(PLAN_CONTEXT_HEADING, body);
|
|
1053
|
+
}
|
|
1054
|
+
function stageSection(model, dispatched, lines) {
|
|
1055
|
+
const owner = dispatched.stage === null ? undefined : model.stages[dispatched.stage];
|
|
1056
|
+
if (owner === undefined)
|
|
1057
|
+
return section(CHECKLIST_HEADING, lines.join(`
|
|
1058
|
+
`));
|
|
1059
|
+
return section(`${STAGE_HEADING_PREFIX}${owner.name}`, contextBody(owner.context), lines.join(`
|
|
1060
|
+
`));
|
|
1061
|
+
}
|
|
1062
|
+
function stageListSection(model, dispatched) {
|
|
1063
|
+
if (model.stages.length === 0)
|
|
1064
|
+
return null;
|
|
1065
|
+
const items = model.stages.map((stage, index) => {
|
|
1066
|
+
const mark = index === dispatched.stage ? CURRENT_STAGE_MARK : "";
|
|
1067
|
+
return `${index + 1}. ${stage.name}${mark}`;
|
|
1068
|
+
});
|
|
1069
|
+
return section(STAGE_LIST_HEADING, items.join(`
|
|
1070
|
+
`));
|
|
1071
|
+
}
|
|
1072
|
+
function renderStage(model, dispatched) {
|
|
1073
|
+
const checklist = model.tasks.filter((task) => task.stage === dispatched.stage).map((task) => taskLine(shownStatus(task, dispatched), task));
|
|
1074
|
+
return renderDocument([
|
|
1075
|
+
planContextSection(model),
|
|
1076
|
+
stageSection(model, dispatched, checklist),
|
|
1077
|
+
stageListSection(model, dispatched)
|
|
1078
|
+
]);
|
|
1079
|
+
}
|
|
1080
|
+
function renderTask(model, dispatched) {
|
|
1081
|
+
return renderDocument([
|
|
1082
|
+
planContextSection(model),
|
|
1083
|
+
stageSection(model, dispatched, [taskLine("unchecked", dispatched)])
|
|
1084
|
+
]);
|
|
1085
|
+
}
|
|
1086
|
+
var RENDERERS = {
|
|
1087
|
+
stage: renderStage,
|
|
1088
|
+
task: renderTask
|
|
1089
|
+
};
|
|
1090
|
+
function renderInjection(request) {
|
|
1091
|
+
const { mode, plan, task } = request;
|
|
1092
|
+
if (mode === "full")
|
|
1093
|
+
return { requested: mode, mode, text: plan, fallback: null };
|
|
1094
|
+
const model = parsePlan(plan);
|
|
1095
|
+
const location = locate(model, task);
|
|
1096
|
+
if (!location.found) {
|
|
1097
|
+
return { requested: mode, mode: "full", text: plan, fallback: location.fallback };
|
|
1098
|
+
}
|
|
1099
|
+
return { requested: mode, mode, text: RENDERERS[mode](model, location.task), fallback: null };
|
|
1100
|
+
}
|
|
1101
|
+
export { isStampableStub, planStubFromPrompt, stampPrompt, planStubFromPath, REPORT_STATUSES, FINDING_KINDS, FINDING_SIGNALS, CHANGE_LEVELS, parseReport, escapeBlockerText, findNextTask, updateTrackerLine, writeTrackerBlocker, trackerPathFor, PLAN_BLOCK_KINDS, PLAN_HEADER_FIELDS, parsePlan, parsePrerequisites, planPrerequisites, mergePlanPrerequisites, prerequisitesPathForPlan, loadPlanPrerequisites, PROBE_TIMEOUT_MS, runShellProbe, runPreflight, renderInjection };
|