@turri-dev/cli 0.1.7-next.f0d55df → 0.1.8-next.bcca62a

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 (50) hide show
  1. package/README.md +5 -5
  2. package/dist/btw.d.ts +2 -0
  3. package/dist/btw.js +2 -2
  4. package/dist/extension.d.ts +11 -0
  5. package/dist/extension.js +43 -5
  6. package/dist/identity.d.ts +6 -1
  7. package/dist/identity.js +33 -7
  8. package/dist/loop-tools.js +3 -1
  9. package/dist/regenerate/cases.js +3 -0
  10. package/dist/regenerate/conformance-agents.js +2 -2
  11. package/dist/regenerate/conformance.js +3 -3
  12. package/dist/regenerate/environment-error.d.ts +6 -0
  13. package/dist/regenerate/environment-error.js +19 -1
  14. package/dist/regenerate/jobs.d.ts +9 -0
  15. package/dist/regenerate/jobs.js +11 -0
  16. package/dist/regenerate/lineage.d.ts +9 -1
  17. package/dist/regenerate/lineage.js +47 -12
  18. package/dist/regenerate/loop.d.ts +6 -0
  19. package/dist/regenerate/loop.js +26 -5
  20. package/dist/regenerate/records.d.ts +5 -3
  21. package/dist/regenerate/records.js +6 -4
  22. package/dist/regenerate/regenerate.js +3 -3
  23. package/dist/regenerate/regression.js +2 -2
  24. package/dist/regenerate/spec-map.js +8 -5
  25. package/dist/run-report.d.ts +1 -1
  26. package/dist/run-report.js +6 -0
  27. package/dist/self-invocation.d.ts +1 -1
  28. package/dist/self-invocation.js +3 -2
  29. package/dist/skill-progress.d.ts +1 -1
  30. package/dist/skill.d.ts +5 -0
  31. package/dist/skill.js +1 -0
  32. package/dist/skills.d.ts +14 -0
  33. package/dist/skills.js +19 -1
  34. package/dist/surface.d.ts +5 -0
  35. package/dist/surface.js +18 -0
  36. package/docs/authoring-plain.md +2 -2
  37. package/docs/config.md +1 -1
  38. package/docs/headless.md +4 -0
  39. package/docs/hosting.md +17 -0
  40. package/docs/regenerating.md +4 -4
  41. package/docs/skills-and-gates.md +3 -1
  42. package/docs/using.md +6 -4
  43. package/genome/rules/func-specs.md +1 -0
  44. package/genome/rules/turri-regeneration.md +3 -0
  45. package/genome/skills/add-functional-spec/SKILL.md +12 -3
  46. package/genome/skills/add-functional-specs/SKILL.md +12 -2
  47. package/genome/skills/brood/SKILL.md +2 -0
  48. package/genome/skills/bud/SKILL.md +2 -0
  49. package/genome/skills/spawn/SKILL.md +47 -35
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -49,7 +49,7 @@ turri init-config --vendor anthropic
49
49
 
50
50
  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).
51
51
 
52
- Once `turri` is running, say: *Let's start a new project: a small API where people can register and sign in.*
52
+ 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
53
 
54
54
  No project of your own yet? Build one of [**the examples**](examples/README.md) with turri, step by step.
55
55
 
@@ -146,11 +146,11 @@ cd ~/my-project
146
146
  turri
