paseo-req-kit 1.8.0 → 1.9.0-beta.2

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +2 -0
  3. package/bin/req-plugin.mjs +226 -0
  4. package/bin/req.mjs +27 -0
  5. package/contracts/README.md +2 -0
  6. package/contracts/cli-json.md +66 -0
  7. package/contracts/usage-record.md +48 -0
  8. package/docs/adr/0026-machine-records-and-api.md +23 -0
  9. package/docs/adr/0027-paseo-plugin.md +23 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  12. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +26 -0
  13. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
  14. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +10 -0
  15. package/engine/src/api.ts +397 -0
  16. package/engine/src/control.ts +2 -2
  17. package/engine/src/eval.ts +2 -0
  18. package/engine/src/index-cmd.ts +33 -0
  19. package/engine/src/main.ts +26 -0
  20. package/engine/src/records.ts +271 -0
  21. package/engine/src/state.ts +3 -0
  22. package/engine/src/stats.ts +479 -0
  23. package/install.mjs +3 -0
  24. package/package.json +3 -2
  25. package/paseo-plugin/OVERVIEW.md +7 -0
  26. package/paseo-plugin/client/data.ts +55 -0
  27. package/paseo-plugin/client/detail.tsx +277 -0
  28. package/paseo-plugin/client/format.ts +101 -0
  29. package/paseo-plugin/client/live.ts +161 -0
  30. package/paseo-plugin/client/overview.tsx +89 -0
  31. package/paseo-plugin/client/panel.tsx +56 -0
  32. package/paseo-plugin/client/row.tsx +40 -0
  33. package/paseo-plugin/client/settings.tsx +90 -0
  34. package/paseo-plugin/client/sidebar.tsx +27 -0
  35. package/paseo-plugin/client/stats.tsx +80 -0
  36. package/paseo-plugin/client/ui.tsx +111 -0
  37. package/paseo-plugin/index.client.tsx +52 -0
  38. package/paseo-plugin/index.server.ts +41 -0
  39. package/paseo-plugin/package.json +22 -0
  40. package/paseo-plugin/paseo-plugin.json +6 -0
  41. package/paseo-plugin/server/handlers.ts +45 -0
  42. package/paseo-plugin/server/req.ts +100 -0
  43. package/paseo-plugin/shared/contracts.ts +165 -0
  44. package/paseo-plugin/tsconfig.json +16 -0
