@turri-dev/cli 0.1.2-next.48dacfc → 0.1.3-next.17f0f7f
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/config.js +23 -1
- package/dist/conform-cli.js +12 -5
- package/dist/init-config.js +3 -3
- package/dist/loop-cli.js +212 -17
- 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 +23 -29
- package/dist/run-report.js +309 -0
- package/dist/setup.js +12 -1
- package/dist/spec-diff-cli.js +16 -6
- package/docs/config.md +22 -11
- package/docs/headless.md +161 -15
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +2 -2
- package/genome/skills/implement-conformance-testing-script/SKILL.md +1 -1
- package/package.json +1 -1
|
@@ -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,5 @@
|
|
|
1
1
|
import { CONFIG_FILE, readProjectConfig } from "./config.js";
|
|
2
|
-
import {
|
|
3
|
-
import { parseArgs, runLoop } from "./loop-cli.js";
|
|
2
|
+
import { headlessOptions, parseArgs, runLoop } from "./loop-cli.js";
|
|
4
3
|
const USAGE = `usage: turri regenerate [file.plain] [frid ...] [options]
|
|
5
4
|
|
|
6
5
|
Regenerates the module's code from its spec as it is now, and everything it requires, bottom first, as
|
|
@@ -12,33 +11,27 @@ does in the chat with functionalities named: their code rewritten or started ove
|
|
|
12
11
|
|
|
13
12
|
The project's config.turri.yaml, in the current directory, gives the defaults: module (the file to
|
|
14
13
|
build when none is given), coding-model (the agents that build), harness-model (the planner), and
|
|
15
|
-
conformance-tests.
|
|
14
|
+
conformance-tests. It exits 0 only when every module is complete: regenerated from the spec as it is,
|
|
15
|
+
its conformance tests too when they are on.
|
|
16
16
|
|
|
17
17
|
--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
|
|
18
|
+
--start-over start over, without asking, a codebase regenerated without a module it now requires -
|
|
19
|
+
with no terminal to ask and without it, the run stops there
|
|
19
20
|
--from-scratch delete the module's code and conformance tests (its lineage keeps them) and regenerate all of it
|
|
20
|
-
--
|
|
21
|
-
--model <
|
|
21
|
+
--harness-model <p/id> config.turri.yaml's harness-model for this run: the planner, the conformance chooser, a diagnosis
|
|
22
|
+
--coding-model <p/id> its coding-model, --model too: the agents that build; p/id:level for how hard a model thinks
|
|
23
|
+
--judge-model <p/id> its judge-model: the judge and the test review
|
|
24
|
+
--review <true|false> its review: whether the code and its tests are reviewed once they pass
|
|
25
|
+
--conformance-tests <all|chosen|module|false>
|
|
26
|
+
its conformance-tests: which conformance tests are written and run
|
|
27
|
+
--build-dir <dir> as the run ends, however it ends, export the code there: the furthest-built codebase's
|
|
28
|
+
last commit, which holds every module it requires - the folder's contents are replaced
|
|
29
|
+
--report <file.json> write a report of the run there - outcome, usage, pulses, functional specs completed -
|
|
30
|
+
as it starts, as each pulse ends and as it exits (docs/headless.md)
|
|
22
31
|
|
|
23
32
|
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
|
-
}
|
|
33
|
+
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"]);
|
|
34
|
+
const { report, buildDir } = headlessOptions(options, true);
|
|
42
35
|
const named = positional[0] ?? readProjectConfig(process.cwd()).module;
|
|
43
36
|
// Functional specs named after the file: regenerated again though their spec did not change.
|
|
44
37
|
const again = positional.slice(1);
|
|
@@ -53,15 +46,16 @@ if (options["from-scratch"] !== undefined && options["dry-run"] !== undefined) {
|
|
|
53
46
|
}
|
|
54
47
|
runLoop({
|
|
55
48
|
plainFile: named,
|
|
56
|
-
...(
|
|
57
|
-
...(
|
|
49
|
+
...(buildDir !== undefined ? { buildDir } : {}),
|
|
50
|
+
...(report !== undefined ? { report } : {}),
|
|
51
|
+
command: ["regenerate", ...process.argv.slice(2)],
|
|
58
52
|
dryRun: options["dry-run"] !== undefined,
|
|
59
53
|
startOver: options["start-over"] !== undefined,
|
|
60
54
|
ask: (loop) => again.length > 0
|
|
61
|
-
? loop.regenerateAgain(named, again
|
|
55
|
+
? loop.regenerateAgain(named, again)
|
|
62
56
|
: options["from-scratch"] !== undefined
|
|
63
|
-
? loop.fromScratch(named
|
|
64
|
-
: loop.bringUp(named
|
|
57
|
+
? loop.fromScratch(named)
|
|
58
|
+
: loop.bringUp(named),
|
|
65
59
|
}).catch((error) => {
|
|
66
60
|
console.error(error instanceof Error ? error.message : error);
|
|
67
61
|
process.exit(1);
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { PACKAGE_ROOT } from "./environment.js";
|
|
5
|
+
import { onModelEvent } from "./regenerate/activity.js";
|
|
6
|
+
/**
|
|
7
|
+
* A headless run's report: one JSON file, `turri regenerate --report <file>`, saying what the run did - how it ended,
|
|
8
|
+
* what it spent, which pulses landed, how many functional specs are completed - for a benchmark or CI to read rather
|
|
9
|
+
* than the log. See docs/headless.md for every field.
|
|
10
|
+
*
|
|
11
|
+
* Written when the run starts, again as each pulse ends, and last as the process exits - however it exits: done, a
|
|
12
|
+
* pulse failed, a crash, a signal. A process killed outright (SIGKILL) leaves the one written as the last pulse ended.
|
|
13
|
+
* Each write is atomic: the file is written beside it and renamed over it, so a reader never sees half of one.
|
|
14
|
+
*/
|
|
15
|
+
export const REPORT_SCHEMA_VERSION = 1;
|
|
16
|
+
const SIGNALS = { 129: "SIGHUP", 130: "SIGINT", 143: "SIGTERM" };
|
|
17
|
+
const emptyTotals = () => ({
|
|
18
|
+
input_tokens: 0,
|
|
19
|
+
output_tokens: 0,
|
|
20
|
+
cache_read_tokens: 0,
|
|
21
|
+
cache_write_tokens: 0,
|
|
22
|
+
cost_usd: 0,
|
|
23
|
+
llm_calls: 0,
|
|
24
|
+
agent_turns: 0,
|
|
25
|
+
compactions: 0,
|
|
26
|
+
model_seconds: 0,
|
|
27
|
+
});
|
|
28
|
+
function addUsage(totals, usage) {
|
|
29
|
+
if (usage === undefined)
|
|
30
|
+
return;
|
|
31
|
+
totals.input_tokens += usage.input;
|
|
32
|
+
totals.output_tokens += usage.output;
|
|
33
|
+
totals.cache_read_tokens += usage.cacheRead;
|
|
34
|
+
totals.cache_write_tokens += usage.cacheWrite;
|
|
35
|
+
totals.cost_usd += usage.cost;
|
|
36
|
+
}
|
|
37
|
+
/** An event's counts - a call, a turn, a compaction, the model's time - and, with `tokens`, its usage too. */
|
|
38
|
+
function addEvent(totals, event, tokens) {
|
|
39
|
+
if (event.kind === "call") {
|
|
40
|
+
totals.llm_calls += 1;
|
|
41
|
+
totals.model_seconds += (event.ms ?? 0) / 1000;
|
|
42
|
+
if (tokens)
|
|
43
|
+
addUsage(totals, event.usage);
|
|
44
|
+
}
|
|
45
|
+
else if (event.kind === "turn")
|
|
46
|
+
totals.agent_turns += 1;
|
|
47
|
+
else
|
|
48
|
+
totals.compactions += 1;
|
|
49
|
+
}
|
|
50
|
+
function sumTotals(a, b) {
|
|
51
|
+
const sum = emptyTotals();
|
|
52
|
+
for (const key of Object.keys(sum))
|
|
53
|
+
sum[key] = a[key] + b[key];
|
|
54
|
+
return sum;
|
|
55
|
+
}
|
|
56
|
+
/** 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. */
|
|
57
|
+
function rounded(totals) {
|
|
58
|
+
return { ...totals, cost_usd: Math.round(totals.cost_usd * 1e6) / 1e6, model_seconds: Math.round(totals.model_seconds * 10) / 10 };
|
|
59
|
+
}
|
|
60
|
+
/** turri's version, and the commit when it runs from a clone. */
|
|
61
|
+
function turriIdentity() {
|
|
62
|
+
let version = null;
|
|
63
|
+
try {
|
|
64
|
+
version = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* no package.json: left out */
|
|
68
|
+
}
|
|
69
|
+
if (!existsSync(join(PACKAGE_ROOT, ".git")))
|
|
70
|
+
return { version, git_commit: null, git_dirty: null };
|
|
71
|
+
const commit = spawnSync("git", ["-C", PACKAGE_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
72
|
+
const status = spawnSync("git", ["-C", PACKAGE_ROOT, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" });
|
|
73
|
+
return {
|
|
74
|
+
version,
|
|
75
|
+
git_commit: commit.status === 0 ? commit.stdout.trim() : null,
|
|
76
|
+
git_dirty: status.status === 0 ? status.stdout.trim() !== "" : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const pulseKind = (job) => (job.kind === "regenerate" ? "code" : job.kind);
|
|
80
|
+
const firstLines = (text) => {
|
|
81
|
+
const trimmed = text?.trim();
|
|
82
|
+
return trimmed === undefined || trimmed === "" ? null : trimmed.length > 1000 ? `${trimmed.slice(0, 1000)}…` : trimmed;
|
|
83
|
+
};
|
|
84
|
+
/** Write a file whole or not at all: beside it first, then renamed over it. */
|
|
85
|
+
export function writeAtomically(file, text) {
|
|
86
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
87
|
+
const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(temporary, text);
|
|
90
|
+
renameSync(temporary, file);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
rmSync(temporary, { force: true });
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function startRunReport(options) {
|
|
98
|
+
const file = resolve(options.file);
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
const identity = turriIdentity();
|
|
101
|
+
const events = [];
|
|
102
|
+
const stopListening = onModelEvent((event) => events.push(event));
|
|
103
|
+
let sources = { jobs: () => [] };
|
|
104
|
+
let ended;
|
|
105
|
+
let crash;
|
|
106
|
+
let exitParts;
|
|
107
|
+
let finishedAt;
|
|
108
|
+
const fromProject = (path) => relative(options.projectDir, path) || ".";
|
|
109
|
+
const attempt = (read) => {
|
|
110
|
+
if (read === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
try {
|
|
113
|
+
return read();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const usageOf = (jobs) => {
|
|
120
|
+
const ids = new Set(jobs.map((job) => job.id));
|
|
121
|
+
const kindOf = new Map(jobs.map((job) => [job.id, pulseKind(job)]));
|
|
122
|
+
// In pulses: their tokens as each job adds them up - what its pulse line prints - and their calls from the events.
|
|
123
|
+
const inPulses = emptyTotals();
|
|
124
|
+
for (const job of jobs)
|
|
125
|
+
addUsage(inPulses, job.usage);
|
|
126
|
+
const outside = emptyTotals();
|
|
127
|
+
const byModel = {};
|
|
128
|
+
const byRole = {};
|
|
129
|
+
const byKind = {};
|
|
130
|
+
for (const job of jobs)
|
|
131
|
+
addUsage((byKind[pulseKind(job)] ??= emptyTotals()), job.usage);
|
|
132
|
+
for (const event of events) {
|
|
133
|
+
const inPulse = event.job !== undefined && ids.has(event.job);
|
|
134
|
+
addEvent(inPulse ? inPulses : outside, event, !inPulse);
|
|
135
|
+
addEvent((byModel[event.model ?? "unknown"] ??= emptyTotals()), event, true);
|
|
136
|
+
addEvent((byRole[event.role ?? "unknown"] ??= emptyTotals()), event, true);
|
|
137
|
+
if (inPulse)
|
|
138
|
+
addEvent((byKind[kindOf.get(event.job)] ??= emptyTotals()), event, false);
|
|
139
|
+
}
|
|
140
|
+
const each = (table) => Object.fromEntries(Object.entries(table).map(([key, totals]) => [key, rounded(totals)]));
|
|
141
|
+
return {
|
|
142
|
+
total: rounded(sumTotals(inPulses, outside)),
|
|
143
|
+
in_pulses: rounded(inPulses),
|
|
144
|
+
outside_pulses: rounded(outside),
|
|
145
|
+
by_model: each(byModel),
|
|
146
|
+
by_role: each(byRole),
|
|
147
|
+
by_pulse_kind: each(byKind),
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
const pulsesOf = (jobs, now) => {
|
|
151
|
+
const callsOf = new Map();
|
|
152
|
+
for (const event of events)
|
|
153
|
+
if (event.job !== undefined)
|
|
154
|
+
addEvent(callsOf.get(event.job) ?? callsOf.set(event.job, emptyTotals()).get(event.job), event, false);
|
|
155
|
+
const list = jobs.map((job) => {
|
|
156
|
+
const landed = job.status === "succeeded" && job.details?.landed === true;
|
|
157
|
+
const ran = job.status !== "queued";
|
|
158
|
+
const usage = sumTotals(callsOf.get(job.id) ?? emptyTotals(), (() => {
|
|
159
|
+
const tokens = emptyTotals();
|
|
160
|
+
addUsage(tokens, job.usage);
|
|
161
|
+
return tokens;
|
|
162
|
+
})());
|
|
163
|
+
return {
|
|
164
|
+
id: job.id,
|
|
165
|
+
module: job.module ?? null,
|
|
166
|
+
number: job.pulse ?? null,
|
|
167
|
+
kind: pulseKind(job),
|
|
168
|
+
title: job.title ?? job.summary,
|
|
169
|
+
status: job.status,
|
|
170
|
+
landed,
|
|
171
|
+
failed_for_good: job.failedForGood === true,
|
|
172
|
+
started_at: ran ? new Date(job.startedAt).toISOString() : null,
|
|
173
|
+
duration_seconds: ran ? Math.round(((job.finishedAt ?? now) - job.startedAt) / 100) / 10 : null,
|
|
174
|
+
usage: rounded(usage),
|
|
175
|
+
agents: job.agents.map((agent) => ({
|
|
176
|
+
label: agent.label,
|
|
177
|
+
model: agent.model ?? null,
|
|
178
|
+
turns: agent.turns,
|
|
179
|
+
compactions: agent.compactions,
|
|
180
|
+
input_tokens: agent.usage.input,
|
|
181
|
+
output_tokens: agent.usage.output,
|
|
182
|
+
cache_read_tokens: agent.usage.cacheRead,
|
|
183
|
+
cache_write_tokens: agent.usage.cacheWrite,
|
|
184
|
+
cost_usd: Math.round(agent.usage.cost * 1e6) / 1e6,
|
|
185
|
+
})),
|
|
186
|
+
log_file: job.logFile !== undefined ? fromProject(job.logFile) : null,
|
|
187
|
+
error: job.status === "failed" || job.status === "stopped" ? firstLines(job.error) : null,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
const ran = list.filter((pulse) => pulse.status !== "queued");
|
|
191
|
+
return {
|
|
192
|
+
counts: {
|
|
193
|
+
total: ran.length,
|
|
194
|
+
landed: ran.filter((pulse) => pulse.landed).length,
|
|
195
|
+
failed: ran.filter((pulse) => pulse.status === "failed").length,
|
|
196
|
+
stopped: ran.filter((pulse) => pulse.status === "stopped").length,
|
|
197
|
+
rolled_back: ran.filter((pulse) => !pulse.landed && pulse.status !== "running").length,
|
|
198
|
+
running: ran.filter((pulse) => pulse.status === "running").length,
|
|
199
|
+
not_started: list.length - ran.length,
|
|
200
|
+
},
|
|
201
|
+
list,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const functionalitiesOf = (counts) => {
|
|
205
|
+
if (counts === undefined)
|
|
206
|
+
return null;
|
|
207
|
+
const modules = counts.flatMap((chain) => chain.modules.map((module) => ({
|
|
208
|
+
module: module.module,
|
|
209
|
+
specified: module.specified,
|
|
210
|
+
completed: module.completed,
|
|
211
|
+
conformance_tested: module.conformanceTested,
|
|
212
|
+
incomplete_frids: module.incomplete,
|
|
213
|
+
})));
|
|
214
|
+
return {
|
|
215
|
+
total: {
|
|
216
|
+
specified: modules.reduce((sum, module) => sum + module.specified, 0),
|
|
217
|
+
completed: modules.reduce((sum, module) => sum + module.completed, 0),
|
|
218
|
+
conformance_tested: modules.reduce((sum, module) => sum + module.conformance_tested, 0),
|
|
219
|
+
},
|
|
220
|
+
modules,
|
|
221
|
+
codebases: counts.map((chain) => fromProject(chain.codebase)),
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
const statusAt = (exitCode) => {
|
|
225
|
+
const signal = exitCode !== undefined ? (SIGNALS[exitCode] ?? null) : null;
|
|
226
|
+
if (crash !== undefined)
|
|
227
|
+
return { status: "crashed", signal: null, error: crash };
|
|
228
|
+
if (ended !== undefined)
|
|
229
|
+
return { status: ended.status, signal: null, error: ended.error ?? null };
|
|
230
|
+
if (exitCode === undefined)
|
|
231
|
+
return { status: "running", signal: null, error: null };
|
|
232
|
+
if (signal !== null)
|
|
233
|
+
return { status: "interrupted", signal, error: null };
|
|
234
|
+
return { status: exitCode === 0 ? "completed" : "crashed", signal: null, error: exitCode === 0 ? null : `exited with ${exitCode} before the run ended` };
|
|
235
|
+
};
|
|
236
|
+
const build = (exitCode) => {
|
|
237
|
+
const now = finishedAt ?? Date.now();
|
|
238
|
+
// In the order they started; one queued again as another never ran, and is not a pulse of its own.
|
|
239
|
+
const jobs = (attempt(sources.jobs) ?? []).filter((job) => job.replacedBy === undefined).sort((a, b) => a.sequence - b.sequence);
|
|
240
|
+
const outcome = statusAt(exitCode);
|
|
241
|
+
const projectRoot = options.projectDir;
|
|
242
|
+
return {
|
|
243
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
244
|
+
turri: identity,
|
|
245
|
+
command: options.command,
|
|
246
|
+
spec_file: resolve(options.specFile),
|
|
247
|
+
project_dir: projectRoot,
|
|
248
|
+
build_dir: options.buildDir !== undefined ? resolve(options.buildDir) : null,
|
|
249
|
+
conformance_tests: options.conformanceTests,
|
|
250
|
+
review: options.review ?? null,
|
|
251
|
+
models: {
|
|
252
|
+
...Object.fromEntries(Object.entries(options.models).map(([role, choice]) => [role, { model: choice.model ?? null, thinking: choice.thinking ?? null }])),
|
|
253
|
+
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()])),
|
|
254
|
+
},
|
|
255
|
+
started_at: new Date(startedAt).toISOString(),
|
|
256
|
+
finished_at: finishedAt !== undefined ? new Date(finishedAt).toISOString() : null,
|
|
257
|
+
duration_seconds: Math.round((now - startedAt) / 100) / 10,
|
|
258
|
+
outcome: {
|
|
259
|
+
status: outcome.status,
|
|
260
|
+
exit_code: exitCode ?? null,
|
|
261
|
+
signal: outcome.signal,
|
|
262
|
+
error: outcome.error,
|
|
263
|
+
modules: attempt(sources.modules) ?? null,
|
|
264
|
+
},
|
|
265
|
+
usage: usageOf(jobs),
|
|
266
|
+
pulses: pulsesOf(jobs, now),
|
|
267
|
+
functionalities: functionalitiesOf(attempt(sources.completion)),
|
|
268
|
+
...(exitParts ?? {}),
|
|
269
|
+
logs: {
|
|
270
|
+
jobs_dir: fromProject(join(projectRoot, ".turri", "jobs")),
|
|
271
|
+
debug_dir: fromProject(join(projectRoot, ".turri", "debug")),
|
|
272
|
+
sessions_dir: fromProject(join(projectRoot, ".turri", "sessions")),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
const write = (exitCode) => {
|
|
277
|
+
try {
|
|
278
|
+
writeAtomically(file, `${JSON.stringify(build(exitCode), null, 2)}\n`);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
console.error(`Could not write the run report ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
if (options.onExit !== false) {
|
|
285
|
+
process.on("uncaughtExceptionMonitor", (error) => {
|
|
286
|
+
crash ??= error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
287
|
+
});
|
|
288
|
+
process.once("exit", (code) => {
|
|
289
|
+
finishedAt = Date.now();
|
|
290
|
+
exitParts = attempt(sources.atExit);
|
|
291
|
+
stopListening();
|
|
292
|
+
write(code);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
write();
|
|
296
|
+
return {
|
|
297
|
+
attach(given) {
|
|
298
|
+
sources = given;
|
|
299
|
+
},
|
|
300
|
+
end(status, error) {
|
|
301
|
+
ended = { status, ...(error !== undefined ? { error } : {}) };
|
|
302
|
+
},
|
|
303
|
+
crashed(error) {
|
|
304
|
+
crash ??= error instanceof Error ? error.message : String(error);
|
|
305
|
+
},
|
|
306
|
+
write: () => write(),
|
|
307
|
+
build,
|
|
308
|
+
};
|
|
309
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -33,6 +33,17 @@ export function checkShell(found = spawnSync("bash", ["--version"], { stdio: "ig
|
|
|
33
33
|
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
34
|
};
|
|
35
35
|
}
|
|
36
|
+
/** git: each module's history, which every pulse starts from and lands in, and the spec diffs the work is planned from. */
|
|
37
|
+
export function checkGit(found = spawnSync("git", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) {
|
|
38
|
+
if (found)
|
|
39
|
+
return { level: "ok", name: "git", detail: "on the PATH - each module's history, and the spec diffs" };
|
|
40
|
+
return {
|
|
41
|
+
level: "missing",
|
|
42
|
+
name: "git",
|
|
43
|
+
detail: "not on the PATH - each module's history is a git repository, and the work is planned from git diffs",
|
|
44
|
+
fix: "install git with your system's package manager",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
36
47
|
/** Which wall is up under the agents' commands, and - where none starts - why, and how to give it one. */
|
|
37
48
|
export function checkSandbox(wall = sandboxAvailable(), platform = process.platform) {
|
|
38
49
|
if (wall.kind === "seatbelt")
|
|
@@ -157,7 +168,7 @@ export async function checkProjectModels(project, access) {
|
|
|
157
168
|
});
|
|
158
169
|
}
|
|
159
170
|
async function allChecks(options = {}) {
|
|
160
|
-
const checks = [checkNode(), checkShell(), checkSandbox(), await checkProvider(), checkPath(), ...checkGlobal()];
|
|
171
|
+
const checks = [checkNode(), checkShell(), checkGit(), checkSandbox(), await checkProvider(), checkPath(), ...checkGlobal()];
|
|
161
172
|
// In a project: what it needs of this machine, and whether its models answer.
|
|
162
173
|
const project = process.cwd();
|
|
163
174
|
if (hasProjectConfig(project) || existsSync(join(project, "plain_modules"))) {
|
package/dist/spec-diff-cli.js
CHANGED
|
@@ -2,16 +2,24 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { checkPlainFile } from "./plain/check.js";
|
|
4
4
|
import { applySpecDiff, revertSpecDiff } from "./regenerate/regenerate.js";
|
|
5
|
-
import { parseArgs, runLoop } from "./loop-cli.js";
|
|
5
|
+
import { headlessOptions, parseArgs, runLoop } from "./loop-cli.js";
|
|
6
6
|
const USAGE = `usage: turri spec-diff <file.plain> <diff-file> [options]
|
|
7
7
|
|
|
8
8
|
Applies a change to the specs, given as a git diff of the .plain files, and builds it: the headless
|
|
9
9
|
counterpart of regenerate, given a diff. The diff is applied first, unless it is already in; then, as
|
|
10
10
|
turri regenerate does, what is left to build is planned into jobs and built.
|
|
11
11
|
|
|
12
|
-
--model <
|
|
13
|
-
--
|
|
14
|
-
|
|
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
|
+
--start-over start over, without asking, a codebase regenerated without a module it now requires
|
|
15
|
+
--judge-model <p/id> its judge-model: the judge and the test review
|
|
16
|
+
--review <true|false> its review: whether the code and its tests are reviewed once they pass
|
|
17
|
+
--conformance-tests <all|chosen|module|false>
|
|
18
|
+
its conformance-tests: which conformance tests are written and run
|
|
19
|
+
--build-dir <dir> as the run ends, export the code there (see turri regenerate --help)
|
|
20
|
+
--report <file.json> write a report of the run there (see turri regenerate --help)`;
|
|
21
|
+
const { positional, options } = parseArgs(process.argv.slice(2), ["--start-over"], USAGE, ["--build-dir", "--report", "--conformance-tests", "--harness-model", "--judge-model", "--review"]);
|
|
22
|
+
const { report, buildDir } = headlessOptions(options, true);
|
|
15
23
|
const [plainFile, diffFile] = positional;
|
|
16
24
|
if (plainFile === undefined || diffFile === undefined) {
|
|
17
25
|
console.error(USAGE);
|
|
@@ -28,9 +36,11 @@ if (!check.ok) {
|
|
|
28
36
|
}
|
|
29
37
|
runLoop({
|
|
30
38
|
plainFile,
|
|
31
|
-
...(options.model !== undefined ? { model: options.model } : {}),
|
|
32
39
|
startOver: options["start-over"] !== undefined,
|
|
33
|
-
|
|
40
|
+
...(buildDir !== undefined ? { buildDir } : {}),
|
|
41
|
+
...(report !== undefined ? { report } : {}),
|
|
42
|
+
command: ["spec-diff", ...process.argv.slice(2)],
|
|
43
|
+
ask: (loop) => loop.bringUp(plainFile),
|
|
34
44
|
}).catch((error) => {
|
|
35
45
|
console.error(error instanceof Error ? error.message : error);
|
|
36
46
|
process.exit(1);
|
package/docs/config.md
CHANGED
|
@@ -5,7 +5,7 @@ is optional: with no file at all, turri finds the testing scripts on its own, tu
|
|
|
5
5
|
tests on wherever a module has test reqs, and runs on pi's configured model. A project whose
|
|
6
6
|
parts are on different stacks - a backend and a frontend - adds a file per stack,
|
|
7
7
|
`config.turri.<stack>.yaml`, beside it, and chooses the stack to build with (see
|
|
8
|
-
[stacks](#stacks)). turri never writes a `config.yaml`: it may be another tool's
|
|
8
|
+
[stacks](#stacks)). turri never writes a `config.yaml`: it may be another tool's, and
|
|
9
9
|
is read only by a project that has no file of turri's yet (see [the old config.yaml](#the-old-configyaml)).
|
|
10
10
|
|
|
11
11
|
`turri init-config` starts one: the two models, a strong one to talk to and a smaller one to write the code. It
|
|
@@ -42,7 +42,7 @@ change one by changing it. The optional ones, which have no value of their own -
|
|
|
42
42
|
skill that writes them), `module`, `conformance-budget`, `judge-model`, `models` - are there commented out, each with an
|
|
43
43
|
example: uncomment one to use it. A key taken out of the file is still read as the table below says.
|
|
44
44
|
|
|
45
|
-
Beside a `config.yaml` -
|
|
45
|
+
Beside a `config.yaml` - another tool's, say - the new file takes from it the keys turri reads too, each in its place, its
|
|
46
46
|
testing scripts above all, and says which it left (see [the old config.yaml](#the-old-configyaml)); `config.yaml`
|
|
47
47
|
is left as it is. A `config.turri.yaml` already there keeps everything but its two models, which it sets - `--force` writes the whole
|
|
48
48
|
file again - and a `config.yaml` turri read until then is copied to it first. It takes a folder other than the current
|
|
@@ -133,7 +133,7 @@ models: # each agent role's model, thinking and tur
|
|
|
133
133
|
| `release-after-pulse` | `true` or `false`: whether every pulse that lands code releases it, in the chat, as its last stage - before the module's next pulse starts, whatever the chat is doing: an agent on the coding model looks for the project's release guidance as the chat does (what was passed to `release` this conversation, AGENTS.md, the project's skills) and, when it says to release after a build, does what it says - see [deploy](deploy.md#after-every-pulse). One release a pulse that lands; a release that fails is said in the pulse's report, and what it landed stays | `false` |
|
|
134
134
|
| `unittests-script` | the unit testing script, by path from the project root - it runs the code's static checks too, exiting 80 when only they fail (see [the static checks](regenerating.md#the-static-checks)) | looked for - see below |
|
|
135
135
|
| `conformance-tests-script` | the conformance testing script, by path from the project root | looked for - see below |
|
|
136
|
-
| `prepare-environment-script` | optional
|
|
136
|
+
| `prepare-environment-script` | optional: run as `<script> <build>` before every run of the conformance testing script, which runs only once it passes - see [the testing scripts](regenerating.md#the-testing-scripts). turri never writes one | the one `prepare_environment*.sh` at the root or in `test_scripts/`, else none |
|
|
137
137
|
| `compact-at` | how many tokens of work - past its instructions - an agent kept through a job holds before the harness compacts it between turns; at least 1000; the project's alone | 100,000 - see [agent sessions](regenerating.md#agent-sessions) |
|
|
138
138
|
| `max-specs-per-pulse` | the most functional specs the planner groups into one pulse - what builds one feature goes together, up to this many; a stack's file wins | 3 |
|
|
139
139
|
| `max-lines-per-pulse` | the most lines of spec in one pulse - its functional specs with the definitions they bring; a group past it is split again, a functional spec a pulse; a stack's file wins | 120 |
|
|
@@ -145,7 +145,7 @@ models: # each agent role's model, thinking and tur
|
|
|
145
145
|
| `testing-scripts-run-on` | what the testing scripts run on - always on this machine, in your environment. `in-place`: the real build and tests folders, the script at its own path, from the project - so a script that finds a host codebase from its own path finds it. `copy`: a fresh copy of the code, staged in `plain_modules/.turri/scripts/`, with the script copied beside it; it gives the coding agent `can_it_fail` (see [the testing scripts](regenerating.md#the-testing-scripts)) | `in-place` |
|
|
146
146
|
|
|
147
147
|
Keys are written with dashes; underscores are read the same (`conformance_tests`). A key turri
|
|
148
|
-
does not know is ignored -
|
|
148
|
+
does not know is ignored - another tool's `config.yaml`, read while it is the old file, may carry several - and so is a value of the
|
|
149
149
|
wrong type, as if it were not there. `model:`, from before there were separate models, is read as
|
|
150
150
|
`coding-model`.
|
|
151
151
|
|
|
@@ -218,8 +218,13 @@ They are defaults. What is asked for wins over them:
|
|
|
218
218
|
| model | wins over the config |
|
|
219
219
|
| --- | --- |
|
|
220
220
|
| harness | `turri --harness-model <m>` (or pi's own `--model`), and pi's `/model` in the conversation |
|
|
221
|
-
| coding | `turri --coding-model <m>`, `engine` in the conversation
|
|
222
|
-
| judge |
|
|
221
|
+
| coding | `turri --coding-model <m>`, `engine` in the conversation |
|
|
222
|
+
| judge | - |
|
|
223
|
+
|
|
224
|
+
`turri regenerate`, `conform` and `spec-diff` read `--harness-model`, `--coding-model` (or `--model`),
|
|
225
|
+
`--judge-model`, `--review` and `--conformance-tests` differently: as the keys themselves, as if the file had
|
|
226
|
+
them. Each takes its key's value and its place in the order above - a `models:` entry still wins over it - so a
|
|
227
|
+
headless run is the same as a chat with those keys in the file.
|
|
223
228
|
|
|
224
229
|
A job keeps the model it was queued with: choosing another holds for what is queued after.
|
|
225
230
|
|
|
@@ -231,8 +236,8 @@ codebase are not run, and no agent is shown the conformance testing script. No k
|
|
|
231
236
|
`false`: conformance tests are off by default. `all` (or `true`) means on wherever a module has
|
|
232
237
|
`***test reqs***` - a module without them has nothing to write conformance tests from, so it writes
|
|
233
238
|
none, but still keeps the codebase's existing ones passing. Asked in the
|
|
234
|
-
conversation, or with `--conformance
|
|
235
|
-
otherwise.
|
|
239
|
+
conversation (`conform`), or with `--conformance-tests all|chosen|module|false` on `turri regenerate` and
|
|
240
|
+
`spec-diff` - for that run, the file left as it is - a render does otherwise.
|
|
236
241
|
|
|
237
242
|
`spawn` writes `conformance-tests: false` into a new project unless you ask for tests;
|
|
238
243
|
`brood` asks, whatever the key says, offering what it says first.
|
|
@@ -346,8 +351,14 @@ The first time turri itself writes a setting there with `/turri-config`, it copi
|
|
|
346
351
|
`config.turri.yaml` and each `config.<name>.yaml` to `config.turri.<name>.yaml` first, leaves the old
|
|
347
352
|
files as they are, and from then on reads only the new ones. `turri init-config` copies the stacks'
|
|
348
353
|
files the same way, but writes `config.turri.yaml` as its quick start, taking from `config.yaml` only
|
|
349
|
-
the keys turri reads too -
|
|
354
|
+
the keys turri reads too - `unittests-script`, `conformance-tests-script` and
|
|
350
355
|
`prepare-environment-script`, say - each in its place in the file, and saying which keys it left:
|
|
351
|
-
`template_dir`, `verbose`, `api`,
|
|
356
|
+
`template_dir`, `verbose`, `api`, the other tool's alone. The two models are the vendor's, never taken. To change a
|
|
352
357
|
setting by hand in such a project, do the same: copy the files to their new names, and edit the
|
|
353
|
-
copies - never `config.yaml`.
|
|
358
|
+
copies - never `config.yaml`. Writing a `config.turri.yaml` of your own beside it is the same move: from then on
|
|
359
|
+
`config.yaml` is not read, its testing scripts with it. For a headless run that only needs conformance tests or
|
|
360
|
+
another judge, or review, those options leave the files alone: `--conformance-tests`, `--judge-model`, `--review true|false`, and the models' too.
|
|
361
|
+
|
|
362
|
+
Keys turri does not read - another tool's `verbose`, `api-key`, `build-folder`, ... - are ignored without a word, and so
|
|
363
|
+
is a value of the wrong type. A file that does not parse as YAML is read as empty, so its testing scripts are then
|
|
364
|
+
looked for by name.
|