@turri-dev/cli 0.2.0 → 0.2.1-next.bc56a0d

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 (80) hide show
  1. package/README.md +3 -2
  2. package/dist/chats.d.ts +31 -4
  3. package/dist/chats.js +146 -52
  4. package/dist/cli.js +4 -2
  5. package/dist/config.d.ts +4 -1
  6. package/dist/config.js +39 -4
  7. package/dist/environment.d.ts +0 -2
  8. package/dist/environment.js +0 -6
  9. package/dist/extension.d.ts +4 -3
  10. package/dist/extension.js +109 -22
  11. package/dist/host-session.d.ts +14 -0
  12. package/dist/host-session.js +2 -0
  13. package/dist/host.d.ts +6 -1
  14. package/dist/host.js +5 -1
  15. package/dist/init-config.d.ts +1 -1
  16. package/dist/init-config.js +33 -3
  17. package/dist/installed-providers.d.ts +40 -0
  18. package/dist/installed-providers.js +98 -0
  19. package/dist/model-check.d.ts +4 -2
  20. package/dist/model-check.js +33 -17
  21. package/dist/notice.d.ts +30 -6
  22. package/dist/pi.js +8 -24
  23. package/dist/plain/files.d.ts +16 -0
  24. package/dist/plain/files.js +68 -5
  25. package/dist/plain/graph.js +2 -2
  26. package/dist/plain/module-concepts.js +3 -2
  27. package/dist/plain/parser.d.ts +8 -3
  28. package/dist/plain/parser.js +47 -6
  29. package/dist/plain/stdlib.js +3 -3
  30. package/dist/regenerate/base-folder.d.ts +2 -0
  31. package/dist/regenerate/base-folder.js +24 -6
  32. package/dist/regenerate/command-check.d.ts +12 -1
  33. package/dist/regenerate/command-check.js +74 -0
  34. package/dist/regenerate/confine.d.ts +5 -0
  35. package/dist/regenerate/confine.js +9 -9
  36. package/dist/regenerate/ledger.d.ts +7 -0
  37. package/dist/regenerate/lineage.js +43 -2
  38. package/dist/regenerate/loop.d.ts +8 -0
  39. package/dist/regenerate/loop.js +11 -4
  40. package/dist/regenerate/os-sandbox.d.ts +2 -0
  41. package/dist/regenerate/os-sandbox.js +55 -20
  42. package/dist/regenerate/release.d.ts +2 -0
  43. package/dist/regenerate/requires.d.ts +12 -0
  44. package/dist/regenerate/requires.js +70 -1
  45. package/dist/regenerate/spec-steps.js +18 -1
  46. package/dist/regenerate/standing.d.ts +28 -0
  47. package/dist/regenerate/standing.js +30 -0
  48. package/dist/regenerate/testing.d.ts +2 -0
  49. package/dist/regenerate/testing.js +14 -0
  50. package/dist/release-agent.d.ts +17 -0
  51. package/dist/release-agent.js +19 -7
  52. package/dist/release-tools.d.ts +6 -0
  53. package/dist/release-tools.js +24 -5
  54. package/dist/sentinel-chat.d.ts +1 -1
  55. package/dist/sentinel-chat.js +21 -8
  56. package/dist/sentinel-check.d.ts +36 -0
  57. package/dist/sentinel-check.js +78 -0
  58. package/dist/sentinel.d.ts +40 -9
  59. package/dist/sentinel.js +240 -37
  60. package/dist/setup.d.ts +24 -3
  61. package/dist/setup.js +117 -31
  62. package/dist/spawn-async.d.ts +18 -0
  63. package/dist/spawn-async.js +35 -0
  64. package/dist/stack.d.ts +14 -0
  65. package/dist/stack.js +44 -2
  66. package/dist/turri-versions.d.ts +71 -0
  67. package/dist/turri-versions.js +275 -0
  68. package/docs/README.md +2 -0
  69. package/docs/claude-subscription.md +131 -0
  70. package/docs/config.md +5 -0
  71. package/docs/deploy.md +2 -2
  72. package/docs/hosting.md +42 -4
  73. package/docs/installation.md +39 -2
  74. package/docs/parser.md +1 -1
  75. package/docs/regenerating.md +1 -1
  76. package/docs/sandbox.md +2 -1
  77. package/docs/sentinel.md +11 -3
  78. package/docs/using.md +4 -2
  79. package/genome/rules/turri-regeneration.md +1 -1
  80. package/package.json +1 -1
@@ -212,6 +212,20 @@ export const NOT_CODE = new Set([
212
212
  RECORD_FILE,
213
213
  BASE_RECORD_FILE,
214
214
  ]);
