@turri-dev/cli 0.1.19-next.ee6bc61 → 0.1.20-next.3ae4a0b

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 (49) hide show
  1. package/dist/chat-commands.js +2 -1
  2. package/dist/chat-records.d.ts +2 -0
  3. package/dist/chat-terminal.d.ts +1 -0
  4. package/dist/chat-terminal.js +1 -1
  5. package/dist/chats.d.ts +60 -3
  6. package/dist/chats.js +201 -14
  7. package/dist/config-edit.js +2 -0
  8. package/dist/config.d.ts +8 -1
  9. package/dist/config.js +5 -2
  10. package/dist/extension.js +57 -1
  11. package/dist/host-session.d.ts +11 -0
  12. package/dist/host-session.js +2 -0
  13. package/dist/host.d.ts +1 -0
  14. package/dist/host.js +2 -0
  15. package/dist/identity.js +2 -2
  16. package/dist/init-config.d.ts +1 -0
  17. package/dist/init-config.js +6 -0
  18. package/dist/notice.d.ts +59 -0
  19. package/dist/notice.js +38 -0
  20. package/dist/pi-launch.js +9 -2
  21. package/dist/regenerate/agent-shell.d.ts +5 -0
  22. package/dist/regenerate/agent-shell.js +2 -2
  23. package/dist/regenerate/audit.js +1 -1
  24. package/dist/regenerate/codebase.js +1 -1
  25. package/dist/regenerate/command-check.js +1 -1
  26. package/dist/regenerate/confine.d.ts +2 -0
  27. package/dist/regenerate/confine.js +2 -2
  28. package/dist/regenerate/ledger.d.ts +14 -0
  29. package/dist/regenerate/paths.d.ts +2 -0
  30. package/dist/regenerate/paths.js +2 -0
  31. package/dist/regenerate/records.d.ts +1 -1
  32. package/dist/regenerate/records.js +3 -1
  33. package/dist/regenerate/release.js +1 -1
  34. package/dist/regenerate/testing.js +1 -1
  35. package/dist/regenerate/workspace.js +1 -1
  36. package/dist/sentinel-chat.d.ts +14 -0
  37. package/dist/sentinel-chat.js +178 -0
  38. package/dist/sentinel.d.ts +55 -0
  39. package/dist/sentinel.js +279 -0
  40. package/docs/README.md +2 -0
  41. package/docs/chats.md +4 -3
  42. package/docs/config.md +3 -1
  43. package/docs/deploy.md +2 -2
  44. package/docs/development.md +1 -0
  45. package/docs/hosting.md +18 -3
  46. package/docs/sentinel.md +54 -0
  47. package/docs/using.md +1 -0
  48. package/genome/rules/turri-regeneration.md +1 -0
  49. package/package.json +1 -1
