@turri-dev/cli 0.0.0-stage → 0.1.1
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/.pi/themes/turri-dark.json +80 -0
- package/.pi/themes/turri-light.json +82 -0
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/assets/jellyfish/dark-00.png +0 -0
- package/assets/jellyfish/dark-01.png +0 -0
- package/assets/jellyfish/dark-02.png +0 -0
- package/assets/jellyfish/dark-03.png +0 -0
- package/assets/jellyfish/dark-04.png +0 -0
- package/assets/jellyfish/dark-05.png +0 -0
- package/assets/jellyfish/dark-06.png +0 -0
- package/assets/jellyfish/dark-07.png +0 -0
- package/assets/jellyfish/dark-08.png +0 -0
- package/assets/jellyfish/dark-09.png +0 -0
- package/assets/jellyfish/dark-10.png +0 -0
- package/assets/jellyfish/dark-11.png +0 -0
- package/assets/jellyfish/dark-12.png +0 -0
- package/assets/jellyfish/dark-13.png +0 -0
- package/assets/jellyfish/dark-14.png +0 -0
- package/assets/jellyfish/fallback.txt +252 -0
- package/assets/jellyfish/light-00.png +0 -0
- package/assets/jellyfish/light-01.png +0 -0
- package/assets/jellyfish/light-02.png +0 -0
- package/assets/jellyfish/light-03.png +0 -0
- package/assets/jellyfish/light-04.png +0 -0
- package/assets/jellyfish/light-05.png +0 -0
- package/assets/jellyfish/light-06.png +0 -0
- package/assets/jellyfish/light-07.png +0 -0
- package/assets/jellyfish/light-08.png +0 -0
- package/assets/jellyfish/light-09.png +0 -0
- package/assets/jellyfish/light-10.png +0 -0
- package/assets/jellyfish/light-11.png +0 -0
- package/assets/jellyfish/light-12.png +0 -0
- package/assets/jellyfish/light-13.png +0 -0
- package/assets/jellyfish/light-14.png +0 -0
- package/bin/turri.mjs +29 -0
- package/dist/ask.js +149 -0
- package/dist/brand.js +50 -0
- package/dist/btw.js +174 -0
- package/dist/chat-models.js +37 -0
- package/dist/chat-skills.js +18 -0
- package/dist/cli.js +178 -0
- package/dist/config-command.js +197 -0
- package/dist/config-edit.js +367 -0
- package/dist/config.js +489 -0
- package/dist/conform-cli.js +29 -0
- package/dist/cultivate-cli.js +110 -0
- package/dist/drift-cli.js +25 -0
- package/dist/environment.js +70 -0
- package/dist/extension.js +2372 -0
- package/dist/genome.js +301 -0
- package/dist/graph.js +246 -0
- package/dist/identity.js +35 -0
- package/dist/init-config.js +511 -0
- package/dist/judge.js +62 -0
- package/dist/lineage-cli.js +13 -0
- package/dist/loop-cli.js +132 -0
- package/dist/loop-tools.js +225 -0
- package/dist/mature-tools.js +193 -0
- package/dist/model-check.js +117 -0
- package/dist/module-report.js +9 -0
- package/dist/pi-launch.js +31 -0
- package/dist/pi.js +813 -0
- package/dist/plain/check.js +76 -0
- package/dist/plain/concepts.js +213 -0
- package/dist/plain/errors.js +43 -0
- package/dist/plain/files.js +57 -0
- package/dist/plain/highlight.js +195 -0
- package/dist/plain/index.js +15 -0
- package/dist/plain/liquid.js +129 -0
- package/dist/plain/markdown.js +208 -0
- package/dist/plain/parser.js +752 -0
- package/dist/plain/plain.tmLanguage.json +124 -0
- package/dist/plain/spec.js +291 -0
- package/dist/plain/stdlib.js +44 -0
- package/dist/question-dialog.js +125 -0
- package/dist/questioning.js +109 -0
- package/dist/quiet.js +125 -0
- package/dist/regenerate/activity.js +187 -0
- package/dist/regenerate/agent-env.js +190 -0
- package/dist/regenerate/agent-shell.js +114 -0
- package/dist/regenerate/arbiter.js +133 -0
- package/dist/regenerate/audit.js +203 -0
- package/dist/regenerate/cases.js +83 -0
- package/dist/regenerate/change-view.js +102 -0
- package/dist/regenerate/choose-conformance.js +117 -0
- package/dist/regenerate/chunk-plan.js +625 -0
- package/dist/regenerate/codebase.js +75 -0
- package/dist/regenerate/command-check.js +312 -0
- package/dist/regenerate/compaction.js +20 -0
- package/dist/regenerate/confine.js +310 -0
- package/dist/regenerate/conform.js +133 -0
- package/dist/regenerate/conformance-agents.js +955 -0
- package/dist/regenerate/conformance.js +936 -0
- package/dist/regenerate/conversation-writes.js +100 -0
- package/dist/regenerate/credentials.js +88 -0
- package/dist/regenerate/debug-log.js +228 -0
- package/dist/regenerate/debugger.js +90 -0
- package/dist/regenerate/delivery.js +19 -0
- package/dist/regenerate/diagnose-job.js +40 -0
- package/dist/regenerate/diagnose.js +81 -0
- package/dist/regenerate/diff-view.js +240 -0
- package/dist/regenerate/drift.js +194 -0
- package/dist/regenerate/engine.js +118 -0
- package/dist/regenerate/environment-error.js +220 -0
- package/dist/regenerate/job-log.js +102 -0
- package/dist/regenerate/job-panel.js +336 -0
- package/dist/regenerate/jobs-tree.js +300 -0
- package/dist/regenerate/jobs.js +427 -0
- package/dist/regenerate/lanes.js +32 -0
- package/dist/regenerate/lineage.js +384 -0
- package/dist/regenerate/loop.js +1435 -0
- package/dist/regenerate/masking.js +95 -0
- package/dist/regenerate/notes.js +397 -0
- package/dist/regenerate/os-sandbox.js +243 -0
- package/dist/regenerate/paths.js +165 -0
- package/dist/regenerate/plain-palette.js +83 -0
- package/dist/regenerate/prompt.js +704 -0
- package/dist/regenerate/records.js +294 -0
- package/dist/regenerate/regenerate.js +598 -0
- package/dist/regenerate/regression.js +181 -0
- package/dist/regenerate/release.js +262 -0
- package/dist/regenerate/requires.js +241 -0
- package/dist/regenerate/resources.js +164 -0
- package/dist/regenerate/self-check.js +111 -0
- package/dist/regenerate/services.js +155 -0
- package/dist/regenerate/shell.js +319 -0
- package/dist/regenerate/side-panel.js +278 -0
- package/dist/regenerate/spec-map-view.js +574 -0
- package/dist/regenerate/spec-map.js +561 -0
- package/dist/regenerate/spec-steps.js +527 -0
- package/dist/regenerate/state.js +10 -0
- package/dist/regenerate/steer.js +91 -0
- package/dist/regenerate/steering.js +119 -0
- package/dist/regenerate/stop-report.js +161 -0
- package/dist/regenerate/submit-work.js +71 -0
- package/dist/regenerate/test-kinds.js +124 -0
- package/dist/regenerate/test-scripts.js +695 -0
- package/dist/regenerate/testing.js +406 -0
- package/dist/regenerate/transcript-view.js +1 -0
- package/dist/regenerate/unit-gate.js +20 -0
- package/dist/regenerate/unit-review.js +347 -0
- package/dist/regenerate/web.js +256 -0
- package/dist/regenerate/workspace.js +99 -0
- package/dist/regenerate-cli.js +68 -0
- package/dist/release-agent.js +114 -0
- package/dist/release-cli.js +131 -0
- package/dist/release-tools.js +148 -0
- package/dist/review/live.js +165 -0
- package/dist/rules.js +47 -0
- package/dist/self-invocation.js +110 -0
- package/dist/setup.js +245 -0
- package/dist/skill-progress.js +78 -0
- package/dist/skill.js +149 -0
- package/dist/skills.js +74 -0
- package/dist/spec-diff-cli.js +37 -0
- package/dist/spec-tools.js +155 -0
- package/dist/stack.js +158 -0
- package/dist/stacks.js +97 -0
- package/dist/stdlib.js +100 -0
- package/dist/terminal-images.js +46 -0
- package/dist/tools.js +242 -0
- package/dist/validate-cli.js +52 -0
- package/docs/README.md +58 -0
- package/docs/authoring-plain.md +245 -0
- package/docs/config.md +353 -0
- package/docs/deploy.md +109 -0
- package/docs/development.md +75 -0
- package/docs/headless.md +148 -0
- package/docs/images/how-turri-works.svg +50 -0
- package/docs/images/turri-banner.png +0 -0
- package/docs/installation.md +118 -0
- package/docs/parser.md +108 -0
- package/docs/regenerating.md +1116 -0
- package/docs/sandbox.md +233 -0
- package/docs/skills-and-gates.md +254 -0
- package/docs/theming.md +76 -0
- package/docs/using.md +454 -0
- package/genome/rules/bullet-continuation.md +69 -0
- package/genome/rules/concise-specs-examples.md +431 -0
- package/genome/rules/concise-specs-integration-examples.md +270 -0
- package/genome/rules/concise-specs.md +80 -0
- package/genome/rules/definitions.md +107 -0
- package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
- package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
- package/genome/rules/exported-concepts.md +39 -0
- package/genome/rules/func-specs.md +129 -0
- package/genome/rules/impl-reqs.md +68 -0
- package/genome/rules/import-modules.md +48 -0
- package/genome/rules/integration-embedded-testing.md +276 -0
- package/genome/rules/integration-embedded.md +171 -0
- package/genome/rules/integration-standalone.md +135 -0
- package/genome/rules/integrations.md +137 -0
- package/genome/rules/linked-resources.md +69 -0
- package/genome/rules/module-structure.md +105 -0
- package/genome/rules/project-model.md +88 -0
- package/genome/rules/required-concepts.md +44 -0
- package/genome/rules/requires-modules.md +80 -0
- package/genome/rules/stdlib.md +73 -0
- package/genome/rules/test-reqs.md +57 -0
- package/genome/skills/add-acceptance-test/SKILL.md +100 -0
- package/genome/skills/add-concept/SKILL.md +71 -0
- package/genome/skills/add-functional-spec/SKILL.md +84 -0
- package/genome/skills/add-functional-specs/SKILL.md +118 -0
- package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
- package/genome/skills/add-resource/SKILL.md +105 -0
- package/genome/skills/add-template/SKILL.md +64 -0
- package/genome/skills/add-test-requirement/SKILL.md +73 -0
- package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
- package/genome/skills/analyze-func-specs/SKILL.md +123 -0
- package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
- package/genome/skills/break-down-func-spec/SKILL.md +205 -0
- package/genome/skills/brood/SKILL.md +229 -0
- package/genome/skills/brood/references/intent.md +35 -0
- package/genome/skills/brood/references/product.md +101 -0
- package/genome/skills/brood/references/stack.md +61 -0
- package/genome/skills/brood/references/testing.md +56 -0
- package/genome/skills/bud/SKILL.md +148 -0
- package/genome/skills/bud/references/checklist.md +44 -0
- package/genome/skills/bud/references/intent.md +30 -0
- package/genome/skills/check-plain-env/SKILL.md +285 -0
- package/genome/skills/consolidate-concepts/SKILL.md +192 -0
- package/genome/skills/create-import-module/SKILL.md +104 -0
- package/genome/skills/create-requires-module/SKILL.md +109 -0
- package/genome/skills/debug-specs/SKILL.md +188 -0
- package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
- package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
- package/genome/skills/init-plain-project/SKILL.md +117 -0
- package/genome/skills/init-plain-project/references/checklist.md +30 -0
- package/genome/skills/mature/SKILL.md +126 -0
- package/genome/skills/refactor-module/SKILL.md +196 -0
- package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
- package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
- package/genome/skills/spawn/SKILL.md +163 -0
- package/package.json +68 -4
- package/stdlib/backend/common.plain +25 -0
- package/stdlib/backend/conformance/common.plain +12 -0
- package/stdlib/backend/conformance/go-gin.plain +9 -0
- package/stdlib/backend/conformance/go-nethttp.plain +9 -0
- package/stdlib/backend/conformance/java-springboot.plain +9 -0
- package/stdlib/backend/conformance/python-django.plain +9 -0
- package/stdlib/backend/conformance/python-fastapi.plain +9 -0
- package/stdlib/backend/conformance/python-flask.plain +9 -0
- package/stdlib/backend/conformance/rust-axum.plain +9 -0
- package/stdlib/backend/conformance/typescript-express.plain +9 -0
- package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
- package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
- package/stdlib/backend/go-gin.plain +18 -0
- package/stdlib/backend/go-nethttp.plain +18 -0
- package/stdlib/backend/java-springboot.plain +19 -0
- package/stdlib/backend/python-django.plain +19 -0
- package/stdlib/backend/python-fastapi.plain +23 -0
- package/stdlib/backend/python-flask.plain +23 -0
- package/stdlib/backend/rust-axum.plain +17 -0
- package/stdlib/backend/typescript-express.plain +19 -0
- package/stdlib/backend/typescript-fastify.plain +19 -0
- package/stdlib/backend/typescript-nestjs.plain +19 -0
- package/stdlib/cli/common.plain +17 -0
- package/stdlib/cli/conformance/common.plain +11 -0
- package/stdlib/cli/conformance/go-cobra.plain +9 -0
- package/stdlib/cli/conformance/go-flag.plain +9 -0
- package/stdlib/cli/conformance/java-picocli.plain +9 -0
- package/stdlib/cli/conformance/python-argparse.plain +9 -0
- package/stdlib/cli/conformance/python-click.plain +9 -0
- package/stdlib/cli/conformance/python-typer.plain +9 -0
- package/stdlib/cli/conformance/rust-clap.plain +9 -0
- package/stdlib/cli/conformance/typescript-commander.plain +9 -0
- package/stdlib/cli/go-cobra.plain +14 -0
- package/stdlib/cli/go-flag.plain +14 -0
- package/stdlib/cli/java-picocli.plain +15 -0
- package/stdlib/cli/python-argparse.plain +18 -0
- package/stdlib/cli/python-click.plain +16 -0
- package/stdlib/cli/python-typer.plain +16 -0
- package/stdlib/cli/rust-clap.plain +14 -0
- package/stdlib/cli/typescript-commander.plain +17 -0
- package/stdlib/frontend/angular.plain +17 -0
- package/stdlib/frontend/common.plain +17 -0
- package/stdlib/frontend/conformance/angular.plain +5 -0
- package/stdlib/frontend/conformance/common.plain +11 -0
- package/stdlib/frontend/conformance/react-vite.plain +5 -0
- package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
- package/stdlib/frontend/conformance/vue-vite.plain +5 -0
- package/stdlib/frontend/react-vite.plain +16 -0
- package/stdlib/frontend/sveltekit-spa.plain +16 -0
- package/stdlib/frontend/vue-vite.plain +16 -0
- package/stdlib/lang/go.plain +13 -0
- package/stdlib/lang/java.plain +12 -0
- package/stdlib/lang/python.plain +18 -0
- package/stdlib/lang/rust.plain +12 -0
- package/stdlib/lang/typescript.plain +13 -0
- package/stdlib/library/common.plain +15 -0
- package/stdlib/library/conformance/common.plain +9 -0
- package/stdlib/library/conformance/go.plain +9 -0
- package/stdlib/library/conformance/java-gradle.plain +9 -0
- package/stdlib/library/conformance/java-maven.plain +9 -0
- package/stdlib/library/conformance/python.plain +9 -0
- package/stdlib/library/conformance/rust.plain +9 -0
- package/stdlib/library/conformance/typescript.plain +9 -0
- package/stdlib/library/go.plain +12 -0
- package/stdlib/library/java-gradle.plain +13 -0
- package/stdlib/library/java-maven.plain +13 -0
- package/stdlib/library/python.plain +13 -0
- package/stdlib/library/rust.plain +12 -0
- package/stdlib/library/typescript.plain +15 -0
package/dist/loop-cli.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { sweepOrphanedGroups } from "./regenerate/shell.js";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { configForSpec, modelSpec, projectModels, readProjectConfig } from "./config.js";
|
|
5
|
+
import { PACKAGE_ROOT } from "./environment.js";
|
|
6
|
+
import { c } from "./module-report.js";
|
|
7
|
+
import { tryItCommand } from "./regenerate/delivery.js";
|
|
8
|
+
import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
|
|
9
|
+
import { createJobRegistry, pulseName } from "./regenerate/jobs.js";
|
|
10
|
+
import { conformanceChooser } from "./regenerate/choose-conformance.js";
|
|
11
|
+
import { createWorkLoop, describeModules, promptOf } from "./regenerate/loop.js";
|
|
12
|
+
import { projectDirOf } from "./regenerate/paths.js";
|
|
13
|
+
/**
|
|
14
|
+
* What every command that builds - `turri regenerate`, `conform`, `spec-diff` - does around its own
|
|
15
|
+
* 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
|
+
* 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
|
|
18
|
+
* start printed, and nothing runs.
|
|
19
|
+
*/
|
|
20
|
+
export async function runLoop(options) {
|
|
21
|
+
const plainFile = resolve(options.plainFile);
|
|
22
|
+
// What a turri that was killed left running - commands' process groups - stopped before anything starts.
|
|
23
|
+
const orphans = sweepOrphanedGroups();
|
|
24
|
+
if (orphans > 0)
|
|
25
|
+
console.log(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`);
|
|
26
|
+
const project = { ...readProjectConfig(process.cwd()), ...Object.fromEntries(Object.entries(configForSpec(plainFile)).filter(([, value]) => value !== undefined)) };
|
|
27
|
+
const model = options.model ?? modelSpec(projectModels(project).coding);
|
|
28
|
+
const projectDir = projectDirOf(plainFile);
|
|
29
|
+
// Each job as it starts and as it ends - the transcript of each is in .turri/jobs/.
|
|
30
|
+
const seen = new Map();
|
|
31
|
+
const registry = createJobRegistry({
|
|
32
|
+
retries: 1,
|
|
33
|
+
diagnose: async (job, failures, jobLog) => {
|
|
34
|
+
const diagnosis = await diagnoseFailure({ job, failures, projectDir, harnessDir: PACKAGE_ROOT, jobLog, model: modelSpec(projectModels(project).harness) ?? model });
|
|
35
|
+
return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
|
|
36
|
+
},
|
|
37
|
+
onChange: (job) => {
|
|
38
|
+
const line = job.status === "running" ? `${job.stage ?? "started"}` : job.status;
|
|
39
|
+
if (seen.get(job.id) === line)
|
|
40
|
+
return;
|
|
41
|
+
seen.set(job.id, line);
|
|
42
|
+
if (job.status === "running")
|
|
43
|
+
console.log(`${c.cyan(`▶ ${job.id}`)} ${pulseName(job)}${job.stage !== undefined ? c.dim(` - ${job.stage}`) : ""}`);
|
|
44
|
+
},
|
|
45
|
+
onFinish: (job) => {
|
|
46
|
+
const mark = job.status === "succeeded" ? c.green("✓") : job.status === "stopped" ? c.dim("■") : c.red("✗");
|
|
47
|
+
console.log(`${mark} ${job.id} ${pulseName(job)} - ${job.status}`);
|
|
48
|
+
if (job.status !== "succeeded")
|
|
49
|
+
console.log(c.dim((job.announce ?? job.report ?? job.error ?? "").trim()));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const planner = modelSpec(projectModels(project).harness);
|
|
53
|
+
const loop = createWorkLoop({
|
|
54
|
+
registry,
|
|
55
|
+
cwd: process.cwd(),
|
|
56
|
+
...(options.buildDir !== undefined ? { buildDir: resolve(options.buildDir) } : {}),
|
|
57
|
+
...(model !== undefined ? { modelFor: () => model } : {}),
|
|
58
|
+
...(planner !== undefined ? { plannerModel: planner } : {}),
|
|
59
|
+
...(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 } : {}) }),
|
|
62
|
+
});
|
|
63
|
+
let planned = await options.ask(loop);
|
|
64
|
+
console.log(describeModules(planned, { for: "command line" }));
|
|
65
|
+
// A codebase built without a module it now requires is started over only when the person says so.
|
|
66
|
+
const toStartOver = planned.filter((status) => status.startOver !== undefined);
|
|
67
|
+
if (toStartOver.length > 0 && options.dryRun !== true) {
|
|
68
|
+
const why = toStartOver
|
|
69
|
+
.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
|
+
.join("\n");
|
|
71
|
+
const agreed = options.startOver === true ||
|
|
72
|
+
(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
|
+
if (agreed === true) {
|
|
74
|
+
planned = await loop.startOver(plainFile, model !== undefined ? { model } : {});
|
|
75
|
+
console.log(describeModules(planned, { for: "command line" }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (options.dryRun === true) {
|
|
79
|
+
for (const first of loop.firsts()) {
|
|
80
|
+
console.log(`\n${c.bold(`${first.module}: ${first.planned.title}`)}${first.planned.track === "conformance" ? c.dim(" (conformance)") : ""}`);
|
|
81
|
+
if (first.planned.track === "conformance") {
|
|
82
|
+
console.log(c.dim("A conformance job's prompt is built from what the code it tests holds once it is built: not shown before."));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
console.log((await promptOf(first)) ?? "(no task)");
|
|
86
|
+
}
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
// Until nothing more starts: a choice is made inside the pulse it follows, so draining waits for it.
|
|
90
|
+
await loop.drain();
|
|
91
|
+
console.log(`\n${describeModules(loop.status(), { for: "command line" })}`);
|
|
92
|
+
const ended = registry.list();
|
|
93
|
+
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`))}`);
|
|
95
|
+
for (const job of [...ended].reverse())
|
|
96
|
+
console.log(` ${job.id} ${pulseName(job)}: ${job.status}`);
|
|
97
|
+
if (ended.some((job) => job.status === "succeeded"))
|
|
98
|
+
console.log(`\n${tryItCommand(plainFile)}`);
|
|
99
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
100
|
+
}
|
|
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);
|
|
117
|
+
}
|
|
118
|
+
return { positional, options };
|
|
119
|
+
}
|
|
120
|
+
/** Yes or no at the terminal - or undefined when there is none, and so no one to ask. */
|
|
121
|
+
async function askAtTerminal(title, body) {
|
|
122
|
+
if (process.stdin.isTTY !== true)
|
|
123
|
+
return undefined;
|
|
124
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
125
|
+
try {
|
|
126
|
+
const answer = await prompt.question(`\n${c.bold(title)}\n${body}\n\n${c.bold("Go ahead? [y/N] ")}`);
|
|
127
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
prompt.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { CONFIG_FILE, readProjectConfig } from "./config.js";
|
|
6
|
+
import { checkPlainFile } from "./plain/check.js";
|
|
7
|
+
import { describeDrift, describeModules } from "./regenerate/loop.js";
|
|
8
|
+
import { pulseName } from "./regenerate/jobs.js";
|
|
9
|
+
import { stopDeployed } from "./regenerate/release.js";
|
|
10
|
+
/** How the queue is to be planned, for the work this call queues - see regenerate/steer.ts. */
|
|
11
|
+
export const GUIDANCE_PARAM = Type.Optional(Type.String({
|
|
12
|
+
description: "How to plan the queue for this work, in a sentence or two - which functional specs go together, what first - as " +
|
|
13
|
+
"the user said it, or as you understood it. A strong preference for the planner, within its limits. Omit when there is none.",
|
|
14
|
+
}));
|
|
15
|
+
export const WORKING_DIR_PARAM = Type.Optional(Type.String({
|
|
16
|
+
description: "The folder to render from, relative to where turri was started - as if it had been started there " +
|
|
17
|
+
"(e.g. \"plain\"). plain_file, build_dir and a spec diff's paths are then relative to it - hubspot.plain, not " +
|
|
18
|
+
"plain/hubspot.plain. Default: where " +
|
|
19
|
+
"turri was started.",
|
|
20
|
+
}));
|
|
21
|
+
export const MODEL_PARAM = Type.Optional(Type.String({
|
|
22
|
+
description: "The model for the agents of this one call: a provider/model-id, or its name as the user said it. " +
|
|
23
|
+
"Omitted: the model chosen for this kind of work with engine, else the conversation's. " +
|
|
24
|
+
"To change it for every later render, use engine instead.",
|
|
25
|
+
}));
|
|
26
|
+
export const WAIT_PARAM = Type.Optional(Type.Boolean({
|
|
27
|
+
description: "Block until the work finishes instead of returning a pulse id. Defaults to false in the " +
|
|
28
|
+
"interactive TUI and true elsewhere, where a pulse would not outlive the turn.",
|
|
29
|
+
}));
|
|
30
|
+
/**
|
|
31
|
+
* A render tool's arguments as if turri had been started in `working_dir`: the .plain file
|
|
32
|
+
* and the build folder made absolute from there, and that folder as `base`.
|
|
33
|
+
*/
|
|
34
|
+
export function fromWorkingDir(cwd, params) {
|
|
35
|
+
const base = params.working_dir !== undefined && params.working_dir.trim() !== "" ? resolve(cwd, params.working_dir) : cwd;
|
|
36
|
+
let plainFile = params.plain_file !== undefined ? resolve(base, params.plain_file) : undefined;
|
|
37
|
+
if (params.plain_file !== undefined && plainFile !== undefined && !existsSync(plainFile)) {
|
|
38
|
+
// plain/hubspot.plain with working_dir plain: the folder named twice. Taken from where it is.
|
|
39
|
+
const fromSession = resolve(cwd, params.plain_file);
|
|
40
|
+
if (existsSync(fromSession))
|
|
41
|
+
plainFile = fromSession;
|
|
42
|
+
else {
|
|
43
|
+
throw new Error(`There is no .plain file at ${plainFile}` +
|
|
44
|
+
(base !== cwd ? ` - plain_file is relative to working_dir (${relative(cwd, base) || "."}), so do not repeat the folder in it.` : "."));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...params,
|
|
49
|
+
base,
|
|
50
|
+
...(plainFile !== undefined ? { plain_file: plainFile } : {}),
|
|
51
|
+
...(params.build_dir !== undefined ? { build_dir: resolve(base, params.build_dir) } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** What the loop is doing for a spec's modules, for the conversation - and, waiting, how each module ended. */
|
|
55
|
+
export async function loopReply(loop, statuses, wait) {
|
|
56
|
+
if (wait)
|
|
57
|
+
await loop.drain();
|
|
58
|
+
const now = wait ? loop.status().filter((status) => statuses.some((asked) => asked.plainFile === status.plainFile)) : statuses;
|
|
59
|
+
const jobIds = now.flatMap((status) => (status.running !== undefined ? [status.running.jobId] : []));
|
|
60
|
+
const text = [
|
|
61
|
+
describeModules(now),
|
|
62
|
+
"",
|
|
63
|
+
wait
|
|
64
|
+
? "Tell the user how it went."
|
|
65
|
+
: "It regenerates in the background. " +
|
|
66
|
+
"Nothing to wait for: if what you are doing has a next step - a question to ask, a file to edit, functional specs to conform - take it now, " +
|
|
67
|
+
"in this same turn, and tell the user what runs and what is planned alongside it, never as a reply that ends your turn. " +
|
|
68
|
+
"Use pulses with a pulse's id if they ask how it is going.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
return { content: [{ type: "text", text }], details: { jobIds } };
|
|
71
|
+
}
|
|
72
|
+
/** Halt a pulse by its id, or the start of it: what it was, and what moves up behind it. `reason`: why, kept with it. */
|
|
73
|
+
export function stopJob(jobs, named, reason) {
|
|
74
|
+
const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
|
|
75
|
+
if (matches.length === 0)
|
|
76
|
+
return `No pulse ${named}. /regen shows them.`;
|
|
77
|
+
if (matches.length > 1)
|
|
78
|
+
return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
|
|
79
|
+
const target = matches[0];
|
|
80
|
+
const behind = jobs.list().filter((job) => job.status === "queued" && job.waitingOn.includes(target.id));
|
|
81
|
+
const stopped = jobs.stop(target.id);
|
|
82
|
+
if (stopped === undefined)
|
|
83
|
+
return `${pulseName(target)} is already ${target.status}.`;
|
|
84
|
+
if (reason !== undefined && reason.trim() !== "")
|
|
85
|
+
stopped.note = `Halted: ${reason.trim()}`;
|
|
86
|
+
const queued = behind.length > 0 ? ` Queued behind it, now moving up: ${behind.map((job) => `${pulseName(job)} (${job.id})`).join(", ")}.` : "";
|
|
87
|
+
// A spec diff's change is in the .plain files: stopping its render does not take it out of the spec.
|
|
88
|
+
const inSpec = target.kind === "regenerate"
|
|
89
|
+
? " Its change is still in the .plain files: nothing more is regenerated in that codebase until the spec changes or it is asked " +
|
|
90
|
+
"for again with regenerate - then it is regenerated with the rest. To drop the change, take it out of the spec and regenerate; to change " +
|
|
91
|
+
"how the work is cut or ordered, use steer."
|
|
92
|
+
: "";
|
|
93
|
+
return stopped.status === "stopped"
|
|
94
|
+
? `Stopped ${pulseName(target)} (${target.id}) before it started.${queued}${inSpec}`
|
|
95
|
+
: `Stopping ${pulseName(target)} (${target.id}): its agent is being stopped now.${queued}${inSpec}`;
|
|
96
|
+
}
|
|
97
|
+
/** The module to act on: the .plain file named, or the one the project's config.turri.yaml names. */
|
|
98
|
+
function plainFileOf(cwd, given, what) {
|
|
99
|
+
const located = fromWorkingDir(cwd, given);
|
|
100
|
+
const named = located.plain_file ?? readProjectConfig(located.base).module;
|
|
101
|
+
if (named === undefined)
|
|
102
|
+
throw new Error(`Name the .plain file to ${what}, or set module: in ${CONFIG_FILE}.`);
|
|
103
|
+
return resolve(located.base, named);
|
|
104
|
+
}
|
|
105
|
+
/** Whether a /regenerate's words ask for the module from scratch: its code deleted, and all of it regenerated again. */
|
|
106
|
+
export function asksFromScratch(words) {
|
|
107
|
+
return FROM_SCRATCH.test(words);
|
|
108
|
+
}
|
|
109
|
+
const FROM_SCRATCH = /\b(from (scratch|nothing|zero|the (start|beginning|ground up))|start(ing)? (it )?(over|afresh)|afresh|anew|clean slate|(delete|wipe|remove|drop|throw away)\b.{0,30}\b(module|code|codebase|everything|all of it))/i;
|
|
110
|
+
/** regenerate: regenerate a module's code up to its spec as it is now. */
|
|
111
|
+
export function regenerateTool(options) {
|
|
112
|
+
return defineTool({
|
|
113
|
+
name: "regenerate",
|
|
114
|
+
label: "Regenerate",
|
|
115
|
+
description: "Regenerate a .plain module's code from its spec as the files have it now - and every module it requires, bottom of the " +
|
|
116
|
+
"requires chain first. Call it after any change to the spec, by any means: the file tools, a skill, the user in an " +
|
|
117
|
+
"editor. It takes no diff: what is left to regenerate - from what each module's code was regenerated from to its spec now - is " +
|
|
118
|
+
"worked out and planned into pulses, and a pulse running that regenerates something the spec no longer wants is stopped and " +
|
|
119
|
+
"rolled back. Nothing to do where the code is regenerated from the spec as it is. With functionalities named, those are " +
|
|
120
|
+
"regenerated though their spec did not change - rewritten or started over, better made, what they replace taken out - " +
|
|
121
|
+
"planned into pulses as any change is, the ones next to each other together; their conformance tests stay as they are.",
|
|
122
|
+
promptSnippet: "regenerate a module's code from its spec as it is now - after any change to the spec",
|
|
123
|
+
promptGuidelines: [
|
|
124
|
+
"Call regenerate after editing a .plain file with the file tools - or when the user says they changed one - instead of working out a diff: turri compares what is regenerated with the spec now.",
|
|
125
|
+
"regenerate returns at once with what runs and what is planned. Report it, keep talking, and use pulses when the user asks; the pulses report when they finish.",
|
|
126
|
+
"When the user wants a functionality to come out differently, change its spec - the spec is what persists - then regenerate. When they want ones already built regenerated as their spec stands - better made, on another model, after many patches - name them in functionalities, all in one call.",
|
|
127
|
+
"A change to a file the spec links - an API surface, a mapping document - is a change to the spec: regenerate sees it by itself, and regenerates every functional spec that uses what links it.",
|
|
128
|
+
"Never stop pulses and start them again yourself to follow a spec change: regenerate does it.",
|
|
129
|
+
"Set from_scratch only when the user asks for the module to be regenerated from scratch - started over, its code deleted - in this conversation, or agrees to it when a module's status says it has to start over: never to fix a pulse that did not land.",
|
|
130
|
+
],
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
plain_file: Type.Optional(Type.String({ description: "Path to the .plain file. Omitted: the module the project's config.turri.yaml names" })),
|
|
133
|
+
working_dir: WORKING_DIR_PARAM,
|
|
134
|
+
guidance: GUIDANCE_PARAM,
|
|
135
|
+
functionalities: Type.Optional(Type.Array(Type.String(), {
|
|
136
|
+
description: "Functional specs already built, by id, to regenerate though their spec did not change. Omitted: what the spec now differs from the code in",
|
|
137
|
+
})),
|
|
138
|
+
from_scratch: Type.Optional(Type.Boolean({
|
|
139
|
+
description: "Only when the user asks for it: the module's code and conformance tests deleted - committed first, so its lineage keeps them - and the whole module regenerated from its spec",
|
|
140
|
+
})),
|
|
141
|
+
model: MODEL_PARAM,
|
|
142
|
+
wait: WAIT_PARAM,
|
|
143
|
+
}),
|
|
144
|
+
async execute(_id, given, _signal, _onUpdate, ctx) {
|
|
145
|
+
const plainFile = plainFileOf(options.cwd, given, "regenerate");
|
|
146
|
+
const check = checkPlainFile(plainFile);
|
|
147
|
+
if (!check.ok)
|
|
148
|
+
throw new Error(`${relative(options.cwd, plainFile) || plainFile} does not parse, so nothing can be regenerated from it yet: ${check.error}`);
|
|
149
|
+
const again = given.functionalities ?? [];
|
|
150
|
+
if (again.length > 0 && given.from_scratch === true)
|
|
151
|
+
throw new Error("Name functionalities or set from_scratch, not both: from scratch regenerates every one of them.");
|
|
152
|
+
// Named functionalities are work on functionalities, as engine chooses a model for it; the rest, spec changes.
|
|
153
|
+
const work = again.length > 0 ? "functionalities" : "spec_changes";
|
|
154
|
+
const model = options.model !== undefined ? options.model(ctx, plainFile, given.model, work) : given.model;
|
|
155
|
+
const loop = options.loop(ctx);
|
|
156
|
+
const request = { ...(model !== undefined ? { model } : {}), ...(given.guidance !== undefined ? { guidance: given.guidance } : {}) };
|
|
157
|
+
const statuses = given.from_scratch === true
|
|
158
|
+
? await loop.fromScratch(plainFile, request)
|
|
159
|
+
: again.length > 0
|
|
160
|
+
? await loop.regenerateAgain(plainFile, again, request)
|
|
161
|
+
: await loop.bringUp(plainFile, request);
|
|
162
|
+
return loopReply(loop, statuses, options.wait !== undefined ? options.wait(ctx, given.wait) : given.wait === true);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** drift: what is left to regenerate - planning nothing, starting nothing. */
|
|
167
|
+
export function driftTool(options) {
|
|
168
|
+
return defineTool({
|
|
169
|
+
name: "drift",
|
|
170
|
+
label: "Drift",
|
|
171
|
+
description: "How far a module's code has drifted from its spec: in each codebase where it lives, what is left to regenerate - the " +
|
|
172
|
+
"functional specs to add, change, regenerate again or take out, and reqs or definitions alone - and the conformance tests " +
|
|
173
|
+
"left to write, those scheduled ahead of their code, what is regenerating now, and why nothing is regenerated where nothing is; " +
|
|
174
|
+
"with nothing left, it says the module is fully regenerated. It plans nothing and starts nothing, and calls no model.",
|
|
175
|
+
promptSnippet: "drift: what is left to regenerate in a module, code and conformance tests - starting nothing",
|
|
176
|
+
promptGuidelines: [
|
|
177
|
+
"Use drift when the user asks what is left to build, whether a module is up to date with its spec, or what changed since it was last regenerated. To see the next pulse's prompt as well, use glimpse.",
|
|
178
|
+
],
|
|
179
|
+
parameters: Type.Object({
|
|
180
|
+
plain_file: Type.Optional(Type.String({ description: "Path to the .plain file. Omitted: the module the project's config.turri.yaml names" })),
|
|
181
|
+
working_dir: WORKING_DIR_PARAM,
|
|
182
|
+
}),
|
|
183
|
+
async execute(_id, given, _signal, _onUpdate, ctx) {
|
|
184
|
+
const reports = options.loop(ctx).drift(plainFileOf(options.cwd, given, "look at"));
|
|
185
|
+
return { content: [{ type: "text", text: describeDrift(reports) }], details: { modules: reports.length } };
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const ACTIVATED_PARAM = Type.Optional(Type.String({ description: 'What activate started, by name - or "all" for every one' }));
|
|
190
|
+
const HALT_PULSE_PARAMS = Type.Object({
|
|
191
|
+
job_id: Type.Optional(Type.String({ description: "The pulse's id, or the start of it" })),
|
|
192
|
+
reason: Type.Optional(Type.String({ description: "Why it is halted, kept with it - what the spec now says that it did not, say" })),
|
|
193
|
+
activated: ACTIVATED_PARAM,
|
|
194
|
+
});
|
|
195
|
+
/** With no pulses - the release agent's halt - only what activate started. */
|
|
196
|
+
const HALT_ACTIVATED_PARAMS = Type.Object({ activated: ACTIVATED_PARAM });
|
|
197
|
+
/** halt: stop a pulse, or what activate started. */
|
|
198
|
+
export function haltTool(options) {
|
|
199
|
+
const { jobs } = options;
|
|
200
|
+
return defineTool({
|
|
201
|
+
name: "halt",
|
|
202
|
+
label: "Halt",
|
|
203
|
+
description: jobs !== undefined
|
|
204
|
+
? "Halt a pulse, or what activate started. A pulse: a queued one never starts, a running one has its agent stopped at once, " +
|
|
205
|
+
"and the pulses queued behind it move up - what a running pulse already changed is rolled back, and what it was regenerating " +
|
|
206
|
+
"counts as not regenerated. What activate started: the one named, or every one."
|
|
207
|
+
: "Stop what activate started: the one named, or every one.",
|
|
208
|
+
promptSnippet: jobs !== undefined ? "halt a pulse, or what activate started" : "stop a deployed server, or all of them",
|
|
209
|
+
promptGuidelines: jobs !== undefined ? ["Halt a pulse only when the user asks, or your instructions say to. Name which one, and say which pulses were queued behind it - they will now run."] : [],
|
|
210
|
+
parameters: (jobs !== undefined ? HALT_PULSE_PARAMS : HALT_ACTIVATED_PARAMS),
|
|
211
|
+
async execute(_id, params) {
|
|
212
|
+
if (params.job_id === undefined && params.activated === undefined) {
|
|
213
|
+
throw new Error(jobs !== undefined ? "Say what to halt: a job_id, or what activate started (activated)." : 'Say what to stop: its name, or "all" (activated).');
|
|
214
|
+
}
|
|
215
|
+
const lines = [];
|
|
216
|
+
if (params.job_id !== undefined && jobs !== undefined)
|
|
217
|
+
lines.push(stopJob(jobs, params.job_id, params.reason));
|
|
218
|
+
if (params.activated !== undefined) {
|
|
219
|
+
const stopped = await stopDeployed(params.activated === "all" ? {} : { name: params.activated });
|
|
220
|
+
lines.push(stopped.length > 0 ? `Halted ${stopped.join(", ")}.` : `Nothing activate started is running${params.activated === "all" ? "" : ` as ${params.activated}`}.`);
|
|
221
|
+
}
|
|
222
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { formatDuration } from "./regenerate/jobs.js";
|
|
6
|
+
import { readNotes } from "./regenerate/notes.js";
|
|
7
|
+
import { modulesRoot, projectDirOf } from "./regenerate/paths.js";
|
|
8
|
+
/** A time as the notes stamp their lines: "2026-09-28 17:40". */
|
|
9
|
+
export function noteStamp(date) {
|
|
10
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
11
|
+
return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`;
|
|
12
|
+
}
|
|
13
|
+
/** Every module codebase of a project that has working notes. */
|
|
14
|
+
function codebasesOf(project) {
|
|
15
|
+
const root = modulesRoot(project);
|
|
16
|
+
if (!existsSync(root))
|
|
17
|
+
return [];
|
|
18
|
+
return readdirSync(root, { withFileTypes: true })
|
|
19
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
20
|
+
.map((entry) => ({ module: entry.name, buildDir: join(root, entry.name, "code") }))
|
|
21
|
+
.filter((codebase) => existsSync(codebase.buildDir));
|
|
22
|
+
}
|
|
23
|
+
/** A notes line's stamp, and what it says: "[2026-09-28 17:40 · app 3] text". */
|
|
24
|
+
function stamped(line) {
|
|
25
|
+
const match = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ([^\]]*)\]\s*(.*)$/.exec(line);
|
|
26
|
+
return match === null ? undefined : { stamp: match[1], source: match[2], text: match[3] };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What the agents and the harness recorded since `since`: the assumptions and pitfalls in every
|
|
30
|
+
* codebase's working notes, the diagnosis of every job that failed for good, and every spec that
|
|
31
|
+
* contradicts itself.
|
|
32
|
+
*/
|
|
33
|
+
export function collectFindings(options) {
|
|
34
|
+
const since = noteStamp(options.since);
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const { module, buildDir } of codebasesOf(options.project)) {
|
|
37
|
+
// The code's notes, and its conformance tests' beside them.
|
|
38
|
+
for (const notes of [readNotes(buildDir), readNotes(join(dirname(buildDir), "tests"))]) {
|
|
39
|
+
for (const [section, kind] of [["assumptions", "assumption"], ["pitfalls", "pitfall"]]) {
|
|
40
|
+
for (const line of notes[section]) {
|
|
41
|
+
const item = stamped(line);
|
|
42
|
+
if (item === undefined || item.stamp < since)
|
|
43
|
+
continue;
|
|
44
|
+
found.push({ kind, where: `${module} (${item.source})`, text: item.text });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const job of options.jobs) {
|
|
50
|
+
if (job.queuedAt < options.since.getTime())
|
|
51
|
+
continue;
|
|
52
|
+
if (job.specConflict !== undefined) {
|
|
53
|
+
const specs = job.specConflict.specs.map((spec) => `${spec.module} ${spec.frid}: ${spec.text.trim()}`).join("\n");
|
|
54
|
+
found.push({ kind: "conflict", where: `job ${job.id} (${job.summary})`, text: `${job.specConflict.reasons.at(-1) ?? ""}\n${specs}`.trim() });
|
|
55
|
+
}
|
|
56
|
+
if (job.diagnosis !== undefined)
|
|
57
|
+
found.push({ kind: "diagnosis", where: `job ${job.id} (${job.summary})`, text: job.diagnosis.trim() });
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
/** How a job stands, in a line: what it is, how it ended, and the first line of what it said. */
|
|
62
|
+
function jobLine(job) {
|
|
63
|
+
const said = (job.status === "succeeded" ? job.report : job.error ?? job.report)?.trim().split("\n")[0] ?? "";
|
|
64
|
+
const flags = [job.diagnosis !== undefined ? "diagnosed" : "", job.specConflict !== undefined ? "spec conflict" : ""].filter(Boolean).join(", ");
|
|
65
|
+
return `- job ${job.id} · ${job.summary} · ${job.status}${job.status === "running" || job.status === "queued" ? "" : ` after ${formatDuration(job)}`}${flags ? ` · ${flags}` : ""}${said ? ` - ${said.slice(0, 200)}` : ""}`;
|
|
66
|
+
}
|
|
67
|
+
/** Setbacks a build reports as it goes: its stages that say a run failed, a review sent tests back, a judge found something wrong. */
|
|
68
|
+
const SETBACK = /not passing|NOT passing|the review found|is wrong|found the|failed|a spec conflict|contradicts|stuck|cannot|still leave|trying it once more/;
|
|
69
|
+
/** How long a build may run before it counts as struggling on its length alone. */
|
|
70
|
+
export const LONG_BUILD_MS = 30 * 60_000;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a running build is struggling, and how: sent back four times or more in one loop, five
|
|
73
|
+
* setbacks or more, a spec conflict raised, a failure being run again, or running far longer than a
|
|
74
|
+
* build should. Each kind comes once per build - a round count again only every four rounds more.
|
|
75
|
+
*/
|
|
76
|
+
export function struggling(job, now = Date.now()) {
|
|
77
|
+
if (job.status !== "running")
|
|
78
|
+
return undefined;
|
|
79
|
+
const stages = job.transcript.filter((entry) => entry.kind === "stage").map((entry) => entry.text);
|
|
80
|
+
if (stages.some((stage) => /a spec conflict|contradicts itself/.test(stage)))
|
|
81
|
+
return { key: "conflict", why: "a spec conflict was raised: two functional specs that may not both hold" };
|
|
82
|
+
if (stages.some((stage) => /trying it once more/.test(stage)))
|
|
83
|
+
return { key: "retry", why: "it failed once and is being run again" };
|
|
84
|
+
const round = /round (\d+) of (\d+)/.exec(job.stage ?? "");
|
|
85
|
+
if (round !== null && Number(round[1]) >= 4) {
|
|
86
|
+
const bucket = Math.floor((Number(round[1]) - 4) / 4);
|
|
87
|
+
return { key: `rounds-${bucket}`, why: `sent back ${round[1]} times of at most ${round[2]}: ${job.stage}` };
|
|
88
|
+
}
|
|
89
|
+
const setbacks = stages.filter((stage) => SETBACK.test(stage));
|
|
90
|
+
if (setbacks.length >= 5)
|
|
91
|
+
return { key: `setbacks-${Math.floor(setbacks.length / 5)}`, why: `${setbacks.length} setbacks so far - the last: ${setbacks.at(-1)}` };
|
|
92
|
+
if (now - job.startedAt > LONG_BUILD_MS)
|
|
93
|
+
return { key: `long-${Math.floor((now - job.startedAt) / LONG_BUILD_MS)}`, why: `running for ${formatDuration(job)}` };
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
/** A struggling build, for the skill to judge: why, its last stages, and the end of what it printed. */
|
|
97
|
+
function describeStruggle(job, struggle) {
|
|
98
|
+
const stages = job.transcript.filter((entry) => entry.kind === "stage").map((entry) => entry.text).slice(-12);
|
|
99
|
+
return [
|
|
100
|
+
`job ${job.id} · ${job.summary} · ${struggle.why}`,
|
|
101
|
+
job.diff !== undefined ? `<its change>\n${job.diff.trim().slice(0, 3000)}\n</its change>` : "",
|
|
102
|
+
`<its last stages>\n${stages.map((stage) => `- ${stage}`).join("\n")}\n</its last stages>`,
|
|
103
|
+
job.tail.trim() !== "" ? `<the end of its output>\n${job.tail.trim().slice(-2500)}\n</the end of its output>` : "",
|
|
104
|
+
]
|
|
105
|
+
.filter((part) => part !== "")
|
|
106
|
+
.join("\n");
|
|
107
|
+
}
|
|
108
|
+
export function createMatureTools(options) {
|
|
109
|
+
const startedAt = options.startedAt ?? new Date();
|
|
110
|
+
const mine = () => options.jobs.list().filter((job) => job.queuedAt >= startedAt.getTime());
|
|
111
|
+
/** Findings already handed out, by what they say: each is reported once. */
|
|
112
|
+
const reported = new Set();
|
|
113
|
+
let counter = 0;
|
|
114
|
+
/** The struggles already handed out, by job and what they were - each reported once, again only when it gets longer. */
|
|
115
|
+
const flagged = new Set();
|
|
116
|
+
const watchJobs = defineTool({
|
|
117
|
+
name: "watch_jobs",
|
|
118
|
+
label: "Watch the builds",
|
|
119
|
+
description: "Watch the builds queued since this run started, and come back as soon as one is struggling - sent back again and " +
|
|
120
|
+
"again, setback after setback, a spec conflict raised, a failure being run again, running far too long - or when " +
|
|
121
|
+
"every one has finished, or when `minutes` have passed. For a struggling build it gives what it has been through: its " +
|
|
122
|
+
"last stages and the end of its output. Says how every build stands.",
|
|
123
|
+
promptSnippet: "watch the builds, and come back when one struggles or all are done",
|
|
124
|
+
parameters: Type.Object({
|
|
125
|
+
minutes: Type.Optional(Type.Number({ description: "The longest to watch, in minutes. Default 20" })),
|
|
126
|
+
}),
|
|
127
|
+
async execute(_id, params, signal) {
|
|
128
|
+
const limit = Math.max(1, Math.min(120, params.minutes ?? 20)) * 60_000;
|
|
129
|
+
const deadline = Date.now() + limit;
|
|
130
|
+
const pending = () => mine().filter((job) => job.status === "running" || job.status === "queued");
|
|
131
|
+
const newStruggles = () => mine()
|
|
132
|
+
.map((job) => ({ job, struggle: struggling(job) }))
|
|
133
|
+
.filter((item) => item.struggle !== undefined && !flagged.has(`${item.job.id}|${item.struggle.key}`));
|
|
134
|
+
let found = newStruggles();
|
|
135
|
+
while (found.length === 0 && pending().length > 0 && Date.now() < deadline && signal?.aborted !== true) {
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
137
|
+
found = newStruggles();
|
|
138
|
+
}
|
|
139
|
+
for (const item of found)
|
|
140
|
+
flagged.add(`${item.job.id}|${item.struggle.key}`);
|
|
141
|
+
const all = mine().sort((a, b) => a.sequence - b.sequence);
|
|
142
|
+
const left = pending();
|
|
143
|
+
const text = [
|
|
144
|
+
found.length > 0
|
|
145
|
+
? `${found.length} build${found.length === 1 ? " is" : "s are"} struggling:`
|
|
146
|
+
: left.length === 0
|
|
147
|
+
? `Every build queued since this run started has finished (${all.length}).`
|
|
148
|
+
: `No build is struggling; ${left.length} of ${all.length} are still running or queued - call watch_jobs again.`,
|
|
149
|
+
...found.flatMap(({ job, struggle }) => ["", describeStruggle(job, struggle)]),
|
|
150
|
+
"",
|
|
151
|
+
"Every build:",
|
|
152
|
+
...all.map(jobLine),
|
|
153
|
+
"",
|
|
154
|
+
"Read what the builds had to decide themselves with spec_findings; see how far the spec is regenerated with drift.",
|
|
155
|
+
].join("\n");
|
|
156
|
+
return { content: [{ type: "text", text }], details: { pending: left.length, struggling: found.map((item) => item.job.id) } };
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const specFindings = defineTool({
|
|
160
|
+
name: "spec_findings",
|
|
161
|
+
label: "What the spec left open",
|
|
162
|
+
description: "What the builds since this run started recorded where the spec left them to decide or went wrong: every " +
|
|
163
|
+
"assumption and pitfall in the codebases' working notes, the diagnosis of every job that failed for good, and every " +
|
|
164
|
+
"spec that contradicts itself - each numbered, and each given once: a finding already handed out is not repeated.",
|
|
165
|
+
promptSnippet: "where the builds had to guess, or failed on the spec",
|
|
166
|
+
parameters: Type.Object({
|
|
167
|
+
plain_file: Type.Optional(Type.String({ description: "A .plain file of the project to read; default the session's folder" })),
|
|
168
|
+
all: Type.Optional(Type.Boolean({ description: "Every finding since the run started, not only the new ones" })),
|
|
169
|
+
}),
|
|
170
|
+
async execute(_id, params) {
|
|
171
|
+
// The project a spec is built in; with none named, the one the session's folder is.
|
|
172
|
+
const project = projectDirOf(params.plain_file !== undefined ? resolve(options.cwd, params.plain_file) : join(options.cwd, "session.plain"));
|
|
173
|
+
const found = collectFindings({ project, jobs: mine(), since: startedAt });
|
|
174
|
+
const fresh = found.filter((finding) => params.all === true || !reported.has(`${finding.kind}|${finding.where}|${finding.text}`));
|
|
175
|
+
const numbered = fresh.map((finding) => {
|
|
176
|
+
reported.add(`${finding.kind}|${finding.where}|${finding.text}`);
|
|
177
|
+
counter += 1;
|
|
178
|
+
return { ...finding, id: `f${counter}` };
|
|
179
|
+
});
|
|
180
|
+
const text = numbered.length === 0
|
|
181
|
+
? "No new findings: the builds since the last look recorded no assumption, no pitfall, no diagnosis and no spec conflict."
|
|
182
|
+
: [
|
|
183
|
+
`${numbered.length} finding${numbered.length === 1 ? "" : "s"}:`,
|
|
184
|
+
"",
|
|
185
|
+
...numbered.map((finding) => `${finding.id} · ${finding.kind} · ${finding.where}\n${finding.text}`),
|
|
186
|
+
].join("\n\n");
|
|
187
|
+
return { content: [{ type: "text", text }], details: { findings: numbered } };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return [watchJobs, specFindings];
|
|
191
|
+
}
|
|
192
|
+
/** The names of the maturing tools, as a skill's allowed-tools names them: its own - it regenerates, halts and reads drift with the conversation's (loop-tools.ts). */
|
|
193
|
+
export const MATURE_TOOLS = ["watch_jobs", "spec_findings"];
|