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.
- package/CHANGELOG.md +119 -0
- package/package.json +1 -1
- package/packages/ampcode/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/ampcode/commands/docs-builder.md +43 -20
- package/packages/ampcode/commands/remember/AGENT_RULES.md +1 -1
- package/packages/ampcode/commands/remember/friction.cjs +407 -7
- package/packages/ampcode/commands/remember.md +210 -149
- package/packages/claude/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/claude/commands/docs-builder.md +43 -20
- package/packages/claude/commands/remember/friction.cjs +407 -7
- package/packages/claude/commands/remember.md +210 -149
- package/packages/droid/commands/docs-builder/docs-builder.cjs +25 -16
- package/packages/droid/commands/docs-builder.md +43 -20
- package/packages/droid/commands/remember/AGENT_RULES.md +1 -1
- package/packages/droid/commands/remember/friction.cjs +407 -7
- package/packages/droid/commands/remember.md +210 -149
- package/packages/opencode/command/docs-builder/docs-builder.cjs +25 -16
- package/packages/opencode/command/docs-builder.md +43 -20
- package/packages/opencode/command/remember/AGENT_RULES.md +1 -1
- package/packages/opencode/command/remember/friction.cjs +407 -7
- package/packages/opencode/command/remember.md +210 -149
|
@@ -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/
|
|
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.
|
|
86
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
node
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
816
|
-
node
|
|
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](#
|
|
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
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
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.
|
|
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
|
+
}
|