SLOPSHOPPER

hindsight

Improves the Rule Files your agents load, out of band: a Debrief Gate at the end of a turn, a cache-sharing Debrief fork, a Notice at session start and a…

newpanecommandtoaststatusprompt
v0.1.0MITupdated 2026-10-14bengous/hindsight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hindsight
│ ┃ hindsight-review ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Aucune Proposal en attente. │ hindsight │ │ ● hindsight: status fa│ config illisible, défauts gardés │ │ ⏺ Read(src/auth.ts) │ (hindsight bun │ │ ⎿ Read 6 lines │ /plugins/hindsight/src/cli/main.ts config │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ⏺ Bash(bun test) │ hindsight │ │ ⎿ 3 pass, 1 fail │ commande inconnue « » (review, status, │ │ │ debrief, learn, handoff) │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hindsight │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · hindsight-review
Aucune Proposal en attente.
README

hindsight

Keeps the Rule Files your agents load (AGENTS.md, CLAUDE.md, .claude/rules/, output styles) current from what you keep telling Claude Code, out of band, with the evidence attached, and writes nothing until you accept it.

The Review pane in a Claude Code session: a Proposal adds "Use pnpm for every package command; never npm." to AGENTS.md, quoting four corrections over four days; the second Proposal is rejected as already covered, the first is accepted.

Why

You correct Claude Code, and the same correction comes back a week later in another session, because no Rule File says it. hindsight keeps your own words as evidence, lets recurrence decide what deserves a rule, and hands you one exact edit to accept or reject.

How it works

<img alt="During your session: your session, then the Debrief Gate (code and a small model), then the Debrief (a fork on the session's cache), then Notes checked word for word. Out of band, later: Learn and Prune turn what recurs into a Proposal, you review it, and an accepted Proposal becomes one commit to a Rule File." src="docs/readme/flow-light.png" width="880">

  1. Debrief Gate. At the end of a turn, a Claude Code mod reads what happened since the session's previous Debrief: your own messages, the tools the agent used, refusals, errors, Rule File edits, commits. Code decides first; a small model confirms.
  2. Debrief. A fork of your session, served from its own prompt cache, answers five questions: did an edit make a loaded rule wrong, does a new rule deserve its place, which rule hindered, what was guessed for lack of a rule, what did you correct. Its answer is checked word for word against the transcript before anything is stored.
  3. Learn and Prune. When enough Notes wait, one Opus call per repository turns what recurs into Proposals; code gates keep ids, counts, scope and style honest. Prune checks the Rule Files for dead globs, gone paths and missing scripts.
  4. Review. At your next session start, one status line says how many Proposals wait. /hindsight review opens a pane: the target, the diff, the reasoning, the quotes. Accept commits one file in its repository (never pushed); a global Rule File is written where the agents load it. Reject needs a reason. Handoff writes instructions you give to an agent session.

Nothing of hindsight enters the agent's context.

Install

At the prompt of a Claude Code session:

/plugin install hindsight --marketplace bengous/hindsight

Answer y to add the marketplace, then pick a scope. The CLI runs with bun src/cli/main.ts <command> and needs Bun and the claude CLI, logged in, for Learn. The mod's interface (status line, toasts, Review pane) and the Proposals' claims are in French.

To work on it, add the checkout as a marketplace and install from it: claude plugin marketplace add <checkout>, then claude plugin install hindsight@hindsight. The plugin is read in place: edit, then /reload-plugins. A session can also load it for itself with claude --plugin-dir <checkout>, which hot-reloads the mod on every save.

Commands

CommandWhat it does
/hindsight reviewOpens the Review pane
/hindsight statusPending Proposals, failed Debriefs
/hindsight debriefForces a Debrief of the current session
/hindsight learnStarts Learn in the background
/hindsight handoffFills the prompt with the handed-off Proposals' instructions
hindsight status, doctorState, cache shares, Gate decisions, Learn cost; paths and permissions
hindsight gate replay --days 30Replays the Gate's code over past transcripts, without a model
hindsight learn, pruneRun Learn or Prune now

Configuration

$XDG_CONFIG_HOME/hindsight/config.json, every field optional; hindsight config prints the effective values. Gate: threshold (1.0), cooldownMs (90000) times the session's Debriefs up to cooldownMaxSteps (5), quietMinTools (3), dailyCap (20), minCacheShare (0.9), classifierModel. Learn: pendingNotes (8), minIntervalMs (6 h), model (opus), maxBudgetUsd (2), timeoutMs. notes.windowDays (30), review.pageSize (3), excludedRepos (checkout roots the Gate skips).

State

$XDG_STATE_HOME/hindsight/hindsight.sqlite (directory 0700, file 0600): debriefs with their cache share, the Gate's journal, the cited Human Messages, Notes, Proposals, the Ledger of your decisions, Learn runs and their cost, Handoffs. Logs of detached Learn runs sit beside it.

Safety model

The invariants in AGENTS.md are the contract: a human Decision before any write, only your words as Quotes, exact edits applied once, no Quote in a commit, no fallback model, and nothing in the agent's context.

License

MIT

Source 17 files
src/mod/register.ts 469 lines
1import type { HindsightConfig } from "../domain/config.ts";
2import type { JsonObject, JsonValue } from "../domain/json.ts";
3import type { Host, ReviewState } from "./host.ts";
4import type { PaneItem, ReviewActions } from "./review-pane.tsx";
5import type { EngineInterface, Register, SessionMessage } from "claude-code";
6
7import { atom, read, update } from "claude-code";
8
9import { defaultConfig, readConfig } from "../domain/config.ts";
10import { fromDraft, toDraft } from "../domain/diff.ts";
11import { addHumanText } from "../domain/facts.ts";
12import { cleanHumanText } from "../domain/human.ts";
13import { isJsonArray, isJsonObject, isJsonValue, numberField, textField } from "../domain/json.ts";
14import { cliConfig, cliStatus, runCli } from "./cli-client.ts";
15import { afterTurn, classifyThenDebrief } from "./gate.ts";
16import { noticeLine, refreshStatus } from "./notice.ts";
17import { drawReview, emptyReview, isPaneItem } from "./review-pane.tsx";
18import { changeState, pruneStore, readState } from "./state.ts";
19
20const PANE = "hindsight-review";
21
22const HUMAN_ORIGINS = new Set(["composer", "bridge"]);
23
24const DECIDE_TIMEOUT_MS = 10 * 60 * 1000;
25
26const REVIEW = atom({ plugin: "hindsight", key: "review" } as const, emptyReview);
27
28let config: HindsightConfig = defaultConfig;
29
30let isInteractive = true;
31
32/** The texts typed in the edit mode: a `$.state` write per keystroke would redraw the field under the typing. */
33let drafts: readonly string[] = [];
34
35function makeHost($: EngineInterface): Host {
36  return {
37    pluginRoot: $.plugin.root,
38    now: async () => $.clock.now(),
39    sessionId: async () => $.session.id(),
40    repoRoot: async () => (await $.session.repo())?.root ?? null,
41    messages: async () => $.session.messages(),
42    storeGet: async (key) => {
43      const value = await $.store.get(key);
44
45      return isJsonValue(value) ? value : undefined;
46    },
47    storeSet: async (key, value) => $.store.set(key, value),
48    storeDelete: async (key) => $.store.delete(key),
49    storeKeys: async () => $.store.keys(),
50    reviewState: async () => read($, REVIEW),
51    updateReview: async (change) => {
52      await update($, REVIEW, change);
53    },
54    run: async (argv, init) => $.process.run([...argv], init),
55    classify: async (text, labels, model) => $.model.classify(text, labels, model === null ? undefined : { model }),
56    fork: async (prompt) => $.model.fork({ prompt }),
57    after: (ms, fn) => {
58      $.clock.after(ms, fn);
59    },
60    status: (text) => $.ui.status(text),
61    toast: (text) => $.ui.toast(text),
62    log: (text) => $.ui.log(text, { to: "debug" }),
63    fill: async (text) => {
64      // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- `$.prompt.fill` fills the prompt, not an array: no slot shares the object.
65      await $.prompt.fill({ text, mode: "replace" });
66    },
67    focus: async (key) => {
68      const result = await $.ui.focus({ requestId: PANE, key });
69
70      if (result.deny !== undefined) {
71        throw new Error(`focus on ${key} denied: ${result.deny}`);
72      }
73    },
74  };
75}
76
77const describe = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
78
79const logFailure =
80  (host: Host, what: string) =>
81  (cause: unknown): void => {
82    host.log(`${what} failed: ${describe(cause)}`);
83  };
84
85const toastFailure =
86  (host: Host) =>
87  (cause: unknown): void => {
88    host.toast(describe(cause));
89  };
90
91/** Config, Notice and store upkeep, none of it awaited by the hook: the first prompt never waits for the CLI. */
92async function startup(host: Host): Promise<void> {
93  try {
94    config = readConfig(await cliConfig(host));
95  } catch (error) {
96    host.toast(`config illisible, défauts gardés (${describe(error)})`);
97  }
98
99  await refreshStatus(host);
100
101  try {
102    await pruneStore(host, await host.now());
103  } catch (error) {
104    host.log(`store prune failed: ${describe(error)}`);
105  }
106}
107
108async function captureHuman(host: Host, text: string): Promise<void> {
109  const cleaned = cleanHumanText(text);
110
111  if (cleaned === "") {
112    return;
113  }
114
115  const now = await host.now();
116  const sessionId = await host.sessionId();
117  await changeState(host, sessionId, now, (state) => ({ ...state, facts: addHumanText(state.facts, cleaned) }));
118}
119
120async function endTurn(
121  host: Host,
122  turnId: string,
123  isAborted: boolean,
124  messages: readonly SessionMessage[],
125): Promise<void> {
126  const outcome = await afterTurn(host, config, { turnId, isAborted, agentId: null, isInteractive }, messages);
127
128  if (outcome.kind !== "pass") {
129    return;
130  }
131
132  try {
133    await classifyThenDebrief(host, config, turnId, outcome.brief, outcome.score, false);
134  } catch (error) {
135    logFailure(host, "debrief pipeline")(error);
136  }
137}
138
139async function forceDebrief(host: Host): Promise<void> {
140  const now = await host.now();
141  const sessionId = await host.sessionId();
142  const state = await readState(host, sessionId, now);
143  const brief = `Forced by /hindsight debrief. Facts since the previous debrief: ${state.facts.prompts} human message(s), ${state.facts.toolCalls} tool call(s), ${state.facts.edits.length} Edit/Write.`;
144  host.toast("Debrief forcé : le fork relit la session…");
145  await classifyThenDebrief(host, config, null, brief, 0, true);
146}
147
148function toPaneItems(answer: JsonValue): Pick<ReviewState, "items" | "pending"> {
149  const listed = isJsonObject(answer) ? answer["items"] : undefined;
150
151  if (!isJsonArray(listed)) {
152    throw new TypeError("review list answered without items");
153  }
154
155  const items = listed.map((item, index) => {
156    if (!isPaneItem(item)) {
157      throw new TypeError(`review list: item ${index} is out of shape`);
158    }
159
160    return item;
161  });
162
163  return { items, pending: numberField(answer, "pending") ?? items.length };
164}
165
166async function openReview(host: Host, open: (focus: boolean) => Promise<boolean>): Promise<void> {
167  const listed = toPaneItems(await runCli(host, ["review", "list"]));
168  await host.updateReview(() => ({ ...emptyReview, items: listed.items, pending: listed.pending }));
169
170  if (listed.items.length === 0) {
171    host.toast("aucune Proposal en attente");
172
173    return;
174  }
175
176  if (!(await open(true))) {
177    host.toast("élargis le terminal pour voir le panneau de Review");
178  }
179}
180
181function decisionLabel(kind: JsonValue | undefined): string {
182  if (kind === "accepted") {
183    return "acceptée";
184  }
185
186  if (kind === "rejected") {
187    return "rejetée";
188  }
189
190  return "en Handoff";
191}
192
193function dropProposal(state: ReviewState, id: string): ReviewState {
194  const items = state.items.filter((item) => item.id !== id);
195
196  return {
197    ...state,
198    items,
199    index: Math.min(state.index, Math.max(0, items.length - 1)),
200    mode: { kind: "view" },
201    expanded: [],
202    folded: true,
203  };
204}
205
206function moveTo(state: ReviewState, step: 1 | -1): ReviewState {
207  const count = state.items.length;
208
209  return count === 0
210    ? state
211    : {
212        ...state,
213        index: (state.index + step + count) % count,
214        mode: { kind: "view" },
215        expanded: [],
216        folded: true,
217        notice: null,
218      };
219}
220
221async function decide(host: Host, id: string, decision: JsonObject): Promise<void> {
222  await host.updateReview((state) => ({ ...state, mode: { kind: "busy", label: "Décision en cours…" }, notice: null }));
223
224  try {
225    const answer = await runCli(host, ["review", "decide"], {
226      stdin: JSON.stringify({ id, ...decision }),
227      timeoutMs: DECIDE_TIMEOUT_MS,
228    });
229
230    const kind = isJsonObject(answer) ? answer["kind"] : undefined;
231
232    if (kind === "refused") {
233      const reason = textField(answer, "reason") ?? "refusée";
234      await host.updateReview((state) => ({
235        ...state,
236        mode: { kind: "view" },
237        notice: `Décision refusée : ${reason}`,
238      }));
239    } else {
240      host.toast(`Proposal ${decisionLabel(kind)}`);
241      await host.updateReview((state) => dropProposal(state, id));
242      await refreshStatus(host);
243    }
244  } catch (error) {
245    await host.updateReview((state) => ({ ...state, mode: { kind: "view" }, notice: describe(error) }));
246    await refreshStatus(host);
247  }
248}
249
250/** The drafts of the item being edited; seeded again when a reload emptied them while `$.state` kept the edit mode. */
251function draftsFor(state: ReviewState): readonly string[] {
252  const item = state.items[state.index];
253
254  if (state.mode.kind === "edit" && item?.change.kind === "edit" && drafts.length !== item.change.edits.length) {
255    drafts = item.change.edits.map((edit) => toDraft(edit.newText));
256  }
257
258  return drafts;
259}
260
261function paneActions(host: Host): ReviewActions {
262  const change = (fn: (state: ReviewState) => ReviewState): void => {
263    void host.updateReview(fn).catch(logFailure(host, "review"));
264  };
265
266  const onCurrent = (act: (item: PaneItem, state: ReviewState) => Promise<void>): void => {
267    const run = async (): Promise<void> => {
268      const state = await host.reviewState();
269      const item = state.items[state.index];
270
271      if (item !== undefined) {
272        await act(item, state);
273      }
274    };
275
276    void run().catch(logFailure(host, "review"));
277  };
278
279  return {
280    accept: (edits) => onCurrent(async (item) => decide(host, item.id, { decision: "accept", edits })),
281    acceptEdited: () =>
282      onCurrent(async (item, state) => {
283        if (item.change.kind !== "edit" || state.mode.kind !== "edit") {
284          return;
285        }
286
287        const edits = item.change.edits.map((edit, index) => ({
288          oldText: edit.oldText,
289          newText: fromDraft(drafts[index] ?? toDraft(edit.newText)),
290        }));
291
292        await decide(host, item.id, { decision: "accept", edits });
293      }),
294    reject: (reason) =>
295      onCurrent(async (item) => {
296        if (reason.trim() === "") {
297          await host.updateReview((state) => ({ ...state, notice: "Un rejet demande une raison." }));
298
299          return;
300        }
301
302        await decide(host, item.id, { decision: "reject", reason: reason.trim() });
303      }),
304    handoff: () => onCurrent(async (item) => decide(host, item.id, { decision: "handoff" })),
305    next: () => change((state) => moveTo(state, 1)),
306    previous: () => change((state) => moveTo(state, -1)),
307    edit: () =>
308      onCurrent(async (item) => {
309        if (item.change.kind !== "edit") {
310          return;
311        }
312
313        drafts = item.change.edits.map((edit) => toDraft(edit.newText));
314        await host.updateReview((state) => ({ ...state, mode: { kind: "edit" }, notice: null }));
315        await host.focus("draft-0");
316      }),
317    setMode: (mode) => {
318      change((state) => ({ ...state, mode, notice: null }));
319
320      if (mode.kind === "reject-other") {
321        void host.focus("reject-reason").catch(logFailure(host, "review"));
322      }
323    },
324    toggleEvidence: (index) =>
325      change((state) => ({
326        ...state,
327        expanded: state.expanded.includes(index)
328          ? state.expanded.filter((i) => i !== index)
329          : [...state.expanded, index],
330      })),
331    toggleFold: () => change((state) => ({ ...state, folded: !state.folded })),
332    setDraft: (index, value) => {
333      drafts = drafts.map((draft, i) => (i === index ? value : draft));
334    },
335  };
336}
337
338async function fillHandoff(host: Host): Promise<void> {
339  const answer = await runCli(host, ["handoff", "prepare"]);
340  const instructions = textField(answer, "instructions");
341
342  if (instructions === null) {
343    host.toast("aucune Proposal en Handoff");
344
345    return;
346  }
347
348  await host.fill(instructions);
349  host.toast("instructions du Handoff dans le prompt, à envoyer si tu veux");
350}
351
352async function runCommand(host: Host, args: string, open: (focus: boolean) => Promise<boolean>): Promise<void> {
353  const [verb = "status"] = args.trim().split(/\s+/);
354
355  switch (verb) {
356    case "status": {
357      const line = noticeLine(await cliStatus(host));
358      host.toast(line ?? "rien en attente");
359      host.status(line);
360
361      return;
362    }
363
364    case "debrief": {
365      await forceDebrief(host);
366
367      return;
368    }
369
370    case "review": {
371      await openReview(host, open);
372
373      return;
374    }
375
376    case "handoff": {
377      await fillHandoff(host);
378
379      return;
380    }
381
382    case "learn": {
383      const answer = await runCli(host, ["learn", "--detach"]);
384      host.toast(
385        isJsonObject(answer) && answer["detached"] === true ? "Learn lancé en arrière-plan" : "Learn non lancé",
386      );
387
388      return;
389    }
390
391    default: {
392      host.toast(`commande inconnue « ${verb} » (review, status, debrief, learn, handoff)`);
393    }
394  }
395}
396
397export const register: Register = (on) => {
398  on("session.start", async ($, e, next) => {
399    isInteractive = e.isInteractive;
400    const host = makeHost($);
401
402    try {
403      await $.command.register({
404        name: "hindsight",
405        description: "review, status, debrief, learn, handoff",
406        argumentHint: "review|status|debrief|learn|handoff",
407      });
408    } catch (error) {
409      host.toast(`commande non enregistrée (${describe(error)})`);
410    }
411
412    void startup(host);
413
414    return next(e);
415  });
416
417  on("prompt.submit", async ($, e, next) => {
418    if (HUMAN_ORIGINS.has(e.origin.kind)) {
419      try {
420        await captureHuman(makeHost($), e.text);
421      } catch (error) {
422        makeHost($).log(`prompt capture failed: ${describe(error)}`);
423      }
424    }
425
426    return next(e);
427  });
428
429  on("turn.complete", async ($, e, next) => {
430    const result = await next(e);
431
432    if (e.agentId === undefined) {
433      const host = makeHost($);
434
435      const messages = await host.messages();
436
437      host.after(1, () => {
438        void endTurn(host, e.turnId, e.isAborted, messages).catch(logFailure(host, "gate"));
439      });
440    }
441
442    return result;
443  });
444
445  on("command.run", { command: "hindsight" }, async ($, e) => {
446    const host = makeHost($);
447
448    const open = async (focus: boolean): Promise<boolean> => {
449      const opened = await $.ui.open(
450        focus
451          ? { id: PANE, title: "hindsight · Review", focus: true, closeOnEscape: true, rows: 30 }
452          : { id: PANE, title: "hindsight · Review" },
453      );
454
455      return opened.isPlaced;
456    };
457
458    void runCommand(host, e.args, open).catch(toastFailure(host));
459
460    return {};
461  });
462
463  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
464    const state = await read($, REVIEW);
465
466    return drawReview($.ui.resolve(e), state, draftsFor(state), paneActions(makeHost($)), e.props.bodyColumns);
467  });
468};
469
src/domain/config.ts 93 lines
1import type { JsonObject, JsonValue } from "./json.ts";
2
3import { isJsonObject, isString, numberField, textField } from "./json.ts";
4
5export type HindsightConfig = {
6  readonly gate: {
7    readonly threshold: number;
8    readonly cooldownMs: number;
9    readonly cooldownMaxSteps: number;
10    readonly quietMinTools: number;
11    readonly dailyCap: number;
12    readonly minCacheShare: number;
13    readonly classifierModel: string | null;
14  };
15  readonly learn: {
16    readonly pendingNotes: number;
17    readonly minIntervalMs: number;
18    readonly model: string;
19    readonly maxBudgetUsd: number;
20    readonly timeoutMs: number;
21  };
22  readonly notes: { readonly windowDays: number };
23  readonly review: { readonly pageSize: number };
24  readonly excludedRepos: readonly string[];
25};
26
27export const defaultConfig: HindsightConfig = {
28  gate: {
29    threshold: 1,
30    cooldownMs: 90_000,
31    cooldownMaxSteps: 5,
32    quietMinTools: 3,
33    dailyCap: 20,
34    minCacheShare: 0.9,
35    classifierModel: null,
36  },
37  learn: {
38    pendingNotes: 8,
39    minIntervalMs: 6 * 60 * 60 * 1000,
40    model: "opus",
41    maxBudgetUsd: 2,
42    timeoutMs: 15 * 60 * 1000,
43  },
44  notes: { windowDays: 30 },
45  review: { pageSize: 3 },
46  excludedRepos: [],
47};
48
49function num(source: JsonObject, key: string, fallback: number): number {
50  const value = numberField(source, key);
51
52  return value !== null && Number.isFinite(value) ? value : fallback;
53}
54
55function str(source: JsonObject, key: string, fallback: string): string {
56  const value = textField(source, key);
57
58  return value !== null && value !== "" ? value : fallback;
59}
60
61/** Overlays a parsed `config.json` on the defaults; an unreadable or absent field keeps its default. */
62export function readConfig(input: JsonValue | undefined): HindsightConfig {
63  const root = isJsonObject(input) ? input : {};
64  const gate = isJsonObject(root["gate"]) ? root["gate"] : {};
65  const learn = isJsonObject(root["learn"]) ? root["learn"] : {};
66  const notes = isJsonObject(root["notes"]) ? root["notes"] : {};
67  const review = isJsonObject(root["review"]) ? root["review"] : {};
68  const excluded = root["excludedRepos"];
69  const classifier = textField(gate, "classifierModel");
70
71  return {
72    gate: {
73      threshold: num(gate, "threshold", defaultConfig.gate.threshold),
74      cooldownMs: num(gate, "cooldownMs", defaultConfig.gate.cooldownMs),
75      cooldownMaxSteps: num(gate, "cooldownMaxSteps", defaultConfig.gate.cooldownMaxSteps),
76      quietMinTools: num(gate, "quietMinTools", defaultConfig.gate.quietMinTools),
77      dailyCap: num(gate, "dailyCap", defaultConfig.gate.dailyCap),
78      minCacheShare: num(gate, "minCacheShare", defaultConfig.gate.minCacheShare),
79      classifierModel: classifier === "" ? null : classifier,
80    },
81    learn: {
82      pendingNotes: num(learn, "pendingNotes", defaultConfig.learn.pendingNotes),
83      minIntervalMs: num(learn, "minIntervalMs", defaultConfig.learn.minIntervalMs),
84      model: str(learn, "model", defaultConfig.learn.model),
85      maxBudgetUsd: num(learn, "maxBudgetUsd", defaultConfig.learn.maxBudgetUsd),
86      timeoutMs: num(learn, "timeoutMs", defaultConfig.learn.timeoutMs),
87    },
88    notes: { windowDays: num(notes, "windowDays", defaultConfig.notes.windowDays) },
89    review: { pageSize: num(review, "pageSize", defaultConfig.review.pageSize) },
90    excludedRepos: Array.isArray(excluded) ? excluded.filter(isString) : [],
91  };
92}
93
src/domain/json.ts 47 lines
1export type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject;
2
3export type JsonObject = { readonly [key: string]: JsonValue };
4
5export const isString = (value: unknown): value is string => typeof value === "string";
6
7export const isNumber = (value: unknown): value is number => typeof value === "number";
8
9export function isJsonValue(value: unknown): value is JsonValue {
10  if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
11    return true;
12  }
13
14  if (Array.isArray(value)) {
15    return value.every(isJsonValue);
16  }
17
18  return typeof value === "object" && Object.values(value).every(isJsonValue);
19}
20
21export const isJsonArray = (value: JsonValue | undefined): value is readonly JsonValue[] => Array.isArray(value);
22
23export const isJsonObject = (value: JsonValue | undefined): value is JsonObject =>
24  typeof value === "object" && value !== null && !Array.isArray(value);
25
26export function parseJson(text: string): JsonValue {
27  const parsed: unknown = JSON.parse(text);
28
29  if (!isJsonValue(parsed)) {
30    throw new TypeError("JSON.parse returned a value outside JSON");
31  }
32
33  return parsed;
34}
35
36export function textField(source: JsonValue | undefined, key: string): string | null {
37  const value = isJsonObject(source) ? source[key] : undefined;
38
39  return isString(value) ? value : null;
40}
41
42export function numberField(source: JsonValue | undefined, key: string): number | null {
43  const value = isJsonObject(source) ? source[key] : undefined;
44
45  return isNumber(value) ? value : null;
46}
47
src/mod/host.ts 30 lines
1import type { JsonValue } from "../domain/json.ts";
2import type { ModelForkResult, PluginState, ProcessRunInit, ProcessRunResult, SessionMessage } from "claude-code";
3
4/** The pane's state as the contract in types/index.d.ts declares it on PluginState: written once, read from here. */
5export type ReviewState = PluginState["hindsight"]["review"];
6
7/** What the mod's logic needs from the engine, built from `$` in register.ts alone so that bun:test can fake it. */
8export type Host = {
9  readonly pluginRoot: string;
10  readonly now: () => Promise<number>;
11  readonly sessionId: () => Promise<string>;
12  readonly repoRoot: () => Promise<string | null>;
13  readonly messages: () => Promise<readonly SessionMessage[]>;
14  readonly storeGet: (key: string) => Promise<JsonValue | undefined>;
15  readonly storeSet: (key: string, value: JsonValue) => Promise<void>;
16  readonly storeDelete: (key: string) => Promise<void>;
17  readonly storeKeys: () => Promise<readonly string[]>;
18  readonly reviewState: () => Promise<ReviewState>;
19  readonly updateReview: (change: (state: ReviewState) => ReviewState) => Promise<void>;
20  readonly run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>;
21  readonly classify: (text: string, labels: readonly string[], model: string | null) => Promise<string | undefined>;
22  readonly fork: (prompt: string) => Promise<ModelForkResult>;
23  readonly after: (ms: number, fn: () => void) => void;
24  readonly status: (text: string | undefined) => void;
25  readonly toast: (text: string) => void;
26  readonly log: (text: string) => void;
27  readonly fill: (text: string) => Promise<void>;
28  readonly focus: (key: string) => Promise<void>;
29};
30
src/mod/review-pane.tsx 315 lines
1import type { JsonValue } from "../domain/json.ts";
2import type { ReviewState } from "./host.ts";
3import type { Elements, RenderElement } from "claude-code";
4
5import { unifiedDiff } from "../domain/diff.ts";
6import { isJsonObject, isNumber, isString } from "../domain/json.ts";
7import { displayText } from "../domain/text.ts";
8
9/** The elements the pane draws with: present on every surface except `Input`, which mobile does not draw. */
10export type PaneElements = Pick<Elements["mobile"], "Box" | "Text" | "Button" | "Code"> &
11  Partial<Pick<Elements["terminal"], "Input">>;
12
13export type PaneItem = ReviewState["items"][number];
14
15export type PaneMode = ReviewState["mode"];
16
17export type PaneEvidence = PaneItem["evidence"][number];
18
19export type PaneEdit = Extract<PaneItem["change"], { kind: "edit" }>["edits"][number];
20
21export type ReviewActions = {
22  readonly accept: (edits: readonly PaneEdit[] | null) => void;
23  readonly acceptEdited: () => void;
24  readonly reject: (reason: string) => void;
25  readonly handoff: () => void;
26  readonly next: () => void;
27  readonly previous: () => void;
28  readonly edit: () => void;
29  readonly setMode: (mode: PaneMode) => void;
30  readonly toggleEvidence: (index: number) => void;
31  readonly toggleFold: () => void;
32  readonly setDraft: (index: number, value: string) => void;
33};
34
35export const emptyReview: ReviewState = {
36  items: [],
37  index: 0,
38  mode: { kind: "view" },
39  expanded: [],
40  folded: true,
41  notice: null,
42  pending: 0,
43};
44
45export const REJECT_REASONS = ["Pas une règle", "Déjà couvert", "Trop lié à une session", "Faux positif"] as const;
46
47const plural = (count: number, word: string): string => `${count} ${word}${count > 1 ? "s" : ""}`;
48
49export function prevalenceLine(prevalence: { sessions: number; days: number; repos: number }): string {
50  if (prevalence.sessions === 0) {
51    return "vérifié par le code";
52  }
53
54  return `vu dans ${plural(prevalence.sessions, "session")} sur ${plural(prevalence.days, "jour")}, ${plural(prevalence.repos, "dépôt")}`;
55}
56
57const MONTHS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];
58
59/** The day and month of an ISO date or datetime, read from its text: a timezone would move the day. */
60export function shortDate(iso: string): string {
61  const [, month, day] = /^\d{4}-(\d{2})-(\d{2})/.exec(iso) ?? [];
62  const name = MONTHS[Number(month) - 1];
63
64  return name === undefined || day === undefined ? iso : `${Number(day)} ${name}`;
65}
66
67const isTextOrNull = (value: JsonValue | undefined): value is string | null => value === null || isString(value);
68
69function isPaneEvidence(value: JsonValue): value is PaneEvidence {
70  return (
71    isJsonObject(value) &&
72    (value["kind"] === "quote" || value["kind"] === "observation") &&
73    isString(value["at"]) &&
74    isString(value["text"]) &&
75    isTextOrNull(value["message"]) &&
76    isTextOrNull(value["tool"])
77  );
78}
79
80function isPaneEdit(value: JsonValue): value is PaneEdit {
81  return isJsonObject(value) && isString(value["oldText"]) && isString(value["newText"]);
82}
83
84function isPaneChange(value: JsonValue | undefined): value is PaneItem["change"] {
85  if (!isJsonObject(value)) {
86    return false;
87  }
88
89  const edits = value["edits"];
90
91  return value["kind"] === "edit"
92    ? Array.isArray(edits) && edits.every(isPaneEdit)
93    : value["kind"] === "description" && isString(value["text"]);
94}
95
96export function isPaneItem(value: JsonValue): value is PaneItem {
97  if (!isJsonObject(value)) {
98    return false;
99  }
100
101  const evidence = value["evidence"];
102  const prevalence = value["prevalence"];
103
104  return (
105    isString(value["id"]) &&
106    isString(value["targetPath"]) &&
107    isTextOrNull(value["repoRoot"]) &&
108    isTextOrNull(value["verdict"]) &&
109    isString(value["source"]) &&
110    isString(value["claimFr"]) &&
111    isString(value["reasoningFr"]) &&
112    isString(value["lessonEn"]) &&
113    isPaneChange(value["change"]) &&
114    Array.isArray(evidence) &&
115    evidence.every(isPaneEvidence) &&
116    isJsonObject(prevalence) &&
117    isNumber(prevalence["sessions"]) &&
118    isNumber(prevalence["days"]) &&
119    isNumber(prevalence["repos"])
120  );
121}
122
123const TEXT_MAX = 600;
124
125const MESSAGE_MAX = 2000;
126
127export function displayPath(path: string, repoRoot: string | null): string {
128  if (repoRoot !== null && path.startsWith(`${repoRoot}/`)) {
129    return path.slice(repoRoot.length + 1);
130  }
131
132  return path;
133}
134
135/** The Decision row: an edit is accepted, modified, rejected or handed off; a description only handed off or rejected. */
136function viewActions(el: PaneElements, isEdit: boolean, actions: ReviewActions): RenderElement {
137  const { Box, Button } = el;
138
139  return (
140    <Box flexDirection="row" gap={1}>
141      {isEdit && (
142        <Button key="accept" hotkey="a" variant="primary" onPress={() => actions.accept(null)}>
143          Accepter
144        </Button>
145      )}
146      {isEdit && (
147        <Button key="edit" hotkey="m" onPress={() => actions.edit()}>
148          Modifier
149        </Button>
150      )}
151      <Button key="reject" hotkey="r" onPress={() => actions.setMode({ kind: "reject" })}>
152        Rejeter
153      </Button>
154      <Button
155        key="handoff"
156        hotkey="h"
157        {...(isEdit ? {} : { variant: "primary" as const })}
158        onPress={() => actions.handoff()}
159      >
160        {isEdit ? "Handoff" : "Ajouter au Handoff"}
161      </Button>
162      <Button key="previous" hotkey="p" onPress={() => actions.previous()}>
163        Précédent
164      </Button>
165      <Button key="next" hotkey="n" onPress={() => actions.next()}>
166        Suivant
167      </Button>
168    </Box>
169  );
170}
171
172export function drawReview(
173  el: PaneElements,
174  state: ReviewState,
175  drafts: readonly string[],
176  actions: ReviewActions,
177  bodyColumns: number,
178): RenderElement {
179  const { Box, Text, Button, Input, Code } = el;
180  const width = Math.max(30, bodyColumns - 2);
181  const item = state.items[state.index];
182
183  if (item === undefined) {
184    return (
185      <Box flexDirection="column">
186        <Text dimColor>{state.notice ?? "Aucune Proposal en attente."}</Text>
187      </Box>
188    );
189  }
190
191  const isEdit = item.change.kind === "edit";
192  const path = displayPath(item.targetPath, item.repoRoot);
193
194  return (
195    <Box flexDirection="column" width={width} gap={1}>
196      <Box flexDirection="row" justifyContent="space-between">
197        <Text bold>
198          {path} <Text dimColor>{item.repoRoot ?? "global"}</Text>
199        </Text>
200        <Text dimColor>
201          {state.index + 1} sur {state.items.length}
202        </Text>
203      </Box>
204      {item.change.kind === "edit" ? (
205        <Box borderStyle="single" borderDimColor paddingX={1}>
206          <Code source={unifiedDiff(path, item.change.edits)} format="diff" path={path} />
207        </Box>
208      ) : (
209        <Box flexDirection="column">
210          <Text>{displayText(item.change.text, TEXT_MAX)}</Text>
211          <Text dimColor>Ce changement se fait dans un Handoff, pas par une édition de texte.</Text>
212        </Box>
213      )}
214      <Box flexDirection="column">
215        <Text>{displayText(item.claimFr, TEXT_MAX)}</Text>
216        <Box flexDirection="column" paddingLeft={2}>
217          {item.evidence.map((evidence, index) => (
218            <Box flexDirection="column" key={`ev-${index}`}>
219              <Button key={`evidence-${index}`} plain onPress={() => actions.toggleEvidence(index)}>
220                {state.expanded.includes(index) ? "▾ " : "▸ "}
221                {evidence.kind === "quote" ? "« " : `${evidence.tool ?? "outil"} › `}
222                {displayText(evidence.text, TEXT_MAX)}
223                {evidence.kind === "quote" ? " »" : ""} <Text dimColor>{shortDate(evidence.at)}</Text>
224              </Button>
225              {state.expanded.includes(index) && evidence.message !== null && (
226                <Text dimColor>{displayText(evidence.message, MESSAGE_MAX)}</Text>
227              )}
228            </Box>
229          ))}
230        </Box>
231      </Box>
232      <Box flexDirection="column">
233        <Button key="fold" plain onPress={() => actions.toggleFold()}>
234          {state.folded ? "▸ " : "▾ "}Raisonnement et Lesson
235        </Button>
236        {!state.folded && (
237          <Box flexDirection="column" paddingLeft={2}>
238            <Text dimColor>{displayText(item.reasoningFr, TEXT_MAX)}</Text>
239            <Text dimColor>Lesson: {displayText(item.lessonEn, TEXT_MAX)}</Text>
240          </Box>
241        )}
242      </Box>
243      <Text dimColor>
244        {item.verdict ?? item.source} · {prevalenceLine(item.prevalence)}
245      </Text>
246      {state.notice !== null && <Text color="warning">{displayText(state.notice, TEXT_MAX)}</Text>}
247      {state.mode.kind === "view" && viewActions(el, isEdit, actions)}
248      {state.mode.kind === "edit" && item.change.kind === "edit" && (
249        <Box flexDirection="column">
250          <Text dimColor>Nouveau texte, une ligne par modification ; ⏎ marque un saut de ligne.</Text>
251          {Input !== undefined &&
252            drafts.map((draft, index) => (
253              <Input
254                key={`draft-${index}`}
255                label={`Modification ${index + 1}`}
256                value={draft}
257                {...(index === 0 ? { autoFocus: true as const } : {})}
258                onInput={(value) => actions.setDraft(index, value)}
259                onSubmit={(value) => actions.setDraft(index, value)}
260              />
261            ))}
262          <Box flexDirection="row" gap={1}>
263            <Button key="accept-edited" variant="primary" onPress={() => actions.acceptEdited()}>
264              Accepter le texte modifié
265            </Button>
266            <Button key="cancel-edit" onPress={() => actions.setMode({ kind: "view" })}>
267              Annuler
268            </Button>
269          </Box>
270        </Box>
271      )}
272      {state.mode.kind === "reject" && (
273        <Box flexDirection="column">
274          <Text dimColor>Pourquoi ce rejet ?</Text>
275          {REJECT_REASONS.map((reason, index) => (
276            <Button key={`reason-${index}`} hotkey={String(index + 1)} plain onPress={() => actions.reject(reason)}>
277              {reason}
278            </Button>
279          ))}
280          {Input !== undefined && (
281            <Button
282              key="reject-other"
283              hotkey={String(REJECT_REASONS.length + 1)}
284              plain
285              onPress={() => actions.setMode({ kind: "reject-other" })}
286            >
287              Autre…
288            </Button>
289          )}
290          <Button key="cancel-reject" onPress={() => actions.setMode({ kind: "view" })}>
291            Annuler
292          </Button>
293        </Box>
294      )}
295      {state.mode.kind === "reject-other" && (
296        <Box flexDirection="column">
297          {Input !== undefined && (
298            <Input
299              key="reject-reason"
300              label="Autre raison"
301              placeholder="une phrase, gardée dans le Ledger"
302              autoFocus
303              onSubmit={(value) => actions.reject(value)}
304            />
305          )}
306          <Button key="cancel-reject" onPress={() => actions.setMode({ kind: "view" })}>
307            Annuler
308          </Button>
309        </Box>
310      )}
311      {state.mode.kind === "busy" && <Text dimColor>{state.mode.label}</Text>}
312    </Box>
313  );
314}
315
src/domain/diff.ts 34 lines
1export type TextEdit = { readonly oldText: string; readonly newText: string };
2
3const lines = (text: string): string[] => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
4
5/** One unified diff of the edits, as `Code format="diff"` draws it: a hunk per edit, old lines out, new lines in. */
6export function unifiedDiff(path: string, edits: readonly TextEdit[]): string {
7  const creation = edits.length === 1 && edits[0]?.oldText === "";
8  const header = [`--- ${creation ? "/dev/null" : `a/${path}`}`, `+++ b/${path}`];
9
10  const hunks = edits.map((edit) => {
11    const removed = lines(edit.oldText);
12    const added = lines(edit.newText);
13
14    return [
15      `@@ -1,${removed.length} +1,${added.length} @@`,
16      ...removed.map((line) => `-${line}`),
17      ...added.map((line) => `+${line}`),
18    ].join("\n");
19  });
20
21  return [...header, ...hunks].join("\n");
22}
23
24export const NEWLINE_MARK = "⏎";
25
26/** The pane's Input is one line: the mark stands for a line break both ways. */
27export function toDraft(text: string): string {
28  return text.replaceAll("\n", NEWLINE_MARK);
29}
30
31export function fromDraft(draft: string): string {
32  return draft.replaceAll(NEWLINE_MARK, "\n");
33}
34
src/domain/facts.ts 106 lines
1import type { Marker } from "./human.ts";
2
3import { markersFor } from "./human.ts";
4
5/** One tool call of a turn, reduced to what the Gate reads. */
6export type ToolUse = {
7  readonly tool: string;
8  readonly path: string | null;
9  readonly command: string | null;
10  readonly isError: boolean;
11  readonly isRefused: boolean;
12};
13
14export type HumanText = {
15  readonly text: string;
16  readonly markers: readonly Marker[];
17};
18
19/** What happened in a session since its last Debrief, accumulated turn by turn. */
20export type TurnFacts = {
21  readonly humanTexts: readonly HumanText[];
22  readonly edits: readonly string[];
23  readonly toolCalls: number;
24  readonly toolErrors: number;
25  readonly refusedTools: readonly string[];
26  readonly gitCommits: number;
27  readonly afterInterrupt: boolean;
28  readonly prompts: number;
29};
30
31export const HUMAN_TEXT_MAX = 4000;
32
33const HUMAN_TEXTS_KEPT = 20;
34
35const RULE_FILE = /(^|\/)(AGENTS|CLAUDE)\.md$|\/\.claude\/rules\/[^/]+\.md$|\/output-styles\/[^/]+\.md$/;
36
37const GIT_COMMIT = /(^|[;&|]\s*)git\s+(-[^\s]+\s+)*commit\b/;
38
39const REFUSAL_TEXT = /user doesn't want to proceed|user rejected|rejected this tool|permission denied by the user/i;
40
41export const emptyFacts: TurnFacts = {
42  humanTexts: [],
43  edits: [],
44  toolCalls: 0,
45  toolErrors: 0,
46  refusedTools: [],
47  gitCommits: 0,
48  afterInterrupt: false,
49  prompts: 0,
50};
51
52export function isRuleFile(path: string): boolean {
53  return RULE_FILE.test(path);
54}
55
56export function isGitCommit(command: string): boolean {
57  return GIT_COMMIT.test(command);
58}
59
60/** True for the error text a tool result carries when the human refused the call. */
61export function isRefusalText(text: string): boolean {
62  return REFUSAL_TEXT.test(text);
63}
64
65/** `text` is already cleaned (`cleanHumanText`); an empty one adds nothing. */
66export function addHumanText(facts: TurnFacts, text: string): TurnFacts {
67  if (text === "") {
68    return facts;
69  }
70
71  const markers = markersFor(text, {
72    isFirstPrompt: facts.prompts === 0,
73    afterInterrupt: facts.afterInterrupt,
74    rejectedTools: facts.refusedTools,
75  });
76
77  return {
78    ...facts,
79    humanTexts: [...facts.humanTexts, { text: text.slice(0, HUMAN_TEXT_MAX), markers }].slice(-HUMAN_TEXTS_KEPT),
80    afterInterrupt: false,
81    prompts: facts.prompts + 1,
82  };
83}
84
85export function addToolUses(facts: TurnFacts, uses: readonly ToolUse[]): TurnFacts {
86  const edits = uses.flatMap((use) =>
87    (use.tool === "Edit" || use.tool === "Write" || use.tool === "NotebookEdit") && use.path !== null ? [use.path] : [],
88  );
89
90  const commits = uses.filter((use) => use.tool === "Bash" && use.command !== null && isGitCommit(use.command)).length;
91  const refused = uses.flatMap((use) => (use.isRefused ? [use.tool] : []));
92
93  return {
94    ...facts,
95    edits: [...facts.edits, ...edits],
96    toolCalls: facts.toolCalls + uses.length,
97    toolErrors: facts.toolErrors + uses.filter((use) => use.isError && !use.isRefused).length,
98    refusedTools: [...facts.refusedTools, ...refused],
99    gitCommits: facts.gitCommits + commits,
100  };
101}
102
103export function markInterrupted(facts: TurnFacts): TurnFacts {
104  return { ...facts, afterInterrupt: true };
105}
106
src/domain/human.ts 158 lines
1import { foldText } from "./text.ts";
2
3export type Marker =
4  | "interrupt_followup"
5  | "user_rejected"
6  | "question_rejected"
7  | "plan_rejected"
8  | "correction_strong"
9  | "correction_medium"
10  | "frustration"
11  | "standing_instruction";
12
13export type MarkerContext = {
14  readonly isFirstPrompt: boolean;
15  readonly afterInterrupt: boolean;
16  /** Tools the human rejected since their previous message. */
17  readonly rejectedTools: readonly string[];
18};
19
20const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
21
22const PASTED_CONTENT = /<pasted_content(?: id="[^"]*")?>([\s\S]*?)<\/pasted_content(?: id="[^"]*")?>/g;
23
24const FORM_ROUND_HEADER =
25  /^[^\S\n]*(?:[^:\n]{1,60},[^\S\n]*)?(?:round|tour)[^\S\n]+\d+[^\S\n]*\n(?=[^\S\n]*[^:\n]{1,40}[^\S\n]:[^\S\n])/gim;
26
27const MARKDOWN_LINE = /^\s*(#|[-*>|+]\s|\d+[.)]\s|```|\{|<)/m;
28
29const FIRST_PERSON_FR = /\b(je|j'|moi|mon|ma|mes)\b/;
30
31const DICTATION_PROBE = 600;
32
33const DICTATION_MIN_LENGTH = 150;
34
35const PATH_OR_URL = /(?:^|[\s(])(?:~\/|\$HOME|[\w.-]+\/[\w.-]+\/)|:\/\//;
36
37const TERMINAL_GLYPH = /[❯●⏺]/;
38
39/**
40 * The user's own dictation: long, first-person French, with no markdown, path, URL or terminal glyph.
41 * Agent reports and copied terminal output fail at least one of these.
42 */
43export function isDictatedPaste(body: string): boolean {
44  return (
45    body.length >= DICTATION_MIN_LENGTH &&
46    !MARKDOWN_LINE.test(body) &&
47    !body.includes("**") &&
48    !PATH_OR_URL.test(body) &&
49    !TERMINAL_GLYPH.test(body) &&
50    FIRST_PERSON_FR.test(foldText(body.slice(0, DICTATION_PROBE)))
51  );
52}
53
54export function cleanHumanText(raw: string): string {
55  return raw
56    .replace(SYSTEM_REMINDER, "")
57    .replace(PASTED_CONTENT, (_block, body: string) => (isDictatedPaste(body) ? body.trim() : ""))
58    .replace(FORM_ROUND_HEADER, "")
59    .trim();
60}
61
62const CORRECTION_STRONG = [
63  /\bje (te l'|t'en |t'|vous )?(ai|avais) (deja |pourtant |bien )?(dit|demande|precise|explique)/,
64  /\bon (en )?avait (dit|convenu|decide)/,
65  /\bcombien de fois\b/,
66  /\bencore une fois\b/,
67  /\btoujours pas\b/,
68  /\bc'est faux\b/,
69  /\bn'importe quoi\b/,
70  /\bpas (du tout )?ce que (je|j'ai)\b/,
71  /\b(tu as|t'as|tu t'es|t'es) (encore |deja )?(oublie|trompe|invente|ignore|zappe)/,
72  /\btu n'as pas (lu|respecte|suivi|verifie|teste)/,
73  /\bpourquoi (tu as|as-tu|t'as|est-ce que tu|tu n'as|tu ne)\b/,
74  /\b(il ne|ne) fallait pas\b/,
75  /\bj'ai jamais (dit|demande)\b|\bje n'ai jamais (dit|demande)\b/,
76  /\barrete de\b/,
77  /\bi (already )?(told|asked) you\b/,
78  /\bnot what i (asked|wanted|said|meant)\b/,
79  /\bwhy did you\b/,
80];
81
82const CORRECTION_MEDIUM = [
83  /^\s*non\b/,
84  /^\s*no[,.!]/,
85  /\bpas comme (ca|cela)\b/,
86  /\bau lieu de\b/,
87  /\bne (fais|touche|modifie|supprime|lance|commit|push|mets|utilise|ajoute|cree)\w* (pas|jamais|rien)\b/,
88  /\b(remets|annule|revert|undo)\b/,
89  /\btu (n')?aurais (du|pu)\b/,
90  /\bstop\b/,
91  /\bwrong\b/,
92  /\bdon'?t (do|touch|change|commit|push|use|add)\b/,
93];
94
95const FRUSTRATION = [
96  /\b(putain|bordel|merde|chier|chiant|relou|wtf|me saoule|debecte|j'en ai marre)\b/,
97  /\bpas besoin d(e\b|')/,
98  /\bfranchement\b/,
99];
100
101const STANDING_INSTRUCTION = [
102  /\bje (te )?rappelle\b|\brappelle-toi\b/,
103  /\bcomme (d'habitude|indique|ecrit|dit dans|prevu)\b/,
104  /\b(agents|claude)\.md\b/,
105  /\bles? regles?\b|\.claude\/rules\b|\bconventions?\b/,
106  /\bmemoires?\b|\bmemory\b|\bretiens\b|\bnote (ca|bien)\b/,
107  /\b(a chaque fois|a l'avenir|dorenavant|desormais|la prochaine fois|from now on|next time|every time)\b/,
108];
109
110const MEDIUM_SCOPE = 400;
111
112const MARKER_ORDER: readonly Marker[] = [
113  "interrupt_followup",
114  "user_rejected",
115  "question_rejected",
116  "plan_rejected",
117  "correction_strong",
118  "correction_medium",
119  "frustration",
120  "standing_instruction",
121];
122
123const matchesAny = (patterns: readonly RegExp[], text: string): boolean => patterns.some((p) => p.test(text));
124
125export function markersFor(text: string, context: MarkerContext): readonly Marker[] {
126  const folded = foldText(text);
127  const strong = matchesAny(CORRECTION_STRONG, folded);
128
129  const flags: Record<Marker, boolean> = {
130    interrupt_followup: context.afterInterrupt,
131    user_rejected: context.rejectedTools.length > 0,
132    question_rejected: context.rejectedTools.includes("AskUserQuestion"),
133    plan_rejected: context.rejectedTools.includes("ExitPlanMode"),
134    correction_strong: strong,
135    correction_medium:
136      !strong && !context.isFirstPrompt && matchesAny(CORRECTION_MEDIUM, folded.slice(0, MEDIUM_SCOPE)),
137    frustration: matchesAny(FRUSTRATION, folded),
138    standing_instruction: matchesAny(STANDING_INSTRUCTION, folded),
139  };
140
141  return MARKER_ORDER.filter((marker) => flags[marker]);
142}
143
144const SLASH_COMMAND_NAME = /<command-name>([\s\S]*?)<\/command-name>/;
145
146const SLASH_COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
147
148/** A slash command's name and arguments when `text` is one; null for plain text. */
149export function slashCommand(text: string): { readonly name: string; readonly args: string } | null {
150  const name = SLASH_COMMAND_NAME.exec(text)?.[1]?.trim();
151
152  if (name === undefined) {
153    return null;
154  }
155
156  return { name, args: SLASH_COMMAND_ARGS.exec(text)?.[1]?.trim() ?? "" };
157}
158
src/mod/cli-client.ts 151 lines
1import type { JsonValue } from "../domain/json.ts";
2import type { Host } from "./host.ts";
3
4import { isJsonObject, isNumber, numberField, parseJson, textField } from "../domain/json.ts";
5
6// @effect-diagnostics-next-line extendsNativeError:off -- src/mod imports no Effect: this error never enters an Effect failure channel.
7export class CliError extends Error {
8  constructor(
9    readonly argv: readonly string[],
10    readonly exitCode: number,
11    readonly stderr: string,
12  ) {
13    super(`hindsight ${argv.join(" ")} failed (exit ${exitCode}): ${stderr.trim().split("\n").at(-1) ?? ""}`);
14    this.name = "CliError";
15  }
16}
17
18const DEFAULT_TIMEOUT_MS = 30_000;
19
20/** The CLI answers with one JSON line; this is the mod's side of that contract. */
21export async function runCli(
22  host: Host,
23  args: readonly string[],
24  options: { readonly stdin?: string; readonly timeoutMs?: number } = {},
25): Promise<JsonValue> {
26  const argv = ["bun", `${host.pluginRoot}/src/cli/main.ts`, ...args, "--json"];
27  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28  const init = options.stdin === undefined ? { timeoutMs } : { timeoutMs, stdin: options.stdin };
29
30  const result = await host.run(argv, init);
31
32  if (result.exitCode !== 0) {
33    throw new CliError(argv, result.exitCode, result.stderr);
34  }
35
36  const line = result.stdout.trim().split("\n").at(-1) ?? "";
37
38  try {
39    return parseJson(line);
40  } catch {
41    throw new CliError(argv, result.exitCode, `not a JSON line: ${line.slice(0, 200)}`);
42  }
43}
44
45export async function cliConfig(host: Host): Promise<JsonValue> {
46  return runCli(host, ["config"]);
47}
48
49export type StatusSummary = { readonly pendingProposals: number; readonly failures: number };
50
51export async function cliStatus(host: Host): Promise<StatusSummary> {
52  const answer = await runCli(host, ["status"]);
53  const failures = isJsonObject(answer) ? answer["failures"] : undefined;
54
55  return {
56    pendingProposals: numberField(answer, "pendingProposals") ?? 0,
57    failures: Array.isArray(failures) ? failures.length : 0,
58  };
59}
60
61export async function cliDebriefStart(host: Host, sessionId: string, repoRoot: string | null): Promise<string> {
62  const answer = await runCli(host, [
63    "debrief",
64    "start",
65    "--session",
66    sessionId,
67    ...(repoRoot === null ? [] : ["--repo", repoRoot]),
68  ]);
69
70  const id = textField(answer, "id");
71
72  if (id === null) {
73    throw new TypeError("debrief start answered without an id");
74  }
75
76  return id;
77}
78
79export type RecordAnswer = {
80  readonly status: string;
81  readonly failure: string | null;
82  readonly notes: { readonly steers: number; readonly observations: number; readonly staleRules: number };
83  readonly proposals: number;
84  readonly rejected: Readonly<Record<string, number>>;
85};
86
87function rejectedCounts(value: JsonValue | undefined): RecordAnswer["rejected"] {
88  if (!isJsonObject(value)) {
89    throw new TypeError("debrief record answered without rejected counts");
90  }
91
92  const counts: Record<string, number> = {};
93
94  for (const [reason, count] of Object.entries(value)) {
95    if (!isNumber(count)) {
96      throw new TypeError(`debrief record: rejected.${reason} is not a number`);
97    }
98
99    counts[reason] = count;
100  }
101
102  return counts;
103}
104
105export async function cliDebriefRecord(
106  host: Host,
107  id: string,
108  body: { sessionId: string; cacheShare: number; durationMs: number; text: string; repoRoot: string | null },
109): Promise<RecordAnswer> {
110  const answer = await runCli(host, ["debrief", "record", "--id", id], {
111    stdin: JSON.stringify(body),
112    timeoutMs: 120_000,
113  });
114
115  const status = textField(answer, "status");
116
117  if (status === null) {
118    throw new Error("debrief record answered out of shape");
119  }
120
121  const notes = isJsonObject(answer) ? answer["notes"] : undefined;
122  const rejected = isJsonObject(answer) ? answer["rejected"] : undefined;
123  const count = (key: string): number => numberField(notes, key) ?? 0;
124
125  return {
126    status,
127    failure: textField(answer, "failure"),
128    notes: { steers: count("steers"), observations: count("observations"), staleRules: count("staleRules") },
129    proposals: numberField(answer, "proposals") ?? 0,
130    rejected: rejectedCounts(rejected),
131  };
132}
133
134export type GateLogEntry = {
135  readonly sessionId: string;
136  readonly turnId: string | null;
137  readonly decision: string;
138  readonly reason: string | null;
139  readonly score: number | null;
140  readonly signals: unknown;
141};
142
143/** Fire and forget: a journal line never holds the session. */
144export function cliGateLog(host: Host, entry: GateLogEntry): void {
145  const logFailure = (cause: unknown): void => {
146    host.log(`gate log failed: ${cause instanceof Error ? cause.message : String(cause)}`);
147  };
148
149  void runCli(host, ["gate", "log"], { stdin: JSON.stringify(entry) }).catch(logFailure);
150}
151
src/mod/gate.ts 219 lines
1import type { HindsightConfig } from "../domain/config.ts";
2import type { ToolUse } from "../domain/facts.ts";
3import type { GateState } from "../domain/gate.ts";
4import type { Host } from "./host.ts";
5import type { SessionState } from "./state.ts";
6import type { SessionMessage, ToolUseSummary } from "claude-code";
7
8import { addToolUses, isRefusalText, markInterrupted } from "../domain/facts.ts";
9import { classifierText, gateBrief, gatePasses, gateSignals, gateSkip } from "../domain/gate.ts";
10import { isString } from "../domain/json.ts";
11import { cliGateLog } from "./cli-client.ts";
12import { debriefToast, runDebrief } from "./debrief.ts";
13import { refreshStatus } from "./notice.ts";
14import { changeState, countDebriefToday, debriefsToday, factsSince, takeFork } from "./state.ts";
15
16export type TurnEnd = {
17  readonly turnId: string;
18  readonly isAborted: boolean;
19  readonly agentId: string | null;
20  readonly isInteractive: boolean;
21};
22
23function inputString(use: ToolUseSummary, key: string): string | null {
24  const value = use.input[key];
25
26  return isString(value) ? value : null;
27}
28
29export function toToolUse(use: ToolUseSummary): ToolUse {
30  const isError = use.isError === true;
31
32  return {
33    tool: use.tool,
34    path: inputString(use, "file_path") ?? inputString(use, "notebook_path"),
35    command: inputString(use, "command"),
36    isError,
37    isRefused: isError && isRefusalText(use.text ?? ""),
38  };
39}
40
41/** The tool uses of the last turn: every assistant row after the last human prompt. */
42export function lastTurnTools(messages: readonly SessionMessage[]): readonly ToolUse[] {
43  let start = 0;
44
45  for (let index = messages.length - 1; index >= 0; index -= 1) {
46    const message = messages[index];
47
48    if (message?.role === "user" && message.text.trim() !== "" && (message.toolResults ?? []).length === 0) {
49      start = index;
50      break;
51    }
52  }
53
54  return messages
55    .slice(start)
56    .filter((message) => message.role === "assistant")
57    .flatMap((message) => message.toolUses.map(toToolUse));
58}
59
60export type GateOutcome =
61  | { readonly kind: "skip"; readonly reason: string }
62  | { readonly kind: "pass"; readonly brief: string; readonly score: number };
63
64/** The code's part of the Gate at the end of a turn: adds the turn's tool uses to the facts, then skips or passes to the classifier. */
65export async function afterTurn(
66  host: Host,
67  config: HindsightConfig,
68  turn: TurnEnd,
69  messages: readonly SessionMessage[],
70): Promise<GateOutcome> {
71  const now = await host.now();
72  const sessionId = await host.sessionId();
73  const tools = lastTurnTools(messages);
74  const repoRoot = await host.repoRoot();
75  const today = await debriefsToday(host, now);
76
77  const state = await changeState(host, sessionId, now, (current) => {
78    const withTools = addToolUses(current.facts, tools);
79
80    return { ...current, facts: turn.isAborted ? markInterrupted(withTools) : withTools };
81  });
82
83  const { facts } = state;
84
85  const gateState: GateState = {
86    isInteractive: turn.isInteractive,
87    agentId: turn.agentId,
88    isAborted: turn.isAborted,
89    forkRunning: state.forkRunning,
90    repoRoot,
91    lastDebriefAt: state.lastDebriefAt,
92    debriefsInSession: state.debriefsInSession,
93    debriefsToday: today,
94    now,
95  };
96
97  const skip = gateSkip(gateState, facts, config);
98  const signals = gateSignals(facts);
99
100  if (skip !== null) {
101    cliGateLog(host, {
102      sessionId,
103      turnId: turn.turnId,
104      decision: "skip",
105      reason: skip,
106      score: signals.score,
107      signals: signals.signals,
108    });
109
110    return { kind: "skip", reason: skip };
111  }
112
113  if (!gatePasses(signals, config)) {
114    cliGateLog(host, {
115      sessionId,
116      turnId: turn.turnId,
117      decision: "skip",
118      reason: "below-threshold",
119      score: signals.score,
120      signals: signals.signals,
121    });
122
123    return { kind: "skip", reason: "below-threshold" };
124  }
125
126  return { kind: "pass", brief: gateBrief(signals, facts), score: signals.score };
127}
128
129async function setFork(host: Host, sessionId: string, update: (state: SessionState) => SessionState): Promise<void> {
130  await changeState(host, sessionId, await host.now(), update);
131}
132
133/** The model's part: the classifier, then the Debrief. Runs off the turn's dispatch, from a timer. */
134export async function classifyThenDebrief(
135  host: Host,
136  config: HindsightConfig,
137  turnId: string | null,
138  brief: string,
139  score: number,
140  force: boolean,
141): Promise<void> {
142  const sessionId = await host.sessionId();
143  const startedAt = await host.now();
144  const state = await takeFork(host, sessionId, startedAt);
145
146  if (state === null) {
147    cliGateLog(host, { sessionId, turnId, decision: "skip", reason: "fork-running", score, signals: [] });
148
149    return;
150  }
151
152  const release = async (): Promise<void> =>
153    setFork(host, sessionId, (current) => ({ ...current, forkRunning: false, forkStartedAt: null }));
154
155  if (!force) {
156    let label: string | undefined;
157
158    try {
159      label = await host.classify(classifierText(brief, state.facts), ["debrief", "skip"], config.gate.classifierModel);
160    } catch (error) {
161      cliGateLog(host, { sessionId, turnId, decision: "skip", reason: "classifier-error", score, signals: [] });
162      host.log(`classifier failed: ${error instanceof Error ? error.message : String(error)}`);
163      await release();
164
165      return;
166    }
167
168    if (label !== "debrief") {
169      cliGateLog(host, {
170        sessionId,
171        turnId,
172        decision: "skip",
173        reason: label === "skip" ? "classifier-skip" : "classifier-unsure",
174        score,
175        signals: [],
176      });
177      await release();
178
179      return;
180    }
181  }
182
183  cliGateLog(host, { sessionId, turnId, decision: force ? "forced" : "debrief", reason: null, score, signals: [] });
184
185  try {
186    const run = await runDebrief(host, sessionId, await host.repoRoot(), brief);
187    const at = await host.now();
188    await setFork(host, sessionId, (current) =>
189      run.kind === "aborted"
190        ? { ...current, forkRunning: false, forkStartedAt: null }
191        : {
192            ...current,
193            forkRunning: false,
194            forkStartedAt: null,
195            facts: factsSince(current.facts, state.facts),
196            lastDebriefAt: at,
197            debriefsInSession: current.debriefsInSession + 1,
198          },
199    );
200
201    if (run.kind !== "aborted") {
202      await countDebriefToday(host, at);
203    }
204
205    const toast = debriefToast(run);
206
207    if (toast !== null) {
208      host.toast(toast);
209    }
210
211    if (run.kind !== "aborted") {
212      await refreshStatus(host);
213    }
214  } catch (error) {
215    await release();
216    host.toast(`Debrief impossible : ${error instanceof Error ? error.message : String(error)}`);
217  }
218}
219
src/mod/notice.ts 31 lines
1import type { StatusSummary } from "./cli-client.ts";
2import type { Host } from "./host.ts";
3
4import { cliStatus } from "./cli-client.ts";
5
6/** The status line the human sees; undefined clears it. The agent never reads it. */
7export function noticeLine(status: StatusSummary): string | undefined {
8  const parts: string[] = [];
9
10  if (status.pendingProposals > 0) {
11    parts.push(
12      `${status.pendingProposals} Proposal${status.pendingProposals > 1 ? "s" : ""} en attente · /hindsight review`,
13    );
14  }
15
16  if (status.failures > 0) {
17    parts.push(`${status.failures} Debrief${status.failures > 1 ? "s" : ""} en erreur · /hindsight status`);
18  }
19
20  return parts.length === 0 ? undefined : parts.join(" · ");
21}
22
23/** Rewrites the status line from `hindsight status`; a failure is logged, never thrown: the line is cosmetic. */
24export async function refreshStatus(host: Host): Promise<void> {
25  try {
26    host.status(noticeLine(await cliStatus(host)));
27  } catch (error) {
28    host.log(`status failed: ${error instanceof Error ? error.message : String(error)}`);
29  }
30}
31
src/mod/state.ts 167 lines
1import type { TurnFacts } from "../domain/facts.ts";
2import type { JsonValue } from "../domain/json.ts";
3import type { Host } from "./host.ts";
4
5import { emptyFacts } from "../domain/facts.ts";
6import { isJsonObject, isNumber } from "../domain/json.ts";
7
8/** What one session accumulates between Debriefs, kept in `$.store` so that a reload or /clear loses nothing. */
9export type SessionState = {
10  readonly facts: TurnFacts;
11  readonly lastDebriefAt: number | null;
12  readonly debriefsInSession: number;
13  readonly forkRunning: boolean;
14  readonly forkStartedAt: number | null;
15  readonly updatedAt: number;
16};
17
18const SESSION_PREFIX = "session:";
19
20const DAY_PREFIX = "day:";
21
22const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
23
24const DAY_TTL_MS = 2 * 24 * 60 * 60 * 1000;
25
26export const freshState = (now: number): SessionState => ({
27  facts: emptyFacts,
28  lastDebriefAt: null,
29  debriefsInSession: 0,
30  forkRunning: false,
31  forkStartedAt: null,
32  updatedAt: now,
33});
34
35const isState = (value: JsonValue | undefined): value is SessionState =>
36  isJsonObject(value) &&
37  isJsonObject(value["facts"]) &&
38  (value["lastDebriefAt"] === null || isNumber(value["lastDebriefAt"])) &&
39  isNumber(value["debriefsInSession"]) &&
40  typeof value["forkRunning"] === "boolean" &&
41  (value["forkStartedAt"] === null || isNumber(value["forkStartedAt"])) &&
42  isNumber(value["updatedAt"]);
43
44export const FORK_STALE_MS = 15 * 60 * 1000;
45
46/** A fork lock older than fifteen minutes belongs to a fork the engine lost: it no longer holds. */
47export async function readState(host: Host, sessionId: string, now: number): Promise<SessionState> {
48  const stored = await host.storeGet(`${SESSION_PREFIX}${sessionId}`);
49
50  if (!isState(stored)) {
51    return freshState(now);
52  }
53
54  const startedAt = stored.forkStartedAt;
55
56  if (stored.forkRunning && (startedAt === null || now - startedAt > FORK_STALE_MS)) {
57    return { ...stored, forkRunning: false, forkStartedAt: null };
58  }
59
60  return stored;
61}
62
63/** What the Debrief did not see: the facts added while it ran. */
64export function factsSince(current: TurnFacts, snapshot: TurnFacts): TurnFacts {
65  return {
66    humanTexts: current.humanTexts.slice(snapshot.humanTexts.length),
67    edits: current.edits.slice(snapshot.edits.length),
68    toolCalls: Math.max(0, current.toolCalls - snapshot.toolCalls),
69    toolErrors: Math.max(0, current.toolErrors - snapshot.toolErrors),
70    refusedTools: current.refusedTools.slice(snapshot.refusedTools.length),
71    gitCommits: Math.max(0, current.gitCommits - snapshot.gitCommits),
72    afterInterrupt: current.afterInterrupt,
73    prompts: Math.max(0, current.prompts - snapshot.prompts),
74  };
75}
76
77export async function writeState(host: Host, sessionId: string, state: SessionState, now: number): Promise<void> {
78  await host.storeSet(`${SESSION_PREFIX}${sessionId}`, { ...state, updatedAt: now });
79}
80
81const queues = new Map<string, Promise<unknown>>();
82
83/** `$.store` has no versions: the mod's read-modify-writes of one session run one after the other. */
84async function exclusive<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
85  const previous = queues.get(sessionId) ?? Promise.resolve();
86
87  const run = (async (): Promise<T> => {
88    await previous;
89
90    return fn();
91  })();
92
93  queues.set(sessionId, Promise.allSettled([run]));
94
95  return run;
96}
97
98export async function changeState(
99  host: Host,
100  sessionId: string,
101  now: number,
102  change: (state: SessionState) => SessionState,
103): Promise<SessionState> {
104  return exclusive(sessionId, async () => {
105    const next = change(await readState(host, sessionId, now));
106    await writeState(host, sessionId, next, now);
107
108    return next;
109  });
110}
111
112/** Takes the session's fork lock and answers the state as locked, or null while another fork holds it. */
113export async function takeFork(host: Host, sessionId: string, now: number): Promise<SessionState | null> {
114  return exclusive(sessionId, async () => {
115    const state = await readState(host, sessionId, now);
116
117    if (state.forkRunning) {
118      return null;
119    }
120
121    const locked = { ...state, forkRunning: true, forkStartedAt: now };
122    await writeState(host, sessionId, locked, now);
123
124    return locked;
125  });
126}
127
128export function utcDayOf(now: number): string {
129  return new Date(now).toISOString().slice(0, 10);
130}
131
132export async function debriefsToday(host: Host, now: number): Promise<number> {
133  const count = await host.storeGet(`${DAY_PREFIX}${utcDayOf(now)}`);
134
135  return isNumber(count) ? count : 0;
136}
137
138export async function countDebriefToday(host: Host, now: number): Promise<void> {
139  await host.storeSet(`${DAY_PREFIX}${utcDayOf(now)}`, (await debriefsToday(host, now)) + 1);
140}
141
142/** Drops session states older than seven days and day counters older than two. */
143export async function pruneStore(host: Host, now: number): Promise<number> {
144  const isExpired = async (key: string): Promise<boolean> => {
145    if (key.startsWith(SESSION_PREFIX)) {
146      const value = await host.storeGet(key);
147
148      return !isState(value) || now - value.updatedAt > STATE_TTL_MS;
149    }
150
151    if (key.startsWith(DAY_PREFIX)) {
152      const day = new Date(`${key.slice(DAY_PREFIX.length)}T00:00:00.000Z`).getTime();
153
154      return !Number.isFinite(day) || now - day > DAY_TTL_MS;
155    }
156
157    return false;
158  };
159
160  const keys = await host.storeKeys();
161  const expired = await Promise.all(keys.map(isExpired));
162  const dropped = keys.filter((_, index) => expired[index] === true);
163  await Promise.all(dropped.map(async (key) => host.storeDelete(key)));
164
165  return dropped.length;
166}
167