SLOPSHOPPER

hunch

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…

newpanebandrowsguardcommand
★ 35v0.9.4MITupdated 2026-09-30saadshahd/moo.md/hunch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hunch
› fix the failing auth test and add an audit log call ╭─────────────────╮ │ hunch │ ⏺ Read(src/auth.ts) │ nothing to show │ ⎿ Read 6 lines ╰─────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ hunch │ ⏺ Bash(bun test) │ replies at full length │ ⎿ 3 pass, 1 fail ╰────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cards ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

skills.sh

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.

Installation (30-second setup)

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.

1. Get the skills

/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.

2. Run the loop

/hope:full <your seed>

Or run the stages directly — /hope:intent <your seed>, then /hope:shape <your intent>.

A loop

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.

The loop

An anchor

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.

freeze

A mode

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.

Deciding stays, doing leaves

Overview

The trapLayerGuard
AI fills in your decisions/hope:intent & /hope:shapeinteractive questions, each choice previewed
compaction mutates & drifts your context/hope:routerdoing stays out, deciding stays in
stale or remembered external state/hope:freezesnapshot facts, never infer

Reading

Source 4 files
hooks/tend.tsx 1273 lines
1import 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 lines
1// 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}
290
hooks/gate.tsx 68 lines
1// 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);
68
hooks/memory.tsx 124 lines
1import 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