@@ -0,0 +1,397 @@
1
+ // `req api`: machine-readable output for programs (the Paseo plugin, scripts), 1.9.
2
+ // The format is a contract (contracts/cli-json.md): every command prints one JSON object,
3
+ // { "schema": 1, "kitVersion": "…", "data": … } exit 0
4
+ // { "schema": 1, "kitVersion": "…", "error": { "code", "message" } } exit 1
5
+ //
6
+ // req api version
7
+ // req api list [--all] [--repo <dir>]
8
+ // req api status <需求> [--repo <dir>]
9
+ // req api file <需求> <文件> [--repo <dir>]
10
+ // req api stats [--since 30d] [--month 2026-10]
11
+ // req api index [--rebuild <dir>...] [--prune]
12
+ // req api run <approve|stop|resume|verify> <需求> [--repo <dir>] [其他选项]
13
+ //
14
+ // Reading never changes anything. `run` performs the same command as `req <command>` in that
15
+ // directory, so the engine stays the only one that changes a requirement's state.
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { resultText, stepName } from "./control.js";
21
+ import { FEATURES_DIR, findWorktreeRun, listFeatures, resolveFeatureArg, summarize } from "./features.js";
22
+ import { KIT_ROOT, kitVersion } from "./locations.js";
23
+ import { pruneIndex, readIndex, registerRequirement, requirementKey } from "./records.js";
24
+ import { isAlive, type PipelineState, readState, statePath, type StepRecord } from "./state.js";
25
+ import { parsePeriod, summarizeRuns } from "./stats.js";
26
+ import { readRuns, runsFile } from "./records.js";
27
+ import { totals } from "./usage.js";
28
+
29
+ export const API_SCHEMA = 1;
30
+
31
+ export class ApiError extends Error {
32
+ constructor(
33
+ readonly code: string,
34
+ message: string,
35
+ ) {
36
+ super(message);
37
+ }
38
+ }
39
+
40
+ /** Documents a program may read from a feature directory (`req api file`). */
41
+ export const READABLE_FILES = [
42
+ "spec.md",
43
+ "alignment.md",
44
+ "spec-review.md",
45
+ "changes.md",
46
+ "decisions.tsv",
47
+ "design.md",
48
+ "design-review.md",
49
+ "preview.md",
50
+ "contract.md",
51
+ "diagnosis.md",
52
+ "assessment.md",
53
+ "implementation.md",
54
+ "progress.md",
55
+ "code-review.md",
56
+ "handoff.md",
57
+ "service-start.md",
58
+ "verification.md",
59
+ "report.md",
60
+ "acceptance.md",
61
+ "lessons.tsv",
62
+ ] as const;
63
+
64
+ const MAX_FILE_BYTES = 512 * 1024;
65
+
66
+ export type Attention = "approval" | "business" | "interrupted" | "not_completed" | null;
67
+
68
+ /** What a person needs to do about a requirement, if anything. */
69
+ export function attentionOf(state: Pick<PipelineState, "status" | "pid" | "outcome"> | null): Attention {
70
+ if (!state) return null;
71
+ if (state.status === "waiting_for_approval") return "approval";
72
+ if (state.status === "waiting_for_business") return "business";
73
+ if (state.status === "failed" || (state.status === "running" && !isAlive(state.pid))) return "interrupted";
74
+ if (state.status === "completed" && state.outcome === "not_completed") return "not_completed";
75
+ return null;
76
+ }
77
+
78
+ function usageOf(state: PipelineState | null) {
79
+ if (!state || state.history.length === 0) return null;
80
+ const t = totals(state.history);
81
+ return { steps: t.steps, agentMinutes: Math.round(t.agentMinutes * 10) / 10, inputTokens: t.inputTokens, outputTokens: t.outputTokens, costUsd: t.costUsd };
82
+ }
83
+
84
+ function readStateSafe(repo: string, featureDir: string): PipelineState | null {
85
+ try {
86
+ return readState(statePath(repo, featureDir));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function currentSteps(state: PipelineState | null) {
93
+ if (!state || state.status !== "running" || !isAlive(state.pid)) return [];
94
+ return state.history.filter((h) => h.outcome === "running").map((h) => ({ key: h.key, title: stepName(h, state), agentId: h.agentId }));
95
+ }
96
+
97
+ export interface ListItem {
98
+ key: string;
99
+ project: string;
100
+ repo: string;
101
+ sourceRepo: string | null;
102
+ workspaceId: string | null;
103
+ featureDir: string;
104
+ name: string;
105
+ title: string;
106
+ type: string | null;
107
+ profile: string | null;
108
+ branch: string | null;
109
+ /** Plain-language stage, as `req list` shows it. */
110
+ stage: string;
111
+ /** The state file's status; null before the first run. */
112
+ status: string | null;
113
+ running: boolean;
114
+ phase: string | null;
115
+ attention: Attention;
116
+ current: Array<{ key: string; title: string; agentId: string }>;
117
+ updatedAt: string;
118
+ usage: ReturnType<typeof usageOf>;
119
+ /** False when the index still lists a requirement whose directory is gone. */
120
+ present: boolean;
121
+ }
122
+
123
+ function item(repo: string, branch: string | null, featureDir: string, extra: { sourceRepo: string | null; workspaceId: string | null; project: string }): ListItem | null {
124
+ const name = path.basename(featureDir);
125
+ const summary = summarize({ path: repo, branch }, name);
126
+ if (!summary) return null;
127
+ const state = readStateSafe(repo, featureDir);
128
+ return {
129
+ key: requirementKey(extra.sourceRepo ?? repo, featureDir),
130
+ project: extra.project,
131
+ repo,
132
+ sourceRepo: extra.sourceRepo,
133
+ workspaceId: extra.workspaceId ?? state?.worktree?.workspaceId ?? null,
134
+ featureDir: summary.featureDir,
135
+ name,
136
+ title: summary.title,
137
+ type: state?.type ?? null,
138
+ profile: summary.profile ?? state?.profile ?? null,
139
+ branch: state?.branch ?? summary.branch,
140
+ stage: summary.stage,
141
+ status: state?.status ?? null,
142
+ running: !!state && state.status === "running" && isAlive(state.pid),
143
+ phase: state?.phase ?? null,
144
+ attention: attentionOf(state),
145
+ current: currentSteps(state),
146
+ updatedAt: summary.updatedAt,
147
+ usage: usageOf(state),
148
+ present: true,
149
+ };
150
+ }
151
+
152
+ /** Requirements of the repository at `repo` (all its worktrees), or of the whole machine. */
153
+ export function apiList(input: { repo: string; all: boolean }): ListItem[] {
154
+ if (!input.all) {
155
+ const repo = fs.realpathSync(input.repo);
156
+ return listFeatures(repo)
157
+ .map((f) => {
158
+ const state = readStateSafe(f.worktree, f.featureDir);
159
+ const source = state?.worktree?.sourceRepo ?? null;
160
+ return item(f.worktree, f.branch, f.featureDir, { sourceRepo: source, workspaceId: state?.worktree?.workspaceId ?? null, project: path.basename(source ?? repo) });
161
+ })
162
+ .filter((x): x is ListItem => x !== null);
163
+ }
164
+ const out: ListItem[] = [];
165
+ for (const e of readIndex()) {
166
+ const found = e.present ? item(e.repo, null, e.featureDir, { sourceRepo: e.sourceRepo, workspaceId: e.workspaceId, project: e.project }) : null;
167
+ out.push(
168
+ found ?? {
169
+ key: e.key,
170
+ project: e.project,
171
+ repo: e.repo,
172
+ sourceRepo: e.sourceRepo,
173
+ workspaceId: e.workspaceId,
174
+ featureDir: e.featureDir,
175
+ name: path.basename(e.featureDir),
176
+ title: path.basename(e.featureDir),
177
+ type: null,
178
+ profile: null,
179
+ branch: null,
180
+ stage: "已移除(目录不在了)",
181
+ status: null,
182
+ running: false,
183
+ phase: null,
184
+ attention: null,
185
+ current: [],
186
+ updatedAt: e.registeredAt,
187
+ usage: null,
188
+ present: false,
189
+ },
190
+ );
191
+ }
192
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
193
+ }
194
+
195
+ /** The repository and feature directory a requirement argument names, as `req status` resolves it. */
196
+ export function locate(repoArg: string, feature: string): { repo: string; featureDir: string; sourceRepo: string | null } {
197
+ if (!fs.existsSync(repoArg)) throw new ApiError("not_found", `目录不存在:${repoArg}`);
198
+ const repo = fs.realpathSync(repoArg);
199
+ let here: string | null = null;
200
+ try {
201
+ here = resolveFeatureArg(repo, feature);
202
+ } catch {
203
+ here = null;
204
+ }
205
+ if (here && fs.existsSync(statePath(repo, here))) return { repo, featureDir: here, sourceRepo: null };
206
+ const away = findWorktreeRun(repo, here ?? feature);
207
+ if (away) return { repo: away.repo, featureDir: away.featureDir, sourceRepo: repo };
208
+ if (here) return { repo, featureDir: here, sourceRepo: null };
209
+ throw new ApiError("not_found", `没有找到需求"${feature}"(${repo})`);
210
+ }
211
+
212
+ function stepJson(h: StepRecord, state: PipelineState) {
213
+ const minutes = Math.max(0, ((h.endedAt ? new Date(h.endedAt) : new Date()).getTime() - new Date(h.startedAt).getTime()) / 60_000);
214
+ return {
215
+ key: h.key,
216
+ stepId: h.stepId,
217
+ title: stepName(h, state),
218
+ phase: h.phase ?? null,
219
+ attempt: h.attempt,
220
+ round: h.round,
221
+ agentId: h.agentId,
222
+ startedAt: h.startedAt,
223
+ endedAt: h.endedAt,
224
+ minutes: Math.round(minutes * 10) / 10,
225
+ outcome: h.outcome,
226
+ result: h.outcome === "completed" ? resultText(h) : null,
227
+ error: h.error,
228
+ usage: h.usage ?? null,
229
+ };
230
+ }
231
+
232
+ export function apiStatus(repoArg: string, feature: string) {
233
+ const { repo, featureDir, sourceRepo } = locate(repoArg, feature);
234
+ const state = readStateSafe(repo, featureDir);
235
+ const source = sourceRepo ?? state?.worktree?.sourceRepo ?? null;
236
+ const base = item(repo, state?.branch ?? null, featureDir, { sourceRepo: source, workspaceId: state?.worktree?.workspaceId ?? null, project: path.basename(source ?? repo) });
237
+ if (!base) throw new ApiError("not_found", `${featureDir} 中没有 spec.md`);
238
+ const dir = path.join(repo, featureDir);
239
+ return {
240
+ ...base,
241
+ files: READABLE_FILES.filter((f) => fs.existsSync(path.join(dir, f))),
242
+ run: state
243
+ ? {
244
+ attempt: state.attempt,
245
+ startedAt: state.startedAt,
246
+ updatedAt: state.updatedAt,
247
+ pid: state.pid,
248
+ phaseMode: state.phases?.mode ?? null,
249
+ stopReason: state.stopReason,
250
+ outcome: state.outcome,
251
+ queuedSince: state.queuedSince ?? null,
252
+ verifyAt: state.verifyAt ?? null,
253
+ approval: state.approval ?? null,
254
+ originAgentId: state.originAgentId,
255
+ }
256
+ : null,
257
+ steps: state ? state.history.map((h) => stepJson(h, state)) : [],
258
+ gate: state?.gate ?? null,
259
+ joint: (state?.joint ?? []).map((j) => ({ name: j.name, slug: j.slug, repo: j.repo, sourceRepo: j.sourceRepo, branch: j.branch, workspaceId: j.workspaceId, gate: j.gate ?? null })),
260
+ };
261
+ }
262
+
263
+ export function apiFile(repoArg: string, feature: string, file: string) {
264
+ if (!(READABLE_FILES as readonly string[]).includes(file)) {
265
+ throw new ApiError("not_allowed", `只能读取这些文件:${READABLE_FILES.join("、")}`);
266
+ }
267
+ const { repo, featureDir } = locate(repoArg, feature);
268
+ const full = path.join(repo, featureDir, file);
269
+ if (!fs.existsSync(full)) throw new ApiError("not_found", `${featureDir} 中没有 ${file}`);
270
+ const size = fs.statSync(full).size;
271
+ const buf = fs.readFileSync(full);
272
+ return { featureDir, file, size, truncated: size > MAX_FILE_BYTES, content: buf.subarray(0, MAX_FILE_BYTES).toString("utf8") };
273
+ }
274
+
275
+ /** Registers the requirements that have run in these directories' repositories (all worktrees). */
276
+ export function apiIndex(input: { rebuild: string[]; prune: boolean }) {
277
+ let registered = 0;
278
+ const dirs = new Set(input.rebuild.map((d) => path.resolve(d)));
279
+ for (const e of readIndex()) if (input.rebuild.length > 0 && e.present) dirs.add(e.sourceRepo ?? e.repo);
280
+ for (const dir of dirs) {
281
+ if (!fs.existsSync(dir)) continue;
282
+ for (const f of listFeatures(dir)) {
283
+ const state = readStateSafe(f.worktree, f.featureDir);
284
+ if (!state) continue;
285
+ registerRequirement({ repo: f.worktree, featureDir: f.featureDir, worktree: state.worktree ?? null });
286
+ registered++;
287
+ }
288
+ }
289
+ const pruned = input.prune ? pruneIndex() : 0;
290
+ return { scanned: [...dirs], registered, pruned, total: readIndex().length };
291
+ }
292
+
293
+ const RUNNABLE = ["approve", "stop", "resume", "verify"] as const;
294
+
295
+ /** Runs `req <command> <需求>` where people would, and reports what it said. */
296
+ export function apiRun(command: string, repoArg: string, feature: string, extra: string[]) {
297
+ if (!(RUNNABLE as readonly string[]).includes(command)) throw new ApiError("not_allowed", `只能执行:${RUNNABLE.join("、")}`);
298
+ if (!fs.existsSync(repoArg)) throw new ApiError("not_found", `目录不存在:${repoArg}`);
299
+ const cwd = fs.realpathSync(repoArg);
300
+ // Not from a chat: the run keeps reporting to the chat that started it.
301
+ const env = { ...process.env };
302
+ delete env.PASEO_AGENT_ID;
303
+ delete env.PASEO_AGENT_CWD;
304
+ const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), command, feature, ...extra], {
305
+ cwd,
306
+ env,
307
+ encoding: "utf8",
308
+ timeout: 180_000,
309
+ windowsHide: true,
310
+ });
311
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
312
+ return { command, featureDir: feature, ok: r.status === 0, exitCode: r.status, message: r.error ? r.error.message : output };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // CLI
317
+
318
+ function option(argv: string[], name: string): string | null {
319
+ const i = argv.indexOf(name);
320
+ if (i < 0) return null;
321
+ const v = argv[i + 1];
322
+ if (v === undefined || v.startsWith("--")) throw new ApiError("bad_arguments", `${name} 后面要写值`);
323
+ argv.splice(i, 2);
324
+ return v;
325
+ }
326
+
327
+ function flag(argv: string[], name: string): boolean {
328
+ const i = argv.indexOf(name);
329
+ if (i < 0) return false;
330
+ argv.splice(i, 1);
331
+ return true;
332
+ }
333
+
334
+ export function apiMain(argvIn: string[], cwd = process.cwd()): unknown {
335
+ const argv = [...argvIn];
336
+ const command = argv.shift();
337
+ const repo = option(argv, "--repo") ?? cwd;
338
+ const need = (what: string) => {
339
+ const v = argv.shift();
340
+ if (!v || v.startsWith("--")) throw new ApiError("bad_arguments", `缺少${what}`);
341
+ return v;
342
+ };
343
+ switch (command) {
344
+ case "version":
345
+ return {
346
+ kitVersion: kitVersion(),
347
+ kitRoot: KIT_ROOT,
348
+ apiSchema: API_SCHEMA,
349
+ paseo: (JSON.parse(fs.readFileSync(path.join(KIT_ROOT, "package.json"), "utf8")) as { reqKit?: { paseo?: string } }).reqKit?.paseo ?? null,
350
+ featuresDir: FEATURES_DIR.split(path.sep).join("/"),
351
+ };
352
+ case "list":
353
+ return apiList({ repo, all: flag(argv, "--all") });
354
+ case "status":
355
+ return apiStatus(repo, need("需求"));
356
+ case "file": {
357
+ const feature = need("需求");
358
+ return apiFile(repo, feature, need("文件名"));
359
+ }
360
+ case "stats": {
361
+ const since = option(argv, "--since");
362
+ const month = option(argv, "--month");
363
+ return summarizeRuns(readRuns(runsFile()), parsePeriod({ since, month }));
364
+ }
365
+ case "index": {
366
+ const prune = flag(argv, "--prune");
367
+ const rebuild: string[] = [];
368
+ if (flag(argv, "--rebuild")) {
369
+ while (argv[0] && !argv[0].startsWith("--")) rebuild.push(argv.shift()!);
370
+ if (rebuild.length === 0) rebuild.push(cwd);
371
+ }
372
+ return rebuild.length > 0 || prune ? apiIndex({ rebuild, prune }) : readIndex();
373
+ }
374
+ case "run": {
375
+ const what = need("命令(approve、stop、resume、verify)");
376
+ return apiRun(what, repo, need("需求"), argv.splice(0));
377
+ }
378
+ default:
379
+ throw new ApiError("bad_arguments", `没有 "${command ?? ""}" 这个 api 命令。可用:version、list、status、file、stats、index、run`);
380
+ }
381
+ }
382
+
383
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
384
+ let code = 0;
385
+ let out: Record<string, unknown>;
386
+ try {
387
+ out = { schema: API_SCHEMA, kitVersion: kitVersion(), data: apiMain(process.argv.slice(2)) };
388
+ const data = out.data as { ok?: boolean } | null;
389
+ if (data && data.ok === false) code = 1;
390
+ } catch (error) {
391
+ const e = error as Error & { code?: string };
392
+ out = { schema: API_SCHEMA, kitVersion: kitVersion(), error: { code: error instanceof ApiError ? e.code : "failed", message: e.message } };
393
+ code = 1;
394
+ }
395
+ process.stdout.write(`${JSON.stringify(out)}\n`);
396
+ process.exit(code);
397
+ }
@@ -31,7 +31,7 @@ const RESULT_TEXT: Record<string, string> = {
31
31
  not_completed: "未完成",
32
32
  };
