@turri-dev/cli 0.1.8-next.01c7342 → 0.1.8-next.a9bda1b

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +6 -2
  3. package/dist/config-edit.d.ts +1 -1
  4. package/dist/config-edit.js +4 -0
  5. package/dist/config.d.ts +27 -0
  6. package/dist/config.js +28 -1
  7. package/dist/extension.js +75 -9
  8. package/dist/genome.js +2 -0
  9. package/dist/host.d.ts +3 -1
  10. package/dist/host.js +4 -1
  11. package/dist/init-config.d.ts +3 -0
  12. package/dist/init-config.js +15 -0
  13. package/dist/ledger-cli.d.ts +1 -0
  14. package/dist/ledger-cli.js +50 -0
  15. package/dist/ledger-tools.d.ts +10 -0
  16. package/dist/ledger-tools.js +64 -0
  17. package/dist/loop-cli.js +4 -0
  18. package/dist/pi.d.ts +2 -0
  19. package/dist/pi.js +24 -4
  20. package/dist/regenerate/activity.d.ts +9 -0
  21. package/dist/regenerate/activity.js +11 -0
  22. package/dist/regenerate/agent-env.d.ts +7 -0
  23. package/dist/regenerate/agent-env.js +126 -5
  24. package/dist/regenerate/agent-shell.js +5 -0
  25. package/dist/regenerate/chunk-plan.d.ts +0 -6
  26. package/dist/regenerate/chunk-plan.js +16 -4
  27. package/dist/regenerate/conformance.js +3 -0
  28. package/dist/regenerate/debug-log.d.ts +3 -1
  29. package/dist/regenerate/debug-log.js +10 -2
  30. package/dist/regenerate/diagnose.js +12 -2
  31. package/dist/regenerate/job-log.d.ts +3 -1
  32. package/dist/regenerate/job-log.js +8 -5
  33. package/dist/regenerate/jobs-tree.d.ts +7 -3
  34. package/dist/regenerate/jobs-tree.js +21 -6
  35. package/dist/regenerate/jobs.d.ts +9 -0
  36. package/dist/regenerate/jobs.js +51 -5
  37. package/dist/regenerate/ledger-view.d.ts +92 -0
  38. package/dist/regenerate/ledger-view.js +345 -0
  39. package/dist/regenerate/ledger.d.ts +275 -0
  40. package/dist/regenerate/ledger.js +349 -0
  41. package/dist/regenerate/lineage.js +18 -1
  42. package/dist/regenerate/loop.js +10 -1
  43. package/dist/regenerate/pulse-files.d.ts +14 -0
  44. package/dist/regenerate/pulse-files.js +63 -0
  45. package/dist/regenerate/regenerate.js +4 -2
  46. package/dist/regenerate/regression.js +2 -0
  47. package/dist/regenerate/services.js +3 -0
  48. package/dist/regenerate/side-panel.d.ts +2 -0
  49. package/dist/regenerate/side-panel.js +4 -3
  50. package/dist/regenerate/test-scripts.js +18 -5
  51. package/dist/regenerate/testing.js +3 -0
  52. package/dist/regenerate/unit-review.js +3 -0
  53. package/dist/run-report.js +9 -5
  54. package/docs/config.md +23 -2
  55. package/docs/development.md +3 -1
  56. package/docs/headless.md +5 -4
  57. package/docs/hosting.md +29 -0
  58. package/docs/installation.md +1 -0
  59. package/docs/regenerating.md +7 -7
  60. package/docs/sandbox.md +5 -5
  61. package/docs/using.md +33 -4
  62. package/package.json +1 -1
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));
@@ -147,12 +147,6 @@ export interface ChunkAgents {
147
147
  export declare function groupProblems(specs: NewSpec[], answer: GroupAnswer, maxSpecs?: number): string[];
148
148
  /** What is wrong with a placement: every new req once, each where there is such a job. */
149
149
  export declare function placeProblems(input: PlaceInput, answer: PlaceAnswer): string[];
150
- /**
151
- * Cut the queue's work - from `input.base` to `input.current` - into jobs, as the three steps say. Returns the draft
152
- * the work loop runs: checked (checkDraft), or, when nothing holds, the whole work as one job. `notes` is given what
153
- * the plan did not do as asked - the person's wishes an agent did not follow and why, an answer that did not hold, a
154
- * group split for its size - for the conversation to say, rather than guess.
155
- */
156
150
  export declare function planChunks(input: PlanInput, agents?: ChunkAgents, notes?: string[]): Promise<DraftJob[]>;
157
151
  export declare function groupPrompt(specs: NewSpec[], steering?: Steering[], maxSpecs?: number): string;
158
152
  export declare function placePrompt(input: PlaceInput): string;
@@ -4,6 +4,7 @@ import { DEFAULT_CONCEPTS, extractConceptsFromDefinition, extractConceptsFromSpe
4
4
  import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS } from "../plain/spec.js";
5
5
  import { ask, BEGIN, createExecutor } from "../pi.js";
6
6
  import { reportActivity, reportStage } from "./activity.js";
7
+ import { firstLine, record } from "./ledger.js";
7
8
  import { steeringBlock } from "./steer.js";
8
9
  import { marksRegenerateAgain } from "./records.js";
9
10
  import { validateConcepts } from "../plain/concepts.js";
@@ -245,6 +246,11 @@ function titleOf(change) {
245
246
  * the plan did not do as asked - the person's wishes an agent did not follow and why, an answer that did not hold, a
246
247
  * group split for its size - for the conversation to say, rather than guess.
247
248
  */
249
+ /** A planner agent that failed: the plan falls back, and the ledger says why. */
250
+ function plannerFailed(step, error) {
251
+ record({ kind: "planner.failed", step, error: firstLine(error instanceof Error ? error.message : String(error), 300) });
252
+ return undefined;
253
+ }
248
254
  export async function planChunks(input, agents, notes) {
249
255
  if (input.track !== "conformance")
250
256
  return cut(input, agents, notes);
@@ -370,7 +376,7 @@ async function cut(input, agents, notes) {
370
376
  ...list.map((group) => ({ title: group.title, specs: group.specs.map((number) => bySpec.get(number)), definitions: [], reqs: [] })),
371
377
  ];
372
378
  const conformance = input.track === "conformance";
373
- const grouped = conformance ? undefined : ((await agents?.group(numbered, (answer) => groupProblems(numbered, answer, maxSpecs), input.steering, maxSpecs).catch(() => undefined)) ?? undefined);
379
+ const grouped = conformance ? undefined : ((await agents?.group(numbered, (answer) => groupProblems(numbered, answer, maxSpecs), input.steering, maxSpecs).catch((error) => plannerFailed("group", error))) ?? undefined);
374
380
  const groups = conformance
375
381
  ? conformanceGroups(numbered, input.separately === true, maxLines, (list) => {
376
382
  const trial = chunksFor(list);
@@ -386,10 +392,14 @@ async function cut(input, agents, notes) {
386
392
  if (grouped?.notes !== undefined && grouped.notes.trim() !== "")
387
393
  say(`the grouping: ${grouped.notes.trim()}`);
388
394
  if (!conformance && agents !== undefined) {
389
- if (grouped === undefined)
395
+ if (grouped === undefined) {
396
+ record({ kind: "planner.fellBack", step: "group", why: "no grouping was given" });
390
397
  say("the grouping was not given - a functional spec a pulse");
391
- else if (groupProblems(numbered, grouped, maxSpecs).length > 0)
398
+ }
399
+ else if (groupProblems(numbered, grouped, maxSpecs).length > 0) {
400
+ record({ kind: "planner.fellBack", step: "group", why: firstLine(groupProblems(numbered, grouped, maxSpecs).join(" "), 300) });
392
401
  say(`the grouping did not hold (${groupProblems(numbered, grouped, maxSpecs).join(" ")}) - a functional spec a pulse`);
402
+ }
393
403
  }
394
404
  let chunks = chunksFor(groups);
395
405
  // 2. The definitions each group uses.
@@ -407,10 +417,12 @@ async function cut(input, agents, notes) {
407
417
  implementationReqs: implementationReqs.map((change, index) => ({ number: index + 1, text: textOf(change) })),
408
418
  testReqs: testReqs.map((change, index) => ({ number: index + 1, text: textOf(change) })),
409
419
  };
410
- const answer = (await agents?.place(placeInput, (given) => placeProblems(placeInput, given)).catch(() => undefined)) ?? undefined;
420
+ const answer = (await agents?.place(placeInput, (given) => placeProblems(placeInput, given)).catch((error) => plannerFailed("place", error))) ?? undefined;
411
421
  if (answer?.notes !== undefined && answer.notes.trim() !== "")
412
422
  say(`the placement: ${answer.notes.trim()}`);
413
423
  placement = answer !== undefined && placeProblems(placeInput, answer).length === 0 ? answer : undefined;
424
+ if (placement === undefined && agents !== undefined)
425
+ record({ kind: "planner.fellBack", step: "place", why: answer === undefined ? "no placement was given" : firstLine(placeProblems(placeInput, answer).join(" "), 300) });
414
426
  if (placement === undefined && agents !== undefined)
415
427
  say("the placement did not hold - every implementation req with the first pulse, every test req with the first conformance pulse");
416
428
  }
@@ -6,6 +6,7 @@ import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_
6
6
  import { reportActivity, reportLogNote, reportSpec, reportStage, unitOf } from "./activity.js";
7
7
  import { confinedTools } from "./confine.js";
8
8
  import { abandonWork, diffSinceSnapshot, landWork, moduleDirOf, startWork, workDiff, workSnapshot } from "./lineage.js";
9
+ import { firstLine, record } from "./ledger.js";
9
10
  import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
10
11
  import { CONFORMANCE_COMPACTION } from "./compaction.js";
11
12
  import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
@@ -417,6 +418,7 @@ export async function renderConformanceTests(options) {
417
418
  // every functionality's tests - and the code's unit testing script.
418
419
  stage(`running the conformance testing script on the whole suite (attempt ${rounds + 1} of ${maxRounds})`);
419
420
  rounds += 1;
421
+ record({ kind: "gate.round", gate: "conformance", round: rounds, of: maxRounds });
420
422
  let recheck = await runConformanceScript(buildDir, [conformanceDir], scripts);
421
423
  runs.push({ command: recheck.command, exitCode: recheck.exitCode });
422
424
  options.onRun?.(recheck.command, recheck.exitCode);
@@ -537,6 +539,7 @@ export async function renderConformanceTests(options) {
537
539
  onDelta: options.onDelta,
538
540
  });
539
541
  const reason = verdict.reason.split("\n")[0].slice(0, 160);
542
+ record({ kind: "judge.verdict", culprit: verdict.culprit, round: rounds, reason: firstLine(verdict.reason, 300) });
540
543
  // Stopped by the time limit while it was still getting on: no agent can give it more time. The pulse stops now -
541
544
  // rolled back, no retry - and the person is told how to raise the limit.
542
545
  if (verdict.culprit === "environment" && recheck.timedOut === true) {
@@ -1,6 +1,8 @@
1
1
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
2
  /**
3
- * Every render agent's whole conversation, streamed to a file of its own as it happens - always.
3
+ * Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
4
+ * long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
5
+ * pulse-files.ts); an agent outside a pulse only in debug mode.
4
6
  *
5
7
  * A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
6
8
  * running. When an agent goes wrong, what it was actually
@@ -2,9 +2,13 @@ import { appendFileSync, existsSync } from "node:fs";
2
2
  import { maskSecrets } from "./masking.js";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { currentJob, reportActivity, reportTranscript } from "./activity.js";
5
+ import { debugMode } from "../config.js";
6
+ import { pulseWrote } from "./pulse-files.js";
5
7
  import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
6
8
  /**
7
- * Every render agent's whole conversation, streamed to a file of its own as it happens - always.
9
+ * Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
10
+ * long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
11
+ * pulse-files.ts); an agent outside a pulse only in debug mode.
8
12
  *
9
13
  * A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
10
14
  * running. When an agent goes wrong, what it was actually
@@ -47,8 +51,11 @@ export function debugLogOf(session) {
47
51
  */
48
52
  export function startDebugLog(session, options) {
49
53
  let file;
54
+ const dir = debugDirFor(options.cwd);
55
+ // Outside a pulse nothing reads it - no diagnosis, no person but one debugging turri.
56
+ if (currentJob() === undefined && !debugMode(dirname(dirname(dir))))
57
+ return undefined;
50
58
  try {
51
- const dir = debugDirFor(options.cwd);
52
59
  ensureInScratch(dirname(dir), dir);
53
60
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
54
61
  const name = options.label.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
@@ -56,6 +63,7 @@ export function startDebugLog(session, options) {
56
63
  for (let n = 2; existsSync(file); n += 1)
57
64
  file = join(dir, `${stamp}-${name}-${n}.log`);
58
65
  appendFileSync(file, maskSecrets(header(session, options)), { flag: "wx" });
66
+ pulseWrote(file);
59
67
  }
60
68
  catch (error) {
61
69
  reportActivity(`debug log not started: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { ask, BEGIN, createExecutor } from "../pi.js";
4
+ import { debugMode } from "../config.js";
4
5
  import { confinedTools } from "./confine.js";
5
6
  /** How many tool calls the diagnosis may make before it is told to decide. */
6
7
  export const DIAGNOSIS_CALLS = 40;
@@ -49,8 +50,10 @@ export async function diagnoseFailure(options) {
49
50
  if (diagnosis === undefined)
50
51
  await ask(session, "You have not called submit_diagnosis. Call it now, with what you know.");
51
52
  }
52
- catch {
53
- // Without a diagnosis the person still gets both failures, as they were.
53
+ catch (error) {
54
+ // Without a diagnosis the person still gets both failures, as they were - and the ledger why there is none.
55
+ if (diagnosis === undefined)
56
+ throw error;
54
57
  }
55
58
  finally {
56
59
  session.dispose();
@@ -75,6 +78,13 @@ export function diagnosisPrompt(options) {
75
78
  "Find the root cause, not the last symptom: read the failing run's output and the agents' logs around where it",
76
79
  "failed, and follow it to the spec line, the code, the test, the environment or the harness it comes from. Then",
77
80
  "call submit_diagnosis: what happened, why, whose it is to fix, and exactly what to do.",
81
+ ...(debugMode(options.projectDir)
82
+ ? []
83
+ : [
84
+ "",
85
+ "The logs and the full outputs are removed as the job ends - the project is not in debug mode - so never send",
86
+ "the person to one of them: quote what in them shows the cause.",
87
+ ]),
78
88
  "",
79
89
  ...options.failures.flatMap((failure, index) => [`<attempt ${index + 1}>`, failure.trim().slice(-12_000), `</attempt ${index + 1}>`, ""]),
80
90
  ].join("\n");
@@ -17,7 +17,9 @@ export interface JobLog {
17
17
  /** `agents`: a line per agent - its turns, compactions and cost. */
18
18
  end(status: string, report: string | undefined, agents?: string[]): void;
19
19
  }
20
- /** The folder a job's log goes in: the project of the first module it writes, from its lanes. */
20
+ /** The project a job works in: the one of the first module it writes, from its lanes. */
21
+ export declare function projectOfLanes(lanes: string[]): string | undefined;
22
+ /** The folder a job's log goes in: `.turri/jobs` in its project. */
21
23
  export declare function jobLogDir(lanes: string[]): string | undefined;
22
24
  export declare function startJobLog(job: {
23
25
  id: string;
@@ -2,12 +2,15 @@ import { appendFileSync, existsSync } from "node:fs";
2
2
  import { maskSecrets } from "./masking.js";
3
3
  import { dirname, isAbsolute, join, sep } from "node:path";
4
4
  import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
5
- /** The folder a job's log goes in: the project of the first module it writes, from its lanes. */
6
- export function jobLogDir(lanes) {
5
+ /** The project a job works in: the one of the first module it writes, from its lanes. */
6
+ export function projectOfLanes(lanes) {
7
7
  const lane = lanes.find((candidate) => isAbsolute(candidate) && candidate.split(sep).includes(MODULES_DIR));
8
- if (lane === undefined)
9
- return undefined;
10
- return join(scratchDir(join(projectRootOf(lane), MODULES_DIR)), "jobs");
8
+ return lane !== undefined ? projectRootOf(lane) : undefined;
9
+ }
10
+ /** The folder a job's log goes in: `.turri/jobs` in its project. */
11
+ export function jobLogDir(lanes) {
12
+ const project = projectOfLanes(lanes);
13
+ return project !== undefined ? join(scratchDir(join(project, MODULES_DIR)), "jobs") : undefined;
11
14
  }
12
15
  export function startJobLog(job, lanes) {
13
16
  const dir = jobLogDir(lanes);
@@ -3,15 +3,17 @@ import type { TranscriptStyle } from "./transcript-view.ts";
3
3
  /**
4
4
  * The side panel's Jobs view: the queue as a tree, read one job at a time.
5
5
  *
6
- * Three sections - running, queued, finished - each opened and closed on its own, and under them one line per job:
6
+ * Four sections - running, queued, finished, and earlier sessions' from the ledger - each opened and closed on its own,
7
+ * and under them one line per job:
7
8
  * its module, its time and its cost. A job opened shows all of what it builds - the diff, or the spec - and, running,
8
- * what it is doing; finished, what went on in it. So what is planned can be read without every diff on the screen at once.
9
+ * what it is doing; finished, what went on in it; earlier, what the ledger says of it. So what is planned can be read
10
+ * without every diff on the screen at once.
9
11
  *
10
12
  * A cursor moves over the sections and the jobs; it stays on its row, whatever moves under it - a job that finishes
11
13
  * leaves its row to the one after it. A job opened stays open while it stays where it is: moving to another section
12
14
  * it closes, and turns up there collapsed. The state is the conversation's, kept while the panel is closed.
13
15
  */
14
- export type JobSection = "running" | "queued" | "finished";
16
+ export type JobSection = "running" | "queued" | "finished" | "earlier";
15
17
  export declare const SECTIONS: JobSection[];
16
18
  export interface JobsTreeState {
17
19
  /** Which sections are open. */
@@ -24,6 +26,8 @@ export interface JobsTreeState {
24
26
  cursor: number;
25
27
  /** The first line shown. */
26
28
  top: number;
29
+ /** How many runs the ledger has beyond the earlier sessions shown: said under them. */
30
+ earlierMore?: number;
27
31
  }
28
32
  export declare function createJobsTreeState(): JobsTreeState;
29
33
  export declare function sectionOf(job: Job): JobSection;