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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -0,0 +1,271 @@
1
+ // Machine records under the kit home (1.9, ADR 0026): what this machine knows beyond one
2
+ // repository. Neither is committed anywhere; both are local to the machine.
3
+ //
4
+ // index/<key>.json one file per requirement that has run here: where it lives, so
5
+ // `req api list --all` and the Paseo plugin can find every requirement
6
+ // on the machine. The overview itself is still read live (ADR 0010).
7
+ // usage/runs.jsonl one line per pipeline run (a process from start to stop): steps,
8
+ // time, usage, gate and review rounds. What `req stats` reports on.
9
+ //
10
+ // Recording never affects a run: every write is best effort. REQ_KIT_NO_RECORD=1 turns both
11
+ // off (evaluation runs are not real requirements).
12
+
13
+ import { createHash } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { kitHome, kitVersion } from "./locations.js";
18
+ import type { PipelineState } from "./state.js";
19
+
20
+ export const RECORD_SCHEMA = 1;
21
+
22
+ function recordingOff(env: NodeJS.ProcessEnv = process.env): boolean {
23
+ return env.REQ_KIT_NO_RECORD === "1" || env.REQ_KIT_NO_RECORD === "true";
24
+ }
25
+
26
+ function real(p: string): string {
27
+ try {
28
+ return fs.realpathSync(p);
29
+ } catch {
30
+ return path.resolve(p);
31
+ }
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Index
36
+
37
+ export interface IndexEntry {
38
+ schema: 1;
39
+ /** Stable key of the requirement on this machine (also the file name). */
40
+ key: string;
41
+ /** Where the requirement is developed (its own worktree when it has one). */
42
+ repo: string;
43
+ featureDir: string;
44
+ /** The checkout the chat works in, when the requirement has its own worktree. */
45
+ sourceRepo: string | null;
46
+ workspaceId: string | null;
47
+ /** Name of the project: the source checkout's directory name. */
48
+ project: string;
49
+ registeredAt: string;
50
+ }
51
+
52
+ export function indexDir(env: NodeJS.ProcessEnv = process.env): string {
53
+ return path.join(kitHome(env), "index");
54
+ }
55
+
56
+ /** Key of a requirement: the checkout people work in plus the feature directory. */
57
+ export function requirementKey(sourceOrRepo: string, featureDir: string): string {
58
+ return createHash("sha1").update(`${real(sourceOrRepo)}\0${featureDir.split(path.sep).join("/")}`).digest("hex").slice(0, 16);
59
+ }
60
+
61
+ /** Records where this requirement lives, if it is not recorded that way already. Never throws. */
62
+ export function registerRequirement(state: Pick<PipelineState, "repo" | "featureDir" | "worktree">, env: NodeJS.ProcessEnv = process.env): void {
63
+ if (recordingOff(env)) return;
64
+ try {
65
+ const source = state.worktree?.sourceRepo ?? null;
66
+ const key = requirementKey(source ?? state.repo, state.featureDir);
67
+ const file = path.join(indexDir(env), `${key}.json`);
68
+ const existing = readEntry(file);
69
+ const entry: IndexEntry = {
70
+ schema: 1,
71
+ key,
72
+ repo: real(state.repo),
73
+ featureDir: state.featureDir.split(path.sep).join("/"),
74
+ sourceRepo: source ? real(source) : null,
75
+ workspaceId: state.worktree?.workspaceId ?? null,
76
+ project: path.basename(real(source ?? state.repo)),
77
+ registeredAt: existing?.registeredAt ?? new Date().toISOString(),
78
+ };
79
+ if (existing && JSON.stringify(existing) === JSON.stringify(entry)) return;
80
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ const tmp = `${file}.${process.pid}.tmp`;
82
+ fs.writeFileSync(tmp, `${JSON.stringify(entry, null, 2)}\n`);
83
+ fs.renameSync(tmp, file);
84
+ } catch {
85
+ // The index is a convenience; the run goes on without it.
86
+ }
87
+ }
88
+
89
+ function readEntry(file: string): IndexEntry | null {
90
+ try {
91
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as IndexEntry;
92
+ return raw && typeof raw.repo === "string" && typeof raw.featureDir === "string" ? raw : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** Every recorded requirement, with whether its directory is still there. */
99
+ export function readIndex(env: NodeJS.ProcessEnv = process.env): Array<IndexEntry & { present: boolean }> {
100
+ const dir = indexDir(env);
101
+ if (!fs.existsSync(dir)) return [];
102
+ return fs
103
+ .readdirSync(dir)
104
+ .filter((n) => n.endsWith(".json"))
105
+ .map((n) => readEntry(path.join(dir, n)))
106
+ .filter((e): e is IndexEntry => e !== null)
107
+ .map((e) => ({ ...e, present: fs.existsSync(path.join(e.repo, e.featureDir, ".pipeline", "state.json")) }));
108
+ }
109
+
110
+ /** Removes the entries whose requirement is gone. Returns how many. */
111
+ export function pruneIndex(env: NodeJS.ProcessEnv = process.env): number {
112
+ let removed = 0;
113
+ for (const e of readIndex(env)) {
114
+ if (e.present) continue;
115
+ fs.rmSync(path.join(indexDir(env), `${e.key}.json`), { force: true });
116
+ removed++;
117
+ }
118
+ return removed;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Usage records
123
+
124
+ export interface StepUsageRecord {
125
+ key: string;
126
+ stepId: string;
127
+ phase: string | null;
128
+ attempt: number;
129
+ round: number | null;
130
+ startedAt: string;
131
+ endedAt: string | null;
132
+ minutes: number;
133
+ outcome: string;
134
+ inputTokens: number | null;
135
+ outputTokens: number | null;
136
+ costUsd: number | null;
137
+ }
138
+
139
+ /** One pipeline run: a process from its start to its end (contracts/usage-record.md). */
140
+ export interface RunRecord {
141
+ schema: 1;
142
+ kind: "run";
143
+ machine: string;
144
+ kitVersion: string;
145
+ requirement: {
146
+ key: string;
147
+ project: string;
148
+ name: string;
149
+ title: string;
150
+ type: string | null;
151
+ profile: string;
152
+ };
153
+ run: {
154
+ startedAt: string;
155
+ endedAt: string;
156
+ attempt: number;
157
+ phase: string | null;
158
+ phaseMode: string | null;
159
+ /** State at the end of the run: completed, phase_done, waiting_for_approval, failed… */
160
+ status: string;
161
+ /** The report's verdict when the run completed (completed, completed_with_issues, not_completed). */
162
+ outcome: string | null;
163
+ stopReason: string | null;
164
+ };
165
+ agents: { builder: string | null; reviewer: string | null };
166
+ steps: StepUsageRecord[];
167
+ gate: { runs: number; failed: number };
168
+ }
169
+
170
+ export function usageDir(env: NodeJS.ProcessEnv = process.env): string {
171
+ return path.join(kitHome(env), "usage");
172
+ }
173
+
174
+ export function runsFile(env: NodeJS.ProcessEnv = process.env): string {
175
+ return path.join(usageDir(env), "runs.jsonl");
176
+ }
177
+
178
+ function minutesBetween(from: string, to: string | null, now: Date): number {
179
+ const end = to ? new Date(to) : now;
180
+ return Math.max(0, Math.round(((end.getTime() - new Date(from).getTime()) / 60_000) * 10) / 10);
181
+ }
182
+
183
+ /** The record of the run that started at `runStartedAt`, from the state at its end. */
184
+ export function runRecord(input: {
185
+ state: PipelineState;
186
+ runStartedAt: string;
187
+ title: string;
188
+ agents: { builder: string | null; reviewer: string | null };
189
+ now?: Date;
190
+ machine?: string;
191
+ }): RunRecord {
192
+ const { state } = input;
193
+ const now = input.now ?? new Date();
194
+ const source = state.worktree?.sourceRepo ?? state.repo;
195
+ const since = new Date(input.runStartedAt).getTime();
196
+ const steps = state.history
197
+ .filter((h) => new Date(h.startedAt).getTime() >= since)
198
+ .map(
199
+ (h): StepUsageRecord => ({
200
+ key: h.key,
201
+ stepId: h.stepId,
202
+ phase: h.phase ?? null,
203
+ attempt: h.attempt,
204
+ round: h.round,
205
+ startedAt: h.startedAt,
206
+ endedAt: h.endedAt,
207
+ minutes: minutesBetween(h.startedAt, h.endedAt, now),
208
+ outcome: h.outcome,
209
+ inputTokens: h.usage?.inputTokens ?? null,
210
+ outputTokens: h.usage?.outputTokens ?? null,
211
+ costUsd: h.usage?.totalCostUsd ?? null,
212
+ }),
213
+ );
214
+ const gateRuns = [...(state.gate?.runs ?? []), ...(state.joint ?? []).flatMap((j) => j.gate?.runs ?? [])].filter(
215
+ (r) => new Date(r.at).getTime() >= since,
216
+ );
217
+ return {
218
+ schema: 1,
219
+ kind: "run",
220
+ machine: input.machine ?? os.hostname(),
221
+ kitVersion: kitVersion(),
222
+ requirement: {
223
+ key: requirementKey(source, state.featureDir),
224
+ project: path.basename(real(source)),
225
+ name: path.basename(state.featureDir),
226
+ title: input.title,
227
+ type: state.type ?? null,
228
+ profile: state.profile,
229
+ },
230
+ run: {
231
+ startedAt: input.runStartedAt,
232
+ endedAt: now.toISOString(),
233
+ attempt: state.attempt,
234
+ phase: state.phase ?? null,
235
+ phaseMode: state.phases?.mode ?? null,
236
+ status: state.status,
237
+ outcome: state.status === "completed" ? state.outcome : null,
238
+ stopReason: state.stopReason,
239
+ },
240
+ agents: input.agents,
241
+ steps,
242
+ gate: { runs: gateRuns.length, failed: gateRuns.filter((r) => !r.passed).length },
243
+ };
244
+ }
245
+
246
+ /** Appends a run record. Never throws. */
247
+ export function appendRun(record: RunRecord, env: NodeJS.ProcessEnv = process.env): void {
248
+ if (recordingOff(env)) return;
249
+ try {
250
+ fs.mkdirSync(usageDir(env), { recursive: true });
251
+ fs.appendFileSync(runsFile(env), `${JSON.stringify(record)}\n`);
252
+ } catch {
253
+ // Usage records are a convenience; the run is not affected.
254
+ }
255
+ }
256
+
257
+ /** Run records from a JSONL file (this machine's, or an export from another). Bad lines are skipped. */
258
+ export function readRuns(file: string): RunRecord[] {
259
+ if (!fs.existsSync(file)) return [];
260
+ const out: RunRecord[] = [];
261
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
262
+ if (!line.trim()) continue;
263
+ try {
264
+ const r = JSON.parse(line) as RunRecord;
265
+ if (r && r.kind === "run" && r.schema === RECORD_SCHEMA && r.requirement && r.run) out.push(r);
266
+ } catch {
267
+ // A torn last line (power loss mid-write) or a foreign line.
268
+ }
269
+ }
270
+ return out;
271
+ }
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { DefectHandling, PhaseMode, ProfileName, RequirementType, VerifyEnvironment } from "./config.js";
6
+ import { registerRequirement } from "./records.js";
6
7
  import type { AgentUsage } from "./usage.js";
7
8
  import type { StepId } from "./steps.js";
8
9
 
@@ -246,6 +247,8 @@ export function writeState(file: string, state: PipelineState): void {
246
247
  const tmp = `${file}.${process.pid}.tmp`;
247
248
  fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
248
249
  fs.renameSync(tmp, file);
250
+ // The machine's index of requirements (1.9): written once, and again only if it moved.
251
+ registerRequirement(state);
249
252
  }
250
253
 
251
254
  export function hashFile(file: string): string {