33
33
 
34
- function resultText(record: StepRecord): string {
34
+ export function resultText(record: StepRecord): string {
35
35
  const r = record.result ?? {};
36
36
  if (typeof r.has_runnable_service === "boolean") return r.has_runnable_service ? "完成(项目有常驻服务)" : "完成";
37
37
  if (typeof r.started === "boolean") return r.started ? "服务启动成功" : "服务启动失败";
@@ -42,7 +42,7 @@ function resultText(record: StepRecord): string {
42
42
  return "完成";
43
43
  }
44
44
 
45
- function stepName(record: StepRecord, state: Pick<PipelineState, "type" | "joint"> | null = null): string {
45
+ export function stepName(record: StepRecord, state: Pick<PipelineState, "type" | "joint"> | null = null): string {
46
46
  const [base, slug] = record.key.split("@");
47
47
  const repo = slug ? (state?.joint ?? []).find((j) => j.slug === slug)?.name ?? slug : null;
48
48
  const title = `${stepTitle(state?.type, record.stepId)}${repo ? `·${repo}` : ""}`;
@@ -104,6 +104,8 @@ function runPipeline(repo: string, featureDir: string, c: EvalCase, configPath:
104
104
  const env = { ...process.env };
105
105
  delete env.PASEO_AGENT_ID;
106
106
  delete env.PASEO_AGENT_CWD;
107
+ // Evaluation runs are not real requirements: keep them out of the machine's index and usage records.
108
+ env.REQ_KIT_NO_RECORD = "1";
107
109
  const run = spawnSync(process.execPath, args, { cwd: repo, env, encoding: "utf8", timeout: timeoutMs, windowsHide: true });
108
110
  fs.appendFileSync(path.join(repo, featureDir, ".pipeline", "eval-run.log"), `${run.stdout ?? ""}${run.stderr ?? ""}`);
109
111
  if (run.error) return `流水线进程出错:${run.error.message}`;
@@ -0,0 +1,33 @@
1
+ // `req index`: the machine's list of requirements (records.ts), for people.
2
+ // req index what is recorded
3
+ // req index --rebuild [<dir>...] record the requirements that have run in these repositories
4
+ // (default: here) and in those already recorded
5
+ // req index --prune forget the requirements whose directory is gone
6
+
7
+ import path from "node:path";
8
+ import { apiIndex } from "./api.js";
9
+ import { indexDir, readIndex } from "./records.js";
10
+
11
+ const argv = process.argv.slice(2);
12
+ const prune = argv.includes("--prune");
13
+ const rebuildAt = argv.indexOf("--rebuild");
14
+ const dirs = rebuildAt < 0 ? [] : argv.slice(rebuildAt + 1).filter((a) => !a.startsWith("--"));
15
+ if (rebuildAt >= 0 && dirs.length === 0) dirs.push(process.cwd());
16
+
17
+ if (rebuildAt >= 0 || prune) {
18
+ const r = apiIndex({ rebuild: dirs, prune });
19
+ if (rebuildAt >= 0) console.log(`扫描了 ${r.scanned.length} 个仓库,登记 ${r.registered} 个运行过的需求。`);
20
+ if (prune) console.log(`移除了 ${r.pruned} 个目录已不在的需求。`);
21
+ console.log(`本机共记录 ${r.total} 个需求(${indexDir()})。`);
22
+ } else {
23
+ const entries = readIndex();
24
+ if (entries.length === 0) {
25
+ console.log("本机还没有记录任何需求。1.9 之前运行过的需求:在项目目录运行 req index --rebuild。");
26
+ } else {
27
+ console.log(`本机记录的需求(${entries.length} 个;查看进度用 req api list --all 或 Paseo 插件):`);
28
+ for (const e of entries) {
29
+ console.log(`- ${e.project}:${path.basename(e.featureDir)}${e.present ? "" : "(目录已不在,req index --prune 可以移除)"}`);
30
+ console.log(` ${path.join(e.repo, e.featureDir)}`);
31
+ }
32
+ }
33
+ }
@@ -2,6 +2,7 @@
2
2
  // from the chat agent's shell; can also be run in the foreground for debugging.
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
5
6
  import path from "node:path";
6
7
  import { approveCommand, LAUNCHER, parseLaunchArgs, resumeCommand, statusCommand, verifyCommand } from "./cli-args.js";
7
8
  import { dequeuedMessage, queuedMessage, watchdogPrompt } from "./messages.js";
@@ -13,6 +14,7 @@ import { satisfies } from "./daemon-check.js";
13
14
  import { describeAgentProfile, resolveConfigProfiles, usesPaseoProfiles } from "./paseo-profiles.js";
14
15
  import { jointProblem } from "./joint.js";
15
16
  import { runPipeline } from "./pipeline.js";
17
+ import { appendRun, runRecord } from "./records.js";
16
18
  import { preflight, verifyPhaseProblem } from "./preflight.js";
17
19
  import { loadRuntimeFactory } from "./runtime-loader.js";
18
20
  import {
@@ -243,6 +245,28 @@ async function main(): Promise<number> {
243
245
  writeState(stateFile, state);
244
246
  }
245
247
 
248
+ // This run's usage record (1.9): written once when the process ends, however it ends.
249
+ let recorded = false;
250
+ const recordRun = () => {
251
+ if (recorded) return;
252
+ recorded = true;
253
+ const title = (() => {
254
+ try {
255
+ return (/^#\s+(.+)$/m.exec(fs.readFileSync(checked.specPath, "utf8"))?.[1] ?? path.basename(featureDir)).trim();
256
+ } catch {
257
+ return path.basename(featureDir);
258
+ }
259
+ })();
260
+ appendRun(
261
+ runRecord({
262
+ state,
263
+ runStartedAt: now,
264
+ title,
265
+ agents: { builder: describeAgentProfile(config.agents.builder), reviewer: describeAgentProfile(config.agents.reviewer) },
266
+ }),
267
+ );
268
+ };
269
+
246
270
  let releaseSlot = () => {};
247
271
  const shutdown = (signal: string) => {
248
272
  log(`收到 ${signal},流水线停止`);
@@ -250,6 +274,7 @@ async function main(): Promise<number> {
250
274
  state.stopReason = `流水线进程被 ${signal} 终止`;
251
275
  state.queuedSince = null;
252
276
  writeState(stateFile, state);
277
+ recordRun();
253
278
  releaseSlot();
254
279
  release();
255
280
  process.exit(1);
@@ -331,6 +356,7 @@ async function main(): Promise<number> {
331
356
  writeState(stateFile, state);
332
357
  }
333
358
  await runtime.close().catch(() => undefined);
359
+ recordRun();
334
360
  releaseSlot();
335
361
  release();
336
362
  }