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 @@ allowed-tools: Read, Write, Edit, Grep, Glob, Task, AskUserQuestion, Bash(node:*
9
9
  # docs-builder
10
10
 
11
11
  Keep project docs **current, complete and findable**, and split a file when it outgrows
12
- its row in `docs/README.md`.
12
+ its row in `docs/index.md`.
13
13
 
14
14
  > **The honest label: this does NOT make docs cheaper to read.**
15
15
  > Measured four ways; best case is a tie with doing nothing. Cost tracks *findings*, not
@@ -36,6 +36,21 @@ carry over, the absolute prices do not.
36
36
 
37
37
  ## Invocation
38
38
 
39
+ **Locate the script first.** `docs-builder.cjs` is bundled next to this command at
40
+ `docs-builder/docs-builder.cjs` — the same directory as this file, whether installed or run
41
+ from the package. Never search the target repo for it, never reconstruct it from this spec,
42
+ and if it truly exists nowhere say so and stop. Set `DB` to its ABSOLUTE path, then `cd` to
43
+ the target repo's root:
44
+
45
+ ```
46
+ DB=<absolute path to docs-builder.cjs>
47
+ cd <target repo root>
48
+ ```
49
+
50
+ Every command below is `node $DB …`; everything the script writes (`docs/.docs-builder/*`
51
+ JSON state, `docs/index.md`, the ledger, the log, the config pointer) lands under the target
52
+ repo. `REPO=` is optional and only needed when not running from the repo root.
53
+
39
54
  **With an argument** (`reorg`, `cleanup <file>`, or `search <query words...>`) — run that mode
40
55
  directly, no question asked.
41
56
 
@@ -82,10 +97,17 @@ read-only — no model cost, no interview, nothing moves.
82
97
  3. `apply-reorg` moves every row, **oversized included** — size only decides whether a doc is
83
98
  *splittable*, not whether it gets sorted. It refuses outright if any row's `bucket` is
84
99
  still empty. Afterward it prints the oversized docs it just moved as a follow-up list,
85
- `cleanup <NEW path> (N lines)`, `logs/` entries last. Show that list, then **ask which to
86
- split** (any, all, none). Only then run `cleanup <file>` (Mode 1) on each chosen file
100
+ `cleanup <NEW path> (N lines)`, `logs/` entries last. If the list is empty (nothing
101
+ oversized), say so and skip the split question; otherwise show the list, then **ask which
102
+ to split** (any, all, none). Only then run `cleanup <file>` (Mode 1) on each chosen file —
87
103
  `cleanup` itself prints the estimated split cost for that one file, then a mechanical
88
104
  shape report, then stops for its own interview (Mode 1, step 1b) before anything else runs.
105
+ Before that first commit, add `docs/.docs-builder/` to `.gitignore` if it is not already
106
+ ignored: it is machine state, regenerated every run, and the ledger stamp is per-clone by
107
+ design — it must never ride into history on a later `git add -A`.
108
+ Once the moves are committed, run `node $DB ledger` — nothing in steps 1-3 stamps the
109
+ ledger, and without the stamp `due` stays NOT due, the picker's verdict stays uninformed,
110
+ and `/remember`'s docs nudge never fires.
89
111
 
90
112
  The two stops are deliberate and different. Step 2 guards *correctness* — the interview and
91
113
  the user's approval, before a single file moves. Step 3's follow-up guards *cost* — splitting
@@ -96,7 +118,8 @@ when they pick "First run". Never split N files in one shot on an unseen list.
96
118
  first, if a ledger stamp exists, then it runs `discover`. If any row's `bucket` is still
97
119
  empty (true on a genuine first run, or when new files appeared since the last classification),
98
120
  `reorg` **stops right there** and prints what to do next — it never silently proceeds past an
99
- unclassified plan. Once the plan is fully classified (an already-sorted corpus's re-run
121
+ unclassified plan. Commit what it changed, then run `node $DB ledger` to move the stamp. Once
122
+ the plan is fully classified (an already-sorted corpus's re-run
100
123
  carries its prior classifications forward automatically — see "Discover is idempotent"
101
124
  below), `reorg` continues straight through `apply-reorg` → `lint`, no further stop, so
102
125
  `index.md` and `lint.json` stay current. This is the common, cheap case for a corpus that is
@@ -109,7 +132,7 @@ already sorted: nothing new to classify, so the interview gate never fires.
109
132
  | Mode | Menu option | Does | Destructive |
110
133
  |---|---|---|---|
111
134
  | `/docs-builder reorg` (discover, classification interview, confirm, then apply-reorg) | *First run*, steps 1-3 | classify a WHOLE corpus into product/logs/archive | no (moves are `git mv`, plan classified and reviewed first) |
112
- | `/docs-builder cleanup <file>` | *First run*, step 4 | measure ONE named oversized doc (cost, scan, heading shape) → **stops for the interview** | no (measure-only; original preserved) |
135
+ | `/docs-builder cleanup <file>` | *First run*, step 3's split question | measure ONE named oversized doc (cost, scan, heading shape) → **stops for the interview** | no (measure-only; original preserved) |
113
136
  | `/docs-builder reorg` (bare `docs-builder.cjs reorg`) | *Docs drift* | due's drift summary (if a ledger stamp exists) + discover → (stops here if anything is still unclassified) → apply-reorg → lint, whole corpus | no |
114
137
  | `/docs-builder search <query words...>` | *(none — explicit-argument mode only, never offered in the bare picker)* | BM25-rank sections of `docs/.docs-builder/outline.json` against the query, read-only | no |
115
138
 
@@ -206,7 +229,7 @@ purpose, not silently dropped.
206
229
  ### 1. Discover (script) — enriches and PROPOSES, never classifies, never moves
207
230
 
208
231
  ```bash
209
- REPO=<repo> node docs-builder/docs-builder.cjs discover # defaults to docs/
232
+ node $DB discover # defaults to docs/
210
233
  ```
211
234
 
212
235
  Recursively finds every `*.md` under the root (skipping `wiki/`, `logs/`, `archive/`,
@@ -317,7 +340,7 @@ files with no gate at all.
317
340
  ### 3. Apply (script) — an ALREADY-CLASSIFIED plan, verified moves, survives a bad file
318
341
 
319
342
  ```bash
320
- CONFIG=AGENTS.md node docs-builder/docs-builder.cjs apply-reorg # defaults to the plan above
343
+ CONFIG=AGENTS.md node $DB apply-reorg # defaults to the plan above
321
344
  ```
322
345
 
323
346
  **Refuses outright if any row's `bucket` is still empty** — the interview-has-not-happened
@@ -455,7 +478,7 @@ real page count isn't known until the model groups sections in step 2), runs ste
455
478
  below for you, then measures the document's heading shape and **stops**:
456
479
 
457
480
  ```bash
458
- REPO=<repo> node docs-builder/docs-builder.cjs cleanup docs/BIG.md
481
+ node $DB cleanup docs/BIG.md
459
482
  ```
460
483
 
461
484
  **Nothing past this command runs until a human has answered the interview (step 1b) below.**
@@ -468,8 +491,8 @@ choosing to continue, and confirming, the themes.
468
491
  ### 1. Scan (script) — run automatically by `cleanup`, shown here for what it produces
469
492
 
470
493
  ```bash
471
- REPO=<repo> OUT=docs/.docs-builder/outline.json \
472
- node docs-builder/docs-builder.cjs scan docs/BIG.md
494
+ OUT=docs/.docs-builder/outline.json \
495
+ node $DB scan docs/BIG.md
473
496
  ```
474
497
 
475
498
  One record per H2, each carrying the doc's H1 identity, a 2-line snippet, every H3 **with
@@ -560,7 +583,7 @@ it `false`) on every other theme.
560
583
  ### 3. Validate (script) — **hard gate, exits 1 on failure**
561
584
 
562
585
  ```bash
563
- REPO=<repo> node docs-builder/docs-builder.cjs validate \
586
+ node $DB validate \
564
587
  docs/.docs-builder/{outline,labels}.json
565
588
  ```
566
589
 
@@ -586,7 +609,7 @@ index, which is gone — on the one whole-corpus index it would silently skip mo
586
609
  ### 4. Plan + apply (script) — `cleanup-apply`, the door back in after the interview
587
610
 
588
611
  ```bash
589
- REPO=<repo> node docs-builder/docs-builder.cjs cleanup-apply docs/BIG.md \
612
+ node $DB cleanup-apply docs/BIG.md \
590
613
  docs/.docs-builder/outline.json docs/.docs-builder/labels.json
591
614
  ```
592
615
 
@@ -616,7 +639,7 @@ the checkpoint; there is no separate state file to go stale. It is also still ru
616
639
  own:
617
640
 
618
641
  ```bash
619
- REPO=<repo> OUT=docs/.docs-builder/tasks node docs-builder/docs-builder.cjs plan \
642
+ OUT=docs/.docs-builder/tasks node $DB plan \
620
643
  docs/.docs-builder/{outline,labels}.json
621
644
  ```
622
645
 
@@ -653,7 +676,7 @@ Each agent reads **only its own line ranges**. The value is context isolation.
653
676
  ### 6. Archive the original (script) — run for you by `cleanup-apply` once all pages exist
654
677
 
655
678
  ```bash
656
- REPO=<repo> node docs-builder/docs-builder.cjs archive docs/BIG.md
679
+ node $DB archive docs/BIG.md
657
680
  ```
658
681
 
659
682
  A **verified move**, not a copy: hash → `git mv` (so history follows) → hash again →
@@ -697,7 +720,7 @@ which defaults the outline path and takes only the query. The underlying script
697
720
  works directly, and is what the slash command runs:
698
721
 
699
722
  ```bash
700
- REPO=<repo> node docs-builder/docs-builder.cjs search docs/.docs-builder/outline.json <query words...>
723
+ node $DB search docs/.docs-builder/outline.json <query words...>
701
724
  ```
702
725
 
703
726
  BM25 over each section's real text (no deps, no separate index to build — it reads
@@ -717,7 +740,7 @@ reorg, not only whichever ones a split happened to touch.
717
740
  some archived docs).
718
741
 
719
742
  ```bash
720
- REPO=<repo> node docs-builder/docs-builder.cjs index-flat
743
+ node $DB index-flat
721
744
  ```
722
745
 
723
746
  Writes **one** `docs/index.md` covering the whole corpus, in three sections: `## Product`
@@ -745,7 +768,7 @@ v3 folds the old `reconcile` and `due` commands into one: "first run" (nothing s
745
768
  state, and two separate commands only made users guess which one to run.
746
769
 
747
770
  ```bash
748
- REPO=<repo> node docs-builder/docs-builder.cjs reorg
771
+ node $DB reorg
749
772
  ```
750
773
 
751
774
  If a ledger stamp exists (see "Knowing when reorg is due" below), its `due`-style drift
@@ -773,7 +796,7 @@ still runnable by hand once a `labels.json` exists.
773
796
  `lint` is also runnable standalone, on any file list, not only as part of `reorg`:
774
797
 
775
798
  ```bash
776
- REPO=<repo> node docs-builder/docs-builder.cjs lint <file.md...>
799
+ node $DB lint <file.md...>
777
800
  ```
778
801
 
779
802
  -> `lint.json`. Every check below is declared-only (see the governing rule further down) —
@@ -812,8 +835,8 @@ git is the diff engine. The ledger stores only the one thing git cannot know —
812
835
  last consolidated** — so the two can never drift apart.
813
836
 
814
837
  ```bash
815
- node docs-builder/docs-builder.cjs ledger # stamp the current state (run after a reorg)
816
- node docs-builder/docs-builder.cjs due # what changed since, and by how much
838
+ node $DB ledger # stamp the current state (run after COMMITTING a reorg)
839
+ node $DB due # what changed since, and by how much
817
840
  ```
818
841
 
819
842
  `due` classifies every doc against the stamped SHA using `git diff --numstat -M`:
@@ -9,7 +9,7 @@
9
9
  6. [Environment](#environment)
10
10
  7. [Development Workflow](#development-workflow)
11
11
  8. [Twelve-Factor Checklist](#twelve-factor-checklist)
12
- 9. [AGENTS.md Stub](#claudemd-stub)
12
+ 9. [AGENTS.md Stub](#agentsmd-stub)
13
13
  10. [AI Agent Instructions](#ai-agent-instructions)
14
14
 
15
15
  ---
@@ -1412,7 +1412,7 @@ function analyzeMain(sessionsDir) {
1412
1412
  stat = fs.statSync(inputPath);
1413
1413
  } catch {
1414
1414
  console.log(`No sessions found in ${inputPath}`);
1415
- return 1;
1415
+ return 2; // 2 = no input; 1 is reserved for the verdict below
1416
1416
  }
1417
1417
 
1418
1418
  if (stat.isFile()) {
@@ -1444,7 +1444,7 @@ function analyzeMain(sessionsDir) {
1444
1444
 
1445
1445
  if (sessionFiles.length === 0) {
1446
1446
  console.log(`No sessions found in ${inputPath}`);
1447
- return 1;
1447
+ return 2; // 2 = no input; 1 is reserved for the verdict below
1448
1448
  }
1449
1449
 
1450
1450
  // Create output dir
@@ -2213,9 +2213,17 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2213
2213
  const SELF_RE = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
2214
2214
  const hasContext = cl.contexts.length > 0;
2215
2215
  const allSelf = hasContext && cl.contexts.every(q => SELF_RE.test(q || ''));
2216
- const severe = signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
2217
- || (hasContext && signalNames.includes('user_correction') && !allSelf)
2218
- || cl.errors.length > 0;
2216
+ // Severity is INTENSITY, distinct from existence. Every cluster already
2217
+ // exists only because of an observed reaction (ANCHOR_SIGNALS), so a plain
2218
+ // user_correction must NOT by itself qualify as severe — that made every
2219
+ // cluster severe by construction and collapsed the 2x2 below into
2220
+ // recurrence alone (fact/drop were unreachable; measured 69/69 severe on
2221
+ // the real corpus). Severe = a curse, an interrupt cascade, or a tool
2222
+ // error corroborating the reaction. A self-correction ("wrong repo") is
2223
+ // never severe even with an error attached.
2224
+ const severe = !allSelf && (
2225
+ signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
2226
+ || cl.errors.length > 0);
2219
2227
  const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
2220
2228
  let artifact;
2221
2229
  if (recurring && severe) artifact = 'antigen';
@@ -2457,6 +2465,387 @@ function extractMain(sessionsDir) {
2457
2465
  return 0;
2458
2466
  }
2459
2467
 
2468
+ // =============================================================================
2469
+ // CLASSIFY-THEN-COUNT SUBCOMMANDS -- count/render/check/migrate-attempts (see remember.md steps 4a-4c/5)
2470
+ // =============================================================================
2471
+
2472
+ function antigenHash(id) { return id.split('-').pop(); }
2473
+
2474
+ /** Extracts a session's date (YYYY-MM-DD) from its id's "MMDD-HHMM-hash" suffix.
2475
+ * Session ids carry no year, so the run's own year is assumed; if that would put
2476
+ * the date in the future relative to runDate, the year is rolled back by one
2477
+ * (handles a session from late in the prior year being replayed early in a new
2478
+ * one). Returns null if the id doesn't match the expected shape. */
2479
+ function sessionDateFromId(id, runDate) {
2480
+ const tail = id.split('/').pop() || '';
2481
+ const m = /^(\d{2})(\d{2})-\d{4}-/.exec(tail);
2482
+ if (!m) return null;
2483
+ const [, mm, dd] = m;
2484
+ const runYear = parseInt(runDate.slice(0, 4), 10);
2485
+ let dateStr = `${runYear}-${mm}-${dd}`;
2486
+ if (dateStr > runDate) dateStr = `${runYear - 1}-${mm}-${dd}`;
2487
+ return dateStr;
2488
+ }
2489
+
2490
+ /**
2491
+ * `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
2492
+ * Merges classifier labels (index -> "drop" | "ag-NNN" | "new:theme" | {label:
2493
+ * "new:theme", rule: "<one-line rule>"}) by label, counts distinct new conversations
2494
+ * (one per cluster INDEX, never per hash or per group), and applies the ledger rules
2495
+ * mechanically. Prints the count report to stdout; writes the updated ledger to
2496
+ * outLedgerPath if given, else also to stdout.
2497
+ */
2498
+ function nextAntigenId(ledger) {
2499
+ let max = 0;
2500
+ for (const e of ledger.entries) {
2501
+ const m = /^ag-(\d+)$/.exec(e.id);
2502
+ if (m) max = Math.max(max, parseInt(m[1], 10));
2503
+ }
2504
+ return `ag-${String(max + 1).padStart(3, '0')}`;
2505
+ }
2506
+
2507
+ /** Builds class_hints for a freshly-created `new:` entry from the cluster's own
2508
+ * top_keywords (mechanical, matches how the theme label itself is derived) plus
2509
+ * short quote snippets, so a LATER run's classifier has real material to match a
2510
+ * recurrence against via class_hints -- required by Decision 1c. */
2511
+ function buildClassHints(cluster) {
2512
+ const hints = [];
2513
+ for (const kw of (cluster.top_keywords || []).slice(0, 4)) hints.push(kw);
2514
+ for (const q of (cluster.contexts || []).slice(0, 2)) hints.push(q.slice(0, 80));
2515
+ return hints;
2516
+ }
2517
+
2518
+ /**
2519
+ * `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
2520
+ *
2521
+ * Decision 1 (Guard B, adopted after the guard-choice escalation): `new:` labels
2522
+ * NEVER merge in-batch, regardless of whether two cluster indices share the same
2523
+ * label string. Each `new:`-labeled cluster is evaluated on its own: if the
2524
+ * cluster's OWN `sessions` count (friction's pre-existing lexical recurrence, not
2525
+ * anything from this batch's grouping) is >=2, it creates its own new ledger entry
2526
+ * directly; if ==1, it is written nowhere. A genuine cross-cluster recurrence of
2527
+ * the same mistake is instead caught on a LATER run, once the first occurrence's
2528
+ * entry exists and its class_hints let the classifier match the next occurrence to
2529
+ * it like any other existing entry -- this is the intentional recall cost of
2530
+ * Guard B (measured in the Decision 1b validation, not tuned around).
2531
+ */
2532
+ /** Default run date used wherever a `runDate` arg is optional (count, migrate-attempts). */
2533
+ function defaultRunDate() {
2534
+ return new Date().toISOString().slice(0, 10);
2535
+ }
2536
+
2537
+ /**
2538
+ * Pure core of `count`: merges classifier labels into the ledger and produces the
2539
+ * count report. Throws on malformed input (bad ledger/clusters/labels shape); does
2540
+ * no IO -- callers own reading/writing files.
2541
+ */
2542
+ function countLedger(ledger, labels, clusters, runDate) {
2543
+ if (!ledger || !Array.isArray(ledger.entries)) throw new Error('countLedger: ledger.entries must be an array');
2544
+ if (!Array.isArray(clusters)) throw new Error('countLedger: clusters must be an array');
2545
+ if (!labels || typeof labels !== 'object') throw new Error('countLedger: labels must be an object');
2546
+
2547
+ const VALID_ID = /^ag-\d+$/;
2548
+ const malformed = [];
2549
+ const agGroups = new Map(); // ag-NNN label -> [cluster indices] (matching still merges)
2550
+ const newClusterIdxs = []; // `new:` clusters -- Guard B: never grouped, each stands alone
2551
+ for (let i = 0; i < clusters.length; i++) {
2552
+ // Label shape: a bare string ("drop"|"ag-NNN"|"new:theme") for drop/ag-NNN, or
2553
+ // {label, rule} for "new:" -- the 4a classifier now emits the one-line rule text
2554
+ // for a brand-new theme in the same judgment (no separate LLM pass). Both shapes
2555
+ // are accepted so pre-existing bare-string labels.json fixtures keep working.
2556
+ const raw = labels[String(i)];
2557
+ const isObjLabel = raw && typeof raw === 'object';
2558
+ const lbl = isObjLabel ? raw.label : raw;
2559
+ const rule = isObjLabel ? raw.rule : undefined;
2560
+ const isNewLabel = typeof lbl === 'string' && lbl.startsWith('new:');
2561
+ const known = lbl === 'drop' || VALID_ID.test(lbl) || isNewLabel;
2562
+ if (!known) { malformed.push({ index: i, label: lbl }); continue; }
2563
+ if (lbl === 'drop') continue;
2564
+ if (isNewLabel) { newClusterIdxs.push({ index: i, label: lbl, rule }); continue; }
2565
+ if (!agGroups.has(lbl)) agGroups.set(lbl, []);
2566
+ agGroups.get(lbl).push(i);
2567
+ }
2568
+
2569
+ const byId = new Map(ledger.entries.map(e => [e.id, e]));
2570
+ const report = { matched: [], newEntries: [], droppedNew1session: [], malformed, badLedgerRef: [] };
2571
+
2572
+ for (const [label, idxs] of agGroups.entries()) {
2573
+ const entry = byId.get(label);
2574
+ if (!entry) { report.badLedgerRef.push({ label, idxs }); continue; }
2575
+ // Defensive: a ledger that predates the session_ids scheme entirely can be missing
2576
+ // the key outright (observed on a real repo, 8een -- every entry lacked it, not just
2577
+ // an empty array). Treat missing the same as an empty array.
2578
+ if (!entry.evidence.session_ids) entry.evidence.session_ids = [];
2579
+ const existing = new Set(entry.evidence.session_ids.map(s => antigenHash(s.id)));
2580
+ const before = entry.evidence.sessions;
2581
+ const statusBefore = entry.status;
2582
+ const wasEmpty = entry.evidence.session_ids.length === 0;
2583
+ const hadMigrationLine = (entry.history || []).some(h => h.event.startsWith('identity migration'));
2584
+ // TRUE first-time migration (remember.md 4c "SEED, DO NOT COUNT"): session_ids empty
2585
+ // going in AND no "identity migration" history line yet at all -- the entry's bare
2586
+ // `sessions` count predates hash tracking entirely. Seed hashes, count nothing, write
2587
+ // ONE "identity migration" line (not per-cluster) even if several clusters match.
2588
+ const isTrueMigration = wasEmpty && !hadMigrationLine;
2589
+ // Migration-fill sub-case: session_ids still empty going in, but an "identity
2590
+ // migration" line ALREADY exists (a prior run migrated with 0 matches) -> first match
2591
+ // after that fills session_ids without counting; counting resumes once non-empty.
2592
+ const isMigrationFill = wasEmpty && hadMigrationLine;
2593
+ const isMigrationRun = isTrueMigration || isMigrationFill;
2594
+
2595
+ let newConversations = 0;
2596
+ const newConvClusterIdxs = [];
2597
+ let recurredWhileHotCount = 0;
2598
+ const gatedOutClusterIdxs = [];
2599
+ const currentAttempt = (entry.attempts || [])[(entry.attempts || []).length - 1];
2600
+ for (const i of idxs) {
2601
+ const clusterHashes = clusters[i].session_ids.map(antigenHash);
2602
+ const isNew = clusterHashes.every(h => !existing.has(h));
2603
+ if (isNew && !isMigrationRun) {
2604
+ newConversations += 1;
2605
+ newConvClusterIdxs.push(i);
2606
+ // Adopted-date gate: a new conversation still counts as evidence (sessions,
2607
+ // hashes) regardless of date, but only counts toward recurred_while_hot if its
2608
+ // OWN session date is on/after the CURRENT attempt's adopted date -- a mistake
2609
+ // that predates the rule's current phrasing isn't a phrasing failure of it.
2610
+ if (entry.status === 'hot' && currentAttempt) {
2611
+ const sessionDate = sessionDateFromId(clusters[i].session_ids[0], runDate);
2612
+ if (sessionDate && sessionDate >= currentAttempt.adopted) {
2613
+ recurredWhileHotCount += 1;
2614
+ } else {
2615
+ gatedOutClusterIdxs.push({ index: i, sessionDate, adopted: currentAttempt.adopted });
2616
+ }
2617
+ }
2618
+ }
2619
+ for (const sid of clusters[i].session_ids) {
2620
+ const h = antigenHash(sid);
2621
+ if (!existing.has(h)) { entry.evidence.session_ids.push({ id: sid, seen: runDate }); existing.add(h); }
2622
+ }
2623
+ }
2624
+ if (isTrueMigration) {
2625
+ entry.history.push({ date: runDate, event: 'identity migration — legacy count grandfathered, growth requires new hashes' });
2626
+ } else if (isMigrationFill) {
2627
+ entry.history.push({ date: runDate, event: `identity migration fill — first matching hash(es) seeded (${idxs.map(i => clusters[i].session_ids.map(antigenHash).join(',')).join(', ')}), count unchanged` });
2628
+ } else if (newConversations > 0) {
2629
+ entry.evidence.sessions += newConversations;
2630
+ entry.evidence.last_seen = runDate;
2631
+ if (entry.status === 'hot' && recurredWhileHotCount > 0) {
2632
+ entry.recurred_while_hot = (entry.recurred_while_hot || 0) + recurredWhileHotCount;
2633
+ }
2634
+ if (gatedOutClusterIdxs.length > 0) {
2635
+ entry.history.push({ date: runDate, event: `${gatedOutClusterIdxs.length} new conversation(s) counted as evidence only, not recurred_while_hot -- session date predates current attempt's adopted date (${gatedOutClusterIdxs.map(g => `${g.sessionDate} < ${g.adopted}`).join(', ')})` });
2636
+ }
2637
+ if (entry.status === 'observing' && entry.evidence.sessions >= 5) {
2638
+ entry.status = 'hot';
2639
+ entry.history.push({ date: runDate, event: `promoted to hot (${entry.evidence.sessions} sessions)` });
2640
+ if (entry.attempts && entry.attempts.length > 0) {
2641
+ entry.attempts[entry.attempts.length - 1].adopted = runDate;
2642
+ }
2643
+ }
2644
+ }
2645
+ report.matched.push({ label, clusters: idxs, before, after: entry.evidence.sessions, newConversations, newConvClusterIdxs, isTrueMigration, isMigrationFill, recurredWhileHotCount, gatedOutClusterIdxs, promoted: statusBefore === 'observing' && entry.status === 'hot' });
2646
+ }
2647
+
2648
+ for (const { index: i, label, rule } of newClusterIdxs) {
2649
+ const cluster = clusters[i];
2650
+ const combinedSessions = cluster.sessions;
2651
+ const hashes = cluster.session_ids.map(antigenHash);
2652
+ if (combinedSessions < 2) { report.droppedNew1session.push({ label, idxs: [i], sessions: combinedSessions }); continue; }
2653
+ // A `new:` cluster that will actually create a ledger entry requires the
2654
+ // classifier-authored rule text -- no placeholder fallback. Missing/empty is
2655
+ // reported as malformed and the entry is NOT created (see friction.cjs BUG fix).
2656
+ if (!rule || typeof rule !== 'string' || rule.trim() === '') {
2657
+ malformed.push({ index: i, label, reason: 'new: label creating an entry (sessions>=2) requires a non-empty rule' });
2658
+ continue;
2659
+ }
2660
+ const status = combinedSessions >= 5 ? 'hot' : 'observing';
2661
+ const id = nextAntigenId(ledger);
2662
+ const newEntry = {
2663
+ id,
2664
+ class: label.slice(4),
2665
+ class_hints: buildClassHints(cluster),
2666
+ status,
2667
+ rule,
2668
+ attempts: [{ n: 1, rule, adopted: runDate, outcome: 'active' }],
2669
+ evidence: {
2670
+ sessions: combinedSessions,
2671
+ session_ids: cluster.session_ids.map(sid => ({ id: sid, seen: runDate })),
2672
+ projects: cluster.projects || [],
2673
+ quotes: (cluster.contexts || []).slice(0, 2),
2674
+ last_seen: runDate,
2675
+ },
2676
+ recurred_while_hot: 0,
2677
+ history: [{ date: runDate, event: `candidate (${combinedSessions} sessions)${status === 'hot' ? ' — born hot' : ''}` }],
2678
+ };
2679
+ ledger.entries.push(newEntry);
2680
+ byId.set(id, newEntry);
2681
+ report.newEntries.push({ label, idxs: [i], sessions: combinedSessions, status, hashes, createdId: id });
2682
+ }
2683
+
2684
+ return { ledger, report };
2685
+ }
2686
+
2687
+ /**
2688
+ * `count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath]`
2689
+ * Thin IO wrapper: reads files, calls countLedger, prints the report, writes/prints
2690
+ * the updated ledger.
2691
+ */
2692
+ function countMain(argv) {
2693
+ const [labelsPath, ledgerPath, clustersPathArg, runDateArg, outLedgerPath, reportPath] = argv;
2694
+ if (!labelsPath || !ledgerPath) {
2695
+ console.log('Usage: node friction.cjs count <labels.json> <ledger.json> [clusters.json] [runDate] [outLedgerPath] [reportPath]');
2696
+ return 1;
2697
+ }
2698
+ const clustersPath = clustersPathArg || './antigen_clusters.json';
2699
+ const runDate = runDateArg || defaultRunDate();
2700
+
2701
+ const labels = JSON.parse(fs.readFileSync(labelsPath, 'utf8'));
2702
+ const clusters = JSON.parse(fs.readFileSync(clustersPath, 'utf8'));
2703
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
2704
+
2705
+ const { ledger: updatedLedger, report } = countLedger(ledger, labels, clusters, runDate);
2706
+
2707
+ console.log(JSON.stringify(report, null, 2));
2708
+ if (reportPath) {
2709
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
2710
+ }
2711
+ if (outLedgerPath) {
2712
+ fs.writeFileSync(outLedgerPath, JSON.stringify(updatedLedger, null, 2));
2713
+ } else {
2714
+ console.log(JSON.stringify(updatedLedger, null, 2));
2715
+ }
2716
+ return 0;
2717
+ }
2718
+
2719
+ /**
2720
+ * `render <ledger.json>` -- prints the MEMORY.md "## Antigens" section from a ledger.
2721
+ * High = hot && sessions>=5 (quotes shown). Medium = observing && sessions 3-4.
2722
+ * Low = observing && sessions==2. expired/rejected/escalated/sessions<2 never render.
2723
+ */
2724
+ function renderTier(entries, quotesShown, noneText) {
2725
+ if (entries.length === 0) return [`- (none — ${noneText})`];
2726
+ return entries.map(e => {
2727
+ const n = e.evidence.sessions;
2728
+ const p = e.evidence.projects ? e.evidence.projects.length : 0;
2729
+ let evidence = `${n} session${n === 1 ? '' : 's'}`;
2730
+ if (quotesShown && p > 0) evidence += `, ${p} project${p === 1 ? '' : 's'}`;
2731
+ if (quotesShown && e.evidence.quotes && e.evidence.quotes.length > 0) {
2732
+ evidence += ' — ' + e.evidence.quotes.slice(0, 2).map(q => `"${q}"`).join(', ');
2733
+ }
2734
+ return `- ${e.rule} (evidence: ${evidence}) — ${e.id}`;
2735
+ });
2736
+ }
2737
+
2738
+ function renderMain(argv) {
2739
+ const [ledgerPath] = argv;
2740
+ if (!ledgerPath) { console.log('Usage: node friction.cjs render <ledger.json>'); return 1; }
2741
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
2742
+ console.log(renderLedgerText(ledger));
2743
+ return 0;
2744
+ }
2745
+
2746
+ /**
2747
+ * I7: does `rule` equal the LAST attempt's rule (array order)? Decision 2 -- resolves
2748
+ * the earlier "which attempt counts as current" ambiguity by dropping the `outcome`
2749
+ * field from the comparison entirely: whichever attempt is last in the array IS the
2750
+ * current phrasing, full stop. Returns the list of mismatching entry ids.
2751
+ */
2752
+ function checkRuleAttempt(ledger) {
2753
+ const bad = [];
2754
+ for (const entry of ledger.entries) {
2755
+ const attempts = entry.attempts || [];
2756
+ if (attempts.length === 0) continue;
2757
+ const last = attempts[attempts.length - 1];
2758
+ if (entry.rule !== last.rule) bad.push(entry.id);
2759
+ }
2760
+ return bad;
2761
+ }
2762
+
2763
+ /** I6-new: is render(ledger) byte-equal to a MEMORY.md's "## Antigens" section? */
2764
+ function checkRenderEquality(ledger, memoryMdText) {
2765
+ const rendered = renderLedgerText(ledger);
2766
+ const start = memoryMdText.indexOf('## Antigens');
2767
+ if (start === -1) return { equal: false, reason: 'no ## Antigens section in MEMORY.md' };
2768
+ let end = memoryMdText.indexOf('\n## ', start + 1);
2769
+ if (end === -1) end = memoryMdText.length;
2770
+ const actual = memoryMdText.slice(start, end).trimEnd();
2771
+ return { equal: actual === rendered, rendered, actual };
2772
+ }
2773
+
2774
+ /** Shared by renderMain and checkRenderEquality so both use the exact same bytes. */
2775
+ function renderLedgerText(ledger) {
2776
+ const high = ledger.entries.filter(e => e.status === 'hot' && e.evidence.sessions >= 5);
2777
+ const medium = ledger.entries.filter(e => e.status === 'observing' && e.evidence.sessions >= 3 && e.evidence.sessions <= 4);
2778
+ const low = ledger.entries.filter(e => e.status === 'observing' && e.evidence.sessions === 2);
2779
+ const lines = [];
2780
+ lines.push('## Antigens');
2781
+ lines.push('### High Confidence (loaded — applies every session)');
2782
+ lines.push(...renderTier(high, true, 'no class currently sits at 5+ sessions'));
2783
+ lines.push('');
2784
+ lines.push('### Medium Confidence (observing — not loaded)');
2785
+ lines.push(...renderTier(medium, false, 'no class currently sits at 3-4 distinct sessions'));
2786
+ lines.push('');
2787
+ lines.push('### Low Confidence (needs more data)');
2788
+ lines.push(...renderTier(low, false, 'no class currently sits at exactly 2 sessions'));
2789
+ return lines.join('\n');
2790
+ }
2791
+
2792
+ /** `check <ledger.json> [memory.md]` -- prints I7 (always) and I6-new (if a MEMORY.md
2793
+ * path is given) results. */
2794
+ function checkMain(argv) {
2795
+ const [ledgerPath, memoryMdPath] = argv;
2796
+ if (!ledgerPath) { console.log('Usage: node friction.cjs check <ledger.json> [memory.md]'); return 1; }
2797
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
2798
+
2799
+ const i7bad = checkRuleAttempt(ledger);
2800
+ console.log(`I7 (rule == last attempt's rule): ${i7bad.length} mismatch(es)${i7bad.length ? ': ' + i7bad.join(', ') : ''}`);
2801
+
2802
+ let i6newBad = false;
2803
+ if (memoryMdPath) {
2804
+ const memText = fs.readFileSync(memoryMdPath, 'utf8');
2805
+ const r = checkRenderEquality(ledger, memText);
2806
+ i6newBad = !r.equal;
2807
+ console.log(`I6-new (render(ledger) byte-equal to MEMORY.md Antigens): ${r.equal ? 'EQUAL' : 'NOT EQUAL' + (r.reason ? ' (' + r.reason + ')' : '')}`);
2808
+ } else {
2809
+ console.log('I6-new: skipped (no MEMORY.md path given)');
2810
+ }
2811
+ return (i7bad.length > 0 || i6newBad) ? 1 : 0;
2812
+ }
2813
+
2814
+ /**
2815
+ * `migrate-attempts <ledger.json> <outPath> [runDate]` -- one-time migration (Decision
2816
+ * 2): for every entry checkRuleAttempt flags, append a new attempt recording the
2817
+ * drifted rule text as the new current attempt, and mark the former-last attempt
2818
+ * "superseded" (was whatever it was before -- typically "active"). Idempotent: an
2819
+ * entry already satisfying I7 is untouched, so a second run is a no-op.
2820
+ */
2821
+ function migrateAttemptsMain(argv) {
2822
+ const [ledgerPath, outPath, runDateArg] = argv;
2823
+ if (!ledgerPath || !outPath) { console.log('Usage: node friction.cjs migrate-attempts <ledger.json> <outPath> [runDate]'); return 1; }
2824
+ const runDate = runDateArg || defaultRunDate();
2825
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
2826
+
2827
+ let migrated = 0;
2828
+ for (const entry of ledger.entries) {
2829
+ const attempts = entry.attempts || [];
2830
+ if (attempts.length === 0) continue;
2831
+ const last = attempts[attempts.length - 1];
2832
+ if (entry.rule === last.rule) continue; // already consistent -- no-op for this entry
2833
+ last.outcome = 'superseded';
2834
+ attempts.push({
2835
+ n: last.n + 1,
2836
+ rule: entry.rule,
2837
+ adopted: runDate,
2838
+ outcome: 'active',
2839
+ note: 'migration: rule text had drifted from attempt log',
2840
+ });
2841
+ migrated++;
2842
+ }
2843
+
2844
+ fs.writeFileSync(outPath, JSON.stringify(ledger, null, 2));
2845
+ console.log(`migrate-attempts: ${migrated} entr${migrated === 1 ? 'y' : 'ies'} migrated, wrote ${outPath}`);
2846
+ return 0;
2847
+ }
2848
+
2460
2849
  // =============================================================================
2461
2850
  // PIPELINE ENTRY POINT
2462
2851
  // =============================================================================
@@ -2494,7 +2883,8 @@ Outputs (all in .opencode/remember/friction/):
2494
2883
 
2495
2884
  // Step 1: Analyze sessions
2496
2885
  console.log('\n[1/2] Analyzing sessions...\n');
2497
- analyzeMain(sessionsDir);
2886
+ const rc = analyzeMain(sessionsDir);
2887
+ if (rc === 2) return rc; // no sessions found -- stop before extractMain touches antigen_clusters.json
2498
2888
 
2499
2889
  // Check if analysis produced output
2500
2890
  const analysisFile = '.opencode/remember/friction/friction_analysis.json';
@@ -2524,4 +2914,14 @@ Outputs (all in .opencode/remember/friction/):
2524
2914
  return 0;
2525
2915
  }
2526
2916
 
2527
- process.exit(main());
2917
+ if (process.argv[2] === 'count') {
2918
+ process.exit(countMain(process.argv.slice(3)));
2919
+ } else if (process.argv[2] === 'render') {
2920
+ process.exit(renderMain(process.argv.slice(3)));
2921
+ } else if (process.argv[2] === 'check') {
2922
+ process.exit(checkMain(process.argv.slice(3)));
2923
+ } else if (process.argv[2] === 'migrate-attempts') {
2924
+ process.exit(migrateAttemptsMain(process.argv.slice(3)));
2925
+ } else {
2926
+ process.exit(main());
2927
+ }