215
+ /** Whether a codebase holds a file of its own - as `listFiles` counts them - stopping at the first. */
216
+ export function hasFiles(root) {
217
+ const look = (dir) => {
218
+ for (const entry of readdirSync(dir)) {
219
+ if (NOT_CODE.has(entry))
220
+ continue;
221
+ const full = join(dir, entry);
222
+ if (!statSync(full).isDirectory() || look(full))
223
+ return true;
224
+ }
225
+ return false;
226
+ };
227
+ return existsSync(root) && look(root);
228
+ }
215
229
  export function listFiles(root) {
216
230
  const found = [];
217
231
  const walk = (dir) => {
@@ -1,4 +1,16 @@
1
+ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
1
2
  import { type DeployState } from "./release-tools.ts";
3
+ /**
4
+ * The agent that releases a module on its own - `turri release`'s, and a pulse's last stage under
5
+ * `release-after-pulse: true`, which is the word to release: the guidance it finds as the chat does - what it was told,
6
+ * the project's AGENTS.md, its skills - says only how. It passes it to `release`, and does what it says, asking no one.
7
+ */
8
+ type AnyTool = ToolDefinition<any, any, any>;
9
+ /**
10
+ * The person's own shell, in the deployed folder: it installs what the release needs into the project, and runs it. It
11
+ * changes nothing of the machine's - no toolchain installed, no system setting changed: a command that would is refused.
12
+ */
13
+ export declare function releaseShell(dir: string): AnyTool;
2
14
  /**
3
15
  * What the project says of itself, for the agent to find release guidance in: its AGENTS.md and its skills
4
16
  * (`.agents/skills`, `.pi/skills`, `.claude/skills`), where turri runs and in the host codebase - once each, whole.
@@ -20,7 +32,12 @@ export interface ReleaseAgentInput {
20
32
  moduleName: string;
21
33
  subject: string;
22
34
  };
35
+ /** Released for the sentinel to check functional specs of the module in: asked for, so released; the guidance says how. */
36
+ forCheck?: {
37
+ moduleName: string;
38
+ };
23
39
  onDelta?: (delta: string) => void;
24
40
  }
25
41
  /** Run the release agent to its end; its last reply. */
26
42
  export declare function runReleaseAgent(input: ReleaseAgentInput): Promise<string>;
43
+ export {};
@@ -3,14 +3,24 @@ import { join, relative, resolve, sep } from "node:path";
3
3
  import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "@earendil-works/pi-coding-agent";
4
4
  import { createDeployTools, DEPLOY_TOOLS } from "./release-tools.js";
5
5
  import { hostDirOf, shortPath } from "./regenerate/release.js";
6
+ import { machineChange, machineChangeReason } from "./regenerate/command-check.js";
6
7
  import { projectDirOf } from "./regenerate/paths.js";
7
8
  import { ask, BEGIN, createExecutor } from "./pi.js";
8
- /** The person's own shell, in the deployed folder: it installs and runs what the release needs. */
9
- function hostBash(dir) {
9
+ /**
10
+ * The person's own shell, in the deployed folder: it installs what the release needs into the project, and runs it. It
11
+ * changes nothing of the machine's - no toolchain installed, no system setting changed: a command that would is refused.
12
+ */
13
+ export function releaseShell(dir) {
10
14
  const bash = createBashToolDefinition(dir);
11
15
  return {
12
16
  ...bash,
13
- description: `${bash.description}\n\nIt runs on the person's own machine, in ${shortPath(dir)}. Never start a server here: activate does.`,
17
+ description: `${bash.description}\n\nIt runs on the person's own machine, in ${shortPath(dir)}. Never start a server here: activate does. It installs nothing on the machine and changes none of its settings: what the machine lacks, the person installs.`,
18
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
19
+ const changes = machineChange(String(params.command ?? ""));
20
+ if (changes !== undefined)
21
+ throw new Error(machineChangeReason(changes));
22
+ return bash.execute(toolCallId, params, signal, onUpdate, ctx);
23
+ },
14
24
  };
15
25
  }
16
26
  /** A file tool that writes, refused inside plain_modules: the built code is the spec's to change, never a release's. */
@@ -78,7 +88,7 @@ export async function runReleaseAgent(input) {
78
88
  const executor = await createExecutor({
79
89
  // Its sessions are kept in the project, not in the folder it deploys.
80
90
  cwd: projectDirOf(plainFile),
81
- customTools: [...fileTools, hostBash(dir), ...tools],
91
+ customTools: [...fileTools, releaseShell(dir), ...tools],
82
92
  tools: [...fileTools.map((tool) => tool.name), "bash", ...DEPLOY_TOOLS],
83
93
  ...(input.model !== undefined ? { model: input.model } : {}),
84
94
  role: "release",
@@ -89,16 +99,18 @@ export async function runReleaseAgent(input) {
89
99
  task: [
90
100
  input.afterPulse !== undefined
91
101
  ? `A pulse of module ${input.afterPulse.moduleName} just landed: ${input.afterPulse.subject}. Release it now, before the module's next pulse starts: the project releases after every pulse.`
92
- : `Release ${shortPath(plainFile)} for the person to try, with the release tool, then do what it says.`,
102
+ : input.forCheck !== undefined
103
+ ? `The person asked turri's sentinel to check functional specs of module ${input.forCheck.moduleName} in its release, and what runs of it is not its last commit. Release its last commit now, for the sentinel to test.`
104
+ : `Release ${shortPath(plainFile)} for the person to try, with the release tool, then do what it says.`,
93
105
  "",
94
106
  "First ask yourself: is there guidance for how this project releases it - what the person said below, the project's",
95
107
  "notes and skills below, or anything else you find? If there is, call release with it as guidance, in full: the code is",
96
108
  "put in dist/<module>, and the guidance takes it on from there. If there is none, call release without guidance: the",
97
109
  "code is put in dist/<module> and started from there, as best it can be.",
98
- ...(input.afterPulse !== undefined
110
+ ...(input.afterPulse !== undefined || input.forCheck !== undefined
99
111
  ? [
100
112
  "",
101
- "Release it whether or not the guidance speaks of releasing after a build - the project's setting has said to. What",
113
+ `Release it whether or not the guidance speaks of releasing ${input.afterPulse !== undefined ? "after a build - the project's setting has said to" : "for a check - the person asked for it"}. What`,
102
114
  "the guidance says is how. When release says it is already released, leave it at that.",
103
115
  ]
104
116
  : []),
@@ -17,6 +17,12 @@ export interface DeployState {
17
17
  guidance: Map<string, string>;
18
18
  }
19
19
  export declare function createDeployState(): DeployState;
20
+ /**
21
+ * A module's release as it stands, when it is current: its own codebase's last commit is what the last release shipped,
22
+ * and nothing that release started has ended. Undefined when it is behind, was never made, or what it started died -
23
+ * then it is released again.
24
+ */
25
+ export declare function currentRelease(state: DeployState, plainFile: string, moduleName: string): Deployment | undefined;
20
26
  /** What the agent is to do once the code is in dist - in the deploy tool's answer, and the CLI agent's prompt. */
21
27
  export declare function deployGuide(dir: string): string;
22
28
  /**
@@ -2,20 +2,39 @@ import { isAbsolute, relative, resolve } from "node:path";
2
2
  import { refuseTestPlainFile } from "./test-plain-files.js";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
- import { deployLogDir, deployTarget, describeDeployment, exportModule, hostDirOf, shortPath, startDeployed, stopDeployed, } from "./regenerate/release.js";
5
+ import { deployLogDir, deployTarget, describeDeployment, exportModule, hostDirOf, listDeployed, shortPath, startDeployed, stopDeployed, } from "./regenerate/release.js";
6
6
  import { projectDirOf } from "./regenerate/paths.js";
7
7
  import { haltTool } from "./loop-tools.js";
8
8
  export function createDeployState() {
9
9
  return { released: new Map(), guidance: new Map() };
10
10
  }
11
+ /**
12
+ * A module's release as it stands, when it is current: its own codebase's last commit is what the last release shipped,
13
+ * and nothing that release started has ended. Undefined when it is behind, was never made, or what it started died -
14
+ * then it is released again.
15
+ */
16
+ export function currentRelease(state, plainFile, moduleName) {
17
+ let target;
18
+ try {
19
+ target = deployTarget(plainFile, moduleName);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ const last = state.released.get(moduleName);
25
+ if (target.entry.moduleName !== moduleName || last === undefined || !target.commit.startsWith(last.commit))
26
+ return undefined;
27
+ const started = listDeployed().filter((item) => item.moduleName === moduleName && (last.at === undefined || item.startedAt >= last.at));
28
+ return started.some((item) => item.exited !== undefined) ? undefined : last;
29
+ }
11
30
  /** What the agent is to do once the code is in dist - in the deploy tool's answer, and the CLI agent's prompt. */
12
31
  export function deployGuide(dir) {
13
32
  const where = shortPath(dir);
14
33
  return [
15
34
  `What to do now, to have the person try it - it runs on this machine, outside the sandbox, from ${where}:`,
16
35
  `1. Find out how it runs: its README, its manifests (package.json, pyproject.toml, requirements.txt, go.mod, Cargo.toml, pom.xml, build.gradle, docker-compose.yml) and entry points, in ${where}.`,
17
- "2. Check the toolchains it needs are on this machine (`command -v node`, `python3 --version`, `go version`, ...). One missing: install it with this machine's package manager (brew on macOS, apt or dnf on Linux), then go on.",
18
- "3. Install what it needs yourself, in your shell - dependencies into a virtual environment or node_modules, a build that fetches packages, an image pull. Do not ask the person: do it, and fix what fails.",
36
+ "2. Check the toolchains it needs are on this machine (`command -v node`, `python3 --version`, `go version`, ...) - under another name too: python where python3 is not, of the version it needs. One missing: you install nothing on the machine and change none of its settings - stop, and tell the person exactly what to install, the command they run themselves (`brew install node`), and that releasing again picks it up.",
37
+ "3. Install what it needs into the project yourself, in your shell - dependencies into a virtual environment or node_modules, a build that fetches packages, an image pull. Do not ask the person: do it, and fix what fails - within the project: never a global install, never a system setting.",
19
38
  "4. Start anything that keeps running - a backend, a frontend's dev server - with activate, with the URL it serves at: never in the shell with & or nohup. An app with a backend and a frontend: start both, the backend first.",
20
39
  "5. Then tell the person, briefly. Something that serves: only where to open it - the frontend's URL, and the API's in one line when there is one. A CLI or a library: a few commands to paste, run from the folder, with what they print. A port that is taken: say which and by what, and do not move the app to another.",
21
40
  `Do not change the code in ${where} - it is a copy of what was built, and a fix belongs in the spec. A config file it needs made from its example (a .env from .env.example) is fine.`,
@@ -37,7 +56,7 @@ export function guidedGuide(deployment, guidance) {
37
56
  `What to do now - on this machine, outside the sandbox, from ${host}:`,
38
57
  `1. The code to release is in ${dist}: the last commit that landed whole. Take it from there, never from ${built}, where a pulse may be writing. Where the guidance's own tooling reads ${built}, point it at ${dist} if it can take another source; if it cannot, do by hand, from ${dist}, what it does.`,
39
58
  `2. Read what the guidance leaves open - the scripts, files and commands it names, in ${host} - then do what it says, in its order. Where it says to change the host - a registration, an import, a config entry - change it. Never change ${built} or ${dist}: a fix to the code belongs in the spec.`,
40
- "3. Run the project's scripts and install what it needs yourself, in your shell. Do not ask the person: do it, and fix what fails.",
59
+ "3. Run the project's scripts and install what it needs into the project yourself, in your shell. Do not ask the person: do it, and fix what fails. A toolchain or system tool the machine lacks you never install, nor change a setting of the machine's: tell the person the command they run themselves.",
41
60
  "4. Start what keeps running - a start script, a dev server - with activate, with the URL it serves at - never in the shell with & or nohup.",
42
61
  `5. Then tell the person briefly: what was done, what you changed in ${host} if anything, and where to open it or how to try it. Where the guidance does not fit what you find, say so, and do not guess past it.`,
43
62
  ].join("\n");
@@ -105,7 +124,7 @@ export function createDeployTools(options) {
105
124
  const exported = exportModule(plainFile, params.in_module);
106
125
  const deployment = guidance === undefined ? exported : { ...exported, way: "guided", hostDir: hostDirOf(plainFile, options.cwd) };
107
126
  state.current = deployment;
108
- state.released.set(deployment.moduleName, deployment);
127
+ state.released.set(deployment.moduleName, { ...deployment, at: Date.now() });
109
128
  if (guidance !== undefined)
110
129
  state.guidance.set(deployment.moduleName, guidance);
111
130
  return text([
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-w
2
2
  import type { ChatLink, TurriCore } from "./chats.ts";
3
3
  import { type Conclusion, type Notice } from "./sentinel.ts";
4
4
  /** The sentinel's tools and model, built into its chat. */
5
- export declare function registerSentinel(pi: ExtensionAPI, session: TurriCore, cwd: string, hostTools: ToolDefinition[], allowed: Set<string>): void;
5
+ export declare function registerSentinel(pi: ExtensionAPI, session: TurriCore, cwd: string, hostTools: ToolDefinition[], allowed: Set<string>, resume: (plainFile: string) => Promise<string>): void;
6
6
  /**
7
7
  * The terminal's question whether to start the sentinel: the sentence, and how to switch to it - it opens last in the
8
8
  * strip, after the `open` chats there are. A host has its own way to it, and is given the sentence alone.
@@ -1,6 +1,6 @@
1
1
  import { Box, Text } from "@earendil-works/pi-tui";
2
2
  import { projectModels, readProjectConfig, roleModel } from "./config.js";
3
- import { concludedNote, concludeTool, keepGuidanceTool, notifyTool, sentinelFileTools } from "./sentinel.js";
3
+ import { concludedNote, concludeTool, keepGuidanceTool, notifyTool, resumeTool, sentinelFileTools } from "./sentinel.js";
4
4
  /**
5
5
  * The sentinel in a chat: its tools in place of the chat's - the project to read, its shell behind the walls, notices,
6
6
  * its guidance, a host's own - its model, and, in every chat, the notices it puts to the person: a card in the chat and,
@@ -16,7 +16,7 @@ const IGNORE = "Ignore - leave it as the build has it";
16
16
  const STEER = "Say what to do instead…";
17
17
  const DISMISS = "Dismiss";
18
18
  /** The sentinel's tools and model, built into its chat. */
19
- export function registerSentinel(pi, session, cwd, hostTools, allowed) {
19
+ export function registerSentinel(pi, session, cwd, hostTools, allowed, resume) {
20
20
  // pi's own file tools and shell, replaced by fenced ones of the same names: the whole project to read, nothing to write.
21
21
  for (const tool of sentinelFileTools(cwd))
22
22
  pi.registerTool(tool);
@@ -27,6 +27,7 @@ export function registerSentinel(pi, session, cwd, hostTools, allowed) {
27
27
  }));
28
28
  pi.registerTool(concludeTool((conclusion) => session.concluded(conclusion)));
29
29
  pi.registerTool(keepGuidanceTool(cwd));
30
+ pi.registerTool(resumeTool(resume));
30
31
  for (const tool of hostTools)
31
32
  pi.registerTool(tool);
32
33
  // Only its tools are offered to its model; what it calls besides is refused (extension.ts, tool_call).
@@ -90,10 +91,15 @@ export function noticeDialogs(pi, session, link, context) {
90
91
  const data = entry.data;
91
92
  if (data === undefined)
92
93
  return undefined;
93
- const about = data.module !== undefined ? `${data.module}${data.pulse !== undefined ? ` · pulse ${data.pulse}` : ""}` : data.job;
94
- const mark = data.verdict === "holds" ? theme.fg("success", "✓") : theme.fg("error", "✗");
94
+ const about = data.check !== undefined
95
+ ? `${data.module !== undefined ? `${data.module} · ` : ""}check ${data.check}`
96
+ : data.module !== undefined
97
+ ? `${data.module}${data.pulse !== undefined ? ` · pulse ${data.pulse}` : ""}`
98
+ : (data.job ?? "");
99
+ const mark = data.verdict === "holds" ? theme.fg("success", "✓") : data.verdict === "unchecked" ? theme.fg("muted", "·") : theme.fg("error", "✗");
100
+ const said = data.verdict === "holds" ? "checked, it holds" : data.verdict === "unchecked" ? "not checked" : "found something wrong";
95
101
  const box = new Box(1, 0);
96
- box.addChild(new Text(`${mark} ${theme.fg("muted", `sentinel · ${about} · ${data.verdict === "holds" ? "checked, it holds" : "found something wrong"}`)}\n ${theme.fg("text", data.summary)}`, 0, 0));
102
+ box.addChild(new Text(`${mark} ${theme.fg("muted", `sentinel · ${about} · ${said}`)}\n ${theme.fg("text", data.summary)}`, 0, 0));
97
103
  return box;
98
104
  });
99
105
  pi.registerEntryRenderer(NOTICE_OVER_ENTRY, (entry, _options, theme) => {
@@ -176,8 +182,8 @@ export function noticeDialogs(pi, session, link, context) {
176
182
  catch {
177
183
  /* the host still hears of it */
178
184
  }
179
- // A host answers it itself: its card, its buttons.
180
- if (session.host.onNotice !== undefined)
185
+ // A host answers it itself: its card, its buttons. A note of a fix the sentinel made asks nothing: its card alone.
186
+ if (session.host.onNotice !== undefined || notice.kind === "fixed")
181
187
  return;
182
188
  waiting.push(notice);
183
189
  void ask();
@@ -189,7 +195,14 @@ export function noticeDialogs(pi, session, link, context) {
189
195
  catch {
190
196
  /* the agent is still told */
191
197
  }
192
- void pi.sendMessage({ customType: "turri_sentinel_concluded", content: concludedNote(conclusion), display: false, details: { jobId: conclusion.job, verdict: conclusion.verdict } }, { triggerTurn: true, deliverAs: "followUp" });
198
+ // A host that shows the person the conclusion itself: the chat knows of it - in order, with no turn of its own.
199
+ const shown = session.host.pulseEndsShown === true;
200
+ void pi.sendMessage({
201
+ customType: "turri_sentinel_concluded",
202
+ content: concludedNote(conclusion, { shown }),
203
+ display: false,
204
+ details: { ...(conclusion.job !== undefined ? { jobId: conclusion.job } : {}), ...(conclusion.check !== undefined ? { checkId: conclusion.check } : {}), verdict: conclusion.verdict },
205
+ }, shown ? { triggerTurn: false } : { triggerTurn: true, deliverAs: "followUp" });
193
206
  },
194
207
  over(notice) {
195
208
  asking.get(notice.id)?.abort();
@@ -0,0 +1,36 @@
1
+ import type { SentinelCheck } from "./notice.ts";
2
+ /**
3
+ * A check a host asks the sentinel for (docs/sentinel.md): functional specs of one module, tested in the module's release -
4
+ * its own codebase's last commit, which holds the code of every module its requires chain builds on. Refused before it is
5
+ * queued when there is nothing to test: a module that is not there or does not parse, a func-id it does not have, one
6
+ * not built in its codebase as its ***plain has it now.
7
+ */
8
+ export interface CheckRequest {
9
+ /** The module, by name: its .plain file's, in the project's folder. */
10
+ module: string;
11
+ /**
12
+ * The functional specs to test, each `module:func-id` - of the module, or of any module its requires chain builds on,
13
+ * whose code its release holds. A bare func-id is the module's own.
14
+ */
15
+ funcIds: string[];
16
+ }
17
+ /** A host's check, found good to queue - or why not, saying which. */
18
+ export declare function prepareCheck(project: string, request: CheckRequest): SentinelCheck | {
19
+ refused: string;
20
+ };
21
+ /** A functional spec of a check, as turri reads it now: what it says, and its scenarios. */
22
+ export interface CheckedSpec {
23
+ /** As the check names it: `module:func-id`. */
24
+ funcId: string;
25
+ text: string;
26
+ conformance: Array<{
27
+ id: string;
28
+ text: string;
29
+ }>;
30
+ acceptance: Array<{
31
+ id: string;
32
+ text: string;
33
+ }>;
34
+ }
35
+ /** The check's functional specs as the module's ***plain has them now, with their scenarios - in the brief. */
36
+ export declare function checkedSpecs(check: SentinelCheck): CheckedSpec[];
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { PLAIN_SOURCE_FILE_EXTENSION, withParseCache } from "./plain/parser.js";
4
+ import { ACCEPTANCE_TESTS, CONFORMANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS } from "./plain/spec.js";
5
+ import { chainSpec, codebaseCompletion } from "./regenerate/loop.js";
6
+ import { builtTips, isImportModule, projectPlainFiles, resolveRequiresChain } from "./regenerate/requires.js";
7
+ /** A host's check, found good to queue - or why not, saying which. */
8
+ export function prepareCheck(project, request) {
9
+ return withParseCache(() => {
10
+ const name = (request.module ?? "").trim();
11
+ const file = projectPlainFiles(project).find((path) => basename(path, PLAIN_SOURCE_FILE_EXTENSION) === name);
12
+ if (name === "" || file === undefined)
13
+ return { refused: `There is no module ${name === "" ? "named" : name} in the project: no .plain file of its folder is ${name || "<module>"}.plain.` };
14
+ if (isImportModule(file))
15
+ return { refused: `${name} is an import module: it has no codebase of its own to release. Check a module that imports it.` };
16
+ let chain;
17
+ try {
18
+ chain = resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true });
19
+ }
20
+ catch (error) {
21
+ return { refused: `${name} does not parse: ${error instanceof Error ? error.message : String(error)}` };
22
+ }
23
+ const tip = chain.at(-1);
24
+ const asked = [...new Set((request.funcIds ?? []).map((funcId) => funcId.trim()).filter((funcId) => funcId !== ""))];
25
+ if (asked.length === 0)
26
+ return { refused: `Name at least one of ${whose(name)} functional specs to check, by func-id.` };
27
+ // Each as the module and func-id it is - `module:func-id`, a bare func-id the module's own.
28
+ const named = asked.map((funcId) => {
29
+ const colon = funcId.indexOf(":");
30
+ const module = colon === -1 ? name : funcId.slice(0, colon).trim();
31
+ const id = colon === -1 ? funcId : funcId.slice(colon + 1).trim();
32
+ return { module, id, label: `${module}:${id}` };
33
+ });
34
+ // Only what its release holds: the module and the modules its requires chain builds on.
35
+ const inChain = new Map(chain.map((entry) => [entry.moduleName, entry]));
36
+ const outside = named.filter(({ module }) => !inChain.has(module));
37
+ if (outside.length > 0) {
38
+ const below = chain.slice(0, -1).map((entry) => entry.moduleName);
39
+ return {
40
+ refused: `Not in ${whose(name)} release: ${outside.map((spec) => spec.label).join(", ")} - ${[...new Set(outside.map((spec) => spec.module))].join(", ")} ${new Set(outside.map((spec) => spec.module)).size === 1 ? "is" : "are"} not ${name} or a module its requires chain builds on` +
41
+ `${below.length > 0 ? ` (${below.join(", ")})` : ` - it builds on none`}.`,
42
+ };
43
+ }
44
+ const unknown = named.filter(({ module, id }) => !inChain.get(module).funcIds.includes(id));
45
+ if (unknown.length > 0)
46
+ return { refused: `No such functional spec: ${unknown.map((spec) => `${spec.module} has no ${spec.id}`).join("; ")}.` };
47
+ // Built in its own codebase, as its ***plain has it now: its record, as last committed, holds it.
48
+ const completion = codebaseCompletion(chain);
49
+ const incomplete = new Set(completion.codebase === tip.buildDir ? completion.modules.flatMap((module) => module.incomplete.map((id) => `${module.module}\0${id}`)) : named.map(({ module, id }) => `${module}\0${id}`));
50
+ const notBuilt = named.filter(({ module, id }) => incomplete.has(`${module}\0${id}`));
51
+ if (notBuilt.length > 0) {
52
+ const above = builtTips(file).map((built) => built.at(-1).moduleName).filter((module) => module !== name);
53
+ return {
54
+ refused: `Not built in ${whose(name)} codebase as its ***plain has ${notBuilt.length === 1 ? "it" : "them"} now: ${notBuilt.map((spec) => spec.label).join(", ")}.` +
55
+ (above.length > 0 ? ` ${whose(name)} code is built on in ${above.join(" and ")}'s codebase: check ${above.length === 1 ? above[0] : "one of them"}, naming ${notBuilt.length === 1 ? "it" : "them"} as ${name}:<func-id>.` : ""),
56
+ };
57
+ }
58
+ return { id: randomUUID().slice(0, 8), module: name, plainFile: file, funcIds: named.map((spec) => spec.label), at: new Date().toISOString() };
59
+ });
60
+ }
61
+ /** A module's, in words: predators', app's. */
62
+ const whose = (name) => (name.endsWith("s") ? `${name}'` : `${name}'s`);
63
+ /** The check's functional specs as the module's ***plain has them now, with their scenarios - in the brief. */
64
+ export function checkedSpecs(check) {
65
+ const tree = chainSpec(resolveRequiresChain(check.plainFile, [], undefined, { allowNoFunctionalities: true })).tree;
66
+ const own = tree[FUNCTIONAL_REQUIREMENTS] ?? [];
67
+ const inherited = tree[REQUIRED_FUNCTIONAL_SPECS] ?? [];
68
+ const scenarios = (list) => (list ?? []).map((scenario) => ({ id: String(scenario.id ?? ""), text: String(scenario.text ?? "").trim() }));
69
+ return check.funcIds.flatMap((funcId) => {
70
+ const colon = funcId.indexOf(":");
71
+ const module = colon === -1 ? check.module : funcId.slice(0, colon);
72
+ const id = colon === -1 ? funcId : funcId.slice(colon + 1);
73
+ const item = module === check.module ? own.find((each) => each.id === id) : inherited.find((each) => each.module === module && each.id === id);
74
+ if (item === undefined)
75
+ return [];
76
+ return [{ funcId, text: String(item.markdown ?? "").trim(), conformance: scenarios(item[CONFORMANCE_TESTS]), acceptance: scenarios(item[ACCEPTANCE_TESTS]) }];
77
+ });
78
+ }
@@ -2,7 +2,8 @@ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { type SentinelSetting } from "./config.ts";
3
3
  import { type Job } from "./regenerate/jobs.ts";
4
4
  import type { DeployedProcess } from "./regenerate/release.ts";
5
- import { type Conclusion, type Notice, type NoticeInput } from "./notice.ts";
5
+ import { type Conclusion, type Notice, type NoticeInput, type SentinelCheck } from "./notice.ts";
6
+ import type { CheckedSpec } from "./sentinel-check.ts";
6
7
  export * from "./notice.ts";
7
8
  /**
8
9
  * The sentinel: a chat of the session's own that every finished pulse goes to (docs/sentinel.md). It checks how each
@@ -15,9 +16,10 @@ export * from "./notice.ts";
15
16
  export declare function sentinelSetting(cwd: string): SentinelSetting;
16
17
  /**
17
18
  * What the person is asked before the sentinel first opens in a session, under `sentinel: ask`, in one sentence: the pulse
18
- * that would open it and what it does - so a tab that starts working on its own is not a surprise - and whether to start it.
19
+ * - or the host's check - that would open it and what it does - so a tab that starts working on its own is not a surprise
20
+ * - and whether to start it.
19
21
  */
20
- export declare function sentinelStartNotice(job: Job, cwd: string): NoticeInput;
22
+ export declare function sentinelStartNotice(about: Job | SentinelCheck, cwd: string): NoticeInput;
21
23
  /** A pulse that stopped on ***plain that contradicts itself: put to the person by turri itself, the way out the chat's to work out. */
22
24
  export declare function specConflictNotice(job: Job, task: string): NoticeInput;
23
25
  export declare function guidanceFile(cwd: string): string;
@@ -32,7 +34,9 @@ export declare function pulseEndsBlock(sentinel: boolean): string;
32
34
  * What the chat the person is in is told of a pulse that landed, with the sentinel on: that it landed, what the sentinel
33
35
  * does with it - and to tell the person so, and do nothing else about it.
34
36
  */
35
- export declare function landedNote(job: Job, released: DeployedProcess[]): string;
37
+ export declare function landedNote(job: Job, released: DeployedProcess[], options?: {
38
+ shown?: boolean;
39
+ }): string;
36
40
  /**
37
41
  * Its identity and its task, in its system prompt in place of the conversation's. A host's `surface` - where the person
38
42
  * watches it, in the host's words - stands in place of their not watching, and has it try a release with the host's tools.
@@ -50,16 +54,43 @@ export declare function sentinelBrief(job: Job, context: {
50
54
  releaseAfterPulse: boolean;
51
55
  reaction: string;
52
56
  }): string;
53
- /** The tools the sentinel's agent may call: what reads, its notices and its guidance - and what a host gives it. */
57
+ /**
58
+ * What the sentinel is told of a host's check: the module and its release - what runs, and where - and the functional
59
+ * specs to test, with what each says and its scenarios, as turri reads them now.
60
+ */
61
+ export declare function sentinelCheckBrief(check: SentinelCheck, context: {
62
+ specs: CheckedSpec[];
63
+ released: DeployedProcess[];
64
+ commit?: string;
65
+ releasedNow: boolean;
66
+ focused?: string;
67
+ }): string;
68
+ /**
69
+ * The tools the sentinel's agent may call: what reads, write and edit for the project's tooling alone, resume, its
70
+ * notices and its guidance - and what a host gives it.
71
+ */
54
72
  export declare const SENTINEL_TOOLS: string[];
73
+ /** The keys of config.turri.yaml the sentinel may change: where the testing scripts are, and the toolchains' folders. */
74
+ export declare const SENTINEL_CONFIG_KEYS: string[];
55
75
  /**
56
- * Its file tools and shell: the whole project to read and nothing to write - the shell behind the command check, the OS
57
- * sandbox where one is up, and the audit, with the network open. Its home and temporary folder are its own, in the
58
- * modules' scratch.
76
+ * The project's tooling, which the sentinel fixes itself: its testing scripts - `test_scripts/`, a `run_*.sh` or
77
+ * `prepare_environment*.sh` at its root, and each script its config names - and its `config.turri*.yaml` files. In the
78
+ * project, and in a project one folder down (***plain kept in plain/). Never a .plain file: the ***plain is the person's.
79
+ */
80
+ export declare function sentinelTooling(cwd: string): string[];
81
+ /**
82
+ * Its file tools and shell: the whole project to read, and to write only its tooling (`sentinelTooling`) - in a config
83
+ * file, only the keys of SENTINEL_CONFIG_KEYS. Its shell runs behind the command check, the OS sandbox where one is up,
84
+ * and the audit, with the project read only and the network open - and changes nothing of the machine's. Its home and
85
+ * temporary folder are its own, in the modules' scratch.
59
86
  */
60
87
  export declare function sentinelFileTools(cwd: string): ToolDefinition[];
61
88
  export declare function notifyTool(notify: (input: NoticeInput) => Notice | undefined): ToolDefinition;
62
89
  export declare function concludeTool(conclude: (conclusion: Conclusion) => string | undefined): ToolDefinition;
63
90
  /** What the chat the person is in is told when the sentinel is done with a pulse: to say so, and do nothing more. */
64
- export declare function concludedNote(conclusion: Conclusion): string;
91
+ export declare function concludedNote(conclusion: Conclusion, options?: {
92
+ shown?: boolean;
93
+ }): string;
94
+ /** Bring a module up again once the sentinel fixed what stopped its pulse: what is left there is regenerated, its scripts run. */
95
+ export declare function resumeTool(resume: (plainFile: string) => Promise<string>): ToolDefinition;
65
96
  export declare function keepGuidanceTool(cwd: string): ToolDefinition;