@turri-dev/cli 0.1.25-next.d0c7575 → 0.1.25-next.fb3566e

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.
Files changed (52) hide show
  1. package/README.md +5 -5
  2. package/dist/extension.d.ts +0 -4
  3. package/dist/extension.js +13 -55
  4. package/dist/genome.d.ts +0 -3
  5. package/dist/genome.js +4 -7
  6. package/dist/graph.js +5 -2
  7. package/dist/new-project.d.ts +1 -3
  8. package/dist/new-project.js +3 -13
  9. package/dist/plain/parser.d.ts +1 -0
  10. package/dist/plain/parser.js +1 -1
  11. package/dist/plain-files.d.ts +21 -0
  12. package/dist/plain-files.js +156 -0
  13. package/dist/regenerate/diff-view.d.ts +4 -1
  14. package/dist/regenerate/diff-view.js +6 -3
  15. package/dist/regenerate/stop-report.js +1 -1
  16. package/dist/regenerate/test-scripts.d.ts +2 -0
  17. package/dist/regenerate/test-scripts.js +1 -1
  18. package/dist/skill-progress.d.ts +4 -4
  19. package/dist/skill.d.ts +1 -1
  20. package/dist/skills.d.ts +4 -4
  21. package/dist/skills.js +5 -5
  22. package/docs/authoring-plain.md +7 -23
  23. package/docs/chats.md +1 -1
  24. package/docs/config.md +3 -3
  25. package/docs/development.md +0 -1
  26. package/docs/headless.md +2 -2
  27. package/docs/hosting.md +1 -1
  28. package/docs/regenerating.md +3 -3
  29. package/docs/skills-and-gates.md +17 -15
  30. package/docs/using.md +5 -13
  31. package/genome/rules/concise-specs-examples.md +1 -1
  32. package/genome/rules/concise-specs-integration-examples.md +1 -1
  33. package/genome/rules/func-specs.md +10 -2
  34. package/genome/rules/integration-embedded.md +2 -2
  35. package/genome/rules/integration-standalone.md +2 -2
  36. package/genome/skills/break-down-func-spec/SKILL.md +8 -8
  37. package/genome/skills/consolidate-concepts/SKILL.md +1 -1
  38. package/genome/skills/resolve-section-ownership/SKILL.md +2 -2
  39. package/genome/skills/resolve-spec-conflict/SKILL.md +1 -1
  40. package/package.json +1 -1
  41. package/dist/mature-tools.d.ts +0 -53
  42. package/dist/mature-tools.js +0 -193
  43. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +0 -206
  44. package/genome/skills/brood/SKILL.md +0 -129
  45. package/genome/skills/brood/references/intent.md +0 -27
  46. package/genome/skills/brood/references/product.md +0 -49
  47. package/genome/skills/brood/references/stack.md +0 -39
  48. package/genome/skills/brood/references/testing.md +0 -25
  49. package/genome/skills/bud/SKILL.md +0 -104
  50. package/genome/skills/bud/references/intent.md +0 -27
  51. package/genome/skills/mature/SKILL.md +0 -78
  52. package/genome/skills/spawn/SKILL.md +0 -85
package/README.md CHANGED
@@ -50,7 +50,7 @@ turri init-config --vendor anthropic
50
50
 
