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

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 (42) 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/jobs.d.ts +9 -0
  11. package/dist/regenerate/jobs.js +11 -0
  12. package/dist/regenerate/loop.d.ts +6 -0
  13. package/dist/regenerate/loop.js +24 -3
  14. package/dist/regenerate/records.d.ts +5 -3
  15. package/dist/regenerate/records.js +6 -4
  16. package/dist/regenerate/spec-map.js +8 -5
  17. package/dist/run-report.d.ts +1 -1
  18. package/dist/run-report.js +6 -0
  19. package/dist/self-invocation.d.ts +1 -1
  20. package/dist/self-invocation.js +3 -2
  21. package/dist/skill-progress.d.ts +1 -1
  22. package/dist/skill.d.ts +5 -0
  23. package/dist/skill.js +1 -0
  24. package/dist/skills.d.ts +14 -0
  25. package/dist/skills.js +19 -1
  26. package/dist/surface.d.ts +5 -0
  27. package/dist/surface.js +18 -0
  28. package/docs/authoring-plain.md +2 -2
  29. package/docs/config.md +1 -1
  30. package/docs/headless.md +4 -0
  31. package/docs/hosting.md +17 -0
  32. package/docs/regenerating.md +4 -4
  33. package/docs/skills-and-gates.md +3 -1
  34. package/docs/using.md +3 -3
  35. package/genome/rules/func-specs.md +1 -0
  36. package/genome/rules/turri-regeneration.md +3 -0
  37. package/genome/skills/add-functional-spec/SKILL.md +12 -3
  38. package/genome/skills/add-functional-specs/SKILL.md +12 -2
  39. package/genome/skills/brood/SKILL.md +2 -0
  40. package/genome/skills/bud/SKILL.md +2 -0
  41. package/genome/skills/spawn/SKILL.md +47 -35
  42. 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) {
@@ -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,
@@ -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;
@@ -9,7 +9,7 @@ import { commitNotes, recordHistory, resetToLastCommit } from "./lineage.js";
9
9
  import { NOTES_FILE, recordPulse } from "./notes.js";
10
10
  import { createJobRegistry, formatDuration } from "./jobs.js";
11
11
  import { moduleLanes } from "./lanes.js";
12
- import { adoptDigests, chainTree, codeTree, conformanceTarget, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
12
+ import { adoptDigests, chainTree, codeTree, conformanceTarget, marksRegenerateAgain, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
13
13
  import { describeSpecDiffConformance, renderSpecDiffConformance } from "./conform.js";
14
14
  import { conformanceDirFor } from "./conformance.js";
15
15
  import { modulesRoot } from "./paths.js";
@@ -153,7 +153,8 @@ export function functionalityCompletion(plainFile) {
153
153
  codebase.forEach((entry, index) => {
154
154
  const count = counts[index];
155
155
  const completed = entry.frids.filter((frid) => !touched.has(`${entry.moduleName}:${frid}`));
156
- const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && item.markdown.trim() === specText(entry.moduleName, item.frid));
156
+ // Tests marked to regenerate again were written against code since replaced: not conformance tested until rewritten.
157
+ const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && !marksRegenerateAgain(item) && item.markdown.trim() === specText(entry.moduleName, item.frid));
157
158
  count.completed = completed.length;
158
159
  count.tested = completed.filter((frid) => tested.some((item) => item.frid === frid));
159
160
  count.conformanceTested = count.tested.length;
@@ -521,7 +522,7 @@ export function createWorkLoop(options) {
521
522
  run: async (onDelta) => {
522
523
  // The guardrail: a conformance job tests only code that is built - the code record, read as the job starts, has every functional spec it tests as it tests it.
523
524
  if (conformance) {
524
- const testing = [...planned.cases.add, ...planned.cases.patch].map((touched) => touched.frid);
525
+ const testing = [...planned.cases.add, ...planned.cases.patch, ...planned.cases.again].map((touched) => touched.frid);
525
526
  const missing = notBuilt(readRecord(tip.buildDir), planned.step.tree, testing);
526
527
  if (missing.length > 0) {
527
528
  const record = relative(options.cwd, join(tip.buildDir, RECORD_FILE));
@@ -1109,6 +1110,17 @@ export function createWorkLoop(options) {
1109
1110
  found.add(frid);
1110
1111
  const marked = items.map((item, index) => (frids.includes(named(item, index)) ? { ...item, [REGENERATE_AGAIN]: true } : item));
1111
1112
  writeCodeRecord(tip.buildDir, { ...record, [section]: marked });
1113
+ // Its conformance tests were written against the code being replaced: marked with it, they are written again -
1114
+ // checked against the new code, and rewritten where they relied on what the old code chose - once it lands.
1115
+ if (section === FUNCTIONAL_REQUIREMENTS) {
1116
+ const testsDir = conformanceDirFor(tip.moduleName, modulesRoot(projectDirOf(tip.plainFile)));
1117
+ const tests = readRecord(testsDir);
1118
+ const tested = tests?.[FUNCTIONAL_REQUIREMENTS] ?? [];
1119
+ if (tested.some((item) => item.frid !== undefined && taken.includes(item.frid))) {
1120
+ const again = tested.map((item) => (item.frid !== undefined && taken.includes(item.frid) ? { ...item, [REGENERATE_AGAIN]: true } : item));
1121
+ writeTestsRecord(testsDir, { ...tests, [FUNCTIONAL_REQUIREMENTS]: again });
1122
+ }
1123
+ }
1112
1124
  recordHistory(tip.buildDir, `${module} functional spec${taken.length === 1 ? "" : "s"} ${taken.join(", ")} to regenerate again`);
1113
1125
  }
1114
1126
  const missing = frids.filter((frid) => !found.has(frid));
@@ -1600,6 +1612,14 @@ const defaultConformanceRunner = async (job, onDelta) => {
1600
1612
  }
1601
1613
  return { landed: result.passed, report: describeSpecDiffConformance(result), ...(result.specGaps !== undefined ? { specGaps: result.specGaps } : {}) };
1602
1614
  };
1615
+ /**
1616
+ * 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
1617
+ * unit tests alone, with no review. A reviewer, and an arbiter after it, on a scaffold is time spent before anything can
1618
+ * be tried.
1619
+ */
1620
+ export function skipsReview(cases) {
1621
+ return cases.fresh && cases.add.length <= 1 && cases.patch.length === 0 && cases.again.length === 0 && cases.delete.length === 0;
1622
+ }
1603
1623
  /** A job as a spec diff render, landed on its unit tests: conformance is a track of its own. */
1604
1624
  const defaultRunner = async (job, onDelta) => {
1605
1625
  const result = await renderSpecDiff({
@@ -1609,6 +1629,7 @@ const defaultRunner = async (job, onDelta) => {
1609
1629
  applied: "already applied",
1610
1630
  spec: job.spec,
1611
1631
  cases: job.planned.cases,
1632
+ ...(skipsReview(job.planned.cases) ? { unitReview: false } : {}),
1612
1633
  ...(job.model !== undefined ? { model: job.model } : {}),
1613
1634
  ...(job.steering !== undefined ? { steering: job.steering } : {}),
1614
1635
  onDelta,
@@ -63,9 +63,11 @@ export type Chosen = (frid: string) => boolean;
63
63
  * definitions and implementation reqs, with the test reqs as the spec has them now.
64
64
  *
65
65
  * Conformance follows the code, never goes ahead of it: a functional spec whose code is not caught up
66
- * with the spec - its text in the code record is not the spec's, or the code record does not have it at
67
- * all, as while it is regenerated again - stays as the tests record has it, until the code lands. Its tests are
68
- * taken out only once the spec and the code have both dropped it.
66
+ * with the spec - its text in the code record is not the spec's, the code record does not have it at
67
+ * all, or has it marked to regenerate again - stays as the tests record has it, until the code lands. Its tests are
68
+ * taken out only once the spec and the code have both dropped it. Tests marked to regenerate again with their code
69
+ * (`regenerateAgain`) differ from the target once the code lands: written against the code it replaced, they are
70
+ * conformance work again, though the spec's text never changed.
69
71
  */
70
72
  export declare function conformanceTarget(code: MarshalledPlainSource | null, tests: MarshalledPlainSource | null, spec: MarshalledPlainSource, chosen?: Chosen): MarshalledPlainSource;
71
73
  /**
@@ -155,9 +155,11 @@ export function inheritedRecord(source, sourceModule) {
155
155
  * definitions and implementation reqs, with the test reqs as the spec has them now.
156
156
  *
157
157
  * Conformance follows the code, never goes ahead of it: a functional spec whose code is not caught up
158
- * with the spec - its text in the code record is not the spec's, or the code record does not have it at
159
- * all, as while it is regenerated again - stays as the tests record has it, until the code lands. Its tests are
160
- * taken out only once the spec and the code have both dropped it.
158
+ * with the spec - its text in the code record is not the spec's, the code record does not have it at
159
+ * all, or has it marked to regenerate again - stays as the tests record has it, until the code lands. Its tests are
160
+ * taken out only once the spec and the code have both dropped it. Tests marked to regenerate again with their code
161
+ * (`regenerateAgain`) differ from the target once the code lands: written against the code it replaced, they are
162
+ * conformance work again, though the spec's text never changed.
161
163
  */
162
164
  export function conformanceTarget(code, tests, spec, chosen = () => true) {
163
165
  const built = itemsOf(code, FUNCTIONAL_REQUIREMENTS);
@@ -168,7 +170,7 @@ export function conformanceTarget(code, tests, spec, chosen = () => true) {
168
170
  for (let index = 0; index < Math.max(built.length, now.length); index += 1) {
169
171
  const frid = String(index + 1);
170
172
  const current = now[index];
171
- if (current !== undefined && built[index]?.markdown.trim() === current.markdown.trim()) {
173
+ if (current !== undefined && built[index]?.markdown.trim() === current.markdown.trim() && !marksRegenerateAgain(built[index])) {
172
174
  if (chosen(frid))
173
175
  specs.push({ ...current, frid });
174
176
  continue;
@@ -8,7 +8,7 @@ import { conformanceDirFor } from "./conformance.js";
8
8
  import { chainSpec } from "./loop.js";
9
9
  import { modulesRoot, projectDirOf } from "./paths.js";
10
10
  import { HISTORY_DIR } from "./lineage.js";
11
- import { codeTree, readRecord, RECORD_FILE } from "./records.js";
11
+ import { codeTree, marksRegenerateAgain, readRecord, RECORD_FILE } from "./records.js";
12
12
  import { specFilesOf } from "./regenerate.js";
13
13
  import { builtTips, resolveRequiresChain } from "./requires.js";
14
14
  import { changesBetween, layoutOf, serializeTree } from "./spec-steps.js";
@@ -115,17 +115,20 @@ export function pageItems(input) {
115
115
  }
116
116
  }
117
117
  // Conformance tests: of the functional specs, those the tests record has as they read now, as they read before, or taken out.
118
- const tested = new Set(itemsOf(input.tested ?? null, FUNCTIONAL_REQUIREMENTS).map((item) => item.markdown.trim()));
118
+ // Tests marked to regenerate again were written against code since replaced: written for an earlier build, not now.
119
+ const testedItems = itemsOf(input.tested ?? null, FUNCTIONAL_REQUIREMENTS);
120
+ const tested = new Set(testedItems.filter((item) => !marksRegenerateAgain(item)).map((item) => item.markdown.trim()));
121
+ const replaced = new Set(testedItems.filter((item) => marksRegenerateAgain(item)).map((item) => item.markdown.trim()));
119
122
  for (const item of items) {
120
- if (item.section !== FUNCTIONAL_REQUIREMENTS || tested.size === 0)
123
+ if (item.section !== FUNCTIONAL_REQUIREMENTS || testedItems.length === 0)
121
124
  continue;
122
125
  if (item.state === "removed") {
123
- if (tested.has(item.text.trim()))
126
+ if (tested.has(item.text.trim()) || replaced.has(item.text.trim()))
124
127
  item.tests = "removed";
125
128
  }
126
129
  else if (tested.has(item.text.trim()))
127
130
  item.tests = "now";
128
- else if (item.was !== undefined && tested.has(item.was.trim()))
131
+ else if (replaced.has(item.text.trim()) || (item.was !== undefined && (tested.has(item.was.trim()) || replaced.has(item.was.trim()))))
129
132
  item.tests = "older";
130
133
  }
131
134
  return keyed(items);
@@ -1,4 +1,4 @@
1
- import type { Job } from "./regenerate/jobs.ts";
1
+ import { type Job } from "./regenerate/jobs.ts";
2
2
  /**
3
3
  * A headless run's report: one JSON file, `turri regenerate --report <file>`, saying what the run did - how it ended,
4
4
  * what it spent, which pulses landed, how many functional specs are completed - for a benchmark or CI to read rather
@@ -2,6 +2,7 @@ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { turriIdentity } from "./environment.js";
4
4
  import { onModelEvent } from "./regenerate/activity.js";
5
+ import { firstRegeneration } from "./regenerate/jobs.js";
5
6
  /**
6
7
  * A headless run's report: one JSON file, `turri regenerate --report <file>`, saying what the run did - how it ended,
7
8
  * what it spent, which pulses landed, how many functional specs are completed - for a benchmark or CI to read rather
@@ -177,7 +178,12 @@ export function startRunReport(options) {
177
178
  };
178
179
  });
179
180
  const ran = list.filter((pulse) => pulse.status !== "queued");
181
+ const first = firstRegeneration(jobs, startedAt);
182
+ const seconds = (ms) => (ms !== undefined ? Math.round(ms / 100) / 10 : null);
180
183
  return {
184
+ // The time to first regeneration - the first code pulse started - and to the first one landed, from the run's start.
185
+ first_started_seconds: seconds(first.started),
186
+ first_landed_seconds: seconds(first.landed),
181
187
  counts: {
182
188
  total: ran.length,
183
189
  landed: ran.filter((pulse) => pulse.landed).length,
@@ -20,6 +20,6 @@ export declare function selfInvocation(command: string): string | undefined;
20
20
  */
21
21
  export declare function askedForCommandLine(message: string): boolean;
22
22
  /** What the agent is told when the call is refused. */
23
- export declare function refusal(what: string): string;
23
+ export declare function refusal(what: string, surface?: string): string;
24
24
  /** The instruction that goes into the conversation's system prompt. */
25
25
  export declare const TOOLS_NOT_CLI: string;
@@ -88,12 +88,13 @@ export function askedForCommandLine(message) {
88
88
  /(^|\s)pi\s+-p\b/.test(text));
89
89
  }
90
90
  /** What the agent is told when the call is refused. */
91
- export function refusal(what) {
91
+ export function refusal(what, surface) {
92
92
  const instead = what === "pi"
93
93
  ? "Do not start another agent: do the work here, with this conversation's tools."
94
94
  : `Use ${TOOL_FOR[what] ?? "the matching turri tool"} instead.`;
95
95
  return (`Not run: that starts turri${what === "pi" ? "'s agent" : ` ${what}`} again from the shell - a separate, headless ` +
96
- "run outside this conversation, its jobs, the panel and /halt. " +
96
+ // In a host there is no panel: the person watches the pulses where its surface says.
97
+ (surface !== undefined ? "run outside this conversation and its pulses, where the person can neither watch it nor halt it. " : "run outside this conversation, its jobs, the panel and /halt. ") +
97
98
  `${instead} Run turri from the command line only when the user asks for that.`);
98
99
  }
99
100
  /** The instruction that goes into the conversation's system prompt. */
@@ -6,7 +6,7 @@
6
6
  * brood ✓ intent ━━━━ ⠋ stack ──── ○ testing ──── ○ product phase 2 of 4
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 - `groundwork` - says little to someone
9
+ * The second line says what the phase it is in does: a phase's name alone - `skeleton` - 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
@@ -85,6 +85,11 @@ export interface Skill {
85
85
  * last answer is written with `write_spec_diff`, and changes the agent decides itself need no answer before them.
86
86
  */
87
87
  diffPerAnswer: string[];
88
+ /**
89
+ * Started only by the person, with its slash command - `/brood` - never by the model on its own
90
+ * (`metadata.command-only: true`): cultivate runs it only when the person has just typed its command.
91
+ */
92
+ commandOnly: boolean;
88
93
  }
89
94
  export declare function loadSkill(skillDir: string): Skill;
90
95
  export declare function readRubric(skill: Skill, review: {
package/dist/skill.js CHANGED
@@ -131,6 +131,7 @@ export function loadSkill(skillDir) {
131
131
  paced: data.metadata?.paced !== false,
132
132
  questioning: capped,
133
133
  diffPerAnswer,
134
+ commandOnly: data.metadata?.["command-only"] === true,
134
135
  };
135
136
  }
136
137
  export function readRubric(skill, review) {
package/dist/skills.d.ts CHANGED
@@ -28,3 +28,17 @@ export declare function discoverSkills(packageRoot: string, projectDir: string |
28
28
  export declare function catalogueLine(skill: FoundSkill): string;
29
29
  /** What is said under the list about the skills it leaves out. */
30
30
  export declare function leftOutNote(catalogue: SkillCatalogue): string;
31
+ /**
32
+ * Which skills cultivate may start now. A skill marked command-only (`/brood`) waits for the person to type its command:
33
+ * the command lets it start once, and the run starting uses that up - the model cannot start it again later on its own.
34
+ */
35
+ export declare function createCommandGate(): {
36
+ /** The person typed the skill's command. */
37
+ typed(name: string): void;
38
+ /** Whether cultivate may start it now. */
39
+ mayStart(skill: Pick<FoundSkill, "name" | "commandOnly">): boolean;
40
+ /** Its run started: the command is used up. */
41
+ started(name: string): void;
42
+ };
43
+ /** What cultivate says of a command-only skill the person did not start. */
44
+ export declare function startedByCommandOnly(name: string): string;