147
147
  ```
148
148
 
149
- Then talk to it:
149
+ Then start the interview:
150
150
 
151
- > Let's start a new project: a small API where people can register and sign in.
151
+ > /brood a small API where people can register and sign in
152
152
 
153
- It runs the interview skill, asks you about the problem, the stack and the product, writes \*\*\*plain, and builds each functionality as it is written. While it works:
153
+ 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
154
 
155
155
  - the **panel** above the input shows every build - its stage, what its agents are doing, its model and cost - and the queue behind it;
156
156
  - 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;
@@ -188,7 +188,7 @@ Start at the [documentation index](docs/README.md), or go straight to a page:
188
188
  | | |
189
189
  | --- | --- |
190
190
  | [Installation](docs/installation.md) | doctor, what turri needs of this machine, **turri** and `pi`, environment variables |
191
- | [Examples](examples/README.md) | projects to build with turri step by step: cognio from an empty folder, guestbook - a backend and a frontend in minutes - a CLI, a backend service with conformance tests, a REST API integration |
191
+ | [Examples](examples/README.md) | projects to build with turri step by step: guestbook - a backend and a frontend in minutes - a CLI, and a backend service with conformance tests |
192
192
  | [Using **turri**](docs/using.md) | the conversation, skills, tools, pulses and regen, choosing models, the command line |
193
193
  | [The project config](docs/config.md) | `config.turri.yaml` and each stack's `config.turri.<stack>.yaml`: every key, models and their inheritance, conformance, the testing scripts, stacks and choosing one, the old `config.yaml` |
194
194
  | [Deploy](docs/deploy.md) | trying what is built: the last commit in `dist/<module>`, started on your machine, nothing installed without asking |
package/dist/btw.d.ts CHANGED
@@ -53,6 +53,8 @@ export interface SideQuestion {
53
53
  projectDir: string;
54
54
  /** turri's own folder: its source, skills, rules and docs. */
55
55
  harnessDir: string;
56
+ /** A host's surface, in its own words (TurriHost.surface): where the person watches the work instead of the terminal. */
57
+ surface?: string;
56
58
  }
57
59
  export declare function sideQuestionPrompt(side: SideQuestion): string;
58
60
  /** Answers a side question with an agent of its own, read only, on `model`. */
package/dist/btw.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { confinedTools } from "./regenerate/confine.js";
15
- import { TURRI_IDENTITY } from "./identity.js";
15
+ import { conversationIdentity } from "./identity.js";
16
16
  import { ask, BEGIN, createExecutor } from "./pi.js";
17
17
  /** How many tool calls the side agent may make before it is told to answer. */
18
18
  export const SIDE_CALLS = 20;
@@ -85,7 +85,7 @@ export function skillSessionText(runDir, maxChars = SKILL_SESSION_CHARS) {
85
85
  }
86
86
  export function sideQuestionPrompt(side) {
87
87
  return [
88
- TURRI_IDENTITY,
88
+ conversationIdentity(side.surface),
89
89
  "",
90
90
  "The person is in the middle of something with you - a skill, a pulse - and has asked you a question on the side",
91
91
  "with /btw. Answer it as turri: in the first person, to them, in whole sentences - briefly and plainly, from what is",
@@ -41,6 +41,17 @@ export interface TurriHost {
41
41
  onPlanChange?(): void;
42
42
  /** The dialogs ask_user puts its questions in - the conversation's and a skill's interview - instead of the terminal's. */
43
43
  dialogs?: AskUserIO;
44
+ /**
45
+ * The host's own UI, in its own words: the views it has in place of the terminal's, what its marks and buttons mean,
46
+ * where questions appear. Given, turri tells the model none of the terminal's - its keys, the side panel, regen,
47
+ * /pulses - and gives it this instead, as a `<surface>` block in turri's identity. Absent, the terminal is the surface.
48
+ */
49
+ surface?: string;
50
+ /**
51
+ * The last word on the whole system prompt, as turri has built it for this turn - after its own before_agent_start
52
+ * work: what it returns is what is sent. For a host that needs to change anything else.
53
+ */
54
+ systemPrompt?(prompt: string): string;
44
55
  }
45
56
  /** turri's conversation as an extension of pi's, for a host that runs it itself: see TurriHost. */
46
57
  export declare function createTurriExtension(host?: TurriHost): (pi: ExtensionAPI) => void;
package/dist/extension.js CHANGED
@@ -53,13 +53,14 @@ import { describeStdlib } from "./stdlib.js";
53
53
  import { peekKeyListener, runAsk } from "./ask.js";
54
54
  import { QuestionDialog } from "./question-dialog.js";
55
55
  import { registerStacks } from "./stacks.js";
56
- import { catalogueLine, discoverSkills, leftOutNote } from "./skills.js";
56
+ import { catalogueLine, createCommandGate, discoverSkills, leftOutNote, startedByCommandOnly } from "./skills.js";
57
57
  import { supersededByTurri } from "./chat-skills.js";
58
- import { TURRI_IDENTITY } from "./identity.js";
58
+ import { conversationIdentity } from "./identity.js";
59
+ import { setSurface, surfaceOf } from "./surface.js";
59
60
  import { turriIdentity } from "./environment.js";
60
61
  import { conformanceChooser } from "./regenerate/choose-conformance.js";
61
62
  import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
62
- import { createJobRegistry, defaultParallelJobs, describeJob, formatDuration, pulseName, shouldWait, STOPPED_ON_REQUEST, } from "./regenerate/jobs.js";
63
+ import { createJobRegistry, defaultParallelJobs, describeJob, firstRegeneration, formatDuration, formatElapsed, pulseName, shouldWait, STOPPED_ON_REQUEST, } from "./regenerate/jobs.js";
63
64
  /**
64
65
  * Makes gated skills reachable from a conversation.
65
66
  *
@@ -180,6 +181,14 @@ export function chatAskUserTool(dialogs) {
180
181
  },
181
182
  };
182
183
  }
184
+ /**
185
+ * The conversation's system prompt for a turn: pi's, turri's identity - with the host's surface in place of the
186
+ * terminal's, when it has one - and turri's blocks; then the host's last word on it.
187
+ */
188
+ function conversationSystemPrompt(base, host, blocks) {
189
+ const prompt = [base, conversationIdentity(host.surface), ...blocks].filter((part) => part !== "").join("\n\n");
190
+ return host.systemPrompt !== undefined ? host.systemPrompt(prompt) : prompt;
191
+ }
183
192
  /** The host each conversation's jobs report to, for the work loop made for them later. */
184
193
  const hosts = new WeakMap();
185
194
  /** turri's conversation as an extension of pi's, for a host that runs it itself: see TurriHost. */
@@ -260,6 +269,8 @@ function turriExtension(pi, host) {
260
269
  },
261
270
  });
262
271
  hosts.set(jobs, host);
272
+ // What the model is told of where the person watches the work, deep in the tools too: the host's surface, or the terminal.
273
+ setSurface(jobs, host.surface);
263
274
  host.onJobs?.(jobs);
264
275
  // The ***plain rules go into the system prompt in full, every turn. The block is
265
276
  // identical each time, so it sits inside the cached prefix rather than invalidating it.
@@ -298,7 +309,7 @@ function turriExtension(pi, host) {
298
309
  }
299
310
  }
300
311
  return {
301
- systemPrompt: [event.systemPrompt, TURRI_IDENTITY, TOOLS_NOT_CLI, projectConfigBlock(cwd), machineBlock(cwd, machine.now()), rulesBlock()].filter((part) => part !== "").join("\n\n"),
312
+ systemPrompt: conversationSystemPrompt(event.systemPrompt, host, [TOOLS_NOT_CLI, projectConfigBlock(cwd), machineBlock(cwd, machine.now()), rulesBlock()]),
302
313
  };
303
314
  });
304
315
  // The conversation's agent does not run turri again through its shell, unless the person
@@ -314,7 +325,7 @@ function turriExtension(pi, host) {
314
325
  if (event.toolName === "bash" && typeof command === "string") {
315
326
  const what = selfInvocation(command);
316
327
  if (what !== undefined && !askedForCommandLine(lastUserMessage))
317
- return { block: true, reason: refusal(what) };
328
+ return { block: true, reason: refusal(what, surfaceOf(jobs)) };
318
329
  }
319
330
  markConversationWrite(event.toolCallId, event.toolName, event.input);
320
331
  return undefined;
@@ -416,6 +427,19 @@ function turriExtension(pi, host) {
416
427
  return;
417
428
  const catalogue = skills.map(catalogueLine).join("\n");
418
429
  const pendingNote = leftOutNote(discoveredSkills);
430
+ // The skills the person starts themselves - brood, spawn, bud: each has its command, and cultivate starts it only
431
+ // once its command is typed. The command puts what was typed in the chat, as the person's message, for the model to
432
+ // run it with what follows as its context.
433
+ const gate = createCommandGate();
434
+ for (const skill of skills.filter((s) => s.commandOnly)) {
435
+ pi.registerCommand(skill.name, {
436
+ description: `Start ${skill.name}${skill.steps.length > 1 ? ` - ${skill.steps.map((step) => step.name).join(" → ")}` : ""}: /${skill.name} [what you want]`,
437
+ handler: async (args) => {
438
+ gate.typed(skill.name);
439
+ pi.sendUserMessage(`/${skill.name}${args.trim() !== "" ? ` ${args.trim()}` : ""}`, { deliverAs: "followUp" });
440
+ },
441
+ });
442
+ }
419
443
  pi.registerTool({
420
444
  name: "cultivate",
421
445
  label: "Cultivate",
@@ -426,6 +450,7 @@ function turriExtension(pi, host) {
426
450
  promptSnippet: "cultivate: run a gated skill - its steps and review gates enforced by turri",
427
451
  promptGuidelines: [
428
452
  "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.",
453
+ "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, so call cultivate for it at once, with what follows the command as context. Never call cultivate for one otherwise; tell them to type its command.",
429
454
  // pi lists .agents/skills among its own skills too: read and followed by hand, a skill's gates would be skipped.
430
455
  "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.",
431
456
  "cultivate returns the reviewed artifact and a trace of every gate decision. Report the artifact to the user; do not rewrite it.",
@@ -458,6 +483,8 @@ function turriExtension(pi, host) {
458
483
  details: {},
459
484
  };
460
485
  }
486
+ if (!gate.mayStart(skill))
487
+ return { content: [{ type: "text", text: startedByCommandOnly(skill.name) }], details: { notStarted: true } };
461
488
  // A new run clears the run folder. An interrupted one is not thrown away by accident:
462
489
  // whether to pick it up or start over is the user's call.
463
490
  const runDir = skillRunDir(cwd, skill.name);
@@ -475,6 +502,8 @@ function turriExtension(pi, host) {
475
502
  details: { interrupted },
476
503
  };
477
504
  }
505
+ // Its command is used up: started again later, it is typed again.
506
+ gate.started(skill.name);
478
507
  const trace = [`skill: ${skill.name}`, ...describeGates(skill).map((l) => ` ${l}`)];
479
508
  // While it runs, what its agent is doing leads: pi shows a running tool's first lines, and the trace's first
480
509
  // lines are the gates, which say nothing about now. The result keeps the trace alone.
@@ -560,6 +589,8 @@ function turriExtension(pi, host) {
560
589
  const filesBefore = projectFiles(cwd);
561
590
  // A side question asked while it runs is told what it has done so far.
562
591
  const ended = skillRunStarted({ skill: skill.name, dir: skill.dir, step: () => live.step, trace: () => [...trace], runDir });
592
+ // From here the time to first regeneration is counted, for a skill that writes spec diffs: its first pulse started.
593
+ const began = Date.now();
563
594
  const { result, output: captured } = await captureConsole(() => runSkill({
564
595
  skillDir: skill.dir,
565
596
  cwd,
@@ -620,11 +651,17 @@ function turriExtension(pi, host) {
620
651
  ? `${specsChanged.join(", ")} changed, and nothing is regenerated from that yet: call regenerate for it, and the code is brought up to the spec.`
621
652
  : "";
622
653
  const gated = skill.steps.some((step) => step.gate !== undefined);
654
+ const first = skill.allowedTools.includes("write_spec_diff") ? firstRegeneration(jobs.list(), began) : {};
655
+ const ttfr = first.started === undefined
656
+ ? ""
657
+ : `Time to first regeneration: ${formatElapsed(first.started)} - its first pulse started that long after ${skill.name} began` +
658
+ (first.landed !== undefined ? `, and landed after ${formatElapsed(first.landed)}.` : ".");
623
659
  const text = result.ok
624
660
  ? [
625
661
  gated ? `${skill.name}: passed all gates.` : `${skill.name}: finished. It has no review gates, so nothing checked its work: read what it changed before you report it.`,
626
662
  changed,
627
663
  ...(unrendered !== "" ? [unrendered] : []),
664
+ ...(ttfr !== "" ? [ttfr] : []),
628
665
  "",
629
666
  "<artifact>",
630
667
  result.output,
@@ -2083,6 +2120,7 @@ async function askOnTheSide(pi, ctx, cwd, jobs, question) {
2083
2120
  pulses: jobs.list().map((job) => `${job.id} ${job.status}: ${pulseName(job)}`),
2084
2121
  projectDir: cwd,
2085
2122
  harnessDir: PACKAGE_ROOT,
2123
+ ...(surfaceOf(jobs) !== undefined ? { surface: surfaceOf(jobs) } : {}),
2086
2124
  }, chatModelOf(ctx));
2087
2125
  try {
2088
2126
  pi.appendEntry(SIDE_ENTRY, { question, answer });
@@ -1,2 +1,7 @@
1
- /** Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. */
1
+ /**
2
+ * Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. Where
3
+ * the person watches the work is the terminal's regen; given a host's surface, that is said in its place.
4
+ */
5
+ export declare function conversationIdentity(surface?: string): string;
6
+ /** turri as the terminal runs it. */
2
7
  export declare const TURRI_IDENTITY: string;
package/dist/identity.js CHANGED
@@ -1,5 +1,31 @@
1
- /** Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. */
2
- export const TURRI_IDENTITY = [
1
+ /** In the terminal: regen, where the person watches the work - the side panel, its key, and the pulses behind it. */
2
+ const REGEN = [
3
+ "- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
4
+ " planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
5
+ " lowercase. It was the regeneration, the job queue, or the job panel.",
6
+ ];
7
+ /** In a host: where the person watches the work is the host's to say - its surface, below. */
8
+ const HOST_SURFACE = [
9
+ "- What the person sees of your work - where they watch it, and what they press - is the app's you run in, and",
10
+ " <surface> below says it in its own words. Say only what it says, and speak of no key, panel or view it does not",
11
+ " name.",
12
+ ];
13
+ /**
14
+ * Who the conversation's agent is: turri, and what it does - first, so it speaks as turri, in the first person. Where
15
+ * the person watches the work is the terminal's regen; given a host's surface, that is said in its place.
16
+ */
17
+ export function conversationIdentity(surface) {
18
+ const text = surface?.trim() ?? "";
19
+ const hosted = text !== "";
20
+ return [
21
+ ...IDENTITY_BEFORE,
22
+ ...(hosted ? HOST_SURFACE : REGEN),
23
+ ...IDENTITY_AFTER,
24
+ ...(hosted ? ["", "<surface>", text, "</surface>"] : []),
25
+ "</turri>",
26
+ ].join("\n");
27
+ }
28
+ const IDENTITY_BEFORE = [
3
29
  "<turri>",
4
30
  "You are turri - always lowercase - a coding agent with the power of regenerative software. The software is its",
5
31
  "***plain; the code is regenerated from it. You help the person write ***plain, and you regenerate the code from it a",
@@ -25,11 +51,11 @@ export const TURRI_IDENTITY = [
25
51
  "- A pulse is one unit of work regenerating the code - a spec diff regenerated in a codebase, or its conformance",
26
52
  " tests. It was called a job, and still is in places: the job_id a tool takes is a pulse's id, and parallel-jobs and",
27
53
  " allow-to-stop-jobs in config.turri.yaml are about pulses. \"Job\" in what you read is a pulse; say pulse.",
28
- "- regen is the side panel (ctrl+h): the spec with each functionality and item marked built, regenerating,",
29
- " planned or waiting - and behind it the pulses (/pulses), every pulse regenerating, planned and finished. Say regen,",
30
- " lowercase. It was the regeneration, the job queue, or the job panel.",
54
+ ];
55
+ const IDENTITY_AFTER = [
31
56
  "- mutate, in a skill's activity, is write_spec_diff: a spec change written and regenerated in one step.",
32
57
  "- Regenerate is the word for what you do to the code: never grow, grew or grown - older text, a tool's description",
33
58
  " or a report may still say it, and means regenerate.",
34
- "</turri>",
35
- ].join("\n");
59
+ ];
60
+ /** turri as the terminal runs it. */
61
+ export const TURRI_IDENTITY = conversationIdentity();
@@ -9,6 +9,7 @@ import { pulseName } from "./regenerate/jobs.js";
9
9
  import { stopDeployed } from "./regenerate/release.js";
10
10
  import { projectDirOf } from "./regenerate/paths.js";
11
11
  import { findTestScripts } from "./regenerate/test-scripts.js";
12
+ import { surfaceOf } from "./surface.js";
12
13
  /** How the queue is to be planned, for the work this call queues - see regenerate/steer.ts. */
13
14
  export const GUIDANCE_PARAM = Type.Optional(Type.String({
14
15
  description: "How to plan the queue for this work, in a sentence or two - which functional specs go together, what first - as " +
@@ -114,8 +115,9 @@ export function stopJob(jobs, named, reason, loop) {
114
115
  if (module !== undefined)
115
116
  return module;
116
117
  const matches = jobs.list().filter((job) => job.id.startsWith(named.trim()));
118
+ // /pulses is the terminal's: in a host, the pulses tool lists them.
117
119
  if (matches.length === 0)
118
- return `No pulse ${named}. /pulses shows them.`;
120
+ return `No pulse ${named}. ${surfaceOf(jobs) !== undefined ? "The pulses tool lists them." : "/pulses shows them."}`;
119
121
  if (matches.length > 1)
120
122
  return `'${named}' matches ${matches.map((job) => job.id).join(", ")}. Give more of the id.`;
121
123
  const target = matches[0];
@@ -64,6 +64,9 @@ export function conformanceCasesBlock(cases) {
64
64
  if (cases.patch.length > 0) {
65
65
  lines.push(`- Changed - its text, its acceptance tests, or a concept it uses: ${named(cases.patch)}.`, " Its conformance tests exist: rewrite them to what it says now. The tests of everything else stay as they are.");
66
66
  }
67
+ if (cases.again.length > 0) {
68
+ lines.push(`- Regenerated again - its code was built anew, and its conformance tests were written against the code it replaced: ${named(cases.again)}.`, " Its text did not change, but the code did. Run its tests against the new code. What they check that the", " specification says stays checked: rewrite such a test only as the new code needs it reached. What they check", " that the specification leaves open - a path, a field, a message, a format the old code chose and the", " specification never names - is the new code's choice now: rewrite those checks to what the new code does,", " or take them out. No test may hold the new code to what only the old code did. The tests of everything else", " stay as they are.");
69
+ }
67
70
  if (cases.delete.length > 0)
68
71
  lines.push(`- Taken out - remove its conformance tests: ${named(cases.delete)}.`);
69
72
  if (cases.mechanical) {
@@ -316,9 +316,9 @@ export function createAskExplorerTool(options) {
316
316
  return defineTool({
317
317
  name: "ask_explorer",
318
318
  label: "Ask the explorer",
319
- description: "Ask the explorer - who read and ran the code, and wrote the account of it you were given - something the account " +
319
+ description: "Ask the explorer - who read the code, and wrote the account of it you were given - something the account " +
320
320
  "does not say: how a functionality is reached, what a field is called, what the software needs around it. It reads " +
321
- `and runs the code to answer. ${options.questions ?? EXPLORER_QUESTIONS} questions a run: ask what reading the code yourself would take long to find.`,
321
+ `the code to answer - it runs nothing, so what only running the software shows is yours to try. ${options.questions ?? EXPLORER_QUESTIONS} questions a run: ask what reading the code yourself would take long to find.`,
322
322
  promptSnippet: "ask_explorer: a question about how the software is reached, answered by the explorer from the code",
323
323
  parameters: Type.Object({ question: Type.String({ description: "What you need to know, exactly" }) }),
324
324
  async execute(_id, params) {
@@ -5,7 +5,7 @@ import { PLAIN_SOURCE_FILE_EXTENSION, plainFileParser } from "../plain/parser.js
5
5
  import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, getFrids, getInheritedFunctionalSpecs, getSpecificationsForFrid, TEST_REQUIREMENTS, } from "../plain/spec.js";
6
6
  import { reportActivity, reportLogNote, reportSpec, reportStage, unitOf } from "./activity.js";
7
7
  import { confinedTools } from "./confine.js";
8
- import { abandonWork, diffSinceSnapshot, moduleDirOf, recordHistory, startWork, workDiff, workSnapshot } from "./lineage.js";
8
+ import { abandonWork, diffSinceSnapshot, landWork, moduleDirOf, startWork, workDiff, workSnapshot } from "./lineage.js";
9
9
  import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
10
10
  import { CONFORMANCE_COMPACTION } from "./compaction.js";
11
11
  import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
@@ -797,9 +797,9 @@ export async function renderConformanceTests(options) {
797
797
  // The tests, and the code when it changed: one commit if they share a module folder.
798
798
  const subject = `conformance ${moduleName} ${options.diff !== undefined ? "for a spec change" : `fs ${frid}`}${changed ? " (code changed)" : ""}`;
799
799
  stage(`committing the conformance tests${changed ? " and the code changes" : ""}`);
800
- recordHistory(conformanceDir, subject);
800
+ landWork(conformanceDir, subject);
801
801
  if (moduleDirOf(buildDir) !== moduleDirOf(conformanceDir))
802
- recordHistory(buildDir, subject);
802
+ landWork(buildDir, subject);
803
803
  }
804
804
  else if (start !== undefined) {
805
805
  const why = notLandedWhy(options.diff !== undefined ? "for the spec change" : `of functionality ${frid}`, unlabelled);
@@ -128,6 +128,12 @@ export declare function missingToolchainError(run: {
128
128
  script: string;
129
129
  kind: "unit" | "conformance";
130
130
  }): EnvironmentError | undefined;
131
+ /**
132
+ * Work that passed could not be committed into the module's history: the next pulse, putting the module back to its last
133
+ * commit, would undo it while turri took it as built - so the pulse does not land, and is rolled back like any other that
134
+ * did not, and the person is told why. `said` is what git said.
135
+ */
136
+ export declare function historyError(moduleDir: string, said: string): EnvironmentError;
131
137
  /**
132
138
  * Before a pulse in `moduleDir` starts any agent: a credential `agent-env:` names that is not there stops it - every
133
139
  * command that needs it would fail, and no agent may make one up. Nothing when all are there, or the folder is no
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
4
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { passedCredentials } from "./agent-env.js";
@@ -246,6 +246,24 @@ export function missingToolchainError(run, details) {
246
246
  const missing = missingToolchain(run);
247
247
  return missing !== undefined ? toolchainError(missing, details) : undefined;
248
248
  }
249
+ /**
250
+ * Work that passed could not be committed into the module's history: the next pulse, putting the module back to its last
251
+ * commit, would undo it while turri took it as built - so the pulse does not land, and is rolled back like any other that
252
+ * did not, and the person is told why. `said` is what git said.
253
+ */
254
+ export function historyError(moduleDir, said) {
255
+ const lock = /index\.lock/.test(said);
256
+ return new EnvironmentError({
257
+ moduleName: moduleNameOf(moduleDir),
258
+ missing: `a commit into the module's history, \`${join(moduleDir, ".plaingit")}\``,
259
+ neededBy: "the work that passed - without the commit, the next pulse would undo it",
260
+ shownBy: `git said: ${said.replace(/\s+/g, " ").trim().slice(0, 600)}`,
261
+ fix: lock
262
+ ? `if no git is still running on it, remove \`${join(moduleDir, ".plaingit", "index.lock")}\`, which a git stopped mid-commit left behind.`
263
+ : "fix what git said - a full disk, files it cannot read - so a commit can be made there.",
264
+ source: "harness",
265
+ });
266
+ }
249
267
  /** The module a folder is part of, by name: `plain_modules/<module>/...`. */
