@turri-dev/cli 0.1.2 → 0.1.3-next.04c4f71

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.
package/dist/loop-cli.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import { sweepOrphanedGroups } from "./regenerate/shell.js";
2
2
  import { createInterface } from "node:readline/promises";
3
- import { resolve } from "node:path";
4
- import { configForSpec, modelSpec, projectModels, readProjectConfig } from "./config.js";
3
+ import { resolve, sep } from "node:path";
4
+ import { configForSpec, inheritModel, modelOnly, modelSpec, projectModels, readProjectConfig, roleModel, ROLE_THINKING, splitModelSpec, } from "./config.js";
5
5
  import { PACKAGE_ROOT } from "./environment.js";
6
6
  import { c } from "./module-report.js";
7
7
  import { tryItCommand } from "./regenerate/delivery.js";
8
8
  import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
9
9
  import { createJobRegistry, pulseName } from "./regenerate/jobs.js";
10
10
  import { conformanceChooser } from "./regenerate/choose-conformance.js";
11
- import { createWorkLoop, describeModules, promptOf } from "./regenerate/loop.js";
12
- import { projectDirOf } from "./regenerate/paths.js";
11
+ import { createWorkLoop, describeModules, functionalityCompletion, promptOf } from "./regenerate/loop.js";
12
+ import { MODULES_DIR, projectDirOf } from "./regenerate/paths.js";
13
+ import { exportModule } from "./regenerate/release.js";
14
+ import { listFiles } from "./regenerate/testing.js";
15
+ import { startRunReport } from "./run-report.js";
16
+ import { configKeyOptions, optionValue } from "./cli-options.js";
13
17
  /**
14
18
  * What every command that builds - `turri regenerate`, `conform`, `spec-diff` - does around its own
15
19
  * request of the work loop, as the chat does it: the project's models, a job that fails run once more and, failing again,
16
20
  * diagnosed, a line as each job starts and ends; then the loop run until nothing more can start, how each job ended,
17
- * and exit 1 when any failed. With `dryRun`, the work is planned and the prompt of the first job each codebase would
21
+ * and exit 1 when any failed or a module is not complete. With `dryRun`, the work is planned and the prompt of the first job each codebase would
18
22
  * start printed, and nothing runs.
19
23
  */
