@ebullient/qrspi-x 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/qrspi-x.mjs +3532 -0
- package/package.json +54 -0
package/dist/qrspi-x.mjs
ADDED
|
@@ -0,0 +1,3532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/command/Result.ts
|
|
4
|
+
function toJson(result) {
|
|
5
|
+
const { exitCode, findings, wrote, text, ...fields } = result;
|
|
6
|
+
const json = { ...fields };
|
|
7
|
+
if (findings && findings.length > 0) {
|
|
8
|
+
json.findings = findings;
|
|
9
|
+
}
|
|
10
|
+
if (wrote && wrote.length > 0) {
|
|
11
|
+
json.wrote = wrote;
|
|
12
|
+
}
|
|
13
|
+
return json;
|
|
14
|
+
}
|
|
15
|
+
function ok(fields) {
|
|
16
|
+
return { exitCode: 0, ...fields };
|
|
17
|
+
}
|
|
18
|
+
function warn(fields, findings, wrote) {
|
|
19
|
+
return { exitCode: 3, ...fields, findings, wrote };
|
|
20
|
+
}
|
|
21
|
+
function blocked(findings, wrote) {
|
|
22
|
+
return { exitCode: 1, findings, wrote };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/command/Root.ts
|
|
26
|
+
import { join as join5 } from "node:path";
|
|
27
|
+
|
|
28
|
+
// src/workspace/Decisions.ts
|
|
29
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
function decisionsPath(root) {
|
|
32
|
+
return join(root, "decisions.md");
|
|
33
|
+
}
|
|
34
|
+
function decisionsAt(root) {
|
|
35
|
+
async function add3(text) {
|
|
36
|
+
let existing = "";
|
|
37
|
+
try {
|
|
38
|
+
existing = await readFile(decisionsPath(root), "utf8");
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.code !== "ENOENT") {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await mkdir(root, { recursive: true });
|
|
45
|
+
await writeFile(decisionsPath(root), `${existing}- ${text}
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
async function read3() {
|
|
49
|
+
try {
|
|
50
|
+
return await readFile(decisionsPath(root), "utf8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.code === "ENOENT") {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { add: add3, read: read3 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/workspace/Git.ts
|
|
62
|
+
import { execFile } from "node:child_process";
|
|
63
|
+
import { promisify } from "node:util";
|
|
64
|
+
var run = promisify(execFile);
|
|
65
|
+
function gitAt(cwd) {
|
|
66
|
+
async function isClean() {
|
|
67
|
+
const { stdout } = await run("git", ["status", "--porcelain"], {
|
|
68
|
+
cwd
|
|
69
|
+
});
|
|
70
|
+
return stdout.trim().length === 0;
|
|
71
|
+
}
|
|
72
|
+
async function headSha() {
|
|
73
|
+
const { stdout } = await run("git", ["rev-parse", "HEAD"], { cwd });
|
|
74
|
+
return stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
async function isAncestor(commit) {
|
|
77
|
+
try {
|
|
78
|
+
await run("git", ["merge-base", "--is-ancestor", commit, "HEAD"], {
|
|
79
|
+
cwd
|
|
80
|
+
});
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function originRepo() {
|
|
87
|
+
try {
|
|
88
|
+
const { stdout } = await run(
|
|
89
|
+
"git",
|
|
90
|
+
["remote", "get-url", "origin"],
|
|
91
|
+
{
|
|
92
|
+
cwd
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
return parseGitHubRepo(stdout);
|
|
96
|
+
} catch {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { isClean, headSha, isAncestor, originRepo };
|
|
101
|
+
}
|
|
102
|
+
function parseGitHubRepo(input) {
|
|
103
|
+
const trimmed = input.trim();
|
|
104
|
+
if (!trimmed) {
|
|
105
|
+
throw new Error("Repository cannot be empty.");
|
|
106
|
+
}
|
|
107
|
+
const sshMatch = trimmed.match(
|
|
108
|
+
/^(?:ssh:\/\/)?git@([^:/]+)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
109
|
+
);
|
|
110
|
+
if (sshMatch) {
|
|
111
|
+
const [, host, owner, repo] = sshMatch;
|
|
112
|
+
if (host !== "github.com") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Unsupported host "${host}". Only github.com is supported.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return { owner, repo };
|
|
118
|
+
}
|
|
119
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(trimmed);
|
|
122
|
+
if (url.hostname !== "github.com") {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Unsupported host "${url.hostname}". Only github.com is supported.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const match = url.pathname.match(
|
|
128
|
+
/^\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/
|
|
129
|
+
);
|
|
130
|
+
if (match) {
|
|
131
|
+
return { owner: match[1], repo: match[2] };
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e instanceof Error && e.message.startsWith("Unsupported host")) {
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Invalid repository URL "${input}". Expected a github.com repository URL.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const shorthandMatch = trimmed.match(
|
|
143
|
+
/^([^/#\s:]+)\/([^/#\s:]+?)(?:\.git)?$/
|
|
144
|
+
);
|
|
145
|
+
if (shorthandMatch) {
|
|
146
|
+
return { owner: shorthandMatch[1], repo: shorthandMatch[2] };
|
|
147
|
+
}
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Invalid repository "${input}". Expected owner/repo shorthand or a github.com repository URL.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/workspace/History.ts
|
|
154
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile2 } from "node:fs/promises";
|
|
155
|
+
import { join as join2 } from "node:path";
|
|
156
|
+
var DEFAULT_TAIL = 20;
|
|
157
|
+
function historyPath(root) {
|
|
158
|
+
return join2(root, "history.jsonl");
|
|
159
|
+
}
|
|
160
|
+
function historyAt(root) {
|
|
161
|
+
async function readAll() {
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
text = await readFile2(historyPath(root), "utf8");
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err.code === "ENOENT") {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
return text.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
|
|
172
|
+
}
|
|
173
|
+
async function append(entry) {
|
|
174
|
+
await appendLine(entry);
|
|
175
|
+
}
|
|
176
|
+
async function appendLine(entry) {
|
|
177
|
+
const line = `${JSON.stringify({ ...entry, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
178
|
+
`;
|
|
179
|
+
await mkdir2(root, { recursive: true });
|
|
180
|
+
await appendFile(historyPath(root), line);
|
|
181
|
+
}
|
|
182
|
+
async function conditionalAppend(filter, unless, entry) {
|
|
183
|
+
const entries = await readAll();
|
|
184
|
+
const matches = entries.filter(
|
|
185
|
+
(candidate) => Object.entries(filter).every(
|
|
186
|
+
([key, value]) => candidate[key] === value
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
const last = matches[matches.length - 1];
|
|
190
|
+
if (last && Object.entries(unless).every(([key, value]) => last[key] === value)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
await appendLine(entry);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
async function read3(filters = {}) {
|
|
197
|
+
const { tail, all, ...fieldFilters } = filters;
|
|
198
|
+
let entries = await readAll();
|
|
199
|
+
for (const [key, value] of Object.entries(fieldFilters)) {
|
|
200
|
+
if (value === void 0) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
entries = entries.filter((entry) => entry[key] === value);
|
|
204
|
+
}
|
|
205
|
+
if (all) {
|
|
206
|
+
return entries;
|
|
207
|
+
}
|
|
208
|
+
const limit = tail ?? DEFAULT_TAIL;
|
|
209
|
+
return entries.slice(Math.max(entries.length - limit, 0));
|
|
210
|
+
}
|
|
211
|
+
async function isParked() {
|
|
212
|
+
const entries = await readAll();
|
|
213
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
214
|
+
const kind = entries[i].kind;
|
|
215
|
+
if (kind === "note") {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
return kind === "park";
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return { append, conditionalAppend, read: read3, isParked };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/workspace/LoopState.ts
|
|
226
|
+
import { mkdir as mkdir3, readFile as readFile4, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
227
|
+
import { join as join4 } from "node:path";
|
|
228
|
+
|
|
229
|
+
// src/workspace/Workspace.ts
|
|
230
|
+
import { access, readdir, readFile as readFile3, stat } from "node:fs/promises";
|
|
231
|
+
import { join as join3 } from "node:path";
|
|
232
|
+
|
|
233
|
+
// src/workspace/ApproachDecision.ts
|
|
234
|
+
function isDecided(text) {
|
|
235
|
+
const match = /^## Decision\s*\n([\s\S]*?)(?=\n## |\s*$)/m.exec(text);
|
|
236
|
+
if (!match) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const body = match[1].trim();
|
|
240
|
+
return body !== "" && body !== "None.";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/workspace/PhaseFile.ts
|
|
244
|
+
var STEP_MARKER_PATTERN = /^### Step \d+.*\r?\n(?:[ \t]*\r?\n)*[ \t]*- (\[[ ~x!]\])/gm;
|
|
245
|
+
function parseSteps(text) {
|
|
246
|
+
const markers = [];
|
|
247
|
+
for (const match of text.matchAll(STEP_MARKER_PATTERN)) {
|
|
248
|
+
markers.push(match[1]);
|
|
249
|
+
}
|
|
250
|
+
return markers;
|
|
251
|
+
}
|
|
252
|
+
function isPhaseComplete(text) {
|
|
253
|
+
const markers = parseSteps(text);
|
|
254
|
+
return markers.length > 0 && markers.every((marker) => marker === "[x]");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/workspace/PlanTable.ts
|
|
258
|
+
var STATUS_MARKERS = /* @__PURE__ */ new Set(["[ ]", "[~]", "[x]", "[!]"]);
|
|
259
|
+
function parsePlanTable(text) {
|
|
260
|
+
const rows = [];
|
|
261
|
+
for (const line of text.split("\n")) {
|
|
262
|
+
if (!line.trim().startsWith("|")) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
266
|
+
if (cells.length < 6) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const [phase, name, dependsOn, , , status] = cells;
|
|
270
|
+
if (phase === "Phase" || /^-+$/.test(phase)) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!STATUS_MARKERS.has(status)) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
rows.push({
|
|
277
|
+
phase,
|
|
278
|
+
name,
|
|
279
|
+
dependsOn: parseDependsOn(dependsOn),
|
|
280
|
+
status
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return rows;
|
|
284
|
+
}
|
|
285
|
+
var DependencyCycleError = class extends Error {
|
|
286
|
+
constructor(cycle) {
|
|
287
|
+
super(`Dependency cycle in plan.md: ${cycle.join(" -> ")}`);
|
|
288
|
+
this.cycle = cycle;
|
|
289
|
+
this.name = "DependencyCycleError";
|
|
290
|
+
}
|
|
291
|
+
cycle;
|
|
292
|
+
};
|
|
293
|
+
var UnknownPhaseError = class extends Error {
|
|
294
|
+
constructor(phaseId) {
|
|
295
|
+
super(`Unknown phase id: ${phaseId}`);
|
|
296
|
+
this.phaseId = phaseId;
|
|
297
|
+
this.name = "UnknownPhaseError";
|
|
298
|
+
}
|
|
299
|
+
phaseId;
|
|
300
|
+
};
|
|
301
|
+
function phaseGraphAt(rows) {
|
|
302
|
+
const byId = new Map(rows.map((row) => [row.phase, row]));
|
|
303
|
+
function isDone(phaseId, seen) {
|
|
304
|
+
if (seen.has(phaseId)) {
|
|
305
|
+
throw new DependencyCycleError([...seen, phaseId]);
|
|
306
|
+
}
|
|
307
|
+
const row = byId.get(phaseId);
|
|
308
|
+
if (!row) {
|
|
309
|
+
throw new UnknownPhaseError(phaseId);
|
|
310
|
+
}
|
|
311
|
+
if (incomplete(row)) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const nextSeen = new Set(seen).add(phaseId);
|
|
315
|
+
return row.dependsOn.every((id) => isDone(id, nextSeen));
|
|
316
|
+
}
|
|
317
|
+
function isSatisfied(phaseId) {
|
|
318
|
+
const row = byId.get(phaseId);
|
|
319
|
+
if (!row) {
|
|
320
|
+
throw new UnknownPhaseError(phaseId);
|
|
321
|
+
}
|
|
322
|
+
return row.dependsOn.every((id) => isDone(id, /* @__PURE__ */ new Set([phaseId])));
|
|
323
|
+
}
|
|
324
|
+
function visitDependencies(phaseId, seen, out, visited) {
|
|
325
|
+
if (seen.has(phaseId)) {
|
|
326
|
+
throw new DependencyCycleError([...seen, phaseId]);
|
|
327
|
+
}
|
|
328
|
+
if (visited.has(phaseId)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const row = byId.get(phaseId);
|
|
332
|
+
if (!row) {
|
|
333
|
+
throw new UnknownPhaseError(phaseId);
|
|
334
|
+
}
|
|
335
|
+
const nextSeen = new Set(seen).add(phaseId);
|
|
336
|
+
for (const depId of row.dependsOn) {
|
|
337
|
+
visitDependencies(depId, nextSeen, out, visited);
|
|
338
|
+
}
|
|
339
|
+
visited.add(phaseId);
|
|
340
|
+
if (incomplete(row)) {
|
|
341
|
+
out.push(phaseId);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function resolveScope(selector) {
|
|
345
|
+
const scope = parseSelector(selector, rows);
|
|
346
|
+
const out = [];
|
|
347
|
+
const visited = /* @__PURE__ */ new Set();
|
|
348
|
+
for (const phaseId of scope) {
|
|
349
|
+
visitDependencies(phaseId, /* @__PURE__ */ new Set(), out, visited);
|
|
350
|
+
}
|
|
351
|
+
return { scope, phaseIds: out };
|
|
352
|
+
}
|
|
353
|
+
return { rows, isSatisfied: (id) => isSatisfied(id), resolveScope };
|
|
354
|
+
}
|
|
355
|
+
function parseSelector(selector, rows) {
|
|
356
|
+
if (selector === "all") {
|
|
357
|
+
return rows.map((row) => row.phase);
|
|
358
|
+
}
|
|
359
|
+
if (selector.includes("..")) {
|
|
360
|
+
const [from, to] = selector.split("..");
|
|
361
|
+
const fromIndex = rows.findIndex((row) => row.phase === from);
|
|
362
|
+
const toIndex = rows.findIndex((row) => row.phase === to);
|
|
363
|
+
if (fromIndex === -1) {
|
|
364
|
+
throw new UnknownPhaseError(from);
|
|
365
|
+
}
|
|
366
|
+
if (toIndex === -1) {
|
|
367
|
+
throw new UnknownPhaseError(to);
|
|
368
|
+
}
|
|
369
|
+
if (toIndex < fromIndex) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Invalid range "${selector}": "${to}" comes before "${from}" in plan.md.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return rows.slice(fromIndex, toIndex + 1).map((row) => row.phase);
|
|
375
|
+
}
|
|
376
|
+
if (selector.includes(",")) {
|
|
377
|
+
return selector.split(",").map((id) => id.trim());
|
|
378
|
+
}
|
|
379
|
+
return [selector];
|
|
380
|
+
}
|
|
381
|
+
function parseDependsOn(cell) {
|
|
382
|
+
if (cell === "" || cell.toLowerCase() === "none") {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
return cell.split(",").map((id) => id.trim()).filter(Boolean);
|
|
386
|
+
}
|
|
387
|
+
function completed(row) {
|
|
388
|
+
return row.status === "[x]";
|
|
389
|
+
}
|
|
390
|
+
function incomplete(row) {
|
|
391
|
+
return !completed(row);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/workspace/ReviewVerdict.ts
|
|
395
|
+
function parseVerdict(text) {
|
|
396
|
+
const match = /^## Verdict:\s*(PASS WITH CONDITIONS|PASS|FAIL)\s*$/m.exec(
|
|
397
|
+
text
|
|
398
|
+
);
|
|
399
|
+
return match?.[1];
|
|
400
|
+
}
|
|
401
|
+
function isPending(text) {
|
|
402
|
+
return /^## Verdict:\s*PENDING\s*$/m.test(text);
|
|
403
|
+
}
|
|
404
|
+
function parseVerdictSummary(text) {
|
|
405
|
+
const match = /^## Verdict:.*\n+(.+)$/m.exec(text);
|
|
406
|
+
return match?.[1]?.trim();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/workspace/Workspace.ts
|
|
410
|
+
var NoActivePhaseError = class extends Error {
|
|
411
|
+
constructor(blockedPhases) {
|
|
412
|
+
super(
|
|
413
|
+
`No active phase: every incomplete row is blocked on an unsatisfied dependency (${blockedPhases.join(", ")}).`
|
|
414
|
+
);
|
|
415
|
+
this.blockedPhases = blockedPhases;
|
|
416
|
+
this.name = "NoActivePhaseError";
|
|
417
|
+
}
|
|
418
|
+
blockedPhases;
|
|
419
|
+
};
|
|
420
|
+
function phaseFilePath(phaseId) {
|
|
421
|
+
return `plans/plan-phase-${phaseId}.md`;
|
|
422
|
+
}
|
|
423
|
+
function reviewPath(label) {
|
|
424
|
+
return `reviews/${label}.md`;
|
|
425
|
+
}
|
|
426
|
+
function reviewStem(opts) {
|
|
427
|
+
if (opts.phase === void 0) {
|
|
428
|
+
throw new Error("review labels require opts.phase.");
|
|
429
|
+
}
|
|
430
|
+
return opts.step !== void 0 ? `phase-${opts.phase}-step-${opts.step}` : `phase-${opts.phase}`;
|
|
431
|
+
}
|
|
432
|
+
function reReviewLabel(phaseId) {
|
|
433
|
+
return `${reviewStem({ phase: phaseId })}-r1`;
|
|
434
|
+
}
|
|
435
|
+
function parseReviewLabel(label) {
|
|
436
|
+
const phaseId = /^phase-([^-]+)(?:-|$)/.exec(label)?.[1];
|
|
437
|
+
return { phaseId, isReReview: /-r\d+$/.test(label) };
|
|
438
|
+
}
|
|
439
|
+
function workspaceAt(root) {
|
|
440
|
+
let graph;
|
|
441
|
+
const readGraph = () => {
|
|
442
|
+
if (!graph) {
|
|
443
|
+
graph = readFile3(join3(root, "plan.md"), "utf8").then(parsePlanTable).then(phaseGraphAt);
|
|
444
|
+
}
|
|
445
|
+
return graph;
|
|
446
|
+
};
|
|
447
|
+
async function deriveStep() {
|
|
448
|
+
if (await exists(join3(root, "plan.md"))) {
|
|
449
|
+
const { rows } = await readGraph();
|
|
450
|
+
const allDone = rows.every(completed);
|
|
451
|
+
return allDone ? "done" : "implement";
|
|
452
|
+
}
|
|
453
|
+
if (await exists(join3(root, "spec.md"))) {
|
|
454
|
+
return "plan";
|
|
455
|
+
}
|
|
456
|
+
if (await exists(join3(root, "approach.md"))) {
|
|
457
|
+
const text = await readFile3(join3(root, "approach.md"), "utf8");
|
|
458
|
+
return isDecided(text) ? "spec" : "shape";
|
|
459
|
+
}
|
|
460
|
+
if (await exists(join3(root, "research.md"))) {
|
|
461
|
+
return "research";
|
|
462
|
+
}
|
|
463
|
+
if (await exists(join3(root, "queries.md"))) {
|
|
464
|
+
return "research";
|
|
465
|
+
}
|
|
466
|
+
return "query";
|
|
467
|
+
}
|
|
468
|
+
async function isPhaseComplete2(phaseId) {
|
|
469
|
+
try {
|
|
470
|
+
const text = await readFile3(
|
|
471
|
+
join3(root, phaseFilePath(phaseId)),
|
|
472
|
+
"utf8"
|
|
473
|
+
);
|
|
474
|
+
return isPhaseComplete(text);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
if (err.code === "ENOENT") {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function checkProgress(phaseId) {
|
|
483
|
+
const { isSatisfied } = await readGraph();
|
|
484
|
+
const phaseText = await readFile3(
|
|
485
|
+
join3(root, phaseFilePath(phaseId)),
|
|
486
|
+
"utf8"
|
|
487
|
+
);
|
|
488
|
+
const markers = parseSteps(phaseText);
|
|
489
|
+
return {
|
|
490
|
+
dependenciesSatisfied: isSatisfied(phaseId),
|
|
491
|
+
total: markers.length,
|
|
492
|
+
complete: markers.filter((marker) => marker === "[x]").length
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async function findActivePhase() {
|
|
496
|
+
const { rows, isSatisfied } = await readGraph();
|
|
497
|
+
const blockedPhases = [];
|
|
498
|
+
for (const row of rows) {
|
|
499
|
+
if (completed(row)) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (!isSatisfied(row.phase)) {
|
|
503
|
+
blockedPhases.push(row.phase);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const satisfaction = await checkProgress(row.phase);
|
|
507
|
+
const phaseFile = phaseFilePath(row.phase);
|
|
508
|
+
const phaseFileMtime = (await stat(join3(root, phaseFile))).mtime;
|
|
509
|
+
let staleInput;
|
|
510
|
+
if (await exists(join3(root, "spec.md"))) {
|
|
511
|
+
const specMtime = (await stat(join3(root, "spec.md"))).mtime;
|
|
512
|
+
if (phaseFileMtime < specMtime) {
|
|
513
|
+
staleInput = { phaseFile, specMtime, phaseFileMtime };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
phase: row.phase,
|
|
518
|
+
planProgress: `${satisfaction.complete}/${satisfaction.total} steps`,
|
|
519
|
+
staleInput
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
throw new NoActivePhaseError(blockedPhases);
|
|
523
|
+
}
|
|
524
|
+
async function nextLabel(type, opts = {}) {
|
|
525
|
+
if (type === "review") {
|
|
526
|
+
if (opts.phase === void 0) {
|
|
527
|
+
throw new Error("nextLabel('review') requires opts.phase.");
|
|
528
|
+
}
|
|
529
|
+
const stem = reviewStem(opts);
|
|
530
|
+
return nextInSequence(
|
|
531
|
+
join3(root, "reviews"),
|
|
532
|
+
stem,
|
|
533
|
+
(n) => `${stem}-r${n}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (type === "final") {
|
|
537
|
+
return nextInSequence(
|
|
538
|
+
join3(root, "reviews"),
|
|
539
|
+
"final",
|
|
540
|
+
(n) => `final-r${n}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return nextNumbered(join3(root, "backups"), (n) => `${type}-${n}`);
|
|
544
|
+
}
|
|
545
|
+
async function nextFile(type, opts) {
|
|
546
|
+
const label = await nextLabel(type, opts);
|
|
547
|
+
return type === "review" || type === "final" ? reviewPath(label) : `backups/${label}.md`;
|
|
548
|
+
}
|
|
549
|
+
async function lastLabel(type, opts = {}) {
|
|
550
|
+
if (type === "review") {
|
|
551
|
+
if (opts.phase === void 0) {
|
|
552
|
+
throw new Error("lastLabel('review') requires opts.phase.");
|
|
553
|
+
}
|
|
554
|
+
const stem = reviewStem(opts);
|
|
555
|
+
return lastInSequence(
|
|
556
|
+
join3(root, "reviews"),
|
|
557
|
+
stem,
|
|
558
|
+
(n) => `${stem}-r${n}`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return lastInSequence(
|
|
562
|
+
join3(root, "reviews"),
|
|
563
|
+
"final",
|
|
564
|
+
(n) => `final-r${n}`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
async function lastFile(type, opts) {
|
|
568
|
+
const label = await lastLabel(type, opts);
|
|
569
|
+
return label === void 0 ? void 0 : reviewPath(label);
|
|
570
|
+
}
|
|
571
|
+
async function pendingReviewLabel(phaseId) {
|
|
572
|
+
const label = await lastLabel("review", { phase: phaseId });
|
|
573
|
+
if (label === void 0) {
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
const text = await readFile3(join3(root, reviewPath(label)), "utf8");
|
|
577
|
+
return isPending(text) ? label : void 0;
|
|
578
|
+
}
|
|
579
|
+
async function resolveScope(selector) {
|
|
580
|
+
const { resolveScope: resolve2 } = await readGraph();
|
|
581
|
+
return resolve2(selector);
|
|
582
|
+
}
|
|
583
|
+
async function isPhaseDone(phaseId) {
|
|
584
|
+
const { rows } = await readGraph();
|
|
585
|
+
const row = rows.find((r) => r.phase === phaseId);
|
|
586
|
+
if (!row) {
|
|
587
|
+
throw new UnknownPhaseError(phaseId);
|
|
588
|
+
}
|
|
589
|
+
return completed(row);
|
|
590
|
+
}
|
|
591
|
+
async function reviewExists(label) {
|
|
592
|
+
return exists(join3(root, reviewPath(label)));
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
deriveStep,
|
|
596
|
+
isPhaseComplete: isPhaseComplete2,
|
|
597
|
+
checkProgress,
|
|
598
|
+
findActivePhase,
|
|
599
|
+
nextLabel,
|
|
600
|
+
nextFile,
|
|
601
|
+
lastLabel,
|
|
602
|
+
lastFile,
|
|
603
|
+
pendingReviewLabel,
|
|
604
|
+
resolveScope,
|
|
605
|
+
isPhaseDone,
|
|
606
|
+
reviewExists
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
async function usedStems(dir) {
|
|
610
|
+
let names;
|
|
611
|
+
try {
|
|
612
|
+
names = await readdir(dir);
|
|
613
|
+
} catch {
|
|
614
|
+
names = [];
|
|
615
|
+
}
|
|
616
|
+
return new Set(names.map((name) => name.replace(/\.md$/, "")));
|
|
617
|
+
}
|
|
618
|
+
async function nextInSequence(dir, bareLabel, nth) {
|
|
619
|
+
const used = await usedStems(dir);
|
|
620
|
+
if (!used.has(bareLabel)) {
|
|
621
|
+
return bareLabel;
|
|
622
|
+
}
|
|
623
|
+
for (let n = 1; ; n++) {
|
|
624
|
+
const candidate = nth(n);
|
|
625
|
+
if (!used.has(candidate)) {
|
|
626
|
+
return candidate;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function lastInSequence(dir, bareLabel, nth) {
|
|
631
|
+
const used = await usedStems(dir);
|
|
632
|
+
if (!used.has(bareLabel)) {
|
|
633
|
+
return void 0;
|
|
634
|
+
}
|
|
635
|
+
let last = bareLabel;
|
|
636
|
+
for (let n = 1; ; n++) {
|
|
637
|
+
const candidate = nth(n);
|
|
638
|
+
if (!used.has(candidate)) {
|
|
639
|
+
return last;
|
|
640
|
+
}
|
|
641
|
+
last = candidate;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function nextNumbered(dir, nth) {
|
|
645
|
+
const used = await usedStems(dir);
|
|
646
|
+
for (let n = 1; ; n++) {
|
|
647
|
+
const candidate = nth(n);
|
|
648
|
+
if (!used.has(candidate)) {
|
|
649
|
+
return candidate;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function exists(path) {
|
|
654
|
+
try {
|
|
655
|
+
await access(path);
|
|
656
|
+
return true;
|
|
657
|
+
} catch {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/workspace/LoopState.ts
|
|
663
|
+
var LoopAlreadyRunningError = class extends Error {
|
|
664
|
+
constructor() {
|
|
665
|
+
super(
|
|
666
|
+
"A loop is already running; stop or abandon it before starting another."
|
|
667
|
+
);
|
|
668
|
+
this.name = "LoopAlreadyRunningError";
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
var RepairAlreadySpentError = class extends Error {
|
|
672
|
+
constructor(phaseId) {
|
|
673
|
+
super(`Phase ${phaseId}'s one --loop repair attempt is already spent.`);
|
|
674
|
+
this.phaseId = phaseId;
|
|
675
|
+
this.name = "RepairAlreadySpentError";
|
|
676
|
+
}
|
|
677
|
+
phaseId;
|
|
678
|
+
};
|
|
679
|
+
function loopStatePath(root) {
|
|
680
|
+
return join4(root, "loop-state.json");
|
|
681
|
+
}
|
|
682
|
+
function loopStateAt(root, workspace) {
|
|
683
|
+
async function readState() {
|
|
684
|
+
let text;
|
|
685
|
+
try {
|
|
686
|
+
text = await readFile4(loopStatePath(root), "utf8");
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (err.code === "ENOENT") {
|
|
689
|
+
return void 0;
|
|
690
|
+
}
|
|
691
|
+
throw err;
|
|
692
|
+
}
|
|
693
|
+
return JSON.parse(text);
|
|
694
|
+
}
|
|
695
|
+
async function writeState(state) {
|
|
696
|
+
await mkdir3(root, { recursive: true });
|
|
697
|
+
await writeFile2(loopStatePath(root), JSON.stringify(state, null, 2));
|
|
698
|
+
}
|
|
699
|
+
async function deleteState() {
|
|
700
|
+
await rm(loopStatePath(root), { force: true });
|
|
701
|
+
}
|
|
702
|
+
async function repairSpent(phaseId) {
|
|
703
|
+
return workspace.reviewExists(reReviewLabel(phaseId));
|
|
704
|
+
}
|
|
705
|
+
async function start2(selector) {
|
|
706
|
+
if (await readState()) {
|
|
707
|
+
throw new LoopAlreadyRunningError();
|
|
708
|
+
}
|
|
709
|
+
const { scope, phaseIds } = await workspace.resolveScope(selector);
|
|
710
|
+
await writeState({
|
|
711
|
+
scope,
|
|
712
|
+
phaseIds,
|
|
713
|
+
cycle: 0,
|
|
714
|
+
phaseId: phaseIds[0],
|
|
715
|
+
bases: {},
|
|
716
|
+
conditions: []
|
|
717
|
+
});
|
|
718
|
+
return phaseIds;
|
|
719
|
+
}
|
|
720
|
+
async function advance2() {
|
|
721
|
+
const state = await readState();
|
|
722
|
+
if (!state) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (!await workspace.isPhaseDone(state.phaseId)) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const nextCycle = state.cycle + 1;
|
|
729
|
+
if (nextCycle >= state.phaseIds.length) {
|
|
730
|
+
await deleteState();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
await writeState({
|
|
734
|
+
...state,
|
|
735
|
+
cycle: nextCycle,
|
|
736
|
+
phaseId: state.phaseIds[nextCycle],
|
|
737
|
+
inFlight: void 0,
|
|
738
|
+
checkpoint: void 0
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
async function writeInFlight(task, phaseId, opts = {}) {
|
|
742
|
+
const state = await readState();
|
|
743
|
+
if (!state) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (task === "repair" && await repairSpent(phaseId)) {
|
|
747
|
+
throw new RepairAlreadySpentError(phaseId);
|
|
748
|
+
}
|
|
749
|
+
const bases = { ...state.bases };
|
|
750
|
+
if ((task === "implement" || task === "review") && opts.baseSha !== void 0) {
|
|
751
|
+
if (opts.resetBase) {
|
|
752
|
+
bases[phaseId] = opts.baseSha;
|
|
753
|
+
} else {
|
|
754
|
+
bases[phaseId] ??= opts.baseSha;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
await writeState({
|
|
758
|
+
...state,
|
|
759
|
+
bases,
|
|
760
|
+
inFlight: { task, phaseId, label: opts.label }
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
async function clearInFlight() {
|
|
764
|
+
const state = await readState();
|
|
765
|
+
if (!state) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
await writeState({ ...state, inFlight: void 0 });
|
|
769
|
+
}
|
|
770
|
+
async function updateCheckpoint(phaseId, label, verdict) {
|
|
771
|
+
const state = await readState();
|
|
772
|
+
if (!state) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const withoutPhase = state.conditions.filter(
|
|
776
|
+
(c) => c.phaseId !== phaseId
|
|
777
|
+
);
|
|
778
|
+
const conditions = verdict === "PASS WITH CONDITIONS" ? [...withoutPhase, { phaseId, label }] : verdict === "PASS" ? withoutPhase : state.conditions;
|
|
779
|
+
await writeState({
|
|
780
|
+
...state,
|
|
781
|
+
checkpoint: { phaseId, label, verdict },
|
|
782
|
+
conditions
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
async function stop2(reason) {
|
|
786
|
+
const state = await readState();
|
|
787
|
+
if (!state || state.stoppedReason === reason) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
await writeState({ ...state, stoppedReason: reason });
|
|
791
|
+
}
|
|
792
|
+
async function ok2() {
|
|
793
|
+
const state = await readState();
|
|
794
|
+
if (!state) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
await writeState({ ...state, stoppedReason: void 0 });
|
|
798
|
+
}
|
|
799
|
+
async function abandon2() {
|
|
800
|
+
await deleteState();
|
|
801
|
+
}
|
|
802
|
+
async function status() {
|
|
803
|
+
const state = await readState();
|
|
804
|
+
if (!state) {
|
|
805
|
+
return void 0;
|
|
806
|
+
}
|
|
807
|
+
const base = {
|
|
808
|
+
scope: state.scope,
|
|
809
|
+
phaseIds: state.phaseIds,
|
|
810
|
+
cycle: state.cycle,
|
|
811
|
+
phaseId: state.phaseId,
|
|
812
|
+
stoppedReason: state.stoppedReason,
|
|
813
|
+
checkpoint: state.checkpoint,
|
|
814
|
+
conditions: state.conditions
|
|
815
|
+
};
|
|
816
|
+
if (state.stoppedReason !== void 0) {
|
|
817
|
+
return { ...base, action: "acknowledge-required" };
|
|
818
|
+
}
|
|
819
|
+
const baseSha = state.bases[state.phaseId];
|
|
820
|
+
const diff = baseSha ? `git diff ${baseSha}..HEAD` : void 0;
|
|
821
|
+
if (!state.checkpoint) {
|
|
822
|
+
const implemented = await workspace.isPhaseComplete(state.phaseId);
|
|
823
|
+
if (!implemented) {
|
|
824
|
+
return { ...base, action: "implement" };
|
|
825
|
+
}
|
|
826
|
+
const inFlightLabel = state.inFlight?.task === "review" && state.inFlight.phaseId === state.phaseId ? state.inFlight.label : void 0;
|
|
827
|
+
const label = inFlightLabel ?? await workspace.pendingReviewLabel(state.phaseId) ?? await workspace.nextLabel("review", {
|
|
828
|
+
phase: state.phaseId
|
|
829
|
+
});
|
|
830
|
+
return { ...base, action: "review", label, diff };
|
|
831
|
+
}
|
|
832
|
+
if (state.checkpoint.verdict === "PASS" || state.checkpoint.verdict === "PASS WITH CONDITIONS") {
|
|
833
|
+
return { ...base, action: "advance" };
|
|
834
|
+
}
|
|
835
|
+
const reReview = reReviewLabel(state.phaseId);
|
|
836
|
+
if (state.checkpoint.label === reReview) {
|
|
837
|
+
return { ...base, action: "stop" };
|
|
838
|
+
}
|
|
839
|
+
const reReviewLaunched = await workspace.reviewExists(reReview);
|
|
840
|
+
if (!reReviewLaunched) {
|
|
841
|
+
return {
|
|
842
|
+
...base,
|
|
843
|
+
action: "repair",
|
|
844
|
+
review: reviewPath(state.checkpoint.label)
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return { ...base, action: "re-review", label: reReview, diff };
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
start: start2,
|
|
851
|
+
advance: advance2,
|
|
852
|
+
writeInFlight,
|
|
853
|
+
clearInFlight,
|
|
854
|
+
updateCheckpoint,
|
|
855
|
+
stop: stop2,
|
|
856
|
+
ok: ok2,
|
|
857
|
+
status,
|
|
858
|
+
abandon: abandon2,
|
|
859
|
+
read: readState
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/command/Root.ts
|
|
864
|
+
function resolveRoot(project, feature) {
|
|
865
|
+
return join5(project, "qrspi", feature);
|
|
866
|
+
}
|
|
867
|
+
function resolveServices(root, project, overrides = {}) {
|
|
868
|
+
const workspace = overrides.workspace ?? workspaceAt(root);
|
|
869
|
+
return {
|
|
870
|
+
workspace,
|
|
871
|
+
loopState: overrides.loopState ?? loopStateAt(root, workspace),
|
|
872
|
+
history: overrides.history ?? historyAt(root),
|
|
873
|
+
decisions: overrides.decisions ?? decisionsAt(root),
|
|
874
|
+
git: overrides.git ?? gitAt(project)
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
var commonOptions = [
|
|
878
|
+
{
|
|
879
|
+
flag: "--feature <feature>",
|
|
880
|
+
required: true,
|
|
881
|
+
description: "The feature workspace under ./qrspi/."
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
flag: "--project <path>",
|
|
885
|
+
required: false,
|
|
886
|
+
description: "The project root holding ./qrspi/; defaults to the current directory."
|
|
887
|
+
}
|
|
888
|
+
];
|
|
889
|
+
|
|
890
|
+
// src/command/decision.ts
|
|
891
|
+
var addHelp = {
|
|
892
|
+
name: "add",
|
|
893
|
+
summary: "Append <why> verbatim as one Markdown bullet to decisions.md.",
|
|
894
|
+
flags: [
|
|
895
|
+
...commonOptions,
|
|
896
|
+
{
|
|
897
|
+
flag: "--text <why>",
|
|
898
|
+
required: true,
|
|
899
|
+
description: "The decision and its rationale, verbatim."
|
|
900
|
+
}
|
|
901
|
+
],
|
|
902
|
+
example: 'qrspi-x decision add --text "Skipping Shape: this feature is a one-file fix." --feature widget --project .'
|
|
903
|
+
};
|
|
904
|
+
var readHelp = {
|
|
905
|
+
name: "read",
|
|
906
|
+
summary: "Print decisions.md verbatim.",
|
|
907
|
+
flags: [...commonOptions],
|
|
908
|
+
example: "qrspi-x decision read --feature widget --project ."
|
|
909
|
+
};
|
|
910
|
+
var help = {
|
|
911
|
+
name: "decision",
|
|
912
|
+
summary: "Durable rationale (why): add-only, read verbatim.",
|
|
913
|
+
actions: [addHelp, readHelp]
|
|
914
|
+
};
|
|
915
|
+
async function add(opts, services = {}) {
|
|
916
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
917
|
+
const { decisions } = resolveServices(root, opts.project, services);
|
|
918
|
+
await decisions.add(opts.text);
|
|
919
|
+
return ok();
|
|
920
|
+
}
|
|
921
|
+
async function read(opts, services = {}) {
|
|
922
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
923
|
+
const { decisions } = resolveServices(root, opts.project, services);
|
|
924
|
+
return decisions.read();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/command/Help.ts
|
|
928
|
+
function renderHelp(help10, action) {
|
|
929
|
+
if (action) {
|
|
930
|
+
const found = help10.actions?.find((a) => a.name === action);
|
|
931
|
+
if (!found) {
|
|
932
|
+
return `Unknown action "${action}" for "${help10.name}".`;
|
|
933
|
+
}
|
|
934
|
+
return renderAction(help10.name, found);
|
|
935
|
+
}
|
|
936
|
+
return renderCommand(help10);
|
|
937
|
+
}
|
|
938
|
+
function renderToolHelp(commands) {
|
|
939
|
+
const lines = ["Commands:"];
|
|
940
|
+
for (const c of commands) {
|
|
941
|
+
lines.push(` ${c.name} - ${c.summary} (qrspi-x --help ${c.name})`);
|
|
942
|
+
}
|
|
943
|
+
lines.push(
|
|
944
|
+
"",
|
|
945
|
+
"Output:",
|
|
946
|
+
" Commands normally print JSON to stdout.",
|
|
947
|
+
" decision read, history read, next-file, and import print bare content or a path.",
|
|
948
|
+
"",
|
|
949
|
+
"Exit codes:",
|
|
950
|
+
" 0 success, 1 blocked/refused, 2 usage error, 3 usable result with findings, 4 unexpected error."
|
|
951
|
+
);
|
|
952
|
+
return lines.join("\n");
|
|
953
|
+
}
|
|
954
|
+
function renderCommand(help10) {
|
|
955
|
+
const lines = [help10.name, "", help10.summary];
|
|
956
|
+
if (help10.usage) {
|
|
957
|
+
lines.push("", "Usage:", ` ${help10.usage}`);
|
|
958
|
+
}
|
|
959
|
+
if (help10.flags && help10.flags.length > 0) {
|
|
960
|
+
lines.push("", "Flags:", ...renderFlags(help10.flags));
|
|
961
|
+
}
|
|
962
|
+
if (help10.actions && help10.actions.length > 0) {
|
|
963
|
+
lines.push("", "Actions:");
|
|
964
|
+
for (const a of help10.actions) {
|
|
965
|
+
lines.push(
|
|
966
|
+
` ${a.name} - ${a.summary} (qrspi-x --help ${help10.name} ${a.name})`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return lines.join("\n");
|
|
971
|
+
}
|
|
972
|
+
function renderAction(commandName, action) {
|
|
973
|
+
const lines = [`${commandName} ${action.name}`, "", action.summary];
|
|
974
|
+
if (action.usage) {
|
|
975
|
+
lines.push("", "Usage:", ` ${action.usage}`);
|
|
976
|
+
}
|
|
977
|
+
if (action.flags && action.flags.length > 0) {
|
|
978
|
+
lines.push("", "Flags:", ...renderFlags(action.flags));
|
|
979
|
+
}
|
|
980
|
+
const examples = action.examples ?? (action.example ? [action.example] : []);
|
|
981
|
+
if (examples.length > 0) {
|
|
982
|
+
lines.push(
|
|
983
|
+
"",
|
|
984
|
+
examples.length === 1 ? "Example:" : "Examples:",
|
|
985
|
+
...examples.map((example) => ` ${example}`)
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
return lines.join("\n");
|
|
989
|
+
}
|
|
990
|
+
function renderFlags(flags) {
|
|
991
|
+
const lines = [];
|
|
992
|
+
for (const f of flags) {
|
|
993
|
+
lines.push(` ${f.flag}`);
|
|
994
|
+
const tag = f.required ? "required" : "optional";
|
|
995
|
+
lines.push(` (${tag}) ${f.description}`);
|
|
996
|
+
}
|
|
997
|
+
return lines;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/command/history.ts
|
|
1001
|
+
var addHelp2 = {
|
|
1002
|
+
name: "add",
|
|
1003
|
+
summary: "Append a selective 'what happened' entry to history.jsonl.",
|
|
1004
|
+
flags: [
|
|
1005
|
+
...commonOptions,
|
|
1006
|
+
{
|
|
1007
|
+
flag: "--text <what happened>",
|
|
1008
|
+
required: true,
|
|
1009
|
+
description: "The fact worth recording, verbatim."
|
|
1010
|
+
}
|
|
1011
|
+
],
|
|
1012
|
+
example: 'qrspi-x history add --text "Shape skipped" --feature widget --project .'
|
|
1013
|
+
};
|
|
1014
|
+
var readHelp2 = {
|
|
1015
|
+
name: "read",
|
|
1016
|
+
summary: "Print matching history.json entries verbatim, as a JSON array.",
|
|
1017
|
+
flags: [
|
|
1018
|
+
...commonOptions,
|
|
1019
|
+
{
|
|
1020
|
+
flag: "--tail <N>",
|
|
1021
|
+
required: false,
|
|
1022
|
+
description: "The last N matching entries. Default when neither --tail nor --all is given (N=20)."
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
flag: "--all",
|
|
1026
|
+
required: false,
|
|
1027
|
+
description: "Every matching entry, no limit. Mutually exclusive with --tail."
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
flag: "--kind <task>",
|
|
1031
|
+
required: false,
|
|
1032
|
+
description: "Only entries whose kind matches (e.g. review, loop)."
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
flag: "--phase <N>",
|
|
1036
|
+
required: false,
|
|
1037
|
+
description: "Only entries tagged with that phase."
|
|
1038
|
+
}
|
|
1039
|
+
],
|
|
1040
|
+
example: "qrspi-x history read --kind review --phase 2 --tail 5 --feature widget --project ."
|
|
1041
|
+
};
|
|
1042
|
+
var help2 = {
|
|
1043
|
+
name: "history",
|
|
1044
|
+
summary: "Selective timeline (what happened): add-only, filterable read.",
|
|
1045
|
+
actions: [addHelp2, readHelp2]
|
|
1046
|
+
};
|
|
1047
|
+
async function add2(opts, services = {}) {
|
|
1048
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1049
|
+
const { history } = resolveServices(root, opts.project, services);
|
|
1050
|
+
await history.append({ kind: "note", text: opts.text });
|
|
1051
|
+
return ok();
|
|
1052
|
+
}
|
|
1053
|
+
async function read2(opts, services = {}) {
|
|
1054
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1055
|
+
const { history } = resolveServices(root, opts.project, services);
|
|
1056
|
+
return history.read({
|
|
1057
|
+
tail: opts.tail,
|
|
1058
|
+
all: opts.all,
|
|
1059
|
+
kind: opts.kind,
|
|
1060
|
+
phase: opts.phase
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/command/import.ts
|
|
1065
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
1066
|
+
import { access as access2, mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1067
|
+
import { join as join6 } from "node:path";
|
|
1068
|
+
import { promisify as promisify2 } from "node:util";
|
|
1069
|
+
var defaultRun = promisify2(nodeExecFile);
|
|
1070
|
+
var help3 = {
|
|
1071
|
+
name: "import",
|
|
1072
|
+
summary: "Fetch a GitHub issue or pull request into request.md.",
|
|
1073
|
+
usage: "qrspi-x import <number> [--feature <name>] [--repo <owner/repo | url>] [--project <path>]",
|
|
1074
|
+
flags: [
|
|
1075
|
+
{
|
|
1076
|
+
flag: "<number>",
|
|
1077
|
+
required: true,
|
|
1078
|
+
description: "GitHub issue or PR number (e.g. 42 or #42)."
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
flag: "--feature <name>",
|
|
1082
|
+
required: false,
|
|
1083
|
+
description: "Explicit feature name instead of default gh-<repo>-<n>."
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
flag: "--repo <owner/repo | url>",
|
|
1087
|
+
required: false,
|
|
1088
|
+
description: "Target repo (shorthand or URL); defaults to project's git origin remote."
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
flag: "--project <path>",
|
|
1092
|
+
required: false,
|
|
1093
|
+
description: "The project root holding ./qrspi/; defaults to the current directory."
|
|
1094
|
+
}
|
|
1095
|
+
]
|
|
1096
|
+
};
|
|
1097
|
+
async function runImport(opts, services = {}) {
|
|
1098
|
+
const issueNumber = parseIssueNumber(opts.number);
|
|
1099
|
+
if (issueNumber === void 0) {
|
|
1100
|
+
return {
|
|
1101
|
+
exitCode: 1,
|
|
1102
|
+
text: `Invalid issue number "${opts.number}".
|
|
1103
|
+
Provide a positive issue or pull request number (e.g. 42 or #42).`
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
let explicitRepo;
|
|
1107
|
+
if (opts.repo) {
|
|
1108
|
+
try {
|
|
1109
|
+
explicitRepo = parseGitHubRepo(opts.repo);
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1112
|
+
if (message.startsWith("Unsupported host")) {
|
|
1113
|
+
return {
|
|
1114
|
+
exitCode: 1,
|
|
1115
|
+
text: `${message}
|
|
1116
|
+
Only github.com repositories are supported.`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
exitCode: 1,
|
|
1121
|
+
text: `${message}
|
|
1122
|
+
Provide a valid GitHub repository shorthand (owner/repo) or URL.`
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const resolvedServices = resolveServices(
|
|
1127
|
+
resolveRoot(opts.project, "tmp"),
|
|
1128
|
+
opts.project,
|
|
1129
|
+
services
|
|
1130
|
+
);
|
|
1131
|
+
const execFile2 = services.execFile ?? defaultRun;
|
|
1132
|
+
const fetchFn = services.fetch ?? fetch;
|
|
1133
|
+
const fetchRes = await executeFetch(
|
|
1134
|
+
issueNumber,
|
|
1135
|
+
explicitRepo,
|
|
1136
|
+
opts,
|
|
1137
|
+
resolvedServices,
|
|
1138
|
+
execFile2,
|
|
1139
|
+
fetchFn
|
|
1140
|
+
);
|
|
1141
|
+
if (!fetchRes.ok) {
|
|
1142
|
+
if (fetchRes.kind === "ambiguous") {
|
|
1143
|
+
return {
|
|
1144
|
+
exitCode: 1,
|
|
1145
|
+
text: `${fetchRes.message}
|
|
1146
|
+
Specify the repository with --repo <owner/repo | url>.`
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
if (fetchRes.kind === "not-found") {
|
|
1150
|
+
return {
|
|
1151
|
+
exitCode: 1,
|
|
1152
|
+
text: `${fetchRes.message}
|
|
1153
|
+
Double-check the repository and issue/PR number.`
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
exitCode: 1,
|
|
1158
|
+
text: `${fetchRes.message}
|
|
1159
|
+
Ensure you have access to the repository (e.g. gh auth login for private repositories).`
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
let featureName;
|
|
1163
|
+
if (opts.feature) {
|
|
1164
|
+
if (!isValidFeatureName(opts.feature)) {
|
|
1165
|
+
return {
|
|
1166
|
+
exitCode: 1,
|
|
1167
|
+
text: `Feature name "${opts.feature}" is invalid.
|
|
1168
|
+
Feature names must match [a-z0-9]+(-[a-z0-9]+)*.`
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
featureName = opts.feature;
|
|
1172
|
+
} else {
|
|
1173
|
+
const normRepo = normalizeRepoName(fetchRes.data.repo);
|
|
1174
|
+
featureName = `gh-${normRepo}-${fetchRes.data.number}`;
|
|
1175
|
+
}
|
|
1176
|
+
const featureRoot = resolveRoot(opts.project, featureName);
|
|
1177
|
+
try {
|
|
1178
|
+
await access2(featureRoot);
|
|
1179
|
+
return {
|
|
1180
|
+
exitCode: 1,
|
|
1181
|
+
text: `Feature workspace already exists at ${featureRoot}.
|
|
1182
|
+
Resume work there or specify a distinct name with --feature <name>.`
|
|
1183
|
+
};
|
|
1184
|
+
} catch {
|
|
1185
|
+
}
|
|
1186
|
+
await mkdir4(featureRoot, { recursive: true });
|
|
1187
|
+
const title = fetchRes.data.title;
|
|
1188
|
+
const body = fetchRes.data.body;
|
|
1189
|
+
const content = body.trim().length > 0 ? `# ${title}
|
|
1190
|
+
|
|
1191
|
+
${body}
|
|
1192
|
+
` : `# ${title}
|
|
1193
|
+
`;
|
|
1194
|
+
const requestPath = join6(featureRoot, "request.md");
|
|
1195
|
+
await writeFile3(requestPath, content, "utf8");
|
|
1196
|
+
return {
|
|
1197
|
+
exitCode: 0,
|
|
1198
|
+
feature: featureName,
|
|
1199
|
+
path: requestPath,
|
|
1200
|
+
source: {
|
|
1201
|
+
owner: fetchRes.data.owner,
|
|
1202
|
+
repo: fetchRes.data.repo,
|
|
1203
|
+
number: fetchRes.data.number,
|
|
1204
|
+
fetchedVia: fetchRes.data.fetchedVia
|
|
1205
|
+
},
|
|
1206
|
+
text: `Imported ${fetchRes.data.owner}/${fetchRes.data.repo}#${fetchRes.data.number} into ${requestPath} (via ${fetchRes.data.fetchedVia}).
|
|
1207
|
+
Next: qrspi-x:init ${featureName} (or qrspi-x:query ${featureName} to skip straight to Query).`
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function parseIssueNumber(input) {
|
|
1211
|
+
const match = input.trim().match(/^#?(\d+)$/);
|
|
1212
|
+
if (!match) return void 0;
|
|
1213
|
+
return Number.parseInt(match[1], 10);
|
|
1214
|
+
}
|
|
1215
|
+
function isNotFoundErr(err) {
|
|
1216
|
+
if (!err || typeof err !== "object") return false;
|
|
1217
|
+
const msg = `${err.message || ""} ${err.stderr || ""} ${err.stdout || ""}`.toLowerCase();
|
|
1218
|
+
return msg.includes("could not resolve to an issue or pull request") || msg.includes("could not resolve to an issue") || msg.includes("could not resolve to a pull request") || msg.includes("not found") || msg.includes("404") || msg.includes("no issue found") || msg.includes("no pull request found");
|
|
1219
|
+
}
|
|
1220
|
+
function isNoRepoErr(err) {
|
|
1221
|
+
if (!err || typeof err !== "object") return false;
|
|
1222
|
+
const msg = `${err.message || ""} ${err.stderr || ""} ${err.stdout || ""}`.toLowerCase();
|
|
1223
|
+
return msg.includes("not a git repository") || msg.includes("no git remote") || msg.includes("could not determine base repo") || msg.includes("no default repository") || msg.includes(
|
|
1224
|
+
"none of the git remotes configured for this repository point to a known github host"
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
function isValidFeatureName(name) {
|
|
1228
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
|
|
1229
|
+
}
|
|
1230
|
+
function normalizeRepoName(repo) {
|
|
1231
|
+
return repo.toLowerCase().replace(/[._]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1232
|
+
}
|
|
1233
|
+
async function executeFetch(issueNumber, explicitRepo, opts, services, execFile2, fetchFn) {
|
|
1234
|
+
const git = services.git;
|
|
1235
|
+
let owner = explicitRepo?.owner;
|
|
1236
|
+
let repo = explicitRepo?.repo;
|
|
1237
|
+
const baseArgs = [String(issueNumber)];
|
|
1238
|
+
if (owner && repo) {
|
|
1239
|
+
baseArgs.push("--repo", `${owner}/${repo}`);
|
|
1240
|
+
}
|
|
1241
|
+
baseArgs.push("--json", "title,body");
|
|
1242
|
+
let ghOutcome;
|
|
1243
|
+
try {
|
|
1244
|
+
const { stdout } = await execFile2(
|
|
1245
|
+
"gh",
|
|
1246
|
+
["issue", "view", ...baseArgs],
|
|
1247
|
+
{
|
|
1248
|
+
cwd: opts.project
|
|
1249
|
+
}
|
|
1250
|
+
);
|
|
1251
|
+
const json = JSON.parse(stdout);
|
|
1252
|
+
ghOutcome = {
|
|
1253
|
+
ok: true,
|
|
1254
|
+
title: json.title ?? "",
|
|
1255
|
+
body: json.body ?? ""
|
|
1256
|
+
};
|
|
1257
|
+
} catch (issueErr) {
|
|
1258
|
+
if (isNoRepoErr(issueErr)) {
|
|
1259
|
+
ghOutcome = { ok: false, reason: "no-repo", error: issueErr };
|
|
1260
|
+
} else if (isNotFoundErr(issueErr)) {
|
|
1261
|
+
try {
|
|
1262
|
+
const { stdout } = await execFile2(
|
|
1263
|
+
"gh",
|
|
1264
|
+
["pr", "view", ...baseArgs],
|
|
1265
|
+
{
|
|
1266
|
+
cwd: opts.project
|
|
1267
|
+
}
|
|
1268
|
+
);
|
|
1269
|
+
const json = JSON.parse(stdout);
|
|
1270
|
+
ghOutcome = {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
title: json.title ?? "",
|
|
1273
|
+
body: json.body ?? ""
|
|
1274
|
+
};
|
|
1275
|
+
} catch (prErr) {
|
|
1276
|
+
if (isNoRepoErr(prErr)) {
|
|
1277
|
+
ghOutcome = { ok: false, reason: "no-repo", error: prErr };
|
|
1278
|
+
} else if (isNotFoundErr(prErr)) {
|
|
1279
|
+
ghOutcome = {
|
|
1280
|
+
ok: false,
|
|
1281
|
+
reason: "not-found",
|
|
1282
|
+
error: prErr
|
|
1283
|
+
};
|
|
1284
|
+
} else {
|
|
1285
|
+
ghOutcome = { ok: false, reason: "other", error: prErr };
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
} else {
|
|
1289
|
+
ghOutcome = { ok: false, reason: "other", error: issueErr };
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (ghOutcome.ok) {
|
|
1293
|
+
if (!owner || !repo) {
|
|
1294
|
+
const inferred = await git?.originRepo();
|
|
1295
|
+
if (inferred) {
|
|
1296
|
+
owner = inferred.owner;
|
|
1297
|
+
repo = inferred.repo;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (owner && repo) {
|
|
1301
|
+
return {
|
|
1302
|
+
ok: true,
|
|
1303
|
+
data: {
|
|
1304
|
+
title: ghOutcome.title,
|
|
1305
|
+
body: ghOutcome.body,
|
|
1306
|
+
owner,
|
|
1307
|
+
repo,
|
|
1308
|
+
number: issueNumber,
|
|
1309
|
+
fetchedVia: "gh"
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (!owner || !repo) {
|
|
1315
|
+
const inferred = await git?.originRepo();
|
|
1316
|
+
if (inferred) {
|
|
1317
|
+
owner = inferred.owner;
|
|
1318
|
+
repo = inferred.repo;
|
|
1319
|
+
} else {
|
|
1320
|
+
return {
|
|
1321
|
+
ok: false,
|
|
1322
|
+
kind: "ambiguous",
|
|
1323
|
+
message: `Reference "#${issueNumber}" has no target repository specified and none could be inferred from git remote. Specify --repo <owner/repo | url>.`
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`;
|
|
1329
|
+
const res = await fetchFn(url, {
|
|
1330
|
+
headers: { "User-Agent": "qrspi-x-import" }
|
|
1331
|
+
});
|
|
1332
|
+
if (res.status === 404) {
|
|
1333
|
+
if (!ghOutcome.ok && ghOutcome.reason === "not-found") {
|
|
1334
|
+
return {
|
|
1335
|
+
ok: false,
|
|
1336
|
+
kind: "not-found",
|
|
1337
|
+
message: `Issue/PR #${issueNumber} not found in repository ${owner}/${repo}.`
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
kind: "fetch-failed",
|
|
1343
|
+
message: `Failed to fetch ${owner}/${repo}#${issueNumber}: gh failed and REST fallback returned 404.`
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
if (!res.ok) {
|
|
1347
|
+
return {
|
|
1348
|
+
ok: false,
|
|
1349
|
+
kind: "fetch-failed",
|
|
1350
|
+
message: `Failed to fetch ${owner}/${repo}#${issueNumber}: gh failed and REST fallback returned HTTP ${res.status}.`
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
const data = await res.json();
|
|
1354
|
+
return {
|
|
1355
|
+
ok: true,
|
|
1356
|
+
data: {
|
|
1357
|
+
title: data.title ?? "",
|
|
1358
|
+
body: data.body ?? "",
|
|
1359
|
+
owner,
|
|
1360
|
+
repo,
|
|
1361
|
+
number: issueNumber,
|
|
1362
|
+
fetchedVia: "rest"
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
} catch (restErr) {
|
|
1366
|
+
if (!ghOutcome.ok && ghOutcome.reason === "not-found") {
|
|
1367
|
+
return {
|
|
1368
|
+
ok: false,
|
|
1369
|
+
kind: "not-found",
|
|
1370
|
+
message: `Issue/PR #${issueNumber} not found in repository ${owner}/${repo}.`
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
kind: "fetch-failed",
|
|
1376
|
+
message: `Failed to fetch ${owner}/${repo}#${issueNumber}: gh failed and REST error: ${restErr instanceof Error ? restErr.message : String(restErr)}`
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/command/log.ts
|
|
1382
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
1383
|
+
import { join as join7 } from "node:path";
|
|
1384
|
+
var reasonFlag = {
|
|
1385
|
+
flag: '--reason "<why>"',
|
|
1386
|
+
required: false,
|
|
1387
|
+
description: "A rerun/resume worth noting in history.jsonl."
|
|
1388
|
+
};
|
|
1389
|
+
var implementHelp = {
|
|
1390
|
+
name: "implement",
|
|
1391
|
+
summary: "Record implement/repair completion: reads plan.md/phase markers, clears inFlight.",
|
|
1392
|
+
flags: [
|
|
1393
|
+
...commonOptions,
|
|
1394
|
+
{
|
|
1395
|
+
flag: "--phase <id>",
|
|
1396
|
+
required: true,
|
|
1397
|
+
description: "The phase that finished."
|
|
1398
|
+
},
|
|
1399
|
+
reasonFlag
|
|
1400
|
+
],
|
|
1401
|
+
example: "qrspi-x log implement --feature widget --project . --phase 1"
|
|
1402
|
+
};
|
|
1403
|
+
var repairHelp = {
|
|
1404
|
+
name: "repair",
|
|
1405
|
+
summary: "Alias for `log implement` \u2014 identical validation, same call.",
|
|
1406
|
+
flags: implementHelp.flags,
|
|
1407
|
+
example: "qrspi-x log repair --feature widget --project . --phase 1"
|
|
1408
|
+
};
|
|
1409
|
+
var reviewHelp = {
|
|
1410
|
+
name: "review",
|
|
1411
|
+
summary: "Record a review verdict: reads reviews/<label>.md, always writes, updates loop-state.json's checkpoint/conditions.",
|
|
1412
|
+
flags: [
|
|
1413
|
+
...commonOptions,
|
|
1414
|
+
{
|
|
1415
|
+
flag: "--label <label>",
|
|
1416
|
+
required: true,
|
|
1417
|
+
description: "The review artifact's label (from status's next.label or next-file)."
|
|
1418
|
+
}
|
|
1419
|
+
],
|
|
1420
|
+
example: "qrspi-x log review --feature widget --project . --label phase-1"
|
|
1421
|
+
};
|
|
1422
|
+
var parkHelp = {
|
|
1423
|
+
name: "park",
|
|
1424
|
+
summary: "Mark the feature not-currently-active. Not a gate; always appends to history.jsonl.",
|
|
1425
|
+
flags: [...commonOptions, reasonFlag],
|
|
1426
|
+
example: 'qrspi-x log park --feature widget --project . --reason "waiting on design review"'
|
|
1427
|
+
};
|
|
1428
|
+
var help4 = {
|
|
1429
|
+
name: "log",
|
|
1430
|
+
summary: "Record completed work, after the fact, from real on-disk evidence.",
|
|
1431
|
+
actions: [implementHelp, repairHelp, reviewHelp, parkHelp]
|
|
1432
|
+
};
|
|
1433
|
+
async function logImplement(opts, implementKind, services) {
|
|
1434
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1435
|
+
const { workspace, loopState, history } = resolveServices(
|
|
1436
|
+
root,
|
|
1437
|
+
opts.project,
|
|
1438
|
+
services
|
|
1439
|
+
);
|
|
1440
|
+
if (!await workspace.isPhaseComplete(opts.phase)) {
|
|
1441
|
+
return blocked([
|
|
1442
|
+
{
|
|
1443
|
+
code: "phase-not-complete",
|
|
1444
|
+
message: `Phase ${opts.phase}'s steps aren't all [x] yet.`
|
|
1445
|
+
}
|
|
1446
|
+
]);
|
|
1447
|
+
}
|
|
1448
|
+
await history.conditionalAppend(
|
|
1449
|
+
{ kind: implementKind, phase: opts.phase },
|
|
1450
|
+
{ action: "end" },
|
|
1451
|
+
{
|
|
1452
|
+
kind: implementKind,
|
|
1453
|
+
action: "end",
|
|
1454
|
+
phase: opts.phase,
|
|
1455
|
+
...opts.reason !== void 0 ? { text: opts.reason } : {}
|
|
1456
|
+
}
|
|
1457
|
+
);
|
|
1458
|
+
await loopState.clearInFlight();
|
|
1459
|
+
return ok();
|
|
1460
|
+
}
|
|
1461
|
+
async function implement(opts, services = {}) {
|
|
1462
|
+
return logImplement(opts, "implement", services);
|
|
1463
|
+
}
|
|
1464
|
+
async function repair(opts, services = {}) {
|
|
1465
|
+
return logImplement(opts, "repair", services);
|
|
1466
|
+
}
|
|
1467
|
+
async function review(opts, services = {}) {
|
|
1468
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1469
|
+
const { loopState, history } = resolveServices(
|
|
1470
|
+
root,
|
|
1471
|
+
opts.project,
|
|
1472
|
+
services
|
|
1473
|
+
);
|
|
1474
|
+
const path = reviewPath(opts.label);
|
|
1475
|
+
const text = await readFile5(join7(root, path), "utf8");
|
|
1476
|
+
const verdict = parseVerdict(text);
|
|
1477
|
+
if (!verdict) {
|
|
1478
|
+
return blocked([
|
|
1479
|
+
{
|
|
1480
|
+
code: "no-verdict",
|
|
1481
|
+
message: `${path} has no ## Verdict: line yet.`
|
|
1482
|
+
}
|
|
1483
|
+
]);
|
|
1484
|
+
}
|
|
1485
|
+
const { phaseId, isReReview } = parseReviewLabel(opts.label);
|
|
1486
|
+
const outcome = phaseId === void 0 ? "final" : verdict === "FAIL" ? isReReview ? "stop" : "repair" : "advance";
|
|
1487
|
+
const conditions = verdict === "PASS WITH CONDITIONS" ? [parseVerdictSummary(text) ?? ""] : void 0;
|
|
1488
|
+
await history.conditionalAppend(
|
|
1489
|
+
{ kind: "review", label: opts.label },
|
|
1490
|
+
{ action: "end" },
|
|
1491
|
+
{
|
|
1492
|
+
kind: "review",
|
|
1493
|
+
action: "end",
|
|
1494
|
+
label: opts.label,
|
|
1495
|
+
...phaseId !== void 0 ? { phase: phaseId } : {},
|
|
1496
|
+
verdict,
|
|
1497
|
+
outcome,
|
|
1498
|
+
...conditions ? { conditions } : {}
|
|
1499
|
+
}
|
|
1500
|
+
);
|
|
1501
|
+
if (phaseId !== void 0) {
|
|
1502
|
+
await loopState.updateCheckpoint(phaseId, opts.label, verdict);
|
|
1503
|
+
await loopState.clearInFlight();
|
|
1504
|
+
}
|
|
1505
|
+
return ok();
|
|
1506
|
+
}
|
|
1507
|
+
async function park(opts, services = {}) {
|
|
1508
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1509
|
+
const { history, decisions } = resolveServices(
|
|
1510
|
+
root,
|
|
1511
|
+
opts.project,
|
|
1512
|
+
services
|
|
1513
|
+
);
|
|
1514
|
+
if (await history.isParked()) {
|
|
1515
|
+
return ok();
|
|
1516
|
+
}
|
|
1517
|
+
await history.append({
|
|
1518
|
+
kind: "park",
|
|
1519
|
+
...opts.reason !== void 0 ? { text: opts.reason } : {}
|
|
1520
|
+
});
|
|
1521
|
+
if (opts.reason !== void 0) {
|
|
1522
|
+
await decisions.add(opts.reason);
|
|
1523
|
+
}
|
|
1524
|
+
return ok();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// src/command/loop.ts
|
|
1528
|
+
var startHelp = {
|
|
1529
|
+
name: "start",
|
|
1530
|
+
summary: "Resolve a selector and create loop-state.json. Use all, one phase id, a row range like 2..4, or a comma list like 1,3.",
|
|
1531
|
+
usage: "qrspi-x loop start <selector> --feature <feature> --project <path>",
|
|
1532
|
+
flags: [...commonOptions],
|
|
1533
|
+
examples: [
|
|
1534
|
+
"qrspi-x loop start all --feature widget --project .",
|
|
1535
|
+
"qrspi-x loop start 2..4 --feature widget --project .",
|
|
1536
|
+
"qrspi-x loop start 1,3 --feature widget --project ."
|
|
1537
|
+
]
|
|
1538
|
+
};
|
|
1539
|
+
var advanceHelp = {
|
|
1540
|
+
name: "advance",
|
|
1541
|
+
summary: "Use after the current phase is complete and marked [x] to move the loop pointer to the next phase; it does not clear a stop.",
|
|
1542
|
+
usage: "qrspi-x loop advance --feature <feature> --project <path>",
|
|
1543
|
+
flags: [...commonOptions],
|
|
1544
|
+
example: "qrspi-x loop advance --feature widget --project ."
|
|
1545
|
+
};
|
|
1546
|
+
var stopHelp = {
|
|
1547
|
+
name: "stop",
|
|
1548
|
+
summary: "Record a stop (agent self-stop or human intervention). Called only by autoloop itself.",
|
|
1549
|
+
usage: 'qrspi-x loop stop "<reason>" --feature <feature> --project <path>',
|
|
1550
|
+
flags: [...commonOptions],
|
|
1551
|
+
example: 'qrspi-x loop stop "dirty-tree after repair" --feature widget --project .'
|
|
1552
|
+
};
|
|
1553
|
+
var okHelp = {
|
|
1554
|
+
name: "ok",
|
|
1555
|
+
summary: "Use only after status reports acknowledge-required and a human has resolved the stop; clears the stop without advancing the phase.",
|
|
1556
|
+
usage: 'qrspi-x loop ok ["<reason>"] --feature <feature> --project <path>',
|
|
1557
|
+
flags: [...commonOptions],
|
|
1558
|
+
example: 'qrspi-x loop ok "retried with --base" --feature widget --project .'
|
|
1559
|
+
};
|
|
1560
|
+
var abandonHelp = {
|
|
1561
|
+
name: "abandon",
|
|
1562
|
+
summary: "End the run (reject a proposed scope, or end an active one). Removes loop-state.json.",
|
|
1563
|
+
usage: 'qrspi-x loop abandon "<reason>" --feature <feature> --project <path>',
|
|
1564
|
+
flags: [...commonOptions],
|
|
1565
|
+
example: 'qrspi-x loop abandon "scope too large" --feature widget --project .'
|
|
1566
|
+
};
|
|
1567
|
+
var help5 = {
|
|
1568
|
+
name: "loop",
|
|
1569
|
+
summary: "Own loop-state.json's scope and lifecycle: start/advance/stop/ok/abandon.",
|
|
1570
|
+
actions: [startHelp, advanceHelp, stopHelp, okHelp, abandonHelp]
|
|
1571
|
+
};
|
|
1572
|
+
async function writeLoopEntry(services, action, reason) {
|
|
1573
|
+
if (action !== "stop" && reason === void 0) {
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
await services.history.conditionalAppend(
|
|
1577
|
+
{ kind: "loop", action },
|
|
1578
|
+
{ text: reason },
|
|
1579
|
+
{
|
|
1580
|
+
kind: "loop",
|
|
1581
|
+
action,
|
|
1582
|
+
...reason !== void 0 ? { text: reason } : {}
|
|
1583
|
+
}
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
async function start(opts, services = {}) {
|
|
1587
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1588
|
+
const resolved = resolveServices(root, opts.project, services);
|
|
1589
|
+
try {
|
|
1590
|
+
const phaseIds = await resolved.loopState.start(opts.selector);
|
|
1591
|
+
return ok({ phaseIds });
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
if (err instanceof Error) {
|
|
1594
|
+
return blocked([{ code: "loop-refused", message: err.message }]);
|
|
1595
|
+
}
|
|
1596
|
+
throw err;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
async function advance(opts, services = {}) {
|
|
1600
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1601
|
+
const resolved = resolveServices(root, opts.project, services);
|
|
1602
|
+
await resolved.loopState.advance();
|
|
1603
|
+
return ok();
|
|
1604
|
+
}
|
|
1605
|
+
async function stop(opts, services = {}) {
|
|
1606
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1607
|
+
const resolved = resolveServices(root, opts.project, services);
|
|
1608
|
+
await resolved.loopState.stop(opts.reason);
|
|
1609
|
+
await writeLoopEntry(resolved, "stop", opts.reason);
|
|
1610
|
+
return ok();
|
|
1611
|
+
}
|
|
1612
|
+
async function loopOk(opts, services = {}) {
|
|
1613
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1614
|
+
const resolved = resolveServices(root, opts.project, services);
|
|
1615
|
+
await resolved.loopState.ok();
|
|
1616
|
+
await writeLoopEntry(resolved, "ok", opts.reason);
|
|
1617
|
+
const status = await resolved.loopState.status();
|
|
1618
|
+
return ok({ action: status?.action });
|
|
1619
|
+
}
|
|
1620
|
+
async function abandon(opts, services = {}) {
|
|
1621
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1622
|
+
const resolved = resolveServices(root, opts.project, services);
|
|
1623
|
+
await writeLoopEntry(resolved, "abandon", opts.reason);
|
|
1624
|
+
await resolved.loopState.abandon();
|
|
1625
|
+
return ok();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// src/command/next-file.ts
|
|
1629
|
+
var help6 = {
|
|
1630
|
+
name: "next-file",
|
|
1631
|
+
summary: "Print the next artifact path for <type>: query, research, approach, spec, review, or final.",
|
|
1632
|
+
usage: "qrspi-x next-file <type> --feature <feature> --project <path> [--phase <id>] [--step <k>]",
|
|
1633
|
+
flags: [
|
|
1634
|
+
...commonOptions,
|
|
1635
|
+
{
|
|
1636
|
+
flag: "--phase <id>",
|
|
1637
|
+
required: false,
|
|
1638
|
+
description: "The phase id; required for review artifacts."
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
flag: "--step <k>",
|
|
1642
|
+
required: false,
|
|
1643
|
+
description: "The step number for a step-scoped review."
|
|
1644
|
+
}
|
|
1645
|
+
]
|
|
1646
|
+
};
|
|
1647
|
+
var TYPES = [
|
|
1648
|
+
"query",
|
|
1649
|
+
"research",
|
|
1650
|
+
"approach",
|
|
1651
|
+
"spec",
|
|
1652
|
+
"review",
|
|
1653
|
+
"final"
|
|
1654
|
+
];
|
|
1655
|
+
async function run2(type, opts, services = {}) {
|
|
1656
|
+
if (!TYPES.includes(type)) {
|
|
1657
|
+
throw new Error(
|
|
1658
|
+
`Unknown next-file type "${type}" (expected one of: ${TYPES.join(", ")}).`
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
if (type === "review" && opts.phase === void 0) {
|
|
1662
|
+
throw new Error("next-file review requires --phase <id>.");
|
|
1663
|
+
}
|
|
1664
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
1665
|
+
const { workspace } = resolveServices(root, opts.project, services);
|
|
1666
|
+
return workspace.nextFile(type, {
|
|
1667
|
+
phase: opts.phase,
|
|
1668
|
+
step: opts.step
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/command/plugin.ts
|
|
1673
|
+
import { execFile as nodeExecFile4 } from "node:child_process";
|
|
1674
|
+
import { readdir as readdir4, readFile as readFile8 } from "node:fs/promises";
|
|
1675
|
+
import { homedir as homedir2 } from "node:os";
|
|
1676
|
+
import { join as join10 } from "node:path";
|
|
1677
|
+
import { promisify as promisify5 } from "node:util";
|
|
1678
|
+
|
|
1679
|
+
// src/plugin/GitHub.ts
|
|
1680
|
+
import { execFile as nodeExecFile2 } from "node:child_process";
|
|
1681
|
+
import { promisify as promisify3 } from "node:util";
|
|
1682
|
+
|
|
1683
|
+
// src/plugin/Manifest.ts
|
|
1684
|
+
var REQUIRED_STRING_FIELDS = [
|
|
1685
|
+
"version",
|
|
1686
|
+
"releaseTag",
|
|
1687
|
+
"downloadedAt",
|
|
1688
|
+
"archiveHash"
|
|
1689
|
+
];
|
|
1690
|
+
function parseManifest(text) {
|
|
1691
|
+
let data;
|
|
1692
|
+
try {
|
|
1693
|
+
data = JSON.parse(text);
|
|
1694
|
+
} catch {
|
|
1695
|
+
return void 0;
|
|
1696
|
+
}
|
|
1697
|
+
if (!isValidManifestShape(data)) {
|
|
1698
|
+
return void 0;
|
|
1699
|
+
}
|
|
1700
|
+
return data;
|
|
1701
|
+
}
|
|
1702
|
+
function isValidManifestShape(data) {
|
|
1703
|
+
if (typeof data !== "object" || data === null) {
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
const record = data;
|
|
1707
|
+
for (const field of REQUIRED_STRING_FIELDS) {
|
|
1708
|
+
if (typeof record[field] !== "string" || record[field] === "") {
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const payloadPaths = record.payloadPaths;
|
|
1713
|
+
if (!Array.isArray(payloadPaths) || payloadPaths.length === 0) {
|
|
1714
|
+
return false;
|
|
1715
|
+
}
|
|
1716
|
+
for (const path of payloadPaths) {
|
|
1717
|
+
if (typeof path !== "string" || path === "" || !isPayloadPathSafe(path)) {
|
|
1718
|
+
return false;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
function isPayloadPathSafe(path) {
|
|
1724
|
+
return !path.includes("/") && !path.includes("\\") && path !== "." && path !== "..";
|
|
1725
|
+
}
|
|
1726
|
+
function isCurrentWith(a, b) {
|
|
1727
|
+
return a.version === b.version && a.archiveHash === b.archiveHash;
|
|
1728
|
+
}
|
|
1729
|
+
var RELEASE_TAG_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/;
|
|
1730
|
+
function parseReleaseTag(tag) {
|
|
1731
|
+
const match = RELEASE_TAG_PATTERN.exec(tag);
|
|
1732
|
+
if (!match) {
|
|
1733
|
+
return void 0;
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
tag,
|
|
1737
|
+
major: Number(match[1]),
|
|
1738
|
+
minor: Number(match[2]),
|
|
1739
|
+
patch: Number(match[3]),
|
|
1740
|
+
prerelease: match[4] !== void 0
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// src/plugin/GitHub.ts
|
|
1745
|
+
var defaultRun2 = promisify3(nodeExecFile2);
|
|
1746
|
+
function gitHubAt(repository, cwd, deps = {}) {
|
|
1747
|
+
const execFile2 = deps.execFile ?? defaultRun2;
|
|
1748
|
+
const repoSlug = `${repository.owner}/${repository.repo}`;
|
|
1749
|
+
async function resolveRelease(requested) {
|
|
1750
|
+
if (requested !== void 0) {
|
|
1751
|
+
if (!parseReleaseTag(requested)) {
|
|
1752
|
+
return {
|
|
1753
|
+
ok: false,
|
|
1754
|
+
reason: "invalid-tag",
|
|
1755
|
+
message: `"${requested}" is not a valid release tag. Expected unprefixed SemVer (e.g. 1.2.3), matching the project's release-tag format.`
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
const exists2 = await releaseExists(requested);
|
|
1759
|
+
if (exists2.status === "error") {
|
|
1760
|
+
return {
|
|
1761
|
+
ok: false,
|
|
1762
|
+
reason: "lookup-failed",
|
|
1763
|
+
message: `Failed to look up release "${requested}" in ${repoSlug}: ${exists2.message}`
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
if (exists2.status === "not-found") {
|
|
1767
|
+
return {
|
|
1768
|
+
ok: false,
|
|
1769
|
+
reason: "not-found",
|
|
1770
|
+
message: `Release "${requested}" was not found in ${repoSlug}.`
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
return { ok: true, tag: requested };
|
|
1774
|
+
}
|
|
1775
|
+
const latest = await latestEligibleTag();
|
|
1776
|
+
if (!latest) {
|
|
1777
|
+
return {
|
|
1778
|
+
ok: false,
|
|
1779
|
+
reason: "no-eligible",
|
|
1780
|
+
message: `No eligible release tag found in ${repoSlug}.`
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
return { ok: true, tag: latest };
|
|
1784
|
+
}
|
|
1785
|
+
async function releaseExists(tag) {
|
|
1786
|
+
try {
|
|
1787
|
+
await execFile2(
|
|
1788
|
+
"gh",
|
|
1789
|
+
[
|
|
1790
|
+
"release",
|
|
1791
|
+
"view",
|
|
1792
|
+
tag,
|
|
1793
|
+
"--repo",
|
|
1794
|
+
repoSlug,
|
|
1795
|
+
"--json",
|
|
1796
|
+
"tagName"
|
|
1797
|
+
],
|
|
1798
|
+
{ cwd }
|
|
1799
|
+
);
|
|
1800
|
+
return { status: "exists" };
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
if (isNotFoundError(err)) {
|
|
1803
|
+
return { status: "not-found" };
|
|
1804
|
+
}
|
|
1805
|
+
return { status: "error", message: errorText(err) };
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
async function latestEligibleTag() {
|
|
1809
|
+
const { stdout } = await execFile2(
|
|
1810
|
+
"gh",
|
|
1811
|
+
[
|
|
1812
|
+
"release",
|
|
1813
|
+
"list",
|
|
1814
|
+
"--repo",
|
|
1815
|
+
repoSlug,
|
|
1816
|
+
"--exclude-drafts",
|
|
1817
|
+
"--exclude-pre-releases",
|
|
1818
|
+
"--order",
|
|
1819
|
+
"desc",
|
|
1820
|
+
"--limit",
|
|
1821
|
+
"1",
|
|
1822
|
+
"--json",
|
|
1823
|
+
"tagName"
|
|
1824
|
+
],
|
|
1825
|
+
{ cwd }
|
|
1826
|
+
);
|
|
1827
|
+
const releases = JSON.parse(stdout);
|
|
1828
|
+
const tag = releases[0]?.tagName;
|
|
1829
|
+
return tag && parseReleaseTag(tag) ? tag : void 0;
|
|
1830
|
+
}
|
|
1831
|
+
async function verifyAttestation(assetPath) {
|
|
1832
|
+
try {
|
|
1833
|
+
await execFile2(
|
|
1834
|
+
"gh",
|
|
1835
|
+
[
|
|
1836
|
+
"attestation",
|
|
1837
|
+
"verify",
|
|
1838
|
+
assetPath,
|
|
1839
|
+
"--repo",
|
|
1840
|
+
repoSlug,
|
|
1841
|
+
"--format",
|
|
1842
|
+
"json"
|
|
1843
|
+
],
|
|
1844
|
+
{ cwd }
|
|
1845
|
+
);
|
|
1846
|
+
return { ok: true };
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
if (isGhMissing(err)) {
|
|
1849
|
+
return {
|
|
1850
|
+
ok: false,
|
|
1851
|
+
reason: "gh-missing",
|
|
1852
|
+
message: "GitHub CLI (gh) is required to verify release attestations and was not found. Install gh and try again; attestation verification cannot be skipped."
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
return {
|
|
1856
|
+
ok: false,
|
|
1857
|
+
reason: "verification-failed",
|
|
1858
|
+
message: `Attestation verification failed for ${assetPath}: ${errorText(err)}`
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function checkFreshness(installedVersion) {
|
|
1863
|
+
let latestTag;
|
|
1864
|
+
try {
|
|
1865
|
+
latestTag = await latestEligibleTag();
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
return { status: "unknown", message: errorText(err) };
|
|
1868
|
+
}
|
|
1869
|
+
if (!latestTag) {
|
|
1870
|
+
return { status: "unknown" };
|
|
1871
|
+
}
|
|
1872
|
+
const latest = parseReleaseTag(latestTag);
|
|
1873
|
+
if (!latest) {
|
|
1874
|
+
return { status: "unknown" };
|
|
1875
|
+
}
|
|
1876
|
+
const installed = parseReleaseTag(installedVersion);
|
|
1877
|
+
if (!installed) {
|
|
1878
|
+
return { status: "unknown", latest: latestTag };
|
|
1879
|
+
}
|
|
1880
|
+
const isNewer = latest.major !== installed.major ? latest.major > installed.major : latest.minor !== installed.minor ? latest.minor > installed.minor : latest.patch > installed.patch;
|
|
1881
|
+
return isNewer ? { status: "update-available", latest: latestTag } : { status: "up-to-date" };
|
|
1882
|
+
}
|
|
1883
|
+
return { resolveRelease, verifyAttestation, checkFreshness };
|
|
1884
|
+
}
|
|
1885
|
+
function isGhMissing(err) {
|
|
1886
|
+
const code = err?.code;
|
|
1887
|
+
return code === "ENOENT";
|
|
1888
|
+
}
|
|
1889
|
+
function errorText(err) {
|
|
1890
|
+
if (err && typeof err === "object") {
|
|
1891
|
+
const stderr = err.stderr;
|
|
1892
|
+
if (stderr?.trim()) {
|
|
1893
|
+
return stderr.trim();
|
|
1894
|
+
}
|
|
1895
|
+
const message = err.message;
|
|
1896
|
+
if (message) {
|
|
1897
|
+
return message;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return String(err);
|
|
1901
|
+
}
|
|
1902
|
+
function isNotFoundError(err) {
|
|
1903
|
+
const code = err?.code;
|
|
1904
|
+
const stderr = err?.stderr ?? "";
|
|
1905
|
+
const message = err?.message ?? "";
|
|
1906
|
+
return code === "GH_NOT_FOUND" || /release not found/i.test(`${stderr}
|
|
1907
|
+
${message}`);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/plugin/Staging.ts
|
|
1911
|
+
import { execFile as nodeExecFile3 } from "node:child_process";
|
|
1912
|
+
import { createHash } from "node:crypto";
|
|
1913
|
+
import {
|
|
1914
|
+
mkdir as mkdir5,
|
|
1915
|
+
mkdtemp,
|
|
1916
|
+
readdir as readdir2,
|
|
1917
|
+
readFile as readFile6,
|
|
1918
|
+
readFile as readFileBuffer,
|
|
1919
|
+
rename,
|
|
1920
|
+
rm as rm2,
|
|
1921
|
+
writeFile as writeFile4
|
|
1922
|
+
} from "node:fs/promises";
|
|
1923
|
+
import { tmpdir } from "node:os";
|
|
1924
|
+
import { dirname, join as join8 } from "node:path";
|
|
1925
|
+
import { promisify as promisify4 } from "node:util";
|
|
1926
|
+
import yauzl from "yauzl";
|
|
1927
|
+
var defaultRun3 = promisify4(nodeExecFile3);
|
|
1928
|
+
function stagingAt(targetDir, repoSlug, cwd, deps = {}) {
|
|
1929
|
+
const execFile2 = deps.execFile ?? defaultRun3;
|
|
1930
|
+
const doRename = deps.rename ?? rename;
|
|
1931
|
+
const doRm = deps.rm ?? rm2;
|
|
1932
|
+
async function download(releaseTag) {
|
|
1933
|
+
const workDir = await mkdtemp(join8(tmpdir(), "qrspi-stage-"));
|
|
1934
|
+
const cleanup = () => rm2(workDir, { recursive: true, force: true });
|
|
1935
|
+
const archivePath = join8(workDir, "release.zip");
|
|
1936
|
+
const downloadResult = await downloadAsset(
|
|
1937
|
+
execFile2,
|
|
1938
|
+
repoSlug,
|
|
1939
|
+
cwd,
|
|
1940
|
+
releaseTag,
|
|
1941
|
+
archivePath
|
|
1942
|
+
);
|
|
1943
|
+
if (!downloadResult.ok) {
|
|
1944
|
+
await cleanup();
|
|
1945
|
+
return downloadResult;
|
|
1946
|
+
}
|
|
1947
|
+
return { ok: true, archivePath, cleanup };
|
|
1948
|
+
}
|
|
1949
|
+
async function extractAndCommit(archivePath, releaseTag) {
|
|
1950
|
+
const workDir = dirname(archivePath);
|
|
1951
|
+
try {
|
|
1952
|
+
const archiveBytes = await readFileBuffer(archivePath);
|
|
1953
|
+
const archiveHash = createHash("sha256").update(archiveBytes).digest("hex");
|
|
1954
|
+
const extractedDir = join8(workDir, "extracted");
|
|
1955
|
+
await mkdir5(extractedDir, { recursive: true });
|
|
1956
|
+
const extractResult = await extractZip(archivePath, extractedDir);
|
|
1957
|
+
if (!extractResult.ok) {
|
|
1958
|
+
return extractResult;
|
|
1959
|
+
}
|
|
1960
|
+
const manifestName = "qrspi-manifest.json";
|
|
1961
|
+
const manifestPath = join8(extractedDir, manifestName);
|
|
1962
|
+
let authorManifest;
|
|
1963
|
+
try {
|
|
1964
|
+
authorManifest = JSON.parse(
|
|
1965
|
+
await readFile6(manifestPath, "utf8")
|
|
1966
|
+
);
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
return {
|
|
1969
|
+
ok: false,
|
|
1970
|
+
reason: "invalid-manifest",
|
|
1971
|
+
message: `Release ${releaseTag} is missing a readable qrspi-manifest.json: ${errorText2(err)}`
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
const payloadPaths = await listTopLevelEntries(
|
|
1975
|
+
extractedDir,
|
|
1976
|
+
manifestName
|
|
1977
|
+
);
|
|
1978
|
+
const stamped = {
|
|
1979
|
+
...authorManifest,
|
|
1980
|
+
releaseTag,
|
|
1981
|
+
downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1982
|
+
archiveHash,
|
|
1983
|
+
payloadPaths
|
|
1984
|
+
};
|
|
1985
|
+
if (!isValidManifestShape(stamped)) {
|
|
1986
|
+
return {
|
|
1987
|
+
ok: false,
|
|
1988
|
+
reason: "invalid-manifest",
|
|
1989
|
+
message: `Release ${releaseTag}'s manifest is missing required fields after stamping installer metadata.`
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
await writeFile4(
|
|
1993
|
+
manifestPath,
|
|
1994
|
+
JSON.stringify(stamped, null, 4),
|
|
1995
|
+
"utf8"
|
|
1996
|
+
);
|
|
1997
|
+
await commit(extractedDir);
|
|
1998
|
+
return { ok: true, manifest: stamped };
|
|
1999
|
+
} finally {
|
|
2000
|
+
await rm2(workDir, { recursive: true, force: true });
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
async function commit(extractedDir) {
|
|
2004
|
+
await mkdir5(dirname(targetDir), { recursive: true });
|
|
2005
|
+
const backupDir = `${targetDir}.previous-${Date.now()}`;
|
|
2006
|
+
const stagedDir = `${targetDir}.incoming-${Date.now()}`;
|
|
2007
|
+
await doRename(extractedDir, stagedDir);
|
|
2008
|
+
let hadPrevious = true;
|
|
2009
|
+
try {
|
|
2010
|
+
await doRename(targetDir, backupDir);
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
if (!isNotFound(err)) {
|
|
2013
|
+
throw err;
|
|
2014
|
+
}
|
|
2015
|
+
hadPrevious = false;
|
|
2016
|
+
}
|
|
2017
|
+
try {
|
|
2018
|
+
await doRename(stagedDir, targetDir);
|
|
2019
|
+
} catch (err) {
|
|
2020
|
+
let cleanupErr;
|
|
2021
|
+
try {
|
|
2022
|
+
await doRm(stagedDir, { recursive: true, force: true });
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
cleanupErr = error;
|
|
2025
|
+
}
|
|
2026
|
+
if (hadPrevious) {
|
|
2027
|
+
try {
|
|
2028
|
+
await doRename(backupDir, targetDir);
|
|
2029
|
+
} catch (restoreErr) {
|
|
2030
|
+
const errors = [err, restoreErr];
|
|
2031
|
+
if (cleanupErr) errors.push(cleanupErr);
|
|
2032
|
+
throw new AggregateError(
|
|
2033
|
+
errors,
|
|
2034
|
+
"Failed to install the staged plugin and restore the previous install"
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (cleanupErr) {
|
|
2039
|
+
throw new AggregateError(
|
|
2040
|
+
[err, cleanupErr],
|
|
2041
|
+
"Failed to install the staged plugin and clean up its temporary directory"
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
throw err;
|
|
2045
|
+
}
|
|
2046
|
+
if (hadPrevious) {
|
|
2047
|
+
await doRm(backupDir, { recursive: true, force: true });
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return { download, extractAndCommit };
|
|
2051
|
+
}
|
|
2052
|
+
async function downloadAsset(execFile2, repoSlug, cwd, releaseTag, destPath) {
|
|
2053
|
+
try {
|
|
2054
|
+
await execFile2(
|
|
2055
|
+
"gh",
|
|
2056
|
+
[
|
|
2057
|
+
"release",
|
|
2058
|
+
"download",
|
|
2059
|
+
releaseTag,
|
|
2060
|
+
"--repo",
|
|
2061
|
+
repoSlug,
|
|
2062
|
+
"--pattern",
|
|
2063
|
+
"*.zip",
|
|
2064
|
+
"--output",
|
|
2065
|
+
destPath,
|
|
2066
|
+
"--clobber"
|
|
2067
|
+
],
|
|
2068
|
+
{ cwd }
|
|
2069
|
+
);
|
|
2070
|
+
return { ok: true };
|
|
2071
|
+
} catch (err) {
|
|
2072
|
+
return {
|
|
2073
|
+
ok: false,
|
|
2074
|
+
reason: "download-failed",
|
|
2075
|
+
message: `Failed to download release ${releaseTag} from ${repoSlug}: ${errorText2(err)}`
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
async function listTopLevelEntries(extractedDir, exclude) {
|
|
2080
|
+
const entries = await readdir2(extractedDir);
|
|
2081
|
+
return entries.filter((entry) => entry !== exclude).sort();
|
|
2082
|
+
}
|
|
2083
|
+
async function extractZip(archivePath, destDir) {
|
|
2084
|
+
try {
|
|
2085
|
+
const zipfile = await yauzl.openPromise(archivePath, {
|
|
2086
|
+
lazyEntries: false
|
|
2087
|
+
});
|
|
2088
|
+
try {
|
|
2089
|
+
for await (const entry of zipfile.eachEntry()) {
|
|
2090
|
+
const invalid = yauzl.validateFileName(entry.fileName);
|
|
2091
|
+
if (invalid) {
|
|
2092
|
+
throw new Error(
|
|
2093
|
+
`Unsafe path in release archive: ${entry.fileName} (${invalid})`
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
const entryPath = join8(destDir, entry.fileName);
|
|
2097
|
+
if (entry.fileName.endsWith("/")) {
|
|
2098
|
+
await mkdir5(entryPath, { recursive: true });
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
await mkdir5(dirname(entryPath), { recursive: true });
|
|
2102
|
+
const readStream = await zipfile.openReadStreamPromise(entry);
|
|
2103
|
+
await writeStreamToFile(readStream, entryPath);
|
|
2104
|
+
}
|
|
2105
|
+
} finally {
|
|
2106
|
+
zipfile.close();
|
|
2107
|
+
}
|
|
2108
|
+
return { ok: true };
|
|
2109
|
+
} catch (err) {
|
|
2110
|
+
return {
|
|
2111
|
+
ok: false,
|
|
2112
|
+
reason: "extraction-failed",
|
|
2113
|
+
message: `Failed to extract release archive: ${errorText2(err)}`
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
function writeStreamToFile(readStream, destPath) {
|
|
2118
|
+
return new Promise((resolve2, reject) => {
|
|
2119
|
+
const chunks = [];
|
|
2120
|
+
readStream.on("data", (chunk) => chunks.push(chunk));
|
|
2121
|
+
readStream.on("end", async () => {
|
|
2122
|
+
try {
|
|
2123
|
+
await writeFile4(destPath, Buffer.concat(chunks));
|
|
2124
|
+
resolve2();
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
reject(err);
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
readStream.on("error", reject);
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
function errorText2(err) {
|
|
2133
|
+
if (err && typeof err === "object") {
|
|
2134
|
+
const stderr = err.stderr;
|
|
2135
|
+
if (stderr?.trim()) {
|
|
2136
|
+
return stderr.trim();
|
|
2137
|
+
}
|
|
2138
|
+
const message = err.message;
|
|
2139
|
+
if (message) {
|
|
2140
|
+
return message;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return String(err);
|
|
2144
|
+
}
|
|
2145
|
+
function isNotFound(err) {
|
|
2146
|
+
return err?.code === "ENOENT";
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// src/plugin/Target.ts
|
|
2150
|
+
import {
|
|
2151
|
+
cp,
|
|
2152
|
+
lstat,
|
|
2153
|
+
mkdir as mkdir6,
|
|
2154
|
+
readdir as readdir3,
|
|
2155
|
+
readFile as readFile7,
|
|
2156
|
+
readlink,
|
|
2157
|
+
rename as rename2,
|
|
2158
|
+
rm as rm3,
|
|
2159
|
+
symlink
|
|
2160
|
+
} from "node:fs/promises";
|
|
2161
|
+
import { homedir } from "node:os";
|
|
2162
|
+
import { dirname as dirname2, join as join9, resolve } from "node:path";
|
|
2163
|
+
var AGENT_TARGETS = {
|
|
2164
|
+
claude: (home) => join9(home, ".claude", "skills", "qrspi-x"),
|
|
2165
|
+
codex: (home) => join9(home, ".agents", "skills", "qrspi-x"),
|
|
2166
|
+
bob: (home) => join9(home, ".bob", "plugins", "qrspi-x")
|
|
2167
|
+
};
|
|
2168
|
+
var AGENT_NAMES = Object.keys(AGENT_TARGETS);
|
|
2169
|
+
function isAgentName(value) {
|
|
2170
|
+
return Object.hasOwn(AGENT_TARGETS, value);
|
|
2171
|
+
}
|
|
2172
|
+
function targetPathFor(agent, home = homedir()) {
|
|
2173
|
+
return AGENT_TARGETS[agent](home);
|
|
2174
|
+
}
|
|
2175
|
+
async function probeTarget(targetDir, installDir) {
|
|
2176
|
+
let stats;
|
|
2177
|
+
try {
|
|
2178
|
+
stats = await lstat(targetDir);
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
if (!isNotFound2(err)) {
|
|
2181
|
+
return { state: "error", message: errorText3(err) };
|
|
2182
|
+
}
|
|
2183
|
+
return { state: "absent" };
|
|
2184
|
+
}
|
|
2185
|
+
if (stats.isSymbolicLink()) {
|
|
2186
|
+
let resolved;
|
|
2187
|
+
try {
|
|
2188
|
+
resolved = resolve(dirname2(targetDir), await readlink(targetDir));
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
return { state: "error", message: errorText3(err) };
|
|
2191
|
+
}
|
|
2192
|
+
if (resolved !== resolve(installDir)) {
|
|
2193
|
+
return {
|
|
2194
|
+
state: "unmanaged",
|
|
2195
|
+
reason: `symlink resolves to ${resolved}, not ${installDir}`
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
try {
|
|
2199
|
+
await lstat(resolved);
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
if (!isNotFound2(err)) {
|
|
2202
|
+
return { state: "error", message: errorText3(err) };
|
|
2203
|
+
}
|
|
2204
|
+
return { state: "unmanaged", reason: "dangling symlink" };
|
|
2205
|
+
}
|
|
2206
|
+
return { state: "managed", mode: "symlink" };
|
|
2207
|
+
}
|
|
2208
|
+
if (!stats.isDirectory()) {
|
|
2209
|
+
return { state: "unmanaged", reason: "a file, not a directory" };
|
|
2210
|
+
}
|
|
2211
|
+
const manifestPath = join9(targetDir, "qrspi-manifest.json");
|
|
2212
|
+
let manifestText;
|
|
2213
|
+
try {
|
|
2214
|
+
manifestText = await readFile7(manifestPath, "utf8");
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
if (!isNotFound2(err)) {
|
|
2217
|
+
return { state: "error", message: errorText3(err) };
|
|
2218
|
+
}
|
|
2219
|
+
return { state: "unmanaged", reason: "no manifest found" };
|
|
2220
|
+
}
|
|
2221
|
+
const manifest = parseManifest(manifestText);
|
|
2222
|
+
if (!manifest) {
|
|
2223
|
+
return { state: "unmanaged", reason: "manifest is invalid" };
|
|
2224
|
+
}
|
|
2225
|
+
for (const payloadPath of manifest.payloadPaths) {
|
|
2226
|
+
try {
|
|
2227
|
+
await lstat(join9(targetDir, payloadPath));
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
if (!isNotFound2(err)) {
|
|
2230
|
+
return { state: "error", message: errorText3(err) };
|
|
2231
|
+
}
|
|
2232
|
+
return {
|
|
2233
|
+
state: "unmanaged",
|
|
2234
|
+
reason: `manifest-declared path "${payloadPath}" is missing`
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
return { state: "managed", mode: "copy", manifest };
|
|
2239
|
+
}
|
|
2240
|
+
function isNotFound2(err) {
|
|
2241
|
+
return err?.code === "ENOENT";
|
|
2242
|
+
}
|
|
2243
|
+
function isCurrent(probe, centralManifest) {
|
|
2244
|
+
if (probe.state !== "managed") {
|
|
2245
|
+
return false;
|
|
2246
|
+
}
|
|
2247
|
+
if (probe.mode === "symlink") {
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2250
|
+
return isCurrentWith(probe.manifest, centralManifest);
|
|
2251
|
+
}
|
|
2252
|
+
async function placeTarget(targetDir, installDir, requested, deps = {}) {
|
|
2253
|
+
const doSymlink = deps.symlink ?? symlink;
|
|
2254
|
+
const doCopy = deps.cp ?? cp;
|
|
2255
|
+
const doRename = deps.rename ?? rename2;
|
|
2256
|
+
const doRm = deps.rm ?? rm3;
|
|
2257
|
+
async function copy() {
|
|
2258
|
+
await mkdir6(dirname2(targetDir), { recursive: true });
|
|
2259
|
+
const tempPath = `${targetDir}.qrspi-incoming-${Date.now()}`;
|
|
2260
|
+
await doCopy(installDir, tempPath, { recursive: true });
|
|
2261
|
+
const backupPath = `${targetDir}.qrspi-previous-${Date.now()}`;
|
|
2262
|
+
let hadPrevious = true;
|
|
2263
|
+
try {
|
|
2264
|
+
await doRename(targetDir, backupPath);
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
if (!isNotFound2(err)) {
|
|
2267
|
+
throw err;
|
|
2268
|
+
}
|
|
2269
|
+
hadPrevious = false;
|
|
2270
|
+
}
|
|
2271
|
+
try {
|
|
2272
|
+
await doRename(tempPath, targetDir);
|
|
2273
|
+
} catch (err) {
|
|
2274
|
+
await doRm(tempPath, { recursive: true, force: true });
|
|
2275
|
+
if (hadPrevious) {
|
|
2276
|
+
await doRename(backupPath, targetDir);
|
|
2277
|
+
}
|
|
2278
|
+
throw err;
|
|
2279
|
+
}
|
|
2280
|
+
if (hadPrevious) {
|
|
2281
|
+
await doRm(backupPath, { recursive: true, force: true });
|
|
2282
|
+
}
|
|
2283
|
+
return { ok: true, mode: "copy" };
|
|
2284
|
+
}
|
|
2285
|
+
async function link() {
|
|
2286
|
+
await mkdir6(dirname2(targetDir), { recursive: true });
|
|
2287
|
+
const tempPath = `${targetDir}.qrspi-incoming-${Date.now()}`;
|
|
2288
|
+
await doSymlink(installDir, tempPath);
|
|
2289
|
+
const replacingDirectory = await isExistingDirectory(targetDir);
|
|
2290
|
+
if (!replacingDirectory) {
|
|
2291
|
+
try {
|
|
2292
|
+
await rename2(tempPath, targetDir);
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
await rm3(tempPath, { force: true });
|
|
2295
|
+
throw err;
|
|
2296
|
+
}
|
|
2297
|
+
return { ok: true, mode: "symlink" };
|
|
2298
|
+
}
|
|
2299
|
+
const backupPath = `${targetDir}.qrspi-previous-${Date.now()}`;
|
|
2300
|
+
await rename2(targetDir, backupPath);
|
|
2301
|
+
try {
|
|
2302
|
+
await rename2(tempPath, targetDir);
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
await rm3(tempPath, { force: true });
|
|
2305
|
+
await rename2(backupPath, targetDir);
|
|
2306
|
+
throw err;
|
|
2307
|
+
}
|
|
2308
|
+
await rm3(backupPath, { recursive: true, force: true });
|
|
2309
|
+
return { ok: true, mode: "symlink" };
|
|
2310
|
+
}
|
|
2311
|
+
if (requested === "copy") {
|
|
2312
|
+
return copy();
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
return await link();
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
if (!isEperm(err)) {
|
|
2318
|
+
throw err;
|
|
2319
|
+
}
|
|
2320
|
+
if (requested === "symlink") {
|
|
2321
|
+
return {
|
|
2322
|
+
ok: false,
|
|
2323
|
+
reason: "symlink-unavailable",
|
|
2324
|
+
message: `Cannot create a symlink at ${targetDir}: ${errorText3(err)}`
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
return copy();
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
function isEperm(err) {
|
|
2331
|
+
return err?.code === "EPERM";
|
|
2332
|
+
}
|
|
2333
|
+
async function isExistingDirectory(path) {
|
|
2334
|
+
try {
|
|
2335
|
+
const stats = await lstat(path);
|
|
2336
|
+
return stats.isDirectory();
|
|
2337
|
+
} catch {
|
|
2338
|
+
return false;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
async function clearUnmanagedTarget(targetDir) {
|
|
2342
|
+
await rm3(targetDir, { recursive: true, force: true });
|
|
2343
|
+
}
|
|
2344
|
+
var MANIFEST_FILE_NAME = "qrspi-manifest.json";
|
|
2345
|
+
async function removeTarget(targetDir, probe, force) {
|
|
2346
|
+
if (probe.mode === "symlink") {
|
|
2347
|
+
await rm3(targetDir, { force: true });
|
|
2348
|
+
return { ok: true };
|
|
2349
|
+
}
|
|
2350
|
+
const declared = /* @__PURE__ */ new Set([
|
|
2351
|
+
...probe.manifest.payloadPaths,
|
|
2352
|
+
MANIFEST_FILE_NAME
|
|
2353
|
+
]);
|
|
2354
|
+
const entries = await readdir3(targetDir);
|
|
2355
|
+
const extraPaths = entries.filter((entry) => !declared.has(entry));
|
|
2356
|
+
if (extraPaths.length > 0 && !force) {
|
|
2357
|
+
return { ok: false, reason: "blocked-by-extras", extraPaths };
|
|
2358
|
+
}
|
|
2359
|
+
for (const entry of entries) {
|
|
2360
|
+
if (declared.has(entry)) {
|
|
2361
|
+
await rm3(join9(targetDir, entry), { recursive: true, force: true });
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (extraPaths.length === 0) {
|
|
2365
|
+
await rm3(targetDir, { recursive: true, force: true });
|
|
2366
|
+
}
|
|
2367
|
+
return { ok: true };
|
|
2368
|
+
}
|
|
2369
|
+
function errorText3(err) {
|
|
2370
|
+
return err instanceof Error ? err.message : String(err);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// src/command/plugin.ts
|
|
2374
|
+
var defaultRun4 = promisify5(nodeExecFile4);
|
|
2375
|
+
var QRSPI_X_REPO = {
|
|
2376
|
+
owner: "ebullient",
|
|
2377
|
+
repo: "qrspi-x"
|
|
2378
|
+
};
|
|
2379
|
+
var releaseFlag = {
|
|
2380
|
+
flag: "--release <tag>",
|
|
2381
|
+
required: false,
|
|
2382
|
+
description: "Install this release tag instead of GitHub's own latest-eligible release."
|
|
2383
|
+
};
|
|
2384
|
+
var installHelp = {
|
|
2385
|
+
name: "install",
|
|
2386
|
+
summary: "Download, verify, and stage a release into ~/.qrspi/plugin, replacing any existing content.",
|
|
2387
|
+
flags: [releaseFlag],
|
|
2388
|
+
example: "qrspi-x plugin install --release 1.2.3"
|
|
2389
|
+
};
|
|
2390
|
+
var initHelp = {
|
|
2391
|
+
name: "init",
|
|
2392
|
+
summary: "Place the installed plugin content at one agent's fixed target.",
|
|
2393
|
+
flags: [
|
|
2394
|
+
{
|
|
2395
|
+
flag: "--agent <claude|codex|bob>",
|
|
2396
|
+
required: true,
|
|
2397
|
+
description: "Which agent's fixed target to place content at."
|
|
2398
|
+
},
|
|
2399
|
+
{
|
|
2400
|
+
flag: "--copy",
|
|
2401
|
+
required: false,
|
|
2402
|
+
description: "Force copy mode. Mutually exclusive with --symlink."
|
|
2403
|
+
},
|
|
2404
|
+
{
|
|
2405
|
+
flag: "--symlink",
|
|
2406
|
+
required: false,
|
|
2407
|
+
description: "Force symlink mode, failing cleanly if unavailable rather than falling back. Mutually exclusive with --copy."
|
|
2408
|
+
},
|
|
2409
|
+
{
|
|
2410
|
+
flag: "--force",
|
|
2411
|
+
required: false,
|
|
2412
|
+
description: "Don't refuse an unmanaged (foreign) target; delete it and place fresh content there instead, for the one named agent. No effect on an absent or already-managed target."
|
|
2413
|
+
}
|
|
2414
|
+
],
|
|
2415
|
+
example: "qrspi-x plugin init --agent claude"
|
|
2416
|
+
};
|
|
2417
|
+
var statusHelp = {
|
|
2418
|
+
name: "status",
|
|
2419
|
+
summary: "Report the installed version, each agent's placement, and GitHub release freshness. Makes no changes.",
|
|
2420
|
+
flags: [],
|
|
2421
|
+
example: "qrspi-x plugin status"
|
|
2422
|
+
};
|
|
2423
|
+
var updateHelp = {
|
|
2424
|
+
name: "update",
|
|
2425
|
+
summary: "Re-run install, then re-place every initialized agent not already current with the freshly staged release.",
|
|
2426
|
+
flags: [
|
|
2427
|
+
releaseFlag,
|
|
2428
|
+
{
|
|
2429
|
+
flag: "--force",
|
|
2430
|
+
required: false,
|
|
2431
|
+
description: "Don't refuse an unmanaged (foreign) target; delete it and place fresh content there instead, for each unmanaged agent found during the sweep. No effect on an absent or already-current agent."
|
|
2432
|
+
}
|
|
2433
|
+
],
|
|
2434
|
+
example: "qrspi-x plugin update"
|
|
2435
|
+
};
|
|
2436
|
+
var removeHelp = {
|
|
2437
|
+
name: "remove",
|
|
2438
|
+
summary: "Remove one agent's placed files or symlink from its fixed target. Never touches ~/.qrspi/plugin or any other agent.",
|
|
2439
|
+
flags: [
|
|
2440
|
+
{
|
|
2441
|
+
flag: "--agent <claude|codex|bob>",
|
|
2442
|
+
required: true,
|
|
2443
|
+
description: "Which agent's fixed target to remove."
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
flag: "--force",
|
|
2447
|
+
required: false,
|
|
2448
|
+
description: "Don't refuse an unmanaged (foreign) target; delete it instead, for this one agent, with nothing placed afterward. Separately, copy mode only: also proceed when the target directory contains paths beyond the manifest-declared payload, removing only those declared paths and leaving the extras in place. No effect in symlink mode."
|
|
2449
|
+
}
|
|
2450
|
+
],
|
|
2451
|
+
example: "qrspi-x plugin remove --agent claude"
|
|
2452
|
+
};
|
|
2453
|
+
var help7 = {
|
|
2454
|
+
name: "plugin",
|
|
2455
|
+
summary: "Human-invoked only: install/init/update/status/remove the central plugin payload and agent targets.",
|
|
2456
|
+
actions: [installHelp, initHelp, statusHelp, updateHelp, removeHelp]
|
|
2457
|
+
};
|
|
2458
|
+
async function runInstall(opts, services = {}) {
|
|
2459
|
+
const execFile2 = services.execFile ?? defaultRun4;
|
|
2460
|
+
const home = services.homeDir ?? homedir2();
|
|
2461
|
+
const targetDir = join10(home, ".qrspi", "plugin");
|
|
2462
|
+
const repoSlug = `${QRSPI_X_REPO.owner}/${QRSPI_X_REPO.repo}`;
|
|
2463
|
+
const github = services.github ?? gitHubAt(QRSPI_X_REPO, home, { execFile: execFile2 });
|
|
2464
|
+
const staging = services.staging ?? stagingAt(targetDir, repoSlug, home, { execFile: execFile2 });
|
|
2465
|
+
const resolved = await github.resolveRelease(opts.release);
|
|
2466
|
+
if (!resolved.ok) {
|
|
2467
|
+
return fail(resolved.reason, resolved.message);
|
|
2468
|
+
}
|
|
2469
|
+
const downloadResult = await staging.download(resolved.tag);
|
|
2470
|
+
if (!downloadResult.ok) {
|
|
2471
|
+
return fail(downloadResult.reason, downloadResult.message);
|
|
2472
|
+
}
|
|
2473
|
+
const attestation = await github.verifyAttestation(
|
|
2474
|
+
downloadResult.archivePath
|
|
2475
|
+
);
|
|
2476
|
+
if (!attestation.ok) {
|
|
2477
|
+
await downloadResult.cleanup();
|
|
2478
|
+
return fail(attestation.reason, attestation.message);
|
|
2479
|
+
}
|
|
2480
|
+
const staged = await staging.extractAndCommit(
|
|
2481
|
+
downloadResult.archivePath,
|
|
2482
|
+
resolved.tag
|
|
2483
|
+
);
|
|
2484
|
+
if (!staged.ok) {
|
|
2485
|
+
return fail(staged.reason, staged.message);
|
|
2486
|
+
}
|
|
2487
|
+
return ok({
|
|
2488
|
+
release: resolved.tag,
|
|
2489
|
+
version: staged.manifest.version,
|
|
2490
|
+
installedAt: targetDir,
|
|
2491
|
+
text: `Installed release ${resolved.tag} into ${targetDir}.`
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
async function runInit(opts, services = {}) {
|
|
2495
|
+
if (opts.copy && opts.symlink) {
|
|
2496
|
+
const message = "--copy and --symlink are mutually exclusive.";
|
|
2497
|
+
return fail("conflicting-mode", message);
|
|
2498
|
+
}
|
|
2499
|
+
if (!isAgentName(opts.agent)) {
|
|
2500
|
+
const message = `Unknown agent "${opts.agent}". Supported agents: ${AGENT_NAMES.join(", ")}.`;
|
|
2501
|
+
return fail("unknown-agent", message);
|
|
2502
|
+
}
|
|
2503
|
+
const agent = opts.agent;
|
|
2504
|
+
const home = services.homeDir ?? homedir2();
|
|
2505
|
+
const installDir = join10(home, ".qrspi", "plugin");
|
|
2506
|
+
const installCheck = await checkInstall(installDir);
|
|
2507
|
+
if (!installCheck.ok) {
|
|
2508
|
+
const message = installCheck.reason === "not-installed" ? `No plugin install found at ${installDir}. Run "qrspi-x plugin install" first.` : `Could not read ${installDir}: ${installCheck.message}`;
|
|
2509
|
+
return fail(installCheck.reason, message);
|
|
2510
|
+
}
|
|
2511
|
+
const inspection = await inspectAgent(agent, home, installDir);
|
|
2512
|
+
if ("error" in inspection) {
|
|
2513
|
+
return inspection.error;
|
|
2514
|
+
}
|
|
2515
|
+
const { targetDir, probe } = inspection;
|
|
2516
|
+
if (probe.state === "unmanaged" && !opts.force) {
|
|
2517
|
+
const message = `${targetDir} is not a QRSPI-X-managed target (${probe.reason}). Refusing to touch it. Re-run with --force to delete it and place fresh content there.`;
|
|
2518
|
+
return fail("unmanaged-target", message);
|
|
2519
|
+
}
|
|
2520
|
+
if (probe.state === "unmanaged" && opts.force) {
|
|
2521
|
+
const clearUnmanaged = services.clearUnmanaged ?? clearUnmanagedTarget;
|
|
2522
|
+
try {
|
|
2523
|
+
await clearUnmanaged(targetDir);
|
|
2524
|
+
} catch (err) {
|
|
2525
|
+
const message = `Could not clear unmanaged target ${targetDir}: ${errorText4(err)}`;
|
|
2526
|
+
return fail("cleanup-failed", message);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
const requested = opts.copy ? "copy" : opts.symlink ? "symlink" : probe.state === "managed" ? probe.mode : void 0;
|
|
2530
|
+
const place = services.place ?? placeTarget;
|
|
2531
|
+
const placed = await place(targetDir, installDir, requested);
|
|
2532
|
+
if (!placed.ok) {
|
|
2533
|
+
return fail(placed.reason, placed.message);
|
|
2534
|
+
}
|
|
2535
|
+
return ok({
|
|
2536
|
+
agent,
|
|
2537
|
+
mode: placed.mode,
|
|
2538
|
+
target: targetDir,
|
|
2539
|
+
text: `Placed ${agent}'s plugin content at ${targetDir} (${placed.mode} mode).`
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
async function runRemove(opts, services = {}) {
|
|
2543
|
+
if (!isAgentName(opts.agent)) {
|
|
2544
|
+
const message = `Unknown agent "${opts.agent}". Supported agents: ${AGENT_NAMES.join(", ")}.`;
|
|
2545
|
+
return fail("unknown-agent", message);
|
|
2546
|
+
}
|
|
2547
|
+
const agent = opts.agent;
|
|
2548
|
+
const home = services.homeDir ?? homedir2();
|
|
2549
|
+
const installDir = join10(home, ".qrspi", "plugin");
|
|
2550
|
+
const inspection = await inspectAgent(agent, home, installDir);
|
|
2551
|
+
if ("error" in inspection) {
|
|
2552
|
+
return inspection.error;
|
|
2553
|
+
}
|
|
2554
|
+
const { targetDir, probe } = inspection;
|
|
2555
|
+
if (probe.state === "absent") {
|
|
2556
|
+
return ok({
|
|
2557
|
+
agent,
|
|
2558
|
+
removed: false,
|
|
2559
|
+
text: `${agent} is not installed at ${targetDir}. Nothing to do.`
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
if (probe.state === "unmanaged") {
|
|
2563
|
+
if (!opts.force) {
|
|
2564
|
+
const message = `${targetDir} is not a QRSPI-X-managed target (${probe.reason}). Refusing to touch it. Re-run with --force to delete it.`;
|
|
2565
|
+
return fail("unmanaged-target", message);
|
|
2566
|
+
}
|
|
2567
|
+
const clearUnmanaged = services.clearUnmanaged ?? clearUnmanagedTarget;
|
|
2568
|
+
try {
|
|
2569
|
+
await clearUnmanaged(targetDir);
|
|
2570
|
+
} catch (err) {
|
|
2571
|
+
const message = `Could not clear unmanaged target ${targetDir}: ${errorText4(err)}`;
|
|
2572
|
+
return fail("cleanup-failed", message);
|
|
2573
|
+
}
|
|
2574
|
+
return ok({
|
|
2575
|
+
agent,
|
|
2576
|
+
removed: true,
|
|
2577
|
+
target: targetDir,
|
|
2578
|
+
text: `Removed ${agent}'s unmanaged content from ${targetDir}.`
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
const remove = services.remove ?? removeTarget;
|
|
2582
|
+
const removed = await remove(targetDir, probe, opts.force === true);
|
|
2583
|
+
if (!removed.ok) {
|
|
2584
|
+
const message = `${targetDir} contains paths not placed by init: ${removed.extraPaths.join(", ")}. Re-run with --force to remove only the plugin's own paths and leave these in place.`;
|
|
2585
|
+
return fail(removed.reason, message);
|
|
2586
|
+
}
|
|
2587
|
+
return ok({
|
|
2588
|
+
agent,
|
|
2589
|
+
removed: true,
|
|
2590
|
+
target: targetDir,
|
|
2591
|
+
text: `Removed ${agent}'s plugin content from ${targetDir}.`
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
async function inspectAgent(agent, home, installDir) {
|
|
2595
|
+
const targetDir = targetPathFor(agent, home);
|
|
2596
|
+
const probe = await probeTarget(targetDir, installDir);
|
|
2597
|
+
if (probe.state === "error") {
|
|
2598
|
+
const message = `Could not probe ${targetDir}: ${probe.message}`;
|
|
2599
|
+
return { targetDir, error: fail("probe-failed", message) };
|
|
2600
|
+
}
|
|
2601
|
+
return { targetDir, probe };
|
|
2602
|
+
}
|
|
2603
|
+
async function placeForUpdate(place, targetDir, installDir, requested) {
|
|
2604
|
+
try {
|
|
2605
|
+
return await place(targetDir, installDir, requested);
|
|
2606
|
+
} catch (err) {
|
|
2607
|
+
return {
|
|
2608
|
+
ok: false,
|
|
2609
|
+
reason: "placement-failed",
|
|
2610
|
+
message: errorText4(err)
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
async function runUpdate(opts, services = {}) {
|
|
2615
|
+
const home = services.homeDir ?? homedir2();
|
|
2616
|
+
const installDir = join10(home, ".qrspi", "plugin");
|
|
2617
|
+
const installResult = await runInstall(
|
|
2618
|
+
{ release: opts.release },
|
|
2619
|
+
{
|
|
2620
|
+
execFile: services.execFile,
|
|
2621
|
+
homeDir: home,
|
|
2622
|
+
github: services.github,
|
|
2623
|
+
staging: services.staging
|
|
2624
|
+
}
|
|
2625
|
+
);
|
|
2626
|
+
if (installResult.exitCode !== 0) {
|
|
2627
|
+
return installResult;
|
|
2628
|
+
}
|
|
2629
|
+
const manifestPath = join10(installDir, "qrspi-manifest.json");
|
|
2630
|
+
let centralManifest;
|
|
2631
|
+
try {
|
|
2632
|
+
const manifestText = await readFile8(manifestPath, "utf8");
|
|
2633
|
+
const parsed = parseManifest(manifestText);
|
|
2634
|
+
if (!parsed) {
|
|
2635
|
+
const message = `Freshly staged manifest at ${manifestPath} is invalid.`;
|
|
2636
|
+
return fail("invalid-manifest", message);
|
|
2637
|
+
}
|
|
2638
|
+
centralManifest = parsed;
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
const message = `Could not read ${manifestPath}: ${errorText4(err)}`;
|
|
2641
|
+
return fail("manifest-read-failed", message);
|
|
2642
|
+
}
|
|
2643
|
+
const place = services.place ?? placeTarget;
|
|
2644
|
+
const results = [];
|
|
2645
|
+
const processedAgents = /* @__PURE__ */ new Set();
|
|
2646
|
+
let stoppedEarly = false;
|
|
2647
|
+
const inspections = await Promise.all(
|
|
2648
|
+
AGENT_NAMES.map((agent) => inspectAgent(agent, home, installDir))
|
|
2649
|
+
);
|
|
2650
|
+
for (const [index, inspection] of inspections.entries()) {
|
|
2651
|
+
if (stoppedEarly) {
|
|
2652
|
+
break;
|
|
2653
|
+
}
|
|
2654
|
+
const agent = AGENT_NAMES[index];
|
|
2655
|
+
processedAgents.add(agent);
|
|
2656
|
+
if ("error" in inspection) {
|
|
2657
|
+
return inspection.error;
|
|
2658
|
+
}
|
|
2659
|
+
const { targetDir, probe } = inspection;
|
|
2660
|
+
if (probe.state === "absent") {
|
|
2661
|
+
results.push({ agent, outcome: "skipped-not-initialized" });
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
if (probe.state === "unmanaged") {
|
|
2665
|
+
if (!opts.force) {
|
|
2666
|
+
results.push({
|
|
2667
|
+
agent,
|
|
2668
|
+
outcome: "failed",
|
|
2669
|
+
reason: "unmanaged-target",
|
|
2670
|
+
message: `${targetDir} is not a QRSPI-X-managed target (${probe.reason}). Refusing to touch it. Re-run with --force to delete it and place fresh content there.`
|
|
2671
|
+
});
|
|
2672
|
+
continue;
|
|
2673
|
+
}
|
|
2674
|
+
try {
|
|
2675
|
+
const clearUnmanaged = services.clearUnmanaged ?? clearUnmanagedTarget;
|
|
2676
|
+
await clearUnmanaged(targetDir);
|
|
2677
|
+
} catch (err) {
|
|
2678
|
+
results.push({
|
|
2679
|
+
agent,
|
|
2680
|
+
outcome: "failed",
|
|
2681
|
+
reason: "placement-failed",
|
|
2682
|
+
message: errorText4(err)
|
|
2683
|
+
});
|
|
2684
|
+
stoppedEarly = true;
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
} else if (isCurrent(probe, centralManifest)) {
|
|
2688
|
+
results.push({
|
|
2689
|
+
agent,
|
|
2690
|
+
outcome: "already-current",
|
|
2691
|
+
mode: probe.mode
|
|
2692
|
+
});
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
const requested = probe.state === "unmanaged" ? void 0 : probe.mode;
|
|
2696
|
+
const placed = await placeForUpdate(
|
|
2697
|
+
place,
|
|
2698
|
+
targetDir,
|
|
2699
|
+
installDir,
|
|
2700
|
+
requested
|
|
2701
|
+
);
|
|
2702
|
+
if (!placed.ok) {
|
|
2703
|
+
results.push({
|
|
2704
|
+
agent,
|
|
2705
|
+
outcome: "failed",
|
|
2706
|
+
reason: placed.reason,
|
|
2707
|
+
message: placed.message
|
|
2708
|
+
});
|
|
2709
|
+
stoppedEarly = true;
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
results.push({ agent, outcome: "updated", mode: placed.mode });
|
|
2713
|
+
}
|
|
2714
|
+
const notReached = AGENT_NAMES.filter(
|
|
2715
|
+
(agent) => !processedAgents.has(agent)
|
|
2716
|
+
).map(
|
|
2717
|
+
(agent) => ({
|
|
2718
|
+
agent,
|
|
2719
|
+
outcome: "failed",
|
|
2720
|
+
reason: "not-reached",
|
|
2721
|
+
message: "Not reached: an earlier agent's update failed."
|
|
2722
|
+
})
|
|
2723
|
+
);
|
|
2724
|
+
const allResults = [...results, ...notReached];
|
|
2725
|
+
const hasFailure = allResults.some((r) => r.outcome === "failed");
|
|
2726
|
+
return {
|
|
2727
|
+
exitCode: hasFailure ? 1 : 0,
|
|
2728
|
+
release: installResult.release,
|
|
2729
|
+
version: installResult.version,
|
|
2730
|
+
agents: allResults,
|
|
2731
|
+
text: updateSummaryText(installResult, allResults)
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
function updateSummaryText(installResult, results) {
|
|
2735
|
+
const parts = [String(installResult.text ?? "")];
|
|
2736
|
+
for (const r of results) {
|
|
2737
|
+
switch (r.outcome) {
|
|
2738
|
+
case "updated":
|
|
2739
|
+
parts.push(`${r.agent}: updated (${r.mode} mode).`);
|
|
2740
|
+
break;
|
|
2741
|
+
case "already-current":
|
|
2742
|
+
parts.push(`${r.agent}: already current.`);
|
|
2743
|
+
break;
|
|
2744
|
+
case "skipped-not-initialized":
|
|
2745
|
+
parts.push(`${r.agent}: not initialized, skipped.`);
|
|
2746
|
+
break;
|
|
2747
|
+
case "failed":
|
|
2748
|
+
parts.push(`${r.agent}: failed (${r.reason}).`);
|
|
2749
|
+
break;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return parts.join(" ");
|
|
2753
|
+
}
|
|
2754
|
+
async function runStatus(services = {}) {
|
|
2755
|
+
const execFile2 = services.execFile ?? defaultRun4;
|
|
2756
|
+
const home = services.homeDir ?? homedir2();
|
|
2757
|
+
const installDir = join10(home, ".qrspi", "plugin");
|
|
2758
|
+
const manifestPath = join10(installDir, "qrspi-manifest.json");
|
|
2759
|
+
let installedVersion;
|
|
2760
|
+
try {
|
|
2761
|
+
const manifestText = await readFile8(manifestPath, "utf8");
|
|
2762
|
+
installedVersion = parseManifest(manifestText)?.version;
|
|
2763
|
+
} catch (err) {
|
|
2764
|
+
if (err?.code !== "ENOENT") {
|
|
2765
|
+
const message = `Could not read ${manifestPath}: ${errorText4(err)}`;
|
|
2766
|
+
return fail("manifest-read-failed", message);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
const inspections = await Promise.all(
|
|
2770
|
+
AGENT_NAMES.map((agent) => inspectAgent(agent, home, installDir))
|
|
2771
|
+
);
|
|
2772
|
+
const agents = [];
|
|
2773
|
+
for (const [index, inspection] of inspections.entries()) {
|
|
2774
|
+
if ("error" in inspection) {
|
|
2775
|
+
return inspection.error;
|
|
2776
|
+
}
|
|
2777
|
+
const { probe } = inspection;
|
|
2778
|
+
const agent = AGENT_NAMES[index];
|
|
2779
|
+
if (probe.state === "absent") {
|
|
2780
|
+
agents.push({ agent, present: false });
|
|
2781
|
+
} else if (probe.state === "managed") {
|
|
2782
|
+
agents.push({ agent, present: true, mode: probe.mode });
|
|
2783
|
+
} else {
|
|
2784
|
+
agents.push({ agent, present: true, mode: "unmanaged" });
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
const github = services.github ?? gitHubAt(QRSPI_X_REPO, home, { execFile: execFile2 });
|
|
2788
|
+
const freshness = await github.checkFreshness(
|
|
2789
|
+
installedVersion ?? ""
|
|
2790
|
+
);
|
|
2791
|
+
return ok({
|
|
2792
|
+
installed: installedVersion !== void 0,
|
|
2793
|
+
version: installedVersion,
|
|
2794
|
+
agents,
|
|
2795
|
+
freshness,
|
|
2796
|
+
text: `${installedVersion ? `Installed version ${installedVersion}.` : "No plugin installed."} ${freshnessText(freshness)}`.trim()
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
function freshnessText(freshness) {
|
|
2800
|
+
switch (freshness.status) {
|
|
2801
|
+
case "update-available":
|
|
2802
|
+
return `Update available: ${freshness.latest}.`;
|
|
2803
|
+
case "up-to-date":
|
|
2804
|
+
return "Up to date.";
|
|
2805
|
+
case "unknown":
|
|
2806
|
+
if (freshness.message) {
|
|
2807
|
+
return `Error checking available releases: ${freshness.message}`;
|
|
2808
|
+
}
|
|
2809
|
+
return freshness.latest ? `Latest available: ${freshness.latest}.` : "";
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
async function checkInstall(installDir) {
|
|
2813
|
+
let entries;
|
|
2814
|
+
try {
|
|
2815
|
+
entries = await readdir4(installDir);
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
if (err?.code === "ENOENT") {
|
|
2818
|
+
return { ok: false, reason: "not-installed" };
|
|
2819
|
+
}
|
|
2820
|
+
return {
|
|
2821
|
+
ok: false,
|
|
2822
|
+
reason: "install-check-failed",
|
|
2823
|
+
message: errorText4(err)
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
return entries.length > 0 ? { ok: true } : { ok: false, reason: "not-installed" };
|
|
2827
|
+
}
|
|
2828
|
+
function errorText4(err) {
|
|
2829
|
+
return err instanceof Error ? err.message : String(err);
|
|
2830
|
+
}
|
|
2831
|
+
function fail(code, message) {
|
|
2832
|
+
return {
|
|
2833
|
+
exitCode: 1,
|
|
2834
|
+
code,
|
|
2835
|
+
text: message
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// src/command/start.ts
|
|
2840
|
+
import { readFile as readFile9 } from "node:fs/promises";
|
|
2841
|
+
import { join as join11 } from "node:path";
|
|
2842
|
+
var phaseFlag = {
|
|
2843
|
+
flag: "--phase <id>",
|
|
2844
|
+
required: true,
|
|
2845
|
+
description: "The phase this unit of work targets."
|
|
2846
|
+
};
|
|
2847
|
+
var loopFlag = {
|
|
2848
|
+
flag: "--loop",
|
|
2849
|
+
required: false,
|
|
2850
|
+
description: "Write the pre-spawn crash-safety intent to loop-state.json before the caller spawns its agent."
|
|
2851
|
+
};
|
|
2852
|
+
var baseFlag = {
|
|
2853
|
+
flag: "--base <commit-ish>",
|
|
2854
|
+
required: false,
|
|
2855
|
+
description: "Explicitly overwrite loop-state.json's recorded base for this phase. Fixes base-not-ancestor/phase-base-reset; --loop only."
|
|
2856
|
+
};
|
|
2857
|
+
var implementHelp2 = {
|
|
2858
|
+
name: "implement",
|
|
2859
|
+
summary: "Confirm it's clear to implement <phase>, or report why not.",
|
|
2860
|
+
flags: [...commonOptions, phaseFlag, loopFlag, baseFlag],
|
|
2861
|
+
example: "qrspi-x start implement --feature widget --project . --phase 1 --loop"
|
|
2862
|
+
};
|
|
2863
|
+
var reviewHelp2 = {
|
|
2864
|
+
name: "review",
|
|
2865
|
+
summary: "Confirm it's clear to review <phase>, or report why not.",
|
|
2866
|
+
flags: [...commonOptions, phaseFlag, loopFlag, baseFlag],
|
|
2867
|
+
example: "qrspi-x start review --feature widget --project . --phase 1 --loop"
|
|
2868
|
+
};
|
|
2869
|
+
var repairHelp2 = {
|
|
2870
|
+
name: "repair",
|
|
2871
|
+
summary: "Confirm it's clear to repair <phase>'s last FAIL, or report why not.",
|
|
2872
|
+
flags: [...commonOptions, phaseFlag, loopFlag],
|
|
2873
|
+
example: "qrspi-x start repair --feature widget --project . --phase 1 --loop"
|
|
2874
|
+
};
|
|
2875
|
+
var help8 = {
|
|
2876
|
+
name: "start",
|
|
2877
|
+
summary: "Begin implement/review/repair: confirm ready, or report blockers.",
|
|
2878
|
+
actions: [implementHelp2, reviewHelp2, repairHelp2]
|
|
2879
|
+
};
|
|
2880
|
+
async function resolveBase(opts, git, loopState, hasWorkBegun) {
|
|
2881
|
+
if (opts.base !== void 0) {
|
|
2882
|
+
return { baseSha: opts.base, resetBase: true };
|
|
2883
|
+
}
|
|
2884
|
+
const state = await loopState.read();
|
|
2885
|
+
const recordedBase = state?.bases[opts.phase];
|
|
2886
|
+
if (recordedBase === void 0) {
|
|
2887
|
+
if (await hasWorkBegun()) {
|
|
2888
|
+
return {
|
|
2889
|
+
baseSha: await git.headSha(),
|
|
2890
|
+
resetBase: false,
|
|
2891
|
+
finding: {
|
|
2892
|
+
code: "phase-base-reset",
|
|
2893
|
+
message: `Phase ${opts.phase} has steps begun but no base recorded; its checkpoint diff will start at the current commit. Pass --base <commit-ish> to point at where its work actually began.`
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
return { baseSha: await git.headSha(), resetBase: false };
|
|
2898
|
+
}
|
|
2899
|
+
if (!await git.isAncestor(recordedBase)) {
|
|
2900
|
+
return {
|
|
2901
|
+
baseSha: recordedBase,
|
|
2902
|
+
resetBase: false,
|
|
2903
|
+
finding: {
|
|
2904
|
+
code: "base-not-ancestor",
|
|
2905
|
+
message: `Phase ${opts.phase}'s recorded base ${recordedBase} is no longer an ancestor of HEAD; retry with --base <commit-ish>.`
|
|
2906
|
+
}
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
return { baseSha: recordedBase, resetBase: false };
|
|
2910
|
+
}
|
|
2911
|
+
async function writeBegin(history, kind, filter, rest = {}) {
|
|
2912
|
+
await history.conditionalAppend({ kind, ...filter }, { action: "begin" }, {
|
|
2913
|
+
kind,
|
|
2914
|
+
action: "begin",
|
|
2915
|
+
...filter,
|
|
2916
|
+
...rest
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
async function implement2(opts, services = {}) {
|
|
2920
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
2921
|
+
const { workspace, loopState, history, git } = resolveServices(
|
|
2922
|
+
root,
|
|
2923
|
+
opts.project,
|
|
2924
|
+
services
|
|
2925
|
+
);
|
|
2926
|
+
if (!await git.isClean()) {
|
|
2927
|
+
return blocked([
|
|
2928
|
+
{ code: "dirty-tree", message: "working tree is not clean" }
|
|
2929
|
+
]);
|
|
2930
|
+
}
|
|
2931
|
+
const progress = await workspace.checkProgress(opts.phase);
|
|
2932
|
+
if (!progress.dependenciesSatisfied) {
|
|
2933
|
+
return blocked([
|
|
2934
|
+
{
|
|
2935
|
+
code: "incomplete-dependency",
|
|
2936
|
+
message: `Phase ${opts.phase} depends on a phase that isn't done yet.`
|
|
2937
|
+
}
|
|
2938
|
+
]);
|
|
2939
|
+
}
|
|
2940
|
+
if (!opts.loop) {
|
|
2941
|
+
await writeBegin(history, "implement", { phase: opts.phase });
|
|
2942
|
+
return ok({ phaseId: opts.phase });
|
|
2943
|
+
}
|
|
2944
|
+
const { baseSha, resetBase, finding } = await resolveBase(
|
|
2945
|
+
opts,
|
|
2946
|
+
git,
|
|
2947
|
+
loopState,
|
|
2948
|
+
async () => progress.complete > 0
|
|
2949
|
+
);
|
|
2950
|
+
await loopState.writeInFlight("implement", opts.phase, {
|
|
2951
|
+
baseSha,
|
|
2952
|
+
resetBase
|
|
2953
|
+
});
|
|
2954
|
+
await writeBegin(history, "implement", { phase: opts.phase });
|
|
2955
|
+
return finding ? warn({ phaseId: opts.phase }, [finding]) : ok({ phaseId: opts.phase });
|
|
2956
|
+
}
|
|
2957
|
+
async function review2(opts, services = {}) {
|
|
2958
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
2959
|
+
const { workspace, loopState, history, git } = resolveServices(
|
|
2960
|
+
root,
|
|
2961
|
+
opts.project,
|
|
2962
|
+
services
|
|
2963
|
+
);
|
|
2964
|
+
if (!await workspace.isPhaseComplete(opts.phase)) {
|
|
2965
|
+
return blocked([
|
|
2966
|
+
{
|
|
2967
|
+
code: "phase-not-complete",
|
|
2968
|
+
message: `Phase ${opts.phase}'s steps aren't all [x] yet.`
|
|
2969
|
+
}
|
|
2970
|
+
]);
|
|
2971
|
+
}
|
|
2972
|
+
const state = opts.loop ? await loopState.read() : void 0;
|
|
2973
|
+
const inFlightLabel = state?.inFlight?.task === "review" && state.inFlight.phaseId === opts.phase ? state.inFlight.label : void 0;
|
|
2974
|
+
let label = inFlightLabel;
|
|
2975
|
+
if (label === void 0) {
|
|
2976
|
+
label = await workspace.pendingReviewLabel(opts.phase);
|
|
2977
|
+
}
|
|
2978
|
+
if (label === void 0) {
|
|
2979
|
+
label = await workspace.nextLabel("review", { phase: opts.phase });
|
|
2980
|
+
}
|
|
2981
|
+
if (!opts.loop) {
|
|
2982
|
+
await writeBegin(history, "review", { label }, { phase: opts.phase });
|
|
2983
|
+
return ok({ label });
|
|
2984
|
+
}
|
|
2985
|
+
const progress = await workspace.checkProgress(opts.phase);
|
|
2986
|
+
const { baseSha, resetBase, finding } = await resolveBase(
|
|
2987
|
+
opts,
|
|
2988
|
+
git,
|
|
2989
|
+
loopState,
|
|
2990
|
+
// review only ever runs after implement has started the phase
|
|
2991
|
+
// under --loop, so a missing base here means the same thing as
|
|
2992
|
+
// for implement: work is underway with nothing recorded.
|
|
2993
|
+
async () => progress.complete > 0
|
|
2994
|
+
);
|
|
2995
|
+
await loopState.writeInFlight("review", opts.phase, {
|
|
2996
|
+
label,
|
|
2997
|
+
baseSha,
|
|
2998
|
+
resetBase
|
|
2999
|
+
});
|
|
3000
|
+
await writeBegin(history, "review", { label }, { phase: opts.phase });
|
|
3001
|
+
const diff = `git diff ${baseSha}..HEAD`;
|
|
3002
|
+
return finding ? warn({ label, diff }, [finding]) : ok({ label, diff });
|
|
3003
|
+
}
|
|
3004
|
+
async function repair2(opts, services = {}) {
|
|
3005
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
3006
|
+
const { workspace, loopState, history } = resolveServices(
|
|
3007
|
+
root,
|
|
3008
|
+
opts.project,
|
|
3009
|
+
services
|
|
3010
|
+
);
|
|
3011
|
+
const state = await loopState.read();
|
|
3012
|
+
const path = opts.loop && state?.checkpoint ? reviewPath(state.checkpoint.label) : await workspace.lastFile("review", { phase: opts.phase });
|
|
3013
|
+
if (path === void 0) {
|
|
3014
|
+
return blocked([
|
|
3015
|
+
{
|
|
3016
|
+
code: "no-fail-review",
|
|
3017
|
+
message: `No review exists yet for phase ${opts.phase}.`
|
|
3018
|
+
}
|
|
3019
|
+
]);
|
|
3020
|
+
}
|
|
3021
|
+
const text = await readFile9(join11(root, path), "utf8");
|
|
3022
|
+
if (parseVerdict(text) !== "FAIL") {
|
|
3023
|
+
return blocked([
|
|
3024
|
+
{
|
|
3025
|
+
code: "no-fail-review",
|
|
3026
|
+
message: `Phase ${opts.phase}'s last review (${path}) isn't a FAIL.`
|
|
3027
|
+
}
|
|
3028
|
+
]);
|
|
3029
|
+
}
|
|
3030
|
+
if (!opts.loop) {
|
|
3031
|
+
await writeBegin(history, "repair", { phase: opts.phase });
|
|
3032
|
+
return ok({ review: path });
|
|
3033
|
+
}
|
|
3034
|
+
try {
|
|
3035
|
+
await loopState.writeInFlight("repair", opts.phase);
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
if (err instanceof RepairAlreadySpentError) {
|
|
3038
|
+
return blocked([
|
|
3039
|
+
{
|
|
3040
|
+
code: "repair-already-spent",
|
|
3041
|
+
message: err.message
|
|
3042
|
+
}
|
|
3043
|
+
]);
|
|
3044
|
+
}
|
|
3045
|
+
throw err;
|
|
3046
|
+
}
|
|
3047
|
+
await writeBegin(history, "repair", { phase: opts.phase });
|
|
3048
|
+
return ok({ review: path });
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// src/command/status.ts
|
|
3052
|
+
var help9 = {
|
|
3053
|
+
name: "status",
|
|
3054
|
+
summary: "Read-only: implementation/review/loop facts. Never writes.",
|
|
3055
|
+
flags: [...commonOptions]
|
|
3056
|
+
};
|
|
3057
|
+
async function run3(opts, services = {}) {
|
|
3058
|
+
const root = resolveRoot(opts.project, opts.feature);
|
|
3059
|
+
const { workspace, loopState, history } = resolveServices(
|
|
3060
|
+
root,
|
|
3061
|
+
opts.project,
|
|
3062
|
+
services
|
|
3063
|
+
);
|
|
3064
|
+
const parked = await history.isParked();
|
|
3065
|
+
const current = {};
|
|
3066
|
+
const next = {};
|
|
3067
|
+
const findings = [];
|
|
3068
|
+
let step;
|
|
3069
|
+
if (parked) {
|
|
3070
|
+
step = "parked";
|
|
3071
|
+
} else {
|
|
3072
|
+
step = await workspace.deriveStep();
|
|
3073
|
+
}
|
|
3074
|
+
current.step = step;
|
|
3075
|
+
if (step === "implement") {
|
|
3076
|
+
try {
|
|
3077
|
+
const active = await workspace.findActivePhase();
|
|
3078
|
+
current.phase = active.phase;
|
|
3079
|
+
current.planProgress = active.planProgress;
|
|
3080
|
+
if (active.staleInput) {
|
|
3081
|
+
findings.push({
|
|
3082
|
+
code: "stale-input",
|
|
3083
|
+
message: `${active.staleInput.phaseFile} predates spec.md (phase file ${active.staleInput.phaseFileMtime.toISOString()}, spec.md ${active.staleInput.specMtime.toISOString()}).`
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
} catch (err) {
|
|
3087
|
+
if (err instanceof NoActivePhaseError) {
|
|
3088
|
+
findings.push({
|
|
3089
|
+
code: "all-phases-blocked",
|
|
3090
|
+
message: `Every incomplete phase is blocked on an unsatisfied dependency (${err.blockedPhases.join(", ")}).`
|
|
3091
|
+
});
|
|
3092
|
+
} else if (err instanceof DependencyCycleError || err instanceof UnknownPhaseError) {
|
|
3093
|
+
findings.push({
|
|
3094
|
+
code: "plan-unreadable",
|
|
3095
|
+
message: err.message
|
|
3096
|
+
});
|
|
3097
|
+
} else {
|
|
3098
|
+
throw err;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
const loopStatus = await loopState.status();
|
|
3103
|
+
let loop;
|
|
3104
|
+
if (loopStatus) {
|
|
3105
|
+
const { action, label, diff, review: review3, ...rest } = loopStatus;
|
|
3106
|
+
loop = rest;
|
|
3107
|
+
next.action = action;
|
|
3108
|
+
next.label = label;
|
|
3109
|
+
next.diff = diff;
|
|
3110
|
+
next.review = review3;
|
|
3111
|
+
} else if (current.phase) {
|
|
3112
|
+
next.label = await workspace.nextLabel("review", {
|
|
3113
|
+
phase: current.phase
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
const fields = {
|
|
3117
|
+
current,
|
|
3118
|
+
...Object.keys(next).length > 0 ? { next } : {},
|
|
3119
|
+
...loop ? { loop } : {}
|
|
3120
|
+
};
|
|
3121
|
+
return findings.length > 0 ? warn(fields, findings) : ok(fields);
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// src/cli.ts
|
|
3125
|
+
var commandHelps = [
|
|
3126
|
+
help,
|
|
3127
|
+
help2,
|
|
3128
|
+
help4,
|
|
3129
|
+
help5,
|
|
3130
|
+
help6,
|
|
3131
|
+
help8,
|
|
3132
|
+
help9,
|
|
3133
|
+
help3,
|
|
3134
|
+
help7
|
|
3135
|
+
];
|
|
3136
|
+
var commandNames = new Set(commandHelps.map((entry) => entry.name));
|
|
3137
|
+
var UsageError = class extends Error {
|
|
3138
|
+
};
|
|
3139
|
+
function processIo() {
|
|
3140
|
+
return {
|
|
3141
|
+
cwd: process.cwd(),
|
|
3142
|
+
stdout: (line) => process.stdout.write(`${line}
|
|
3143
|
+
`),
|
|
3144
|
+
stderr: (line) => process.stderr.write(`${line}
|
|
3145
|
+
`)
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
async function main(argv, io = processIo()) {
|
|
3149
|
+
try {
|
|
3150
|
+
if (argv.length === 0) {
|
|
3151
|
+
io.stdout(renderToolHelp(commandHelps));
|
|
3152
|
+
return 0;
|
|
3153
|
+
}
|
|
3154
|
+
if (argv[0] === "--version") {
|
|
3155
|
+
io.stdout(version());
|
|
3156
|
+
return 0;
|
|
3157
|
+
}
|
|
3158
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
3159
|
+
const positionals2 = [];
|
|
3160
|
+
for (const arg of argv) {
|
|
3161
|
+
if (arg === "--help" || arg === "-h") {
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
3164
|
+
if (arg.startsWith("-")) {
|
|
3165
|
+
break;
|
|
3166
|
+
}
|
|
3167
|
+
positionals2.push(arg);
|
|
3168
|
+
}
|
|
3169
|
+
return emitHelp(positionals2, io);
|
|
3170
|
+
}
|
|
3171
|
+
const command = argv[0];
|
|
3172
|
+
if (command === void 0 || !commandNames.has(command)) {
|
|
3173
|
+
throw new UsageError(`Unknown command "${command ?? ""}"`);
|
|
3174
|
+
}
|
|
3175
|
+
const { positionals, options } = parseOptions(argv.slice(1));
|
|
3176
|
+
validateOptions(command, positionals, options);
|
|
3177
|
+
const result = command === "plugin" ? await dispatchPlugin(positionals, options) : command === "import" ? await dispatchImport(
|
|
3178
|
+
positionals,
|
|
3179
|
+
options,
|
|
3180
|
+
projectAndOptionalFeature(options, io.cwd)
|
|
3181
|
+
) : await dispatch(
|
|
3182
|
+
command,
|
|
3183
|
+
positionals,
|
|
3184
|
+
options,
|
|
3185
|
+
projectAndFeature(options, io.cwd)
|
|
3186
|
+
);
|
|
3187
|
+
if (typeof result === "string") {
|
|
3188
|
+
io.stdout(result);
|
|
3189
|
+
} else if (Array.isArray(result)) {
|
|
3190
|
+
io.stdout(JSON.stringify(result));
|
|
3191
|
+
} else if (typeof result.text === "string") {
|
|
3192
|
+
io.stdout(result.text);
|
|
3193
|
+
} else {
|
|
3194
|
+
io.stdout(JSON.stringify(toJson(result)));
|
|
3195
|
+
}
|
|
3196
|
+
return typeof result === "object" && !Array.isArray(result) ? result.exitCode : 0;
|
|
3197
|
+
} catch (error) {
|
|
3198
|
+
if (error instanceof UsageError) {
|
|
3199
|
+
io.stderr(error.message);
|
|
3200
|
+
return 2;
|
|
3201
|
+
}
|
|
3202
|
+
io.stderr(
|
|
3203
|
+
error instanceof Error ? error.stack ?? error.message : String(error)
|
|
3204
|
+
);
|
|
3205
|
+
return 4;
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
function emitHelp(args, io) {
|
|
3209
|
+
const command = args[0];
|
|
3210
|
+
if (command === void 0) {
|
|
3211
|
+
io.stdout(renderToolHelp(commandHelps));
|
|
3212
|
+
return 0;
|
|
3213
|
+
}
|
|
3214
|
+
const help10 = commandHelps.find((entry) => entry.name === command);
|
|
3215
|
+
if (help10 === void 0) {
|
|
3216
|
+
io.stderr(`Unknown command "${command}"`);
|
|
3217
|
+
return 2;
|
|
3218
|
+
}
|
|
3219
|
+
const action = args[1];
|
|
3220
|
+
if (action === void 0) {
|
|
3221
|
+
io.stdout(renderHelp(help10));
|
|
3222
|
+
return 0;
|
|
3223
|
+
}
|
|
3224
|
+
io.stdout(renderHelp(help10, action));
|
|
3225
|
+
return 0;
|
|
3226
|
+
}
|
|
3227
|
+
function parseOptions(args) {
|
|
3228
|
+
const positionals = [];
|
|
3229
|
+
const options = {};
|
|
3230
|
+
for (let i = 0; i < args.length; i++) {
|
|
3231
|
+
const token = args[i];
|
|
3232
|
+
if (!token.startsWith("--")) {
|
|
3233
|
+
positionals.push(token);
|
|
3234
|
+
continue;
|
|
3235
|
+
}
|
|
3236
|
+
const equals = token.indexOf("=");
|
|
3237
|
+
const name = equals === -1 ? token : token.slice(0, equals);
|
|
3238
|
+
const attached = equals === -1 ? void 0 : token.slice(equals + 1);
|
|
3239
|
+
if (name === "--loop" || name === "--all" || name === "--copy" || name === "--symlink" || name === "--force") {
|
|
3240
|
+
setOption(options, name.slice(2), true);
|
|
3241
|
+
continue;
|
|
3242
|
+
}
|
|
3243
|
+
const value = attached ?? args[++i];
|
|
3244
|
+
if (value === void 0 || value === "" || attached === void 0 && value.startsWith("--")) {
|
|
3245
|
+
throw new UsageError(`${name} requires a non-empty value`);
|
|
3246
|
+
}
|
|
3247
|
+
setOption(options, name.slice(2), value);
|
|
3248
|
+
}
|
|
3249
|
+
return { positionals, options };
|
|
3250
|
+
}
|
|
3251
|
+
function flagName(flag) {
|
|
3252
|
+
if (!flag.startsWith("--")) return void 0;
|
|
3253
|
+
const token = flag.split(/[\s=]/, 1)[0];
|
|
3254
|
+
return token.slice(2);
|
|
3255
|
+
}
|
|
3256
|
+
function allowedFlagNames(flags) {
|
|
3257
|
+
const names = /* @__PURE__ */ new Set();
|
|
3258
|
+
for (const f of flags ?? []) {
|
|
3259
|
+
const name = flagName(f.flag);
|
|
3260
|
+
if (name) names.add(name);
|
|
3261
|
+
}
|
|
3262
|
+
return names;
|
|
3263
|
+
}
|
|
3264
|
+
function validateOptions(command, positionals, options) {
|
|
3265
|
+
const help10 = commandHelps.find((c) => c.name === command);
|
|
3266
|
+
if (!help10) {
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
let flags;
|
|
3270
|
+
let scope = command;
|
|
3271
|
+
if (help10.actions && help10.actions.length > 0) {
|
|
3272
|
+
const actionName = positionals[0];
|
|
3273
|
+
const action = help10.actions.find(
|
|
3274
|
+
(a) => a.name === actionName
|
|
3275
|
+
);
|
|
3276
|
+
if (!action) {
|
|
3277
|
+
throw new UsageError(
|
|
3278
|
+
`Unknown ${command} action "${actionName ?? ""}"`
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
flags = action.flags;
|
|
3282
|
+
scope = `${command} ${actionName}`;
|
|
3283
|
+
} else {
|
|
3284
|
+
flags = help10.flags;
|
|
3285
|
+
}
|
|
3286
|
+
const allowed = allowedFlagNames(flags);
|
|
3287
|
+
for (const name of Object.keys(options)) {
|
|
3288
|
+
if (!allowed.has(name)) {
|
|
3289
|
+
throw new UsageError(`Unknown option "--${name}" for "${scope}"`);
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
function setOption(options, name, value) {
|
|
3294
|
+
if (options[name] !== void 0) {
|
|
3295
|
+
throw new UsageError(`--${name} was given more than once`);
|
|
3296
|
+
}
|
|
3297
|
+
options[name] = value;
|
|
3298
|
+
}
|
|
3299
|
+
function required(options, name) {
|
|
3300
|
+
const value = options[name];
|
|
3301
|
+
if (typeof value !== "string") {
|
|
3302
|
+
throw new UsageError(`--${name} is required`);
|
|
3303
|
+
}
|
|
3304
|
+
return value;
|
|
3305
|
+
}
|
|
3306
|
+
function projectAndOptionalFeature(options, cwd) {
|
|
3307
|
+
const project = options.project;
|
|
3308
|
+
const feature = options.feature;
|
|
3309
|
+
return {
|
|
3310
|
+
feature: typeof feature === "string" ? feature : void 0,
|
|
3311
|
+
project: typeof project === "string" ? project : cwd
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
function projectAndFeature(options, cwd) {
|
|
3315
|
+
const project = options.project;
|
|
3316
|
+
return {
|
|
3317
|
+
feature: required(options, "feature"),
|
|
3318
|
+
project: typeof project === "string" ? project : cwd
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
async function dispatch(command, positionals, options, common) {
|
|
3322
|
+
switch (command) {
|
|
3323
|
+
case "status":
|
|
3324
|
+
return dispatchStatus(positionals, common);
|
|
3325
|
+
case "next-file":
|
|
3326
|
+
return dispatchNextFile(positionals, options, common);
|
|
3327
|
+
case "loop":
|
|
3328
|
+
return dispatchLoop(positionals, common);
|
|
3329
|
+
case "decision":
|
|
3330
|
+
return dispatchDecision(positionals, options, common);
|
|
3331
|
+
case "history":
|
|
3332
|
+
return dispatchHistory(positionals, options, common);
|
|
3333
|
+
case "start":
|
|
3334
|
+
return dispatchStart(positionals, options, common);
|
|
3335
|
+
case "log":
|
|
3336
|
+
return dispatchLog(positionals, options, common);
|
|
3337
|
+
default:
|
|
3338
|
+
throw new UsageError(`Unknown command "${command}"`);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
function dispatchStatus(positionals, common) {
|
|
3342
|
+
if (positionals.length !== 0) {
|
|
3343
|
+
throw new UsageError("status takes no positional arguments");
|
|
3344
|
+
}
|
|
3345
|
+
return run3(common);
|
|
3346
|
+
}
|
|
3347
|
+
function dispatchNextFile(positionals, options, common) {
|
|
3348
|
+
const type = positionals[0];
|
|
3349
|
+
if (type === void 0 || positionals.length !== 1) {
|
|
3350
|
+
throw new UsageError("next-file requires exactly one type");
|
|
3351
|
+
}
|
|
3352
|
+
return run2(type, {
|
|
3353
|
+
...common,
|
|
3354
|
+
phase: typeof options.phase === "string" ? options.phase : void 0,
|
|
3355
|
+
step: typeof options.step === "string" ? Number(options.step) : void 0
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
async function dispatchImport(positionals, options, common) {
|
|
3359
|
+
const number = positionals[0];
|
|
3360
|
+
if (number === void 0 || positionals.length !== 1) {
|
|
3361
|
+
throw new UsageError("import requires exactly one issue or PR number");
|
|
3362
|
+
}
|
|
3363
|
+
const repo = typeof options.repo === "string" ? options.repo : void 0;
|
|
3364
|
+
return runImport({
|
|
3365
|
+
number,
|
|
3366
|
+
project: common.project,
|
|
3367
|
+
feature: common.feature,
|
|
3368
|
+
repo
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
async function dispatchPlugin(positionals, options) {
|
|
3372
|
+
const action = singleAction("plugin", positionals);
|
|
3373
|
+
switch (action) {
|
|
3374
|
+
case "install":
|
|
3375
|
+
return runInstall({ release: optional(options, "release") });
|
|
3376
|
+
case "init":
|
|
3377
|
+
return runInit({
|
|
3378
|
+
agent: required(options, "agent"),
|
|
3379
|
+
copy: options.copy === true,
|
|
3380
|
+
symlink: options.symlink === true,
|
|
3381
|
+
force: options.force === true
|
|
3382
|
+
});
|
|
3383
|
+
case "status":
|
|
3384
|
+
return runStatus();
|
|
3385
|
+
case "update":
|
|
3386
|
+
return runUpdate({
|
|
3387
|
+
release: optional(options, "release"),
|
|
3388
|
+
force: options.force === true
|
|
3389
|
+
});
|
|
3390
|
+
case "remove":
|
|
3391
|
+
return runRemove({
|
|
3392
|
+
agent: required(options, "agent"),
|
|
3393
|
+
force: options.force === true
|
|
3394
|
+
});
|
|
3395
|
+
default:
|
|
3396
|
+
throw new UsageError(`Unknown plugin action "${action}"`);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function singleAction(command, positionals) {
|
|
3400
|
+
const action = positionals[0];
|
|
3401
|
+
if (action === void 0 || positionals.length !== 1) {
|
|
3402
|
+
throw new UsageError(`${command} requires exactly one action`);
|
|
3403
|
+
}
|
|
3404
|
+
return action;
|
|
3405
|
+
}
|
|
3406
|
+
function dispatchDecision(positionals, options, common) {
|
|
3407
|
+
const action = singleAction("decision", positionals);
|
|
3408
|
+
switch (action) {
|
|
3409
|
+
case "add":
|
|
3410
|
+
return add({ ...common, text: required(options, "text") });
|
|
3411
|
+
case "read":
|
|
3412
|
+
return read(common);
|
|
3413
|
+
default:
|
|
3414
|
+
throw new UsageError(`Unknown decision action "${action}"`);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
function dispatchHistory(positionals, options, common) {
|
|
3418
|
+
const action = singleAction("history", positionals);
|
|
3419
|
+
switch (action) {
|
|
3420
|
+
case "add":
|
|
3421
|
+
return add2({ ...common, text: required(options, "text") });
|
|
3422
|
+
case "read": {
|
|
3423
|
+
if (options.all === true && options.tail !== void 0) {
|
|
3424
|
+
throw new UsageError(
|
|
3425
|
+
"history read cannot combine --all and --tail"
|
|
3426
|
+
);
|
|
3427
|
+
}
|
|
3428
|
+
return read2({
|
|
3429
|
+
...common,
|
|
3430
|
+
tail: typeof options.tail === "string" ? Number(options.tail) : void 0,
|
|
3431
|
+
all: options.all === true,
|
|
3432
|
+
kind: typeof options.kind === "string" ? options.kind : void 0,
|
|
3433
|
+
phase: typeof options.phase === "string" ? options.phase : void 0
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
default:
|
|
3437
|
+
throw new UsageError(`Unknown history action "${action}"`);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
function dispatchStart(positionals, options, common) {
|
|
3441
|
+
const action = singleAction("start", positionals);
|
|
3442
|
+
const task = {
|
|
3443
|
+
...common,
|
|
3444
|
+
phase: required(options, "phase"),
|
|
3445
|
+
loop: options.loop === true,
|
|
3446
|
+
base: typeof options.base === "string" ? options.base : void 0
|
|
3447
|
+
};
|
|
3448
|
+
switch (action) {
|
|
3449
|
+
case "implement":
|
|
3450
|
+
return implement2(task);
|
|
3451
|
+
case "review":
|
|
3452
|
+
return review2(task);
|
|
3453
|
+
case "repair":
|
|
3454
|
+
return repair2(task);
|
|
3455
|
+
default:
|
|
3456
|
+
throw new UsageError(`Unknown start action "${action}"`);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
function dispatchLog(positionals, options, common) {
|
|
3460
|
+
const action = singleAction("log", positionals);
|
|
3461
|
+
switch (action) {
|
|
3462
|
+
case "implement":
|
|
3463
|
+
return implement({
|
|
3464
|
+
...common,
|
|
3465
|
+
phase: required(options, "phase"),
|
|
3466
|
+
reason: optional(options, "reason")
|
|
3467
|
+
});
|
|
3468
|
+
case "repair":
|
|
3469
|
+
return repair({
|
|
3470
|
+
...common,
|
|
3471
|
+
phase: required(options, "phase"),
|
|
3472
|
+
reason: optional(options, "reason")
|
|
3473
|
+
});
|
|
3474
|
+
case "review":
|
|
3475
|
+
return review({ ...common, label: required(options, "label") });
|
|
3476
|
+
case "park":
|
|
3477
|
+
return park({ ...common, reason: optional(options, "reason") });
|
|
3478
|
+
default:
|
|
3479
|
+
throw new UsageError(`Unknown log action "${action}"`);
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
async function dispatchLoop(positionals, common) {
|
|
3483
|
+
const action = positionals[0];
|
|
3484
|
+
const arg = positionals[1];
|
|
3485
|
+
switch (action) {
|
|
3486
|
+
case "start": {
|
|
3487
|
+
if (arg === void 0 || positionals.length !== 2) {
|
|
3488
|
+
throw new UsageError(
|
|
3489
|
+
"loop start requires exactly one selector"
|
|
3490
|
+
);
|
|
3491
|
+
}
|
|
3492
|
+
return start({ ...common, selector: arg });
|
|
3493
|
+
}
|
|
3494
|
+
case "advance": {
|
|
3495
|
+
if (positionals.length !== 1) {
|
|
3496
|
+
throw new UsageError("loop advance takes no arguments");
|
|
3497
|
+
}
|
|
3498
|
+
return advance(common);
|
|
3499
|
+
}
|
|
3500
|
+
case "stop": {
|
|
3501
|
+
if (arg === void 0 || positionals.length !== 2) {
|
|
3502
|
+
throw new UsageError("loop stop requires exactly one reason");
|
|
3503
|
+
}
|
|
3504
|
+
return stop({ ...common, reason: arg });
|
|
3505
|
+
}
|
|
3506
|
+
case "ok": {
|
|
3507
|
+
if (positionals.length > 2) {
|
|
3508
|
+
throw new UsageError("loop ok takes at most one reason");
|
|
3509
|
+
}
|
|
3510
|
+
return loopOk({ ...common, reason: arg });
|
|
3511
|
+
}
|
|
3512
|
+
case "abandon": {
|
|
3513
|
+
if (arg === void 0 || positionals.length !== 2) {
|
|
3514
|
+
throw new UsageError(
|
|
3515
|
+
"loop abandon requires exactly one reason"
|
|
3516
|
+
);
|
|
3517
|
+
}
|
|
3518
|
+
return abandon({ ...common, reason: arg });
|
|
3519
|
+
}
|
|
3520
|
+
default:
|
|
3521
|
+
throw new UsageError(`Unknown loop action "${action ?? ""}"`);
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
function optional(options, name) {
|
|
3525
|
+
return typeof options[name] === "string" ? options[name] : void 0;
|
|
3526
|
+
}
|
|
3527
|
+
function version() {
|
|
3528
|
+
return true ? "0.10.0+g6a64539" : "0.0.0-dev";
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// src/bin.ts
|
|
3532
|
+
process.exitCode = await main(process.argv.slice(2));
|