/** * Post-compact engineering-state re-injection * (rid `2026-09-13-a2-post-compact-reinject`, session * * THE PROBLEM THIS SOLVES * * `94bacb4a` settled WHO decides when to compact: peaks-loop owns the window, * the harness performs the compaction. This module owns what happens AFTER * it. Compaction summarizes a conversation, and a summarizer has no notion of * a slice, a request, a gate or an acceptance criterion — so the engineering * state is exactly the part it is most likely to drop. The user's word for * the symptom is 飘逸 (drift): the session keeps talking but has lost its * place. * * This repository already has the case on file: * `.peaks/memory/per-turn-obligations-belong-in-per-turn-output.md` — * text that lives in the message history (a SKILL.md body, a dispatch prompt) * does not survive a compaction, so a rule that only exists there stops * applying. The fix has to be re-injection, not a better summary. * * WHY A BUDGET IS THE DESIGN AND NOT A DETAIL * * The harness's own supported mechanism is a `SessionStart` hook whose stdout * it adds to the context. So the card competes for the very space the * compaction just freed. Re-injecting a lot is WORSE than re-injecting * nothing: it re-fills the window, the ratio climbs back, and the next * compaction arrives sooner — the cost is paid on every cycle. Hence a hard, * declared ceiling (`POST_COMPACT_REINJECTION_BYTE_BUDGET`) and a * deterministic rule for what is dropped (see `renderReinjectionCard`). * * POINT, DO NOT INLINE * * Two different things can be put in the card: * - a FACT whose absence changes behaviour on the very next turn (the * runtime rules, the next slice, the identity of the session), and * - a BODY of material the model can go and read (a request artifact, a * job state file, a memory file). * Inlining the second is the worse trade at every size: it costs bytes on * every compaction cycle forever, it goes stale the moment it is written, and * the model cannot tell a stale copy from a live one. A path costs ~60 bytes, * is always current, and the model is going to read the file anyway before it * edits anything. So the card inlines only the first kind and spends one * whole block on pointers to the second. * * VENDOR NEUTRALITY * * Nothing here knows an IDE, a settings file or a hook event. The card is a * string; the hook entry that carries it lives with the other peaks hook * entries (`session-start-hook-constants.ts`) and the settings plumbing is * `applyHookInstall`. The only input that touches the outside world is * `projectRoot`. * * FAIL-SOFT IS A HARD REQUIREMENT, AND IT HAS A SHARP EDGE HERE * * The hook's stdout IS context. So an error message printed on stdout is not * a visible failure — it is a failure injected straight into the model's * context as if peaks-loop had meant it. `buildPostCompactReinjectionCard` * therefore never throws (every source is individually guarded and reports * into `unresolved`), and the CLI wrapper that renders it prints NOTHING and * exits 0 when the card cannot be built. */ /** * Hard ceiling on the card's size, in UTF-8 bytes. * * WHY 3072. Three numbers decided it, not taste: * * 1. It must be small against the freed space. A compaction releases tens * of thousands of tokens. 3072 bytes is under ~1200 tokens of English — * under 0.6% of a 200K window and under 0.12% of a 1M one. At that size * the card cannot plausibly re-fill what the compaction freed, whatever * the window, which is the property that makes the re-injection safe to * run on every cycle. * 2. It must be big enough for the whole content contract. Measured against * this repository the full card — identity, pointers, rules, next action * and progress — lands around 1.2-1.7 KiB. 3072 leaves roughly 2x * headroom for long session ids, a long job id and a long absolute * project root before the drop rule has to fire at all. * 3. It must be a BYTE count rather than a token or percentage count. Bytes * are computable on the hook path with `Buffer.byteLength` and no * tokenizer, no window resolution and no dependency; and unlike a * share-of-window budget, a fixed byte cap cannot silently GROW on a * large-window model — which would reintroduce the very "re-fill the * freed space" failure the cap exists to prevent. A percentage budget * was considered and rejected for that reason. */ export declare const POST_COMPACT_REINJECTION_BYTE_BUDGET = 3072; /** * Runtime rules that live ONLY in the message history, so a compaction * destroys them. * * Why a repo-owned constant and not something parsed out of the dispatch * record: the dispatch record is free-form prose written per slice, and a * parser over it would silently stop matching the day someone rephrases a * heading — a rule that vanishes without a failure. These six are peaks-loop's * OWN operating rules for an agent working in a peaks-loop session; keeping * them here makes adding one a one-line change with a test behind it. * * The membership test for an entry is: "if the model forgets this, does it * act WRONGLY on the next turn?" Session-general advice that already lives in * CLAUDE.md / the system prompt is deliberately absent — the system prompt is * not part of the summarized history, so re-injecting it would spend budget * to say something the model can already see. */ export declare const AGENT_RUNTIME_RULES: ReadonlyArray; /** * The block ranks, in survival order. The drop rule reads THIS list, so it is * the single place the priority is stated. * * The ordering principle is value-per-byte, highest first: * 0 IDENTITY — which session; ~1 line, and every other line depends on it * 1 WHERE TO READ — the pointers; cheap, always current, and the only way the * model reaches the bulky material it must not have inlined * 2 RULES — behaviour-changing, and unavailable anywhere else in context * 3 NEXT — one line; without it the next turn has no target * 4 CURRENT WORK — request / job / change identity and progress * RULES outranks CURRENT WORK deliberately: the identity of the slice is * recoverable by Reading the artifact the card points at, whereas a rule the * model has forgotten will be broken before it thinks to go looking. */ export declare const REINJECTION_BLOCK_RANKS: readonly [0, 1, 2, 3, 4]; export declare const REINJECTION_BLOCK_HEADINGS: Readonly>; /** Short names for the omitted-marker. See `ReinjectionBlock.label`. */ export declare const REINJECTION_BLOCK_LABELS: Readonly>; export type ReinjectionBlock = { readonly rank: number; /** Full heading line, rendered into the card. */ readonly heading: string; /** * Short name used by the omitted-marker. Distinct from `heading` on purpose: * the marker is itself part of the budget, and a marker built from the full * headings ("RULES THAT ARE NOT IN CONTEXT ANY MORE") costs several times * what the useful information in it is worth. */ readonly label: string; readonly lines: readonly string[]; }; export type ReinjectionCard = { readonly text: string; readonly bytes: number; readonly budgetBytes: number; readonly emittedRanks: readonly number[]; readonly droppedRanks: readonly number[]; }; /** * Assemble the card under a byte budget. * * THE RULE, stated so it can be checked rather than interpreted: * * 1. Blocks are rendered whole. Ranks are read in ascending order. * 2. A block is emitted iff its bytes still fit in the remaining budget. * Otherwise it is SKIPPED and the walk CONTINUES — greedy by rank, not * stop-at-first-miss. This is the only one of the two candidate rules * that keeps the survival order meaningful: with stop-at-first-miss, one * oversized low-priority block would evict every block after it, * including the hard rules. Skipping cannot reorder anything, because * ranks are still walked in order. * 3. Blocks are joined by a blank line, which is counted as part of the * block being added (a separator is never emitted for a block that did * not fit). * 4. If anything was dropped and the remaining budget admits it, one final * line names the dropped ranks. It is ALL-OR-NOTHING: it is emitted only * if it fits entirely, so no string is ever truncated mid-way and the * byte accounting stays exact. * * Deterministic in the strict sense: the output is a pure function of * (blocks, budgetBytes). No clock, no filesystem, no "best effort". * * `budgetBytes` is injectable so the rule above is testable at any size; it * defaults to the declared ceiling and no production caller overrides it. */ export declare function renderReinjectionCard(input: { readonly blocks: ReadonlyArray; readonly budgetBytes?: number | undefined; }): ReinjectionCard; /** Facts the card is built from. Every field is independently optional. */ export type PostCompactReinjectionFacts = { readonly projectRoot: string; readonly sessionId: string | null; readonly jobId: string | null; readonly isJob: boolean; readonly progress: { readonly done: number; readonly total: number; readonly currentSlice: string; } | null; readonly latestRequest: string | null; /** Which sources could not be read. Rendered nowhere in the card; reported. */ readonly unresolved: readonly string[]; }; /** * Read every available fact. Never throws: each source is guarded separately * and a failure is recorded in `unresolved` rather than propagated, because * the caller is a session-start hook. */ export declare function resolvePostCompactReinjectionFacts(input: { readonly projectRoot: string; readonly sessionId?: string | null | undefined; }): PostCompactReinjectionFacts; export type PostCompactReinjectionCard = { /** True when a card was produced. False only if rendering itself failed. */ readonly ok: boolean; readonly text: string; readonly bytes: number; readonly budgetBytes: number; readonly sessionId: string | null; readonly emittedRanks: readonly number[]; readonly droppedRanks: readonly number[]; readonly unresolved: readonly string[]; }; /** * Build the card for a project. This is the whole production surface: read * the facts, render them under the budget, return. NEVER throws — a thrown * error here would surface on session start, which is the one place the * absence of this feature must not be able to break anything. */ export declare function buildPostCompactReinjectionCard(input: { readonly projectRoot: string; readonly sessionId?: string | null | undefined; readonly budgetBytes?: number | undefined; }): PostCompactReinjectionCard;