A grab bag of hunches — experimental skills and plugin features tried here before they earn a place in hope or sound. Everything graduates or dies. The tend…

Why introduce friction? Because YOU the human end up being the world model. The agent is just your hands.
moo/hope doesn't build, and doesn't author your code, rules, or taste. It works alongside build tooling like superpowers.
One idea runs through every layer: never delegate a decision.
Two ways in, two philosophies. The Claude Code plugin installs each layer as a managed bundle that updates when I ship — you subscribe rather than fork. skills.sh copies editable skill files into your project, on any agent, so you can make them your own. Pick one — installing both leaves you with every skill twice.
/plugin marketplace add saadshahd/moo.md
/plugin install hope@moo.md
hope is the decision loop, and the reason to be here. More layers ship from the same marketplace: hold@moo.md, comprehension at your own pace, sound@moo.md, code taste as installable rules, and hunch@moo.md, experiments that graduate or die.
npx skills@latest add saadshahd/moo.md
Pick the skills you want and the agents to install them on — but take hope and hold whole if you take them at all: their skills call each other by name, and a missing one stops the skill that called it. sound and hunch have no such requirement.
They land as ordinary files you own — flat, and without the plugin prefix, so hope:intent arrives as intent and sound:review as review. Watch for collisions with skills you already have.
/hope:full <your seed>
Or run the stages directly — /hope:intent <your seed>, then /hope:shape <your intent>.
The loop is pure decision work. You drive it slowly, in your own context.
Every piece of work starts as a raw <seed>, the rough thing you fully typed. It is the one artifact that is fully yours.
/intent and /shape do not make the seed more honest. They make it explicit and specified. Each surfaces a decision as an interactive question, every choice previewed, a few at a time, and you answer. Every added detail stays yours because you chose it. /intent turns the <seed> into a confirmed statement of what you want. /shape turns that <work order> into a chosen approach before any code exists.
The outcome is short enough to carry comfortably outside the context window.

freeze anchors the work to what is observed, not what you remember. When a stage depends on state that lives outside the repo — a service, a database, a queue, live logs — that state keeps moving, and memory of it goes stale the moment you look away.
freeze snaps the slice your work touches into one immutable value: every fact observed live or named as an open gap, never inferred. The stages decide against a fact, not a guess. It is the ground the other three stand on — and repo-local work skips it.

router is the line between deciding and doing.
You keep every decision. The session spawns agents and verifies their output, doing no work itself — only tactical, observable work fans out: implement, test, verify, audit, explore.
This is also what stops compaction from silently rewriting your context. The verbose doing never enters your main thread, so it can never quietly mutate what you decided.

