@turri-dev/cli 0.1.7 → 0.1.8-next.91b957c

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 (96) hide show
  1. package/README.md +6 -8
  2. package/dist/btw.d.ts +2 -0
  3. package/dist/btw.js +2 -2
  4. package/dist/cli.js +6 -2
  5. package/dist/config-edit.d.ts +1 -1
  6. package/dist/config-edit.js +4 -0
  7. package/dist/config.d.ts +27 -0
  8. package/dist/config.js +28 -1
  9. package/dist/extension.d.ts +11 -0
  10. package/dist/extension.js +118 -14
  11. package/dist/genome.js +2 -0
  12. package/dist/host.d.ts +3 -1
  13. package/dist/host.js +4 -1
  14. package/dist/identity.d.ts +6 -1
  15. package/dist/identity.js +33 -7
  16. package/dist/init-config.d.ts +3 -0
  17. package/dist/init-config.js +15 -0
  18. package/dist/ledger-cli.d.ts +1 -0
  19. package/dist/ledger-cli.js +50 -0
  20. package/dist/ledger-tools.d.ts +10 -0
  21. package/dist/ledger-tools.js +64 -0
  22. package/dist/loop-cli.js +4 -0
  23. package/dist/loop-tools.js +3 -1
  24. package/dist/pi.d.ts +2 -0
  25. package/dist/pi.js +24 -4
  26. package/dist/regenerate/activity.d.ts +9 -0
  27. package/dist/regenerate/activity.js +11 -0
  28. package/dist/regenerate/agent-env.d.ts +7 -0
  29. package/dist/regenerate/agent-env.js +126 -5
  30. package/dist/regenerate/agent-shell.js +5 -0
  31. package/dist/regenerate/cases.js +3 -0
  32. package/dist/regenerate/chunk-plan.d.ts +5 -6
  33. package/dist/regenerate/chunk-plan.js +43 -10
  34. package/dist/regenerate/conformance-agents.js +2 -2
  35. package/dist/regenerate/conformance.js +6 -3
  36. package/dist/regenerate/debug-log.d.ts +3 -1
  37. package/dist/regenerate/debug-log.js +10 -2
  38. package/dist/regenerate/diagnose.js +12 -2
  39. package/dist/regenerate/environment-error.d.ts +6 -0
  40. package/dist/regenerate/environment-error.js +19 -1
  41. package/dist/regenerate/job-log.d.ts +3 -1
  42. package/dist/regenerate/job-log.js +8 -5
  43. package/dist/regenerate/jobs-tree.d.ts +7 -3
  44. package/dist/regenerate/jobs-tree.js +21 -6
  45. package/dist/regenerate/jobs.d.ts +18 -0
  46. package/dist/regenerate/jobs.js +62 -5
  47. package/dist/regenerate/ledger-view.d.ts +92 -0
  48. package/dist/regenerate/ledger-view.js +345 -0
  49. package/dist/regenerate/ledger.d.ts +275 -0
  50. package/dist/regenerate/ledger.js +349 -0
  51. package/dist/regenerate/lineage.d.ts +9 -1
  52. package/dist/regenerate/lineage.js +64 -12
  53. package/dist/regenerate/loop.d.ts +6 -0
  54. package/dist/regenerate/loop.js +36 -6
  55. package/dist/regenerate/pulse-files.d.ts +14 -0
  56. package/dist/regenerate/pulse-files.js +63 -0
  57. package/dist/regenerate/records.d.ts +5 -3
  58. package/dist/regenerate/records.js +6 -4
  59. package/dist/regenerate/regenerate.js +7 -5
  60. package/dist/regenerate/regression.js +4 -2
  61. package/dist/regenerate/services.js +3 -0
  62. package/dist/regenerate/side-panel.d.ts +2 -0
  63. package/dist/regenerate/side-panel.js +4 -3
  64. package/dist/regenerate/spec-map.js +8 -5
  65. package/dist/regenerate/test-scripts.js +18 -5
  66. package/dist/regenerate/testing.js +3 -0
  67. package/dist/regenerate/unit-review.js +3 -0
  68. package/dist/run-report.d.ts +1 -1
  69. package/dist/run-report.js +15 -5
  70. package/dist/self-invocation.d.ts +1 -1
  71. package/dist/self-invocation.js +3 -2
  72. package/dist/skill-progress.d.ts +1 -1
  73. package/dist/skill.d.ts +5 -0
  74. package/dist/skill.js +1 -0
  75. package/dist/skills.d.ts +14 -0
  76. package/dist/skills.js +19 -1
  77. package/dist/surface.d.ts +5 -0
  78. package/dist/surface.js +18 -0
  79. package/docs/authoring-plain.md +2 -2
  80. package/docs/config.md +24 -3
  81. package/docs/development.md +3 -1
  82. package/docs/headless.md +9 -4
  83. package/docs/hosting.md +46 -0
  84. package/docs/installation.md +1 -0
  85. package/docs/regenerating.md +11 -11
  86. package/docs/sandbox.md +5 -5
  87. package/docs/skills-and-gates.md +3 -1
  88. package/docs/using.md +39 -8
  89. package/genome/rules/func-specs.md +1 -0
  90. package/genome/rules/turri-regeneration.md +3 -0
  91. package/genome/skills/add-functional-spec/SKILL.md +12 -3
  92. package/genome/skills/add-functional-specs/SKILL.md +12 -2
  93. package/genome/skills/brood/SKILL.md +2 -0
  94. package/genome/skills/bud/SKILL.md +2 -0
  95. package/genome/skills/spawn/SKILL.md +47 -35
  96. package/package.json +1 -1
