@turri-dev/cli 0.1.8-next.bcca62a → 0.1.8-next.bfcb24e

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 (49) hide show
  1. package/README.md +0 -2
  2. package/dist/cli.js +2 -1
  3. package/dist/config-edit.d.ts +1 -1
  4. package/dist/config-edit.js +2 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +5 -1
  7. package/dist/extension.js +75 -9
  8. package/dist/host.d.ts +2 -0
  9. package/dist/host.js +3 -0
  10. package/dist/init-config.d.ts +1 -0
  11. package/dist/init-config.js +5 -0
  12. package/dist/ledger-cli.d.ts +1 -0
  13. package/dist/ledger-cli.js +50 -0
  14. package/dist/ledger-tools.d.ts +10 -0
  15. package/dist/ledger-tools.js +64 -0
  16. package/dist/loop-cli.js +4 -0
  17. package/dist/pi.js +17 -0
  18. package/dist/regenerate/activity.d.ts +9 -0
  19. package/dist/regenerate/activity.js +11 -0
  20. package/dist/regenerate/agent-shell.js +5 -0
  21. package/dist/regenerate/chunk-plan.d.ts +0 -6
  22. package/dist/regenerate/chunk-plan.js +16 -4
  23. package/dist/regenerate/conformance.js +3 -0
  24. package/dist/regenerate/diagnose.js +4 -2
  25. package/dist/regenerate/job-log.d.ts +3 -1
  26. package/dist/regenerate/job-log.js +8 -5
  27. package/dist/regenerate/jobs-tree.d.ts +7 -3
  28. package/dist/regenerate/jobs-tree.js +21 -6
  29. package/dist/regenerate/jobs.d.ts +9 -0
  30. package/dist/regenerate/jobs.js +38 -3
  31. package/dist/regenerate/ledger-view.d.ts +92 -0
  32. package/dist/regenerate/ledger-view.js +345 -0
  33. package/dist/regenerate/ledger.d.ts +275 -0
  34. package/dist/regenerate/ledger.js +349 -0
  35. package/dist/regenerate/lineage.js +18 -1
  36. package/dist/regenerate/loop.js +10 -1
  37. package/dist/regenerate/regenerate.js +4 -2
  38. package/dist/regenerate/regression.js +2 -0
  39. package/dist/regenerate/side-panel.d.ts +2 -0
  40. package/dist/regenerate/side-panel.js +4 -3
  41. package/dist/regenerate/test-scripts.js +18 -5
  42. package/dist/regenerate/unit-review.js +3 -0
  43. package/docs/config.md +3 -1
  44. package/docs/development.md +1 -0
  45. package/docs/hosting.md +28 -0
  46. package/docs/installation.md +1 -0
  47. package/docs/regenerating.md +1 -1
  48. package/docs/using.md +29 -2
  49. package/package.json +1 -1
