@turri-dev/cli 0.1.7 → 0.1.8-next.b03162b

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 (83) hide show
  1. package/README.md +5 -7
  2. package/dist/btw.d.ts +2 -0
  3. package/dist/btw.js +2 -2
  4. package/dist/cli.js +2 -1
  5. package/dist/config-edit.d.ts +1 -1
  6. package/dist/config-edit.js +2 -0
  7. package/dist/config.d.ts +6 -0
  8. package/dist/config.js +5 -1
  9. package/dist/extension.d.ts +11 -0
  10. package/dist/extension.js +112 -13
  11. package/dist/host.d.ts +2 -0
  12. package/dist/host.js +3 -0
  13. package/dist/identity.d.ts +6 -1
  14. package/dist/identity.js +33 -7
  15. package/dist/init-config.d.ts +1 -0
  16. package/dist/init-config.js +5 -0
  17. package/dist/ledger-cli.d.ts +1 -0
  18. package/dist/ledger-cli.js +50 -0
  19. package/dist/loop-cli.js +4 -0
  20. package/dist/loop-tools.js +3 -1
  21. package/dist/pi.js +17 -0
  22. package/dist/regenerate/activity.d.ts +9 -0
  23. package/dist/regenerate/activity.js +11 -0
  24. package/dist/regenerate/agent-shell.js +5 -0
  25. package/dist/regenerate/cases.js +3 -0
  26. package/dist/regenerate/chunk-plan.d.ts +0 -6
  27. package/dist/regenerate/chunk-plan.js +16 -4
  28. package/dist/regenerate/conformance-agents.js +2 -2
  29. package/dist/regenerate/conformance.js +6 -3
  30. package/dist/regenerate/diagnose.js +4 -2
  31. package/dist/regenerate/environment-error.d.ts +6 -0
  32. package/dist/regenerate/environment-error.js +19 -1
  33. package/dist/regenerate/job-log.d.ts +3 -1
  34. package/dist/regenerate/job-log.js +8 -5
  35. package/dist/regenerate/jobs-tree.d.ts +7 -3
  36. package/dist/regenerate/jobs-tree.js +21 -6
  37. package/dist/regenerate/jobs.d.ts +18 -0
  38. package/dist/regenerate/jobs.js +49 -3
  39. package/dist/regenerate/ledger-view.d.ts +73 -0
  40. package/dist/regenerate/ledger-view.js +286 -0
  41. package/dist/regenerate/ledger.d.ts +274 -0
  42. package/dist/regenerate/ledger.js +349 -0
  43. package/dist/regenerate/lineage.d.ts +9 -1
  44. package/dist/regenerate/lineage.js +64 -12
  45. package/dist/regenerate/loop.d.ts +6 -0
  46. package/dist/regenerate/loop.js +36 -6
  47. package/dist/regenerate/records.d.ts +5 -3
  48. package/dist/regenerate/records.js +6 -4
  49. package/dist/regenerate/regenerate.js +7 -5
  50. package/dist/regenerate/regression.js +4 -2
  51. package/dist/regenerate/side-panel.d.ts +2 -0
  52. package/dist/regenerate/side-panel.js +4 -3
  53. package/dist/regenerate/spec-map.js +8 -5
  54. package/dist/regenerate/test-scripts.js +12 -1
  55. package/dist/regenerate/unit-review.js +3 -0
  56. package/dist/run-report.d.ts +1 -1
  57. package/dist/run-report.js +6 -0
  58. package/dist/self-invocation.d.ts +1 -1
  59. package/dist/self-invocation.js +3 -2
  60. package/dist/skill-progress.d.ts +1 -1
  61. package/dist/skill.d.ts +5 -0
  62. package/dist/skill.js +1 -0
  63. package/dist/skills.d.ts +14 -0
  64. package/dist/skills.js +19 -1
  65. package/dist/surface.d.ts +5 -0
  66. package/dist/surface.js +18 -0
  67. package/docs/authoring-plain.md +2 -2
  68. package/docs/config.md +4 -2
  69. package/docs/development.md +1 -0
  70. package/docs/headless.md +4 -0
  71. package/docs/hosting.md +45 -0
  72. package/docs/installation.md +1 -0
  73. package/docs/regenerating.md +5 -5
  74. package/docs/skills-and-gates.md +3 -1
  75. package/docs/using.md +33 -5
  76. package/genome/rules/func-specs.md +1 -0
  77. package/genome/rules/turri-regeneration.md +3 -0
  78. package/genome/skills/add-functional-spec/SKILL.md +12 -3
  79. package/genome/skills/add-functional-specs/SKILL.md +12 -2
  80. package/genome/skills/brood/SKILL.md +2 -0
  81. package/genome/skills/bud/SKILL.md +2 -0
  82. package/genome/skills/spawn/SKILL.md +47 -35
  83. package/package.json +1 -1
