Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cf1605b
feat(analysis): index direct task and steering instructions as citabl…
andrewesweet Sep 24, 2026
81353d0
no-mistakes(review): keep directive-covered mistakes as gaps; drop su…
andrewesweet Sep 24, 2026
9449084
no-mistakes(review): keep directive section out of synthesis prompt
andrewesweet Sep 24, 2026
7f2c1fa
no-mistakes(review): drop fold directive rows and their render section
andrewesweet Sep 24, 2026
c516fd2
no-mistakes(review): index every non-empty envelope; guard directive …
andrewesweet Sep 25, 2026
6e1593c
no-mistakes(review): close envelope fences per CommonMark marker matc…
andrewesweet Sep 25, 2026
7df7bdc
no-mistakes(review): reject backtick-in-info fence openers in envelop…
andrewesweet Sep 25, 2026
7950b48
no-mistakes(document): document direct task/steering instruction sour…
andrewesweet Sep 25, 2026
2dfb33f
no-mistakes(document): reflow README direct-instruction paragraph wrap
andrewesweet Sep 25, 2026
c55bb8c
no-mistakes(review): carve envelopes pre-clamp, bound fence indent, m…
andrewesweet Sep 25, 2026
5b6d0ca
no-mistakes(review): require whole-turn trace containment; relative f…
andrewesweet Sep 25, 2026
95536ed
no-mistakes(document): document elided directive index entries; fix d…
andrewesweet Sep 25, 2026
ef542bd
no-mistakes(ci): ci-5 fixed. Invariant: a directive index entry may s…
andrewesweet Sep 25, 2026
78e44e6
fix(directives): pin the relative fence-indent boundary with a test
andrewesweet Oct 9, 2026
f120fe1
no-mistakes(review): Fix fence tab stops and strengthen clamping regr…
andrewesweet Oct 9, 2026
cc9c3b5
no-mistakes(document): Clarify directive truncation and fence tab stops
andrewesweet Oct 9, 2026
0c03509
fix(fold): drop duplicated sessionIdentity from the main merge
andrewesweet Oct 15, 2026
bdff64c
fix(adapters): preserve typed-block indentation for directive extraction
andrewesweet Oct 17, 2026
c179402
fix(directives): recognize list-item fence markers when carving the e…
andrewesweet Oct 20, 2026
106a33a
no-mistakes(review): Restrict closing fences to whitespace-only prefixes
andrewesweet Oct 20, 2026
517f110
no-mistakes(review): Invalidate cached evidence after directive accep…
andrewesweet Oct 20, 2026
f880c41
no-mistakes(document): Clarify task indexing and fence closer documen…
andrewesweet Oct 21, 2026
6a504c7
fix(directives): recognize list-item blockquotes when carving the env…
andrewesweet Oct 21, 2026
dabde3e
no-mistakes(ci): Fixed signal-test startup race: publish child readin…
andrewesweet Oct 21, 2026
fd4cca7
chore: retrigger review
andrewesweet Oct 22, 2026
c604517
no-mistakes(review): Preserve partial directive provenance for trunca…
andrewesweet Oct 25, 2026
c8714af
no-mistakes(review): Align evidence cache regression with analysis in…
andrewesweet Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Each owning file's comments and tests hold the detail; read them before touching
- **Quotes must be found in the trace they cite** (`sanitizeEvidence` in `src/analyze.js`), so fake agents in
tests must quote real session text. Bump `ANALYSIS_INDEX_VERSION` (`src/state.js`) for any change to what
analysis accepts.
- **Direct task/steering instructions are addressable** (`src/directives.js`): the first substantive user turn is
`TASK-1`, later ones `STEER-<turn>`, each with `direct-task`/`direct-steering` authority and a lifetime from
its turn onward. Only the carved envelope indexes (fenced blocks and `>` quotes excluded); assistant/tool text
never indexes. Spans render by turn reference, never duplicated text; only ids plus metadata persist. The fold
has no directive rows: cites stay session authority, and recurrence reaches synthesis as ordinary gaps.
- **Sampling is deterministic and sticky** (`src/sample.js`): a per-transcript hash of `transcriptIdentity` and
`config.seed`, never `Math.random()` or an index/position-derived draw.
- **Corpus mix is interactive vs non-interactive, never an unknown bucket** (`src/interaction.js`).
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,9 @@ weights still evolve as transcripts age, so the selected set can change over tim
equally reproducible sample.