package/dist/identity.js CHANGED
@@ -1,5 +1,31 @@
1
- /** Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. */
2
- export const TURRI_IDENTITY = [
1
+ /** In the terminal: regen, where the person watches the work - the side panel, its key, and the pulses behind it. */
2
+ const REGEN = [
3
+ "- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
4
+ " planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
5
+ " lowercase. It was the regeneration, the job queue, or the job panel.",
6
+ ];
7
+ /** In a host: where the person watches the work is the host's to say - its surface, below. */
8
+ const HOST_SURFACE = [
9
+ "- What the person sees of your work - where they watch it, and what they press - is the app's you run in, and",
10
+ " <surface> below says it in its own words. Say only what it says, and speak of no key, panel or view it does not",
11
+ " name.",
12
+ ];
13
+ /**
14
+ * Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. Where
15
+ * the person watches the work is the terminal's regen; given a host's surface, that is said in its place.
16
+ */
17
+ export function conversationIdentity(surface) {
18
+ const text = surface?.trim() ?? "";
19
+ const hosted = text !== "";
20
+ return [
21
+ ...IDENTITY_BEFORE,
22
+ ...(hosted ? HOST_SURFACE : REGEN),
23
+ ...IDENTITY_AFTER,
24
+ ...(hosted ? ["", "<surface>", text, "</surface>"] : []),
25
+ "</turri>",
26
+ ].join("\n");
27
+ }
28
+ const IDENTITY_BEFORE = [
3
29
  "<turri>",
4
30
  "You are turri - always lowercase - a coding agent with the power of regenerative software. The software is its",
5
31
  "***plain; the code is regenerated from it. You help the person write ***plain, and you regenerate the code from it a",
@@ -25,11 +51,11 @@ export const TURRI_IDENTITY = [
25
51
  "- A pulse is one unit of work regenerating the code - a spec diff regenerated in a codebase, or its conformance",
26
52
  " tests. It was called a job, and still is in places: the job_id a tool takes is a pulse's id, and parallel-jobs and",
27
53
  " allow-to-stop-jobs in config.turri.yaml are about pulses. \"Job\" in what you read is a pulse; say pulse.",
28
- "- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
29
- " planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
30
- " lowercase. It was the regeneration, the job queue, or the job panel.",
54
+ ];
55
+ const IDENTITY_AFTER = [
31
56
  "- mutate, in a skill's activity, is write_spec_diff: a spec change written and regenerated in one step.",
32
57
  "- Regenerate is the word for what you do to the code: never grow, grew or grown - older text, a tool's description",
33
58
  " or a report may still say it, and means regenerate.",
34
- "</turri>",
35
- ].join("\n");
59
+ ];
60
+ /** turri as the terminal runs it. */
61
+ export const TURRI_IDENTITY = conversationIdentity();
@@ -66,6 +66,9 @@ export declare const DEFAULTS: {
66
66
  readonly compactAt: 100000;
67
67
  readonly maxSpecsPerPulse: 3;
68
68
  readonly maxLinesPerPulse: 120;
69
+ readonly ledgerKeep: 100;
70
+ readonly debug: false;
71
+ readonly cacheKeep: 4096;
69
72
  };
70
73
  /** How many pulses run at once without a setting: half this machine's cores, one to four - as regenerate/jobs.ts has it. */
71
74
  export declare function defaultParallel(cores?: number): number;
@@ -118,6 +118,9 @@ export const DEFAULTS = {
118
118
  compactAt: 100000,
119
119
  maxSpecsPerPulse: 3,
120
120
  maxLinesPerPulse: 120,
121
+ ledgerKeep: 100,
122
+ debug: false,
123
+ cacheKeep: 4096,
121
124
  };
122
125
  /** How many pulses run at once without a setting: half this machine's cores, one to four - as regenerate/jobs.ts has it. */
123
126
  export function defaultParallel(cores = availableParallelism()) {
@@ -183,6 +186,18 @@ export function quickStartConfig(vendor = "other", bedrock) {
183
186
  "# Folders of this machine an agent's commands may read beside the usual ones - a toolchain somewhere unusual: [/srv/zig].",
184
187
  "toolchains: []",
185
188
  "",
189
+ "# Megabytes of the ledger - what turri did, as facts, in .turri/ledger, never committed - the project keeps; past it,",
190
+ "# the oldest runs' are pruned. 0 keeps none.",
191
+ `ledger-keep: ${DEFAULTS.ledgerKeep}`,
192
+ "",
193
+ "# Megabytes of package caches - npm's, pip's, Maven's... in plain_modules/.turri/cache - the project keeps; past it,",
194
+ "# the least recently written are removed. 0 keeps none.",
195
+ `cache-keep: ${DEFAULTS.cacheKeep}`,
196
+ "",
197
+ "# Whether turri keeps what it writes for debugging it: every agent's whole log and session, each pulse's log, the",
198
+ "# test runs' outputs, the agents' temporary files. Off, only what turri reads to work is kept. TURRI_DEBUG=1 for one run.",
199
+ `debug: ${DEFAULTS.debug}`,
200
+ "",
186
201
  "# The testing scripts, by path from the project root - written here by the skill that writes them. One not named is",
187
202
  "# looked for at the root, then in test_scripts/. Each runs the static checks of what it tests - the formatter, the",
188
203
  "# linter, the type checker - with the tests, exiting 80 when the tests pass and only the checks do not. The prepare",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { resolve } from "node:path";
2
+ import { readLedger } from "./regenerate/ledger.js";
3
+ import { describePastPulse, describeRuns, ledgerProjects, runViews } from "./regenerate/ledger-view.js";
4
+ const USAGE = `usage: turri ledger [pulse] [--runs <n>] [--json]
5
+
6
+ What turri did here, from the project's ledger (.turri/ledger): the last runs, a line a pulse - how each ended, how
7
+ long it took and what it cost. With a pulse's id, or the start of it, that pulse: its rounds, test runs, verdicts,
8
+ healers, what turri refused and what its history recorded. --json prints the events themselves, a JSON line each.
9
+
10
+ --runs <n> how many runs to show, the most recent first (default 5)
11
+ --json the events, as the ledger has them - the pulse's alone with a pulse`;
12
+ const args = process.argv.slice(2);
13
+ if (args.includes("--help") || args.includes("-h")) {
14
+ console.log(USAGE);
15
+ process.exit(0);
16
+ }
17
+ const runsAt = args.indexOf("--runs");
18
+ const runs = runsAt >= 0 ? Number(args[runsAt + 1]) : 5;
19
+ if (runsAt >= 0 && (!Number.isInteger(runs) || runs < 1)) {
20
+ console.error(`--runs takes a number of runs, at least 1\n\n${USAGE}`);
21
+ process.exit(1);
22
+ }
23
+ const [pulse] = args.filter((arg, at) => !arg.startsWith("-") && (runsAt < 0 || at !== runsAt + 1));
24
+ const projects = ledgerProjects(process.cwd());
25
+ if (projects.length === 0) {
26
+ console.log("No ledger here: turri has not run in this folder, or in a project under it, since the ledger began.");
27
+ process.exit(0);
28
+ }
29
+ if (args.includes("--json")) {
30
+ for (const project of projects)
31
+ for (const entry of readLedger(project, pulse !== undefined ? { job: pulse } : {}))
32
+ console.log(JSON.stringify(entry));
33
+ process.exit(0);
34
+ }
35
+ if (pulse !== undefined) {
36
+ const described = describePastPulse(projects, pulse);
37
+ if (described === undefined) {
38
+ console.error(`The ledger has no pulse ${pulse}.`);
39
+ process.exit(1);
40
+ }
41
+ console.log(described);
42
+ process.exit(0);
43
+ }
44
+ for (const project of projects) {
45
+ if (projects.length > 1)
46
+ console.log(`── ${resolve(project)}\n`);
47
+ console.log(describeRuns(runViews(project).slice(0, runs)));
48
+ if (projects.length > 1)
49
+ console.log("");
50
+ }
@@ -0,0 +1,10 @@
1
+ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ type AnyTool = ToolDefinition<any, any, any>;
3
+ /**
4
+ * ledger: what turri did, as the ledger recorded it - in this session and every one before it. One pulse, by its id,
5
+ * as facts; the last runs, a line a pulse; or the events of some kinds, counted and listed. Reads only.
6
+ */
7
+ export declare function ledgerTool(options: {
8
+ cwd: string;
9
+ }): AnyTool;
10
+ export {};
@@ -0,0 +1,64 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { describeEvents, describePastPulse, describeRuns, ledgerProjects, runViews, sinceOf } from "./regenerate/ledger-view.js";
4
+ /** How many runs `ledger` lists when it is asked for none. */
5
+ const RUNS_SHOWN = 3;
6
+ /**
7
+ * ledger: what turri did, as the ledger recorded it - in this session and every one before it. One pulse, by its id,
8
+ * as facts; the last runs, a line a pulse; or the events of some kinds, counted and listed. Reads only.
9
+ */
10
+ export function ledgerTool(options) {
11
+ return defineTool({
12
+ name: "ledger",
13
+ label: "Ledger",
14
+ description: "What turri did here, as its ledger recorded it - facts, not transcripts - in this session and every earlier one. " +
15
+ "With pulse: that pulse, this session's or an earlier one's - how it ended, its rounds and test runs (how many failed " +
16
+ "the same way), the judge's and the reviews' verdicts, the healers (the debugger, the diagnosis) and whether they " +
17
+ "failed, the model provider's trouble, what turri refused and stopped, what it committed or rolled back, its last " +
18
+ "stages and its log. With kinds (and module, since): how many events of those kinds there were, and the most recent, " +
19
+ "a line each - healer.failed, provider.error, script.run, gate.round, judge.verdict, guard.refused, history.failed, " +
20
+ "job.finished, ...; a group by its first part, healer for every healer event. Otherwise: the last runs, a line a pulse - " +
21
+ "how each ended, how long, what it cost. Starts nothing and calls no model.",
22
+ promptSnippet: "ledger: what turri did - a pulse as facts, the last runs, or events counted by kind - this session and earlier ones",
23
+ promptGuidelines: [
24
+ "Use ledger for what turri recorded of the work - a pulse's rounds, test runs, verdicts, healers and commits, this session's or an earlier one's; how often something happened; what earlier runs did and cost - rather than reading .turri/ledger with the shell. pulses is what is running and planned now; a pulse's whole transcript is its log, which ledger names when it is kept (debug mode).",
25
+ ],
26
+ parameters: Type.Object({
27
+ pulse: Type.Optional(Type.String({ description: "A pulse's id, or the start of it: that pulse, as the ledger has it" })),
28
+ kinds: Type.Optional(Type.Array(Type.String(), { description: "Event kinds to count and list - healer.failed, or healer for the whole group" })),
29
+ module: Type.Optional(Type.String({ description: "With kinds: only the events of this module" })),
30
+ since: Type.Optional(Type.String({ description: "With kinds: only events since then - 24h, 7d, 30m, or a date and time" })),
31
+ runs: Type.Optional(Type.Number({ description: `Without pulse or kinds: how many of the last runs to list (default ${RUNS_SHOWN})` })),
32
+ limit: Type.Optional(Type.Number({ description: "With kinds: how many of the most recent events to list (default 20)" })),
33
+ }),
34
+ async execute(_id, params) {
35
+ const text = (body, details = {}) => ({ content: [{ type: "text", text: body }], details });
36
+ const projects = ledgerProjects(options.cwd);
37
+ if (projects.length === 0)
38
+ return text("The ledger is empty here: nothing has run in this folder, or in a project under it, since it began.", { found: false });
39
+ if (params.pulse !== undefined) {
40
+ const described = describePastPulse(projects, params.pulse.trim());
41
+ return described !== undefined ? text(described, { found: true }) : text(`The ledger has no pulse ${params.pulse}.`, { found: false });
42
+ }
43
+ if (params.kinds !== undefined && params.kinds.length > 0) {
44
+ let since;
45
+ if (params.since !== undefined) {
46
+ since = sinceOf(params.since);
47
+ if (since === undefined)
48
+ return text(`since takes 24h, 7d, 30m or a date and time, not ${JSON.stringify(params.since)}.`, { found: false });
49
+ }
50
+ return text(describeEvents(projects, {
51
+ kinds: params.kinds,
52
+ ...(params.module !== undefined ? { module: params.module } : {}),
53
+ ...(since !== undefined ? { since } : {}),
54
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
55
+ }), { found: true });
56
+ }
57
+ const count = params.runs !== undefined && Number.isFinite(params.runs) ? Math.max(1, Math.floor(params.runs)) : RUNS_SHOWN;
58
+ const runs = projects.flatMap((project) => runViews(project)).sort((a, b) => (b.started?.ts ?? "").localeCompare(a.started?.ts ?? ""));
59
+ const shown = runs.slice(0, count);
60
+ const more = runs.length - shown.length;
61
+ return text(`${describeRuns(shown)}${more > 0 ? `\n\n${more} earlier run${more === 1 ? "" : "s"} beyond these: ask with runs for more.` : ""}`, { found: true, runs: shown.length });
62
+ },
63
+ });
64
+ }
package/dist/loop-cli.js CHANGED
@@ -5,6 +5,7 @@ import { configForSpec, inheritModel, modelOnly, modelSpec, projectModels, readP
5
5
  import { PACKAGE_ROOT } from "./environment.js";
6
6
  import { c } from "./module-report.js";
7
7
  import { describeTurnIncident, onModelEvent } from "./regenerate/activity.js";
8
+ import { ledgerWanted, startLedger } from "./regenerate/ledger.js";
8
9
  import { tryItCommand } from "./regenerate/delivery.js";
9
10
  import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
10
11
  import { createJobRegistry, pulseName } from "./regenerate/jobs.js";
@@ -32,6 +33,9 @@ export async function runLoop(options) {
32
33
  // The models as the config has them - with a command's own options read as its keys (headlessOptions), as the chat reads them.
33
34
  const model = modelSpec(projectModels(project).coding);
34
35
  const projectDir = projectDirOf(plainFile);
36
+ // What the run does, as facts, in the project's .turri/ledger.
37
+ if (ledgerWanted() && options.dryRun !== true)
38
+ startLedger({ mode: options.command?.[0] ?? "headless", project: projectDir, warn: (message) => console.error(message) });
35
39
  const buildDir = options.buildDir !== undefined && options.dryRun !== true ? resolve(options.buildDir) : undefined;
36
40
  if (buildDir !== undefined)
37
41
  refuseBuildDir(buildDir, projectDir);
@@ -9,6 +9,7 @@ import { pulseName } from "./regenerate/jobs.js";
9
9
  import { stopDeployed } from "./regenerate/release.js";
10
10
  import { projectDirOf } from "./regenerate/paths.js";
11
11
  import { findTestScripts } from "./regenerate/test-scripts.js";
12
+ import { surfaceOf } from "./surface.js";
12
13
  /** How the queue is to be planned, for the work this call queues - see regenerate/steer.ts. */
13
14
  export const GUIDANCE_PARAM = Type.Optional(Type.String({
14
15
  description: "How to plan the queue for this work, in a sentence or two - which functional specs go together, what first - as " +
@@ -114,8 +115,9 @@ export function stopJob(jobs, named, reason, loop) {
114
115
  if (module !== undefined)
115
116
  return module;
116
117
  const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
118
+ // /pulses is the terminal's: in a host, the pulses tool lists them.
117
119
  if (matches.length === 0)
118
- return `No pulse ${named}. /pulses shows them.`;
120
+ return `No pulse ${named}. ${surfaceOf(jobs) !== undefined ? "The pulses tool lists them." : "/pulses shows them."}`;
119
121
  if (matches.length > 1)
120
122
  return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
121
123
  const target = matches[0];
package/dist/pi.d.ts CHANGED
@@ -184,6 +184,8 @@ export declare function createExecutor(opts: {
184
184
  rules?: boolean;
185
185
  /** Continue the session saved in this file, instead of starting a new one. */
186
186
  resumeFrom?: string;
187
+ /** Save its session to disk, debug mode or not: it is picked back up later (a skill's run, resumed). */
188
+ saveSession?: boolean;
187
189
  /** Added to its system prompt: its sandbox, above all - what it can reach (see confine.ts sandboxNote). */
188
190
  system?: string;
189
191
  /**
package/dist/pi.js CHANGED
@@ -2,13 +2,14 @@ import { basename, dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
4
4
  import { loadRules, renderRulesBlock } from "./rules.js";
5
- import { inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, roleTurnLimitMs, splitModelSpec } from "./config.js";
5
+ import { debugMode, inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, roleTurnLimitMs, splitModelSpec } from "./config.js";
6
6
  import { maskToolResults } from "./regenerate/masking.js";
7
7
  import { withListArguments } from "./regenerate/list-arguments.js";
8
8
  import { checkAgainstScript, ENVIRONMENT_ERROR_TOOL, EnvironmentError, environmentErrorTool, sandboxGapOf } from "./regenerate/environment-error.js";
9
9
  import { MODULES_DIR, projectRootOf, sessionsDir } from "./regenerate/paths.js";
10
10
  import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportModelEvent, reportUsage, } from "./regenerate/activity.js";
11
11
  import { debugLogOf, startDebugLog } from "./regenerate/debug-log.js";
12
+ import { firstLine, record } from "./regenerate/ledger.js";
12
13
  let thinkingSource;
13
14
  /**
14
15
  * Where sub-agents take their thinking level from: the conversation that started them.
@@ -280,6 +281,8 @@ async function askOnce(session, text, onDelta) {
280
281
  // Calls to one tool failing one after another - arguments it refuses, or errors - with nothing going through.
281
282
  let failing = 0;
282
283
  let failingTool;
284
+ /** When each tool call under way started, by its id: how long it took is recorded as it ends. */
285
+ const toolStarted = new Map();
283
286
  harnessStops.delete(session);
284
287
  reportNow("waiting for the model");
285
288
  // Every entry says which agent it is from, and what it runs on - for reading one agent at a time.
@@ -348,6 +351,7 @@ async function askOnce(session, text, onDelta) {
348
351
  }
349
352
  }
350
353
  if (event.type === "tool_execution_start") {
354
+ toolStarted.set(event.toolCallId, Date.now());
351
355
  const args = JSON.stringify(event.args ?? {});
352
356
  transcript({ kind: "tool", text: `${shownName(event.toolName)} ${clipText(args, MAX_TOOL_ARGS)}`, full: `${shownName(event.toolName)} ${args}` });
353
357
  const took = Date.now() - stepStarted;
@@ -369,6 +373,9 @@ async function askOnce(session, text, onDelta) {
369
373
  stopTurn(session, `${failing} ${shownName(event.toolName)} calls failed in a row`);
370
374
  }
371
375
  const output = resultText(event.result);
376
+ const started = toolStarted.get(event.toolCallId);
377
+ toolStarted.delete(event.toolCallId);
378
+ record({ kind: "tool.call", tool: event.toolName, ms: started !== undefined ? Date.now() - started : 0, ok: !event.isError, resultChars: output.length }, { agent: modelEventOf(session) });
372
379
  transcript({ kind: "result", text: clipText(output, MAX_TOOL_RESULT), error: event.isError, full: output });
373
380
  const line = describeToolResult(event.toolName, event.result, event.isError);
374
381
  if (line !== undefined)
@@ -483,6 +490,7 @@ async function askOnce(session, text, onDelta) {
483
490
  // A turn turri ended is ended, whatever the provider was doing then: what comes back of the abort is not the provider failing.
484
491
  if (providerError !== undefined && stoppedFor === undefined) {
485
492
  debug?.note(`turn ended with a provider error after ${seconds(Date.now() - turnStarted)}: ${providerError}`);
493
+ record({ kind: "provider.error", error: firstLine(providerError, 300), transient: TRANSIENT_PROVIDER_ERROR.test(providerError) }, { agent: modelEventOf(session) });
486
494
  throw new ProviderError(`${session.model?.id ?? "model"}: ${providerError}`);
487
495
  }
488
496
  debug?.note(`turn finished after ${seconds(Date.now() - turnStarted)}`);
@@ -659,6 +667,11 @@ export function guardRepeats(tools, session, budget) {
659
667
  // Calls refused in the restricted turn under way, and which turn's set that was.
660
668
  let refused = 0;
661
669
  let refusedIn;
670
+ /** The agent a refusal is of, for the ledger. */
671
+ const guardPlace = () => {
672
+ const current = session();
673
+ return current !== undefined ? { agent: modelEventOf(current) } : {};
674
+ };
662
675
  return tools.map((tool) => ({
663
676
  ...tool,
664
677
  execute: async (...args) => {
@@ -669,6 +682,7 @@ export function guardRepeats(tools, session, budget) {
669
682
  if (refusedIn !== only)
670
683
  (refused = 0), (refusedIn = only);
671
684
  refused += 1;
685
+ record({ kind: "guard.refused", rule: "restricted", tool: tool.name, times: refused }, guardPlace());
672
686
  if (refused > REFUSED_BEFORE_STOP) {
673
687
  reportActivity(` stopped the agent's turn: ${refused} calls to tools this turn does not have`);
674
688
  stopTurn(current, `${refused} calls to tools this turn does not have`);
@@ -679,6 +693,7 @@ export function guardRepeats(tools, session, budget) {
679
693
  }
680
694
  calls += 1;
681
695
  if (budget !== undefined && !budget.finish.includes(tool.name) && calls > budget.calls) {
696
+ record({ kind: "guard.refused", rule: "budget", tool: tool.name, times: calls - budget.calls }, guardPlace());
682
697
  if (calls > budget.calls * 2) {
683
698
  reportActivity(` stopped the agent's turn: ${calls} tool calls, past its budget of ${budget.calls}`);
684
699
  stopTurn(session(), `${calls} tool calls, past its budget of ${budget.calls}`);
@@ -707,6 +722,8 @@ export function guardRepeats(tools, session, budget) {
707
722
  // Anything else may have changed what a look would find.
708
723
  looks.clear();
709
724
  }
725
+ if (repeats > REPEATS_ALLOWED)
726
+ record({ kind: "guard.refused", rule: "repeat", tool: tool.name, times: repeats }, guardPlace());
710
727
  if (repeats > REPEATS_BEFORE_STOP) {
711
728
  reportActivity(` stopped the agent's turn: it made the same ${tool.name} call ${repeats} times with nothing changed`);
712
729
  stopTurn(session(), `the same ${tool.name} call ${repeats} times with nothing changed`);
@@ -736,11 +753,14 @@ export function guardRepeats(tools, session, budget) {
736
753
  * start that has forgotten what it just wrote.
737
754
  */
738
755
  export async function createExecutor(opts) {
739
- // Saved to disk, in the project: an agent's session is the record of what it did, and the
740
- // only way to pick a long run back up after the process that ran it is gone.
756
+ // Saved to disk, in the project, for an agent picked back up after the process that ran it is gone - a skill's run -
757
+ // and in debug mode, as the record of what each did. Nothing else reads one back: the rest are kept in memory.
758
+ const project = projectRootOf(opts.cwd);
741
759
  const sessionManager = opts.resumeFrom !== undefined
742
760
  ? SessionManager.open(opts.resumeFrom)
743
- : SessionManager.create(opts.cwd, sessionsDir(projectRootOf(opts.cwd)));
761
+ : opts.saveSession === true || debugMode(project)
762
+ ? SessionManager.create(opts.cwd, sessionsDir(project))
763
+ : SessionManager.inMemory(opts.cwd);
744
764
  // An agent that writes and runs code - given the harness's run its reports are checked against - can say the machine
745
765
  // lacks what the work needs: its turn ends, and the pulse stops with it. Observers, explorers and judges cannot.
746
766
  const hasShell = opts.customTools.some((tool) => tool.name === "bash") && opts.checkEnvironment !== undefined;
@@ -36,6 +36,9 @@ export interface ActivitySink {
36
36
  kind: string;
37
37
  summary: string;
38
38
  };
39
+ /** The project the job works in, and its module: where what it does is recorded (ledger.ts). */
40
+ project?: string;
41
+ module?: string;
39
42
  /** The spec was found to contradict itself - for the person to decide. */
40
43
  specConflict?(conflict: SpecConflict): void;
41
44
  /** The functional spec being built now, or nothing when none is. */
@@ -89,6 +92,12 @@ export declare function currentJob(): {
89
92
  kind: string;
90
93
  summary: string;
91
94
  } | undefined;
95
+ /** Where this code runs: the job it runs for, its project and its module - each when it runs for one. */
96
+ export declare function currentPlace(): {
97
+ job?: string;
98
+ project?: string;
99
+ module?: string;
100
+ };
92
101
  /**
93
102
  * The conversation an agent is having, as it happens: what it was asked, what it wrote and
94
103
  * thought, what it called and what came back. Text and thinking arrive as deltas and are
@@ -22,6 +22,17 @@ export function currentStopSignal() {
22
22
  export function currentJob() {
23
23
  return store.getStore()?.job;
24
24
  }
25
+ /** Where this code runs: the job it runs for, its project and its module - each when it runs for one. */
26
+ export function currentPlace() {
27
+ const sink = store.getStore();
28
+ if (sink === undefined)
29
+ return {};
30
+ return {
31
+ ...(sink.job !== undefined ? { job: sink.job.id } : {}),
32
+ ...(sink.project !== undefined ? { project: sink.project } : {}),
33
+ ...(sink.module !== undefined ? { module: sink.module } : {}),
34
+ };
35
+ }
25
36
  export function reportTranscript(entry) {
26
37
  store.getStore()?.transcript(entry);
27
38
  }
@@ -28,6 +28,13 @@ export declare function agentEnvironment(moduleDir: string, options?: {
28
28
  hostEnv?: NodeJS.ProcessEnv;
29
29
  home?: string;
30
30
  }): AgentEnvironment;
31
+ /**
32
+ * Hold the package caches of a scratch area to `keepMb` megabytes: past it, the caches least recently written go whole -
33
+ * a package manager's folder in `cache`, or a tool's in an agent's home - until it is under. A cache is as recent as the
34
+ * newest thing written anywhere in it: a package manager writes deep inside its folder, never beside it. What goes is
35
+ * downloaded again when it is wanted. Never a reason for the work to fail.
36
+ */
37
+ export declare function tidyCaches(scratch: string, keepMb: number): void;
31
38
  /**
32
39
  * On macOS, what a program asks the system for as the per-user temporary folder - not TMPDIR - is that folder with this
33
40
  * suffix added: a folder of the module's own inside it. The JVM keeps its attach sockets there (`.java_pid<pid>`), which
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
- import { readProjectConfig, stackOf } from "../config.js";
5
+ import { DEFAULT_CACHE_KEEP_MB, readProjectConfig, stackOf } from "../config.js";
6
6
  import { registerSecrets } from "./masking.js";
7
7
  import { projectRootOf, modulesScratchDir } from "./paths.js";
8
8
  /**
@@ -12,9 +12,9 @@ import { projectRootOf, modulesScratchDir } from "./paths.js";
12
12
  * agent, and an agent's command - or the code it runs - could read any of them. So only an allow-list passes: the path,
13
13
  * the locale, the terminal, the user, and the variables that locate the host's toolchains. Then turri's own: a HOME of
14
14
  * the module's own, kept between pulses, so a tool's settings and an agent's dotfiles never land in the person's; a
15
- * TMPDIR; the package caches in the project, so a dependency downloads once; and git stopped at the module, so a
16
- * project that is itself a repository is not found from the code. Last, what the project passes on purpose -
17
- * `agent-env:` in its config - which never replaces turri's own.
15
+ * TMPDIR; the package caches in the project, so a dependency downloads once - held to `cache-keep:`; and git stopped
16
+ * at the module, so a project that is itself a repository is not found from the code. Last, what the project passes on
17
+ * purpose - `agent-env:` in its config - which never replaces turri's own.
18
18
  */
19
19
  /** Passed from the host as they are. */
20
20
  const ALLOWED = ["PATH", "LANG", "TERM", "TZ", "USER", "LOGNAME", "COLORTERM"];
@@ -62,11 +62,18 @@ export function cacheEnv(cache) {
62
62
  GRADLE_USER_HOME: join(cache, "gradle"),
63
63
  CARGO_HOME: join(cache, "cargo"),
64
64
  npm_config_cache: join(cache, "npm"),
65
+ // node-gyp's headers, which it keeps in the home folder - a copy each module's - once for the project.
66
+ npm_config_devdir: join(cache, "node-gyp"),
65
67
  YARN_CACHE_FOLDER: join(cache, "yarn"),
66
68
  PIP_CACHE_DIR: join(cache, "pip"),
67
69
  UV_CACHE_DIR: join(cache, "uv"),
68
70
  GOCACHE: join(cache, "go-build"),
69
71
  GOMODCACHE: join(cache, "go-mod"),
72
+ // The browsers the end-to-end test tools download - hundreds of megabytes each, kept in the home folder: once for
73
+ // the project, not once a module.
74
+ PLAYWRIGHT_BROWSERS_PATH: join(cache, "ms-playwright"),
75
+ PUPPETEER_CACHE_DIR: join(cache, "puppeteer"),
76
+ CYPRESS_CACHE_FOLDER: join(cache, "Cypress"),
70
77
  };
71
78
  }
72
79
  /** The git identity an agent's commits in a scratch repository get - never the person's. */
@@ -101,9 +108,123 @@ export function agentEnvironment(moduleDir, options = {}) {
101
108
  Object.assign(env, turrisOwn(module, name));
102
109
  return { env, passed: Object.keys(values).sort(), values, missing };
103
110
  }
111
+ /** The scratch areas whose caches this process has held to `cache-keep:`: once a run, before its first command. */
112
+ const tidied = new Set();
113
+ /** Where in an agent's home folder its tools keep caches of their own. */
114
+ const HOME_CACHES = [join("Library", "Caches"), ".cache"];
115
+ /**
116
+ * Hold the package caches of a scratch area to `keepMb` megabytes: past it, the caches least recently written go whole -
117
+ * a package manager's folder in `cache`, or a tool's in an agent's home - until it is under. A cache is as recent as the
118
+ * newest thing written anywhere in it: a package manager writes deep inside its folder, never beside it. What goes is
119
+ * downloaded again when it is wanted. Never a reason for the work to fail.
120
+ */
121
+ export function tidyCaches(scratch, keepMb) {
122
+ const caches = [];
123
+ const folders = [join(scratch, "cache")];
124
+ for (const home of entries(join(scratch, "home")))
125
+ for (const inHome of HOME_CACHES)
126
+ folders.push(join(scratch, "home", home, inHome));
127
+ for (const folder of folders) {
128
+ for (const name of entries(folder)) {
129
+ const path = join(folder, name);
130
+ try {
131
+ caches.push({ path, ...measure(path) });
132
+ }
133
+ catch {
134
+ /* gone meanwhile */
135
+ }
136
+ }
137
+ }
138
+ let total = caches.reduce((sum, cache) => sum + cache.bytes, 0);
139
+ for (const cache of caches.sort((a, b) => a.written - b.written)) {
140
+ if (total <= keepMb * 1024 * 1024)
141
+ break;
142
+ total -= cache.bytes;
143
+ // What could not be removed still counts: a cache half gone is not the room it took.
144
+ if (existsSync(cache.path))
145
+ total += remove(cache.path);
146
+ }
147
+ }
148
+ function entries(dir) {
149
+ try {
150
+ return readdirSync(dir);
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ }
156
+ /** A file's or folder's bytes and when anything in it was last written, its links not followed. */
157
+ function measure(path) {
158
+ const stat = lstatSync(path);
159
+ if (!stat.isDirectory())
160
+ return { bytes: stat.size, written: stat.mtimeMs };
161
+ let bytes = 0;
162
+ let written = stat.mtimeMs;
163
+ for (const name of entries(path)) {
164
+ try {
165
+ const inside = measure(join(path, name));
166
+ bytes += inside.bytes;
167
+ written = Math.max(written, inside.written);
168
+ }
169
+ catch {
170
+ /* gone meanwhile */
171
+ }
172
+ }
173
+ return { bytes, written };
174
+ }
175
+ /**
176
+ * Remove a cache whole, and say how many of its bytes are left. Some tools make what they cache read-only - Go's module
177
+ * cache, so it is never edited in place - and a folder that cannot be written cannot be emptied: each is made writable
178
+ * first, as `go clean -modcache` does.
179
+ */
180
+ function remove(path) {
181
+ try {
182
+ rmSync(path, { recursive: true, force: true });
183
+ }
184
+ catch {
185
+ writable(path);
186
+ try {
187
+ rmSync(path, { recursive: true, force: true });
188
+ }
189
+ catch {
190
+ /* what cannot be removed stays */
191
+ }
192
+ }
193
+ try {
194
+ return existsSync(path) ? measure(path).bytes : 0;
195
+ }
196
+ catch {
197
+ return 0;
198
+ }
199
+ }
200
+ /** Every folder under `path` made writable by its owner, links not followed. */
201
+ function writable(path) {
202
+ try {
203
+ const stat = lstatSync(path);
204
+ if (!stat.isDirectory())
205
+ return;
206
+ chmodSync(path, stat.mode | 0o700);
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const name of entries(path))
212
+ writable(join(path, name));
213
+ }
104
214
  /** HOME, TMPDIR, the caches and git's ceiling: what no setting of the project's replaces. */
105
215
  function turrisOwn(module, name) {
106
216
  const scratch = modulesScratchDir(module);
217
+ if (!tidied.has(scratch)) {
218
+ tidied.add(scratch);
219
+ let keep = DEFAULT_CACHE_KEEP_MB;
220
+ try {
221
+ keep = readProjectConfig(projectRootOf(module)).cacheKeep ?? DEFAULT_CACHE_KEEP_MB;
222
+ }
223
+ catch {
224
+ /* a config that does not parse is said elsewhere: the default holds */
225
+ }
226
+ tidyCaches(scratch, keep);
227
+ }
107
228
  const fakeHome = join(scratch, "home", name);
108
229
  mkdirSync(fakeHome, { recursive: true });
109
230
  const gitconfig = join(fakeHome, ".gitconfig");
@@ -1,6 +1,7 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { readProjectConfig, stackOf } from "../config.js";
3
3
  import { reportNote } from "./activity.js";
4
+ import { record } from "./ledger.js";
4
5
  import { agentEnvironment, DARWIN_TEMP_SUFFIX, packageCacheDir, TOOLCHAIN_VARIABLES } from "./agent-env.js";
5
6
  import { afterCommand, auditMessage, beforeCommand } from "./audit.js";
6
7
  import { checkCommand, machineReadable } from "./command-check.js";
@@ -67,6 +68,9 @@ export function agentShell(role, options = {}) {
67
68
  throw error;
68
69
  }
69
70
  const result = afterCommand(before);
71
+ if (result.putBack.length + result.left.length + result.lost.length + result.conversation.length > 0) {
72
+ record({ kind: "audit.putBack", putBack: result.putBack.length, left: result.left.length, lost: result.lost.length, conversation: result.conversation.length });
73
+ }
70
74
  if (result.putBack.length > 0)
71
75
  reportNote(` an agent's command changed ${result.putBack.join(", ")} - put back`);
72
76
  if (result.left.length > 0)
@@ -90,6 +94,7 @@ export function agentShell(role, options = {}) {
90
94
  async run(command, runOptions = {}) {
91
95
  const checked = checkCommand(command, commandFence);
92
96
  if (!checked.ok) {
97
+ record({ kind: "command.refused", rule: checked.rule });
93
98
  reportNote(` refused an agent's command (${checked.rule}): ${command.length > 120 ? `${command.slice(0, 120)}...` : command}`);
94
99
  const output = `Not run: ${checked.reason}`;
95
100
  runOptions.onData?.(Buffer.from(output));
@@ -64,6 +64,9 @@ export function conformanceCasesBlock(cases) {
64
64
  if (cases.patch.length > 0) {
65
65
  lines.push(`- Changed - its text, its acceptance tests, or a concept it uses: ${named(cases.patch)}.`, " Its conformance tests exist: rewrite them to what it says now. The tests of everything else stay as they are.");
66
66
  }
67
+ if (cases.again.length > 0) {
68
+ lines.push(`- Regenerated again - its code was built anew, and its conformance tests were written against the code it replaced: ${named(cases.again)}.`, " Its text did not change, but the code did. Run its tests against the new code. What they check that the", " specification says stays checked: rewrite such a test only as the new code needs it reached. What they check", " that the specification leaves open - a path, a field, a message, a format the old code chose and the", " specification never names - is the new code's choice now: rewrite those checks to what the new code does,", " or take them out. No test may hold the new code to what only the old code did. The tests of everything else", " stay as they are.");
69
+ }
67
70
  if (cases.delete.length > 0)
68
71
  lines.push(`- Taken out - remove its conformance tests: ${named(cases.delete)}.`);
69
72
  if (cases.mechanical) {