@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.
- package/dist/chat-skills.js +1 -1
- package/dist/cli-options.js +82 -0
- package/dist/config.js +23 -1
- package/dist/conform-cli.js +13 -5
- package/dist/environment.js +19 -0
- package/dist/extension.js +7 -11
- package/dist/init-config.js +3 -3
- package/dist/loop-cli.js +156 -28
- package/dist/pi-launch.js +13 -4
- package/dist/pi.js +14 -2
- package/dist/plain/errors.js +1 -1
- package/dist/plain/files.js +1 -1
- package/dist/plain/liquid.js +2 -2
- package/dist/regenerate/activity.js +19 -0
- package/dist/regenerate/loop.js +56 -2
- package/dist/regenerate/prompt.js +1 -1
- package/dist/regenerate/release.js +8 -6
- package/dist/regenerate-cli.js +24 -29
- package/dist/run-report.js +289 -0
- package/dist/setup.js +35 -16
- package/dist/spec-diff-cli.js +17 -6
- package/docs/config.md +22 -11
- package/docs/development.md +6 -0
- package/docs/headless.md +170 -16
- package/docs/installation.md +1 -1
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +2 -2
- package/docs/using.md +4 -2
- package/genome/skills/implement-conformance-testing-script/SKILL.md +1 -1
- package/package.json +1 -1
package/dist/plain/liquid.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
}
|
package/dist/regenerate/loop.js
CHANGED
|
@@ -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
|
|
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 } : {}),
|
|
@@ -24,7 +24,7 @@ function alreadyImplemented(moduleName, own, required) {
|
|
|
24
24
|
* one another, the current module marked - and each tagged with its module and its id there. A codebase holds the
|
|
25
25
|
* functionalities of every module it is built on, and each module numbers its own from 1, so
|
|
26
26
|
* "functionality 1" alone is ambiguous: the first listed may be base's 1 and the next top's 3.
|
|
27
|
-
* (
|
|
27
|
+
* (the reference implementation's implemented functional requirements prompt section, as tags.)
|
|
28
28
|
*/
|
|
29
29
|
export function functionalitiesByModule(entries, currentModule, status) {
|
|
30
30
|
// In the order given: the bottom of the chain first. A regeneration in a later module's
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { connect } from "node:net";
|
|
4
4
|
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { HISTORY_DIR } from "./lineage.js";
|
|
@@ -38,15 +38,17 @@ export function deployTarget(plainFile, inModule) {
|
|
|
38
38
|
throw new Error(`Nothing is built yet for ${tip.moduleName}${candidates.length > 1 ? " or the modules it requires" : ""}: there is no code to deploy. Render it first.`);
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Export the module's code at its last commit into `dist/<module
|
|
42
|
-
* harness's own files (what the code is built from, the agents' notes) left out.
|
|
41
|
+
* Export the module's code at its last commit into `dist/<module>` - or `into`, `turri regenerate --build-dir` -
|
|
42
|
+
* replacing whatever was there, the harness's own files (what the code is built from, the agents' notes) left out.
|
|
43
43
|
*/
|
|
44
|
-
export function exportModule(plainFile, inModule) {
|
|
44
|
+
export function exportModule(plainFile, inModule, into) {
|
|
45
45
|
const { entry, commit, instead } = deployTarget(plainFile, inModule);
|
|
46
46
|
const moduleDir = dirname(entry.buildDir);
|
|
47
|
-
const dir = distDirFor(projectDirOf(entry.plainFile), entry.moduleName);
|
|
48
|
-
|
|
47
|
+
const dir = into ?? distDirFor(projectDirOf(entry.plainFile), entry.moduleName);
|
|
48
|
+
// Emptied, not removed: the folder may be a mount point - a container's volume - that cannot be removed.
|
|
49
49
|
mkdirSync(dir, { recursive: true });
|
|
50
|
+
for (const entry of readdirSync(dir))
|
|
51
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
50
52
|
const exported = spawnSync("bash", ["-o", "pipefail", "-c", 'git --git-dir "$1" archive --format=tar "$2" | tar -x -f - -C "$3"', "export", join(moduleDir, HISTORY_DIR), `${commit}:code`, dir], { encoding: "utf8" });
|
|
51
53
|
if (exported.status !== 0)
|
|
52
54
|
throw new Error(`Could not export ${entry.moduleName}'s code at ${commit.slice(0, 8)}: ${(exported.stderr ?? "").trim()}`);
|
package/dist/regenerate-cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CONFIG_FILE, readProjectConfig } from "./config.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { parseArgs } from "./cli-options.js";
|
|
3
|
+
import { headlessOptions, runLoop } from "./loop-cli.js";
|
|
4
4
|
const USAGE = `usage: turri regenerate [file.plain] [frid ...] [options]
|
|
5
5
|
|
|
6
6
|
Regenerates the module's code from its spec as it is now, and everything it requires, bottom first, as
|
|
@@ -12,33 +12,27 @@ does in the chat with functionalities named: their code rewritten or started ove
|
|
|
12
12
|
|
|
13
13
|
The project's config.turri.yaml, in the current directory, gives the defaults: module (the file to
|
|
14
14
|
build when none is given), coding-model (the agents that build), harness-model (the planner), and
|
|
15
|
-
conformance-tests.
|
|
15
|
+
conformance-tests. It exits 0 only when every module is complete: regenerated from the spec as it is,
|
|
16
|
+
its conformance tests too when they are on.
|
|
16
17
|
|
|
17
18
|
--dry-run plan the work and print the prompt of the first job, and run nothing (turri glimpse)
|
|
18
|
-
--start-over start over, without asking, a codebase regenerated without a module it now requires
|
|
19
|
+
--start-over start over, without asking, a codebase regenerated without a module it now requires -
|
|
20
|
+
with no terminal to ask and without it, the run stops there
|
|
19
21
|
--from-scratch delete the module's code and conformance tests (its lineage keeps them) and regenerate all of it
|
|
20
|
-
--
|
|
21
|
-
--model <
|
|
22
|
+
--harness-model <p/id> config.turri.yaml's harness-model for this run: the planner, the conformance chooser, a diagnosis
|
|
23
|
+
--coding-model <p/id> its coding-model, --model too: the agents that build; p/id:level for how hard a model thinks
|
|
24
|
+
--judge-model <p/id> its judge-model: the judge and the test review
|
|
25
|
+
--review <true|false> its review: whether the code and its tests are reviewed once they pass
|
|
26
|
+
--conformance-tests <all|chosen|module|false>
|
|
27
|
+
its conformance-tests: which conformance tests are written and run
|
|
28
|
+
--build-dir <dir> as the run ends, however it ends, export the code there: the furthest-built codebase's
|
|
29
|
+
last commit, which holds every module it requires - the folder's contents are replaced
|
|
30
|
+
--report <file.json> write a report of the run there - outcome, usage, pulses, functional specs completed -
|
|
31
|
+
as it starts, as each pulse ends and as it exits (docs/headless.md)
|
|
22
32
|
|
|
23
33
|
PLAIN_TEMPLATE_DIRS colon-separated template directories`;
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
force: "to regenerate a functionality again, name it: turri regenerate <file.plain> <frid>",
|
|
27
|
-
only: "every module the spec requires is built, bottom first",
|
|
28
|
-
to: "the whole spec is built: stop the jobs to stop partway",
|
|
29
|
-
from: "every functionality not built is queued; built ones are skipped",
|
|
30
|
-
"conformance-root": "conformance tests are in plain_modules/<module>/tests",
|
|
31
|
-
"max-regression-rounds": "each conformance job runs the whole suite",
|
|
32
|
-
"try-it": "run turri release to try what is regenerated",
|
|
33
|
-
conformance: `set conformance-tests: in ${CONFIG_FILE}, or turri conform`,
|
|
34
|
-
"no-conformance": `set conformance-tests: false in ${CONFIG_FILE}`,
|
|
35
|
-
"template-dir": "set PLAIN_TEMPLATE_DIRS",
|
|
36
|
-
};
|
|
37
|
-
const { positional, options } = parseArgs(process.argv.slice(2), ["--only", "--force", "--conformance", "--no-conformance", "--try-it", "--dry-run", "--start-over", "--from-scratch"], USAGE);
|
|
38
|
-
for (const [name, instead] of Object.entries(GONE)) {
|
|
39
|
-
if (options[name] !== undefined)
|
|
40
|
-
console.log(c.dim(`--${name} is no longer an option: ${instead}.`));
|
|
41
|
-
}
|
|
34
|
+
const { positional, options } = parseArgs(process.argv.slice(2), ["--dry-run", "--start-over", "--from-scratch"], USAGE, ["--build-dir", "--report", "--conformance-tests", "--harness-model", "--judge-model", "--review"]);
|
|
35
|
+
const { report, buildDir } = headlessOptions(options, true);
|
|
42
36
|
const named = positional[0] ?? readProjectConfig(process.cwd()).module;
|
|
43
37
|
// Functional specs named after the file: regenerated again though their spec did not change.
|
|
44
38
|
const again = positional.slice(1);
|
|
@@ -53,15 +47,16 @@ if (options["from-scratch"] !== undefined && options["dry-run"] !== undefined) {
|
|
|
53
47
|
}
|
|
54
48
|
runLoop({
|
|
55
49
|
plainFile: named,
|
|
56
|
-
...(
|
|
57
|
-
...(
|
|
50
|
+
...(buildDir !== undefined ? { buildDir } : {}),
|
|
51
|
+
...(report !== undefined ? { report } : {}),
|
|
52
|
+
command: ["regenerate", ...process.argv.slice(2)],
|
|
58
53
|
dryRun: options["dry-run"] !== undefined,
|
|
59
54
|
startOver: options["start-over"] !== undefined,
|
|
60
55
|
ask: (loop) => again.length > 0
|
|
61
|
-
? loop.regenerateAgain(named, again
|
|
56
|
+
? loop.regenerateAgain(named, again)
|
|
62
57
|
: options["from-scratch"] !== undefined
|
|
63
|
-
? loop.fromScratch(named
|
|
64
|
-
: loop.bringUp(named
|
|
58
|
+
? loop.fromScratch(named)
|
|
59
|
+
: loop.bringUp(named),
|
|
65
60
|
}).catch((error) => {
|
|
66
61
|
console.error(error instanceof Error ? error.message : error);
|
|
67
62
|
process.exit(1);
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { turriIdentity } from "./environment.js";
|
|
4
|
+
import { onModelEvent } from "./regenerate/activity.js";
|
|
5
|
+
/**
|
|
6
|
+
* A headless run's report: one JSON file, `turri regenerate --report <file>`, saying what the run did - how it ended,
|
|
7
|
+
* what it spent, which pulses landed, how many functional specs are completed - for a benchmark or CI to read rather
|
|
8
|
+
* than the log. See docs/headless.md for every field.
|
|
9
|
+
*
|
|
10
|
+
* Written when the run starts, again as each pulse ends, and last as the process exits - however it exits: done, a
|
|
11
|
+
* pulse failed, a crash, a signal. A process killed outright (SIGKILL) leaves the one written as the last pulse ended.
|
|
12
|
+
* Each write is atomic: the file is written beside it and renamed over it, so a reader never sees half of one.
|
|
13
|
+
*/
|
|
14
|
+
export const REPORT_SCHEMA_VERSION = 1;
|
|
15
|
+
const SIGNALS = { 129: "SIGHUP", 130: "SIGINT", 143: "SIGTERM" };
|
|
16
|
+
const emptyTotals = () => ({
|
|
17
|
+
input_tokens: 0,
|
|
18
|
+
output_tokens: 0,
|
|
19
|
+
cache_read_tokens: 0,
|
|
20
|
+
cache_write_tokens: 0,
|
|
21
|
+
cost_usd: 0,
|
|
22
|
+
llm_calls: 0,
|
|
23
|
+
agent_turns: 0,
|
|
24
|
+
compactions: 0,
|
|
25
|
+
model_seconds: 0,
|
|
26
|
+
});
|
|
27
|
+
function addUsage(totals, usage) {
|
|
28
|
+
if (usage === undefined)
|
|
29
|
+
return;
|
|
30
|
+
totals.input_tokens += usage.input;
|
|
31
|
+
totals.output_tokens += usage.output;
|
|
32
|
+
totals.cache_read_tokens += usage.cacheRead;
|
|
33
|
+
totals.cache_write_tokens += usage.cacheWrite;
|
|
34
|
+
totals.cost_usd += usage.cost;
|
|
35
|
+
}
|
|
36
|
+
/** An event's counts - a call, a turn, a compaction, the model's time - and, with `tokens`, its usage too. */
|
|
37
|
+
function addEvent(totals, event, tokens) {
|
|
38
|
+
if (event.kind === "call") {
|
|
39
|
+
totals.llm_calls += 1;
|
|
40
|
+
totals.model_seconds += (event.ms ?? 0) / 1000;
|
|
41
|
+
if (tokens)
|
|
42
|
+
addUsage(totals, event.usage);
|
|
43
|
+
}
|
|
44
|
+
else if (event.kind === "turn")
|
|
45
|
+
totals.agent_turns += 1;
|
|
46
|
+
else
|
|
47
|
+
totals.compactions += 1;
|
|
48
|
+
}
|
|
49
|
+
function sumTotals(a, b) {
|
|
50
|
+
const sum = emptyTotals();
|
|
51
|
+
for (const key of Object.keys(sum))
|
|
52
|
+
sum[key] = a[key] + b[key];
|
|
53
|
+
return sum;
|
|
54
|
+
}
|
|
55
|
+
/** Tidied for reading: the cost to a millionth of a dollar, the time to a tenth of a second - float sums otherwise show their noise. */
|
|
56
|
+
function rounded(totals) {
|
|
57
|
+
return { ...totals, cost_usd: Math.round(totals.cost_usd * 1e6) / 1e6, model_seconds: Math.round(totals.model_seconds * 10) / 10 };
|
|
58
|
+
}
|
|
59
|
+
const pulseKind = (job) => (job.kind === "regenerate" ? "code" : job.kind);
|
|
60
|
+
const firstLines = (text) => {
|
|
61
|
+
const trimmed = text?.trim();
|
|
62
|
+
return trimmed === undefined || trimmed === "" ? null : trimmed.length > 1000 ? `${trimmed.slice(0, 1000)}…` : trimmed;
|
|
63
|
+
};
|
|
64
|
+
/** Write a file whole or not at all: beside it first, then renamed over it. */
|
|
65
|
+
export function writeAtomically(file, text) {
|
|
66
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
67
|
+
const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(temporary, text);
|
|
70
|
+
renameSync(temporary, file);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
rmSync(temporary, { force: true });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function startRunReport(options) {
|
|
78
|
+
const file = resolve(options.file);
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const identity = turriIdentity();
|
|
81
|
+
const events = [];
|
|
82
|
+
const stopListening = onModelEvent((event) => events.push(event));
|
|
83
|
+
let sources = { jobs: () => [] };
|
|
84
|
+
let ended;
|
|
85
|
+
let crash;
|
|
86
|
+
let exitParts;
|
|
87
|
+
let finishedAt;
|
|
88
|
+
const fromProject = (path) => relative(options.projectDir, path) || ".";
|
|
89
|
+
const attempt = (read) => {
|
|
90
|
+
if (read === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
try {
|
|
93
|
+
return read();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const usageOf = (jobs) => {
|
|
100
|
+
const ids = new Set(jobs.map((job) => job.id));
|
|
101
|
+
const kindOf = new Map(jobs.map((job) => [job.id, pulseKind(job)]));
|
|
102
|
+
// In pulses: their tokens as each job adds them up - what its pulse line prints - and their calls from the events.
|
|
103
|
+
const inPulses = emptyTotals();
|
|
104
|
+
for (const job of jobs)
|
|
105
|
+
addUsage(inPulses, job.usage);
|
|
106
|
+
const outside = emptyTotals();
|
|
107
|
+
const byModel = {};
|
|
108
|
+
const byRole = {};
|
|
109
|
+
const byKind = {};
|
|
110
|
+
for (const job of jobs)
|
|
111
|
+
addUsage((byKind[pulseKind(job)] ??= emptyTotals()), job.usage);
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
const inPulse = event.job !== undefined && ids.has(event.job);
|
|
114
|
+
addEvent(inPulse ? inPulses : outside, event, !inPulse);
|
|
115
|
+
addEvent((byModel[event.model ?? "unknown"] ??= emptyTotals()), event, true);
|
|
116
|
+
addEvent((byRole[event.role ?? "unknown"] ??= emptyTotals()), event, true);
|
|
117
|
+
if (inPulse)
|
|
118
|
+
addEvent((byKind[kindOf.get(event.job)] ??= emptyTotals()), event, false);
|
|
119
|
+
}
|
|
120
|
+
const each = (table) => Object.fromEntries(Object.entries(table).map(([key, totals]) => [key, rounded(totals)]));
|
|
121
|
+
return {
|
|
122
|
+
total: rounded(sumTotals(inPulses, outside)),
|
|
123
|
+
in_pulses: rounded(inPulses),
|
|
124
|
+
outside_pulses: rounded(outside),
|
|
125
|
+
by_model: each(byModel),
|
|
126
|
+
by_role: each(byRole),
|
|
127
|
+
by_pulse_kind: each(byKind),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
const pulsesOf = (jobs, now) => {
|
|
131
|
+
const callsOf = new Map();
|
|
132
|
+
for (const event of events)
|
|
133
|
+
if (event.job !== undefined)
|
|
134
|
+
addEvent(callsOf.get(event.job) ?? callsOf.set(event.job, emptyTotals()).get(event.job), event, false);
|
|
135
|
+
const list = jobs.map((job) => {
|
|
136
|
+
const landed = job.status === "succeeded" && job.details?.landed === true;
|
|
137
|
+
const ran = job.status !== "queued";
|
|
138
|
+
const usage = sumTotals(callsOf.get(job.id) ?? emptyTotals(), (() => {
|
|
139
|
+
const tokens = emptyTotals();
|
|
140
|
+
addUsage(tokens, job.usage);
|
|
141
|
+
return tokens;
|
|
142
|
+
})());
|
|
143
|
+
return {
|
|
144
|
+
id: job.id,
|
|
145
|
+
module: job.module ?? null,
|
|
146
|
+
number: job.pulse ?? null,
|
|
147
|
+
kind: pulseKind(job),
|
|
148
|
+
title: job.title ?? job.summary,
|
|
149
|
+
status: job.status,
|
|
150
|
+
landed,
|
|
151
|
+
failed_for_good: job.failedForGood === true,
|
|
152
|
+
started_at: ran ? new Date(job.startedAt).toISOString() : null,
|
|
153
|
+
duration_seconds: ran ? Math.round(((job.finishedAt ?? now) - job.startedAt) / 100) / 10 : null,
|
|
154
|
+
usage: rounded(usage),
|
|
155
|
+
agents: job.agents.map((agent) => ({
|
|
156
|
+
label: agent.label,
|
|
157
|
+
model: agent.model ?? null,
|
|
158
|
+
turns: agent.turns,
|
|
159
|
+
compactions: agent.compactions,
|
|
160
|
+
input_tokens: agent.usage.input,
|
|
161
|
+
output_tokens: agent.usage.output,
|
|
162
|
+
cache_read_tokens: agent.usage.cacheRead,
|
|
163
|
+
cache_write_tokens: agent.usage.cacheWrite,
|
|
164
|
+
cost_usd: Math.round(agent.usage.cost * 1e6) / 1e6,
|
|
165
|
+
})),
|
|
166
|
+
log_file: job.logFile !== undefined ? fromProject(job.logFile) : null,
|
|
167
|
+
error: job.status === "failed" || job.status === "stopped" ? firstLines(job.error) : null,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
const ran = list.filter((pulse) => pulse.status !== "queued");
|
|
171
|
+
return {
|
|
172
|
+
counts: {
|
|
173
|
+
total: ran.length,
|
|
174
|
+
landed: ran.filter((pulse) => pulse.landed).length,
|
|
175
|
+
failed: ran.filter((pulse) => pulse.status === "failed").length,
|
|
176
|
+
stopped: ran.filter((pulse) => pulse.status === "stopped").length,
|
|
177
|
+
rolled_back: ran.filter((pulse) => !pulse.landed && pulse.status !== "running").length,
|
|
178
|
+
running: ran.filter((pulse) => pulse.status === "running").length,
|
|
179
|
+
not_started: list.length - ran.length,
|
|
180
|
+
},
|
|
181
|
+
list,
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const functionalitiesOf = (counts) => {
|
|
185
|
+
if (counts === undefined)
|
|
186
|
+
return null;
|
|
187
|
+
const modules = counts.flatMap((chain) => chain.modules.map((module) => ({
|
|
188
|
+
module: module.module,
|
|
189
|
+
specified: module.specified,
|
|
190
|
+
completed: module.completed,
|
|
191
|
+
conformance_tested: module.conformanceTested,
|
|
192
|
+
incomplete_frids: module.incomplete,
|
|
193
|
+
})));
|
|
194
|
+
return {
|
|
195
|
+
total: {
|
|
196
|
+
specified: modules.reduce((sum, module) => sum + module.specified, 0),
|
|
197
|
+
completed: modules.reduce((sum, module) => sum + module.completed, 0),
|
|
198
|
+
conformance_tested: modules.reduce((sum, module) => sum + module.conformance_tested, 0),
|
|
199
|
+
},
|
|
200
|
+
modules,
|
|
201
|
+
codebases: counts.map((chain) => fromProject(chain.codebase)),
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const statusAt = (exitCode) => {
|
|
205
|
+
const signal = exitCode !== undefined ? (SIGNALS[exitCode] ?? null) : null;
|
|
206
|
+
if (crash !== undefined)
|
|
207
|
+
return { status: "crashed", signal: null, error: crash };
|
|
208
|
+
if (ended !== undefined)
|
|
209
|
+
return { status: ended.status, signal: null, error: ended.error ?? null };
|
|
210
|
+
if (exitCode === undefined)
|
|
211
|
+
return { status: "running", signal: null, error: null };
|
|
212
|
+
if (signal !== null)
|
|
213
|
+
return { status: "interrupted", signal, error: null };
|
|
214
|
+
return { status: exitCode === 0 ? "completed" : "crashed", signal: null, error: exitCode === 0 ? null : `exited with ${exitCode} before the run ended` };
|
|
215
|
+
};
|
|
216
|
+
const build = (exitCode) => {
|
|
217
|
+
const now = finishedAt ?? Date.now();
|
|
218
|
+
// In the order they started; one queued again as another never ran, and is not a pulse of its own.
|
|
219
|
+
const jobs = (attempt(sources.jobs) ?? []).filter((job) => job.replacedBy === undefined).sort((a, b) => a.sequence - b.sequence);
|
|
220
|
+
const outcome = statusAt(exitCode);
|
|
221
|
+
const projectRoot = options.projectDir;
|
|
222
|
+
return {
|
|
223
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
224
|
+
turri: identity,
|
|
225
|
+
command: options.command,
|
|
226
|
+
spec_file: resolve(options.specFile),
|
|
227
|
+
project_dir: projectRoot,
|
|
228
|
+
build_dir: options.buildDir !== undefined ? resolve(options.buildDir) : null,
|
|
229
|
+
conformance_tests: options.conformanceTests,
|
|
230
|
+
review: options.review ?? null,
|
|
231
|
+
models: {
|
|
232
|
+
...Object.fromEntries(Object.entries(options.models).map(([role, choice]) => [role, { model: choice.model ?? null, thinking: choice.thinking ?? null }])),
|
|
233
|
+
observed: Object.fromEntries([...new Set(events.map((event) => event.role ?? "unknown"))].sort().map((role) => [role, [...new Set(events.filter((event) => (event.role ?? "unknown") === role).map((event) => event.model ?? "unknown"))].sort()])),
|
|
234
|
+
},
|
|
235
|
+
started_at: new Date(startedAt).toISOString(),
|
|
236
|
+
finished_at: finishedAt !== undefined ? new Date(finishedAt).toISOString() : null,
|
|
237
|
+
duration_seconds: Math.round((now - startedAt) / 100) / 10,
|
|
238
|
+
outcome: {
|
|
239
|
+
status: outcome.status,
|
|
240
|
+
exit_code: exitCode ?? null,
|
|
241
|
+
signal: outcome.signal,
|
|
242
|
+
error: outcome.error,
|
|
243
|
+
modules: attempt(sources.modules) ?? null,
|
|
244
|
+
},
|
|
245
|
+
usage: usageOf(jobs),
|
|
246
|
+
pulses: pulsesOf(jobs, now),
|
|
247
|
+
functionalities: functionalitiesOf(attempt(sources.completion)),
|
|
248
|
+
...(exitParts ?? {}),
|
|
249
|
+
logs: {
|
|
250
|
+
jobs_dir: fromProject(join(projectRoot, ".turri", "jobs")),
|
|
251
|
+
debug_dir: fromProject(join(projectRoot, ".turri", "debug")),
|
|
252
|
+
sessions_dir: fromProject(join(projectRoot, ".turri", "sessions")),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
const write = (exitCode) => {
|
|
257
|
+
try {
|
|
258
|
+
writeAtomically(file, `${JSON.stringify(build(exitCode), null, 2)}\n`);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
console.error(`Could not write the run report ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
if (options.onExit !== false) {
|
|
265
|
+
process.on("uncaughtExceptionMonitor", (error) => {
|
|
266
|
+
crash ??= error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
267
|
+
});
|
|
268
|
+
process.once("exit", (code) => {
|
|
269
|
+
finishedAt = Date.now();
|
|
270
|
+
exitParts = attempt(sources.atExit);
|
|
271
|
+
stopListening();
|
|
272
|
+
write(code);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
write();
|
|
276
|
+
return {
|
|
277
|
+
attach(given) {
|
|
278
|
+
sources = given;
|
|
279
|
+
},
|
|
280
|
+
end(status, error) {
|
|
281
|
+
ended = { status, ...(error !== undefined ? { error } : {}) };
|
|
282
|
+
},
|
|
283
|
+
crashed(error) {
|
|
284
|
+
crash ??= error instanceof Error ? error.message : String(error);
|
|
285
|
+
},
|
|
286
|
+
write: () => write(),
|
|
287
|
+
build,
|
|
288
|
+
};
|
|
289
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { globalExtensionFile, MIN_NODE, nodeVersionOk, PACKAGE_ROOT, turriOnPath } from "./environment.js";
|
|
7
7
|
import { sandboxAvailable } from "./regenerate/os-sandbox.js";
|
|
8
8
|
import { findTestScripts } from "./regenerate/test-scripts.js";
|
|
9
|
+
import { configKeyOptions, parseArgs } from "./cli-options.js";
|
|
9
10
|
import { hasProjectConfig, readProjectConfig, stackOf } from "./config.js";
|
|
10
11
|
import { checkModels, modelsInUse, piModelAccess } from "./model-check.js";
|
|
11
12
|
import { brand } from "./brand.js";
|
|
@@ -33,6 +34,17 @@ export function checkShell(found = spawnSync("bash", ["--version"], { stdio: "ig
|
|
|
33
34
|
fix: platform === "win32" ? "install Git for Windows (Git Bash), and put its bash on the PATH" : "install bash with your system's package manager",
|
|
34
35
|
};
|
|
35
36
|
}
|
|
37
|
+
/** git: each module's history, which every pulse starts from and lands in, and the spec diffs the work is planned from. */
|
|
38
|
+
export function checkGit(found = spawnSync("git", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) {
|
|
39
|
+
if (found)
|
|
40
|
+
return { level: "ok", name: "git", detail: "on the PATH - each module's history, and the spec diffs" };
|
|
41
|
+
return {
|
|
42
|
+
level: "missing",
|
|
43
|
+
name: "git",
|
|
44
|
+
detail: "not on the PATH - each module's history is a git repository, and the work is planned from git diffs",
|
|
45
|
+
fix: "install git with your system's package manager",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
36
48
|
/** Which wall is up under the agents' commands, and - where none starts - why, and how to give it one. */
|
|
37
49
|
export function checkSandbox(wall = sandboxAvailable(), platform = process.platform) {
|
|
38
50
|
if (wall.kind === "seatbelt")
|
|
@@ -140,7 +152,7 @@ function checkGlobal() {
|
|
|
140
152
|
export async function checkProjectModels(project, access) {
|
|
141
153
|
const used = modelsInUse(readProjectConfig(project, stackOf(project)));
|
|
142
154
|
if (used.length === 0) {
|
|
143
|
-
return [{ level: "missing", name: "Models", detail: "config.turri.yaml names none", fix: "turri init-config writes the two models" }];
|
|
155
|
+
return [{ level: "missing", name: "Models", detail: "config.turri.yaml names none", fix: "turri init-config writes the two models - or name them here: --harness-model, --coding-model" }];
|
|
144
156
|
}
|
|
145
157
|
let verdicts;
|
|
146
158
|
try {
|
|
@@ -156,16 +168,17 @@ export async function checkProjectModels(project, access) {
|
|
|
156
168
|
: { level: "missing", name, detail: verdict.spec, more: [verdict.problem], fix: verdict.fix };
|
|
157
169
|
});
|
|
158
170
|
}
|
|
171
|
+
/** `modelsGiven`: models named on the command line - checked wherever doctor runs, a project or not. */
|
|
159
172
|
async function allChecks(options = {}) {
|
|
160
|
-
const checks = [checkNode(), checkShell(), checkSandbox(), await checkProvider(), checkPath(), ...checkGlobal()];
|
|
173
|
+
const checks = [checkNode(), checkShell(), checkGit(), checkSandbox(), await checkProvider(), checkPath(), ...checkGlobal()];
|
|
161
174
|
// In a project: what it needs of this machine, and whether its models answer.
|
|
162
175
|
const project = process.cwd();
|
|
163
|
-
|
|
176
|
+
const inProject = hasProjectConfig(project) || existsSync(join(project, "plain_modules"));
|
|
177
|
+
if (inProject)
|
|
164
178
|
checks.push(...checkProject(project));
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
179
|
+
if (options.models !== false && (hasProjectConfig(project) || options.modelsGiven === true)) {
|
|
180
|
+
console.log(brand.muted("Asking each model the config names for a word..."));
|
|
181
|
+
checks.push(...(await checkProjectModels(project)));
|
|
169
182
|
}
|
|
170
183
|
return checks;
|
|
171
184
|
}
|
|
@@ -224,21 +237,27 @@ function removeGlobalExtension() {
|
|
|
224
237
|
rmSync(file, { force: true });
|
|
225
238
|
console.log(` removed ${file}`);
|
|
226
239
|
}
|
|
240
|
+
const USAGE = [
|
|
241
|
+
"usage: turri doctor [--harness-model <m>] [--coding-model <m>] [--judge-model <m>] [--no-models] [--yes]",
|
|
242
|
+
"",
|
|
243
|
+
"Check everything turri needs - in a project, what its modules need of this machine too, and that",
|
|
244
|
+
"each model its config names answers with this machine's credentials (one short request a model;",
|
|
245
|
+
"--no-models skips it). The model options are read as the config's keys, as turri regenerate reads them:",
|
|
246
|
+
"a headless run's models can be checked before it starts, with no config.turri.yaml.",
|
|
247
|
+
"It changes nothing but this, asked first: a plain pi that an older turri made load turri too is put back to",
|
|
248
|
+
"plain pi. --yes answers yes.",
|
|
249
|
+
].join("\n");
|
|
227
250
|
export async function main(args) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"each model its config names answers with this machine's credentials (one short request a model;\n--no-models skips it).\n" +
|
|
231
|
-
"It changes nothing but this, asked first: a plain pi that an older turri made load turri too is put back to\n" +
|
|
232
|
-
"plain pi. --yes answers yes.");
|
|
233
|
-
return 0;
|
|
234
|
-
}
|
|
251
|
+
const { options } = parseArgs(args, ["--no-models", "--yes", "-y"], USAGE, ["--harness-model", "--judge-model"]);
|
|
252
|
+
configKeyOptions(options);
|
|
235
253
|
console.log(brand.bold(brand.accent("Checking what turri needs.")));
|
|
236
254
|
if (!nodeVersionOk())
|
|
237
255
|
return report([checkNode()]);
|
|
238
|
-
const
|
|
256
|
+
const modelsGiven = ["harness-model", "model", "judge-model"].some((name) => options[name] !== undefined);
|
|
257
|
+
const code = report(await allChecks({ models: options["no-models"] === undefined, modelsGiven }));
|
|
239
258
|
// turri and pi are kept apart: a plain pi does not start turri. An older turri's file that made it do so goes, asked.
|
|
240
259
|
const global = globalExtensionFile();
|
|
241
|
-
if (global !== undefined && (await confirm(`\nA plain \`pi\` loads turri too, through ${global}. Remove it, so pi stays pi?`, true,
|
|
260
|
+
if (global !== undefined && (await confirm(`\nA plain \`pi\` loads turri too, through ${global}. Remove it, so pi stays pi?`, true, options.yes !== undefined || args.includes("-y")))) {
|
|
242
261
|
removeGlobalExtension();
|
|
243
262
|
}
|
|
244
263
|
return code;
|