@@ -0,0 +1,349 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { gunzipSync, gzipSync } from "node:zlib";
5
+ import { readProjectConfig } from "../config.js";
6
+ import { turriIdentity } from "../environment.js";
7
+ import { currentPlace, onModelEvent } from "./activity.js";
8
+ import { maskSecrets } from "./masking.js";
9
+ import { ensureInScratch } from "./paths.js";
10
+ /**
11
+ * The ledger: what turri does, as typed facts, one JSON line each, written as it happens - to
12
+ * `.turri/ledger/` in the project, one file a run (a conversation, a headless run, a host's session).
13
+ *
14
+ * The job and debug logs keep the bodies - every prompt, reply and tool result, whole - for reading one
15
+ * pulse closely. The ledger keeps the facts about them, across pulses, sessions and runs: that a pulse
16
+ * was queued, started, went through its stages and ended, and how; what each agent's model calls and
17
+ * turns cost; each tool call and test run, how long it took and how it ended; the gates' rounds and
18
+ * verdicts; what turri refused or stopped; what its healers did; the history's commits. What a pulse
19
+ * from an earlier session did is looked up here (`pulses`, `diagnose`, `turri ledger`). Never stored:
20
+ * prompts, replies, thinking, tool arguments and results, file contents - and what strings an event
21
+ * carries are masked as the logs are.
22
+ *
23
+ * A host hears each event as it is recorded (`onLedgerEvent`), with the project it is about, rather than reading the
24
+ * file again - written or not, so a project that keeps no ledger is heard all the same.
25
+ *
26
+ * Observation, never control: turri may react to events as they happen, but never reads the ledger on
27
+ * disk to decide what to build - the code and tests records are that (records.ts). Deleting
28
+ * `.turri/ledger/` loses only what can be looked up. A ledger that cannot be written stops, and never
29
+ * fails the work.
30
+ *
31
+ * Closed runs are gzipped, and the oldest pruned past `ledger-keep:` megabytes (100 unset; 0 keeps none),
32
+ * when a run first writes to the project. `.turri` ignores itself, so none of it is ever committed.
33
+ */
34
+ export const LEDGER_DIR = "ledger";
35
+ /** Megabytes of ledger a project keeps when `ledger-keep:` does not say. */
36
+ export const DEFAULT_LEDGER_KEEP_MB = 100;
37
+ let current;
38
+ let seq = 0;
39
+ const listeners = new Set();
40
+ /**
41
+ * Hear every event from now on, as it is recorded, with the project it is about - while the ledger is on. Returns
42
+ * what stops it. A listener that throws is left out of that event, never the work.
43
+ */
44
+ export function onLedgerEvent(listener) {
45
+ listeners.add(listener);
46
+ return () => listeners.delete(listener);
47
+ }
48
+ function tell(entry, project) {
49
+ for (const listener of listeners) {
50
+ try {
51
+ listener(entry, project);
52
+ }
53
+ catch {
54
+ /* a listener that fails does not take the work down */
55
+ }
56
+ }
57
+ }
58
+ /** Whether turri turns the ledger on as it starts: always, unless TURRI_LEDGER=off. */
59
+ export function ledgerWanted() {
60
+ return process.env.TURRI_LEDGER !== "off";
61
+ }
62
+ /** This run's id, while the ledger is on. */
63
+ export function ledgerRun() {
64
+ return current?.id;
65
+ }
66
+ /**
67
+ * Start the ledger for this process: from now on, what is recorded is written. `project` is the one events about no
68
+ * project of their own go to. `warn` is told, once a project, when its ledger cannot be written. Returns what stops it.
69
+ */
70
+ export function startLedger(options) {
71
+ stopLedger();
72
+ const run = {
73
+ id: randomUUID().slice(0, 8),
74
+ mode: options.mode,
75
+ started: Date.now(),
76
+ ...(options.project !== undefined ? { project: resolve(options.project) } : {}),
77
+ writers: new Map(),
78
+ ...(options.warn !== undefined ? { warn: options.warn } : {}),
79
+ unsubscribe: () => { },
80
+ };
81
+ current = run;
82
+ run.unsubscribe = onModelEvent((event) => recordModelEvent(event));
83
+ process.once("exit", endRun);
84
+ return stopLedger;
85
+ }
86
+ /** Stop the ledger: each project this run wrote to is told it ended. */
87
+ export function stopLedger() {
88
+ if (current === undefined)
89
+ return;
90
+ endRun();
91
+ }
92
+ function endRun() {
93
+ const run = current;
94
+ if (run === undefined)
95
+ return;
96
+ process.removeListener("exit", endRun);
97
+ for (const project of run.writers.keys())
98
+ write(run, project, { kind: "run.ended", ms: Date.now() - run.started }, {});
99
+ run.unsubscribe();
100
+ current = undefined;
101
+ }
102
+ /** Record an event: where it happened is the job this code runs for, unless `place` says. Nothing while the ledger is off. */
103
+ export function record(event, place = {}) {
104
+ const run = current;
105
+ if (run === undefined)
106
+ return;
107
+ const here = currentPlace();
108
+ const project = place.project ?? here.project ?? run.project;
109
+ if (project === undefined)
110
+ return;
111
+ const job = place.job ?? here.job;
112
+ const module = place.module ?? here.module;
113
+ write(run, resolve(project), event, { ...(job !== undefined ? { job } : {}), ...(module !== undefined ? { module } : {}), ...(place.agent !== undefined ? { agent: place.agent } : {}) });
114
+ }
115
+ /**
116
+ * Run a healer - the debugger, the diagnosis of a failed pulse - and record it: started, ended with what it found, or
117
+ * failed and why. A healer that fails gives undefined, as it did before it was recorded, but is no longer silent.
118
+ */
119
+ export async function healing(healer, work, found) {
120
+ const started = Date.now();
121
+ record({ kind: "healer.started", healer });
122
+ try {
123
+ const result = await work();
124
+ const said = result !== undefined && found !== undefined ? found(result) : undefined;
125
+ record({ kind: "healer.ended", healer, ms: Date.now() - started, ...(said !== undefined ? { found: firstLine(said, 240) } : {}) });
126
+ return result;
127
+ }
128
+ catch (error) {
129
+ record({ kind: "healer.failed", healer, ms: Date.now() - started, error: firstLine(error instanceof Error ? error.message : String(error), 300) });
130
+ return undefined;
131
+ }
132
+ }
133
+ /** A failure's fingerprint, short: two runs failing the same way have the same one. */
134
+ export function signatureHash(signature) {
135
+ return createHash("sha1").update(signature).digest("hex").slice(0, 12);
136
+ }
137
+ /** The first line of a text, at most `max` characters: what an event keeps of an error or a reason. */
138
+ export function firstLine(text, max = 200) {
139
+ const line = text.trim().split("\n")[0] ?? "";
140
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
141
+ }
142
+ function recordModelEvent(event) {
143
+ const agent = { ...(event.role !== undefined ? { role: event.role } : {}), ...(event.label !== undefined ? { label: event.label } : {}), ...(event.model !== undefined ? { model: event.model } : {}) };
144
+ const place = { ...(event.job !== undefined ? { job: event.job } : {}), ...(Object.keys(agent).length > 0 ? { agent } : {}) };
145
+ switch (event.kind) {
146
+ case "call":
147
+ record({ kind: "model.call", ...(event.ms !== undefined ? { ms: event.ms } : {}), ...(event.usage !== undefined ? { usage: event.usage } : {}) }, place);
148
+ break;
149
+ case "turn":
150
+ record({ kind: "model.turn" }, place);
151
+ break;
152
+ case "compaction":
153
+ record({ kind: "model.compaction" }, place);
154
+ break;
155
+ case "provider_retry":
156
+ record({ kind: "provider.retry", error: firstLine(event.error ?? "", 300), attempt: event.attempt ?? 0, of: event.of ?? 0, waitMs: event.waitMs ?? 0 }, place);
157
+ break;
158
+ case "provider_stall":
159
+ record({ kind: "provider.stall", waitMs: event.waitMs ?? 0 }, place);
160
+ break;
161
+ case "stopped":
162
+ record({ kind: "turn.stopped", reason: firstLine(event.reason ?? "", 300) }, place);
163
+ break;
164
+ }
165
+ }
166
+ /** The project's ledger folder. */
167
+ export function ledgerDir(project) {
168
+ return join(resolve(project), ".turri", LEDGER_DIR);
169
+ }
170
+ /** How many megabytes of ledger the project keeps. */
171
+ function keepOf(project) {
172
+ try {
173
+ return readProjectConfig(project).ledgerKeep ?? DEFAULT_LEDGER_KEEP_MB;
174
+ }
175
+ catch {
176
+ return DEFAULT_LEDGER_KEEP_MB;
177
+ }
178
+ }
179
+ function write(run, project, event, where) {
180
+ let writer = run.writers.get(project);
181
+ if (writer === undefined) {
182
+ writer = openWriter(run, project);
183
+ run.writers.set(project, writer);
184
+ }
185
+ seq += 1;
186
+ const entry = { v: 1, ts: new Date().toISOString(), seq, run: run.id, ...where, ...masked(event) };
187
+ tell(entry, project);
188
+ if (writer.broken || writer.file === undefined)
189
+ return;
190
+ try {
191
+ appendFileSync(writer.file, `${JSON.stringify(entry)}\n`);
192
+ }
193
+ catch (error) {
194
+ writer.broken = true;
195
+ run.warn?.(`turri's ledger for ${project} cannot be written, and is not from now on: ${error instanceof Error ? error.message : String(error)}`);
196
+ }
197
+ }
198
+ /** The run's file in the project, begun with its run.started - or none, when the project keeps no ledger. */
199
+ function openWriter(run, project) {
200
+ const keep = keepOf(project);
201
+ const dir = ledgerDir(project);
202
+ const identity = turriIdentity();
203
+ seq += 1;
204
+ const started = {
205
+ kind: "run.started",
206
+ mode: run.mode,
207
+ version: identity.version,
208
+ commit: identity.git_commit,
209
+ dirty: identity.git_dirty,
210
+ pid: process.pid,
211
+ platform: process.platform,
212
+ node: process.versions.node,
213
+ };
214
+ const entry = { v: 1, ts: new Date().toISOString(), seq, run: run.id, ...started };
215
+ // Heard whether it is written or not: a project that keeps no ledger is still a run a host shows.
216
+ tell(entry, project);
217
+ try {
218
+ if (existsSync(dir))
219
+ tidy(dir, keep * 1024 * 1024);
220
+ if (keep <= 0)
221
+ return { broken: false };
222
+ ensureInScratch(join(project, ".turri"), dir);
223
+ const stamp = new Date(run.started).toISOString().replace(/[:.]/g, "-");
224
+ const file = join(dir, `${stamp}-${run.id}-p${process.pid}.jsonl`);
225
+ writeFileSync(file, `${JSON.stringify(entry)}\n`, { flag: "a" });
226
+ return { file, broken: false };
227
+ }
228
+ catch (error) {
229
+ run.warn?.(`turri's ledger for ${project} cannot be written: ${error instanceof Error ? error.message : String(error)}`);
230
+ return { broken: true };
231
+ }
232
+ }
233
+ /** Every string an event carries, masked as the logs are. */
234
+ function masked(value) {
235
+ if (typeof value === "string")
236
+ return maskSecrets(value);
237
+ if (Array.isArray(value))
238
+ return value.map((item) => masked(item));
239
+ if (value !== null && typeof value === "object")
240
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, masked(item)]));
241
+ return value;
242
+ }
243
+ /** A run file's process, from its name: `<stamp>-<run>-p<pid>.jsonl`. */
244
+ function pidOf(name) {
245
+ const match = /-p(\d+)\.jsonl(?:\.gz)?$/.exec(name);
246
+ return match !== null ? Number(match[1]) : undefined;
247
+ }
248
+ function alive(pid) {
249
+ if (pid === process.pid)
250
+ return true;
251
+ try {
252
+ process.kill(pid, 0);
253
+ return true;
254
+ }
255
+ catch (error) {
256
+ // Not ours to signal, but there.
257
+ return error.code === "EPERM";
258
+ }
259
+ }
260
+ /**
261
+ * Keep the ledger folder within `keepBytes`: the files of runs that have ended gzipped, then the oldest pruned until
262
+ * the rest fit. A run still going - this one, or another turri's in the same project - is never touched.
263
+ */
264
+ export function tidy(dir, keepBytes) {
265
+ const files = () => readdirSync(dir).filter((name) => /\.jsonl(?:\.gz)?$/.test(name)).sort();
266
+ const live = (name) => {
267
+ const pid = pidOf(name);
268
+ return name.endsWith(".jsonl") && (pid === undefined || alive(pid));
269
+ };
270
+ for (const name of files()) {
271
+ if (!name.endsWith(".jsonl") || live(name))
272
+ continue;
273
+ try {
274
+ const path = join(dir, name);
275
+ writeFileSync(`${path}.gz`, gzipSync(readFileSync(path)));
276
+ unlinkSync(path);
277
+ }
278
+ catch {
279
+ /* left as it is - pruned like any other */
280
+ }
281
+ }
282
+ const sized = files().map((name) => ({ name, size: sizeOf(join(dir, name)) }));
283
+ let total = sized.reduce((sum, file) => sum + file.size, 0);
284
+ for (const file of sized) {
285
+ if (total <= keepBytes)
286
+ break;
287
+ if (live(file.name))
288
+ continue;
289
+ try {
290
+ unlinkSync(join(dir, file.name));
291
+ total -= file.size;
292
+ }
293
+ catch {
294
+ /* kept */
295
+ }
296
+ }
297
+ }
298
+ function sizeOf(path) {
299
+ try {
300
+ return statSync(path).size;
301
+ }
302
+ catch {
303
+ return 0;
304
+ }
305
+ }
306
+ /**
307
+ * The project's ledger, oldest first - every run's, the gzipped ones too, as `query` narrows it. A line that does not
308
+ * parse, or a kind this turri does not know, is skipped: the ledger is read as it is, whatever wrote it.
309
+ */
310
+ export function readLedger(project, query = {}) {
311
+ const dir = ledgerDir(project);
312
+ if (!existsSync(dir))
313
+ return [];
314
+ const since = query.since?.toISOString();
315
+ const entries = [];
316
+ for (const name of readdirSync(dir).filter((file) => /\.jsonl(?:\.gz)?$/.test(file)).sort()) {
317
+ let text;
318
+ try {
319
+ const raw = readFileSync(join(dir, name));
320
+ text = name.endsWith(".gz") ? gunzipSync(raw).toString("utf8") : raw.toString("utf8");
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ for (const line of text.split("\n")) {
326
+ if (line.trim() === "")
327
+ continue;
328
+ let entry;
329
+ try {
330
+ entry = JSON.parse(line);
331
+ }
332
+ catch {
333
+ continue;
334
+ }
335
+ if (entry.v !== 1 || typeof entry.kind !== "string")
336
+ continue;
337
+ if (query.run !== undefined && entry.run !== query.run)
338
+ continue;
339
+ if (query.job !== undefined && (entry.job === undefined || !entry.job.startsWith(query.job)))
340
+ continue;
341
+ if (query.kinds !== undefined && !query.kinds.includes(entry.kind))
342
+ continue;
343
+ if (since !== undefined && entry.ts < since)
344
+ continue;
345
+ entries.push(entry);
346
+ }
347
+ }
348
+ return entries;
349
+ }
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { reportActivity } from "./activity.js";
5
5
  import { historyError } from "./environment-error.js";
6
+ import { firstLine, record } from "./ledger.js";
6
7
  import { projectRootOf } from "./paths.js";
7
8
  /**
8
9
  * Version history for each module's generated code and tests.
@@ -84,7 +85,7 @@ function ensureHistory(moduleDir) {
84
85
  */
85
86
  export function recordHistory(dir, subject, body) {
86
87
  try {
87
- return commitAll(dir, subject, body).commit;
88
+ return recorded(dir, subject, commitAll(dir, subject, body), false).commit;
88
89
  }
89
90
  catch {
90
91
  return undefined;
@@ -105,10 +106,24 @@ export function landWork(dir, subject, body) {
105
106
  catch (error) {
106
107
  made = { failed: error instanceof Error ? error.message : String(error) };
107
108
  }
109
+ recorded(dir, subject, made, true);
108
110
  if (made.failed !== undefined)
109
111
  throw historyError(moduleDir, made.failed);
110
112
  return made.commit;
111
113
  }
114
+ /** A commit made or failed, in the ledger - nothing for a step that changed nothing. */
115
+ function recorded(dir, subject, made, landing) {
116
+ const place = placeOf(moduleDirOf(dir));
117
+ if (made.failed !== undefined)
118
+ record({ kind: "history.failed", subject: firstLine(subject, 200), said: firstLine(made.failed, 400), landing }, place);
119
+ else if (made.commit !== undefined)
120
+ record({ kind: "history.committed", subject: firstLine(subject, 200), commit: made.commit }, place);
121
+ return made;
122
+ }
123
+ /** Where a module's history is, for the ledger: its project and its name. */
124
+ function placeOf(moduleDir) {
125
+ return { project: projectRootOf(moduleDir), module: basename(moduleDir) };
126
+ }
112
127
  /** Commit everything the step changed in the module `dir` belongs to: the commit, nothing when nothing changed, or why git failed. */
113
128
  function commitAll(dir, subject, body) {
114
129
  const moduleDir = moduleDirOf(dir);
@@ -191,6 +206,7 @@ export function resetToLastCommit(dir) {
191
206
  return [];
192
207
  git(moduleDir, ["reset", "-q", "--hard", "HEAD"]);
193
208
  git(moduleDir, ["clean", "-q", "-fd"]);
209
+ record({ kind: "history.reset", undone: changed.length, paths: changed.slice(0, 20) }, placeOf(moduleDir));
194
210
  return changed;
195
211
  }
196
212
  catch {
@@ -270,6 +286,7 @@ export function abandonWork(start, moduleName, why) {
270
286
  git(moduleDir, ["reset", "-q", "--hard", commit]);
271
287
  git(moduleDir, ["clean", "-q", "-fd"]);
272
288
  cleanTestsBuild(moduleDir);
289
+ record({ kind: "history.rolledBack", why: firstLine(why, 300) }, placeOf(moduleDir));
273
290
  reportActivity(`${moduleName}: ${why} - ${basename(moduleDir)} is back where the work started`);
274
291
  }
275
292
  catch {
@@ -6,13 +6,14 @@ import { ACCEPTANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, T
6
6
  import { reportActivity, reportStage } from "./activity.js";
7
7
  import { llmChunkAgents, MAX_LINES_PER_JOB, MAX_SPECS_PER_JOB, planChunks, stepsOf } from "./chunk-plan.js";
8
8
  import { commitNotes, landWork, recordHistory, resetToLastCommit } from "./lineage.js";
9
+ import { firstLine, record } from "./ledger.js";
9
10
  import { NOTES_FILE, recordPulse } from "./notes.js";
10
11
  import { createJobRegistry, formatDuration } from "./jobs.js";
11
12
  import { moduleLanes } from "./lanes.js";
12
13
  import { adoptDigests, chainTree, codeTree, conformanceTarget, marksRegenerateAgain, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
13
14
  import { describeSpecDiffConformance, renderSpecDiffConformance } from "./conform.js";
14
15
  import { conformanceDirFor } from "./conformance.js";
15
- import { modulesRoot } from "./paths.js";
16
+ import { modulesRoot, projectRootOf } from "./paths.js";
16
17
  import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn, stackForModule, stackPinned, withStack } from "../config.js";
17
18
  import { describeSpecChange } from "./prompt.js";
18
19
  import { resolveTemplateDirs } from "./paths.js";
@@ -191,6 +192,11 @@ export function createWorkLoop(options) {
191
192
  // One tick at a time: each reads the records and the spec, and decides.
192
193
  let ticking = Promise.resolve();
193
194
  const tipEntry = (state) => tipOf(state.chain);
195
+ /** Where what the loop decides for a module is recorded: its tip's project, the module and the pulse it is about. */
196
+ const loopPlace = (state, job) => {
197
+ const tip = tipEntry(state);
198
+ return { project: projectRootOf(tip.buildDir), module: tip.moduleName, job };
199
+ };
194
200
  /** `work` with the codebase's stack chosen - the one it was asked for with - when it has one pinned. */
195
201
  const inStack = (state, work) => (state.stack !== undefined ? withStack(state.stack.name, work) : work());
196
202
  const keyOf = (spec) => serializeTree(codeTree(spec.tree));
@@ -681,6 +687,7 @@ export function createWorkLoop(options) {
681
687
  /* a spec that does not parse halts it all the same */
682
688
  }
683
689
  state.halted = { why: `${why} - nothing more is built on it until its conformance tests pass`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
690
+ record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "conformance" }, loopPlace(state, running.jobId));
684
691
  }
685
692
  }
686
693
  else if (running !== undefined && !landed) {
@@ -700,6 +707,7 @@ export function createWorkLoop(options) {
700
707
  }
701
708
  const why = settled?.status === "stopped" ? `pulse ${running.jobId} was stopped` : `pulse ${running.jobId} ("${running.planned.title}") did not land`;
702
709
  state.halted = { why: `${why} - it runs again once the spec changes, or the module is asked for again`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
710
+ record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "code" }, loopPlace(state, running.jobId));
703
711
  }
704
712
  }
705
713
  else {
@@ -866,6 +874,7 @@ export function createWorkLoop(options) {
866
874
  else {
867
875
  state.running.stopping = true;
868
876
  state.why = `pulse ${state.running.jobId} was stopped: ${why}, which it was regenerating from`;
877
+ record({ kind: "loop.stopped", why: firstLine(why, 300) }, loopPlace(state, state.running.jobId));
869
878
  reportActivity(` ${tip.moduleName}: ${state.why}`);
870
879
  options.registry.stop(state.running.jobId, `Stopped by turri, not by you: ${why}, which it was regenerating from. What it had done is rolled back, and the spec as it is now is regenerated instead. To have a running pulse finish first and the change regenerated on top of it, set allow-to-stop-jobs: false in config.turri.yaml.`);
871
880
  return;
@@ -12,6 +12,7 @@ import { codebaseMap, diffFiles, digestFiles, wroteNothing } from "./codebase.js
12
12
  import { confinedTools } from "./confine.js";
13
13
  import { buildSpecDiffPrompt, describeSpecChange, reviewedSpec, sectionModules } from "./prompt.js";
14
14
  import { abandonWork, landWork, startWork } from "./lineage.js";
15
+ import { healing, record, signatureHash } from "./ledger.js";
15
16
  import { createWorkLedger, SUBMIT_WORK_NOTE } from "./submit-work.js";
16
17
  import { CODER_COMPACTION } from "./compaction.js";
17
18
  import { gapMark, gapsSince, NOTES_TURN, notesForSession, passedOutcome, recordNote, withNotes } from "./notes.js";
@@ -440,7 +441,7 @@ export async function renderSpecDiff(options) {
440
441
  : timedOut && seen < STUCK_ROUNDS
441
442
  ? `the unit testing script did not finish in ${Math.round(verdict.run.timeoutMs / 1000)}s - a debugger works out whether something hangs`
442
443
  : `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
443
- const found = await debugFailure({
444
+ const found = await healing("debugger", () => debugFailure({
444
445
  buildDir,
445
446
  failure: verdict.run.output,
446
447
  times,
@@ -450,7 +451,7 @@ export async function renderSpecDiff(options) {
450
451
  label: `${moduleName} spec change`,
451
452
  ...(debuggerModel !== undefined ? { model: debuggerModel } : {}),
452
453
  onDelta: options.onDelta,
453
- }).catch(() => undefined);
454
+ }), (finding) => finding?.rootCause);
454
455
  // Nothing hangs: the suite needs more time than a test run may take, and no agent can give it that.
455
456
  if (found?.tooSlow === true) {
456
457
  stage(`the unit testing script needs more than the ${Math.round(verdict.run.timeoutMs / 1000)}s a test run may take - stopping`);
@@ -477,6 +478,7 @@ export async function renderSpecDiff(options) {
477
478
  ].join("\n");
478
479
  }
479
480
  }
481
+ record({ kind: "gate.round", gate: "unit tests", round: followUp + 1, of: MAX_FOLLOW_UPS, ...(key !== undefined ? { signature: signatureHash(key) } : {}) });
480
482
  stage(`${verdict.staticOnly ? "static checks" : "unit tests"} not passing - fixing (round ${followUp + 1} of ${MAX_FOLLOW_UPS})`);
481
483
  response = await ask(executor, prompt, options.onDelta);
482
484
  continue;
@@ -7,6 +7,7 @@ import { confinedTools } from "./confine.js";
7
7
  import { projectRootOf } from "./paths.js";
8
8
  import { buildRegressionFixPrompt, sectionModules } from "./prompt.js";
9
9
  import { abandonWork, landWork, markWork } from "./lineage.js";
10
+ import { record } from "./ledger.js";
10
11
  import { missingToolchainError } from "./environment-error.js";
11
12
  import { workspaceBlock } from "./workspace.js";
12
13
  import { CODER_COMPACTION } from "./compaction.js";
@@ -161,6 +162,7 @@ export async function ensureRegressionPasses(input) {
161
162
  if (round === maxRounds)
162
163
  break;
163
164
  input.onEvent?.({ type: "repair", frids: input.frids });
165
+ record({ kind: "gate.round", gate: "regression", round: round + 1, of: maxRounds });
164
166
  reportStage(`${unitOf(input.moduleName)}: an earlier functionality broke - repairing (round ${round + 1} of ${maxRounds})`);
165
167
  repairs.push(await repairRegression({
166
168
  testsDirs: dirs,
@@ -17,6 +17,8 @@ import type { TranscriptStyle } from "./transcript-view.ts";
17
17
  export interface SidePanelOptions {
18
18
  /** Every job, oldest first. */
19
19
  jobs: () => Job[];
20
+ /** Earlier sessions' pulses, from the ledger: listed under this session's, in a section of their own. */
21
+ earlier?: () => Job[];
20
22
  /** The regeneration's pages. Not given: the pulse list alone. */
21
23
  map?: () => MarkedPage[];
22
24
  /** Rows the panel may use. */
@@ -1,5 +1,5 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, treeKey } from "./jobs-tree.js";
2
+ import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, sectionOf, treeKey } from "./jobs-tree.js";
3
3
  import { columnHeadings, createSpecMapState, DISCUSS_KEY, legendLines, minimap, shownPage, specMapHeader, specMapLines, switchTab, tabBar } from "./spec-map-view.js";
4
4
  export function createSidePanel(options) {
5
5
  const style = options.style;
@@ -71,7 +71,8 @@ export function createSidePanel(options) {
71
71
  const bodyHeight = Math.max(3, rows - top.length - 2);
72
72
  lastBodyHeight = bodyHeight;
73
73
  // The tree scrolls to keep the cursor in sight, and only that far.
74
- const tree_ = jobsTreeLines(options.jobs(), tree, width, style);
74
+ // This session's pulses, and under them the earlier sessions' the ledger has.
75
+ const tree_ = jobsTreeLines([...options.jobs(), ...(options.earlier?.() ?? [])], tree, width, style);
75
76
  drawnTree = { rows: tree_.rows, rowStarts: tree_.rowStarts, lineCount: tree_.lines.length };
76
77
  keepCursorInView(tree, tree_.rowStarts, tree_.lines.length, bodyHeight);
77
78
  const visible = tree_.lines.slice(tree.top, tree.top + bodyHeight);
@@ -132,7 +133,7 @@ export function createSidePanel(options) {
132
133
  const job = jobs.find((entry) => entry.id === id);
133
134
  if (job === undefined)
134
135
  return;
135
- const section = job.status === "running" ? "running" : job.status === "queued" ? "queued" : "finished";
136
+ const section = sectionOf(job);
136
137
  tree.open[section] = true;
137
138
  const at = jobsTreeRows(jobs, tree).findIndex((entry) => entry.kind === "job" && entry.job.id === id);
138
139
  if (at !== -1)
@@ -4,6 +4,7 @@ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync
4
4
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
5
5
  import { CONFIG_FILE, CONFIG_KEYS, projectConfigFile, readProjectConfig, stackConfigFile, stackConfigFileIn, stackOf, stacksOf } from "../config.js";
6
6
  import { reportActivity, reportLogNote, reportNote } from "./activity.js";
7
+ import { record, signatureHash } from "./ledger.js";
7
8
  import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
8
9
  import { defineTool } from "@earendil-works/pi-coding-agent";
9
10
  import { Type } from "typebox";
@@ -361,7 +362,7 @@ async function runScript(cwd, script, args, logDir, options = {}) {
361
362
  const noted = withScriptLineNote(text, script);
362
363
  // Its failures, counts and end are what the agent is shown; the whole is kept, a read_output away.
363
364
  const log = keepTestOutput(logDir, basename(script), noted);
364
- return {
365
+ const run = {
365
366
  command: shown(script),
366
367
  exitCode: timedOut === true ? 124 : exitCode,
367
368
  output: shownTestOutput(noted, log),
@@ -370,6 +371,18 @@ async function runScript(cwd, script, args, logDir, options = {}) {
370
371
  elapsedMs: Date.now() - started,
371
372
  ...(log !== undefined ? { log } : {}),
372
373
  };
374
+ record({
375
+ kind: "script.run",
376
+ script: basename(script),
377
+ exit: run.exitCode,
378
+ ms: run.elapsedMs,
379
+ timedOut: run.timedOut === true,
380
+ inPlace: options.named === undefined,
381
+ // On code changed for the run - can_it_fail's bug: failing is what it is for.
382
+ ...(options.mutated === true ? { mutated: true } : {}),
383
+ ...(run.exitCode !== 0 ? { signature: signatureHash(failureSignature(run)) } : {}),
384
+ });
385
+ return run;
373
386
  }
374
387
  /** What bash says when a line of the script itself cannot run: a command or file that is not there, or not runnable. */
375
388
  const OWN_LINE_FAILURE = /: (No such file or directory|command not found|Permission denied)$/;
@@ -406,10 +419,10 @@ function quote(path) {
406
419
  return `'${path.replace(/'/g, "'\\''")}'`;
407
420
  }
408
421
  /** A testing script run on a fresh copy of the code: the project's script copied fresh into the staging folder, beside it. */
409
- async function runOnCopy(stagingRoot, script, args, logDir, limitMs) {
422
+ async function runOnCopy(stagingRoot, script, args, logDir, limitMs, mutated) {
410
423
  const copy = join(stagingRoot, basename(script));
411
424
  writeFileSync(copy, readFileSync(script));
412
- return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}) });
425
+ return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}), ...(mutated === true ? { mutated } : {}) });
413
426
  }