@@ -0,0 +1,178 @@
1
+ import { Box, Text } from "@earendil-works/pi-tui";
2
+ import { readProjectConfig, roleModel } from "./config.js";
3
+ import { concludedNote, concludeTool, keepGuidanceTool, notifyTool, sentinelFileTools } from "./sentinel.js";
4
+ /**
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
+ * 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,
7
+ * in the terminal, a dialog once the chat is idle (docs/sentinel.md).
8
+ */
9
+ /** A notice in the transcript: the card. */
10
+ const NOTICE_ENTRY = "turri_notice";
11
+ /** A notice's end, under its card: answered, dismissed, superseded by a newer one about its module. */
12
+ const NOTICE_OVER_ENTRY = "turri_notice_over";
13
+ /** The sentinel done with a pulse, in the chat the person is in. */
14
+ const CONCLUDED_ENTRY = "turri_sentinel_concluded";
15
+ const STEER = "Say what to do instead…";
16
+ const DISMISS = "Dismiss";
17
+ /** The sentinel's tools and model, built into its chat. */
18
+ export function registerSentinel(pi, session, cwd, hostTools, allowed) {
19
+ // pi's own file tools and shell, replaced by fenced ones of the same names: the whole project to read, nothing to write.
20
+ for (const tool of sentinelFileTools(cwd))
21
+ pi.registerTool(tool);
22
+ pi.registerTool(notifyTool((input) => {
23
+ const job = input.job !== undefined ? session.jobs.get(input.job) : undefined;
24
+ const module = input.module ?? job?.module;
25
+ return session.notice({ ...input, ...(module !== undefined ? { module } : {}), ...(job?.pulse !== undefined ? { pulse: job.pulse } : {}) });
26
+ }));
27
+ pi.registerTool(concludeTool((conclusion) => session.concluded(conclusion)));
28
+ pi.registerTool(keepGuidanceTool(cwd));
29
+ for (const tool of hostTools)
30
+ pi.registerTool(tool);
31
+ // Only its tools are offered to its model; what it calls besides is refused (extension.ts, tool_call).
32
+ let modelSet = false;
33
+ pi.on("session_start", async (_event, ctx) => {
34
+ try {
35
+ pi.setActiveTools(pi.getAllTools().map((tool) => tool.name).filter((name) => allowed.has(name)));
36
+ }
37
+ catch {
38
+ /* a session let go has no tools to set */
39
+ }
40
+ // Its model, once: the `sentinel` role's, else the chat's - what the person then picks in its chat stands.
41
+ if (modelSet)
42
+ return;
43
+ modelSet = true;
44
+ const choice = roleModel(readProjectConfig(cwd), "sentinel");
45
+ if (choice?.model !== undefined) {
46
+ const slash = choice.model.indexOf("/");
47
+ const found = slash === -1 ? undefined : ctx.modelRegistry.find(choice.model.slice(0, slash), choice.model.slice(slash + 1));
48
+ if (found !== undefined)
49
+ await pi.setModel(found);
50
+ }
51
+ if (choice?.thinking !== undefined)
52
+ pi.setThinkingLevel(choice.thinking);
53
+ });
54
+ }
55
+ /**
56
+ * A chat's notices: each drawn as a card, and - with no host to answer it - put to the person as a dialog once the chat
57
+ * is idle, one at a time: an action accepted, their own words, or dismissed. A notice that is over closes its dialog.
58
+ */
59
+ export function noticeDialogs(pi, session, link, context) {
60
+ const waiting = [];
61
+ const asking = new Map();
62
+ let open = false;
63
+ pi.registerEntryRenderer(NOTICE_ENTRY, (entry, _options, theme) => {
64
+ const notice = entry.data;
65
+ if (notice === undefined)
66
+ return undefined;
67
+ const mark = notice.severity === "error" ? theme.fg("error", "✗") : notice.severity === "warning" ? theme.fg("warning", "!") : theme.fg("accent", "·");
68
+ const about = notice.module !== undefined ? `${notice.module}${notice.pulse !== undefined ? ` · pulse ${notice.pulse}` : ""}` : "";
69
+ const lines = [
70
+ `${mark} ${theme.fg("muted", `sentinel${about !== "" ? ` · ${about}` : ""}`)}`,
71
+ ` ${theme.fg("text", notice.summary)}`,
72
+ ...notice.evidence.map((line) => theme.fg("muted", ` ${line}`)),
73
+ ...notice.actions.map((action, index) => ` ${theme.fg("accent", `${index + 1}.`)} ${action.label}`),
74
+ ];
75
+ const box = new Box(1, 0);
76
+ box.addChild(new Text(lines.join("\n"), 0, 0));
77
+ return box;
78
+ });
79
+ pi.registerEntryRenderer(CONCLUDED_ENTRY, (entry, _options, theme) => {
80
+ const data = entry.data;
81
+ if (data === undefined)
82
+ return undefined;
83
+ const about = data.module !== undefined ? `${data.module}${data.pulse !== undefined ? ` · pulse ${data.pulse}` : ""}` : data.job;
84
+ const mark = data.verdict === "holds" ? theme.fg("success", "✓") : theme.fg("error", "✗");
85
+ const box = new Box(1, 0);
86
+ 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));
87
+ return box;
88
+ });
89
+ pi.registerEntryRenderer(NOTICE_OVER_ENTRY, (entry, _options, theme) => {
90
+ const data = entry.data;
91
+ if (data === undefined)
92
+ return undefined;
93
+ const said = data.state === "superseded" ? "superseded by a newer notice" : data.state === "dismissed" ? "dismissed" : data.state === "steered" ? "answered in your words" : "accepted";
94
+ const box = new Box(1, 0);
95
+ box.addChild(new Text(theme.fg("muted", ` ${said}: ${data.summary}`), 0, 0));
96
+ return box;
97
+ });
98
+ const ask = async () => {
99
+ if (open || link.session()?.isStreaming === true)
100
+ return;
101
+ const notice = waiting.shift();
102
+ if (notice === undefined)
103
+ return;
104
+ if (notice.state !== "open")
105
+ return ask();
106
+ const ctx = context();
107
+ if (ctx === undefined || !ctx.hasUI) {
108
+ waiting.unshift(notice);
109
+ return;
110
+ }
111
+ open = true;
112
+ const controller = new AbortController();
113
+ asking.set(notice.id, controller);
114
+ try {
115
+ const options = [...notice.actions.map((action, index) => `${index + 1}. ${action.label}`), STEER, DISMISS];
116
+ const picked = await ctx.ui.select(`sentinel: ${notice.summary}`, options, { signal: controller.signal });
117
+ if (controller.signal.aborted || notice.state !== "open")
118
+ return;
119
+ if (picked === STEER) {
120
+ const words = await ctx.ui.input("What should be done about it?", "", { signal: controller.signal });
121
+ if (controller.signal.aborted || notice.state !== "open")
122
+ return;
123
+ if (words === undefined || words.trim() === "")
124
+ waiting.unshift(notice);
125
+ else
126
+ session.answer(notice.id, { steer: words });
127
+ return;
128
+ }
129
+ const index = options.indexOf(picked ?? DISMISS);
130
+ session.answer(notice.id, index >= 0 && index < notice.actions.length ? { action: index } : { dismiss: true });
131
+ }
132
+ catch {
133
+ /* a dialog the chat lost - let go, or rebuilt - leaves the notice for the next */
134
+ }
135
+ finally {
136
+ asking.delete(notice.id);
137
+ open = false;
138
+ void ask();
139
+ }
140
+ };
141
+ pi.on("agent_settled", () => void ask());
142
+ return {
143
+ put(notice) {
144
+ try {
145
+ pi.appendEntry(NOTICE_ENTRY, notice);
146
+ }
147
+ catch {
148
+ /* the host still hears of it */
149
+ }
150
+ // A host answers it itself: its card, its buttons.
151
+ if (session.host.onNotice !== undefined)
152
+ return;
153
+ waiting.push(notice);
154
+ void ask();
155
+ },
156
+ concluded(conclusion) {
157
+ try {
158
+ pi.appendEntry(CONCLUDED_ENTRY, conclusion);
159
+ }
160
+ catch {
161
+ /* the agent is still told */
162
+ }
163
+ void pi.sendMessage({ customType: "turri_sentinel_concluded", content: concludedNote(conclusion), display: false, details: { jobId: conclusion.job, verdict: conclusion.verdict } }, { triggerTurn: true, deliverAs: "followUp" });
164
+ },
165
+ over(notice) {
166
+ asking.get(notice.id)?.abort();
167
+ const at = waiting.indexOf(notice);
168
+ if (at !== -1)
169
+ waiting.splice(at, 1);
170
+ try {
171
+ pi.appendEntry(NOTICE_OVER_ENTRY, { state: notice.state, summary: notice.summary });
172
+ }
173
+ catch {
174
+ /* a chat let go says nothing */
175
+ }
176
+ },
177
+ };
178
+ }
@@ -0,0 +1,55 @@
1
+ import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Job } from "./regenerate/jobs.ts";
3
+ import type { DeployedProcess } from "./regenerate/release.ts";
4
+ import { type Conclusion, type Notice, type NoticeInput } from "./notice.ts";
5
+ export * from "./notice.ts";
6
+ /**
7
+ * The sentinel: a chat of the session's own that every finished pulse goes to (docs/sentinel.md). It checks how each
8
+ * pulse went - and, with `release-after-pulse` on, tests what was released - finds out why when something is wrong, and
9
+ * puts it to the person as a notice in the chat they look at, with what they can do about it. It changes nothing: its
10
+ * file tools only read, its shell runs with the project read only, and it has none of the tools that change ***plain or
11
+ * start work. The chat the person talks to is never interrupted for a pulse that went well.
12
+ */
13
+ /** Whether the project wants a sentinel: `sentinel:` in config.turri.yaml, true unset. */
14
+ export declare function sentinelOn(cwd: string): boolean;
15
+ /** 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. */
16
+ export declare function specConflictNotice(job: Job, task: string): NoticeInput;
17
+ export declare function guidanceFile(cwd: string): string;
18
+ export declare function readGuidance(cwd: string): string;
19
+ export declare function keepGuidance(cwd: string, text: string): void;
20
+ /**
21
+ * What a chat of the person's is told of the pulses that end: told itself, of those asked for in it - or, with the
22
+ * sentinel on, nothing, the sentinel telling the person what needs them.
23
+ */
24
+ export declare function pulseEndsBlock(sentinel: boolean): string;
25
+ /**
26
+ * What the chat the person is in is told of a pulse that landed, with the sentinel on: that it landed, what the sentinel
27
+ * does with it - and to tell the person so, and do nothing else about it.
28
+ */
29
+ export declare function landedNote(job: Job, released: DeployedProcess[]): string;
30
+ /** Its identity and its task, in its system prompt in place of the conversation's. */
31
+ export declare function sentinelPrompt(cwd: string, options?: {
32
+ extraTools?: string[];
33
+ }): string;
34
+ /** What the sentinel is told of one finished pulse: the ledger's facts, the chats, and what was released. */
35
+ export declare function sentinelBrief(job: Job, context: {
36
+ project: string;
37
+ asked?: string;
38
+ focused?: string;
39
+ released: DeployedProcess[];
40
+ releaseAfterPulse: boolean;
41
+ reaction: string;
42
+ }): string;
43
+ /** The tools the sentinel's agent may call: what reads, its notices and its guidance - and what a host gives it. */
44
+ export declare const SENTINEL_TOOLS: string[];
45
+ /**
46
+ * Its file tools and shell: the whole project to read and nothing to write - the shell behind the command check, the OS
47
+ * sandbox where one is up, and the audit, with the network open. Its home and temporary folder are its own, in the
48
+ * modules' scratch.
49
+ */
50
+ export declare function sentinelFileTools(cwd: string): ToolDefinition[];
51
+ export declare function notifyTool(notify: (input: NoticeInput) => Notice | undefined): ToolDefinition;
52
+ export declare function concludeTool(conclude: (conclusion: Conclusion) => string | undefined): ToolDefinition;
53
+ /** What the chat the person is in is told when the sentinel is done with a pulse: to say so, and do nothing more. */
54
+ export declare function concludedNote(conclusion: Conclusion): string;
55
+ export declare function keepGuidanceTool(cwd: string): ToolDefinition;
@@ -0,0 +1,279 @@
1
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defineTool } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { readProjectConfig } from "./config.js";
6
+ import { confinedTools } from "./regenerate/confine.js";
7
+ import { describeSpecConflict } from "./regenerate/stop-report.js";
8
+ import { didNotPass } from "./regenerate/job-panel.js";
9
+ import { formatDuration, pulseName } from "./regenerate/jobs.js";
10
+ import { readLedger } from "./regenerate/ledger.js";
11
+ import { describePulseView, pulseView } from "./regenerate/ledger-view.js";
12
+ import { MODULES_DIR, ensureInScratch } from "./regenerate/paths.js";
13
+ import { SENTINEL, wentWrong } from "./notice.js";
14
+ export * from "./notice.js";
15
+ /**
16
+ * The sentinel: a chat of the session's own that every finished pulse goes to (docs/sentinel.md). It checks how each
17
+ * pulse went - and, with `release-after-pulse` on, tests what was released - finds out why when something is wrong, and
18
+ * puts it to the person as a notice in the chat they look at, with what they can do about it. It changes nothing: its
19
+ * file tools only read, its shell runs with the project read only, and it has none of the tools that change ***plain or
20
+ * start work. The chat the person talks to is never interrupted for a pulse that went well.
21
+ */
22
+ /** Whether the project wants a sentinel: `sentinel:` in config.turri.yaml, true unset. */
23
+ export function sentinelOn(cwd) {
24
+ try {
25
+ return readProjectConfig(cwd).sentinel !== false;
26
+ }
27
+ catch {
28
+ return true;
29
+ }
30
+ }
31
+ /** 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. */
32
+ export function specConflictNotice(job, task) {
33
+ return {
34
+ severity: "warning",
35
+ summary: `${pulseName(job)} stopped: two functional specs contradict each other`,
36
+ evidence: describeSpecConflict(job.specConflict).split("\n").filter((line) => line.trim() !== ""),
37
+ actions: [{ label: "Work out the conflict with me", task }],
38
+ ...(job.module !== undefined ? { module: job.module } : {}),
39
+ job: job.id,
40
+ ...(job.pulse !== undefined ? { pulse: job.pulse } : {}),
41
+ };
42
+ }
43
+ // ---------------------------------------------------------------------------------------------
44
+ // The person's guidance: what they told the sentinel, kept apart from its conversation.
45
+ export function guidanceFile(cwd) {
46
+ const scratch = join(cwd, ".turri");
47
+ ensureInScratch(scratch, scratch);
48
+ return join(scratch, "sentinel.md");
49
+ }
50
+ export function readGuidance(cwd) {
51
+ try {
52
+ const file = join(cwd, ".turri", "sentinel.md");
53
+ return existsSync(file) ? readFileSync(file, "utf8").trim() : "";
54
+ }
55
+ catch {
56
+ return "";
57
+ }
58
+ }
59
+ export function keepGuidance(cwd, text) {
60
+ const line = text.replace(/\s+/g, " ").trim();
61
+ if (line === "")
62
+ return;
63
+ appendFileSync(guidanceFile(cwd), `- ${line} (${new Date().toISOString().slice(0, 16).replace("T", " ")})\n`);
64
+ }
65
+ // ---------------------------------------------------------------------------------------------
66
+ // What the sentinel is told.
67
+ /**
68
+ * What a chat of the person's is told of the pulses that end: told itself, of those asked for in it - or, with the
69
+ * sentinel on, nothing, the sentinel telling the person what needs them.
70
+ */
71
+ export function pulseEndsBlock(sentinel) {
72
+ return sentinel
73
+ ? [
74
+ "<pulse-ends>",
75
+ "The sentinel - a chat of my own, beside this one - checks every pulse that finishes and tests what was released.",
76
+ "When a pulse lands, the chat the person is in is told, to tell them it landed and what the sentinel does with it -",
77
+ "and nothing more: the pulse is the sentinel's. A pulse that went wrong you are not told of. What needs the person, the",
78
+ "sentinel puts to them as a notice in the chat they are in: what is wrong, and what can be done. When they accept",
79
+ "one, you are given it, and what they chose: do it.",
80
+ "</pulse-ends>",
81
+ ].join("\n")
82
+ : "<pulse-ends>\nWhen a pulse asked for in this chat ends, you are told, and react to it.\n</pulse-ends>";
83
+ }
84
+ /**
85
+ * What the chat the person is in is told of a pulse that landed, with the sentinel on: that it landed, what the sentinel
86
+ * does with it - and to tell the person so, and do nothing else about it.
87
+ */
88
+ export function landedNote(job, released) {
89
+ const wasReleased = job.details?.released === true;
90
+ const where = released.map((item) => `${item.name} at ${item.url ?? item.command}`).join(", ");
91
+ return [
92
+ `Pulse ${job.id} - ${pulseName(job)} - landed, after ${formatDuration(job)}.`,
93
+ "",
94
+ (job.report ?? "").trim(),
95
+ "",
96
+ `The sentinel - a chat of my own beside this one - is checking it now${wasReleased ? `, and testing what was released${where !== "" ? ` (${where})` : ""}` : ""}. If it finds something wrong, it puts it to the person in the chat they are in, with what can be done; when it is done, you are told.`,
97
+ "",
98
+ `Tell the person, in a line or two, what landed and that the sentinel is checking it${wasReleased ? " and testing the release" : ""}. Do nothing else about it: the pulse is the sentinel's now. Do not test it, release it, open it, run anything for it, or start other work because of it - whatever the project's guidance says to do when a pulse finishes.`,
99
+ ].join("\n");
100
+ }
101
+ /** Its identity and its task, in its system prompt in place of the conversation's. */
102
+ export function sentinelPrompt(cwd, options = {}) {
103
+ const guidance = readGuidance(cwd);
104
+ return [
105
+ "<sentinel>",
106
+ "You are turri's sentinel: a chat of turri's own, beside the chats the person talks to while they build their software",
107
+ "with turri. Every pulse that finishes comes to you, as a message turri writes - what the pulse was, how it ended,",
108
+ "its ***plain diff, its log, and what was released. The person does not watch you: they are in another chat, and",
109
+ "hear from you only through notify_person.",
110
+ "",
111
+ "For each pulse:",
112
+ "1. Check how it went, from what turri tells you - and, when that is not enough, its log, the ledger (ledger, pulses),",
113
+ " diagnose, and the code and the ***plain, which you read.",
114
+ "2. When it was released (the message says so - release-after-pulse is on), test what was released: it is running",
115
+ " where the message says. How far you test is yours to judge, from what the pulse changed and what the ***plain says",
116
+ " it should do: that it came up, answers, and does what its ***plain diff says it now does.",
117
+ "3. When something is wrong - the pulse failed or stopped, it did not pass, the release did not come up, the software",
118
+ " does not do what its ***plain says - find out why, and call notify_person once: what is wrong, what shows it,",
119
+ " and what the person can do about it, each a task the agent of their chat can carry out once they accept it - a",
120
+ " change to the ***plain, a setting, a credential to provide, a pulse to regenerate again. Be exact: the task is",
121
+ " all that agent is given.",
122
+ "4. When all is well, notify no one.",
123
+ "5. Last, call conclude once for the pulse - holds or wrong, and in a line or two what you checked and what you found:",
124
+ " the chat the person is in is told, whether all is well or not. Then say the same here, in a line or two.",
125
+ "",
126
+ "You change nothing. Your file tools read the whole project and write nothing; your shell runs with the project",
127
+ "read only. You never regenerate, steer, release or change ***plain yourself, and never ask the person questions -",
128
+ "a notice is how you reach them. A newer message about the same module can arrive while you check an older pulse",
129
+ "that went well: leave the older one and check the newer.",
130
+ "",
131
+ "The person can talk to you here. When they tell you how to check - what to test, what to leave alone, where things",
132
+ "run - keep it with keep_guidance, in their words, so it holds from now on.",
133
+ ...(options.extraTools !== undefined && options.extraTools.length > 0 ? ["", `The host gives you ${options.extraTools.join(", ")} to test with as well.`] : []),
134
+ "</sentinel>",
135
+ ...(guidance !== "" ? ["", "<guidance>", "What the person told you to keep in mind:", guidance, "</guidance>"] : []),
136
+ ].join("\n");
137
+ }
138
+ /** What the sentinel is told of one finished pulse: the ledger's facts, the chats, and what was released. */
139
+ export function sentinelBrief(job, context) {
140
+ const view = (() => {
141
+ try {
142
+ const entries = readLedger(context.project, { job: job.id });
143
+ const found = pulseView(job.id, entries);
144
+ return found !== undefined ? describePulseView(found) : undefined;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ })();
150
+ const ended = job.status === "succeeded"
151
+ ? didNotPass(job)
152
+ ? "ran to its end, and what it built did not pass"
153
+ : "finished, and landed what it built"
154
+ : job.status === "stopped"
155
+ ? "was stopped"
156
+ : "failed";
157
+ const lines = [`Pulse ${job.id} - ${pulseName(job)} - ${ended}, after ${formatDuration(job)}.`];
158
+ if (context.asked !== undefined)
159
+ lines.push(`It was asked for in the chat "${context.asked}".`);
160
+ if (context.focused !== undefined)
161
+ lines.push(`The person is in the chat "${context.focused}": a notice goes there.`);
162
+ lines.push("", job.status === "succeeded" ? (job.report ?? "").trim() : (job.error ?? job.note ?? "").trim());
163
+ if (view !== undefined)
164
+ lines.push("", "What the ledger has of it:", view);
165
+ else if (job.diff !== undefined && job.diff.trim() !== "")
166
+ lines.push("", "Its ***plain diff:", job.diff.trimEnd());
167
+ if (job.logFile !== undefined)
168
+ lines.push("", `Its log: ${job.logFile}`);
169
+ const released = job.details?.released === true;
170
+ if (released) {
171
+ lines.push("", "It was released, as its last stage (release-after-pulse). What runs now:");
172
+ if (context.released.length === 0)
173
+ lines.push(" nothing is running for it: the release did not leave anything running.");
174
+ for (const item of context.released)
175
+ lines.push(` ${item.name}: ${item.url ?? item.command} - in ${item.dir}, its output in ${item.log}`);
176
+ lines.push("Test it.");
177
+ }
178
+ else if (job.status === "succeeded" && !didNotPass(job)) {
179
+ lines.push("", context.releaseAfterPulse ? "Nothing was released after it." : "release-after-pulse is off: nothing was released, and you test nothing running - check the pulse alone.");
180
+ }
181
+ if (job.specConflict !== undefined) {
182
+ lines.push("", "It stopped on two functional specs that contradict each other: turri has put that to the person itself. Notify no one about it.");
183
+ }
184
+ else if (wentWrong(job)) {
185
+ lines.push("", "It went wrong: find out why, and notify the person. What the chat would have been told to do about it - each step of it", "that changes or installs something is a task for a notice's action, not yours:", context.reaction);
186
+ }
187
+ lines.push("", `When you are done with it, call conclude for ${job.id} - whether all is well or not.`);
188
+ return lines.join("\n");
189
+ }
190
+ // ---------------------------------------------------------------------------------------------
191
+ // Its tools.
192
+ /** The tools the sentinel's agent may call: what reads, its notices and its guidance - and what a host gives it. */
193
+ export const SENTINEL_TOOLS = ["read", "ls", "grep", "find", "bash", "read_output", "ledger", "pulses", "diagnose", "validate", "doctor", "web_search", "web_fetch", "notify_person", "conclude", "keep_guidance"];
194
+ /**
195
+ * Its file tools and shell: the whole project to read and nothing to write - the shell behind the command check, the OS
196
+ * sandbox where one is up, and the audit, with the network open. Its home and temporary folder are its own, in the
197
+ * modules' scratch.
198
+ */
199
+ export function sentinelFileTools(cwd) {
200
+ return confinedTools({ workdir: cwd, read: [cwd], write: [], shellHome: join(cwd, MODULES_DIR, `.${SENTINEL}`) }).tools;
201
+ }
202
+ export function notifyTool(notify) {
203
+ return defineTool({
204
+ name: "notify_person",
205
+ label: "Notify",
206
+ description: "Put what is wrong with a finished pulse to the person, in the chat they are in: what is wrong, what shows it, and what " +
207
+ "they can do about it. Each action is a task the agent of their chat carries out once they accept it. Only when they " +
208
+ "need to act - never for a pulse that went well.",
209
+ parameters: Type.Object({
210
+ severity: Type.Union([Type.Literal("error"), Type.Literal("warning"), Type.Literal("info")], { description: "error: it is broken; warning: it works but not as its ***plain says, or something is missing; info: worth their knowing" }),
211
+ summary: Type.String({ description: "What is wrong, in one line" }),
212
+ evidence: Type.Array(Type.String(), { description: "What shows it, a line each: a step that failed, a log's lines, a request and its answer" }),
213
+ actions: Type.Array(Type.Object({
214
+ label: Type.String({ description: "What the person accepts, in a few words: \"Change the ***plain to …\", \"Regenerate fs 3 again\"" }),
215
+ task: Type.String({ description: "Exactly what their chat's agent does once they accept it - all it is given" }),
216
+ }), { description: "What can be done, the best first" }),
217
+ module: Type.Optional(Type.String({ description: "The module it is about" })),
218
+ pulse: Type.Optional(Type.String({ description: "The pulse's id" })),
219
+ }),
220
+ async execute(_id, params) {
221
+ const notice = notify({
222
+ severity: params.severity,
223
+ summary: params.summary,
224
+ evidence: params.evidence,
225
+ actions: params.actions,
226
+ ...(params.module !== undefined ? { module: params.module } : {}),
227
+ ...(params.pulse !== undefined ? { job: params.pulse } : {}),
228
+ });
229
+ return {
230
+ content: [{ type: "text", text: notice !== undefined ? `Put to the person, in the chat they are in (notice ${notice.id}).` : "There is no chat to put it in: say it here." }],
231
+ details: { notice: notice?.id },
232
+ };
233
+ },
234
+ });
235
+ }
236
+ export function concludeTool(conclude) {
237
+ return defineTool({
238
+ name: "conclude",
239
+ label: "Conclude",
240
+ description: "Say you are done with a pulse - whether what you checked holds, and in a line or two what you checked and found. The chat " +
241
+ "the person is in is told, whether all is well or not. Once a pulse, last - after notify_person, when something is wrong.",
242
+ parameters: Type.Object({
243
+ pulse: Type.String({ description: "The pulse's id" }),
244
+ verdict: Type.Union([Type.Literal("holds"), Type.Literal("wrong")], { description: "holds: it does what its ***plain says; wrong: it does not, or it went wrong" }),
245
+ summary: Type.String({ description: "What you checked and what you found, in a line or two: \"GET /api/ping answers pong; the new alias route redirects\"" }),
246
+ }),
247
+ async execute(_id, params) {
248
+ const chat = conclude({ job: params.pulse, verdict: params.verdict, summary: params.summary });
249
+ return {
250
+ content: [{ type: "text", text: chat !== undefined ? "The chat the person is in is told." : "There is no chat to tell: say it here." }],
251
+ details: {},
252
+ };
253
+ },
254
+ });
255
+ }
256
+ /** What the chat the person is in is told when the sentinel is done with a pulse: to say so, and do nothing more. */
257
+ export function concludedNote(conclusion) {
258
+ const about = conclusion.module !== undefined ? `${conclusion.module}${conclusion.pulse !== undefined ? ` pulse ${conclusion.pulse}` : ""}` : `pulse ${conclusion.job}`;
259
+ return [
260
+ `The sentinel is done with ${about} (${conclusion.job}): ${conclusion.verdict === "holds" ? "it holds" : "something is wrong"}.`,
261
+ conclusion.summary.trim(),
262
+ "",
263
+ conclusion.verdict === "holds"
264
+ ? "Tell the person, in a line, that the sentinel checked it and it holds - and what it checked. Do nothing else about it."
265
+ : "Tell the person, in a line, that the sentinel found something wrong, and that its notice in this chat says what and what can be done. Do nothing else about it until they answer the notice.",
266
+ ].join("\n");
267
+ }
268
+ export function keepGuidanceTool(cwd) {
269
+ return defineTool({
270
+ name: "keep_guidance",
271
+ label: "Keep guidance",
272
+ description: "Keep what the person told you about how to check their software, in their words: it is in your prompt from now on, after you are closed and opened again too.",
273
+ parameters: Type.Object({ text: Type.String({ description: "What to keep in mind, as they said it" }) }),
274
+ async execute(_id, params) {
275
+ keepGuidance(cwd, params.text);
276
+ return { content: [{ type: "text", text: "Kept." }], details: {} };
277
+ },
278
+ });
279
+ }
package/docs/README.md CHANGED
@@ -17,6 +17,7 @@ turri is the first coding agent with the power of regenerative software: [\*\*\*
17
17
  | what turri needs of this machine, and what `turri doctor` checks | [installation](installation.md#what-turri-doctor-checks) |
18
18
  | how to start a project, or add a feature, in a conversation | [using](using.md#in-a-conversation) |
19
19
  | how to have several chats open at once, and reopen them | [chats](chats.md) |
20
+ | what checks each pulse as it finishes, and tells me what needs me | [the sentinel](sentinel.md) |
20
21
  | which model codes, and how to change it | [using](using.md#choosing-a-model), [the project config](config.md#models) |
21
22
  | what `config.turri.yaml` can set, and what happens without it | [the project config](config.md) |
22
23
  | how a backend and a frontend on different stacks share one project | [the project config](config.md#stacks) |
@@ -42,6 +43,7 @@ turri is the first coding agent with the power of regenerative software: [\*\*\*
42
43
  | [Installation](installation.md) | doctor, what turri needs of this machine, turri and `pi`, environment variables |
43
44
  | [Using turri](using.md) | the conversation, the tools, background jobs, choosing a model, module history, the command line |
44
45
  | [Chats](chats.md) | several chats in one session: what each keeps and what they share, `/chat` and its keys, `turri --continue` and `.turri/chats.json` |
46
+ | [The sentinel](sentinel.md) | the chat every finished pulse goes to: what it checks and tests, its notices and how to answer them, what it may do, closing it, its model, in a host |
45
47
  | [The project config](config.md) | `config.turri.yaml` and each stack's `config.turri.<stack>.yaml`: where they are read from, every key, models and their inheritance, conformance, the testing scripts, stacks and choosing one, the old `config.yaml` |
46
48
  | [Deploy](deploy.md) | trying what is built: dist/<module>, the last commit, starting it on your machine, `turri release` |
47
49
  | [Running unattended](headless.md) | regenerating overnight or in CI: before you leave it, the command, the results, resuming, rebuilding one functionality |
package/docs/chats.md CHANGED
@@ -21,7 +21,7 @@ The session's, shared by every chat:
21
21
  - **what `activate` started** - it runs until turri ends or `/halt activated <name>`, whatever chats are opened or closed;
22
22
  - **the ledger** - one run, each event with the id of the chat its work was asked for in (`chat`), and `chat.opened` and `chat.closed` as they happen.
23
23
 
24
- A pulse's end is told to the chat that asked for it, shown or not - its agent reacts to it there; the chat's mark in the strip says it has something new. When that chat has been closed, the chat shown is told. Closing a chat stops its turn, never its pulses.
24
+ A pulse's end goes to the [sentinel](sentinel.md) - a chat of turri's own, opened in the background with the first pulse that finishes - which checks it, tests what was released, and puts what needs you as a notice in the chat you are in. That chat shows the pulse's end; its agent is told of a pulse that landed, to tell you so and what the sentinel is doing with it - and when the sentinel is done with any pulse, what it found - and nothing more - and not of one that went wrong: the sentinel's notice is what it hears of that. With `sentinel: false`, a pulse's end is told to the chat that asked for it instead, shown or not - its agent reacts to it there; the chat's mark in the strip says it has something new. When that chat has been closed, the chat shown is told. Closing a chat stops its turn, never its pulses.
25
25
 
26
26
  ## In the terminal
27
27
 
@@ -45,13 +45,14 @@ On macOS, `alt` is Option (⌥), and reaches turri only when the terminal sends
45
45
  - **The side panel** (`ctrl+h`) is the session's, not a chat's: docked beside the chat or over it, it stays as another chat is shown, and opening it from any chat finds it as it was left.
46
46
  - **A long command** - `/regenerate`, planning a module from scratch - does its work in the background once it is under way: the chat, and switching to another, stay free while the planner plans.
47
47
  - **`ctrl+c`** reaches the chat shown and nothing else: another chat's turn and skill go on, and so do the pulses - `/halt` stops one. A skill stopped keeps its run, for the model to continue with `resume`. At a question - the chat's `ask_user`, a skill's interview - `ctrl+c` declines it, and the asker is told so and goes on - each question it is pressed at, however many in a row. A skill is stopped by `ctrl+c` with no question open. A second press straight after one that stopped something does nothing, so pressing twice to be sure does not leave turri; once the chat is doing nothing, the next one does - with what every other chat is doing. A host has the same as `interrupt(id)`, and `decline(id)` for a question ([hosting](hosting.md)).
48
- - **The last chat** is not closed: `/quit` leaves turri. Leaving turri leaves the terminal as it was: the conversation is not written out to it, nor a line on how to resume - the command run is all that stays. `turri --continue` opens the chats again.
48
+ - **The sentinel** is a chat in the strip like the others, `sentinel`, and never shown by itself: it closes like any chat, and the next pulse that finishes opens it again. With it on, you open up to eight chats - the ninth slot is its. See [the sentinel](sentinel.md).
49
+ - **The last chat** of yours is not closed: `/quit` leaves turri. Leaving turri leaves the terminal as it was: the conversation is not written out to it, nor a line on how to resume - the command run is all that stays. `turri --continue` opens the chats again.
49
50
  - **`/fork`** and **`/reload`** work on the chat shown, as [pi](https://pi.dev)'s do: the chat's history forked, or turri's extension rebuilt in it.
50
51
  - **A chat shown mid-turn** draws its reply as far as it is written, and follows it from there.
51
52
 
52
53
  ## Coming back
53
54
 
54
- `turri --continue` (`-c`) opens every chat of the last session here again, in their order - the first shown, the others beside it. A plain `turri` starts with one fresh chat and deletes nothing: the chats of the sessions before stay on disk, and `--continue` opens the most recent one's - the last session that recorded a chat.
55
+ `turri --continue` (`-c`) opens every chat of the last session here again, in their order - the first of yours shown, the others beside it, the sentinel among them. A plain `turri` starts with one fresh chat and deletes nothing: the chats of the sessions before stay on disk, and `--continue` opens the most recent one's - the last session that recorded a chat.
55
56
 
56
57
  turri records which chats are open in `.turri/chats.json` - a run a session, each chat with its title and [pi](https://pi.dev)'s saved conversation - and keeps it from piling up:
57
58
 
package/docs/config.md CHANGED
@@ -47,7 +47,7 @@ Each change says when it takes effect. None needs turri started again - what tur
47
47
  | the next pulse - one running keeps what it started with | `coding-model`, `judge-model`, every other `models.<role>`, `review`, `mutation-testing`, `turn-limit`, `compact-at`, `agent-env`, `toolchains`, `debug` |
48
48
  | the next time the work is planned | `conformance-tests`, `conformance-budget`, `max-specs-per-pulse`, `max-lines-per-pulse` |
49
49
  | the next test run - the running pulse's too | the testing scripts, `testing-scripts-run-on` |
50
- | the next pulse that lands - the running one too | `release-after-pulse` |
50
+ | the next pulse that lands - the running one too | `release-after-pulse`, `sentinel` |
51
51
  | the next regeneration that names no `.plain` file | `module` |
52
52
  | the next time turri starts here | `ledger-keep`, `cache-keep` |
53
53
 
@@ -90,6 +90,7 @@ models: # each agent role's model, thinking and tur
90
90
  | `rules-off` | a list of rule names - `integrations`, `concise-specs`, the files of `genome/rules/` without `.md` - left out of the conversation's prompt and every \*\*\*plain-writing agent's: a rule this project's own skills or AGENTS.md replace, such as one that says to probe a live API before any \*\*\*plain is written. `turri init-config` writes it with `integration-embedded`, `integration-embedded-testing` and `integration-standalone` - take them out when the project writes an integration against a REST API. The project's alone | none |
91
91
  | `allow-to-stop-jobs` | `true` or `false`: whether turri stops a running job \*\*\*plain has moved on from - a functional spec it regenerates changed, or a file one links was edited - rolls it back and regenerates \*\*\*plain as it now is. `false`: the job runs to its end and lands, and what changed since is regenerated on top of it by the next job - see [regenerating the code](regenerating.md). Your own `halt` stops a job either way | `true` |
92
92
  | `release-after-pulse` | `true` or `false`: whether every pulse that lands code releases it, in the chat, as its last stage - before the module's next pulse starts, whatever the chat is doing: an agent on the coding model releases it, following the project's release guidance as the chat finds it (what was passed to `release` this conversation, AGENTS.md, the project's skills) for how, or to `dist/<module>` without any - see [deploy](deploy.md#after-every-pulse). One release a pulse that lands; a release that fails is said in the pulse's report, and what it landed stays. `turri init-config` writes `true` | `false` |
93
+ | `sentinel` | `true` or `false`: whether a chat of turri's own, the sentinel, gets every pulse that finishes - checks it, tests what was released under `release-after-pulse`, and puts what needs you as a notice in the chat you are in - instead of the chat that asked for it. See [the sentinel](sentinel.md). `turri init-config` writes `true` | `true` |
93
94
  | `unittests-script` | the unit testing script, by path from the project root - it runs the code's static checks too, exiting 80 when only they fail (see [the static checks](regenerating.md#the-static-checks)) | looked for - see below |
94
95
  | `conformance-tests-script` | the conformance testing script, by path from the project root | looked for - see below |
95
96
  | `prepare-environment-script` | optional: run as `<script> <build>` before every run of the conformance testing script, which runs only once it passes - see [the testing scripts](regenerating.md#the-testing-scripts). turri never writes one | the one `prepare_environment*.sh` at the root or in `test_scripts/`, else none |
@@ -116,6 +117,7 @@ Keys are written with dashes; underscores are read the same (`conformance_tests`
116
117
  | --- | --- |
117
118
  | `default` | every role not named below - but the conversation's side |
118
119
  | `chat` | `turri`'s model - the same as `harness-model`, which it wins over |
120
+ | `sentinel` | [the sentinel](sentinel.md): checks every finished pulse, and tests the release. Unnamed, the chat's model |
119
121
  | `chooser`, `planner` | the conversation's side: the conformance chooser of `turri regenerate`, the planner's agents. Unnamed, the chat's model |
120
122
  | `diagnosis` | the diagnosis of a job that failed for good: why it did not land, and what to do. Unnamed, the coding model the job ran on, thinking medium |
121
123
  | `release` | `turri release`'s agent and release-after-pulse's: it follows steps - a script, a start command - so it runs on the coding model, thinking low |
package/docs/deploy.md CHANGED
@@ -11,7 +11,7 @@ Every release starts the same way: the module's last commit - what last landed w
11
11
 
12
12
  Guidance is whatever says it, in any words - a line in AGENTS.md such as *integrations are released with `scripts/deploy_integrations.sh`, then the app is started with `scripts/start.sh`*, or the same said in the chat.
13
13
 
14
- The chat reacts to every pulse that finishes as it finishes, without waiting for you to speak: guidance that says to release after a build - *release each integration once it is regenerated* - is followed then. It never has to work out whether what a pulse landed is already out: `release` does. When the module's last commit is what its last release in this conversation shipped, it does nothing - nothing exported, nothing stopped, what runs left running - and says so; `again: true` releases it anyway, for when what that release said to do was not done.
14
+ With `sentinel: false`, the chat reacts to every pulse that finishes as it finishes, without waiting for you to speak: guidance that says to release after a build - *release each integration once it is regenerated* - is followed then. With the [sentinel](sentinel.md) on - the default - the chat is told only that a pulse landed, and does nothing about it: guidance to follow once a pulse finishes is not followed, and releasing after every pulse is `release-after-pulse`'s. It never has to work out whether what a pulse landed is already out: `release` does. When the module's last commit is what its last release in this conversation shipped, it does nothing - nothing exported, nothing stopped, what runs left running - and says so; `again: true` releases it anyway, for when what that release said to do was not done.
15
15
 
16
16
  ## What goes in dist
17
17
 
@@ -33,7 +33,7 @@ What it started is listed by the `pulses` tool and by **`/halt`**, and stopped b
33
33
 
34
34
  With `release-after-pulse: true` in `config.turri.yaml`, turri releases what each pulse lands itself, as the pulse's last stage (`releasing what this pulse landed`): the pulse ends once it is released, so the module's next pulse starts on what is out, and the chat - waiting on your answer to a question, say - has no part in it. One release a pulse that lands, in order.
35
35
 
36
- The release is done by an agent on the coding model, thinking low - it follows steps, and a small model does that well - the same one `turri release` runs, and it finds the guidance as the chat would: what was passed to `release` earlier in the conversation, the project's AGENTS.md and its skills. The setting is the word to release; the guidance says only how - without any, the code goes to `dist/<module>` and is started from there. Its last words go in the pulse's report - "Released after the pulse: ..." - and the chat, seeing them, does not release it again: it says in a line what was released, then does what it was told to do once something is released - a host such as a desktop app tells it, in its `surface`, to open it where the person can watch and say what to test. A release that fails is said there too; what the pulse landed stays.
36
+ The release is done by an agent on the coding model, thinking low - it follows steps, and a small model does that well - the same one `turri release` runs, and it finds the guidance as the chat would: what was passed to `release` earlier in the conversation, the project's AGENTS.md and its skills. The setting is the word to release; the guidance says only how - without any, the code goes to `dist/<module>` and is started from there. Its last words go in the pulse's report - "Released after the pulse: ..." - and the [sentinel](sentinel.md) is told what was released and where it runs, and tests it: what needs you comes as a notice in the chat you are in, and the chat you are in says only that it landed and is being tested. With `sentinel: false`, the chat, seeing the report, does not release it again: it says in a line what was released, then does what it was told to do once something is released - a host such as a desktop app tells it, in its `surface`, to open it where the person can watch and say what to test. A release that fails is said there too; what the pulse landed stays.
37
37
 
38
38
  Off without the key: releasing after a pulse is then the chat's, when its guidance says so. `turri init-config` writes `release-after-pulse: true`.
39
39
 
@@ -35,6 +35,7 @@ The workflow signs in to npm with npm's trusted publishing (OIDC), not a token:
35
35
  | `src/setup.ts`, `src/doctor-tool.ts`, `src/environment.ts`, `src/stack.ts` | `turri doctor` and the chat's `doctor` tool, and what they check - this machine, and in a project what its modules need of it |
36
36
  | `src/extension.ts` | the [pi](https://pi.dev) extension: every tool and command the conversation has - and `createTurriCore`, the session it is built into |
37
37
  | `src/chats.ts`, `src/chat-scope.ts`, `src/chat-records.ts` | the session's [chats](chats.md): which chat a pulse is told to, each chat's scope - its id and stack - and `.turri/chats.json` |
38
+ | `src/sentinel.ts`, `src/sentinel-chat.ts` | [the sentinel](sentinel.md): its prompt, what it is told of a pulse, its fenced tools, its guidance; notices - their cards, the terminal's dialog, their answers |
38
39
  | `src/chat-terminal.ts`, `src/chat-ui.ts`, `src/chat-commands.ts` | the terminal's chats: [pi](https://pi.dev)'s screen switched between live sessions, a hidden chat's stand-in for it, `/chat` and its keys |
39
40
  | `src/host-session.ts` | `createTurri`, the session for a host |
40
41
  | `src/skill.ts` | parses `SKILL.md` into steps and gates |