51
51
  It then tells you which API key to set - or to type `/login` once **turri** is running instead. **turri** loads the `config.turri.yaml` in the folder you start it from. Below the two models, the file sets every other setting to its default - change any of them in the file, or from the chat with `/turri-config`. Any other model works too: [models](#models).
52
52
 
53
- Once `turri` is running, type: `/spawn a small API where people can register and sign in` - or `/brood` for an interview that settles each feature with you first.
53
+ Once `turri` is running, say what you want - `a small API where people can register and sign in` - and **turri** starts the project and regenerates it at once.
54
54
 
55
55
  No project of your own yet? Build one of [**the examples**](examples/README.md) with turri, step by step.
56
56
 
@@ -147,17 +147,17 @@ cd ~/my-project
147
147
  turri
148
148
  ```
149
149
 
150
- Then start the interview:
150
+ Then say what you want:
151
151
 
152
- > /brood a small API where people can register and sign in
152
+ > A small API where people can register and sign in, in Python.
153
153
 
154
- It asks you about the problem, the stack and the product, writes \*\*\*plain, and builds each functionality as it is written. While it works:
154
+ In a new project **turri** starts it itself, without asking how: it chooses the stack - the one you name, else a standard library stack this machine can build - writes the module and its unit testing script (and the conformance tests' when you ask for them), regenerates the smallest thing that runs at once, then the main thing you described, and says what it chose and what it would write next. Every change after is yours to ask for: **turri** writes it as \*\*\*plain and regenerates it. While it works:
155
155
 
156
156
  - the **panel** above the input shows every build - its stage, what its agents are doing, its model and cost - and the queue behind it;
157
157
  - ask to try it at any time - "let me try it" - and **`release`** puts what is built so far in `dist/<module>` and **`activate`** starts it, installing what it needs - nothing to approve;
158
158
  - the **side panel** (`ctrl+h`) takes the screen and opens on **regen** - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`, kept with `--debug` (`debug: true`);
159
159
  - **`/halt`** halts a pulse, **`/lineage`** shows every generation of a module, and the **`drift`** tool says what is left to regenerate;
160
- - **`/btw <question>`** asks turri something on the side - answered at once, even in the middle of a skill's interview (typed as the answer, under "Something else…"), and left out of the conversation;
160
+ - **`/btw <question>`** asks turri something on the side - answered at once, even while a question waits for you (typed as the answer, under "Something else…"), and left out of the conversation;
161
161
  - **`/turri-config`** changes any setting of the project's `config.turri.yaml` - the models, review, conformance tests, how many pulses at once - and says when it takes effect: now, at your next message, or at the next pulse. No restart: `/turri-config review false`, or `/turri-config` alone to pick from the list;
162
162
  - **`/stack`** chooses the stack to build with, in a project whose backend and frontend each have their own testing scripts in `config.turri.<stack>.yaml` - `/stack backend`, `/stack none` - for the session (`--stack <name>` on the command line). The chat's model builds each part with its own stack by itself, pinned to that module's pulses.
163
163
 
@@ -92,10 +92,6 @@ export declare function moduleNamedFirst(cwd: string, words: string[]): {
92
92
  * is set - else half this machine's cores, between one and four.
93
93
  */
94
94
  export declare function parallelJobs(cwd: string): number;
95
- /** The project's own files and a digest of each - not plain_modules, dependencies, or turri's and git's folders. */
96
- export declare function projectFiles(root: string): Map<string, string>;
97
- /** The files added, changed or removed between two digests of the project. */
98
- export declare function changedFiles(before: Map<string, string>, after: Map<string, string>): string[];
99
95
  /**
100
96
  * This machine, for the chat's agent, read afresh every turn: which wall is up under the agents' commands, whether the
101
97
  * machine has the toolchains the project's modules name, and what in its testing scripts it lacks - for the person to
package/dist/extension.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // Whether the terminal shows images, before anything draws: the banner's jellyfish is a picture or text.
2
2
  import "./terminal-images.js";
3
- import { createHash } from "node:crypto";
4
3
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
4
  import { basename, dirname, join, relative, resolve } from "node:path";
6
5
  import { conversationCommand, conversationSettled, conversationWriting } from "./regenerate/conversation-writes.js";
@@ -29,6 +28,7 @@ import { askedForCommandLine, refusal, selfInvocation, TOOLS_NOT_CLI } from "./s
29
28
  import { NEW_PROJECT } from "./new-project.js";
30
29
  import { describeChoices, describeResolution, modelFor, resolveModel } from "./regenerate/engine.js";
31
30
  import { checkPlainFile, checkPlainTargets, formatCheckResults } from "./plain/check.js";
31
+ import { changedFiles, projectFiles } from "./plain-files.js";
32
32
  import { whyFailed } from "./regenerate/diagnose-job.js";
33
33
  import { createWorkLoop, describeModules, isUpToDate, previewJobs } from "./regenerate/loop.js";
34
34
  import { brandNeon, brandPink, plainPaint } from "./regenerate/plain-palette.js";
@@ -618,7 +618,7 @@ function turriExtension(api, session, link) {
618
618
  registerBranding(pi, discoveredSkills);
619
619
  registerInterviewLog(pi);
620
620
  registerJobTools(pi, jobs, cwd);
621
- // The skills the person starts themselves - brood, spawn, bud: each has its command, and cultivate starts it only
621
+ // The skills the person starts themselves - a project's, marked command-only: each has its command, and cultivate starts it only
622
622
  // once its command is typed, or picked among the options the conversation offers with ask_user.
623
623
  const gate = createCommandGate();
624
624
  // A skill picked is started at once, with what the person asked for: they never type its command after picking it.
@@ -667,7 +667,7 @@ function turriExtension(api, session, link) {
667
667
  promptSnippet: "cultivate: run a gated skill - its steps and review gates enforced by turri",
668
668
  promptGuidelines: [
669
669
  "When a request matches a skill listed in cultivate's description, call cultivate instead of doing the work yourself. Doing it by hand skips the review gates.",
670
- "A skill marked as started only by the person runs only from its command: a message that is its command - /spawn a small server - is the person starting it, and so is an option starting with it that they picked in your ask_user - /brood - an interview. Call cultivate for it at once, with what they said as context. Never call cultivate for one otherwise; for a new project, offer the choice as <new-project> says.",
670
+ "A skill marked as started only by the person runs only from its command: a message that is its command - /<name> what they want - is the person starting it, and so is an option starting with it that they picked in your ask_user. Call cultivate for it at once, with what they said as context. Never call cultivate for one otherwise.",
671
671
  // pi lists .agents/skills among its own skills too: read and followed by hand, a skill's gates would be skipped.
672
672
  "A skill cultivate lists may also be among your own skills, from the project's .agents/skills: run it with cultivate all the same, never by reading its SKILL.md and following it.",
673
673
  "cultivate returns the reviewed artifact and a trace of every gate decision. Report the artifact to the user; do not rewrite it.",
@@ -817,8 +817,9 @@ function turriExtension(api, session, link) {
817
817
  push(` ${line}`);
818
818
  },
819
819
  };
820
- // What the run changes in the project - its specs, resources, scripts - is said with its result: a skill that
821
- // writes nothing is not taken for one that did its work.
820
+ // What the run changes in the project's ***plain - its specs, resources, scripts - is said with its result: a skill
821
+ // that writes nothing is not taken for one that did its work. Only those: a release or a running app changes other
822
+ // files while it runs, and they are not its doing.
822
823
  const filesBefore = projectFiles(cwd);
823
824
  // A side question asked while it runs is told what it has done so far.
824
825
  const ended = skillRunStarted({ skill: skill.name, dir: skill.dir, step: () => live.step, trace: () => [...trace], runDir });
@@ -847,8 +848,6 @@ function turriExtension(api, session, link) {
847
848
  // pulses sees them without knowing a skill started them.
848
849
  // Asked per job as it is queued, so a model chosen mid-interview applies from then on.
849
850
  renderQueue: workLoopFor(jobs, cwd, ctx),
850
- // A skill that waits for its builds and reads what they recorded - `mature` - sees the same jobs.
851
- jobs,
852
851
  // The person answers the skill's agent while it runs, so it can take "render these with ..." too.
853
852
  // validate too: a skill validates what it wrote with the same check the conversation has.
854
853
  sharedTools: [setRenderModelTool(pi, ctx), checkPlainTool(cwd), ...createWebTools()],
@@ -900,10 +899,14 @@ function turriExtension(api, session, link) {
900
899
  const noise = describeCaptured(captured);
901
900
  if (noise !== null)
902
901
  push(noise);
903
- const changes = changedFiles(filesBefore, projectFiles(cwd));
902
+ const changes = changedFiles(filesBefore, projectFiles(cwd, filesBefore.keys()));
903
+ // A skill that only reads and reports - an analysis - changes nothing, and its result is its verdict.
904
+ const writes = ["write", "edit", "bash", "change_plain"].some((tool) => skill.allowedTools.includes(tool));
904
905
  const changed = changes.length > 0
905
906
  ? `It changed ${changes.length} file${changes.length === 1 ? "" : "s"} in the project: ${changes.join(", ")}.`
906
- : "It changed no file in the project - whatever it said it wrote is not there. Say so to the user; do not report the work as done.";
907
+ : writes
908
+ ? "It changed no file in the project - whatever it said it wrote is not there. Say so to the user; do not report the work as done."
909
+ : "It only reads: what it found is the artifact below.";
907
910
  // A skill that edits the specs with the file tools builds nothing: the change is the conversation's to render.
908
911
  const specsChanged = changes.filter((path) => path.endsWith(".plain"));
909
912
  const unrendered = specsChanged.length > 0 && !skill.allowedTools.includes("change_plain")
@@ -989,7 +992,7 @@ function registerRegenerate(pi, cwd, jobs) {
989
992
  say(`${relative(cwd, plainFile) || plainFile} does not parse, so nothing can be regenerated from it yet: ${check.error}`, "error");
990
993
  return;
991
994
  }
992
- // From scratch - started over, its code deleted - asked in the person's words: confirmed first, as it deletes.
995
+ // From scratch - started over, its code deleted - asked in the person's words: started at once, as asked.
993
996
  const scratch = asksFromScratch(guidance);
994
997
  // The whole chain with it - "from scratch, the whole chain" - every module it requires built again from the bottom.
995
998
  const chain = scratch && asksWholeChain(guidance);
@@ -998,13 +1001,6 @@ function registerRegenerate(pi, cwd, jobs) {
998
1001
  say("Name functionalities or ask for it from scratch, not both: from scratch regenerates every one of them.", "warning");
999
1002
  return;
1000
1003
  }
1001
- if (scratch && ctx.hasUI) {
1002
- const agreed = await ctx.ui.confirm(chain ? `Regenerate ${module} and its whole chain from scratch?` : `Regenerate ${module} from scratch?`, chain
1003
- ? `The code and conformance tests of ${module} and of every module it requires are deleted - committed first, so /lineage keeps them - and the whole chain is regenerated from its ***plain, from the bottom.`
1004
- : `Its code and conformance tests are deleted - committed first, so /lineage ${module} keeps them - and the whole module is regenerated from its ***plain.`);
1005
- if (!agreed)
1006
- return;
1007
- }
1008
1004
  const loop = workLoopFor(jobs, cwd, ctx);
1009
1005
  const model = jobModel(ctx, again.length > 0 ? "functionalities" : "spec_changes", undefined, plainFile);
1010
1006
  const request = { ...(model !== undefined ? { model } : {}), ...(guidance !== "" ? { guidance } : {}) };
@@ -1242,44 +1238,6 @@ function configOf(plainFile) {
1242
1238
  export function parallelJobs(cwd) {
1243
1239
  return readProjectConfig(cwd).parallelJobs ?? defaultParallelJobs();
1244
1240
  }
1245
- /** The project's own files and a digest of each - not plain_modules, dependencies, or turri's and git's folders. */
1246
- export function projectFiles(root) {
1247
- const files = new Map();
1248
- const walk = (dir, depth) => {
1249
- if (depth > 8)
1250
- return;
1251
- let entries;
1252
- try {
1253
- entries = readdirSync(dir, { withFileTypes: true });
1254
- }
1255
- catch {
1256
- return;
1257
- }
1258
- for (const entry of entries) {
1259
- if (entry.name.startsWith(".") || entry.name === "plain_modules" || entry.name === "node_modules" || entry.name === "__pycache__")
1260
- continue;
1261
- const path = join(dir, entry.name);
1262
- if (entry.isDirectory())
1263
- walk(path, depth + 1);
1264
- else if (entry.isFile()) {
1265
- try {
1266
- files.set(relative(root, path), createHash("sha1").update(readFileSync(path)).digest("hex"));
1267
- }
1268
- catch {
1269
- // A file that cannot be read is left out.
1270
- }
1271
- }
1272
- }
1273
- };
1274
- walk(root, 0);
1275
- return files;
1276
- }
1277
- /** The files added, changed or removed between two digests of the project. */
1278
- export function changedFiles(before, after) {
1279
- const changed = [...after].filter(([path, digest]) => before.get(path) !== digest).map(([path]) => path);
1280
- const removed = [...before.keys()].filter((path) => !after.has(path)).map((path) => `${path} (removed)`);
1281
- return [...changed, ...removed].sort();
1282
- }
1283
1241
  /**
1284
1242
  * This machine, for the chat's agent, read afresh every turn: which wall is up under the agents' commands, whether the
1285
1243
  * machine has the toolchains the project's modules name, and what in its testing scripts it lacks - for the person to
package/dist/genome.d.ts CHANGED
@@ -2,7 +2,6 @@ import { type Reporter } from "./graph.ts";
2
2
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { type Skill } from "./skill.ts";
4
4
  import { type AskUser } from "./tools.ts";
5
- import type { JobRegistry } from "./regenerate/jobs.ts";
6
5
  import type { WorkLoop } from "./regenerate/loop.ts";
7
6
  export interface EscalationRequest {
8
7
  step: string;
@@ -56,8 +55,6 @@ export interface RunSkillOptions {
56
55
  * builds stand - and regenerate and halt for a skill that names them in its allowed-tools.
57
56
  */
58
57
  loopTools?: ToolDefinition[];
59
- /** The jobs the render queue runs, for a skill that waits for its builds and reads what they recorded. */
60
- jobs?: JobRegistry;
61
58
  /**
62
59
  * Stops the run: its agents end their turns and it throws JobStoppedError, its manifest left as it was - an
63
60
  * interrupted run, for `resume` to pick up. What it started on the work loop goes on.
package/dist/genome.js CHANGED
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { Command, MemorySaver } from "@langchain/langgraph";
4
4
  import { compileSkill } from "./graph.js";
5
5
  import { createExecutor } from "./pi.js";
6
+ import { plainWriteTools } from "./plain-files.js";
6
7
  import { toolchainsBlock } from "./stack.js";
7
8
  import { clearOfStop, describeToolCall, describeToolResult, withStopSignal } from "./regenerate/activity.js";
8
9
  import { createLiveReviewer } from "./review/live.js";
@@ -11,7 +12,6 @@ import { createAskUserTool, createEndQuestionsTool, createReviewTools, createWri
11
12
  import { DECLINED, DISMISSED } from "./ask.js";
12
13
  import { createQuestioning, levelOf, questioningQuestion } from "./questioning.js";
13
14
  import { createSpecDiffTools } from "./spec-tools.js";
14
- import { createMatureTools, MATURE_TOOLS } from "./mature-tools.js";
15
15
  const CHANGE_PLAIN = "change_plain";
16
16
  /** The conversation's tools a skill acts on the work loop with - never pi built-ins. */
17
17
  const LOOP_TOOLS = ["regenerate", "drift", "halt", "conform"];
@@ -28,7 +28,6 @@ export async function runSkill(opts) {
28
28
  return withStopSignal(opts.signal, () => runStoppable({
29
29
  ...opts,
30
30
  ...(opts.renderQueue !== undefined ? { renderQueue: clearOfStop(opts.renderQueue) } : {}),
31
- ...(opts.jobs !== undefined ? { jobs: clearOfStop(opts.jobs) } : {}),
32
31
  ...(opts.loopTools !== undefined ? { loopTools: opts.loopTools.map(clearOfStop) } : {}),
33
32
  }));
34
33
  }
@@ -128,14 +127,12 @@ async function runStoppable(opts) {
128
127
  }
129
128
  if (diffs !== undefined)
130
129
  customTools.push(...diffs.tools);
131
- // A skill that builds what the spec holds, waits for it, and reads back where the builds had to guess.
132
- if (opts.renderQueue !== undefined && opts.jobs !== undefined && skill.allowedTools.some((tool) => MATURE_TOOLS.includes(tool))) {
133
- customTools.push(...createMatureTools({ jobs: opts.jobs, cwd: opts.cwd }));
134
- }
135
130
  customTools.push(...(opts.sharedTools ?? []));
131
+ // Its write and edit reach the project's ***plain only - not the code, a release, or what an app it started writes.
132
+ customTools.push(...plainWriteTools(opts.cwd).filter((tool) => skill.allowedTools.includes(tool.name)));
136
133
  const names = customTools.map((tool) => tool.name);
137
134
  // Tools this harness provides are never pi built-ins, even when it did not provide them.
138
- const builtins = skill.allowedTools.filter((t) => !names.includes(t) && t !== CHANGE_PLAIN && !MATURE_TOOLS.includes(t) && !LOOP_TOOLS.includes(t));
135
+ const builtins = skill.allowedTools.filter((t) => !names.includes(t) && t !== CHANGE_PLAIN && !LOOP_TOOLS.includes(t));
139
136
  const executor = await createExecutor({
140
137
  cwd: opts.cwd,
141
138
  customTools,
package/dist/graph.js CHANGED
@@ -87,8 +87,8 @@ function stepPrompt(skill, step, critique, context) {
87
87
  `This skill's own files are in ${skill.dir}: a path its steps name, such as references/<file>.md, is there. ` +
88
88
  `A skill it says to follow is beside it, in ${dirname(skill.dir)}/<skill name>/SKILL.md - read it and follow it.`,
89
89
  "",
90
- // The artifact exists for the gate's judges to read. Without a gate nothing reads it,
91
- // and asking for one is a tool call spent on a file no one opens.
90
+ // The artifact exists for the gate's judges to read. Without a gate its reply is what the step gives,
91
+ // and asking for a file is a tool call spent on what the reply already says.
92
92
  step.gate
93
93
  ? `When you are done, call write_artifact with path "${artifactPath(step)}" holding the record the review will be checked against.`
94
94
  : "",
@@ -133,6 +133,9 @@ export function compileSkill(opts) {
133
133
  catch {
134
134
  // Left empty on purpose: the gate treats a missing artifact as a failure.
135
135
  }
136
+ // A step with no gate is asked for no artifact: what it found is its reply - an analysis's verdict, all it gives.
137
+ if (artifact === "" && !step.gate)
138
+ artifact = reply.trim();
136
139
  if (artifact)
137
140
  report.artifact(artifactPath(step), artifact);
138
141
  return {
@@ -1,7 +1,5 @@
1
1
  /**
2
2
  * Starting a project from the conversation: the person says what they want built - "create a small server in Java
3
- * Spring Boot" - and the chat offers the three ways to start it: brood's interview, spawn's quick start, or writing the
4
- * ***plain itself, here, with no skill. An option picked that starts with a skill's command starts that skill, as if
5
- * typed (`chosenCommand` in skills.ts).
3
+ * Spring Boot" - and the chat writes the ***plain itself, here, with no skill, regenerating from the first functional spec.
6
4
  */
7
5
  export declare const NEW_PROJECT: string;
@@ -1,21 +1,11 @@
1
1
  /**
2
2
  * Starting a project from the conversation: the person says what they want built - "create a small server in Java
3
- * Spring Boot" - and the chat offers the three ways to start it: brood's interview, spawn's quick start, or writing the
4
- * ***plain itself, here, with no skill. An option picked that starts with a skill's command starts that skill, as if
5
- * typed (`chosenCommand` in skills.ts).
3
+ * Spring Boot" - and the chat writes the ***plain itself, here, with no skill, regenerating from the first functional spec.
6
4
  */
7
5
  export const NEW_PROJECT = [
8
6
  "<new-project>",
9
- "When the person asks for new software / project ",
10
- "and there is no ***plain module for it yet, or they ask for a new project, ask them first how to start it, with",
11
- "ask_user: one question, these three options, word for word, so they know the skills are there:",
12
- `- "${"/brood - an interview: the stack, the testing and every feature settled with you, each answer regenerated"}"`,
13
- `- "${"/spawn - something running in minutes: the quick start, asking the stack in one question unless your message names it"}"`,
14
- `- "${"Just do it - no skill: I write it here and start regenerating"}"`,
15
- "Picked /brood or /spawn: the answer starts it - call cultivate for it at once, with what they said as its context,",
16
- "never asking them to type its command. Do not ask when their",
17
- "message says already - they typed the command, or said to just build it, with no skill, without questions: then",
18
- "start it yourself. Started yourself, how soon the first pulse starts, and something they can try, come first:",
7
+ "When the person asks for new software / project and there is no ***plain module for it yet, or they ask for a new",
8
+ "project, start it yourself, without asking how. How soon the first pulse starts, and something they can try, come first:",
19
9
  "1. The stack: the one they named; otherwise one of the standard library's that this machine can build - common,",
20
10
  " simple - said in a line. A stack named in a language the machine lacks is written all the same: tell them what to",
21
11
  " install, and that turri doctor checks it. Read the stack's template, its language's and its kind's common first.",
@@ -21,6 +21,7 @@ export interface PlainFileParseResult {
21
21
  }
22
22
  export declare function getFilenameFromModuleName(moduleName: string): string;
23
23
  export declare function getModuleNameFromFilename(filename: string): string;
24
+ export declare function hasScheme(target: string): boolean;
24
25
  /** The links written in an item - what each says and where it points - as the parser reads them, checked or not. */
25
26
  export declare function linksIn(section: AnyNode): Array<{
26
27
  text: string;
@@ -27,7 +27,7 @@ export function getFilenameFromModuleName(moduleName) {
27
27
  export function getModuleNameFromFilename(filename) {
28
28
  return filename.split(PLAIN_SOURCE_FILE_EXTENSION).join("");
29
29
  }
30
- function hasScheme(target) {
30
+ export function hasScheme(target) {
31
31
  return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(target);
32
32
  }
33
33
  /**
@@ -0,0 +1,21 @@
1
+ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * The project's ***plain and what it is built with, and a digest of each: its .plain files, the resources they link,
4
+ * its testing scripts and its config.turri*.yaml. Nothing else is a skill's to change - not the built code, a release,
5
+ * what a running app writes. `also`: paths that were among them before, digested while they are there, so one a skill
6
+ * stopped linking or naming is still seen if it changed it.
7
+ */
8
+ export declare function projectFiles(root: string, also?: Iterable<string>): Map<string, string>;
9
+ /** The files added, changed or removed between two digests of the project. */
10
+ export declare function changedFiles(before: Map<string, string>, after: Map<string, string>): string[];
11
+ /**
12
+ * Why a skill's agent may not write a path, or nothing when it may. It writes the project's ***plain: a file of it
13
+ * (`projectFiles`), or a new one - a resource it is about to link, a testing script - anywhere in the project but where
14
+ * what is built, released or installed lives. Never an existing file that is not the ***plain's: the code, a release,
15
+ * what an app it started writes.
16
+ */
17
+ export declare function plainWriteRefusal(root: string, path: string): string | undefined;
18
+ type AnyTool = ToolDefinition<any, any, any>;
19
+ /** pi's write and edit for a skill's agent: each path checked by `plainWriteRefusal` - as given, and as the tool resolved it. */
20
+ export declare function plainWriteTools(root: string): AnyTool[];
21
+ export {};
@@ -0,0 +1,156 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { createEditToolDefinition, createWriteToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { readProjectConfig, stacksOf } from "./config.js";
6
+ import { parseMarkdown } from "./plain/markdown.js";
7
+ import { hasScheme, linksIn } from "./plain/parser.js";
8
+ import { insideAny } from "./regenerate/confine.js";
9
+ import { credentialRefusal, isCredentialFile } from "./regenerate/credentials.js";
10
+ import { DIST_DIR, MODULES_DIR } from "./regenerate/paths.js";
11
+ import { PREPARE_NAME, SCRIPTS_DIR, scriptKind } from "./regenerate/test-scripts.js";
12
+ /** A config.turri.yaml, a stack's config.turri.<stack>.yaml - what the project's testing scripts are named in. */
13
+ const TURRI_CONFIG_NAME = /^config\.turri(\.[^/]+)?\.yaml$/;
14
+ /** Folders that hold what is built, installed or turri's own - never the project's ***plain. */
15
+ function outsidePlain(path) {
16
+ const parts = path.split(sep);
17
+ return parts[0] === DIST_DIR || parts.some((part) => part.startsWith(".") || part === MODULES_DIR || part === "node_modules" || part === "__pycache__");
18
+ }
19
+ /**
20
+ * The project's ***plain and what it is built with, and a digest of each: its .plain files, the resources they link,
21
+ * its testing scripts and its config.turri*.yaml. Nothing else is a skill's to change - not the built code, a release,
22
+ * what a running app writes. `also`: paths that were among them before, digested while they are there, so one a skill
23
+ * stopped linking or naming is still seen if it changed it.
24
+ */
25
+ export function projectFiles(root, also = []) {
26
+ const all = [];
27
+ const walk = (dir, depth) => {
28
+ if (depth > 8)
29
+ return;
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const entry of entries) {
38
+ if (entry.name.startsWith(".") || entry.name === MODULES_DIR || entry.name === "node_modules" || entry.name === "__pycache__")
39
+ continue;
40
+ const path = join(dir, entry.name);
41
+ if (entry.isDirectory())
42
+ walk(path, depth + 1);
43
+ else if (entry.isFile())
44
+ all.push(relative(root, path));
45
+ }
46
+ };
47
+ walk(root, 0);
48
+ const wanted = new Set(also);
49
+ const named = (path) => {
50
+ if (path !== undefined && path.trim() !== "")
51
+ wanted.add(relative(root, resolve(root, path.trim())));
52
+ };
53
+ for (const path of all) {
54
+ const name = basename(path);
55
+ if (path.split(sep)[0] === SCRIPTS_DIR || scriptKind(name) !== undefined || PREPARE_NAME.test(name) || TURRI_CONFIG_NAME.test(name))
56
+ wanted.add(path);
57
+ if (!path.endsWith(".plain"))
58
+ continue;
59
+ wanted.add(path);
60
+ // The resources it links, as the parser reads its links: from the project's folder.
61
+ try {
62
+ for (const { target } of linksIn(parseMarkdown(readFileSync(join(root, path), "utf8")))) {
63
+ if (target !== "" && !hasScheme(target) && !isAbsolute(target))
64
+ named(target);
65
+ }
66
+ }
67
+ catch {
68
+ // A file that cannot be read links nothing.
69
+ }
70
+ }
71
+ // Scripts config.turri*.yaml names, whatever they are called.
72
+ for (const stack of [undefined, ...stacksOf(root)]) {
73
+ const config = readProjectConfig(root, stack);
74
+ named(config.unittestsScript);
75
+ named(config.conformanceTestsScript);
76
+ named(config.prepareEnvironmentScript);
77
+ }
78
+ const files = new Map();
79
+ for (const path of wanted) {
80
+ if (path.startsWith("..") || isAbsolute(path) || path.split(sep).includes(MODULES_DIR))
81
+ continue;
82
+ try {
83
+ files.set(path, createHash("sha1").update(readFileSync(join(root, path))).digest("hex"));
84
+ }
85
+ catch {
86
+ // A file that cannot be read, or is not there, is left out.
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+ /** The files added, changed or removed between two digests of the project. */
92
+ export function changedFiles(before, after) {
93
+ const changed = [...after].filter(([path, digest]) => before.get(path) !== digest).map(([path]) => path);
94
+ const removed = [...before.keys()].filter((path) => !after.has(path)).map((path) => `${path} (removed)`);
95
+ return [...changed, ...removed].sort();
96
+ }
97
+ /**
98
+ * Why a skill's agent may not write a path, or nothing when it may. It writes the project's ***plain: a file of it
99
+ * (`projectFiles`), or a new one - a resource it is about to link, a testing script - anywhere in the project but where
100
+ * what is built, released or installed lives. Never an existing file that is not the ***plain's: the code, a release,
101
+ * what an app it started writes.
102
+ */
103
+ export function plainWriteRefusal(root, path) {
104
+ const absolute = resolve(root, path);
105
+ if (!insideAny(absolute, [root]))
106
+ return `${absolute} is outside the project. A skill writes the project's ***plain, and nothing else.`;
107
+ if (isCredentialFile(absolute))
108
+ return credentialRefusal(absolute);
109
+ const inProject = relative(resolve(root), absolute);
110
+ if (outsidePlain(inProject)) {
111
+ return (`${inProject} is not the project's ***plain - ${MODULES_DIR}/, ${DIST_DIR}/ and the hidden folders hold what is ` +
112
+ "built, released and installed, and turri's own. A skill writes .plain files, the resources they link and the testing scripts.");
113
+ }
114
+ if (existsSync(absolute) && !projectFiles(root).has(inProject)) {
115
+ return (`${inProject} is not the project's ***plain: no .plain file links it, and it is not a testing script or a ` +
116
+ "config.turri*.yaml. A skill changes only those - link a resource from the ***plain before changing it.");
117
+ }
118
+ return undefined;
119
+ }
120
+ /** pi's write and edit for a skill's agent: each path checked by `plainWriteRefusal` - as given, and as the tool resolved it. */
121
+ export function plainWriteTools(root) {
122
+ const check = (path) => {
123
+ if (path === undefined)
124
+ return;
125
+ const refusal = plainWriteRefusal(root, path.trim().replace(/^@/, ""));
126
+ if (refusal !== undefined)
127
+ throw new Error(refusal);
128
+ };
129
+ const guard = (def) => ({
130
+ ...def,
131
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
132
+ check(params.path);
133
+ return def.execute(toolCallId, params, signal, onUpdate, ctx);
134
+ },
135
+ });
136
+ return [
137
+ guard(createWriteToolDefinition(root, {
138
+ operations: {
139
+ writeFile: async (path, content) => (check(path), writeFileSync(path, content)),
140
+ // A folder that is there already is not written: only where it would be made is checked.
141
+ mkdir: async (dir) => {
142
+ if (!existsSync(dir))
143
+ check(dir);
144
+ mkdirSync(dir, { recursive: true });
145
+ },
146
+ },
147
+ })),
148
+ guard(createEditToolDefinition(root, {
149
+ operations: {
150
+ readFile: async (path) => (check(path), readFileSync(path)),
151
+ writeFile: async (path, content) => (check(path), writeFileSync(path, content)),
152
+ access: async (path) => check(path),
153
+ },
154
+ })),
155
+ ];
156
+ }
@@ -21,12 +21,15 @@ export interface DiffPaint {
21
21
  export declare const PLAIN_PAINT: DiffPaint;
22
22
  /**
23
23
  * Where the lines of a diff are in the .plain files: by a line's place in the diff, the file it is in and its
24
- * line there. Only lines that are in a file - added and unchanged ones; a removed line is in none now.
24
+ * line there. Only lines that are in a file - added and unchanged ones; a removed line is in none now. `written`
25
+ * is the line as the file has it - its links as written, not as the diff reads them - so a host can find it
26
+ * again in the file's text once it is edited; absent in a ledger written before it.
25
27
  */
26
28
  export type DiffWhere = Record<number, {
27
29
  file: string;
28
30
  line: number;
29
31
  section?: string;
32
+ written?: string;
30
33
  }>;
31
34
  /**
32
35
  * Place a spec diff's lines in the .plain files they come from - `files`, the module's and every one it
@@ -25,7 +25,10 @@ function asRead(line) {
25
25
  export function locateDiffLines(diff, files) {
26
26
  const where = {};
27
27
  // Each line as the parser renders it: a link `[text](path)` is the span `` `[resource]text` `` in the spec as read.
28
- const texts = files.map((file) => ({ name: file.name, lines: file.text.split("\n").map((line) => asRead(line.trimEnd())) }));
28
+ const texts = files.map((file) => {
29
+ const written = file.text.split("\n").map((line) => line.trimEnd());
30
+ return { name: file.name, written, lines: written.map(asRead) };
31
+ });
29
32
  // The section each line of a file is in: the nearest `***...***` header above it, or on it.
30
33
  const sections = texts.map((file) => {
31
34
  let current;
@@ -36,7 +39,7 @@ export function locateDiffLines(diff, files) {
36
39
  const take = (file, line, index) => {
37
40
  used.add(`${file}:${line}`);
38
41
  const section = sections[file][line];
39
- where[index] = { file: texts[file].name, line: line + 1, ...(section !== undefined ? { section } : {}) };
42
+ where[index] = { file: texts[file].name, line: line + 1, ...(section !== undefined ? { section } : {}), written: texts[file].written[line] };
40
43
  last = { file, line };
41
44
  };
42
45
  diff
@@ -54,7 +57,7 @@ export function locateDiffLines(diff, files) {
54
57
  if (file !== -1) {
55
58
  const at = texts[file].lines.indexOf(removed);
56
59
  const section = sections[file][at];
57
- where[index] = { file: texts[file].name, line: at + 1, ...(section !== undefined ? { section } : {}) };
60
+ where[index] = { file: texts[file].name, line: at + 1, ...(section !== undefined ? { section } : {}), written: texts[file].written[at] };
58
61
  }
59
62
  return;
60
63
  }
@@ -42,7 +42,7 @@ export function describeStop(facts) {
42
42
  why = `after ${tries} attempt${tries === 1 ? "" : "s"} it still could not be built to pass its own unit tests.`;
43
43
  todo = [
44
44
  "The functional spec is most likely too big for one functionality, or leaves a choice open that keeps coming " +
45
- "out wrong. Split it into smaller functional specs (`analyze-if-func-spec-too-complex`, `break-down-func-spec`), " +
45
+ "out wrong. Split it into smaller functional specs (`break-down-func-spec`), " +
46
46
  "or say exactly what it should do.",
47
47
  "If it needs something the stack does not name - a library, a tool, a service - add it to the implementation reqs.",
48
48
  "If its unit tests have to be run in a particular way, say how in the implementation reqs.",
@@ -49,6 +49,8 @@ export interface TestScripts {
49
49
  */
50
50
  export declare function withoutConformance(scripts: TestScripts): TestScripts;
51
51
  export declare function scriptKind(name: string): ScriptKind | undefined;
52
+ /** The name the prepare script has: `prepare_environment_<lang>.sh`. */
53
+ export declare const PREPARE_NAME: RegExp;
52
54
  export declare const SCRIPTS_DIR = "test_scripts";
53
55
  /**
54
56
  * The project a build belongs to: the folder that holds its plain_modules. A build folder
@@ -30,7 +30,7 @@ export function scriptKind(name) {
30
30
  return Object.keys(NAMES).find((kind) => NAMES[kind].test(basename(name)));
31
31
  }
32
32
  /** The name the prepare script has: `prepare_environment_<lang>.sh`. */
33
- const PREPARE_NAME = /^prepare_environment[^/]*\.sh$/i;
33
+ export const PREPARE_NAME = /^prepare_environment[^/]*\.sh$/i;
34
34
  function isFile(path) {
35
35
  try {
36
36
  return statSync(path).isFile();
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * A skill's run as one line above the input: every phase by name, in order - the ones done ticked, the one it is in
3
- * marked, the rest to come - and where that is, phase 2 of 4. `brood` and `spawn` take many turns of
4
- * questions and spec changes; without it, nothing on the screen says how far through they are.
3
+ * marked, the rest to come - and where that is, phase 2 of 4. A skill of many phases takes many turns of
4
+ * questions and ***plain changes; without it, nothing on the screen says how far through it is.
5
5
  *
6
- * brood ✓ intent ━━━━ ⠋ stack ──── ○ testing ──── ○ product phase 2 of 4
6
+ * onboard ✓ intent ━━━━ ⠋ stack ──── ○ product phase 2 of 3
7
7
  * stack - the language, frameworks, storage and architecture
8
8
  *
9
- * The second line says what the phase it is in does: a phase's name alone - `skeleton` - says little to someone
9
+ * The second line says what the phase it is in does: a phase's name alone - `stack` - says little to someone
10
10
  * who has not read the skill.
11
11
  *
12
12
  * A skill of one phase, or none, has nothing to be partway through: its line is the spinner and its name alone.
package/dist/skill.d.ts CHANGED
@@ -86,7 +86,7 @@ export interface Skill {
86
86
  */
87
87
  diffPerAnswer: string[];
88
88
  /**
89
- * Started only by the person, with its slash command - `/brood` - never by the model on its own
89
+ * Started only by the person, with its slash command - `/release-notes` - never by the model on its own
90
90
  * (`metadata.command-only: true`): cultivate runs it only when the person has just typed its command.
91
91
  */
92
92
  commandOnly: boolean;