| The trap | Layer | Guard |
|---|---|---|
| AI fills in your decisions | /hope:intent & /hope:shape | interactive questions, each choice previewed |
| compaction mutates & drifts your context | /hope:router | doing stays out, deciding stays in |
| stale or remembered external state | /hope:freeze | snapshot facts, never infer |
hooks/tend.tsx 1273 lines1import type { Register } from "claude-code";
2import { MEASURE, type Band, type Item } from "./band.tsx";
3import { EDITS, MOVES, SLOTS, heldReason, saysGo, unquoted, userWords, type Said } from "./gate.tsx";
4import { changes, isSteering, isSteeringPath, memoryRows, places, retrieved, type Places, type Snapshot, type Steering } from "./memory.tsx";
5
6export type Question = { q: string; options: string[] };
7/** One expert's idea to try; `test` pits it against the version that exists. */
8export type Idea = { by: string; idea: string; why?: string; test?: string };
9export type Card = {
10 intent?: string;
11 shape?: string;
12 /** An agent's own one-line answer; the session's card has none. */
13 outcome?: string;
14 facts?: string[];
15 questions?: Question[];
16 ideas?: Idea[];
17 skills?: { name: string; outcome: string }[];
18 watch?: { label: string; open: string; see?: string }[];
19 said?: Said;
20};
21type Msg = {
22 role: string;
23 text: string;
24 toolUses?: { tool: string; input: Record<string, unknown> }[];
25 toolResults?: { text: string }[];
26};
27/** `read`: opened once, drawn dim in the agents list. */
28export type Row = { id: string; label: string; type: string; done: boolean; read?: true };
29
30// What a card's facts are, for the session and its agents alike.
31const FACTS = "what the user should carry forward — durable, in plain words, no file paths, tool steps or change details";
32
33export const CARD_FORMAT = `End your reply with a \`\`\`card JSON block of what it settled; omit unchanged keys, [] clears a list:
34{"intent":"…","shape":"…","said":{"goal":"…","done":"…"},"facts":["…"],"questions":[{"q":"…","options":["…"]}],"skills":[{"name":"hope:…","outcome":"…"}],"watch":[{"label":"…","open":"url|path|pane:path","see":"…"}]}
35said: the user's words copied exactly, never reworded: goal — ${SLOTS.goal}; done — ${SLOTS.done}. facts: ${FACTS}; one that settles a choice names what lost. questions: every question still open; the user's next prompt closes them all, so restate any still open. skills: the planned skills in order. watch: where a human looks and what should appear there, never agent state.
36The user cites items by 1-based position: \`fact 2: …\`, \`q1: <option> — …\`.`;
37// Asked of every agent the session starts, so its pane reads like the session's card.
38export const AGENT_CARD = `End your final answer (your last reply, or your last message to the lead) with a \`\`\`card JSON block: {"intent":"…","outcome":"…","facts":["…"]}. intent: what you set out to do. outcome: your answer in one line. facts: ${FACTS}.`;
39// Asked of the session when one of its agents returns: the result sits in the agents pane, not the thread.
40export const AGENT_RETURN = `The user did not send this agent's return; its result sits in their agents pane. Carry on any work it unblocks. Then, if the result changes what the user does next, tell them in one line. Otherwise your reply is only an empty card block:
41\`\`\`card
42{}
43\`\`\``;
44// Asked of consult, so its ideas list in the band to pick from.
45export const IDEAS_FORMAT = `End your reply with a \`\`\`card JSON block of the ideas, and your yes/no as its one question:
46{"ideas":[{"by":"<expert>","idea":"…","why":"…","test":"…"}],"questions":[{"q":"…","options":["yes","no"]}]}
47test: only where this idea's win differs from the one the reply names for all. The question is the reply's last line, word for word. The user cites an idea by its 1-based position: \`idea 2: …\`.`;
48// The card each skill is asked for.
49const CARD_SKILLS = new Map([
50 ...["hope:intent", "hope:shape", "hope:clarify", "hope:elicit", "hope:draft", "hope:compose"].map((k) => [k, CARD_FORMAT] as const),
51 ["hope:consult", IDEAS_FORMAT],
52]);
53// A card opens at a line's start: one quoted inside a reply (`> ```card`) is text, not a card.
54const CARD_RE = /(?<=^|\n)```card[^\S\n]*\n([\s\S]*?)\n```[^\S\n]*\n?/g;
55const PANE = "tend";
56
57const PAD = 2;
58const CHIPS = ["intent", "shape", "facts", "ideas", "questions", "skills", "watch", "memory", "agents"] as const;
59
60// ---- pure ----
61
62// Also hides a block still streaming in, before its closing fence.
63export function stripCards(text: string): string {
64 return text.replace(CARD_RE, "").replace(/(?<=^|\n)```card[\s\S]*$/, "").trimEnd();
65}
66
67// A reply with more prose than two 80-column lines is redrawn short; tables and code don't count.
68const PROSE_MAX = 160;
69
70/** The reply's prose: what is left once fenced code and table rows are out. */
71export function prose(text: string): string {
72 return text
73 .replace(/(?<=^|\n)```[\s\S]*?(\n```|$)/g, "")
74 .split("\n")
75 .filter((l) => !l.trimStart().startsWith("|"))
76 .join("\n")
77 .trim();
78}
79
80/** A reply to "explain" or "walk me through" is long because the user asked for long. */
81export function wantsShort(reply: string, prompt: string): boolean {
82 return prose(reply).length > PROSE_MAX && !/\bexplain\b|walk me through/i.test(prompt);
83}
84
85// What won the user's blind ranking of real replies: short, but never a fact cut they'd act on.
86export function shortAsk(reply: string): string {
87 return `Rewrite the reply below, which an agent sent to a user, as the shortest reply that still covers everything it must.
88It must cover: every decision it asks of the user, every result the user needs, and its next action.
89Derive the rewrite from that coverage, not by deleting sentences. Keep its first-line and last-line order, its tables and its code.
90Cut commit hashes, ids and dates unless the user must act on one.
91Never pack several facts into one line: give each fact the user would act on its own sentence or row, and keep it rather than cut it.
92Hand back the rewritten reply alone.
93
94<reply>
95${reply}
96</reply>`;
97}
98
99/** A short key for a reply's text, so the store holds its short form without the long one. */
100export function textKey(text: string): string {
101 let h = 5381;
102 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
103 return `${h.toString(36)}.${text.length}`;
104}
105
106const str = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
107// An explicit [] stays: it clears an older block's list.
108const list = <T,>(v: unknown, keep: (x: any) => x is T): T[] | undefined =>
109 Array.isArray(v) ? v.filter(keep) : undefined;
110
111/** The model's JSON, keeping only well-formed parts, so a bad field can't break the band. */
112export function cleanCard(c: any): Card {
113 const card: Card = {
114 intent: str(c.intent) ? c.intent : undefined,
115 shape: str(c.shape) ? c.shape : undefined,
116 outcome: str(c.outcome) ? c.outcome : undefined,
117 facts: list(c.facts, str),
118 questions: list(c.questions, (q): q is Question =>
119 str(q?.q) && Array.isArray(q.options) && q.options.every(str),
120 ),
121 ideas: list(c.ideas, (d): d is Idea =>
122 str(d?.by) && str(d.idea) && (d.why === undefined || str(d.why)) && (d.test === undefined || str(d.test)),
123 ),
124 skills: list(c.skills, (k): k is { name: string; outcome: string } =>
125 str(k?.name) && str(k.outcome),
126 ),
127 watch: list(c.watch, (w): w is { label: string; open: string; see?: string } =>
128 str(w?.label) && str(w.open),
129 ),
130 said:
131 c.said && typeof c.said === "object" && (str(c.said.goal) || str(c.said.done))
132 ? { ...(str(c.said.goal) ? { goal: c.said.goal } : {}), ...(str(c.said.done) ? { done: c.said.done } : {}) }
133 : undefined,
134 };
135 for (const k of Object.keys(card) as (keyof Card)[])
136 if (card[k] === undefined) delete card[k];
137 return card;
138}
139
140/** Each key from the newest block that has it: a clarify card keeps compose's skills. */
141export function latestCard(msgs: Msg[]): Card {
142 const card: Card = {};
143 for (let i = msgs.length - 1; i >= 0; i--) {
144 if (msgs[i].role !== "assistant") continue;
145 for (const b of [...msgs[i].text.matchAll(CARD_RE)].reverse()) {
146 let c: unknown;
147 try {
148 c = JSON.parse(b[1]);
149 } catch {
150 continue;
151 }
152 if (!c || typeof c !== "object" || Array.isArray(c)) continue;
153 const clean = cleanCard(c);
154 for (const k of Object.keys(clean) as (keyof Card)[])
155 if (!(k in card)) (card as any)[k] = clean[k];
156 }
157 }
158 return card;
159}
160
161/** Lines of a reply that ask something, outside code and the card: each ends with `?`,
162 * list markers dropped. None already in `known`, none twice. */
163export function proseQuestions(text: string, known: string[]): string[] {
164 const seen = new Set(known);
165 const out: string[] = [];
166 let fenced = false;
167 for (const raw of stripCards(text).split("\n")) {
168 if (raw.trim().startsWith("```")) fenced = !fenced;
169 const q = raw.trim().replace(/^(?:[-*+>]\s+|\d+[.)]\s+)+/, "");
170 if (fenced || !q.endsWith("?") || seen.has(q)) continue;
171 seen.add(q);
172 out.push(q);
173 }
174 return out;
175}
176
177/** The prompt with question `n`'s answer set to `pick`: its first `qn: <option>` already in `box`
178 * swapped, the rest of the box as it was; undefined when no answer to it is there yet. */
179export function swapAnswer(box: string, n: number, options: string[], pick: string): string | undefined {
180 const at = `q${n}: `;
181 const found = options
182 .map((o) => ({ from: box.indexOf(at + o), len: at.length + o.length }))
183 .filter((f) => f.from >= 0)
184 // Of two answers at one place, the longer: "yes, later" over "yes".
185 .sort((x, y) => x.from - y.from || y.len - x.len)[0];
186 return found && box.slice(0, found.from) + at + pick + box.slice(found.from + found.len);
187}
188
189/** The questions a turn leaves open: its reply's card's when it lists any, since a card lists every
190 * open one; else those already open, then the ones it asked in prose, with no options. */
191export function turnQuestions(answer: string, open: Question[]): Question[] {
192 const carded = latestCard([{ role: "assistant", text: answer }]).questions;
193 if (carded?.length) return carded;
194 const asked = carded ?? open;
195 return [...asked, ...proseQuestions(answer, asked.map((q) => q.q)).map((q) => ({ q, options: [] }))];
196}
197
198/** The card with the questions still open in place of its blocks' own; `undefined` (a session from
199 * before open questions were kept) leaves the blocks' own. */
200export function withOpen(card: Card, open: Question[] | undefined): Card {
201 if (!open) return card;
202 const { questions: _, ...rest } = card;
203 return open.length ? { ...rest, questions: open } : rest;
204}
205
206/** The message a compaction ends on: the whole card, word for word. Undefined for an empty card. */
207export function replayCard(card: Card): string | undefined {
208 if (!Object.keys(card).length) return undefined;
209 return `The session card as it stood before this compaction, word for word:\n\`\`\`card\n${JSON.stringify(card)}\n\`\`\`\n`;
210}
211
212/** A plan may name `clarify` where the engine reports `hope:clarify`. */
213export function hasRun(ran: Set<string>, name: string): boolean {
214 return ran.has(name) || [...ran].some((r) => r.endsWith(`:${name}`));
215}
216
217/** The command a prompt starts with: `/hope:intent x` gives `hope:intent`; a path like `/a/b.md` gives none. */
218export function slashName(text: string): string | undefined {
219 return text.match(/^\/([\w:-]+)(?:\s|$)/)?.[1];
220}
221
222/** The slash command a planned skill runs by, or undefined when none exists. */
223export function commandFor(names: string[], name: string): string | undefined {
224 return names.find((n) => n === name) ?? names.find((n) => n.endsWith(`:${name}`));
225}
226
227export function clip(text: string, max = 40): string {
228 return text.length > max ? `${text.slice(0, max - 1)}…` : text;
229}
230
231export function tablesToLists(md: string): string {
232 const out: string[] = [];
233 let head: string[] | null = null;
234 const cells = (l: string) =>
235 l
236 .trim()
237 .replace(/\\\|/g, "\0")
238 .replace(/^\||\|$/g, "")
239 .split("|")
240 .map((c) => c.trim().replace(/\0/g, "|"));
241 for (const l of md.split("\n")) {
242 if (!l.trim().startsWith("|")) {
243 head = null;
244 out.push(l);
245 } else if (/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(l)) continue;
246 else if (!head) head = cells(l);
247 else {
248 const c = cells(l);
249 out.push(`- **${c[0]}**`);
250 for (let i = 1; i < c.length; i++)
251 out.push(` - ${head[i] ?? ""}: ${c[i]}`);
252 }
253 }
254 return out.join("\n");
255}
256
257export function bareFact(f: string): string {
258 return f.replace(/^\s*(?:[\w-]+(?:\s+[\w-]+)?\s+confirmed|found|measured)\s*:\s*/i, "");
259}
260
261/** A consult reply cut where its colours change: markdown as written, each idea's head, and the
262 * lines under it and the shared win, which recede. */
263export type Segment = { md: string } | { head: { lead: string; change: string; rest: string } } | { dim: string };
264
265export function ideaSegments(text: string): Segment[] {
266 const out: Segment[] = [];
267 let md: string[] = [];
268 let inList = false;
269 let listed = false;
270 const flush = () => {
271 if (md.join("\n").trim()) out.push({ md: md.join("\n") });
272 md = [];
273 };
274 const plain = (t: string) => t.replace(/\*\*?|`/g, "");
275 for (const l of text.split("\n")) {
276 const head = l.match(/^(\d+\.\s.*?)\*\*(.+?)\*\*(.*)$/);
277 if (head) {
278 flush();
279 inList = listed = true;
280 out.push({ head: { lead: plain(head[1]), change: plain(head[2]), rest: plain(head[3]) } });
281 } else if (inList && /^\s+\S/.test(l)) out.push({ dim: plain(l) });
282 // A blank line between ideas keeps them apart.
283 else if (inList && !l.trim()) out.push({ dim: "" });
284 // Past the ideas, all but the closing question recedes: the win, and any note the rules left out.
285 else if (listed && l.trim() && !l.trim().endsWith("?")) {
286 flush();
287 inList = false;
288 out.push({ dim: plain(l) });
289 } else {
290 if (l.trim()) inList = false;
291 md.push(l);
292 }
293 }
294 flush();
295 return out;
296}
297
298export function firstLine(text: string): string {
299 return text.split("\n").find((l) => l.trim())?.trim() ?? "";
300}
301
302const TEAMMATE_RE = /^\s*<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>\s*$/;
303
304export type AgentView = { asked: string; returned: string; body: string; card: Card; footprint: string[] };
305
306/** What an agent was asked, what it returned, the card it ended on, and the files and pages
307 * it changed or fetched. A teammate's brief arrives wrapped as a teammate-message, and its
308 * answer is its last SendMessage. */
309export function agentView(msgs: Msg[]): AgentView {
310 const first = msgs.find((m) => m.role === "user" && m.text.trim())?.text ?? "";
311 const wrapped = first.match(TEAMMATE_RE);
312 const summary = wrapped?.[1].match(/summary="([^"]*)"/)?.[1];
313 const asked = summary || firstLine(wrapped ? wrapped[2] : first);
314 const footprint = [
315 ...new Set(
316 msgs.flatMap((m) =>
317 (m.toolUses ?? []).flatMap((t) => {
318 const target = FOOTPRINT[t.tool] && t.input[FOOTPRINT[t.tool]];
319 return typeof target === "string" ? [target] : [];
320 }),
321 ),
322 ),
323 ];
324 // The report is the fullest thing it said back: a closing "task complete" never hides it.
325 let returned = "";
326 let raw = "";
327 for (const m of msgs) {
328 if (m.role !== "assistant") continue;
329 for (const t of m.toolUses ?? []) {
330 const message = t.tool === "SendMessage" ? t.input.message : undefined;
331 if (typeof message === "string" && message.length >= raw.length) {
332 raw = message;
333 returned = String(t.input.summary ?? "");
334 }
335 }
336 if (m.text.trim().length >= raw.length) {
337 raw = m.text;
338 returned = "";
339 }
340 }
341 const card = latestCard([{ role: "assistant", text: raw }]);
342 return { asked, returned, body: stripCards(raw), card, footprint };
343}
344
345// The tools whose argument is a place a human can look: what the agent changed or read online.
346const FOOTPRINT: Record<string, string> = { Write: "file_path", Edit: "file_path", NotebookEdit: "notebook_path", WebFetch: "url" };
347
348/** The places a markdown text links to, in order, once each: a pane lists them to click. */
349export function links(md: string): string[] {
350 return [
351 ...new Set(
352 [...md.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)]
353 .map((m) => m[1].replace(/^file:\/\//, ""))
354 // A place: a url, a path, or a file name; never an anchor or a bare word like `url`.
355 .filter((h) => /^([a-z]+:\/\/|\/|\.{1,2}\/|~\/)/i.test(h) || /\.[a-z0-9]+$/i.test(h)),
356 ),
357 ];
358}
359
360/** A place as a person names it: a file by its name, a page by its site and path. */
361export function placeName(target: string): string {
362 const url = target.match(/^[a-z]+:\/\/(?:www\.)?([^/?#]+)([^?#]*)/i);
363 if (url) return `${url[1]}${url[2].length > 1 ? url[2].replace(/\/$/, "") : ""}`;
364 return target.split("/").pop() || target;
365}
366
367/** What a pane shows for a file it could not read: a missing file is one not written yet. */
368export function unreadable(path: string, err: unknown): string {
369 return /\bENOENT\b/.test(String(err)) ? `_not written yet: ${path}_` : `_${String(err)}_`;
370}
371
372/** A link read in a file, as a place to open: relative ones sit beside the file. */
373export function fromFile(file: string, href: string): string {
374 return href.startsWith("/") || href.includes("://") ? href : `${file.slice(0, file.lastIndexOf("/") + 1)}${href}`;
375}
376
377const line = (id: string, label: string): Item => ({ id, label, kind: "line" });
378const quiet = (id: string, label: string): Item => ({ id, label, kind: "quiet" });
379const note = (id: string, label: string): Item => ({ id, label, kind: "note" });
380const title = (id: string, label: string): Item => ({ id, label, kind: "title" });
381const under = (i: Item): Item => ({ ...i, indent: 2 });
382const column = (i: Item): Item => ({ ...i, column: true });
383// Entries a blank line apart, so each reads as one.
384const spaced = (entries: Item[][][]): Item[][] => entries.flatMap((e, i) => (i ? [[], ...e] : e));
385
386/** A text's sentences, a clause after a semicolon counting as one. */
387export function sentences(text: string): string[] {
388 return text.split(/(?<=[.!?])\s+(?=[A-Z])|(?<=;)\s+/);
389}
390
391/** A fact's first sentence, then the rest of it: the claim, then what lost and why. */
392export function factParts(f: string): [string, string] {
393 const [head, ...rest] = sentences(bareFact(f));
394 return [head, rest.join(" ")];
395}
396
397/** Facts as bullets a blank line apart: each claim, then what lost and why dim under it.
398 * `whose` names the probe: "" for the session's card, an agent's name and a space for its pane. */
399function factRows(facts: string[], whose: string): Item[][] {
400 return spaced(
401 facts.map((f, i) => {
402 const [head, rest] = factParts(f);
403 return [
404 [line(`probe:${whose}fact ${i + 1}`, `• ${head}`)],
405 ...(rest ? [[under(note(`fact-note:${whose}${i}`, rest))]] : []),
406 ];
407 }),
408 );
409}
410
411/** An agent's pane as a card: intent, outcome, where to look, the facts it rests on; the full
412 * report one click further. Its sections sit a blank line apart, the labels one column. */
413export function agentRows(v: AgentView, name: string, id: string): Item[][] {
414 const label = (word: string) => column(note(`note:${word}`, word));
415 const section = (word: string, rows: Item[][]) =>
416 rows.length ? [rows.map((r, i) => (r.length ? [label(i ? "" : word), ...r] : r))] : [];
417 const outcome = v.card.outcome || v.returned || firstLine(v.body.replace(/^#.*$/gm, ""));
418 const intent = v.card.intent || v.asked;
419 return spaced([
420 ...section("intent", intent ? [[line(`probe:${name} intent`, intent)]] : []),
421 // The answer is what the reader came for: it reads bold.
422 ...section("outcome", outcome ? [[{ ...line(`probe:${name} outcome`, outcome), strong: true as const }]] : []),
423 ...section("watch", [...new Set([...v.footprint, ...links(v.body)])].map((f) => [line(`open:${f}`, placeName(f))])),
424 ...section("facts", factRows(v.card.facts ?? [], `${name} `)),
425 ...section("report", v.body ? [[quiet(`report:${id}`, "read it all")]] : []),
426 ]);
427}
428
429const SOURCE_RE = /\.(tsx?|jsx?|mjs|cjs|json|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|sh|zsh|toml|ya?ml|css|scss|sql|lua|txt)$/i;
430
431/** A local text file a person edits, not a page, a picture or a document to view. */
432export function isSourceFile(target: string): boolean {
433 return !target.includes("://") && (SOURCE_RE.test(target) || !/\.[^/]+$/.test(target));
434}
435
436/** A chip's label with the one number that previews its card: how many, or skills run of planned. */
437export function chipLabel(name: string, s: Shown): string {
438 if (name === "intent" || name === "shape") return name;
439 if (name === "skills") {
440 const planned = s.card.skills ?? [];
441 return `skills ${planned.filter((k) => hasRun(s.ran, k.name)).length}/${planned.length}`;
442 }
443 if (name === "facts") return `facts ${s.card.facts?.length ?? 0}`;
444 if (name === "questions") return `questions ${s.card.questions?.length ?? 0}`;
445 if (name === "ideas") return `ideas ${s.card.ideas?.length ?? 0}`;
446 if (name === "memory") return `memory ${s.memory.length}`;
447 return `${name} ${chipRows(name, s).length}`;
448}
449
450/** A card's lines; each inner list is one row the arrows move along. */
451export function cardRows(c: Card, name: string, ran: Set<string>): Item[][] {
452 if (name === "intent" || name === "shape")
453 // Each sentence its own paragraph, set in from the pane's edge.
454 return c[name] ? [[under(line(`probe:${name}`, sentences(c[name]!).join("\n\n")))]] : [];
455 if (name === "facts") return factRows(c.facts ?? [], "");
456 if (name === "questions")
457 return spaced(
458 (c.questions ?? []).map((q, i) => [
459 // Only its answers take a click.
460 [title(`question:${i}`, `• ${q.q}`)],
461 ...q.options.map((o, j) => [under(line(`answer:${i}:${j}`, `◦ ${o}`))]),
462 ]),
463 );
464 if (name === "ideas")
465 // The experts' names one column, so the ideas line up to compare; the why and the test sit under.
466 // Only the idea is bold: the names and the reasons recede.
467 return spaced(
468 (c.ideas ?? []).map((d, i) => [
469 [column(note(`idea-by:${i}`, d.by)), { ...line(`probe:idea ${i + 1}`, d.idea), strong: true as const }],
470 ...[d.why, d.test && `A/B: ${d.test}`].flatMap((t, k) =>
471 t ? [[column(note(`idea-gap:${i}:${k}`, "")), note(`idea-note:${i}:${k}`, t)]] : [],
472 ),
473 ]),
474 );
475 if (name === "skills")
476 return (c.skills ?? []).map((k, i) => [
477 column({ ...line(`skill:${i}`, k.name), ...(hasRun(ran, k.name) ? { state: "done" as const } : {}) }),
478 note(`skill-note:${i}`, k.outcome),
479 ]);
480 if (name === "watch")
481 return (c.watch ?? []).map((w, i) => [
482 column(line(`watch:${i}`, w.label)),
483 quiet(`probe:watch ${i + 1}`, "?"),
484 ...(w.see ? [note(`watch-note:${i}`, w.see)] : []),
485 ]);
486 return [];
487}
488
489function agentItem(r: Row, paneItem: string | null): Item {
490 return {
491 id: `agent:${r.id}`,
492 label: clip(r.label),
493 kind: "line",
494 state: r.done ? "done" : "running",
495 ...(r.read ? { read: true as const } : {}),
496 ...(paneItem === `agent:${r.id}` ? { open: true as const } : {}),
497 };
498}
499
500/** What the band draws from. */
501export type Shown = { card: Card; ran: Set<string>; rows: Row[]; paneItem: string | null; memory: Steering[] };
502
503/** The chip a pane item belongs to: a card's own, or agents for an agent's card or report. */
504export function paneChip(paneItem: string | null): string | undefined {
505 const [kind, ...rest] = (paneItem ?? "").split(":");
506 return kind === "card" ? rest.join(":") : kind === "agent" || kind === "report" ? "agents" : undefined;
507}
508
509/** The rows a chip opens: the card's, the session's agents, or the steering files it updated and retrieved. */
510export function chipRows(name: string, s: Shown): Item[][] {
511 if (name === "memory") return memoryRows(s.memory);
512 return name === "agents"
513 ? s.rows.map((r) => [agentItem(r, s.paneItem)])
514 : cardRows(s.card, name, s.ran);
515}
516
517/** The band is its chips alone: every card reads in the pane. */
518export function bandModel(s: Shown): Band {
519 const shown = CHIPS.filter((n) => chipRows(n, s).length > 0);
520 return {
521 doc: "",
522 chips: shown.map((n) => ({
523 id: `chip:${n}`,
524 label: chipLabel(n, s),
525 kind: "chip",
526 // Client props refuse undefined, so an unmarked chip has no key at all.
527 ...(paneChip(s.paneItem) === n ? { open: true as const } : {}),
528 // While any agent works, its chip says so.
529 ...(n === "agents" && s.rows.some((r) => !r.done) ? { state: "running" as const } : {}),
530 })),
531 body: [],
532 };
533}
534
535// ---- state ----
536
537let card: Card = {};
538let rows: Row[] = [];
539// The steering files this session updated since it started, then those it read or loaded.
540let memory: Steering[] = [];
541// The steering files as the session first saw them.
542let memoryBase: Snapshot | undefined;
543// The questions still open: a card block holds only those its writer chose, and the user's
544// next prompt closes them all, however it answered them.
545let open: Question[] | undefined;
546// Armed from the message a move ran at, until an edit passes or the user says go.
547type Gate = { at: number } | "open";
548let gate: Gate | undefined;
549let paneItem: string | null = null;
550let lastPaneItem: string | null = null;
551const ran = new Set<string>();
552// The plan moved this turn (new card, or a skill ran): only then suggest the next skill.
553let planMoved = false;
554let nextCmd: string | undefined;
555const paneText = new Map<string, string>();
556// The agent a pane shows, kept past a reset that empties the rows while its pane stays open.
557const paneAgent = new Map<string, Row>();
558// Agents of a session this module no longer serves.
559const gone = new Set<string>();
560// Teammates whose last turn ended; a tool call of theirs starts them again.
561const idle = new Set<string>();
562// What each opened agent was asked and returned: a wrong brief is the cheapest thing to catch.
563const views = new Map<string, AgentView>();
564let started = false;
565let sessionId = "";
566// Bumped to hand the keys back to the prompt: the Client redraws under a new key.
567let fills = 0;
568// The stem last put in the box, to swap while it stands bare.
569let lastStem = "";
570// How much of each Client's typed text has reached the prompt, by the Client's key.
571const typedFrom = new Map<string, number>();
572// Each long reply's short form, by textKey of the long one; `/long` shows the long ones again.
573const short = new Map<string, string>();
574let showLong = false;
575// The store keeps the latest short forms only: a session's replies would outgrow its share.
576const KEPT_SHORT = 40;
577
578// ---- effects ----
579
580async function registerCommands($: any) {
581 await $.command.register({
582 name: "cards",
583 description: "Reopen the pane",
584 immediate: true,
585 });
586 await $.command.register({
587 name: "long",
588 description: "Show replies at full length, or short again",
589 immediate: true,
590 });
591}
592
593// A rewrite that fills its token cap was cut off, and with it the reply's closing ask.
594export const SHORT_CAP = 2048;
595
596/** Redraws a long reply short once the turn ends: its long form already showed while it streamed. */
597async function shorten($: any, reply: string) {
598 const r = await $.model.complete({ model: "haiku", prompt: shortAsk(reply), maxTokens: SHORT_CAP, timeoutMs: 60_000 });
599 if (!r.isAnswered) throw new Error(`the short reply failed: ${r.reason}`);
600 if (r.usage.output_tokens >= SHORT_CAP) throw new Error("the short reply ran out of room, so the reply stays long");
601 short.set(textKey(reply), r.text.trim());
602 const kept = [...short].slice(-KEPT_SHORT);
603 await save($, `tend:${await sid($)}:short`, Object.fromEntries(kept));
604 $.ui.invalidate("ui.render");
605}
606
607async function readSession($: any) {
608 await sid($);
609 const msgs: Msg[] = await $.session.messages();
610 const next = withOpen(latestCard(msgs), open);
611 if (JSON.stringify(next) !== JSON.stringify(card)) planMoved = true;
612 card = next;
613 $.ui.invalidate("ui.render");
614}
615
616/** Runs a detached step; a failure is shown, never swallowed, and never breaks the hook that started it. */
617function loud($: any, p: Promise<unknown>) {
618 p.catch((err: unknown) => $.ui.toast(`tend: ${err instanceof Error ? err.message : String(err)}`));
619}
620
621// What the store kept of the session, back in memory; sid sets it going.
622let loaded: Promise<unknown> = Promise.resolve();
623
624/** The session this module now serves. `/clear` and a resume raise no `session.start`, so the
625 * id is read at each use; a new one resets what the old session left in memory and loads what
626 * the store kept of the new one. */
627async function sid($: any): Promise<string> {
628 const id: string = await $.session.id();
629 if (id !== sessionId) {
630 if (sessionId) reset();
631 sessionId = id;
632 loaded = load($, id).catch((err: unknown) =>
633 $.ui.toast(`tend: couldn't read this session's band back: ${err instanceof Error ? err.message : String(err)}`),
634 );
635 }
636 await loaded;
637 return id;
638}
639
640async function load($: any, id: string) {
641 if (!(await headless($))) await keepRecent($, id);
642 const get = (k: string) => $.store.get(`tend:${id}:${k}`);
643 for (const r of ((await get("ran")) as string[]) ?? []) ran.add(r);
644 for (const i of ((await get("idle")) as string[]) ?? []) idle.add(i);
645 for (const [k, v] of Object.entries(((await get("short")) as Record<string, string>) ?? {})) short.set(k, v);
646 memory = ((await get("memory")) as Steering[]) ?? [];
647 memoryBase = (await get("memory-base")) as Snapshot | undefined;
648 open = (await get("open")) as Question[] | undefined;
649 gate = (await get("gate")) as Gate | undefined;
650 rows = ((await $.store.get(`tend:${id}`)) as Row[]) ?? [];
651}
652
653/** A `-p` run or the SDK draws nowhere, so nobody sees what it would keep. Hooks spawn many:
654 * each one kept would push a live session out of the store. */
655async function headless($: any): Promise<boolean> {
656 return (await $.session.surfaces()).length === 0;
657}
658
659// tend draws from its own memory; the store only carries it past a reload or a resume. A write
660// the store refuses (it holds 4 MiB in all) is shown once, and the band goes on without it.
661let refused = false;
662
663async function save($: any, key: string, value: unknown) {
664 try {
665 await $.store.set(key, value);
666 refused = false;
667 } catch (err) {
668 if (!refused) $.ui.toast(`tend: couldn't save the band, so a reload or resume loses it: ${err instanceof Error ? err.message : String(err)}`);
669 refused = true;
670 }
671}
672
673/** The store's sessions, newest first, with this one moved to the front. */
674async function recent($: any, id: string): Promise<string[]> {
675 const before = ((await $.store.get("tend:recent")) as string[] | undefined) ?? [];
676 return [id, ...before.filter((s) => s !== id)];
677}
678
679// The store holds 4 MiB in all. A session starting keeps the newest sessions that fit in 3,
680// itself whatever its size: the last MiB is its room to grow.
681const KEPT_BYTES = 3 * 2 ** 20;
682
683async function keepRecent($: any, id: string) {
684 const order = await recent($, id);
685 const keys = ((await $.store.keys()) as string[]).filter((k) => k.startsWith("tend:") && k !== "tend:recent");
686 const values = await Promise.all(keys.map((k) => $.store.get(k)));
687 const bytes = new Map<string, number>();
688 keys.forEach((k, i) => {
689 const s = k.split(":")[1];
690 bytes.set(s, (bytes.get(s) ?? 0) + k.length + JSON.stringify(values[i] ?? null).length);
691 });
692 const kept = [id];
693 let used = bytes.get(id) ?? 0;
694 // A session with nothing stored has nothing to keep; a live one rejoins at its next stop.
695 for (const s of order.slice(1).filter((s) => bytes.has(s))) {
696 used += bytes.get(s)!;
697 if (used > KEPT_BYTES) break;
698 kept.push(s);
699 }
700 // Delete first: a store already over its cap refuses every set, the list's included.
701 for (const k of keys.filter((k) => !kept.includes(k.split(":")[1]))) await $.store.delete(k);
702 await save($, "tend:recent", kept);
703}
704
705async function remember($: any) {
706 await sid($);
707 await save($, `tend:${sessionId}:ran`, [...ran]);
708 await save($, `tend:${sessionId}:idle`, [...idle]);
709}
710
711async function snapshot($: any, p: Places): Promise<Snapshot> {
712 const listed = await $.process.run(
713 ["git", "ls-files", "-co", "--exclude-standard", "-z"],
714 { cwd: p.root },
715 );
716 // 128: not a git repository, so there are no project files to find this way.
717 if (listed.exitCode !== 0 && listed.exitCode !== 128)
718 throw new Error(`git ls-files: ${listed.stderr.trim()}`);
719 const project =
720 listed.exitCode === 0
721 ? (listed.stdout as string)
722 .split("\0")
723 .filter(isSteering)
724 .map((r) => `${p.root}/${r}`)
725 : [];
726 const saved = (await $.fs.exists(p.memory))
727 ? ((await $.fs.list(p.memory)) as { name: string; kind: string }[])
728 .filter((f) => f.kind === "file")
729 .map((f) => `${p.memory}/${f.name}`)
730 : [];
731 const candidates = [...saved, ...project, `${p.config}/CLAUDE.md`];
732 // A tracked file deleted from disk is still listed by git: only what exists is compared.
733 const paths = (await Promise.all(candidates.map(async (path) => ((await $.fs.exists(path)) ? [path] : [])))).flat();
734 const stats = await Promise.all(paths.map((path) => $.fs.stat(path)));
735 return Object.fromEntries(paths.map((path, i) => [path, stats[i].mtimeMs]));
736}
737
738// The first look at the steering files is the session's baseline; each stop compares against it,
739// and lists what the session read or loaded besides.
740async function readMemory($: any, transcriptPath: string) {
741 const p = places(transcriptPath, await $.session.root());
742 if (!p || (await headless($))) return;
743 // Each stop moves the session to the front, so a long one outlives the sessions opened since.
744 await save($, "tend:recent", await recent($, await sid($)));
745 const base = memoryBase;
746 const now = await snapshot($, p);
747 if (!base) {
748 memoryBase = now;
749 await save($, `tend:${sessionId}:memory-base`, now);
750 }
751 const updated = base ? changes(base, now, p) : [];
752 const usage = await $.session.usage({ breakdown: "summary" });
753 const loaded = ((usage.context.breakdown?.memoryFiles ?? []) as { path: string }[]).map((f) => f.path);
754 const read = ((await $.session.messages()) as Msg[])
755 .flatMap((m) => m.toolUses ?? [])
756 .flatMap((u) => (u.tool === "Read" && typeof u.input.file_path === "string" ? [u.input.file_path] : []))
757 .filter((path) => isSteeringPath(path, p));
758 memory = [...updated, ...retrieved([...loaded, ...read], updated, p)];
759 $.ui.invalidate("ui.render");
760 await save($, `tend:${sessionId}:memory`, memory);
761}
762
763async function setOpen($: any, questions: Question[]) {
764 await sid($);
765 open = questions;
766 card = withOpen(card, open);
767 $.ui.invalidate("ui.render");
768 await save($, `tend:${sessionId}:open`, open);
769}
770
771async function setGate($: any, next: Gate) {
772 await sid($);
773 gate = next;
774 await save($, `tend:${sessionId}:gate`, gate);
775}
776
777/** Why the edit waits, or undefined to let it through: the card written since the gate armed
778 * must quote the user's goal and done-check. */
779async function held($: any): Promise<string | undefined> {
780 await sid($);
781 if (!gate || gate === "open") return undefined;
782 const msgs = (await $.session.messages()) as Msg[];
783 const missing = unquoted(latestCard(msgs.slice(gate.at)).said, userWords(msgs));
784 if (missing.length) return heldReason(missing);
785 await setGate($, "open");
786 return undefined;
787}
788
789// Finished agents drop out of $.agent.list(); rows keep them for the session.
790async function readAgents($: any) {
791 await sid($);
792 const listed: Row[] = ((await $.agent.list()) as any[])
793 .filter((a) => !a.parentId && !gone.has(a.id))
794 .map((a) => ({
795 id: a.id,
796 label: a.name ?? a.description,
797 type: a.type,
798 // A teammate stays `running` while idle; its own turn ending is what says done.
799 done: a.type === "teammate" ? idle.has(a.id) : a.status !== "running" && a.status !== "pending",
800 }));
801 // Read after every await: a flag set a moment ago by another hook stands.
802 const next = [
803 ...listed.map((l) => ({ ...rows.find((k) => k.id === l.id), ...l })),
804 ...rows
805 .filter((k) => !listed.some((l) => l.id === k.id))
806 .map((k) => ({ ...k, done: true })),
807 ];
808 if (JSON.stringify(next) === JSON.stringify(rows)) return;
809 rows = next;
810 await save($, `tend:${sessionId}`, rows);
811 // A pane opened on an agent, its card or its report, reloads as the agent's rows change;
812 // loadAgent alone decides when there is a result to show.
813 const shown = paneItem?.match(/^(?:agent|report):(.+)$/)?.[1] ?? "";
814 if (shown && !paneText.has(`agent:${shown}`)) await loadAgent($, shown);
815 $.ui.invalidate("ui.render");
816}
817
818async function loadAgent($: any, id: string) {
819 const view = agentView(await $.session.messages({ agentId: id }));
820 views.set(`agent:${id}`, view);
821 if (rows.find((r) => r.id === id)?.done) paneText.set(`agent:${id}`, view.body || "_no result_");
822}
823
824async function openAgent($: any, id: string) {
825 const r = rows.find((x) => x.id === id);
826 if (r) paneAgent.set(`agent:${id}`, r);
827 // The pane answers the click at once; the agent's words fill in when read.
828 const shown = showPane($, `agent:${id}`);
829 await loadAgent($, id);
830 // Rows may have been swapped by a poll while this waited: mark the current one.
831 if (rows.some((x) => x.id === id && x.done && !x.read)) {
832 rows = rows.map((x) => (x.id === id ? { ...x, read: true as const } : x));
833 await save($, `tend:${sessionId}`, rows);
834 }
835 $.ui.invalidate("ui.render");
836 await shown;
837}
838
839async function openTarget($: any, target: string) {
840 if (target.startsWith("pane:")) return showPane($, `file:${target.slice(5)}`);
841 if (/\.(md|markdown)$/i.test(target) && !target.includes("://"))
842 return showPane($, `file:${target}`);
843 return openOutside($, target, isSourceFile(target));
844}
845
846// In VS Code (`code`) when asked and it opens, else by the system's default.
847async function openOutside($: any, target: string, inEditor: boolean) {
848 const run = (argv: string[]) =>
849 $.process.run(argv, { timeoutMs: 10000 }).catch((err: unknown) => ({ exitCode: -1, stderr: String(err) }));
850 const opened = inEditor && (await run(["code", "-g", target])).exitCode === 0;
851 if (!opened && (await run(["open", target])).exitCode !== 0) $.ui.toast(`can't open ${target}`);
852}
853
854async function showPane($: any, item: string) {
855 paneItem = item;
856 lastPaneItem = item;
857 // Open before reading: an open answering the press is placed at any width.
858 // Never takes the keys: they stay with the prompt; a click inside hands them over.
859 const opened = $.ui.open({ id: PANE, title: "tend", closeOnEscape: true });
860 if (item.startsWith("file:"))
861 await $.fs.read(item.slice(5)).then(
862 (t: string) => paneText.set(item, t),
863 (err: unknown) => paneText.set(item, unreadable(item.slice(5), err)),
864 );
865 $.ui.invalidate("ui.render");
866 const r = await opened;
867 if (!r.isPlaced) $.ui.toast(r.reason);
868}
869
870async function command($: any, name: string): Promise<string | undefined> {
871 const names = ((await $.command.list()) as { name: string }[]).map((c) => c.name);
872 return commandFor(names, name);
873}
874
875async function slash($: any, name: string): Promise<string | undefined> {
876 const cmd = await command($, name);
877 return cmd && `/${cmd} `;
878}
879
880// A skill the session itself runs moves the plan; a card skill is asked for its card. Called only
881// where the run is certain (its Skill tool call, a prompt that starts with its command), since
882// `skill.prompt` never reaches a user plugin where managed settings seat sec-default.
883async function skillRan($: any, name: string): Promise<string[]> {
884 const skill = await command($, name);
885 if (!skill) return [];
886 ran.add(skill);
887 planMoved = true;
888 await remember($);
889 if (MOVES.has(skill)) await setGate($, { at: ((await $.session.messages()) as Msg[]).length });
890 const format = CARD_SKILLS.get(skill);
891 return format ? [format] : [];
892}
893
894// A stem the user finishes and sends as their own words.
895async function typeThrough($: any, ch: string) {
896 const f = await $.prompt.fill({ text: ch, mode: "insert" });
897 if (f.isFilled) fills++;
898 $.ui.invalidate("ui.render");
899}
900
901async function fill($: any, text: string) {
902 const box: string = (await $.prompt.read()).text;
903 // A second press of the same line leaves the box as it is.
904 if (box.trimEnd().endsWith(text.trimEnd())) return;
905 // A stem left bare is swapped for the new one, never stacked: "intent: fact 2: " can't happen.
906 const bare = lastStem && box.endsWith(lastStem) ? box.slice(0, -lastStem.length) : undefined;
907 const f =
908 bare === undefined
909 ? await $.prompt.fill({ text, mode: "insert" })
910 : await $.prompt.fill({ text: bare + text, mode: "replace" });
911 lastStem = text;
912 if (f.isFilled) fills++;
913 else await $.prompt.suggest({ text });
914}
915
916// A second answer to a question takes the first one's place; the first starts its own line in
917// the box, and is an answer, not a bare stem: the next line joins it rather than replacing it.
918async function setAnswer($: any, n: number, options: string[], pick: string) {
919 const box: string = (await $.prompt.read()).text;
920 const swapped = swapAnswer(box, n, options, pick);
921 const own = box.trim() && !box.endsWith("\n") ? "\n" : "";
922 if (swapped === undefined) await fill($, `${own}q${n}: ${pick} `);
923 else if (swapped !== box && (await $.prompt.fill({ text: swapped, mode: "replace" })).isFilled) fills++;
924 lastStem = "";
925}
926
927function reset() {
928 // The old session's agents can linger in `$.agent.list()`; they are never this session's.
929 for (const r of rows) gone.add(r.id);
930 card = {};
931 rows = [];
932 memory = [];
933 memoryBase = undefined;
934 open = undefined;
935 gate = undefined;
936 paneItem = null;
937 lastPaneItem = null;
938 ran.clear();
939 idle.clear();
940 views.clear();
941 paneText.clear();
942 typedFrom.clear();
943 short.clear();
944 lastStem = "";
945 planMoved = false;
946 nextCmd = undefined;
947}
948
949// What a press or Enter on a band or pane item does.
950async function act($: any, id: string) {
951 const [kind, ...rest] = id.split(":");
952 const arg = rest.join(":");
953 if (kind === "chip") {
954 if (paneItem === `card:${arg}`) await $.ui.close({ id: PANE });
955 else await showPane($, `card:${arg}`);
956 } else if (kind === "probe") await fill($, `${arg}: `);
957 else if (kind === "answer") {
958 const [i, j] = rest.map(Number);
959 const q = card.questions?.[i];
960 // Answered once sent, not on the click: an abandoned answer leaves the question up.
961 if (q) await setAnswer($, i + 1, q.options, q.options[j]);
962 } else if (kind === "skill") {
963 const k = card.skills?.[Number(arg)];
964 const cmd = k && (await slash($, k.name));
965 cmd ? await fill($, cmd) : $.ui.toast(`no command ${k?.name ?? ""}`);
966 } else if (kind === "watch") {
967 const w = card.watch?.[Number(arg)];
968 if (w) await openTarget($, w.open);
969 } else if (kind === "agent")
970 // A second press on what the pane shows closes it.
971 paneItem === id ? await $.ui.close({ id: PANE }) : await openAgent($, arg);
972 else if (kind === "open") await openTarget($, arg);
973 else if (kind === "view") await showPane($, `file:${arg}`);
974 else if (kind === "edit") await openOutside($, arg, true);
975 else if (kind === "ask") await fill($, `change ${arg}: `);
976 else if (kind === "close") await $.ui.close({ id: PANE });
977 else if (kind === "report") await showPane($, id);
978 $.ui.invalidate("ui.render");
979}
980
981export const register: Register = (on) => {
982 // A new or resumed session: memory left from another one is dropped.
983 on("session.start", async ($, e, next) => {
984 const r = await next(e);
985 await sid($);
986 return r;
987 });
988
989 on("session.end", async ($, e, next) => {
990 if (e.reason === "clear") reset();
991 return next(e);
992 });
993
994 on("classic.SessionStart", async ($, e, next) => {
995 loud($, readMemory($, e.transcript_path));
996 return next(e);
997 });
998
999 on("classic.Stop", async ($, e, next) => {
1000 await readMemory($, e.transcript_path).catch((err) => $.ui.toast(`tend: ${err?.message ?? err}`));
1001 return next(e);
1002 });
1003
1004 on("command.run", { command: "long" }, async ($) => {
1005 showLong = !showLong;
1006 $.ui.invalidate("ui.render");
1007 $.ui.toast(showLong ? "replies at full length" : "long replies short again");
1008 return {};
1009 });
1010
1011 on("command.run", { command: "cards" }, async ($) => {
1012 if (lastPaneItem) await showPane($, lastPaneItem);
1013 else $.ui.toast("nothing to show");
1014 return {};
1015 });
1016
1017 // Every agent the session starts is asked to end on a card; an agent at work again is running and unread.
1018 // A skill an agent runs is the agent's: it moves no plan and gets no card (AGENT_CARD asks its own).
1019 on("tool.call", async ($, e, next) => {
1020 if (!e.agentId && e.tool === "Agent" && typeof (e as any).prompt === "string")
1021 return next({ ...e, prompt: `${(e as any).prompt}\n\n${AGENT_CARD}` } as any);
1022 const skill = (e as any).skill;
1023 if (!e.agentId && e.tool === "Skill" && typeof skill === "string") {
1024 const r = await next(e);
1025 if (r.deny !== undefined) return r;
1026 const asked = await skillRan($, skill);
1027 return asked.length ? { ...r, context: [...(r.context ?? []), ...asked] } : r;
1028 }
1029 if (!e.agentId && EDITS.has(e.tool)) {
1030 const reason = await held($);
1031 return reason ? { deny: reason } : next(e);
1032 }
1033 if (!e.agentId) return next(e);
1034 const id = e.agentId;
1035 if (idle.delete(id) || rows.some((x) => x.id === id && x.read)) {
1036 rows = rows.map(({ read, ...x }) => (x.id === id || !read ? x : { ...x, read }));
1037 paneText.delete(`agent:${id}`);
1038 loud($, remember($).then(() => readAgents($)));
1039 }
1040 return next(e);
1041 });
1042
1043 // The user's own prompt keeps a card already shown current: the model is asked for the keys this turn changes.
1044 on("prompt.submit", async ($, e, next) => {
1045 // A prompt that starts with a skill's command runs it, however the prompt arrived.
1046 const name = slashName(e.text);
1047 const asked = name ? await skillRan($, name) : [];
1048 const withContext = (more: string[]) =>
1049 more.length ? next({ ...e, context: [...(e.context ?? []), ...more] }) : next(e);
1050 // An agent's return arrives twice: its report as a peer's hand-back, then the task notification.
1051 if (e.origin.kind === "task-notification" || e.origin.kind === "peer") {
1052 const id = e.text.match(/<task-id>([^<]+)<\/task-id>|<agent-message from="([^"]+)"/)?.slice(1).find(Boolean);
1053 const agent = !!id && (rows.some((r) => r.id === id) || ((await $.agent.list()) as any[]).some((a) => a.id === id));
1054 return withContext(agent ? [...asked, AGENT_RETURN] : asked);
1055 }
1056 // The user's "go" starts the work as it stands, however the prompt arrived.
1057 if (saysGo(e.text)) await setGate($, "open");
1058 if (e.origin.kind !== "composer") return withContext(asked);
1059 // Whatever it says, the prompt answers what was asked: a question the next reply leaves out stays closed.
1060 await setOpen($, []);
1061 if (asked.length || !Object.keys(card).length) return withContext(asked);
1062 return withContext([`Only if this turn settles or changes what the card holds (the block may follow the last line, whatever the reply format says):\n${CARD_FORMAT}`]);
1063 });
1064
1065 on("prompt.suggest", async ($, e, next) =>
1066 e.origin.kind === "suggestion" && nextCmd ? next({ ...e, text: nextCmd }) : next(e),
1067 );
1068
1069 on("turn.complete", async ($, e, next) => {
1070 const r = await next(e);
1071 if (e.agentId) {
1072 idle.add(e.agentId);
1073 await remember($);
1074 await readAgents($);
1075 }
1076 else {
1077 await readSession($);
1078 await setOpen($, turnQuestions(e.answer, card.questions ?? []));
1079 const reply = stripCards(e.answer);
1080 const asked = ((await $.session.messages()) as Msg[]).findLast((m) => m.role === "user")?.text ?? "";
1081 if (!(await headless($)) && wantsShort(reply, asked)) loud($, shorten($, reply));
1082 const nextSkill = planMoved && card.skills?.find((k) => !hasRun(ran, k.name));
1083 planMoved = false;
1084 // A suggestion made while the turn is live never shows.
1085 nextCmd = (nextSkill && (await slash($, nextSkill.name))) || undefined;
1086 const cmd = nextCmd;
1087 if (cmd) $.clock.after(1500, () => loud($, $.prompt.suggest({ text: cmd })));
1088 }
1089 return r;
1090 });
1091
1092 // A compaction keeps what its summary chose; the card it ends on keeps the rest whole.
1093 // A reload re-raises session.start, a compaction never does, so the card rides the compaction itself.
1094 on("session.compact", async ($, e, next) => {
1095 const r = await next(e);
1096 if (e.agentId || !r.messages) return r;
1097 try {
1098 await sid($);
1099 const text = replayCard(withOpen(latestCard(e.messages as Msg[]), open));
1100 return text ? { ...r, messages: [...r.messages, { role: "assistant" as const, text, toolUses: [] }] } : r;
1101 } catch (err) {
1102 $.ui.toast(`tend: the card did not ride the compaction: ${err instanceof Error ? err.message : String(err)}`);
1103 return r;
1104 }
1105 });
1106
1107 on("ui.close", async ($, e, next) => {
1108 const r = await next(e);
1109 if (e.id === PANE) {
1110 paneItem = null;
1111 $.ui.invalidate("ui.render");
1112 }
1113 return r;
1114 });
1115
1116 // The card block is for the band; the model keeps it, the transcript doesn't show it.
1117 on("ui.render", { component: "AssistantMessage" }, ($, e, next) => {
1118 const stripped = stripCards(e.props.text);
1119 const text = (!showLong && short.get(textKey(stripped))) || stripped;
1120 if (text === e.props.text) return next(e);
1121 if (!text.trim()) {
1122 const { Box } = $.ui.resolve(e);
1123 return <Box />;
1124 }
1125 // A reply that ends on ideas draws them itself: the change stands out, the rest recedes.
1126 if (latestCard([{ role: "assistant", text: e.props.text }]).ideas?.length) {
1127 const { Box, Text, Markdown } = $.ui.resolve(e);
1128 return (
1129 <Box flexDirection="column">
1130 {ideaSegments(text).map((g, i) =>
1131 "md" in g ? (
1132 <Markdown key={`md${i}`} text={g.md} />
1133 ) : "head" in g ? (
1134 <Text key={`h${i}`}>
1135 {g.head.lead}
1136 <Text bold>{g.head.change}</Text>
1137 {g.head.rest}
1138 </Text>
1139 ) : (
1140 <Text key={`d${i}`} dimColor>
1141 {g.dim}
1142 </Text>
1143 ),
1144 )}
1145 </Box>
1146 );
1147 }
1148 return next({ ...e, props: { ...e.props, text } });
1149 });
1150
1151 // A teammate's message to the lead carries the card tend asked for; the row shows it without.
1152 on("ui.render", { component: "UserMessage" }, ($, e, next) => {
1153 const text = stripCards(e.props.text);
1154 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } });
1155 });
1156
1157 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
1158 if (e.props.hasSurvey) return next(e);
1159 if (!started) {
1160 started = true;
1161 // The poll starts first, so a failed step below (shown) never leaves the agents unread.
1162 $.clock.every(2000, () => loud($, readAgents($)));
1163 loud($, (async () => {
1164 await registerCommands($);
1165 await readSession($);
1166 await readAgents($);
1167 })());
1168 }
1169 const els = $.ui.resolve(e);
1170 // Surfaces with no Client (mobile, the editor's panel) keep their own band.
1171 if (!("Client" in els)) return next(e);
1172 const { Box, Client } = els;
1173 const band = bandModel({ card, paneItem, ran, rows, memory });
1174 if (!band.chips.length) return <Box />;
1175 // A blank line parts the band from the reply above it, so the chips read as controls, not its last line.
1176 return (
1177 <Box marginTop={1}>
1178 <Client key={`band-${fills}`} module="./band.tsx" props={band} width={e.props.bodyColumns} />
1179 </Box>
1180 );
1181 });
1182
1183 on("ui.message", async ($, e, next) => {
1184 const data = e.data as { act?: unknown; type?: unknown; release?: unknown } | undefined;
1185 const before = fills;
1186 // A failed act is shown; the message still passes on (hooks fail open).
1187 if (typeof data?.act === "string") await act($, data.act).catch((err) => $.ui.toast(`tend: ${err?.message ?? err}`));
1188 if (typeof data?.type === "string") {
1189 const done = typedFrom.get(e.element) ?? 0;
1190 typedFrom.set(e.element, data.type.length);
1191 if (data.type.length > done) await typeThrough($, data.type.slice(done));
1192 }
1193 if (data?.release === true) fills++;
1194 // A pane keeps the keys past its Client's redraw; reopening it hands them to the prompt.
1195 if (fills !== before && e.component === "Pane" && paneItem) {
1196 const item = paneItem;
1197 await $.ui.close({ id: PANE });
1198 await showPane($, item);
1199 }
1200 $.ui.invalidate("ui.render");hooks/band.tsx 290 lines1// Drawn inside the band and the pane. The engine's ring moves the same way on every
2// arrow, and a Button pressed inside a Client keeps the keys from it; so items are
3// text, a click is hit-tested here, and left/right move along a row, up/down between rows.
4
5export type Item = {
6 id: string;
7 label: string;
8 kind: "chip" | "line" | "quiet" | "note" | "title";
9 /** Its card or pane is showing. */
10 open?: true;
11 state?: "done" | "running";
12 /** An agent already read: kept for reopening, drawn dim. */
13 read?: true;
14 /** It sits this far past where it would start: an answer under its question. */
15 indent?: number;
16 /** One of a row's leading columns: each cell takes the width of the widest one in its place
17 * across the card, so the rows read as a table. */
18 column?: true;
19 /** What the reader acts on, set bold among quieter text. */
20 strong?: true;
21};
22/** `doc`: markdown read below the rows (the pane's result or file); "" for none. */
23export type Band = { chips: Item[]; body: Item[][]; doc: string };
24
25/** The pane's width, padding included: its text runs about 72 characters, past which a line is hard to read back. */
26export const MEASURE = 76;
27/** `nav`: the arrows have moved it, so Enter acts here rather than meaning the prompt. */
28export type Focus = { row: number; col: number; nav?: true };
29export type Cell = { item: Item; text: string; x: number; y: number };
30
31const focusable = (i: Item) => i.kind !== "note" && i.kind !== "title";
32
33export function navRows(b: Band): Item[][] {
34 return [...b.body, b.chips]
35 .map((r) => r.filter(focusable))
36 .filter((r) => r.length);
37}
38
39export function move(f: Focus, rows: Item[][], key: string): Focus {
40 if (!rows.length) return { row: 0, col: 0 };
41 const row =
42 key === "down"
43 ? Math.min(f.row + 1, rows.length - 1)
44 : key === "up"
45 ? Math.max(f.row - 1, 0)
46 : Math.min(f.row, rows.length - 1);
47 const width = rows[row].length;
48 // Stepping back to the chips lands on the open one.
49 const opened =
50 row !== f.row ? rows[row].findIndex((i) => i.open) : -1;
51 const col =
52 opened >= 0
53 ? opened
54 : key === "right"
55 ? Math.min(f.col + 1, width - 1)
56 : key === "left"
57 ? Math.max(f.col - 1, 0)
58 : Math.min(f.col, width - 1);
59 return { row, col };
60}
61
62const GLYPH = { done: " ✓", running: " ●" };
63const GLYPH_COLOR = { done: "success", running: "warning" };
64
65/** An item's text in its pieces: brackets for what acts like a button, a state glyph. */
66function pieces(i: Item) {
67 const button = i.kind === "chip" || i.id === "close";
68 return {
69 pre: button ? `[ ${i.open ? "▴ " : ""}` : "",
70 label: i.label,
71 glyph: i.state ? GLYPH[i.state] : "",
72 post: button ? " ]" : "",
73 };
74}
75
76const textOf = (i: Item) => {
77 const p = pieces(i);
78 return p.pre + p.label + p.glyph + p.post;
79};
80
81export type Line = {
82 part: "chips" | "body";
83 y: number;
84 cells: Cell[];
85};
86
87/** The one row plan: every line and where each item sits in it. In a card, an item that won't
88 * fit the rest of its line starts the next one, and one wider than a line wraps at its column. */
89export function layout(b: Band, columns: number): Line[] {
90 const lines: Line[] = [];
91 let y = 0;
92 const place = (
93 part: Line["part"],
94 items: Item[],
95 gap: number,
96 width = columns,
97 widths: number[] = [],
98 ) => {
99 const start = items[0]?.indent ?? 0;
100 let cells: Cell[] = [];
101 let x = start;
102 const end = () => {
103 lines.push({ part, y: y++, cells });
104 cells = [];
105 x = start;
106 };
107 // A table row keeps its columns: what follows the first wraps inside its own.
108 const flows = part === "body" && !(widths.length && items[0]?.column);
109 for (const [n, item] of items.entries()) {
110 const full = textOf(item);
111 if (n) x += item.indent ?? 0;
112 if (flows && cells.length && full.length > width - x && full.length <= width - start) end();
113 const left = width - x;
114 if (left <= 1) break;
115 // Wrapping, the row's last item runs on below itself, aligned at its column.
116 if (part === "body" && n === items.length - 1 && (full.length > left || full.includes("\n"))) {
117 const at = x;
118 const [head, ...rest] = wrap(full, left);
119 cells.push({ item, text: head, x, y });
120 end();
121 for (const text of rest) lines.push({ part, y, cells: [{ item, text, x: at, y: y++ }] });
122 return;
123 }
124 const text = full.length > left ? `${full.slice(0, left - 1)}…` : full;
125 cells.push({ item, text, x, y });
126 x += Math.max(text.length, item.column ? (widths[n] ?? 0) : 0) + gap;
127 }
128 end();
129 };
130 // Leading columns wider than half the line would squeeze the rest: their rows flow instead.
131 const measure = Math.min(columns, MEASURE);
132 const widths: number[] = [];
133 for (const r of b.body) {
134 const lead = r.findIndex((i) => !i.column);
135 for (const [k, i] of r.slice(0, lead < 0 ? r.length : lead).entries())
136 widths[k] = Math.max(widths[k] ?? 0, textOf(i).length);
137 }
138 const table = widths.reduce((s, w) => s + w, 0) + 2 * Math.max(0, widths.length - 1) <= measure / 2;
139 for (const r of b.body) place("body", r, 2, measure, table ? widths : []);
140 if (b.chips.length) place("chips", b.chips, 1);
141 return lines;
142}
143
144/** Words into lines of at most `width`; a list item's later lines hang under its text, and a
145 * blank line in the text stays a blank line. */
146export function wrap(text: string, width: number): string[] {
147 return text.split("\n").flatMap((p) => (p.trim() ? wrapLine(p, width) : [""]));
148}
149
150function wrapLine(text: string, width: number): string[] {
151 const hang = /^[•◦] /.test(text) ? " " : "";
152 const out: string[] = [];
153 let cur = "";
154 const words = text.split(" ").filter(Boolean);
155 // A bullet stays with its first word.
156 if (hang && words.length > 1) words.splice(0, 2, `${words[0]} ${words[1]}`);
157 for (const w of words) {
158 if (!cur) cur = (out.length ? hang : "") + w;
159 else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
160 else {
161 out.push(cur);
162 cur = hang + w;
163 }
164 }
165 if (cur) out.push(cur);
166 // A word wider than the line is the one thing still clipped.
167 return out.map((l) => (l.length > width ? `${l.slice(0, width - 1)}…` : l));
168}
169
170/** Claude Code refuses a Markdown whose text runs past this many characters. */
171export const DOC_MAX = 10_000;
172const CUT = "\n\n_…the rest is past what the pane can draw_";
173
174/** The doc as the pane can draw it: past `DOC_MAX`, cut at the last whole line that fits. */
175export function drawable(doc: string): string {
176 if (doc.length <= DOC_MAX) return doc;
177 const head = doc.slice(0, DOC_MAX - CUT.length);
178 const line = head.lastIndexOf("\n");
179 return (line > 0 ? head.slice(0, line) : head) + CUT;
180}
181
182export function hit(lines: Line[], x: number, y: number): Item | undefined {
183 return lines
184 .find((l) => l.y === y)
185 ?.cells.find((c) => x >= c.x && x < c.x + c.text.length)?.item;
186}
187
188// The surface is sealed, so what one instance has typed is kept here, by its surface.
189const typed = new WeakMap<object, string>();
190
191export default function BandView(band: Band, surface: any) {
192 const { Box, Text, Markdown } = surface.elements;
193 const rows = navRows(band);
194 const lines = layout(band, surface.columns || 200);
195 const f = move(surface.state ?? { row: 0, col: 0 }, rows, "");
196 // Focus is drawn only once this instance has taken a click or a key.
197 const at = surface.state ? rows[f.row]?.[f.col]?.id : undefined;
198
199 const choose = (item: Item) => {
200 const r = rows.findIndex((row) => row.includes(item));
201 if (r >= 0) surface.setState({ row: r, col: rows[r].indexOf(item) });
202 surface.post({ act: item.id });
203 };
204 // Re-set each call so the listeners see these props; keys can outrun a redraw,
205 // so each reads the focus afresh.
206 surface.onKey((k: { key: string }) => {
207 // A typed character was meant for the prompt: send it there, and the keys follow. Posts in
208 // one frame replace each other, so each carries all typed here; the hook fills what's new.
209 if ([...k.key].length === 1) {
210 const all = (typed.get(surface) ?? "") + k.key;
211 typed.set(surface, all);
212 return surface.post({ type: all });
213 }
214 const state: Focus = surface.state ?? { row: 0, col: 0 };
215 const now = move(state, rows, "");
216 if (["up", "down", "left", "right"].includes(k.key)) surface.setState({ ...move(now, rows, k.key), nav: true });
217 else if (k.key === "return" && state.nav) {
218 const item = rows[now.row]?.[now.col];
219 if (item) choose(item);
220 }
221 // Enter after a click, or Tab, meant the prompt: hand the keys back to it.
222 else if (k.key === "return" || k.key === "tab") surface.post({ release: true });
223 // Esc past a focused button: drop the focus and close the pane through the same
224 // path as the pane's own close item, not a bespoke message.
225 else if (k.key === "escape") {
226 surface.setState({ ...now, nav: undefined });
227 surface.post({ act: "close" });
228 }
229 });
230 surface.onPointer((p: { type: string; x: number; y: number }) => {
231 if (p.type !== "down") return;
232 const item = hit(lines, p.x, p.y);
233 if (item && focusable(item)) choose(item);
234 });
235
236 // Colour carries state only, by theme key so it follows the user's theme:
237 // what is open in the engine's selection blue, ✓ and ● in their colours.
238 // Brackets recede so the words stand; hover marks what takes a click.
239 const draw = (c: Cell, marginLeft: number) => {
240 const i = c.item;
241 const p = pieces(i);
242 const style: Record<string, unknown> = {};
243 // A clipped cell draws as one run; its pieces no longer line up.
244 style.children =
245 c.text !== textOf(i) || i.open
246 ? c.text
247 : [
248 p.pre && Text({ key: "pre", color: "subtle", children: p.pre }),
249 p.label,
250 p.glyph && Text({ key: "g", color: GLYPH_COLOR[i.state!], children: p.glyph }),
251 p.post && Text({ key: "post", color: "subtle", children: p.post }),
252 ].filter(Boolean);
253 if (i.kind === "quiet" || i.kind === "note" || i.read) style.dimColor = true;
254 if (i.open) Object.assign(style, { bold: true, color: "suggestion" });
255 if (i.kind === "title" || i.strong) style.bold = true;
256 if (focusable(i)) style.hover = { color: "suggestion" };
257 if (i.id === at) style.inverse = true;
258 return Box({ key: `c-${i.id}`, marginLeft, children: [Text(style)] });
259 };
260 const row = (l: Line) =>
261 Box({
262 key: `y-${l.y}`,
263 flexDirection: "row",
264 // A blank line holds its row, so what's drawn stays where a click is read.
265 height: 1,
266 // Each cell sits at its planned x: a column's padding is space, not text.
267 children: l.cells.map((c, n) => draw(c, c.x - (n ? l.cells[n - 1].x + l.cells[n - 1].text.length : 0))),
268 });
269 const body = lines.filter((l) => l.part === "body").map(row);
270 const out = [
271 ...(body.length
272 ? [Box({ key: "body", flexDirection: "column", children: body })]
273 : []),
274 ...lines.filter((l) => l.part === "chips").map(row),
275 ...(band.doc
276 ? [
277 Box({
278 key: "doc",
279 marginTop: 1,
280 children: [
281 // A Client's Markdown takes no link handler: its links are listed as rows instead.
282 Markdown({ key: "md", text: drawable(band.doc) }),
283 ],
284 }),
285 ]
286 : []),
287 ];
288 return Box({ flexDirection: "column", children: out });
289}
290hooks/gate.tsx 68 lines1// Once a hope move has run, the session's first edit waits until the card quotes the user's goal
2// and done-check in the user's own words. Asking is never held; the user's "go" opens it.
3
4/** The user's own words for what they want and how they will tell it worked. */
5export type Said = { goal?: string; done?: string };
6
7export const SLOTS: Record<keyof Said, string> = {
8 goal: "what they want",
9 done: "how they will tell it worked",
10};
11
12// The moves that settle what the user wants; a run of one arms the gate.
13export const MOVES = new Set([
14 "hope:intent",
15 "hope:shape",
16 "hope:clarify",
17 "hope:elicit",
18 "hope:explain",
19 "hope:interrogate",
20]);
21
22export const EDITS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
23
24type Msg = { role: string; text: string; toolResults?: { text: string }[] };
25
26// Two prefixes from one host version: multi-question answers take the second.
27const ANSWERED = /^(?:Your questions have been answered|The user answered):/;
28// An agent's return arrives as a user message; its words are the agent's.
29const AGENT = /<task-notification>|<agent-message /;
30
31/** What the user typed or picked: their messages, and their answers to AskUserQuestion. */
32export function userWords(msgs: Msg[]): string[] {
33 return msgs.flatMap((m) => [
34 ...(m.role === "user" && m.text.trim() && !AGENT.test(m.text) ? [m.text] : []),
35 ...(m.toolResults ?? []).map((r) => r.text).filter((t) => ANSWERED.test(t)),
36 ]);
37}
38
39// Case, quote marks, spacing and closing punctuation differ between a quote and its source.
40const norm = (s: string) =>
41 s
42 .toLowerCase()
43 .replace(/["'“”‘’]/g, "")
44 .replace(/\s+/g, " ")
45 .trim()
46 .replace(/[.,;:!?]+$/, "");
47
48/** The slots whose quote is missing or appears in none of the user's words. */
49export function unquoted(
50 said: Said | undefined,
51 words: string[],
52): (keyof Said)[] {
53 const heard = words.map(norm);
54 return (Object.keys(SLOTS) as (keyof Said)[]).filter((k) => {
55 const q = norm(said?.[k] ?? "");
56 return !q || !heard.some((w) => w.includes(q));
57 });
58}
59
60/** What the model reads when its edit is held. */
61export function heldReason(missing: (keyof Said)[]): string {
62 const what = missing.map((k) => `${k} (${SLOTS[k]})`).join(" and ");
63 return `Held: the card has no ${what} in the user's own words. Ask the user, then write a \`\`\`card block whose "said" copies their words exactly, {"said":{"goal":"…","done":"…"}}, before editing again. The user can also say "go" to start without them.`;
64}
65
66/** A prompt that tells the session to start as it stands. */
67export const saysGo = (text: string) => /^\s*go\b/i.test(text);
68hooks/memory.tsx 124 lines1import type { Item } from "./band.tsx";
2
3// The files that steer Claude across sessions: its auto-memory, and the instructions it loads.
4
5/** Absolute path → last modification (ms). */
6export type Snapshot = Record<string, number>;
7/** `label`: the path as a person names it, and what a change request asks about. */
8export type Steering = {
9 path: string;
10 label: string;
11 state: "new" | "changed" | "deleted" | "retrieved";
12};
13/** Where the session's steering files live: auto-memory, the project, the user's config. */
14export type Places = { memory: string; root: string; config: string };
15
16/** The transcript sits at `<config>/projects/<project>/<session>.jsonl`, auto-memory beside it. */
17export function places(
18 transcriptPath: string,
19 root: string,
20): Places | undefined {
21 const parts = transcriptPath.split("/");
22 if (parts.length < 5 || parts.at(-3) !== "projects") return undefined;
23 return {
24 memory: `${parts.slice(0, -1).join("/")}/memory`,
25 root,
26 config: parts.slice(0, -3).join("/"),
27 };
28}
29
30const STEERING = /(^|\/)(CLAUDE(\.local)?\.md|AGENTS\.md)$|^\.claude\//;
31
32/** A project file, by its path from the root, that Claude loads as instructions or settings.
33 * Worktrees under .claude/ are copies of the project, not instructions. */
34export function isSteering(relative: string): boolean {
35 return STEERING.test(relative) && !relative.startsWith(".claude/worktrees/");
36}
37
38/** Any absolute path that is a steering file: auto-memory, a project one, the user's CLAUDE.md. */
39export function isSteeringPath(path: string, p: Places): boolean {
40 if (path.startsWith(`${p.memory}/`) || path === `${p.config}/CLAUDE.md`)
41 return true;
42 return (
43 path.startsWith(`${p.root}/`) && isSteering(path.slice(p.root.length + 1))
44 );
45}
46
47export function label(path: string, p: Places): string {
48 if (path.startsWith(`${p.memory}/`))
49 return `memory/${path.slice(p.memory.length + 1)}`;
50 if (path.startsWith(`${p.root}/`)) return path.slice(p.root.length + 1);
51 return p.config.endsWith("/.claude")
52 ? `~/.claude${path.slice(p.config.length)}`
53 : path;
54}
55
56export function changes(
57 before: Snapshot,
58 after: Snapshot,
59 p: Places,
60): Steering[] {
61 const paths = [
62 ...new Set([...Object.keys(before), ...Object.keys(after)]),
63 ].sort();
64 return paths.flatMap((path): Steering[] => {
65 const state = !(path in before)
66 ? "new"
67 : !(path in after)
68 ? "deleted"
69 : before[path] !== after[path]
70 ? "changed"
71 : undefined;
72 return state ? [{ path, label: label(path, p), state }] : [];
73 });
74}
75
76/** The files the session read or loaded, once each; one it also updated is listed there alone. */
77export function retrieved(
78 paths: string[],
79 updated: Steering[],
80 p: Places,
81): Steering[] {
82 const shown = new Set(updated.map((u) => u.path));
83 return [...new Set(paths)]
84 .filter((path) => !shown.has(path))
85 .sort()
86 .map((path) => ({
87 path,
88 label: label(path, p),
89 state: "retrieved" as const,
90 }));
91}
92
93/** One row a file, its state and actions in columns before it, so a long name never pushes them
94 * out of line: view it in the pane, open it in the editor, or ask Claude to change it. */
95function fileRow(f: Steering): Item[] {
96 const gone = f.state === "deleted";
97 const blank = (id: string): Item => ({ id, label: "", kind: "note", column: true });
98 return [
99 f.state === "retrieved"
100 ? blank(`note:mem-state:${f.path}`)
101 : { id: `note:mem-state:${f.path}`, label: f.state, kind: "note", column: true },
102 gone
103 ? blank(`note:mem-edit:${f.path}`)
104 : { id: `edit:${f.path}`, label: "edit", kind: "quiet", column: true },
105 { id: `ask:${f.label}`, label: "ask", kind: "quiet", column: true },
106 { id: `${gone ? "note:mem" : "view"}:${f.path}`, label: f.label, kind: gone ? "note" : "line" },
107 ];
108}
109
110/** Updated or added files, then retrieved ones, each under its heading; an empty section is left out. */
111export function memoryRows(files: Steering[]): Item[][] {
112 const sections = [
113 ["updated", files.filter((f) => f.state !== "retrieved")],
114 ["retrieved", files.filter((f) => f.state === "retrieved")],
115 ] as const;
116 return sections
117 .filter(([, fs]) => fs.length)
118 .flatMap(([heading, fs], i) => [
119 ...(i ? [[]] : []),
120 [{ id: `note:mem-${heading}`, label: heading, kind: "note" as const }],
121 ...fs.map(fileRow),
122 ]);
123}
124