250
268
  function moduleNameOf(moduleDir) {
251
269
  return basename(moduleDir);
@@ -163,6 +163,15 @@ export declare function createJobRegistry(options?: {
163
163
  export declare function formatDuration(job: Job): string;
164
164
  /** "42s", "3m 12s", "1h 5m": how long something has run. */
165
165
  export declare function formatElapsed(ms: number): string;
166
+ /**
167
+ * How soon after `since` the first code pulse queued since then started - the time to first regeneration - and how soon
168
+ * the first of them landed, when one has: from then on there is something to try. In milliseconds; either is undefined
169
+ * until it has happened.
170
+ */
171
+ export declare function firstRegeneration(jobs: Job[], since: number): {
172
+ started?: number;
173
+ landed?: number;
174
+ };
166
175
  /**
167
176
  * What a pulse is called wherever it is shown in a line: its module, kind and number - `shorty · regenerate pulse 3`,
168
177
  * `shorty · conformance pulse 6` - never what it regenerates, which is the agents' words and long. A job of no module,
@@ -377,6 +377,17 @@ export function formatElapsed(ms) {
377
377
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
378
378
  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
379
379
  }
380
+ /**
381
+ * How soon after `since` the first code pulse queued since then started - the time to first regeneration - and how soon
382
+ * the first of them landed, when one has: from then on there is something to try. In milliseconds; either is undefined
383
+ * until it has happened.
384
+ */
385
+ export function firstRegeneration(jobs, since) {
386
+ const pulses = jobs.filter((job) => job.kind === "regenerate" && job.queuedAt >= since && job.status !== "queued");
387
+ const started = Math.min(...pulses.map((job) => job.startedAt));
388
+ const landed = Math.min(...pulses.filter((job) => job.status === "succeeded" && job.details?.landed === true && job.finishedAt !== undefined).map((job) => job.finishedAt));
389
+ return { ...(Number.isFinite(started) ? { started: started - since } : {}), ...(Number.isFinite(landed) ? { landed: landed - since } : {}) };
390
+ }
380
391
  /**
381
392
  * What a pulse is called wherever it is shown in a line: its module, kind and number - `shorty · regenerate pulse 3`,
382
393
  * `shorty · conformance pulse 6` - never what it regenerates, which is the agents' words and long. A job of no module,
@@ -10,7 +10,8 @@
10
10
  * A commit is made after every step that changes the module - a render, a regeneration, a
11
11
  * conformance run, a regression repair, a spec change, a start from the module below - with
12
12
  * a message saying what it was. History is a record of the work, never a gate on it: a git
13
- * failure is swallowed, not raised.
13
+ * failure is swallowed, not raised - except landing work (`landWork`), whose commit the next
14
+ * start would otherwise undo.
14
15
  */
15
16
  export declare const HISTORY_DIR = ".plaingit";
16
17
  /** The module folder a build or tests directory belongs to. */
@@ -20,6 +21,13 @@ export declare function moduleDirOf(dir: string): string;
20
21
  * directory - belongs to. Nothing changed, nothing is committed. Returns the commit, if any.
21
22
  */
22
23
  export declare function recordHistory(dir: string, subject: string, body?: string): string | undefined;
24
+ /**
25
+ * Commit work that landed - the code or tests a pulse passed, with the record of what they are built from. Unlike any
26
+ * other step's, this commit is not only a record: the next unit of work puts the module back to its last commit, and
27
+ * would undo what landed with it. So a commit that cannot be made is raised, as the environment's, with what git said -
28
+ * the work left in place, uncommitted. Nothing changed, nothing is committed. Returns the commit, if any.
29
+ */
30
+ export declare function landWork(dir: string, subject: string, body?: string): string | undefined;
23
31
  /**
24
32
  * The notes the harness writes once a pulse has ended, committed - or the next pulse, putting the module back to its last
25
33
  * commit, would take them away. Into the pulse's own commit when it landed one - `landedSince` (ms), when it started - so
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { reportActivity } from "./activity.js";
5
+ import { historyError } from "./environment-error.js";
5
6
  import { projectRootOf } from "./paths.js";
6
7
  /**
7
8
  * Version history for each module's generated code and tests.
@@ -15,7 +16,8 @@ import { projectRootOf } from "./paths.js";
15
16
  * A commit is made after every step that changes the module - a render, a regeneration, a
16
17
  * conformance run, a regression repair, a spec change, a start from the module below - with
17
18
  * a message saying what it was. History is a record of the work, never a gate on it: a git
18
- * failure is swallowed, not raised.
19
+ * failure is swallowed, not raised - except landing work (`landWork`), whose commit the next
20
+ * start would otherwise undo.
19
21
  */
20
22
  export const HISTORY_DIR = ".plaingit";
21
23
  const AUTHOR = ["-c", "user.name=turri", "-c", "user.email=turri@localhost", "-c", "commit.gpgsign=false"];
@@ -39,7 +41,10 @@ const EXCLUDED = [
39
41
  ".DS_Store",
40
42
  ];
41
43
  function git(moduleDir, args, input) {
42
- return spawnSync("git", [...AUTHOR, "--git-dir", join(moduleDir, HISTORY_DIR), "--work-tree", moduleDir, ...args], {
44
+ const gitDir = join(moduleDir, HISTORY_DIR);
45
+ // The history is turri's, not the person's: hooks their git config names - gitleaks, husky - would refuse some of its
46
+ // commits, and the next start would put back what landed. Its own hooks folder holds only git's samples.
47
+ return spawnSync("git", [...AUTHOR, "-c", `core.hooksPath=${join(gitDir, "hooks")}`, "--git-dir", gitDir, "--work-tree", moduleDir, ...args], {
43
48
  cwd: moduleDir,
44
49
  encoding: "utf8",
45
50
  input,
@@ -79,21 +84,51 @@ function ensureHistory(moduleDir) {
79
84
  */
80
85
  export function recordHistory(dir, subject, body) {
81
86
  try {
82
- const moduleDir = moduleDirOf(dir);
83
- if (!existsSync(moduleDir) || !ensureHistory(moduleDir))
84
- return undefined;
85
- git(moduleDir, ["add", "-A"]);
86
- if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
87
- return undefined;
88
- const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
89
- if (git(moduleDir, ["commit", "-q", "-F", "-"], message).status !== 0)
90
- return undefined;
91
- return head(dir);
87
+ return commitAll(dir, subject, body).commit;
92
88
  }
93
89
  catch {
94
90
  return undefined;
95
91
  }
96
92
  }
93
+ /**
94
+ * Commit work that landed - the code or tests a pulse passed, with the record of what they are built from. Unlike any
95
+ * other step's, this commit is not only a record: the next unit of work puts the module back to its last commit, and
96
+ * would undo what landed with it. So a commit that cannot be made is raised, as the environment's, with what git said -
97
+ * the work left in place, uncommitted. Nothing changed, nothing is committed. Returns the commit, if any.
98
+ */
99
+ export function landWork(dir, subject, body) {
100
+ const moduleDir = moduleDirOf(dir);
101
+ let made;
102
+ try {
103
+ made = commitAll(dir, subject, body);
104
+ }
105
+ catch (error) {
106
+ made = { failed: error instanceof Error ? error.message : String(error) };
107
+ }
108
+ if (made.failed !== undefined)
109
+ throw historyError(moduleDir, made.failed);
110
+ return made.commit;
111
+ }
112
+ /** Commit everything the step changed in the module `dir` belongs to: the commit, nothing when nothing changed, or why git failed. */
113
+ function commitAll(dir, subject, body) {
114
+ const moduleDir = moduleDirOf(dir);
115
+ if (!existsSync(moduleDir))
116
+ return {};
117
+ if (!ensureHistory(moduleDir))
118
+ return { failed: `git could not make the module's history in ${join(moduleDir, HISTORY_DIR)}` };
119
+ const failed = (result) => (result.error?.message ?? `${result.stderr}${result.stdout}`.trim()) || `git exited ${result.status}`;
120
+ const added = git(moduleDir, ["add", "-A"]);
121
+ if (added.status !== 0)
122
+ return { failed: failed(added) };
123
+ if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
124
+ return {};
125
+ const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
126
+ const committed = git(moduleDir, ["commit", "-q", "-F", "-"], message);
127
+ if (committed.status !== 0)
128
+ return { failed: failed(committed) };
129
+ const commit = head(dir);
130
+ return commit !== undefined ? { commit } : {};
131
+ }
97
132
  /**
98
133
  * The notes the harness writes once a pulse has ended, committed - or the next pulse, putting the module back to its last
99
134
  * commit, would take them away. Into the pulse's own commit when it landed one - `landedSince` (ms), when it started - so
@@ -385,6 +385,12 @@ export declare function isUpToDate(status: ModuleStatus): boolean;
385
385
  export declare function describeModules(statuses: ModuleStatus[], options?: {
386
386
  for?: "chat" | "command line";
387
387
  }): string;
388
+ /**
389
+ * A module's first pulse when it builds one functional spec - the project set up and the smallest thing that runs: it lands on its
390
+ * unit tests alone, with no review. A reviewer, and an arbiter after it, on a scaffold is time spent before anything can
391
+ * be tried.
392
+ */
393
+ export declare function skipsReview(cases: JobCases): boolean;
388
394
  /** How a code pulse went, for its line in the notes: the rounds it took, its test runs, what the debugger found. */
389
395
  export declare function codeStory(result: {
390
396
  attempts?: number;