@@ -3,7 +3,8 @@ import { availableParallelism } from "node:os";
3
3
  import { withPulse } from "./shell.js";
4
4
  import { describeEnvironmentError, EnvironmentError, missingCredentials } from "./environment-error.js";
5
5
  import { addAgentReport, describeAgents, JobStoppedError, withActivity } from "./activity.js";
6
- import { startJobLog } from "./job-log.js";
6
+ import { projectOfLanes, startJobLog } from "./job-log.js";
7
+ import { firstLine, healing, record } from "./ledger.js";
7
8
  import { chosenStack, withStack } from "../config.js";
8
9
  const DEFAULT_TAIL_CHARS = 4000;
9
10
  const ACTIVITY_LINES = 10;
@@ -88,6 +89,20 @@ export function createJobRegistry(options) {
88
89
  };
89
90
  jobs.set(job.id, job);
90
91
  let transcriptChars = 0;
92
+ // Where what it does is recorded: its project, from the modules it writes.
93
+ const project = projectOfLanes(claimed);
94
+ const place = { job: id, ...(project !== undefined ? { project } : {}), ...(input.module !== undefined ? { module: input.module } : {}) };
95
+ record({
96
+ kind: "job.queued",
97
+ jobKind: input.kind,
98
+ summary: firstLine(input.summary, 300),
99
+ ...(input.pulse !== undefined ? { pulse: input.pulse } : {}),
100
+ ...(input.title !== undefined ? { title: input.title } : {}),
101
+ ...(input.model !== undefined ? { model: input.model } : {}),
102
+ ...(stack !== undefined ? { stack } : {}),
103
+ waitingOn: blockers,
104
+ ...(input.follows !== undefined ? { follows: input.follows } : {}),
105
+ }, place);
91
106
  const changed = () => {
92
107
  // Like onFinish: a display that throws must not take the job down.
93
108
  try {
@@ -105,6 +120,8 @@ export function createJobRegistry(options) {
105
120
  const sink = {
106
121
  signal: controller.signal,
107
122
  job: { id, kind: input.kind, summary: input.summary },
123
+ ...(project !== undefined ? { project } : {}),
124
+ ...(input.module !== undefined ? { module: input.module } : {}),
108
125
  specConflict(conflict) {
109
126
  job.specConflict = conflict;
110
127
  this.transcript({ kind: "stage", text: `the spec contradicts itself: functionalities ${conflict.specs.map((spec) => `${spec.module} ${spec.frid}`).join(" and ")} cannot both hold` });
@@ -112,6 +129,7 @@ export function createJobRegistry(options) {
112
129
  },
113
130
  stage(label) {
114
131
  job.stage = label;
132
+ record({ kind: "job.stage", label: firstLine(label, 300) }, place);
115
133
  this.transcript({ kind: "stage", text: label });
116
134
  changed();
117
135
  },
@@ -213,22 +231,24 @@ export function createJobRegistry(options) {
213
231
  // and the person is told. What the harness found itself is worked out at once; what an agent said goes as it is.
214
232
  if (error instanceof EnvironmentError) {
215
233
  job.failedForGood = true;
234
+ record({ kind: "environment.error", missing: firstLine(error.missing, 300), source: error.source, sandbox: error.sandbox !== undefined }, place);
216
235
  if (error.source === "harness" && options?.diagnose !== undefined) {
217
236
  sink.stage("the environment lacks something - working out what to do about it");
218
- job.diagnosis = await withActivity(sink, () => options.diagnose(job, [error.message, describeEnvironmentError(error, input.rerun)], log?.file)).catch(() => undefined);
237
+ job.diagnosis = await withActivity(sink, () => healing("diagnosis", () => options.diagnose(job, [error.message, describeEnvironmentError(error, input.rerun)], log?.file), (found) => found));
219
238
  }
220
239
  job.environment = describeEnvironmentError(error, input.rerun);
221
240
  throw new Error([job.environment, ...(job.diagnosis !== undefined ? ["", job.diagnosis] : [])].join("\n"));
222
241
  }
223
242
  failures.push(error !== undefined ? (error instanceof Error ? error.message : String(error)) : outcome.report);
224
243
  if (attempt < retries) {
244
+ record({ kind: "job.retried", attempt: attempt + 1, of: retries, failure: firstLine(failures.at(-1), 300) }, place);
225
245
  sink.stage(`the pulse failed - trying it once more (${attempt + 1} of ${retries})`);
226
246
  continue;
227
247
  }
228
248
  job.failedForGood = true;
229
249
  if (options?.diagnose !== undefined) {
230
250
  sink.stage("the pulse failed again - working out why, and what has to be done");
231
- job.diagnosis = await withActivity(sink, () => options.diagnose(job, failures, log?.file)).catch(() => undefined);
251
+ job.diagnosis = await withActivity(sink, () => healing("diagnosis", () => options.diagnose(job, failures, log?.file), (found) => found));
232
252
  }
233
253
  throw new Error([
234
254
  `Failed ${failures.length} time${failures.length === 1 ? "" : "s"}, so it and every pulse queued behind it have stopped.`,
@@ -277,6 +297,7 @@ export function createJobRegistry(options) {
277
297
  log = startJobLog(job, claimed);
278
298
  if (log !== undefined)
279
299
  job.logFile = log.file;
300
+ record({ kind: "job.started", waitedMs: job.startedAt - job.queuedAt, ...(log !== undefined ? { logFile: log.file } : {}) }, place);
280
301
  // What the job's agents start runs until it ends, and no longer (withPulse) - with the stack it was queued
281
302
  // with, whatever is chosen while it waits or runs.
282
303
  return withStack(stack, () => (guarded ? runGuarded() : runOnce()));
@@ -302,6 +323,20 @@ export function createJobRegistry(options) {
302
323
  giveSlot();
303
324
  }
304
325
  finished.finishedAt = Date.now();
326
+ const outcome = finished.details;
327
+ const stopReason = stopReasons.get(id);
328
+ record({
329
+ kind: "job.finished",
330
+ status: finished.status,
331
+ ...(typeof outcome?.landed === "boolean" ? { landed: outcome.landed } : {}),
332
+ ...(typeof outcome?.passed === "boolean" ? { passed: outcome.passed } : {}),
333
+ ms: finished.finishedAt - finished.startedAt,
334
+ usage: finished.usage,
335
+ ...(finished.failedForGood === true ? { failedForGood: true } : {}),
336
+ ...(finished.status === "stopped" ? { stoppedBy: stopReason !== undefined ? "turri" : "person" } : {}),
337
+ ...(finished.error !== undefined ? { error: firstLine(finished.error, 300) } : {}),
338
+ ...(finished.logFile !== undefined ? { logFile: finished.logFile } : {}),
339
+ }, place);
305
340
  log?.end(finished.status === "succeeded" ? "finished" : finished.status === "stopped" ? "stopped" : "finished - failed", finished.announce ?? finished.report ?? finished.error, describeAgents(finished.agents));
306
341
  // A failing listener must not take the job down with it; the result is
307
342
  // already recorded and can still be collected by asking.
@@ -377,6 +412,17 @@ export function formatElapsed(ms) {
377
412
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
378
413
  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
379
414
  }
415
+ /**
416
+ * How soon after `since` the first code pulse queued since then started - the time to first regeneration - and how soon
417
+ * the first of them landed, when one has: from then on there is something to try. In milliseconds; either is undefined
418
+ * until it has happened.
419
+ */
420
+ export function firstRegeneration(jobs, since) {
421
+ const pulses = jobs.filter((job) => job.kind === "regenerate" && job.queuedAt >= since && job.status !== "queued");
422
+ const started = Math.min(...pulses.map((job) => job.startedAt));
423
+ const landed = Math.min(...pulses.filter((job) => job.status === "succeeded" && job.details?.landed === true && job.finishedAt !== undefined).map((job) => job.finishedAt));
424
+ return { ...(Number.isFinite(started) ? { started: started - since } : {}), ...(Number.isFinite(landed) ? { landed: landed - since } : {}) };
425
+ }
380
426
  /**
381
427
  * What a pulse is called wherever it is shown in a line: its module, kind and number - `shorty · regenerate pulse 3`,
382
428
  * `shorty · conformance pulse 6` - never what it regenerates, which is the agents' words and long. A job of no module,
@@ -0,0 +1,73 @@
1
+ import { type UsageReport } from "./activity.ts";
2
+ import { type Job } from "./jobs.ts";
3
+ import { type LedgerEntry } from "./ledger.ts";
4
+ /**
5
+ * What the ledger says of pulses, read back: one pulse - from this session or an earlier one - as the chat and
6
+ * `turri ledger` are told it, and the runs a project has had, each with its pulses.
7
+ */
8
+ type Of<K extends LedgerEntry["kind"]> = Extract<LedgerEntry, {
9
+ kind: K;
10
+ }>;
11
+ /** The projects whose ledgers a folder's pulses may be in: its own, and those of the projects in folders under it. */
12
+ export declare function ledgerProjects(cwd: string, depth?: number): string[];
13
+ /** One pulse, as its events tell it. */
14
+ export interface PulseView {
15
+ id: string;
16
+ run: string;
17
+ /** How its run was started - chat, host, regenerate - and when. */
18
+ runMode?: string;
19
+ queued?: Of<"job.queued"> & {
20
+ module?: string;
21
+ };
22
+ started?: Of<"job.started">;
23
+ finished?: Of<"job.finished">;
24
+ stages: string[];
25
+ retried: Array<Of<"job.retried">>;
26
+ rounds: Map<string, {
27
+ round: number;
28
+ of: number;
29
+ }>;
30
+ scripts: Array<Of<"script.run">>;
31
+ judge: Map<string, number>;
32
+ reviews: Array<Of<"review.verdict">>;
33
+ healers: Array<Of<"healer.ended"> | Of<"healer.failed">>;
34
+ provider: {
35
+ retries: number;
36
+ stalls: number;
37
+ errors: string[];
38
+ };
39
+ refused: Map<string, number>;
40
+ stopped: string[];
41
+ history: Array<Of<"history.committed"> | Of<"history.failed"> | Of<"history.reset"> | Of<"history.rolledBack">>;
42
+ environment?: Of<"environment.error">;
43
+ usage?: UsageReport;
44
+ }
45
+ /** A pulse's events, gathered: undefined when the ledger has none of it. `entries` are its run's, oldest first. */
46
+ export declare function pulseView(id: string, entries: LedgerEntry[]): PulseView | undefined;
47
+ /** A pulse from the ledger, for the chat and `turri ledger <pulse>`: what happened in it, as facts. */
48
+ export declare function describePulseView(view: PulseView): string;
49
+ /** A pulse the ledger of any of these projects knows, described - or undefined. */
50
+ export declare function describePastPulse(projects: string[], id: string): string | undefined;
51
+ /** A run of the project's, and its pulses: the most recent first. */
52
+ export interface RunView {
53
+ run: string;
54
+ started?: Of<"run.started">;
55
+ ended?: Of<"run.ended">;
56
+ pulses: PulseView[];
57
+ }
58
+ export declare function runViews(project: string): RunView[];
59
+ /** The project's last runs, a line a pulse: what `turri ledger` prints. */
60
+ export declare function describeRuns(runs: RunView[]): string;
61
+ /**
62
+ * The pulses of earlier sessions, for a list beside this session's: the last `runs` runs of these projects that are not
63
+ * this one, the most recent first, each pulse as a job of its own marked `earlier` - none `known` to this session's
64
+ * registry. `more`: how many runs the ledger has beyond them.
65
+ */
66
+ export declare function earlierPulses(projects: string[], known: Set<string>, runs?: number): {
67
+ jobs: Job[];
68
+ runs: RunView[];
69
+ more: number;
70
+ };
71
+ /** A pulse of the ledger's, as the lists show a job: finished as it ended, its time and cost - and nothing to run. */
72
+ export declare function asJob(view: PulseView): Job;
73
+ export {};
@@ -0,0 +1,286 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { describeUsage } from "./activity.js";
4
+ import { formatElapsed } from "./jobs.js";
5
+ import { LEDGER_DIR, ledgerRun, readLedger } from "./ledger.js";
6
+ /** The projects whose ledgers a folder's pulses may be in: its own, and those of the projects in folders under it. */
7
+ export function ledgerProjects(cwd, depth = 3) {
8
+ const found = [];
9
+ const visit = (dir, left) => {
10
+ if (existsSync(join(dir, ".turri", LEDGER_DIR)))
11
+ found.push(dir);
12
+ if (left === 0)
13
+ return;
14
+ let names;
15
+ try {
16
+ names = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ for (const name of names) {
22
+ if (name.startsWith(".") || name === "node_modules" || name === "plain_modules" || name === "dist")
23
+ continue;
24
+ visit(join(dir, name), left - 1);
25
+ }
26
+ };
27
+ visit(resolve(cwd), depth);
28
+ return found;
29
+ }
30
+ /** A pulse's events, gathered: undefined when the ledger has none of it. `entries` are its run's, oldest first. */
31
+ export function pulseView(id, entries) {
32
+ const own = entries.filter((entry) => entry.job !== undefined && entry.job.startsWith(id));
33
+ if (own.length === 0)
34
+ return undefined;
35
+ const fullId = own[0].job;
36
+ const view = {
37
+ id: fullId,
38
+ run: own[0].run,
39
+ stages: [],
40
+ retried: [],
41
+ rounds: new Map(),
42
+ scripts: [],
43
+ judge: new Map(),
44
+ reviews: [],
45
+ healers: [],
46
+ provider: { retries: 0, stalls: 0, errors: [] },
47
+ refused: new Map(),
48
+ stopped: [],
49
+ history: [],
50
+ };
51
+ const started = entries.find((entry) => entry.kind === "run.started" && entry.run === view.run);
52
+ if (started !== undefined)
53
+ view.runMode = `${started.mode}, ${started.ts.slice(0, 16).replace("T", " ")}`;
54
+ for (const entry of own) {
55
+ if (entry.job !== fullId)
56
+ continue;
57
+ switch (entry.kind) {
58
+ case "job.queued":
59
+ view.queued = { ...entry, ...(entry.module !== undefined ? { module: entry.module } : {}) };
60
+ break;
61
+ case "job.started":
62
+ view.started = entry;
63
+ break;
64
+ case "job.finished":
65
+ view.finished = entry;
66
+ view.usage = entry.usage;
67
+ break;
68
+ case "job.stage":
69
+ view.stages.push(entry.label);
70
+ break;
71
+ case "job.retried":
72
+ view.retried.push(entry);
73
+ break;
74
+ case "gate.round":
75
+ view.rounds.set(entry.gate, { round: Math.max(entry.round, view.rounds.get(entry.gate)?.round ?? 0), of: entry.of });
76
+ break;
77
+ case "script.run":
78
+ view.scripts.push(entry);
79
+ break;
80
+ case "judge.verdict":
81
+ view.judge.set(entry.culprit, (view.judge.get(entry.culprit) ?? 0) + 1);
82
+ break;
83
+ case "review.verdict":
84
+ view.reviews.push(entry);
85
+ break;
86
+ case "healer.ended":
87
+ case "healer.failed":
88
+ view.healers.push(entry);
89
+ break;
90
+ case "provider.retry":
91
+ view.provider.retries += 1;
92
+ break;
93
+ case "provider.stall":
94
+ view.provider.stalls += 1;
95
+ break;
96
+ case "provider.error":
97
+ view.provider.errors.push(entry.error);
98
+ break;
99
+ case "guard.refused":
100
+ view.refused.set(entry.rule, (view.refused.get(entry.rule) ?? 0) + 1);
101
+ break;
102
+ case "command.refused":
103
+ view.refused.set("command check", (view.refused.get("command check") ?? 0) + 1);
104
+ break;
105
+ case "turn.stopped":
106
+ view.stopped.push(entry.reason);
107
+ break;
108
+ case "history.committed":
109
+ case "history.failed":
110
+ case "history.reset":
111
+ case "history.rolledBack":
112
+ view.history.push(entry);
113
+ break;
114
+ case "environment.error":
115
+ view.environment = entry;
116
+ break;
117
+ }
118
+ }
119
+ return view;
120
+ }
121
+ /** What a pulse is called: its module, kind and number - as pulseName says it for a live one. */
122
+ function nameOf(view) {
123
+ const queued = view.queued;
124
+ if (queued === undefined)
125
+ return view.id;
126
+ if (queued.module === undefined)
127
+ return queued.summary;
128
+ return queued.pulse !== undefined ? `${queued.module} · ${queued.jobKind} pulse ${queued.pulse}` : `${queued.module} ${queued.jobKind}`;
129
+ }
130
+ function outcomeOf(view) {
131
+ const finished = view.finished;
132
+ if (finished === undefined)
133
+ return view.started !== undefined ? "never ended - its run stopped while it ran" : "never started";
134
+ const landed = finished.landed === true ? ", landed" : finished.landed === false ? ", did not land" : "";
135
+ const stopped = finished.stoppedBy !== undefined ? ` by ${finished.stoppedBy === "turri" ? "turri" : "the person"}` : "";
136
+ return `${finished.status}${stopped}${landed}, after ${formatElapsed(finished.ms)}`;
137
+ }
138
+ /** A pulse from the ledger, for the chat and `turri ledger <pulse>`: what happened in it, as facts. */
139
+ export function describePulseView(view) {
140
+ const lines = [`${view.id} · ${nameOf(view)}${view.queued?.title !== undefined ? ` - ${view.queued.title}` : ""}`];
141
+ lines.push(` run ${view.run}${view.runMode !== undefined ? ` (${view.runMode})` : ""}`);
142
+ lines.push(` ${outcomeOf(view)}${view.usage !== undefined && describeUsage(view.usage) !== "" ? ` · ${describeUsage(view.usage)}` : ""}${view.queued?.model !== undefined ? ` · ${view.queued.model}` : ""}`);
143
+ if (view.finished?.error !== undefined)
144
+ lines.push(` ended with: ${view.finished.error}`);
145
+ if (view.environment !== undefined)
146
+ lines.push(` the environment lacks ${view.environment.missing} (found by ${view.environment.source === "harness" ? "turri" : "an agent"})`);
147
+ for (const retry of view.retried)
148
+ lines.push(` tried again (${retry.attempt} of ${retry.of}) after: ${retry.failure}`);
149
+ if (view.rounds.size > 0)
150
+ lines.push(` rounds: ${[...view.rounds].map(([gate, { round, of }]) => `${gate} ${round} of ${of}`).join("; ")}`);
151
+ if (view.scripts.length > 0) {
152
+ const failing = view.scripts.filter((run) => run.exit !== 0);
153
+ const signatures = new Map();
154
+ for (const run of failing)
155
+ if (run.signature !== undefined)
156
+ signatures.set(run.signature, (signatures.get(run.signature) ?? 0) + 1);
157
+ const most = Math.max(0, ...signatures.values());
158
+ const timedOut = view.scripts.filter((run) => run.timedOut).length;
159
+ lines.push(` test runs: ${view.scripts.length}, ${failing.length} failing` +
160
+ (most > 1 ? ` - ${most} of them the same way` : "") +
161
+ (timedOut > 0 ? `, ${timedOut} stopped at the time limit` : ""));
162
+ }
163
+ if (view.judge.size > 0)
164
+ lines.push(` the judge blamed: ${[...view.judge].map(([culprit, times]) => `${culprit} ${times}x`).join(", ")}`);
165
+ if (view.reviews.length > 0)
166
+ lines.push(` reviews: ${view.reviews.map((review) => (review.pass ? "passed" : `${review.problems} problem${review.problems === 1 ? "" : "s"}`)).join(", ")}`);
167
+ // The same outcome again is counted, not said again: a debugger failing for want of a key fails every time.
168
+ const healed = new Map();
169
+ for (const healer of view.healers) {
170
+ const line = healer.kind === "healer.failed" ? `the ${healer.healer} FAILED: ${healer.error}` : `the ${healer.healer}${healer.found !== undefined ? `: ${healer.found}` : " found nothing"}`;
171
+ healed.set(line, (healed.get(line) ?? 0) + 1);
172
+ }
173
+ for (const [line, times] of healed)
174
+ lines.push(` ${times > 1 ? line.replace(/^(the \S+( FAILED)?)/, `$1 ${times}x`) : line}`);
175
+ const provider = view.provider;
176
+ if (provider.retries + provider.stalls + provider.errors.length > 0) {
177
+ lines.push(` the model provider: ${provider.retries} retries, ${provider.stalls} stalls${provider.errors.length > 0 ? `, ${provider.errors.length} errors - last: ${provider.errors.at(-1)}` : ""}`);
178
+ }
179
+ if (view.refused.size > 0)
180
+ lines.push(` turri refused calls: ${[...view.refused].map(([rule, times]) => `${rule} ${times}x`).join(", ")}`);
181
+ if (view.stopped.length > 0)
182
+ lines.push(` turri stopped ${view.stopped.length} turn${view.stopped.length === 1 ? "" : "s"} - last: ${view.stopped.at(-1)}`);
183
+ for (const event of view.history) {
184
+ if (event.kind === "history.committed")
185
+ lines.push(` committed: ${event.subject}`);
186
+ else if (event.kind === "history.failed")
187
+ lines.push(` a commit FAILED${event.landing ? " (landing work)" : ""}: ${event.said}`);
188
+ else if (event.kind === "history.reset")
189
+ lines.push(` put back to the last commit: ${event.undone} change${event.undone === 1 ? "" : "s"} undone`);
190
+ else
191
+ lines.push(` rolled back: ${event.why}`);
192
+ }
193
+ if (view.stages.length > 0)
194
+ lines.push(" last stages:", ...view.stages.slice(-8).map((stage) => ` ${stage}`));
195
+ const log = view.finished?.logFile ?? view.started?.logFile;
196
+ if (log !== undefined)
197
+ lines.push(` log: ${log}`);
198
+ return lines.join("\n");
199
+ }
200
+ /** A pulse the ledger of any of these projects knows, described - or undefined. */
201
+ export function describePastPulse(projects, id) {
202
+ for (const project of projects) {
203
+ const entries = readLedger(project);
204
+ const view = pulseView(id, entries);
205
+ if (view !== undefined)
206
+ return describePulseView(view);
207
+ }
208
+ return undefined;
209
+ }
210
+ export function runViews(project) {
211
+ const entries = readLedger(project);
212
+ const runs = new Map();
213
+ for (const entry of entries)
214
+ runs.set(entry.run, [...(runs.get(entry.run) ?? []), entry]);
215
+ return [...runs]
216
+ .map(([run, own]) => {
217
+ const ids = [...new Set(own.filter((entry) => entry.kind === "job.queued").map((entry) => entry.job))];
218
+ const started = own.find((entry) => entry.kind === "run.started");
219
+ const ended = own.find((entry) => entry.kind === "run.ended");
220
+ return { run, ...(started !== undefined ? { started } : {}), ...(ended !== undefined ? { ended } : {}), pulses: ids.map((id) => pulseView(id, own)).filter(Boolean) };
221
+ })
222
+ .reverse();
223
+ }
224
+ /** The project's last runs, a line a pulse: what `turri ledger` prints. */
225
+ export function describeRuns(runs) {
226
+ if (runs.length === 0)
227
+ return "The ledger is empty: nothing has run here since it began.";
228
+ return runs
229
+ .map((view) => {
230
+ const started = view.started;
231
+ const when = started?.ts !== undefined ? started.ts.slice(0, 19).replace("T", " ") : "";
232
+ const version = started?.version !== undefined && started.version !== null ? ` · turri ${started.version}${started.commit !== null && started.commit !== undefined ? ` (${started.commit.slice(0, 7)}${started.dirty === true ? ", changed" : ""})` : ""}` : "";
233
+ const head = `run ${view.run} · ${started?.mode ?? "?"} · ${when}${view.ended !== undefined ? ` · ${formatElapsed(view.ended.ms)}` : " · still running, or killed"}${version}`;
234
+ const pulses = view.pulses.map((pulse) => ` ${pulse.id} ${nameOf(pulse).padEnd(32)} ${outcomeOf(pulse)}${pulse.usage !== undefined && describeUsage(pulse.usage) !== "" ? ` · ${describeUsage(pulse.usage)}` : ""}`);
235
+ return [head, ...(pulses.length > 0 ? pulses : [" no pulses"])].join("\n");
236
+ })
237
+ .join("\n\n");
238
+ }
239
+ /**
240
+ * The pulses of earlier sessions, for a list beside this session's: the last `runs` runs of these projects that are not
241
+ * this one, the most recent first, each pulse as a job of its own marked `earlier` - none `known` to this session's
242
+ * registry. `more`: how many runs the ledger has beyond them.
243
+ */
244
+ export function earlierPulses(projects, known, runs = 3) {
245
+ const own = ledgerRun();
246
+ const all = projects
247
+ .flatMap((project) => runViews(project))
248
+ .filter((view) => view.run !== own && view.pulses.some((pulse) => !known.has(pulse.id)))
249
+ .sort((a, b) => (b.started?.ts ?? "").localeCompare(a.started?.ts ?? ""));
250
+ const shown = all.slice(0, runs);
251
+ return { jobs: shown.flatMap((view) => view.pulses.filter((pulse) => !known.has(pulse.id)).map(asJob)), runs: shown, more: all.length - shown.length };
252
+ }
253
+ /** A pulse of the ledger's, as the lists show a job: finished as it ended, its time and cost - and nothing to run. */
254
+ export function asJob(view) {
255
+ const at = (entry) => (entry?.ts !== undefined ? Date.parse(entry.ts) : undefined);
256
+ const queuedAt = at(view.queued) ?? 0;
257
+ const startedAt = at(view.started) ?? queuedAt;
258
+ const finished = view.finished;
259
+ // A pulse whose run ended before it did never finished: it was stopped with its run.
260
+ const status = finished !== undefined && ["succeeded", "failed", "stopped"].includes(finished.status) ? finished.status : "stopped";
261
+ const logFile = finished?.logFile ?? view.started?.logFile;
262
+ return {
263
+ id: view.id,
264
+ kind: view.queued?.jobKind ?? "regenerate",
265
+ summary: view.queued?.summary ?? view.id,
266
+ ...(view.queued?.module !== undefined ? { module: view.queued.module } : {}),
267
+ ...(view.queued?.pulse !== undefined ? { pulse: view.queued.pulse } : {}),
268
+ ...(view.queued?.title !== undefined ? { title: view.queued.title } : {}),
269
+ ...(view.queued?.model !== undefined ? { model: view.queued.model } : {}),
270
+ status,
271
+ sequence: 0,
272
+ queuedAt,
273
+ startedAt,
274
+ finishedAt: finished !== undefined ? startedAt + finished.ms : startedAt,
275
+ waitingOn: [],
276
+ tail: "",
277
+ activity: [],
278
+ usage: view.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
279
+ agents: [],
280
+ transcript: [],
281
+ ...(logFile !== undefined ? { logFile } : {}),
282
+ ...(finished?.error !== undefined ? { error: finished.error } : {}),
283
+ details: { ...(finished?.landed !== undefined ? { landed: finished.landed } : {}), ...(finished?.passed !== undefined ? { passed: finished.passed } : {}) },
284
+ earlier: { run: view.run, ...(view.runMode !== undefined ? { runMode: view.runMode } : {}), lines: describePulseView(view).split("\n").slice(1).map((line) => line.replace(/^ /, "")) },
285
+ };
286
+ }