Each distilled trace goes to a cheap model with the memory file, the project skill index,
and a rubric. It returns strict JSON: which instructions helped, which were violated, and
what mistakes no current instruction covers. Every negative carries a class - `harm` (following the instruction
an index of this session's direct instructions, and a rubric. It returns strict JSON: which
instructions helped, which were violated, and what mistakes no memory instruction covers.
Every negative carries a class - `harm` (following the instruction
caused damage), `non-compliance` (the agent ignored it), or `irrelevant` - because those
argue for opposite fates: harm argues against an instruction, non-compliance argues for
reinforcing it. Every gap carries a domain - `orchestration` when the mistake was caused
Expand All @@ -380,6 +381,18 @@ orchestrating tool) - and the
analysis is shown the ledger's open gaps so it can cite an existing gap id instead of
coining a paraphrase of it.

Much of what governs a session comes from the user directly rather than from a memory file,
so those instructions are addressable too. The first user turn with authoritative text (`TASK-1`) and each
later such turn (`STEER-<turn>`) become citable instruction sources with their own
authority, alongside the memory-file ids. Only the authoritative part of a user turn counts:
fenced blocks and quoted lines are pasted evidence, never instructions, and assistant or tool
text never becomes one. The index references a trace turn without repeating its text.
If a turn is elided by session capping or clamped by message truncation, the index marks it
and points to the raw transcript. Only the ids and their metadata persist in the evidence record. Direct
instructions are session authority, not durable memory, so a mistake covered only by one is
still reported as a gap - the user had to say it in this session, and the next session starts
without it - and their citations do not score a memory-file instruction.

