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,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 {