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…

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.

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.
<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">
/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.
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.
| Command | What it does |
|---|---|
/hindsight review | Opens the Review pane |
/hindsight status | Pending Proposals, failed Debriefs |
/hindsight debrief | Forces a Debrief of the current session |
/hindsight learn | Starts Learn in the background |
/hindsight handoff | Fills the prompt with the handed-off Proposals' instructions |
hindsight status, doctor | State, cache shares, Gate decisions, Learn cost; paths and permissions |
hindsight gate replay --days 30 | Replays the Gate's code over past transcripts, without a model |
hindsight learn, prune | Run Learn or Prune now |
$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).
$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.
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.
src/mod/register.ts 469 lines1import 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};
469src/domain/config.ts 93 lines1import 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}
93src/domain/json.ts 47 lines1export 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}
47src/mod/host.ts 30 lines1import 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};
30src/mod/review-pane.tsx 315 lines1import 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}
315src/domain/diff.ts 34 lines1export 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}
34src/domain/facts.ts 106 lines1import 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}
106src/domain/human.ts 158 lines1import { 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}
158src/mod/cli-client.ts 151 lines1import 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}
151src/mod/gate.ts 219 lines1import 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}
219src/mod/notice.ts 31 lines1import 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}
31src/mod/state.ts 167 lines1import 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