**Every claim must carry a verbatim quote, and the quote must be in the trace.** Quoteless
items are discarded - the single most important defence against a model confabulating
influence - and so are quotes that do not actually appear in the distilled trace they claim
Expand Down
2 changes: 1 addition & 1 deletion src/acpx.js
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ function recoverPiUsage({ promptFile, cwd, startedAt }) {
for (const candidate of candidates) {
const descriptor = piStore.classify(candidate);
if (!descriptor || !wanted.has(descriptor.cwd)) continue;
const entries = readJsonl(candidate.path);
const { entries } = readJsonl(candidate.path);
const firstUser = entries.find((e) => e.type === "message" && e.message?.role === "user");
if (!firstUser || piMessageText(firstUser.message.content).trim() !== prompt) continue;

Expand Down
22 changes: 20 additions & 2 deletions src/analyze.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import { randomUUID } from "node:crypto";

import { extractJson, runModelCall, usageRecord } from "./acpx.js";
import { directiveMetadata, extractDirectives, renderDirectiveIndex } from "./directives.js";
import { distill } from "./distill.js";
import { classifyInteraction } from "./interaction.js";
import { getAdapter, readTranscript } from "./discovery/index.js";
Expand Down Expand Up @@ -149,7 +150,7 @@ function foldSpace(text) {
* `quotesNotInTrace` counts what the trace check rejected, so a run whose analysis model
* paraphrases everything reads as that rather than as a clean repo.
*/
export function sanitizeEvidence(parsed, memoryFile = null, trace = null) {
export function sanitizeEvidence(parsed, memoryFile = null, trace = null, directiveIds = null) {
const clean = {
positive: [],
negative: [],
Expand All @@ -160,6 +161,11 @@ export function sanitizeEvidence(parsed, memoryFile = null, trace = null) {
if (!parsed || typeof parsed !== "object") return clean;

const validInstructions = memoryFile ? new Set(instructionUnits(memoryFile).map((unit) => unit.id)) : null;
// Direct task/steering spans are session-scoped instruction sources alongside the
// memory index; a citation of one is addressable evidence, not a stale reference.
if (validInstructions && Array.isArray(directiveIds)) {
for (const id of directiveIds) validInstructions.add(id);
}
const foldedTrace = typeof trace === "string" && !clean.usedRawTranscript ? foldSpace(trace) : null;
const hasQuote = (item) => {
if (typeof item?.quote !== "string" || item.quote.trim().length < 8) return false;
Expand Down Expand Up @@ -260,6 +266,7 @@ async function analyzeOne({
const distilled = distill(raw.events, {
...transcript,
model: raw.model,
prefixDiscarded: raw.prefixDiscarded,
rawPath: rawFile ?? raw.rawPath,
});

Expand Down Expand Up @@ -299,11 +306,13 @@ async function analyzeOne({
fs.writeFileSync(rawFile, `${lines.join("\n")}\n`, { mode: 0o600, flag: "wx" });
}

const directives = extractDirectives(distilled.turns);
const prompt = renderPrompt("analysis", {
MEMORY_PATH: memoryFile.path,
INSTRUCTION_INDEX: renderInstructionIndex(memoryFile),
ALSO_LOADED: alsoLoaded,
SKILLS: skillIndex,
DIRECTIVES: renderDirectiveIndex(directives),
OPEN_GAPS: openGapIndex,
TRACE: distilled.trace,
});
Expand Down Expand Up @@ -336,9 +345,18 @@ async function analyzeOne({
throw new Error("analysis returned no parseable JSON");
}

const evidence = sanitizeEvidence(
parsed,
memoryFile,
distilled.trace,
directives.map((span) => span.id),
);
return {
status: "ok",
evidence: sanitizeEvidence(parsed, memoryFile, distilled.trace),
// The record carries this session's directive span metadata - ids, turn, authority
// and lifetime, never envelope text - so a cited TASK-1 / STEER-N id stays
// resolvable in persisted evidence (see `src/directives.js`).
evidence: { ...evidence, directives: directiveMetadata(directives) },
usage: usageRecord(ranWith, result),
distilled,
};
Expand Down
172 changes: 172 additions & 0 deletions src/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Direct task and steering instructions as addressable instruction sources.
*
* Backpass indexes project memory files as instruction sources, but in real sessions
* much of what governs agent behaviour comes straight from the user: the initial task
* message and later steering turns. Without ids those read only as undifferentiated
* transcript evidence, so analysis cannot say "the agent followed the user's direct
* instruction" apart from "the agent followed the memory file". This module makes each
* substantive user turn citable:
*
* - the first user turn with a non-empty envelope is the task span (`TASK-1`)
* - each later user turn with a non-empty envelope is a steering span (`STEER-<turn>`)
* - every span carries source kind, turn, an authority tier distinct from project
* memory (`direct-task` / `direct-steering`), and a lifetime running from its turn
* onward
*
* Only the authoritative envelope of a user message becomes a span. Quoted transcripts
* (`>` blockquotes), pasted tool output and code (fenced blocks), retry reasons and
* planned actions embedded as quotes or pastes are structurally excluded: indexing
* them as authority would invert instructions that say to treat that text as
* untrusted evidence. Assistant messages and tool results never become spans.
*
* Spans are extracted from the distilled message turns, so turn numbers match the
* trace the analysis model reads. Each turn carries the envelope `distill` carved from
* its full pre-clamp text, so a closing fence lost to message clamping cannot demote an
* instruction to a paste. The prompt index renders spans by reference (id plus a
* `see turn N` pointer, or a note that the turn was elided or clamped out of the trace), never by
* duplicating turn text: task and steering text is private by default, and only ids plus
* metadata (`directives` on the evidence record) ever persist.
*
* A span is session authority, never durable project memory: a mistake only a direct
* instruction covered is still a gap, because the next session starts without it.
*/

export const TASK_ID = "TASK-1";
export const TASK_AUTHORITY = "direct-task";
export const STEERING_AUTHORITY = "direct-steering";

// A fence opener may sit behind a list marker: an optional bullet (`-`, `*`, `+`) or
// ordered marker (digits with `.` or `)`) plus spaces before the fence. Group 1 keeps the
// whole prefix so the closer indent is measured from the fence's own column - the content
// column after that marker - not from the start of the line.
const FENCE = /^([ \t]*([-*+][ \t]+|\d{1,9}[.)][ \t]+)?)(`{3,}|~{3,})([^\n]*)$/;
// A blockquote may also sit behind a single list marker (`- > quoted`); a marker must
// be followed by whitespace, so an unspaced dash (`-> quoted`) stays instruction text.
const QUOTE = /^\s*(?:[-*+][ \t]+|\d{1,9}[.)][ \t]+)?>/;

// Tabs advance to the next four-column stop. Fence indentation is relative: an opener may
// sit at any indentation, optionally behind a list marker (a list-item code block is still
// a fence), while its closer may be indented at most three columns past the fence's own
// column.
function indentColumns(prefix) {
let columns = 0;
for (const char of prefix) columns += char === "\t" ? 4 - (columns % 4) : 1;
return columns;
}

/** A span id is stable by construction: kind plus the distilled turn it points at. */
export function isDirectiveId(id) {
return id === TASK_ID || /^STEER-\d+$/.test(String(id || ""));
}

function steeringId(turn) {
return `STEER-${turn}`;
}

/**
* The authoritative envelope of a user message. Fenced code blocks (pasted tool output,
* code, retry logs) and blockquote lines (quoted transcripts, pasted replies, planned
* actions quoted back) are evidence-only, never authority, so they are stripped.
*/
export function carveEnvelope(text) {
const kept = [];
let opener = null;

for (const line of String(text ?? "").split("\n")) {
const fence = FENCE.exec(line);
if (opener) {
// CommonMark: a closer must have a whitespace-only prefix, use the opener's
// character at least as many times, have only whitespace after it, and sit at most
// three columns past the opener. A shorter, different or deeper inner fence is
// part of the paste.
if (
fence &&
!fence[2] &&
fence[3][0] === opener.char &&
fence[3].length >= opener.length &&
fence[4].trim() === "" &&
indentColumns(fence[1]) <= opener.indent + 3
) {
opener = null;
}
// An unclosed fence still withholds the rest: a pasted log without a closing
// marker is still a paste, not an instruction.
continue;
}
// CommonMark: a backtick fence's info string may not contain a backtick, so a line
// of inline code is instruction text, not an opener.
if (fence && !(fence[3][0] === "`" && fence[4].includes("`"))) {
opener = { char: fence[3][0], length: fence[3].length, indent: indentColumns(fence[1]) };
continue;
}
if (QUOTE.test(line)) continue;
kept.push(line);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
}

return kept.join("\n").trim();
}

/**
* Index the user turns of one session that carry an authoritative envelope. `turns` are
* the distilled message turns (`{ turn, role, envelope, elided, clamped }`) in trace order,
* so ids stay stable across runs of the same transcript and each span knows whether its
* turn text survived into the trace.
*/
export function extractDirectives(turns) {
const spans = [];
let seenFirstUser = false;
for (const entry of Array.isArray(turns) ? turns : []) {
if (!entry || entry.role !== "user") continue;
if (!entry.envelope) continue;
const span =
!seenFirstUser && !entry.partial
? { id: TASK_ID, kind: "task", turn: entry.turn, authority: TASK_AUTHORITY }
: { id: steeringId(entry.turn), kind: "steering", turn: entry.turn, authority: STEERING_AUTHORITY };
seenFirstUser = true;
spans.push({
...span,
partial: entry.partial === true,
elided: entry.elided === true,
clamped: entry.clamped === true,
lifetime: { fromTurn: entry.turn, toTurn: null },
});
}
return spans;
}

/** Metadata that may persist on an evidence record: ids and spans, never turn text. */
export function directiveMetadata(spans) {
return (Array.isArray(spans) ? spans : []).map((span) => ({
id: span.id,
kind: span.kind,
turn: span.turn,
authority: span.authority,
partial: span.partial === true,
stable: span.partial !== true,
lifetime: { ...span.lifetime },
}));
}

/**
* The analysis-prompt index. By reference only: each entry names the turn whose text is
* already in the trace, or says the turn's text was elided or clamped out of it, so no
* user-turn text is duplicated into the prompt.
*/
export function renderDirectiveIndex(spans) {
const list = Array.isArray(spans) ? spans : [];
if (!list.length) return "(none - no substantive user instruction found in the trace)";
return list
.map(
(span) =>
`[${span.id}] ${span.kind} · turn ${span.turn} · authority ${span.authority} · ` +
(span.partial ? "partial transcript · unstable id · " : "") +
`lifetime turns ${span.lifetime?.fromTurn ?? span.turn}+ - ` +
(span.elided
? `turn ${span.turn} - elided from the trace, open the raw transcript for its text`
: span.clamped
? `turn ${span.turn} - clamped from the trace, open the raw transcript for its text`
: `see turn ${span.turn} in the trace`),
)
.join("\n");
}
4 changes: 2 additions & 2 deletions src/discovery/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function classify(candidate) {
}

export function read(ref) {
const entries = readJsonl(ref.path);
const { entries, prefixDiscarded } = readJsonl(ref.path);
const events = [];
let model = null;

Expand All @@ -98,5 +98,5 @@ export function read(ref) {
}
}

return { events: attachToolResults(events), model };
return { events: attachToolResults(events), model, prefixDiscarded };
}
4 changes: 2 additions & 2 deletions src/discovery/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function classify(candidate) {
}

export function read(ref) {
const entries = readJsonl(ref.path);
const { entries, prefixDiscarded } = readJsonl(ref.path);
const events = [];
let model = null;

Expand Down Expand Up @@ -143,7 +143,7 @@ export function read(ref) {
}
}

return { events: attachToolResults(events), model };
return { events: attachToolResults(events), model, prefixDiscarded };
}

function parseMaybeJson(value) {
Expand Down
4 changes: 2 additions & 2 deletions src/discovery/adapters/grok.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function read(ref) {
const chatPath = ref.extra?.chatPath || path.join(ref.path, "chat_history.jsonl");
if (!fs.existsSync(chatPath)) return { events: [], model: null };

const entries = readJsonl(chatPath);
const { entries, prefixDiscarded } = readJsonl(chatPath);
const events = [];
let model = null;

Expand Down Expand Up @@ -91,7 +91,7 @@ export function read(ref) {
}
}

return { events: attachToolResults(events), model };
return { events: attachToolResults(events), model, prefixDiscarded };
}

/** Exported for the raw-transcript escape hatch: the analysis agent opens this file. */
Expand Down
4 changes: 2 additions & 2 deletions src/discovery/adapters/pi.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function classify(candidate, options = {}) {
}

export function read(ref) {
const entries = readJsonl(ref.path);
const { entries, prefixDiscarded } = readJsonl(ref.path);
const events = [];
let model = null;
const omp = entries[0]?.type === "title";
Expand All @@ -248,7 +248,7 @@ export function read(ref) {
contentToEvents(role, message.content, events);
}

return { events: attachToolResults(events), model };
return { events: attachToolResults(events), model, prefixDiscarded };
}

function textOf(content) {
Expand Down
Loading
Loading