20
24
  export async function runLoop(options) {
@@ -24,8 +28,42 @@ export async function runLoop(options) {
24
28
  if (orphans > 0)
25
29
  console.log(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`);
26
30
  const project = { ...readProjectConfig(process.cwd()), ...Object.fromEntries(Object.entries(configForSpec(plainFile)).filter(([, value]) => value !== undefined)) };
27
- const model = options.model ?? modelSpec(projectModels(project).coding);
31
+ // The models as the config has them - with a command's own options read as its keys (headlessOptions), as the chat reads them.
32
+ const model = modelSpec(projectModels(project).coding);
28
33
  const projectDir = projectDirOf(plainFile);
34
+ const buildDir = options.buildDir !== undefined && options.dryRun !== true ? resolve(options.buildDir) : undefined;
35
+ if (buildDir !== undefined)
36
+ refuseBuildDir(buildDir, projectDir);
37
+ // The code, exported into --build-dir as the process exits - however it exits - before the report's last write.
38
+ let exported;
39
+ if (buildDir !== undefined) {
40
+ process.once("exit", () => {
41
+ exported = exportCode(plainFile, buildDir);
42
+ console.error(exported.exported === true ? `The code is in ${buildDir} (${String(exported.module)} at ${String(exported.commit)}).` : `Nothing exported to ${buildDir}: ${String(exported.why)}`);
43
+ });
44
+ }
45
+ const report = options.report !== undefined && options.dryRun !== true
46
+ ? startRunReport({
47
+ file: options.report,
48
+ command: options.command ?? process.argv.slice(2),
49
+ specFile: plainFile,
50
+ projectDir,
51
+ ...(buildDir !== undefined ? { buildDir } : {}),
52
+ conformanceTests: conformanceLabel(project.conformanceTests),
53
+ review: project.review === true,
54
+ models: reportModels(project, model),
55
+ })
56
+ : undefined;
57
+ try {
58
+ return await runLoopReporting({ ...options, plainFile, project, model, projectDir, reporter: report, exported: () => exported });
59
+ }
60
+ catch (error) {
61
+ report?.crashed(error);
62
+ throw error;
63
+ }
64
+ }
65
+ async function runLoopReporting(options) {
66
+ const { plainFile, project, model, projectDir, reporter: report } = options;
29
67
  // Each job as it starts and as it ends - the transcript of each is in .turri/jobs/.
30
68
  const seen = new Map();
31
69
  const registry = createJobRegistry({
@@ -47,18 +85,28 @@ export async function runLoop(options) {
47
85
  console.log(`${mark} ${job.id} ${pulseName(job)} - ${job.status}`);
48
86
  if (job.status !== "succeeded")
49
87
  console.log(c.dim((job.announce ?? job.report ?? job.error ?? "").trim()));
88
+ report?.write();
50
89
  },
51
90
  });
52
91
  const planner = modelSpec(projectModels(project).harness);
53
92
  const loop = createWorkLoop({
54
93
  registry,
55
94
  cwd: process.cwd(),
56
- ...(options.buildDir !== undefined ? { buildDir: resolve(options.buildDir) } : {}),
57
95
  ...(model !== undefined ? { modelFor: () => model } : {}),
58
96
  ...(planner !== undefined ? { plannerModel: planner } : {}),
59
97
  ...(options.dryRun === true ? { dryRun: true } : {}),
60
- // Under conformance-tests: chosen or module, the chooser decides as a pulse ends, as in the chat.
61
- onChoose: conformanceChooser({ model: () => planner, ...(options.dryRun === true ? { dryRun: true } : {}) }),
98
+ // Under conformance-tests: chosen or module, the chooser decides as a pulse ends, as in the chat - on the harness
99
+ // model, else the coding model: never pi's own default, which may be one this account cannot reach.
100
+ onChoose: conformanceChooser({ model: () => planner ?? model, ...(options.dryRun === true ? { dryRun: true } : {}) }),
101
+ });
102
+ report?.attach({
103
+ jobs: () => registry.list(),
104
+ modules: () => moduleStates(loop.drift(plainFile)),
105
+ completion: () => functionalityCompletion(plainFile),
106
+ atExit: () => {
107
+ const exported = options.exported();
108
+ return exported !== undefined ? { build_dir_export: exported } : undefined;
109
+ },
62
110
  });
63
111
  let planned = await options.ask(loop);
64
112
  console.log(describeModules(planned, { for: "command line" }));
@@ -68,6 +116,13 @@ export async function runLoop(options) {
68
116
  const why = toStartOver
69
117
  .map(({ startOver }) => `${startOver.codebases.join(", ")} was built without ${startOver.requires.join(", ")}, which it now requires: no change to its code can bring it in.`)
70
118
  .join("\n");
119
+ // No terminal and no --start-over: no one to ask, so the run stops here rather than build nothing and say it is done.
120
+ if (options.startOver !== true && process.stdin.isTTY !== true) {
121
+ const message = `${why}\nThere is no terminal to ask whether to start over: run it again with --start-over to start over without asking.`;
122
+ console.error(c.red(message));
123
+ report?.end("incomplete", message);
124
+ process.exit(1);
125
+ }
71
126
  const agreed = options.startOver === true ||
72
127
  (await askAtTerminal("Start over?", `${why}\nStarting over takes its code and conformance tests out - they stay in its history - and builds it again from the bottom of the chain.`));
73
128
  if (agreed === true) {
@@ -91,31 +146,104 @@ export async function runLoop(options) {
91
146
  console.log(`\n${describeModules(loop.status(), { for: "command line" })}`);
92
147
  const ended = registry.list();
93
148
  const failed = ended.filter((job) => job.status === "failed");
94
- console.log(`\n${failed.length === 0 ? c.green(c.bold("all jobs done")) : c.red(c.bold(`${failed.length} job${failed.length === 1 ? "" : "s"} failed`))}`);
149
+ // Complete: every codebase regenerated from the spec as it is, its conformance tests too - nothing halted, nothing
150
+ // waiting for a start over, nothing the planner left out.
151
+ const unfinished = moduleStates(loop.drift(plainFile)).filter((module) => module.state !== "complete");
152
+ const verdict = failed.length > 0
153
+ ? c.red(c.bold(`${failed.length} job${failed.length === 1 ? "" : "s"} failed`))
154
+ : unfinished.length > 0
155
+ ? c.red(c.bold(`not complete: ${unfinished.map((module) => `${module.module}${module.why !== undefined ? ` - ${module.why}` : ""}`).join("; ")}`))
156
+ : c.green(c.bold("all jobs done"));
157
+ console.log(`\n${verdict}`);
95
158
  for (const job of [...ended].reverse())
96
159
  console.log(` ${job.id} ${pulseName(job)}: ${job.status}`);
97
160
  if (ended.some((job) => job.status === "succeeded"))
98
161
  console.log(`\n${tryItCommand(plainFile)}`);
99
- process.exit(failed.length === 0 ? 0 : 1);
162
+ report?.end(failed.length > 0 ? "failed" : unfinished.length > 0 ? "incomplete" : "completed");
163
+ process.exit(failed.length === 0 && unfinished.length === 0 ? 0 : 1);
100
164
  }
101
- /** A command's arguments: the positional ones, and `--name value` / `--flag` options - `--coding-model` is `--model`. */
102
- export function parseArgs(argv, flags, usage) {
103
- const positional = [];
104
- const options = {};
105
- for (let i = 0; i < argv.length; i += 1) {
106
- const arg = argv[i];
107
- if (arg === "-h" || arg === "--help") {
108
- console.log(usage);
109
- process.exit(0);
110
- }
111
- else if (flags.includes(arg))
112
- options[arg.slice(2)] = "yes";
113
- else if (arg.startsWith("--"))
114
- options[arg === "--coding-model" ? "model" : arg.slice(2)] = argv[++i];
115
- else
116
- positional.push(arg);
165
+ /** How each codebase stands, from what is left to regenerate there. */
166
+ export function moduleStates(reports) {
167
+ return reports.map((report) => {
168
+ const state = report.running !== undefined
169
+ ? "running"
170
+ : report.startOver !== undefined
171
+ ? "start_over_needed"
172
+ : report.why !== undefined
173
+ ? "halted"
174
+ : report.code === null && (report.conformance === null || report.conformance === undefined) && report.scheduled.length === 0
175
+ ? "complete"
176
+ : "incomplete";
177
+ const why = state === "start_over_needed"
178
+ ? `built without ${report.startOver.requires.join(", ")}, which it now requires: run with --start-over`
179
+ : state === "incomplete"
180
+ ? report.code !== null && report.code !== undefined
181
+ ? "its code is not regenerated from the spec as it is"
182
+ : "its conformance tests are not written for what is built"
183
+ : report.why;
184
+ return { module: report.module, state, ...(why !== undefined ? { why } : {}) };
185
+ });
186
+ }
187
+ /**
188
+ * A --build-dir the export would wipe something in: the project, a folder holding it, or one inside plain_modules.
189
+ * Anything else is the export's own, its contents replaced as the run ends.
190
+ */
191
+ function refuseBuildDir(buildDir, projectDir) {
192
+ const within = (dir, of) => dir === of || dir.startsWith(`${of}${sep}`);
193
+ const why = within(projectDir, buildDir)
194
+ ? "it holds the project, and the export replaces what is in it"
195
+ : within(buildDir, resolve(projectDir, MODULES_DIR))
196
+ ? `it is inside ${MODULES_DIR}, which turri regenerates in`
197
+ : undefined;
198
+ if (why === undefined)
199
+ return;
200
+ console.error(`--build-dir ${buildDir} cannot be used: ${why}. Name a folder of its own - the code is exported there as the run ends.`);
201
+ process.exit(1);
202
+ }
203
+ /** The furthest-built codebase's last commit, exported into `dir` - or why nothing was. */
204
+ function exportCode(plainFile, dir) {
205
+ try {
206
+ const deployment = exportModule(plainFile, undefined, dir);
207
+ const files = listFiles(dir).length;
208
+ // Its history began, and nothing landed in it yet: only the harness's own files, which are left out.
209
+ if (files === 0)
210
+ return { exported: false, dir, why: `nothing has landed in ${deployment.moduleName} yet: its codebase holds no code` };
211
+ return { exported: true, dir, module: deployment.moduleName, commit: deployment.commit, files };
212
+ }
213
+ catch (error) {
214
+ return { exported: false, dir, why: error instanceof Error ? error.message : String(error) };
117
215
  }
118
- return { positional, options };
216
+ }
217
+ /** `conformance-tests:` as the report says it - not set is false. */
218
+ function conformanceLabel(setting) {
219
+ return setting === true ? "all" : setting === "chosen" || setting === "module" ? setting : "false";
220
+ }
221
+ /** The models of the three roles as this run uses them: the coding model given, the others as they fall back - each with the level it thinks at. */
222
+ function reportModels(project, model) {
223
+ const coding = splitModelSpec(model);
224
+ const harness = projectModels(project).harness ?? modelOnly(model);
225
+ // As the judge's agent finds it: its models: entry over judge-model over models.default, else the coding model's.
226
+ const judge = inheritModel(roleModel(project, "judge"), modelOnly(model)) ?? {};
227
+ const choice = (given, fallback) => ({
228
+ ...(given.model !== undefined ? { model: given.model } : {}),
229
+ ...((given.thinking ?? fallback) !== undefined ? { thinking: given.thinking ?? fallback } : {}),
230
+ });
231
+ return {
232
+ harness: choice(harness, ROLE_THINKING.planner),
233
+ coding: choice(coding, ROLE_THINKING.coding),
234
+ judge: choice(judge, ROLE_THINKING.judge),
235
+ };
236
+ }
237
+ /**
238
+ * The options every command that regenerates takes for a headless run: `--report` and `--build-dir`, and the config's
239
+ * own keys (configKeyOptions) - `--harness-model`, `--coding-model` (`--model`), `--judge-model`, `--review` and, where
240
+ * `conformance` is true, `--conformance-tests` - read as if config.turri.yaml had them. Exits on a value it cannot read.
241
+ */
242
+ export function headlessOptions(options, conformance) {
243
+ configKeyOptions(options, { conformance, review: true });
244
+ const report = optionValue(options, "report");
245
+ const buildDir = optionValue(options, "build-dir");
246
+ return { ...(report !== undefined ? { report } : {}), ...(buildDir !== undefined ? { buildDir } : {}) };
119
247
  }
120
248
  /** Yes or no at the terminal - or undefined when there is none, and so no one to ask. */
121
249
  async function askAtTerminal(title, body) {
@@ -73,7 +73,7 @@ export async function loopReply(loop, statuses, wait) {
73
73
  export function stopJob(jobs, named, reason) {
74
74
  const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
75
75
  if (matches.length === 0)
76
- return `No pulse ${named}. /regen shows them.`;
76
+ return `No pulse ${named}. /pulses shows them.`;
77
77
  if (matches.length > 1)
78
78
  return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
79
79
  const target = matches[0];
package/dist/pi-launch.js CHANGED
@@ -9,19 +9,28 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { dirname, join } from "node:path";
10
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { main, SettingsManager } from "@earendil-works/pi-coding-agent";
12
+ import { ProcessTerminal } from "@earendil-works/pi-tui";
12
13
  import "./terminal-images.js";
13
14
  /**
14
15
  * pi, started the way its own CLI starts it, for `turri`.
15
16
  *
16
- * The one difference: pi's startup header - its version, key hints and the list of every
17
- * loaded skill, extension and theme - is left out, because turri shows its own. pi only
18
- * takes that from the `quietStartup` setting, and a setting would quiet every `pi` on the
19
- * machine, so it is answered for this run instead. `--verbose` still shows it.
17
+ * What differs is pi's own branding, because turri shows its own: its startup header - its
18
+ * version, key hints and the list of every loaded skill, extension and theme - its update notice
19
+ * and its tab title. pi only takes the header from the `quietStartup` setting, and a setting would
20
+ * quiet every `pi` on the machine, so it is answered for this run instead. `--verbose` still shows it.
20
21
  */
21
22
  const args = process.argv.slice(2);
22
23
  if (!args.includes("--verbose")) {
23
24
  SettingsManager.prototype.getQuietStartup = () => true;
24
25
  }
26
+ // pi's "Update Available" notice is left out: the pi turri runs is its pinned dependency, which `pi update`
27
+ // does not touch - it moves when turri does.
28
+ process.env.PI_SKIP_VERSION_CHECK = "1";
29
+ // The terminal's tab title: pi writes `π - <folder>` (its APP_TITLE, a constant), turri a jellyfish and its name.
30
+ const setTitle = ProcessTerminal.prototype.setTitle;
31
+ ProcessTerminal.prototype.setTitle = function (title) {
32
+ setTitle.call(this, title.replace(/^π(?= |$)/, "🪼 turri"));
33
+ };
25
34
  // pi's own keys stand: a new line is shift+enter or ctrl+j. turri's side panel is on ctrl+h (job-panel.ts PANEL_KEY).
26
35
  // What pi's CLI does before main: the process title, pi's environment markers, the HTTP
27
36
  // dispatcher. Not exported, so loaded from the file - beside pi's entry point, wherever npm put it.
package/dist/pi.js CHANGED
@@ -6,7 +6,7 @@ import { inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, rol
6
6
  import { maskToolResults } from "./regenerate/masking.js";
7
7
  import { checkAgainstScript, ENVIRONMENT_ERROR_TOOL, EnvironmentError, environmentErrorTool, sandboxGapOf } from "./regenerate/environment-error.js";
8
8
  import { MODULES_DIR, projectRootOf, sessionsDir } from "./regenerate/paths.js";
9
- import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportUsage, } from "./regenerate/activity.js";
9
+ import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportModelEvent, reportUsage, } from "./regenerate/activity.js";
10
10
  import { debugLogOf, startDebugLog } from "./regenerate/debug-log.js";
11
11
  let thinkingSource;
12
12
  /**
@@ -262,6 +262,7 @@ async function askOnce(session, text, onDelta) {
262
262
  transcript({ kind: "prompt", text: task !== undefined ? `${task}\n\n${text}` : text });
263
263
  if (agent !== undefined)
264
264
  reportAgent({ label: agent.label, ...(agent.model !== undefined ? { model: agent.model } : {}), turns: 1 });
265
+ reportModelEvent({ kind: "turn", ...modelEventOf(session) });
265
266
  const unsubscribe = session.subscribe((event) => {
266
267
  if (event.type === "message_update") {
267
268
  const update = event.assistantMessageEvent;
@@ -332,6 +333,7 @@ async function askOnce(session, text, onDelta) {
332
333
  reportUsage(spent);
333
334
  if (agent !== undefined)
334
335
  reportAgent({ label: agent.label, usage: spent });
336
+ reportModelEvent({ kind: "call", ...modelEventOf(session), usage: spent, ms: Date.now() - stepStarted });
335
337
  }
336
338
  // A provider error counts only if the turn ends on it: a later message that ends well - pi's own retry, the
337
339
  // stream picked up again - means it recovered, and the turn's work stands.
@@ -514,6 +516,7 @@ export async function compactIfDue(session) {
514
516
  const who = agent?.label ?? "the agent";
515
517
  reportActivity(` compacting ${who}'s work: ${thousands(conversation)} tokens of it, past ${thousands(settings.atTokens)}`);
516
518
  try {
519
+ const compactStarted = Date.now();
517
520
  const result = await session.compact(settings.instructions);
518
521
  const usage = result.usage;
519
522
  if (usage !== undefined) {
@@ -521,7 +524,9 @@ export async function compactIfDue(session) {
521
524
  reportUsage(spent);
522
525
  if (agent !== undefined)
523
526
  reportAgent({ label: agent.label, usage: spent });
527
+ reportModelEvent({ kind: "call", ...modelEventOf(session), usage: spent, ms: Date.now() - compactStarted });
524
528
  }
529
+ reportModelEvent({ kind: "compaction", ...modelEventOf(session) });
525
530
  if (agent !== undefined)
526
531
  reportAgent({ label: agent.label, compactions: 1 });
527
532
  reportActivity(` compacted ${who}'s work${result.estimatedTokensAfter !== undefined ? ` to about ${thousands(result.estimatedTokensAfter)} tokens` : ""}`);
@@ -542,6 +547,13 @@ const agentRoles = new WeakMap();
542
547
  function agentOf(session) {
543
548
  return agentLabels.get(session);
544
549
  }
550
+ /** Who a model event is from: the agent's model as it runs now, its role and its label - an agent with no label too. */
551
+ function modelEventOf(session) {
552
+ const agent = agentOf(session);
553
+ const role = agentRoles.get(session);
554
+ const model = session.model !== undefined ? `${session.model.provider}/${session.model.id}${session.thinkingLevel ? `:${session.thinkingLevel}` : ""}` : agent?.model;
555
+ return { ...(model !== undefined ? { model } : {}), ...(role !== undefined ? { role } : {}), ...(agent !== undefined ? { label: agent.label } : {}) };
556
+ }
545
557
  /**
546
558
  * Have this session's turns timed, as an agent a render starts has: `limitMs` each - its role's in the config - or
547
559
  * the default.
@@ -700,7 +712,7 @@ export async function createExecutor(opts) {
700
712
  if (chosen.model !== undefined) {
701
713
  const model = findModel(session, chosen.model);
702
714
  if (model === undefined) {
703
- throw new Error(`Model '${chosen.model}' is not configured. Check the provider's API key.`);
715
+ throw new Error(`Model '${chosen.model}' is not a model pi knows: no such id for that provider. \`turri --list-models <search>\` lists the ones it does; one of your own goes in ~/.pi/agent/models.json.`);
704
716
  }
705
717
  await session.setModel(model);
706
718
  }
@@ -0,0 +1,21 @@
1
+ import { compositeTuiLine } from "@earendil-works/pi-tui";
2
+ /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
3
+ export const PICTURE_AT = "\x1b[2G";
4
+ /** The picture's escape sequence, as the banner writes it after PICTURE_AT: Kitty's, in chunks, or iTerm2's - climbing first on iTerm2. */
5
+ const PICTURE = /^(?:\x1b\[\d+A)?(?:(?:\x1b_G[^\x1b]*\x1b\\)+|\x1b\]1337;File=[^\x07]*\x07)/;
6
+ /**
7
+ * The banner's picture row with an overlay drawn over its text, the picture after it - left out while the overlay
8
+ * reaches over the picture, `pictureRight` columns from the left edge. Undefined for a row that holds no picture.
9
+ *
10
+ * What follows the picture - the padding a row narrower than the screen is given, the chat's beside the docked panel -
11
+ * is the row's text: written after the picture, from the picture's column, it would blank the row.
12
+ */
13
+ export function overPictureRow(base, overlay, col, width, total, pictureRight) {
14
+ const at = base.lastIndexOf(PICTURE_AT);
15
+ const picture = at === -1 ? null : PICTURE.exec(base.slice(at + PICTURE_AT.length));
16
+ if (picture === null)
17
+ return undefined;
18
+ const after = base.slice(at + PICTURE_AT.length + picture[0].length);
19
+ const drawn = compositeTuiLine(`${base.slice(0, at)}${after}`, overlay, col, width, total);
20
+ return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture[0]}` : drawn;
21
+ }
@@ -1,4 +1,4 @@
1
- /** Ports of the exception classes in plain2code_exceptions.py that this parser raises. */
1
+ /** Ports of the exception classes of the Python reference implementation that this parser raises. */
2
2
  export class PlainSyntaxError extends Error {
3
3
  constructor(message) {
4
4
  super(message);
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createEnvironment } from "./liquid.js";
4
- /** Ports of the file_utils.py and plain2code_utils.py helpers this parser needs. */
4
+ /** Ports of the Python reference implementation's file helpers this parser needs. */
5
5
  export function openFrom(dirs, fileName) {
6
6
  for (const dir of dirs) {
7
7
  const fullPath = join(dir, fileName);
@@ -7,7 +7,7 @@ function currentScope() {
7
7
  }
8
8
  /**
9
9
  * Leading indentation of the line the tag sits on, and whether that line is a
10
- * comment. Ported from Plain2CodeIncludeNode.render_to_output, which walks
10
+ * comment. Ported from the reference implementation's include node, which walks
11
11
  * backwards from the tag to the start of its line.
12
12
  */
13
13
  function scanLine(source, start) {
@@ -70,7 +70,7 @@ export function createEnvironment(options) {
70
70
  " 2. The directory specified by --template-dir (if provided)\n" +
71
71
  " 3. The built-in 'standard_template_library' directory\n");
72
72
  }
73
- // Plain2CodeLoaderMixin.load: rstrip, then re-indent every line to match the
73
+ // The reference implementation's loader: rstrip, then re-indent every line to match the
74
74
  // include site. The rstrip is what stops a block-level include from ending
75
75
  // the surrounding list.
76
76
  const indented = templateSource.replace(/\s+$/, "").split("\n").join("\n" + " ".repeat(whitespaces));
@@ -84,6 +84,25 @@ export function describeUsage(usage) {
84
84
  return usage.cost > 0 ? `${size} tok · $${usage.cost.toFixed(2)}` : `${size} tok`;
85
85
  }
86
86
  const store = new AsyncLocalStorage();
87
+ const modelListeners = new Set();
88
+ /** Hear of every model call, turn and compaction from now on. Returns what stops it. */
89
+ export function onModelEvent(listener) {
90
+ modelListeners.add(listener);
91
+ return () => modelListeners.delete(listener);
92
+ }
93
+ export function reportModelEvent(event) {
94
+ if (modelListeners.size === 0)
95
+ return;
96
+ const job = store.getStore()?.job?.id;
97
+ for (const listener of modelListeners) {
98
+ try {
99
+ listener(job !== undefined ? { ...event, job } : event);
100
+ }
101
+ catch {
102
+ /* a listener that fails does not take the agent down */
103
+ }
104
+ }
105
+ }
87
106
  export function withActivity(sink, run) {
88
107
  return store.run(sink, run);
89
108
  }
@@ -188,6 +188,7 @@ export function sandboxNote(fence) {
188
188
  const report = fence.environmentTool !== undefined
189
189
  ? `report it as an environment error with ${fence.environmentTool}`
190
190
  : "say so plainly - what is missing and what needs it - and end your turn";
191
+ lines.push("- A command refused a file outside your folder - `Operation not permitted`, `Permission denied`, a lock it could not", ` take - is these walls, not the code, and nothing you change gets past them: ${report} at once, naming the file`, " and the command that wanted it.");
191
192
  lines.push(fence.write.length > 0
192
193
  ? "- The project's dependencies are yours: declare them in the build manifest, and let the build tools fetch them."
193
194
  : "- You install nothing.", `${fence.write.length > 0 ? " Installing anything else is not" : " Nor is installing anything on the machine yours"}: no toolchains, system packages, drivers, global packages or \`sudo\`. If the`, ` machine lacks something the code needs, ${report}, name what is missing, and go no further with what depends on it.`, " The person decides.", "- Ports and processes: you may stop only what you started. If a port is taken by something else, use another port.", " `ps` may show nothing but what you started: check a server you started with `kill -0 $!`, its log, or a request to it.", "- Temporary files go in `$TMPDIR`: `/tmp` is outside your walls.");
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { basename, dirname, isAbsolute, sep } from "node:path";
3
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
5
  import { Type } from "typebox";
@@ -35,7 +36,7 @@ export class EnvironmentError extends Error {
35
36
  sandbox;
36
37
  constructor(details) {
37
38
  super(details.sandbox !== undefined
38
- ? `${details.moduleName} stopped: turri's sandbox kept its agents from ${details.sandbox.paths[0]}, which is on this machine.`
39
+ ? `${details.moduleName} stopped: turri's sandbox kept its agents from ${details.sandbox.access === "write" ? "writing" : "reading"} ${details.sandbox.paths[0]}, which is on this machine.`
39
40
  : `${details.moduleName} stopped: the environment lacks ${details.missing}.`);
40
41
  this.name = "EnvironmentError";
41
42
  this.moduleName = details.moduleName;
@@ -50,6 +51,10 @@ export class EnvironmentError extends Error {
50
51
  this.sandbox = details.sandbox;
51
52
  }
52
53
  }
54
+ /** A file's name that says it is a lock: `lockfile`, `x.lock`, `x.lck`, `.lock-x`, `x.lock.y`. */
55
+ const LOCK_FILE = /(?:^|[._-])(?:lock|lck|lockfile)(?:$|[._-])/i;
56
+ /** What says the sandbox refused a write, not a read: writing, creating, a lock not taken - not a name with "lock" in it. */
57
+ const WRITE_REFUSED = /\b(?:writ(?:e|es|ing|able)|creat(?:e|es|ing)|acquir(?:e|ing)|(?:take|obtain|get) (?:a |the )?lock|locking|read-only file system)\b/i;
53
58
  /** A folder named for a version - `24.16.0`, `v22.3.0`, `3.12.4` - the root of a version manager's install. */
54
59
  const VERSION = /^v?\d+\.\d+/;
55
60
  /** The parts of an install below its root, where the root ends: `…/24.16.0/lib/node_modules/…` ends before `lib`. */
@@ -74,27 +79,59 @@ export function sandboxGapOf(text, options = {}) {
74
79
  const system = (options.platform ?? process.platform) === "darwin" ? MACOS_MACHINE : LINUX_MACHINE;
75
80
  const within = (path, root) => path === root || path.startsWith(`${root}/`);
76
81
  const named = [...text.matchAll(/(?:^|[\s'"`(=:])(\/[^\s'"`()<>,;]+)/g)].map((match) => match[1].replace(/[.:]+$/, ""));
77
- const paths = [...new Set(named)].filter((path) => path.split("/").length > 3 && exists(path) && !system.some((root) => within(path, root)) && (options.project === undefined || !within(path, options.project)));
78
- return paths.length > 0 ? { paths, folder: toolchainFolder(paths[0]) } : undefined;
82
+ const isLock = (path) => LOCK_FILE.test(basename(path));
83
+ // A lock file the tool could not create is not there: its folder is.
84
+ const found = [...new Set(named)].filter((path) => path.split("/").length > 3 &&
85
+ (exists(path) || (isLock(path) && exists(dirname(path)))) &&
86
+ !system.some((root) => within(path, root)) &&
87
+ (options.project === undefined || !within(path, options.project)));
88
+ if (found.length === 0)
89
+ return undefined;
90
+ // The lock first: it is what the build could not take.
91
+ const paths = [...found.filter(isLock), ...found.filter((path) => !isLock(path))];
92
+ const lock = isLock(paths[0]);
93
+ return { paths, folder: toolchainFolder(paths[0]), access: lock || WRITE_REFUSED.test(text) ? "write" : "read", ...(lock ? { lock } : {}) };
79
94
  }
80
95
  /**
81
96
  * What the person is told, in the chat, at the end of `turri regenerate`, and in the job's log. `rerun`: the command that runs
82
97
  * the work again, as the pulse knows it - the spec the person ran; without it, the module's own spec by its name.
83
98
  */
84
99
  export function describeEnvironmentError(error, rerun) {
85
- if (error.sandbox !== undefined) {
100
+ const again = `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`;
101
+ const needed = `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`;
102
+ const gap = error.sandbox;
103
+ if (gap?.access === "write") {
104
+ const what = gap.lock === true ? `the lock file \`${gap.paths[0]}\`, which the build takes as it runs` : `\`${gap.paths[0]}\``;
105
+ const home = homedir();
106
+ const inHome = gap.paths[0] === home || gap.paths[0].startsWith(`${home}/`);
107
+ return [
108
+ `**${error.moduleName} stopped: turri's sandbox kept its agents from writing ${what} - it is on this machine; nothing is missing, and nothing needs installing.**`,
109
+ needed,
110
+ "An agent's commands write only their module's folders, their own home and temporary folder, and the package caches turri " +
111
+ "points the build tools at - no setting lets them write anywhere else, and `toolchains:` lets them read a folder, not write it.",
112
+ inHome
113
+ ? "To fix: it is in your home, not the agents' own - the tool found your home without `$HOME`, as a JVM does for `user.home`, " +
114
+ "or a setting of yours points it there. Have it use `$HOME` instead - for a JVM tool, `-Duser.home=$HOME` where the build " +
115
+ "starts its JVM - and the agents' own home takes it."
116
+ : `To fix: it is in a toolchain's own install, \`${gap.folder}\`, which the build writes into as it runs - and the agents may ` +
117
+ "not change a toolchain. Have the build keep it elsewhere if the tool has a setting for it, or install a copy of the " +
118
+ "toolchain that keeps its writes in its cache.",
119
+ again,
120
+ ].join("\n");
121
+ }
122
+ if (gap !== undefined) {
86
123
  return [
87
- `**${error.moduleName} stopped: turri's sandbox kept its agents from reading \`${error.sandbox.paths[0]}\` - it is on this machine; nothing is missing.**`,
88
- `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`,
89
- `To fix: let the agents read that toolchain - add its folder to \`toolchains:\` in config.yaml, keeping what is there: \`toolchains: [${error.sandbox.folder}]\`. Nothing needs installing.`,
90
- `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`,
124
+ `**${error.moduleName} stopped: turri's sandbox kept its agents from reading \`${gap.paths[0]}\` - it is on this machine; nothing is missing.**`,
125
+ needed,
126
+ `To fix: let the agents read that toolchain - add its folder to \`toolchains:\` in config.turri.yaml, keeping what is there: \`toolchains: [${gap.folder}]\`. Nothing needs installing.`,
127
+ again,
91
128
  ].join("\n");
92
129
  }
93
130
  return [
94
131
  `**${error.moduleName} stopped: the environment lacks ${error.missing}.**`,
95
132
  `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`,
96
133
  `To fix: ${error.fix ?? "provide it on this machine, then run it again."}`,
97
- `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`,
134
+ again,
98
135
  ].join("\n");
99
136
  }
100
137
  /** The name agents know the tool by - their wall text names it. */
@@ -89,6 +89,61 @@ function chainsFor(plainFile) {
89
89
  function ownBuilt(entry) {
90
90
  return (readRecord(entry.buildDir)?.[FUNCTIONAL_REQUIREMENTS] ?? []).length;
91
91
  }
92
+ /**
93
+ * The functional specs of a spec's whole requires chain, completed or not, in the codebase each chain delivers: its
94
+ * furthest-built module's code - which holds the code of every module below it. A functional spec is completed when that
95
+ * codebase's code record, as last committed, holds it as the spec has it now - the same text, the linked files it uses
96
+ * holding the same, no definition it uses changed, not marked to regenerate again. A record moves only when a pulse
97
+ * lands, its unit tests passing (and its conformance tests, when they are on), so completed is landed and passing.
98
+ * The modules above the delivered codebase have nothing completed. One entry per chain; planning nothing, calling no model.
99
+ */
100
+ export function functionalityCompletion(plainFile) {
101
+ return withParseCache(() => chainsFor(resolve(plainFile)).map((chain) => {
102
+ let delivered = -1;
103
+ chain.forEach((entry, index) => {
104
+ if (readRecord(entry.buildDir) !== null)
105
+ delivered = index;
106
+ });
107
+ const counts = chain.map((entry) => ({ module: entry.moduleName, specified: entry.frids.length, completed: 0, conformanceTested: 0, incomplete: [...entry.frids] }));
108
+ if (delivered === -1)
109
+ return { codebase: tipOf(chain).buildDir, modules: counts };
110
+ const codebase = chain.slice(0, delivered + 1);
111
+ const tip = tipOf(codebase);
112
+ const spec = chainSpec(codebase).tree;
113
+ const record = readRecord(tip.buildDir);
114
+ const changes = codeWork(record, spec);
115
+ // Its own: what the work loop would regenerate - new, changed, a concept it uses changed, or marked again.
116
+ const cases = casesOf(codeTree(record ?? {}), changes, { module: tip.moduleName });
117
+ const touched = new Set([...cases.add, ...cases.patch, ...cases.again].map((spec) => `${spec.module ?? tip.moduleName}:${spec.frid}`));
118
+ // The modules' below it: an item the record does not have as the spec has it.
119
+ for (const change of changes) {
120
+ if (change.section !== REQUIRED_FUNCTIONAL_SPECS || change.after === undefined)
121
+ continue;
122
+ try {
123
+ const item = JSON.parse(change.after);
124
+ if (item.module !== undefined && item.frid !== undefined)
125
+ touched.add(`${item.module}:${item.frid}`);
126
+ }
127
+ catch {
128
+ /* not an item */
129
+ }
130
+ }
131
+ const specText = (module, frid) => {
132
+ const own = module === tip.moduleName;
133
+ const items = spec[own ? FUNCTIONAL_REQUIREMENTS : REQUIRED_FUNCTIONAL_SPECS] ?? [];
134
+ return (own ? items[Number(frid) - 1] : items.find((item) => item.module === module && item.frid === frid))?.markdown.trim();
135
+ };
136
+ codebase.forEach((entry, index) => {
137
+ const count = counts[index];
138
+ const completed = entry.frids.filter((frid) => !touched.has(`${entry.moduleName}:${frid}`));
139
+ const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && item.markdown.trim() === specText(entry.moduleName, item.frid));
140
+ count.completed = completed.length;
141
+ count.conformanceTested = completed.filter((frid) => tested.some((item) => item.frid === frid)).length;
142
+ count.incomplete = entry.frids.filter((frid) => !completed.includes(frid));
143
+ });
144
+ return { codebase: tip.buildDir, modules: counts };
145
+ }));
146
+ }
92
147
  /** The modules of a chain to build: its furthest-built one, and those above it - or, none built, all of it. */
93
148
  function toBuild(chain) {
94
149
  let from = 0;
@@ -434,7 +489,7 @@ export function createWorkLoop(options) {
434
489
  title: planned.title,
435
490
  diff: shown.diff,
436
491
  ...(state.model !== undefined ? { model: state.model } : {}),
437
- lanes: [...moduleLanes(tip.plainFile, options.buildDir)],
492
+ lanes: [...moduleLanes(tip.plainFile)],
438
493
  rerun: `turri regenerate ${relative(options.cwd, tip.plainFile) || basename(tip.plainFile)}`,
439
494
  run: async (onDelta) => {
440
495
  // The guardrail: a conformance job tests only code that is built - the code record, read as the job starts, has every functional spec it tests as it tests it.
@@ -1313,7 +1368,6 @@ export async function previewJobs(options) {
1313
1368
  registry: createJobRegistry(),
1314
1369
  cwd: options.cwd,
1315
1370
  dryRun: true,
1316
- ...(options.buildDir !== undefined ? { buildDir: options.buildDir } : {}),
1317
1371
  ...(options.model !== undefined ? { modelFor: () => options.model } : {}),
1318
1372
  ...(options.plannerModel !== undefined ? { plannerModel: options.plannerModel } : {}),
1319
1373
  ...(options.steering !== undefined ? { steering: options.steering } : {}),