liteagents 2.17.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ Run friction analysis, then consolidate session stashes + friction antigens into
9
9
  **Guardrails**
10
10
  - Favor straightforward, minimal implementations first and add complexity only when requested or clearly required.
11
11
  - Keep changes tightly scoped to the requested outcome.
12
- - **Precision over recall for hot memory.** A false antigen loaded into `@MEMORY.md` steers every future session. When unsure, record as a low-confidence episode do not promote.
12
+ - **Precision over recall for hot memory.** A false antigen loaded into `@MEMORY.md` steers every future session. When unsure, do not promote — leave it to recurrence (a ledger `observing` entry at 2 sessions, nothing at 1).
13
13
  - **Mid-tier model, not hardcoded.** Steps 2/3/4a delegate to a mid-tier model — capable of
14
14
  semantic judgment, cheaper/faster than your top reasoning tier (e.g. Claude's Sonnet vs
15
15
  Opus). Use whatever your tool designates as that balanced default; never hardcode a
@@ -39,7 +39,9 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
39
39
  (the same directory as `remember.md`, whether installed or run from the package). If it
40
40
  exists nowhere, skip to step 1 (stash-only) and tell the user friction.cjs is missing.
41
41
  - **Resolve the global sessions root** — probe this list top-to-bottom, use the first that
