liteagents 2.17.1 → 2.19.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.
@@ -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 .amp/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 = '.amp/remember/friction/friction_analysis.json';
@@ -2524,4 +2914,14 @@ Outputs (all in .amp/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
+ }