414
427
  /**
415
428
  * The harness's run of the unit testing script on the code as it is now - and run_unit_tests'.
@@ -475,7 +488,7 @@ limitMs) {
475
488
  return run;
476
489
  };
477
490
  if (project.inPlace) {
478
- const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, limitMs !== undefined ? { limitMs } : {});
491
+ const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, { ...(limitMs !== undefined ? { limitMs } : {}), ...(change !== undefined ? { mutated: true } : {}) });
479
492
  const started = Date.now();
480
493
  if (change === undefined)
481
494
  return passed(await inPlace(), started);
@@ -490,7 +503,7 @@ limitMs) {
490
503
  if (change !== undefined)
491
504
  writeFileSync(join(staging.build, change.file), change.text);
492
505
  const started = Date.now();
493
- return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs), started);
506
+ return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs, change !== undefined), started);
494
507
  }
495
508
  finally {
496
509
  rmSync(staging.root, { recursive: true, force: true });
@@ -3,6 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { ask, createExecutor } from "../pi.js";
5
5
  import { reportActivity } from "./activity.js";
6
+ import { record } from "./ledger.js";
6
7
  import { diffFiles, digestFiles } from "./codebase.js";
7
8
  import { confinedTools } from "./confine.js";
8
9
  import { REVIEWER_CALLS, reviewKind } from "./conformance-agents.js";
@@ -288,6 +289,7 @@ async function reviewLoop(options, reviewer) {
288
289
  letGo = [];
289
290
  options.stage(`reviewing the code and its unit tests (review ${reviews + 1}): does the code do what the spec says, and does each test fail when that breaks`);
290
291
  const review = await reviewer.review(request);
292
+ record({ kind: "review.verdict", reviewer: "unit", review: reviews + 1, pass: review.pass, problems: review.pass ? 0 : review.problems.length });
291
293
  if (review.pass) {
292
294
  options.stage("the review has nothing left to improve in the code or its unit tests");
293
295
  return waived.length > 0 ? { passed: true, waived, reviews: reviews + 1 } : { passed: true, reviews: reviews + 1 };
@@ -316,6 +318,7 @@ async function reviewLoop(options, reviewer) {
316
318
  });
317
319
  // No ruling: the review stands, as it did before there was an arbiter.
318
320
  if (ruling !== undefined) {
321
+ record({ kind: "arbiter.ruling", upheld: ruling.upheld.length, dismissed: ruling.dismissed.length });
319
322
  waived.push(...ruling.dismissed.map((entry) => entry.problem));
320
323
  if (ruling.dismissed.length > 0)
321
324
  recordNote(options.buildDir, "pitfalls", dismissedNote(ruling, "unit test"), `${options.label} unit tests`);