42
- exists and contains `.jsonl` files (recursively). **Never prompt the user.**
42
+ exists and contains `.jsonl` files directly, or one level down in per-project
43
+ subdirectories (friction.cjs scans exactly those two levels, not a deep recursive walk).
44
+ **Never prompt the user.**
43
45
  ```
44
46
  # ── Add your own global sessions root at the TOP so it is checked first ──
45
47
  ~/.claude/projects/ # Claude Code
@@ -76,15 +78,17 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
76
78
  already exists, leave it untouched — never overwrite, even if the bundled template
77
79
  changes in a later version; it becomes user-owned the moment it lands in the project.
78
80
  - Read all `.amp/stash/*.md` files in the current project
79
- - Read friction output written in step 0: `.amp/remember/friction/antigen_clusters.json` (preferred) or `.amp/remember/friction/antigen_review.md` (fallback)
81
+ - Read friction output written in step 0: `.amp/remember/friction/antigen_clusters.json` (preferred) or `.amp/remember/friction/antigen_review.md` (fallback). On the fallback path, step 4c does NO counting — merge quotes into
82
+ matching entries only; never change `sessions`, `last_seen`, or `recurred_while_hot` (the
83
+ fallback carries no `session_ids`, so identity matching cannot run on it).
80
84
  - Read existing `.amp/remember/MEMORY.md` if it exists — create dir if missing
81
85
  - Read processed manifest at `.amp/remember/.processed` — skip already-processed stashes
82
- - If no unprocessed stashes AND friction produced no new antigens, run the step-8 mechanical
83
- length check (the same awk: over 180 chars with no >100-char backtick literal) against the
84
- existing `.amp/remember/MEMORY.md`. If it returns 0 lines, report "nothing to
85
- consolidate" and stop. If it returns any lines, "no new input" is not a reason to leave
86
- gate debt in place do NOT stop: proceed to step 3 and run the Facts rewrite + pre-write
87
- gate on the existing content with no new input, then continue through step 8 as normal.
86
+ - **No unprocessed stashes skip steps 2-3 (extraction and the Facts rewrite)
87
+ entirely facts are never rewritten with zero new input**, not even to clear existing
88
+ length-gate debt on `.amp/remember/MEMORY.md`. Steps 4-5 (friction ledger count
89
+ Antigens render) are stash-independent and still run whenever friction produced output
90
+ (see step 4's own guard). If there is also no friction
91
+ output, report "nothing to consolidate" and stop after step 1.
88
92
 
89
93
  2. **Extract from unprocessed stashes** (up to 5 stashes per agent, as few agents as possible — see Guardrails)
90
94
  - Each agent reads its batch of stashes together and calls the mid-tier model (see Guardrails) to extract:
@@ -123,10 +127,14 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
123
127
  history — never exempted. Only a draft that passes the gate (or has its overruns
124
128
  exempted under the 100-char backtick rule) is written to `.amp/remember/MEMORY.md`.
125
129
  - **Episodes section**: append new episode entries, keep only the **10 most recent**.
126
- Every older episode is **folded, then deleted**: its lesson becomes a fact (handed to
127
- the rewrite above); the narrative is removed. No archivegit has the history.
130
+ **Dedup before appending**: if a new episode covers the same work as one already in the
131
+ section (same goal or same session's work under different wording judge by content, not
132
+ title), merge the new detail into the existing entry instead of appending a second copy.
133
+ Re-processing a stash whose episode is already filed must not create a near-duplicate
134
+ pair. Every older episode is **folded, then deleted**: its lesson becomes a fact (handed
135
+ to the rewrite above); the narrative is removed. No archive — git has the history.
128
136
  - **Antigens section**: only update from friction output (step 4)
129
- - Write merged result to `.amp/remember/MEMORY.md` in the format under step 6.
137
+ - Write merged result to `.amp/remember/MEMORY.md` in the format under step 5.
130
138
 
131
139
  4. **Distill friction into antigens** (only if friction output exists)
132
140
 
@@ -141,48 +149,72 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
141
149
  > logs.** Friction's lexical grouping and flags are hints, not the verdict.
142
150
 
143
151
  - Read `.amp/remember/friction/antigen_clusters.json`.
144
- - **4a. Classify target, then semantically consolidate** (the parts lexical matching can't do).
145
- Call the mid-tier model with the cluster quotes + their `preceding`/`projects`/`sessions`/`self_suspect`
146
- (NOT the logs), and have it:
147
- 1. **Decide the target of each reaction — agent or self.** Drop *self/context*
148
- corrections where the user redirected themselves ("wrong project", "wrong window",
149
- "nevermind") the agent did nothing wrong. `self_suspect` and an empty `preceding`
150
- (no agent action) are strong cues. Keep only **agent-directed** reactions.
151
- 2. **Merge same-complaint paraphrases** that friction left split because they share no
152
- words (e.g. "nothing landed, fuck you" + "it says pushed but none got it" → one antigen).
153
- Output one object per surviving antigen:
154
- ```json
155
- {
156
- "rule": "Verify the artifact is actually live after publish; a clean exit code ≠ done",
157
- "target": "agent",
158
- "evidence": ["both say pushed... none got it", "notng landed in live-claude, fuck you"],
159
- "errors": ["Exit code 0 (claimed success)"],
160
- "sessions": 2,
161
- "confidence": "medium"
162
- }
163
- ```
164
- - **4b. Route + tier by recurrence.** For each cluster and each LLM-merged group:
165
- - `suggested_artifact: antigen` (recurring + severe) or an LLM-merged group an
152
+ - **4a. Classify** (the LLM classifies only no merging, no arithmetic; counting
153
+ and rendering are mechanical, see 4c and step 5). Call the mid-tier model once per
154
+ cluster batch with each cluster's `contexts`, `preceding`, `errors`,
155
+ `self_suspect`, `projects`, `sessions`, `top_keywords`, and the ledger's existing
156
+ entries (`id`, `class_hints`, `rule`, `evidence.quotes`). For EACH cluster, output
157
+ exactly one label nothing else:
158
+ - `drop` — self-directed correction, agent's own prose captured as context, or a
159
+ real reaction too short/ambiguous to name a specific mistake (`self_suspect` and
160
+ an empty `preceding` no agent action are strong self-directed cues). Don't
161
+ force a match on one overlapping word.
162
+ - an existing ledger id (`ag-NNN`) — only if the cluster is narrowly the SAME
163
+ mistake class as that entry's `class_hints`+`rule`+`evidence.quotes`, not just
164
+ similar sentiment. State the entry's specific claim precisely in the prompt (a
165
+ generic one-liner rule is not enough to bound the match — see 4c Open item 2) and
166
+ give the classifier a negative example, not just the positive claim, e.g. for
167
+ ag-001 (validate, don't assert): "did you test it?" matches; "we're burning money,
168
+ why is it failing?" does NOT — cost/outcome complaints are not validation claims.
169
+ - `new:<theme>` — a real, agent-directed mistake matching no existing entry.
170
+ `<theme>` is NOT freeform LLM prose: derive it mechanically from the cluster's
171
+ own `top_keywords[0]` and `top_keywords[1]` (lowercase, hyphen-joined). This
172
+ alone raised measured 5-run exact-label agreement from 0.884 to ~0.97-0.99 by
173
+ removing wording variance as a source of disagreement the remaining variance
174
+ is genuine classification disagreement (drop vs. new:, or which existing id),
175
+ not paraphrase noise. Also output a `rule`: one line stating the behavioral
176
+ rule this cluster's evidence supports, same do/don't imperative style as an
177
+ existing ledger entry's `rule` (e.g. "Never say work is validated... without an
178
+ actual run behind it"). This is the only LLM-authored field here — `<theme>`
179
+ naming stays mechanical. `friction.cjs count` requires it whenever the cluster's
180
+ own `sessions >= 2` (it will create a ledger entry); below that it's unused.
181
+
182
+ Output is `{cluster_index: label}` for `drop`/`ag-NNN`; for `new:<theme>`, output
183
+ `{cluster_index: {label: "new:<theme>", rule: "<one-line rule>"}}`.
184
+ - **4b. Route + tier by recurrence.** For each cluster and each same-label group
185
+ (the clusters 4a gave the same label) — its tier comes from the distinct-
186
+ conversation count `friction.cjs count` (4c) computes for it, the union of the
187
+ group's hashes deduped against the ledger:
188
+ - `suggested_artifact: antigen` (recurring + severe) or a same-label group → an
166
189
  **antigen** (a "do/don't" behavioral rule), with its verbatim evidence quotes.
167
190
  - `suggested_artifact: fact` (recurring + mild) → a **Fact**.
168
- - `suggested_artifact: episode` that did **not** merge into a recurring group → an
169
- **Episode** (one-off; recorded, not a rule).
191
+ - `suggested_artifact: episode` that did **not** land in a recurring group → **not
192
+ an Episode, and at 1 session not written anywhere.** The Episodes section is stash-fed
193
+ and capped at 10; friction's one-offs are cross-project and arrive by the dozen, so
194
+ filing them there would flush the stash episodes. Nothing is lost: friction re-scans
195
+ every session log on every run, so the cluster re-surfaces until it recurs — and at 2
196
+ sessions it gets its home, a ledger `observing` entry (4c), which step 5 renders under
197
+ Low Confidence. `suggested_artifact` is friction's structural proposal, not a filing
198
+ decision; the filing rule is this list.
170
199
  - Confidence by distinct-session recurrence:
171
200
  - **High** (5+ sessions) → loaded hot via `@MEMORY.md`
172
201
  - **Medium** (3-4 sessions) → recorded under Antigens, *not* loaded hot
173
- - **Low** (<3 sessions) → keep as Episode only
174
- - **Recurrence tiers bind everything, including LLM-merged groups:** merging consolidates
175
- evidence, it never elevates it — a merged group's tier comes from its combined
176
- distinct-session count (e.g. a 2-session merged group is still Low → Episode + ledger
177
- `observing`, not an antigen entry in MEMORY.md).
202
+ - **Low** (<3 sessions) → ledger `observing` only at 2 sessions; nothing at 1
203
+ - **Recurrence tiers bind everything, including same-label groups:** grouping consolidates
204
+ evidence, it never elevates it — a same-label group's tier comes from its combined
205
+ distinct-session count (e.g. a 2-session same-label group is still Low → ledger
206
+ `observing` only, not an antigen entry in MEMORY.md).
178
207
  - **Never auto-promote.** Only High-confidence (5+ sessions) antigens load hot. A
179
- single dramatic correction is an Episode, not an antigen.
180
- - Update the Antigens section in MEMORY.md (promote/demote based on new recurrence).
181
- - **4c. Update the antigen ledger** (`.amp/remember/ledger.json`) — the evidence trail
182
- linking each rule to the mistake it targets and whether it is working. Create it as
183
- `{"version": 1, "entries": []}` if missing. It is JSON for exact matchingbookkeeping
184
- only, never injected into context as guidance (MEMORY.md is what gets read; the ledger
185
- is what gets checked).
208
+ single dramatic correction is recorded nowhere yet, not an antigen.
209
+ - **4c. Count** (`.amp/remember/ledger.json`, the evidence trail linking each rule
210
+ to the mistake it targets and whether it is working replaces the old evidence-merge
211
+ arithmetic; a script now does every count, not the LLM). **This is a literal command
212
+ you run, not a description you reason from.** The LLM's job ended at 4a do not
213
+ hand-compute session counts, do not decide by inspection which entries changed, even
214
+ if you are confident you can do it correctly. Create `ledger.json` as `{"version": 1,
215
+ "entries": []}` if missing. It is JSON for exact matching — bookkeeping only, never
216
+ injected into context as guidance (MEMORY.md is what gets read; the ledger is what
217
+ gets checked).
186
218
 
187
219
  Entry shape:
188
220
  ```json
@@ -196,96 +228,90 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
196
228
  "history": [{ "date": "YYYY-MM-DD", "event": "<transition>" }] }
197
229
  ```
198
230
 
199
- **Evidence merges by session identity, not by re-counting a re-scan.** Friction re-scans
200
- the entire corpus every run, so the same old session matches its cluster again on every
201
- run; without an identity check, that re-detection would masquerade as new recurrence and
202
- could promote a one-off to hot — the exact false-preference failure the observed-signal
203
- redesign exists to prevent. Friction's clusters already carry `session_ids`. Identity is
204
- the **trailing 8-char hash** the part after the last `-` in the id — because project
205
- labels can be renamed while the hash, derived from the session filename, is stable. Two
206
- ids with the same hash are the same session, full stop.
207
-
208
- Two ids with DIFFERENT hashes can also be one session. A fork or resume writes the same
209
- conversation to a second session file with its own filename, so the hash alone would
210
- count one reaction twice. `friction.cjs` collapses these before it emits clusters —
211
- sessions sharing at least one message `uuid` are one conversation, and the group is
212
- reported under a single canonical id (the lexicographically smallest). So the ids
213
- reaching this step are already canonical; do not attempt to re-derive fork identity
214
- here. Measured on a real 3,158-session corpus: 6 such groups exist, and of ~5M possible
215
- session pairs only 9 share any uuid at all — every one a genuine duplicate.
216
-
217
- **Migration (one-time, grandfathered): SEED, DO NOT COUNT.** Existing entries predate
218
- `session_ids` and carry only a bare `sessions` count with an empty `session_ids` set. This
219
- is mechanicaldo not resolve it by judgment. On the run that first populates such an
220
- entry's `session_ids` (i.e. `session_ids` is empty going in), do exactly these two things
221
- and nothing else:
222
- 1. Write `session_ids` to this run's matched ids (NOT an empty set — the empty set is the
223
- pre-migration state you are migrating FROM, not what you write).
224
- 2. Append the history line "identity migration legacy count grandfathered, growth
225
- requires new hashes".
226
-
227
- Change NOTHING else on this run not `sessions`, not `last_seen`, not
228
- `recurred_while_hot`, not `status`. In particular, do NOT apply the "no hashes present →
229
- new conversation" rule here: `session_ids` was empty, so every hash looks absent, and
230
- counting would treat the entry's own already-counted history as fresh recurrence on a
231
- `hot` entry that also fires `recurred_while_hot`, which at 2 marks the phrasing failed and
232
- rewrites a rule that never actually failed. Counting resumes on the NEXT run, once
233
- `session_ids` is non-empty and an absent hash set is genuinely new evidence.
234
-
235
- Observed for real: bareloop's first migration run carried two `hot` entries, each already
236
- at `recurred_while_hot: 1`. Counting on migration would have taken both to 2 and force-
237
- rephrased two working rules from re-detected pre-existing sessions. Two separate runs
238
- avoided it only because whoever ran them noticed and overrode the text — which is the
239
- definition of a rule that needs to be mechanical rather than prose.
240
-
241
- For each surviving antigen from 4a/4b, match against existing entries by `class_hints`
242
- (the mistake class, not the rule wording — rules change, the class doesn't). A matched
243
- cluster represents exactly ONE conversation, no matter how many hashes its `session_ids`
244
- holds a fork/resume group deliberately carries every member file's hash so the cluster
245
- can be matched under any of the conversation's filenames.
246
-
247
- **`sessions` is the authoritative conversation count. `session_ids` is evidence detail —
248
- a list of the FILES one conversation was written to. NEVER derive a count from
249
- `len(session_ids)`; a fork or resume makes that number larger than the conversation
250
- count.** This is mechanical do not resolve it by judgment. Compare the cluster's hashes
251
- against the entry's stored hash set as a set, not one at a time, and count per
252
- conversation, never per hash:
253
- - **None of the cluster's hashes present** a genuinely new conversation: add ALL of the
254
- cluster's hashes to `session_ids`, increment `sessions` by exactly 1 (never by the
255
- number of hashes in the cluster), refresh `last_seen` to today's run date (session ids
256
- carry no year), and count it once toward the 4b promotion threshold.
257
- - **Any of the cluster's hashes already present** this conversation is already counted:
258
- add whichever of its hashes are still missing from `session_ids` (they are aliases of
259
- the same conversation, and storing them keeps future matching robust under any of its
260
- filenames), but change NOTHING else not `sessions`, not `last_seen`, not history, not
261
- `recurred_while_hot`. It is a re-scan re-detecting a conversation already counted, not
262
- new evidence.
263
- - **No match, cluster `sessions` >= 2** → new entry, `status: "observing"`, attempt 1,
264
- history "candidate (N sessions)".
265
- - **No match, cluster `sessions` == 1** do NOT create a ledger entry. The ledger tracks
266
- recurrence, and a single occurrence has no recurrence to track yet — seeding singletons
267
- grows the ledger by dozens of never-recurring entries per run. Friction re-scans every
268
- session log on every run, so if this mistake recurs, a later run will match it back to
269
- 2+ sessions and seed it then. This does not change the Match bullets below — a
270
- 1-session cluster can still merge into an EXISTING entry; that is recurrence.
271
- - **Match, `observing`** apply the new-conversation / already-counted rule above
272
- (sessions, session_ids, quotes, projects, last_seen). Crosses the 4b hot threshold on a
273
- genuinely new conversation `status: "hot"`, history "promoted to hot (N sessions)".
274
- - **Match, `hot`** the mistake happened *while its rule was loaded*, and only when the
275
- match is a genuinely new conversation (per the rule above, not a re-scan of an
276
- already-present hash): `recurred_while_hot += 1` once per conversation, merge evidence,
277
- history "recurred while hot (count)".
278
- - At `recurred_while_hot >= 2`: the phrasing failed. Mark the current attempt
279
- `outcome: "failed"`, draft attempt n+1 it must differ from **every** prior
280
- attempt's text in this entry (failed attempts are the rejected-edit buffer: never
281
- re-propose one verbatim) replace `rule`, update MEMORY.md's Antigens section,
282
- reset `recurred_while_hot` to 0.
283
- - If 2 attempts have already failed and the antigen persists → `status: "escalated"`:
284
- remove the rule from MEMORY.md's hot section, record a Fact instead ("persistent
285
- failure mode: <class> — no phrasing reduces it"), and flag it in the step-7 report.
286
- **Flag, don't act** — the user decides: enforcement (a hook, where the tool has
287
- them) or accepted limit.
288
- - **Match, `escalated`/`rejected`** (rejected = user veto) → merge evidence only; never re-propose.
231
+ Immediately after 4a produces `labels.json`, run these exact commands as real shell
232
+ invocations, in order. First:
233
+ ```bash
234
+ node <path-to-friction.cjs> migrate-attempts .amp/remember/ledger.json .amp/remember/ledger.json
235
+ ```
236
+ This records any hand-drifted `rule` text as a new attempt so I7 (`rule` == the last
237
+ attempt's `rule`) holds before counting runs; it is a no-op on an already-consistent
238
+ ledger, so always run it regardless of whether drift is suspected. Then:
239
+ ```bash
240
+ node <path-to-friction.cjs> count <labels.json> <ledger.json> <clusters.json> <today's-date> <ledger.json>.new .amp/remember/friction/count_report.json
241
+ ```
242
+ Then **overwrite `ledger.json` with `<ledger.json>.new`'s contents** (e.g. `mv
243
+ ledger.json.new ledger.json`). Do not stop after `labels.json` producing labels is
244
+ 4a, not the deliverable of this step. `friction.cjs count <labels.json> <ledger.json>
245
+ [clusters.json] [runDate] [outLedgerPath] [reportPath]` is deterministic, no LLM
246
+ involved, so it can't drift between repos or runs; the count report is also written to
247
+ `.amp/remember/friction/count_report.json` for step 8 to read back. It implements,
248
+ mechanically, everything the old
249
+ prose reasoning here used to require by hand:
250
+
251
+ - **Session identity** the trailing 8-char hash of a session id (stable across
252
+ project-label renames) with the same fork/resume canonicalization friction.cjs
253
+ already applies before clusters are emitted (sessions sharing >=1 message `uuid`
254
+ collapse to one canonical id; do not re-derive fork identity yourself).
255
+ - **Migration (one-time, grandfathered): SEED, DO NOT COUNT** — an entry whose
256
+ `session_ids` is empty going in AND carries no prior "identity migration" history
257
+ line has that line's first match seed `session_ids` from this run's matches WITHOUT
258
+ incrementing `sessions`/`last_seen`/`recurred_while_hot`. The **migration-fill
259
+ sub-case** `session_ids` still empty but an "identity migration" line already
260
+ exists fills `session_ids` on the first post-migration match, still without
261
+ incrementing; counting resumes only once `session_ids` is non-empty.
262
+ - **Counting is per CLUSTER INDEX, never per hash and never once per label-group.**
263
+ For each cluster index in a matched group: if none of that cluster's own hashes are
264
+ already in the entry's stored set, it is one genuinely new conversation `sessions`
265
+ += 1 (never by hash count), `last_seen` refreshed, and if the entry is `hot` and
266
+ the session's own date is on/after the current attempt's `adopted` date (the
267
+ **adopted-date gate**: a mistake that predates the rule's current phrasing isn't a
268
+ phrasing failure of it) `recurred_while_hot` += 1. A gated-out (predates-adopted)
269
+ new conversation still counts as evidence (sessions, hash) but not toward
270
+ `recurred_while_hot`. A hash already present is a re-scan of an already-counted
271
+ conversation: only missing alias hashes are added, nothing else changes.
272
+ - Promotes `observing`→`hot` at `sessions >= 5` (a fresh ledger on a project with
273
+ mature global evidence can hit the hot case on its very first run: new entry born
274
+ `hot` directly if `sessions >= 5` on arrival).
275
+ - **No match, cluster `sessions` == 1** writes nothing. The ledger tracks
276
+ recurrence; a single occurrence has none to track yet. Friction re-scans every
277
+ session log every run, so a later run matches it back to 2+ sessions and seeds it
278
+ then — this does not change matching against an EXISTING entry, which is recurrence
279
+ regardless of the matching cluster's own session count.
280
+ - For `new:<theme>` groups with no ledger match: distinct conversations = distinct
281
+ cluster indices in the group (within one classify batch, no two cluster indices
282
+ share a session hash). `sessions < 2` writes nothing. `sessions >= 2` → new entry,
283
+ `status` follows the same >=5-hot / else-observing rule.
284
+
285
+ **Open item 1 `new:` label collisions: resolved (Guard B).** `new:` clusters never
286
+ merge in-batch, regardless of whether two cluster indices share the same `new:` string
287
+ each `new:` cluster with `sessions >= 2` creates its own ledger entry, and a genuine
288
+ recurrence of the same new mistake is matched on a later run by `class_hints`, like any
289
+ other entry. Measured against the alternative (merge same-labeled `new:` clusters when
290
+ their `top_keywords` overlap by >=1): a synthetic new entry was correctly re-matched by
291
+ a fresh classifier on 5/5 runs under Guard B, while the keyword-overlap guard wrongly
292
+ merged two real, distinct mistakes on real data (clusters 21/23 unrelated mistakes
293
+ sharing the generic keyword "fucking validate"). `friction.cjs count` implements Guard
294
+ B: every `new:`-labeled cluster stands alone.
295
+
296
+ **Open item 2 — a generic one-line `rule` under-specifies the class for matching:**
297
+ the ledger's `class_hints`+`rule` alone can be too broad for the LLM classifier (4a)
298
+ to reliably tell two different mistakes apart, as the "fucking validate" false-merge
299
+ case above shows. Consider requiring a short negative example ("NOT X, even though it
300
+ sounds similar") on ledger entries whose `class_hints` are single generic words/phrases.
301
+
302
+ **After `friction.cjs count` returns**, resume the parts it does not do:
303
+ - **Escalation.** For any `hot` entry the count run left with `recurred_while_hot >=
304
+ 2`: the phrasing failed. Mark the current attempt `outcome: "failed"`, draft
305
+ attempt n+1 it must differ from **every** prior attempt's text in this entry
306
+ (failed attempts are the rejected-edit buffer: never re-propose one verbatim)
307
+ replace `rule`, update MEMORY.md's Antigens section (step 5), reset
308
+ `recurred_while_hot` to 0. If 2 attempts have already failed and the antigen
309
+ persists → `status: "escalated"`: remove the rule from MEMORY.md's hot section,
310
+ record a Fact instead ("persistent failure mode: <class> no phrasing reduces
311
+ it"), and flag it in the step-8 report. **Flag, don't act** — the user decides:
312
+ enforcement (a hook, where the tool has them) or accepted limit.
313
+ - **`escalated`/`rejected` entries** (rejected = user veto) never get a new attempt
314
+ proposed, count run or not.
289
315
 
290
316
  **Decay (observing only).** Antigens are the fastest-decaying artifact and, until now,
291
317
  had no exit. This is meaningful *because* of the identity fix above — without it,
@@ -343,19 +369,44 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
343
369
  ### YYYY-MM-DD - [title]
344
370
  - [bullet narrative]
345
371
 
372
+ ## Antigens
373
+ [rendered — see below, do not hand-write]
374
+ ```
375
+ **The `## Antigens` section is rendered, not hand-written — run this too, as a literal
376
+ command, do not hand-write it even to match the format shown above.** After 4c has
377
+ overwritten `ledger.json`, run:
378
+ ```bash
379
+ node <path-to-friction.cjs> render <ledger.json>
380
+ ```
381
+ Take that command's stdout **verbatim** and replace MEMORY.md's entire `## Antigens`
382
+ section with it (from the `## Antigens` line up to, but not including, the next `## `
383
+ heading, or to end of file if Antigens is the last section — it usually is). This step's
384
+ output IS the correctness check for itself: after replacing, `node
385
+ <path-to-friction.cjs> check <ledger.json> <MEMORY.md>` must report `I6-new: EQUAL` — if
386
+ it doesn't, something was edited by hand instead of pasted from the script's stdout; redo
387
+ it from the script's stdout exactly, never patch MEMORY.md manually to make it match.
388
+
389
+ `friction.cjs render` prints the section byte-for-byte, no LLM paraphrase, no manual
390
+ template filling:
391
+ ```
346
392
  ## Antigens
347
393
  ### High Confidence (loaded — applies every session)
348
- - [behavioral rule] (evidence: [N] sessions — "[verbatim quote]")
394
+ - [behavioral rule] (evidence: [N] sessions, [P] projects — "[quote1]", "[quote2]") — ag-NNN
349
395
 
350
396
  ### Medium Confidence (observing — not loaded)
351
- - [behavioral rule] (evidence: [N] sessions)
397
+ - [behavioral rule] (evidence: [N] sessions) — ag-NNN
352
398
 
353
399
  ### Low Confidence (needs more data)
354
- - [pattern] (evidence: [N] sessions)
400
+ - [pattern] (evidence: [N] sessions) — ag-NNN
355
401
  ```
356
- The Medium/Low lists render only entries whose ledger `status` is `observing` (or `hot`
357
- for High) an entry marked `expired` by the decay rule (step 4c) is skipped here even
358
- though it stays in `ledger.json`.
402
+ Tiers: High = `status == "hot" && sessions >= 5` (the only tier that prints quotes — the
403
+ first 2 of `evidence.quotes`, verbatim, capped at 2 and `evidence.projects.length`).
404
+ Medium = `status == "observing" && 3 <= sessions <= 4`. Low = `status == "observing" &&
405
+ sessions == 2`. `expired`/`escalated`/`rejected`/`sessions < 2` never render, same as
406
+ before — an entry marked `expired` by the decay rule (4c) is skipped here even though it
407
+ stays in `ledger.json`. A legacy 1-session `observing` entry (see 4b) stays in the ledger
408
+ and grows or expires like any other, but is not rendered. An empty tier prints `- (none —
409
+ <why>)`, one line, never an empty section.
359
410
 
360
411
  6. **Update processed manifest**
361
412
  - Append paths of newly processed stashes to `.amp/remember/.processed`
@@ -367,7 +418,9 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
367
418
  write that already happened in steps 3-6.
368
419
 
369
420
  - **Locate `docs-builder.cjs`** — bundled next to this command at
370
- `docs-builder/docs-builder.cjs` (same convention as `remember/friction.cjs`).
421
+ `docs-builder/docs-builder.cjs` (same convention as `remember/friction.cjs`). Call it by
422
+ its **absolute path** in the command below — the cwd here is the target repo, not this
423
+ package, so a cwd-relative path fails everywhere except the liteagents repo itself.
371
424
  - **Not applicable, stay silent:** if the project has no `docs/` directory, skip without
372
425
  saying anything. Most projects have no doc corpus and a nudge every run is noise.
373
426
  - **Applicable but could not run — say so, loudly:** if `docs/` exists but the script is
@@ -382,12 +435,15 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
382
435
  below.
383
436
  - Otherwise run it and pass through its verdict:
384
437
  ```bash
385
- node docs-builder/docs-builder.cjs due
438
+ node <docs-builder.cjs> due
386
439
  ```
387
440
  `due` compares `docs/` against the SHA stamped in `docs/.docs-builder/ledger.json`
388
441
  using `git diff --numstat -M`, classifying each doc as new / moved / moved+changed /
389
442
  changed (with the line delta and rough percentage) / deleted. It is **due at >=5
390
443
  changed docs** — the same derived-not-counted shape as `/stash`'s nudge.
444
+ - If `due` prints "no ledger yet" (no `docs/.docs-builder/ledger.json` to compare against),
445
+ do NOT relay it — print the same `/docs-builder reorg` line as the no-`docs/.docs-builder/`
446
+ case above, for the same reason: `ledger` would stamp an unsorted pile as correct.
391
447
  - If DUE, end with one line and nothing more:
392
448
  ```
393
449
  docs: 7 changed since 991f72d3 — run /docs-builder reorg
@@ -396,7 +452,10 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
396
452
  8. **Report to user** — print it AND write the same content to `.amp/remember/report.md`
397
453
  (overwritten each run; the ledger keeps history — the report is just the latest snapshot)
398
454
  - Number of stashes processed
399
- - Facts count (before → after the rewrite; the number should not grow by the number of new facts)
455
+ - Facts count (before → after the rewrite) plus how many existing lines were merged or
456
+ shortened. At steady state — lines already ≤160, no near-duplicates — a run that grows by
457
+ exactly its new facts and shortens nothing is correct; say so rather than forcing merges
458
+ to hit a number. The bound on facts is the write-bar at entrance, not a count.
400
459
  - **Mechanical length check** — run, don't estimate. This confirms the step-3 gate rather
401
460
  than being the first check to catch an overrun. It implements the SAME mechanical
402
461
  exemption as the gate (a line whose longest backtick literal exceeds 100 chars is not
@@ -418,7 +477,9 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
418
477
  — every remaining overrun already had its chance to be exempted (100-char backtick
419
478
  literal) inside the step-3 gate, so anything printed here should not exist.
420
479
  - Episodes count (new, kept hot, folded + deleted)
421
- - Antigens count by confidence tier, with how many newly promoted to hot
480
+ - Antigens count by confidence tier, with how many newly promoted to hot — sourced
481
+ from `.amp/remember/friction/count_report.json` (4c's count report), not
482
+ recomputed by hand
422
483
  - Ledger lines — one per non-observing entry: id, short rule, status, recurrences since
423
484
  adoption. Highlight rephrased (RECURRED) and ESCALATED entries; escalations need a
424
485
  user decision, e.g.:
@@ -435,7 +496,7 @@ Reads all raw material (`.amp/stash/*.md` + `.amp/remember/friction/antigen_clus
435
496
  - Memory file: `.amp/remember/MEMORY.md` (single source of truth, referenced as `@.amp/remember/MEMORY.md`)
436
497
  - Rules template: `.amp/remember/AGENT_RULES.md` (bootstrapped once from the bundled package template on first `/remember` run, never overwritten again — user-owned after that; referenced as `@.amp/remember/AGENT_RULES.md`)
437
498
  - Antigen ledger: `.amp/remember/ledger.json` (per-rule evidence trail: class, status, attempts/rejected-buffer, recurrence-while-hot)
438
- - Consolidation report: `.amp/remember/report.md` (latest step-7 report, overwritten each run)
499
+ - Consolidation report: `.amp/remember/report.md` (latest step-8 report, overwritten each run)
439
500
  - Processed manifest: `.amp/remember/.processed`
440
501
  - Docs ledger (READ ONLY from here — owned by `/docs-builder`): `docs/.docs-builder/ledger.json`
441
502
  - Friction output (transient, regenerated each run): `.amp/remember/friction/` — `antigen_clusters.json` (preferred input), `antigen_review.md` (fallback), plus raw analysis files
@@ -761,7 +761,7 @@ function indexFlat() {
761
761
 
762
762
  if (!productFiles.length && !logsFiles.length && !archiveFiles.length && !pageFiles.length) {
763
763
  console.log('nothing to index — run `discover` + `apply-reorg` first.');
764
- return;
764
+ return false; // caller (applyReorg) uses this to skip the config pointer — no index.md was written
765
765
  }
766
766
 
767
767
  const outRel = process.env.OUT || 'docs/index.md';
@@ -1748,7 +1748,11 @@ function discover(root) {
1748
1748
  // writes had persisted. Carry-forward (above) is exactly why a re-run can arrive here with
1749
1749
  // buckets already set, so this has to report what is actually in the plan.
1750
1750
  const filled = rows.filter(r => r.bucket).length;
1751
- if (!filled) {
1751
+ if (!rows.length) {
1752
+ console.log(`plan written to docs/.docs-builder/reorg-plan.json — 0 rows. Nothing outside `
1753
+ + 'product/, logs/ and archive/ is left to classify — the corpus is already sorted. '
1754
+ + '`apply-reorg` will only rescan and rebuild the index.');
1755
+ } else if (!filled) {
1752
1756
  console.log(`plan written to docs/.docs-builder/reorg-plan.json — every row's \`suggested\` `
1753
1757
  + 'is a PRIOR, not a verdict, and `bucket` is empty. Run the classification interview '
1754
1758
  + '(docs-builder.md): feed the model the plan, get bucket+reason per row, get the user\'s '
@@ -1880,7 +1884,7 @@ const DOCS_INDEX_END = '<!-- DOCS_INDEX:END -->';
1880
1884
  function docsIndexBlock() {
1881
1885
  return `${DOCS_INDEX_START}\n`
1882
1886
  + 'Docs map: `docs/index.md` — every doc in this project, with line counts.\n'
1883
- + 'Too many rows to read whole? Search instead: `/docs-builder search <query words>`\n'
1887
+ + 'Search this corpus instead of reading it whole: `/docs-builder search <query words>`\n'
1884
1888
  + `${DOCS_INDEX_END}`;
1885
1889
  }
1886
1890
  function injectClaudeMdPointer() {
@@ -1969,7 +1973,6 @@ function applyReorg(planFile) {
1969
1973
  // (re)builds outline.json for a corpus that already sat in docs/product/docs/archive/docs/logs
1970
1974
  // from a previous run, e.g. after a manual git mv or a re-run with nothing left to do.
1971
1975
  scanWholeCorpus();
1972
- console.log(JSON.stringify(results, null, 1));
1973
1976
  if (splitCandidates.length) {
1974
1977
  // Ranked, logs last (spec §5): a prereg is a legitimate split target but rarely the best
1975
1978
  // NEXT one. Array.prototype.sort is stable in Node, so this only reorders logs to the
@@ -1982,18 +1985,23 @@ function applyReorg(planFile) {
1982
1985
  // v3: apply-reorg writes docs/index.md itself — a reorg-only corpus ends up indexed
1983
1986
  // without a second command. Runs unconditionally — oversized docs are sorted like anything
1984
1987
  // else now, so this was never conditional on them.
1985
- indexFlat();
1986
- // Crash-isolated, same spirit as the moveDoc() follow-up failures collected above: a
1987
- // failure to write the config file is a WARN, never a thrown error — it must not make an
1988
- // already-moved file look unmoved or fail the run.
1988
+ const indexed = indexFlat() !== false;
1989
1989
  const configName = process.env.CONFIG || 'CLAUDE.md';
1990
- try {
1991
- injectClaudeMdPointer();
1992
- results.claudeMdUpdated = true;
1993
- console.log(` updated ${configName} with the docs/index.md pointer`);
1994
- } catch (e) {
1995
- console.error(` WARN could not update ${configName} with the docs/index.md pointer: ${e.message}`);
1990
+ if (!indexed) {
1991
+ console.log(` skipped the ${configName} pointer — no docs/index.md was written`);
1992
+ } else {
1993
+ // Crash-isolated, same spirit as the moveDoc() follow-up failures collected above: a
1994
+ // failure to write the config file is a WARN, never a thrown error — it must not make an
1995
+ // already-moved file look unmoved or fail the run.
1996
+ try {
1997
+ injectClaudeMdPointer();
1998
+ results.claudeMdUpdated = true;
1999
+ console.log(` updated ${configName} with the docs/index.md pointer`);
2000
+ } catch (e) {
2001
+ console.error(` WARN could not update ${configName} with the docs/index.md pointer: ${e.message}`);
2002
+ }
1996
2003
  }
2004
+ console.log(JSON.stringify(results, null, 1));
1997
2005
  logOp('apply-reorg', `moved ${results.moved}, skipped ${results.skipped}, `
1998
2006
  + `${splitCandidates.length} oversized split candidate(s), `
1999
2007
  + `${results.linksRewritten} link(s) rewritten, ${results.syncFailed} sync failure(s), `
@@ -2327,7 +2335,7 @@ function cleanupApply(file, outlineF, labelsF) {
2327
2335
 
2328
2336
  // Machine state has one home. Callers can override with OUT, but the default must never
2329
2337
  // scatter JSON into whatever directory the user happened to be standing in.
2330
- const ARTIFACTS = 'docs/.docs-builder';
2338
+ const ARTIFACTS = path.join(REPO, 'docs/.docs-builder');
2331
2339
  function write(obj, fallback) {
2332
2340
  const dest = process.env.OUT || path.join(ARTIFACTS, fallback);
2333
2341
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -2352,7 +2360,7 @@ switch (cmd) {
2352
2360
  case 'cleanup-apply': cleanupApply(rest[0], rest[1], rest[2]); break;
2353
2361
  default:
2354
2362
  die('usage: docs-builder.cjs <scan|validate|plan|index-flat|search|archive|ledger|due|lint|'
2355
- + 'discover|apply-reorg|reorg|cleanup> [args]\n'
2363
+ + 'discover|apply-reorg|reorg|cleanup|cleanup-apply> [args]\n'
2356
2364
  + ' scan <file.md...> -> outline.json\n'
2357
2365
  + ' validate <outline.json> <labels.json> -> PASS/FAIL (exit 1 on FAIL)\n'
2358
2366
  + ' plan <outline.json> <labels.json> -> task-<theme>.json per page\n'
@@ -2368,6 +2376,7 @@ switch (cmd) {
2368
2376
  + 'drift summary if a ledger stamp exists (the single front door)\n'
2369
2377
  + ' cleanup <file.md> -> ONE named file: cost estimate, then scan\n'
2370
2378
  + ' (the ONLY entry point to the split pipeline)\n'
2379
+ + ' cleanup-apply <file.md> [outline] [labels] -> plan + pages + archive, after the cleanup interview\n'
2371
2380
  + 'env: REPO (default cwd), OUT (output path), INDEX (default docs/index.md), '
2372
2381
  + 'PAGES (default docs/wiki), TASKS (default docs/.docs-builder/tasks), '
2373
2382
  + 'N (search result count, default 10), OVERSIZED_LINES (default 500)');