@turri-dev/cli 0.1.2 → 0.1.3-next.26f08c5

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.
@@ -11,7 +11,7 @@
11
11
  * message, a deploy - is offered as pi offers it.
12
12
  */
13
13
  /** What a skill says it is about, that turri covers: ***plain specs and their testing scripts. */
14
- const ABOUT_PLAIN = /\*\*\*plain|\\\*\\\*\\\*plain|\.plain\b|\bcodeplain\b|functional specs?\b|testing scripts?\b|run_unittests|run_conformance_tests/i;
14
+ const ABOUT_PLAIN = /\*\*\*plain|\\\*\\\*\\\*plain|\.plain\b|functional specs?\b|testing scripts?\b|run_unittests|run_conformance_tests/i;
15
15
  /** Whether turri has what a pi skill offers: it runs a skill of that name itself, or the skill is about ***plain. */
16
16
  export function supersededByTurri(skill, turriRuns) {
17
17
  return turriRuns.has(skill.name) || ABOUT_PLAIN.test(`${skill.name} ${skill.description}`);
@@ -0,0 +1,82 @@
1
+ import { conformanceOption, overrideConfig, reviewOption, splitModelSpec } from "./config.js";
2
+ /**
3
+ * The command line of turri's commands: their words, and the options that stand for the config's own keys - kept apart
4
+ * from the commands that use them, so a light one (doctor) reads them as a regenerate does without loading it.
5
+ */
6
+ /**
7
+ * A command's arguments: the positional ones, `--flag`s and `--name value` options (`--name=value` too) -
8
+ * `--coding-model` is `--model`. An option it does not take is refused, rather than taking the word after it, and so is an
9
+ * option with no value - the end of the words, or another option where its value should be.
10
+ */
11
+ export function parseArgs(argv, flags, usage, valued = []) {
12
+ const positional = [];
13
+ const options = {};
14
+ const takes = new Set(["--model", ...valued]);
15
+ for (let i = 0; i < argv.length; i += 1) {
16
+ const arg = argv[i];
17
+ const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
18
+ const option = name === "--coding-model" ? "--model" : name;
19
+ if (arg === "-h" || arg === "--help") {
20
+ console.log(usage);
21
+ process.exit(0);
22
+ }
23
+ else if (flags.includes(option) && inline === undefined)
24
+ options[option.slice(2)] = "yes";
25
+ else if (takes.has(option)) {
26
+ // Its value is the next word - never another option, which would be taken as the value and lost.
27
+ const value = inline ?? argv[i + 1];
28
+ if (value === undefined || (inline === undefined && value.startsWith("--"))) {
29
+ console.error(`${usage}\n\n${name} needs a value${value !== undefined ? `, not the option ${value}` : ""} - ${name}=<value> gives one that starts with --.`);
30
+ process.exit(1);
31
+ }
32
+ if (inline === undefined)
33
+ i += 1;
34
+ options[option.slice(2)] = value;
35
+ }
36
+ else if (arg.startsWith("-")) {
37
+ console.error(`${usage}\n\nNo such option: ${name}.`);
38
+ process.exit(1);
39
+ }
40
+ else
41
+ positional.push(arg);
42
+ }
43
+ return { positional, options };
44
+ }
45
+ /** An option's value, as parseArgs left it: refused when it was given with none. */
46
+ export function optionValue(options, name) {
47
+ const value = options[name];
48
+ if (name in options && (value === undefined || value.trim() === ""))
49
+ failOption(`--${name} needs a value`);
50
+ return value;
51
+ }
52
+ /**
53
+ * The options that stand for the config's own keys - `--harness-model`, `--coding-model` (`--model`), `--judge-model`
54
+ * and, as asked, `--review` and `--conformance-tests` - read as if config.turri.yaml had them, in their place in the
55
+ * order the config resolves them in (overrideConfig): a run, or doctor, with them is the same as one with them in the
56
+ * file. Exits on a value it cannot read.
57
+ */
58
+ export function configKeyOptions(options, take = {}) {
59
+ const tests = take.conformance === true ? optionValue(options, "conformance-tests") : undefined;
60
+ const setting = tests !== undefined ? conformanceOption(tests) : undefined;
61
+ if (tests !== undefined && setting === undefined)
62
+ failOption(`--conformance-tests takes all, chosen, module or false, not ${tests}`);
63
+ const reviewed = take.review === true ? optionValue(options, "review") : undefined;
64
+ const review = reviewed !== undefined ? reviewOption(reviewed) : undefined;
65
+ if (reviewed !== undefined && review === undefined)
66
+ failOption(`--review takes true or false, not ${reviewed}`);
67
+ const model = (name, key) => {
68
+ const spec = optionValue(options, name);
69
+ return spec !== undefined ? { [key]: splitModelSpec(spec) } : {};
70
+ };
71
+ overrideConfig({
72
+ ...model("harness-model", "harnessModel"),
73
+ ...model("model", "codingModel"),
74
+ ...model("judge-model", "judgeModel"),
75
+ ...(setting !== undefined ? { conformanceTests: setting } : {}),
76
+ ...(review !== undefined ? { review } : {}),
77
+ });
78
+ }
79
+ function failOption(message) {
80
+ console.error(message);
81
+ process.exit(1);
82
+ }
package/dist/config.js CHANGED
@@ -49,7 +49,7 @@ import { projectDirOf } from "./regenerate/paths.js";
49
49
  * as `coding-model`, which it was before there were two.
50
50
  *
51
51
  * Keys are written with dashes; underscores are read the same (`conformance_tests`). Keys it
52
- * does not know - a codeplain `config.yaml`, read while it is the old file, carries several - are ignored, and so is a value of
52
+ * does not know - another tool's `config.yaml`, read while it is the old file, may carry several - are ignored, and so is a value of
53
53
  * the wrong type. Everything is optional, and what is given on the command line wins.
54
54
  *
55
55
  * A project whose parts do not share a stack - a Python backend and a React frontend beside each other - keeps a
@@ -385,6 +385,9 @@ function text(value) {
385
385
  * not parse.
386
386
  */
387
387
  export function readProjectConfig(root, stack) {
388
+ return withRunOverrides(readStackedConfig(root, stack));
389
+ }
390
+ function readStackedConfig(root, stack) {
388
391
  const project = readConfigFile(join(root, projectConfigFile(root)));
389
392
  if (stack === undefined)
390
393
  return project;
@@ -403,6 +406,25 @@ export function readProjectConfig(root, stack) {
403
406
  merged.models = joinModels(project.models, own.models);
404
407
  return withoutUndefined(merged);
405
408
  }
409
+ let runOverrides = {};
410
+ /** Read a command's options as the config's keys for the rest of this process. */
411
+ export function overrideConfig(overrides) {
412
+ runOverrides = { ...overrides };
413
+ }
414
+ /** `--conformance-tests <value>`: the config's own values - all (or true), chosen, module, false. Undefined for anything else. */
415
+ export function conformanceOption(value) {
416
+ return conformanceSetting(value);
417
+ }
418
+ /** `--review <value>`: the config's own, true or false. Undefined for anything else. */
419
+ export function reviewOption(value) {
420
+ const word = value.trim().toLowerCase();
421
+ return word === "true" ? true : word === "false" ? false : undefined;
422
+ }
423
+ /** The config with the command's options in place of the keys they name. */
424
+ function withRunOverrides(config) {
425
+ const given = Object.entries(runOverrides).filter(([, value]) => value !== undefined);
426
+ return given.length === 0 ? config : { ...config, ...Object.fromEntries(given) };
427
+ }
406
428
  /** A module's `models:` over the project's, role by role - each entry taking what it leaves out from the project's; the chat's the project's alone. */
407
429
  function joinModels(project, own) {
408
430
  if (own === undefined)
@@ -1,4 +1,5 @@
1
- import { parseArgs, runLoop } from "./loop-cli.js";
1
+ import { parseArgs } from "./cli-options.js";
2
+ import { headlessOptions, runLoop } from "./loop-cli.js";
2
3
  const USAGE = `usage: turri conform <file.plain> [frid ...] [options]
3
4
 
4
5
  Writes and runs the black-box conformance tests of a module's functional specs - those named, or
@@ -8,8 +9,14 @@ module inherits, a failure fixed in the test or the code, whichever is wrong. Ev
8
9
  tested goes in one pulse, as far as the size guardrail lets.
9
10
 
10
11
  --one-by-one a pulse a functional spec instead
11
- --model <provider/id> model for the agents (default: config.turri.yaml's coding-model)`;
12
- const { positional, options } = parseArgs(process.argv.slice(2), ["--one-by-one"], USAGE);
12
+ --harness-model <p/id> config.turri.yaml's harness-model for this run: the planner, the conformance chooser, a diagnosis
13
+ --coding-model <p/id> its coding-model, --model too: the agents that build
14
+ --judge-model <p/id> its judge-model: the judge and the test review
15
+ --review <true|false> its review: whether the code and its tests are reviewed once they pass
16
+ --build-dir <dir> as the run ends, export the code there (see turri regenerate --help)
17
+ --report <file.json> write a report of the run there (see turri regenerate --help)`;
18
+ const { positional, options } = parseArgs(process.argv.slice(2), ["--one-by-one"], USAGE, ["--build-dir", "--report", "--harness-model", "--judge-model", "--review"]);
19
+ const { report, buildDir } = headlessOptions(options, false);
13
20
  const [plainFile, ...frids] = positional;
14
21
  if (plainFile === undefined) {
15
22
  console.error(USAGE);
@@ -17,10 +24,11 @@ if (plainFile === undefined) {
17
24
  }
18
25
  runLoop({
19
26
  plainFile,
20
- ...(options.model !== undefined ? { model: options.model } : {}),
27
+ ...(buildDir !== undefined ? { buildDir } : {}),
28
+ ...(report !== undefined ? { report } : {}),
29
+ command: ["conform", ...process.argv.slice(2)],
21
30
  ask: (loop) => loop.conformance(plainFile, {
22
31
  ...(frids.length > 0 ? { frids } : {}),
23
- ...(options.model !== undefined ? { model: options.model } : {}),
24
32
  ...(options["one-by-one"] !== undefined ? { separately: true } : {}),
25
33
  }),
26
34
  }).catch((error) => {
@@ -68,3 +68,22 @@ export function turriOnPath() {
68
68
  const found = which.status === 0 ? which.stdout.trim().split("\n")[0] : undefined;
69
69
  return found === "" ? undefined : found;
70
70
  }
71
+ /** turri's version, and the commit when it runs from a clone. */
72
+ export function turriIdentity() {
73
+ let version = null;
74
+ try {
75
+ version = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? null;
76
+ }
77
+ catch {
78
+ /* no package.json: left out */
79
+ }
80
+ if (!existsSync(join(PACKAGE_ROOT, ".git")))
81
+ return { version, git_commit: null, git_dirty: null };
82
+ const commit = spawnSync("git", ["-C", PACKAGE_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" });
83
+ const status = spawnSync("git", ["-C", PACKAGE_ROOT, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" });
84
+ return {
85
+ version,
86
+ git_commit: commit.status === 0 ? commit.stdout.trim() : null,
87
+ git_dirty: status.status === 0 ? status.stdout.trim() !== "" : null,
88
+ };
89
+ }
package/dist/extension.js CHANGED
@@ -52,6 +52,7 @@ import { registerStacks } from "./stacks.js";
52
52
  import { catalogueLine, discoverSkills, leftOutNote } from "./skills.js";
53
53
  import { supersededByTurri } from "./chat-skills.js";
54
54
  import { TURRI_IDENTITY } from "./identity.js";
55
+ import { turriIdentity } from "./environment.js";
55
56
  import { conformanceChooser } from "./regenerate/choose-conformance.js";
56
57
  import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
57
58
  import { createJobRegistry, defaultParallelJobs, describeJob, formatDuration, pulseName, shouldWait, STOPPED_ON_REQUEST, } from "./regenerate/jobs.js";
@@ -2158,7 +2159,8 @@ function registerBranding(pi, ruleCount, skills) {
2158
2159
  if (data === undefined)
2159
2160
  return undefined;
2160
2161
  const lines = [
2161
- `${theme.fg("accent", "turri")} ${theme.fg("muted", `v${data.version}`)}`,
2162
+ `${theme.fg("accent", "turri")} ${theme.fg("muted", `v${data.version}`)}` +
2163
+ (data.commit !== undefined ? theme.fg("dim", ` from a clone, ${data.commit}${data.changed ? " with changes" : ""}`) : ""),
2162
2164
  theme.fg("text", "regenerative software - the code is regenerated from the spec"),
2163
2165
  theme.fg("muted", `${data.rules} rules · ${data.skills} skill${data.skills === 1 ? "" : "s"}` +
2164
2166
  (data.projectSkills !== undefined && data.projectSkills > 0 ? ` (${data.projectSkills} the project's)` : "") +
@@ -2180,8 +2182,11 @@ function registerBranding(pi, ruleCount, skills) {
2180
2182
  if (ctx.hasUI)
2181
2183
  animateJellyfish(ctx);
2182
2184
  try {
2185
+ const identity = turriIdentity();
2183
2186
  pi.appendEntry(BANNER_ENTRY, {
2184
- version: packageVersion(),
2187
+ version: identity.version ?? "0.0.0",
2188
+ commit: identity.git_commit?.slice(0, 7),
2189
+ changed: identity.git_dirty === true,
2185
2190
  rules: ruleCount,
2186
2191
  skills: skills.skills.length,
2187
2192
  projectSkills: skills.skills.filter((skill) => skill.origin !== "turri").length,
@@ -2361,12 +2366,3 @@ function jellyfishArt(appearance, ink) {
2361
2366
  return undefined;
2362
2367
  }
2363
2368
  }
2364
- function packageVersion() {
2365
- try {
2366
- const raw = readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8");
2367
- return JSON.parse(raw).version ?? "0.0.0";
2368
- }
2369
- catch {
2370
- return "0.0.0";
2371
- }
2372
- }
@@ -292,7 +292,7 @@ function whose(vendor, bedrock) {
292
292
  return `${VENDORS[choice.maker].label}'s on Amazon Bedrock (${choice.region})`;
293
293
  }
294
294
  /**
295
- * What a config.yaml in `folder` says that turri reads too - codeplain's testing scripts, say - by turri's names
295
+ * What a config.yaml in `folder` says that turri reads too - another tool's testing scripts, say - by turri's names
296
296
  * (`template_dir` read as `template-dir`), in the file's order; and the keys it leaves, turri reading none of them. The
297
297
  * two models are the vendor's, so they are not taken.
298
298
  */
@@ -338,7 +338,7 @@ export function initConfig(dir, options = {}) {
338
338
  const existing = existsSync(path) && options.force !== true;
339
339
  const { harness, coding } = modelsOf(vendor, options.bedrock);
340
340
  const lines = modelLines(harness, coding);
341
- // A new file takes what a config.yaml beside it - codeplain's - says that turri reads too: its testing scripts, say.
341
+ // A new file takes what a config.yaml beside it - another tool's, say - says that turri reads too: its testing scripts, say.
342
342
  const shared = existing ? { carried: [], left: [] } : sharedWithLegacy(folder);
343
343
  const config = existing
344
344
  ? withModels(readFileSync(path, "utf8"), lines)
@@ -384,7 +384,7 @@ const USAGE = [
384
384
  "a mix of Anthropic and Google, the starting point for any provider pi supports. On amazon-bedrock, --maker and",
385
385
  "--region say whose models and where (anthropic: global, us, eu, jp, au; openai: global, us) - asked when not given.",
386
386
  "A config.turri.yaml already there keeps everything but its two models; --force writes the whole of it again. A new",
387
- "one takes from a config.yaml beside it - codeplain's - the keys turri reads too, its testing scripts say, and leaves",
387
+ "one takes from a config.yaml beside it - another tool's, say - the keys turri reads too, its testing scripts say, and leaves",
388
388
  "config.yaml as it is.",
389
389
  ].join("\n");
390
390
  /** Ask until the answer is one `read` takes. */
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) {
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
  }
@@ -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);