liteagents 2.15.2 → 2.17.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/README.md +8 -7
  3. package/package.json +2 -2
  4. package/packages/ampcode/AGENT.md +2 -2
  5. package/packages/ampcode/agents/quality-assurance.md +1 -1
  6. package/packages/ampcode/commands/docs-builder/docs-builder.cjs +2382 -0
  7. package/packages/ampcode/commands/docs-builder.md +787 -223
  8. package/packages/ampcode/commands/remember/AGENT_RULES.md +13 -43
  9. package/packages/ampcode/commands/remember/{friction.js → friction.cjs} +211 -99
  10. package/packages/ampcode/commands/remember.md +208 -29
  11. package/packages/claude/CLAUDE.md +3 -3
  12. package/packages/claude/commands/docs-builder/docs-builder.cjs +2382 -0
  13. package/packages/claude/commands/docs-builder.md +873 -0
  14. package/packages/claude/commands/remember/AGENT_RULES.md +10 -40
  15. package/packages/claude/commands/remember/{friction.js → friction.cjs} +208 -96
  16. package/packages/claude/commands/remember.md +208 -29
  17. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +9 -9
  18. package/packages/droid/AGENTS.md +2 -2
  19. package/packages/droid/commands/docs-builder/docs-builder.cjs +2382 -0
  20. package/packages/droid/commands/docs-builder.md +787 -223
  21. package/packages/droid/commands/remember/AGENT_RULES.md +13 -43
  22. package/packages/droid/commands/remember/{friction.js → friction.cjs} +211 -99
  23. package/packages/droid/commands/remember.md +208 -29
  24. package/packages/droid/droids/quality-assurance.md +1 -1
  25. package/packages/opencode/AGENTS.md +2 -2
  26. package/packages/opencode/agent/quality-assurance.md +1 -1
  27. package/packages/opencode/command/docs-builder/docs-builder.cjs +2382 -0
  28. package/packages/opencode/command/docs-builder.md +787 -223
  29. package/packages/opencode/command/remember/AGENT_RULES.md +13 -43
  30. package/packages/opencode/command/remember/{friction.js → friction.cjs} +211 -99
  31. package/packages/opencode/command/remember.md +208 -29
  32. package/packages/opencode/opencode.jsonc +2 -6
  33. package/packages/subagentic-manual.md +31 -32
  34. package/packages/ampcode/commands/docs-builder/templates.md +0 -601
  35. package/packages/claude/skills/docs-builder/SKILL.md +0 -309
  36. package/packages/claude/skills/docs-builder/references/templates.md +0 -601
  37. package/packages/droid/commands/docs-builder/templates.md +0 -601
  38. package/packages/opencode/command/docs-builder/templates.md +0 -601
@@ -6,12 +6,11 @@
6
6
  3. [Development Standards](#development-standards)
7
7
  4. [Testing Standards](#testing-standards)
8
8
  5. [Security & Robustness Invariants](#security--robustness-invariants)
9
- 6. [Guardrails (Enforced, Not Requested)](#guardrails-enforced-not-requested)
10
- 7. [Environment](#environment)
11
- 8. [Development Workflow](#development-workflow)
12
- 9. [Twelve-Factor Checklist](#twelve-factor-checklist)
13
- 10. [CLAUDE.md Stub](#claudemd-stub)
14
- 11. [AI Agent Instructions](#ai-agent-instructions)
9
+ 6. [Environment](#environment)
10
+ 7. [Development Workflow](#development-workflow)
11
+ 8. [Twelve-Factor Checklist](#twelve-factor-checklist)
12
+ 9. [CLAUDE.md Stub](#claudemd-stub)
13
+ 10. [AI Agent Instructions](#ai-agent-instructions)
15
14
 
16
15
  ---
17
16
 
@@ -21,7 +20,7 @@ Every task runs through three layers. Do not skip ahead to code.
21
20
 
22
21
  1. **Spec — agree on intent before touching anything.** Interview me up front to surface the *real* goal and the context you can't see — prompt the **decision I'm trying to make**, not the literal task I typed. Break the scope into small buckets with checkpoints. **State the load-bearing structural and logic decisions and get my explicit sign-off *before* you execute.** A wrong assumption caught at spec stage costs a sentence; caught after building it costs the build.
23
22
  2. **Verify — define "good" up front, then prove it.** Write down what success looks like *before* changing code. Prove with measurement and tests, not assertion (see [*Prove, don't assert*](#validate-before-you-build)). Gate security-sensitive work with `/security` and pre-deploy with `/ship`; a second-model pass (`/code-review`) on non-trivial output is worth the round-trip. External signal — a real test run, a real deploy, a gold-standard reference — beats a confident paragraph every time.
24
- 3. **Environment — the guardrails are enforced, not requested.** This file is the standing context that primes every session. Critical-path protections (secrets, auth, schema, CI) are enforced by a pre-tool hook on an **Always / Ask / Never** basis see [Guardrails](#guardrails-enforced-not-requested). Where the hook isn't wired, the same rules still bind you.
23
+ 3. **Environment — the standing context.** This file primes every session. Critical-path protections (secrets, auth, schema, CI) are stated as **Always / Ask / Never** below and bind you as written. Where your tool offers a permission allow/ask/deny list, mirror them there so they are enforced and not merely requested.
25
24
 
26
25
  > The model is brilliant at execution and blind to intent. You can outsource the typing; you cannot outsource the understanding. Surface assumptions — don't bury them.
27
26
 
@@ -34,7 +33,7 @@ Every task runs through three layers. Do not skip ahead to code.
34
33
  - **Checkpoint before executing**: State the load-bearing structural and logic decisions and get my explicit sign-off *before* you write code. Never run ahead on an unverified assumption — flag it and stop
35
34
  - **Fact-Based**: Base all recommendations on verified, current information. Prefer external signal (a real run, a real source) over a confident guess
36
35
  - **Simplicity Advocate**: Call out overcomplications and suggest simpler alternatives
37
- - **Safety First**: Never modify critical systems without explicit understanding and approval. Where the [guardrail hook](#guardrails-enforced-not-requested) is wired, this is enforced before the tool runs, not after
36
+ - **Safety First**: Never modify critical systems without explicit understanding and approval
38
37
 
39
38
  ### User Profile
40
39
  - **Technical Level**: Non-coder but technically savvy
@@ -45,7 +44,7 @@ Every task runs through three layers. Do not skip ahead to code.
45
44
 
46
45
  ### Required Safeguards (Always / Ask / Never)
47
46
 
48
- Not courtesies where the [guardrail hook](#guardrails-enforced-not-requested) is wired these are enforced *before* the tool runs. When it isn't, they still bind you.
47
+ Not courtesies. These bind you as written, whether or not your tool enforces them.
49
48
 
50
49
  - **Always** identify affected files before making changes, and explain what will change and why
51
50
  - **Ask first** — stop and get explicit sign-off — before modifying authentication systems, database schema or migrations, CI workflows, or `.claude/settings.json`
@@ -220,35 +219,6 @@ Also hold the line on: input validation at every trust boundary (untrusted uploa
220
219
 
221
220
  ---
222
221
 
223
- ## Guardrails (Enforced, Not Requested)
224
-
225
- A prompt rule is a request the model can rationalise past. For anything that actually matters — secrets, auth, schema — don't rely on soft instruction. Enforce it with a **pre-tool hook** that intercepts the call *before* it runs and decides on an **Always / Ask / Never** basis:
226
-
227
- - **Never** — writing `.env`/`*.env`, keys, or credential files is blocked outright (secrets load from the environment, never the tree). Destructive shell (`rm -rf` of a root-ish target, redirecting into a secret) is blocked too.
228
- - **Ask** — touching auth, DB schema/migrations, CI workflows, or `.claude/settings.json` forces a human confirmation. Same for force-push / push to a default branch.
229
- - **Always / allow** — everything else proceeds through the normal permission flow; the hook stays out of the way.
230
-
231
- The reference implementation ships in this repo at [`.claude/hooks/guardrails.py`](.claude/hooks/guardrails.py) — stdlib only, no deps, fails open on a malformed event so it can never wedge the agent. The Never/Ask lists are constants at the top; **tune them per project**. To wire it up, add to the project's `.claude/settings.json`:
232
-
233
- ```json
234
- {
235
- "hooks": {
236
- "PreToolUse": [
237
- {
238
- "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
239
- "hooks": [
240
- { "type": "command", "command": "python3 .claude/hooks/guardrails.py" }
241
- ]
242
- }
243
- ]
244
- }
245
- }
246
- ```
247
-
248
- The hook is the hard line; the prose rules above are why it exists. Keep them in sync — when you tighten one, tighten the other.
249
-
250
- ---
251
-
252
222
  ## Environment
253
223
 
254
224
  - **OS**: Fedora Linux (use `dnf` for packages, `systemctl` for services)
@@ -311,7 +281,7 @@ Copy this to any project's CLAUDE.md. These are mandatory rules, not suggestions
311
281
 
312
282
  **Responsive web UI is mandatory.** Any web UI must work on mobile by default — fluid layouts, viewport meta, breakpoints, no horizontal scroll. Verify in DevTools device emulation before claiming a UI task is done. POCs exempt; real projects are not.
313
283
 
314
- For full development and testing standards, see `.claude/memory/AGENT_RULES.md`.
284
+ For full development and testing standards, see `.claude/remember/AGENT_RULES.md`.
315
285
  ```
316
286
 
317
287
  ---
@@ -325,5 +295,5 @@ When working with this user:
325
295
  4. **Explain the "why"** behind technical recommendations
326
296
  5. **Flag potential issues** before they become problems — name the assumption, don't bury it
327
297
  6. **Suggest simpler alternatives** when appropriate
328
- 7. **Ask first** before touching auth, DB schema/migrations, CI, or settings; **never** commit secrets — enforced by the [guardrail hook](#guardrails-enforced-not-requested) where wired
298
+ 7. **Ask first** before touching auth, DB schema/migrations, CI, or settings; **never** commit secrets
329
299
  8. **Always identify** which files will be affected by changes
@@ -3,8 +3,13 @@
3
3
  * Friction analysis pipeline - analyze sessions and extract antigens.
4
4
  *
5
5
  * Usage:
6
- * node friction.js <sessions-directory>
7
- * node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
6
+ * node friction.cjs <sessions-directory>
7
+ * node friction.cjs ~/.claude/projects/<encoded-project-dir>/
8
+ *
9
+ * Extension is `.cjs`, not `.js`, ON PURPOSE. Installed project-locally into a repo whose
10
+ * package.json declares "type": "module", a `.js` file loads as an ES module and every
11
+ * `require` below throws before the first line of work. `.cjs` pins CommonJS regardless of
12
+ * the host project. Found via docs-builder.cjs hitting the identical bug on a real repo.
8
13
  *
9
14
  * Outputs (all in .claude/remember/friction/):
10
15
  * friction_analysis.json - Per-session analysis
@@ -19,6 +24,7 @@
19
24
 
20
25
  const fs = require('fs');
21
26
  const path = require('path');
27
+ const os = require('os');
22
28
 
23
29
  // =============================================================================
24
30
  // EMBEDDED CONFIG (from friction_config.json)
@@ -73,6 +79,13 @@ const CONFIG = {
73
79
  },
74
80
  };
75
81
 
82
+ // Genuine USER REACTIONS that a friction antigen may anchor on. tool_loop /
83
+ // repeated_question are agent-behavior signals (no user text to cluster, and
84
+ // repeated_question over-fires on tool output) — they corroborate severity,
85
+ // never seed. Shared by analyzeBadSession (which candidate to build) and
86
+ // extractMain (which sessions are even worth reading for the dedup pass).
87
+ const ANCHOR_SIGNALS = ['user_correction', 'user_curse', 'interrupt_cascade'];
88
+
76
89
  // =============================================================================
77
90
  // UTILITY FUNCTIONS
78
91
  // =============================================================================
@@ -99,15 +112,6 @@ function parseJsonl(raw, source) {
99
112
  return records;
100
113
  }
101
114
 
102
- function parseISODate(s) {
103
- if (!s) return null;
104
- try {
105
- return new Date(s.replace('Z', '+00:00'));
106
- } catch {
107
- return null;
108
- }
109
- }
110
-
111
115
  function formatDuration(minutes) {
112
116
  if (minutes < 60) return `${minutes}m`;
113
117
  const hours = Math.floor(minutes / 60);
@@ -152,11 +156,14 @@ function deriveSessionName(sessionFile, metadata) {
152
156
 
153
157
  let project;
154
158
  if (parent.startsWith('-')) {
159
+ // Detected at runtime — never hardcode a username or a personal folder.
160
+ // Claude Code encodes a project dir by replacing path separators with '-',
161
+ // so the current user's home encodes the same way (/home/ana -> -home-ana).
162
+ const homeEnc = os.homedir().replace(/[\\/]/g, '-');
155
163
  const prefixes = [
156
- '-home-hamr-PycharmProjects-',
157
- '-home-hamr-Documents-PycharmProjects-',
158
- '-home-hamr-',
164
+ homeEnc + '-',
159
165
  '-home-',
166
+ '-Users-',
160
167
  '-',
161
168
  ];
162
169
  let found = false;
@@ -249,7 +256,7 @@ function looksLikeTerminalPaste(text) {
249
256
  function extractSignals(sessionFile) {
250
257
  const signals = [];
251
258
  let llmClaimedSuccess = false;
252
- const toolHistory = [];
259
+ const toolHistoryCounts = new Map();
253
260
  const metadata = {};
254
261
 
255
262
  const raw = fs.readFileSync(sessionFile, 'utf-8');
@@ -445,6 +452,15 @@ function extractSignals(sessionFile) {
445
452
 
446
453
  // User messages (GOLD)
447
454
  if (typeof content === 'string') {
455
+ // Harness-injected notifications ride in as user-role turns but are
456
+ // machine text, not user text — skip signal detection entirely so a
457
+ // notification's boilerplate prose can't be mistaken for a curse or
458
+ // correction aimed at the agent.
459
+ const trimmedContent = content.trim();
460
+ if (trimmedContent.startsWith('<task-notification>') || trimmedContent.startsWith('[SYSTEM NOTIFICATION')) {
461
+ continue;
462
+ }
463
+
448
464
  if (content.toLowerCase().includes('/stash')) {
449
465
  signals.push({
450
466
  ts,
@@ -516,12 +532,8 @@ function extractSignals(sessionFile) {
516
532
  if (block.type === 'tool_use') {
517
533
  const toolName = block.name;
518
534
  const sig = JSON.stringify([toolName, JSON.stringify(block.input || {})]);
519
- toolHistory.push(sig);
520
-
521
- let count = 0;
522
- for (const h of toolHistory) {
523
- if (h === sig) count++;
524
- }
535
+ toolHistoryCounts.set(sig, (toolHistoryCounts.get(sig) || 0) + 1);
536
+ const count = toolHistoryCounts.get(sig);
525
537
  if (count >= 3) {
526
538
  signals.push({
527
539
  ts,
@@ -935,7 +947,7 @@ function aggregateSessions(analyses, config) {
935
947
  const totalObjective = ((aggregateBySource.tool || {}).total_friction || 0) +
936
948
  ((aggregateBySource.user || {}).total_friction || 0);
937
949
  const totalLlm = (aggregateBySource.llm || {}).total_friction || 1;
938
- const snr = totalLlm !== 0 ? Math.abs(totalObjective / totalLlm) : 0;
950
+ const snr = Math.abs(totalObjective / totalLlm);
939
951
 
940
952
  // Verdict
941
953
  const thresholds = config.thresholds;
@@ -970,8 +982,7 @@ function aggregateSessions(analyses, config) {
970
982
  for (const [source, data] of Object.entries(aggregateBySource)) {
971
983
  const sessionsCount = data.sessions_with_signals;
972
984
  // Sort top_signals by count descending (like Python's Counter.most_common)
973
- const sortedSignals = Object.entries(data.top_signals)
974
- .sort((a, b) => b[1] - a[1]);
985
+ const sortedSignals = sortedEntries(data.top_signals);
975
986
  const topSignals = {};
976
987
  for (const [k, v] of sortedSignals) topSignals[k] = v;
977
988
 
@@ -1111,52 +1122,6 @@ function aggregateSessions(analyses, config) {
1111
1122
  };
1112
1123
  }
1113
1124
 
1114
- // =============================================================================
1115
- // FRICTION ANALYZE - print helpers
1116
- // =============================================================================
1117
-
1118
- function printBox(title, lines, width) {
1119
- width = width || 60;
1120
- const hr = '\u2500'.repeat(width - 2);
1121
- console.log(`\u250C${hr}\u2510`);
1122
- console.log(`\u2502 ${title.toUpperCase().padEnd(width - 4)} \u2502`);
1123
- console.log(`\u251C${hr}\u2524`);
1124
- for (let line of lines) {
1125
- if (line.length > width - 4) line = line.slice(0, width - 7) + '...';
1126
- console.log(`\u2502 ${line.padEnd(width - 4)} \u2502`);
1127
- }
1128
- console.log(`\u2514${hr}\u2518`);
1129
- }
1130
-
1131
- function printTable(headers, rows, colWidths) {
1132
- if (!colWidths) {
1133
- colWidths = headers.map((h, i) => {
1134
- let max = String(h).length;
1135
- for (const row of rows) {
1136
- const len = String(row[i]).length;
1137
- if (len > max) max = len;
1138
- }
1139
- return max + 2;
1140
- });
1141
- }
1142
-
1143
- const topBorder = '\u250C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u252C') + '\u2510';
1144
- const headerLine = '\u2502' + headers.map((h, i) => ` ${String(h).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
1145
- const sep = '\u251C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u253C') + '\u2524';
1146
-
1147
- console.log(topBorder);
1148
- console.log(headerLine);
1149
- console.log(sep);
1150
-
1151
- for (const row of rows) {
1152
- const rowLine = '\u2502' + row.map((v, i) => ` ${String(v).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
1153
- console.log(rowLine);
1154
- }
1155
-
1156
- const bottomBorder = '\u2514' + colWidths.map(w => '\u2500'.repeat(w)).join('\u2534') + '\u2518';
1157
- console.log(bottomBorder);
1158
- }
1159
-
1160
1125
  // =============================================================================
1161
1126
  // FRICTION ANALYZE - generate_detailed_report
1162
1127
  // =============================================================================
@@ -1707,9 +1672,13 @@ function findSessionFile(sessionsDir, sessionId) {
1707
1672
  // ANTIGEN EXTRACT - extract helpers
1708
1673
  // =============================================================================
1709
1674
 
1710
- function extractContextWindow(sessionFile, anchorTs, windowSize) {
1711
- windowSize = windowSize || 5;
1712
-
1675
+ /**
1676
+ * Read and parse a session file once into its user/assistant turns.
1677
+ * Callers that need multiple context windows from the same session (one per
1678
+ * anchor signal) should build this once and reuse it, instead of re-reading
1679
+ * and re-parsing the whole file per anchor.
1680
+ */
1681
+ function buildTurns(sessionFile) {
1713
1682
  const raw = fs.readFileSync(sessionFile, 'utf-8');
1714
1683
  const events = parseJsonl(raw, sessionFile);
1715
1684
 
@@ -1720,6 +1689,11 @@ function extractContextWindow(sessionFile, anchorTs, windowSize) {
1720
1689
  turns.push({ ts, type: event.type, event });
1721
1690
  }
1722
1691
  }
1692
+ return turns;
1693
+ }
1694
+
1695
+ function extractContextWindow(turns, anchorTs, windowSize) {
1696
+ windowSize = windowSize || 5;
1723
1697
 
1724
1698
  // Find anchor position
1725
1699
  let anchorIdx = null;
@@ -1852,6 +1826,8 @@ function extractUserMessage(event) {
1852
1826
  if (trimmed.startsWith('<command-name>')) return '';
1853
1827
  if (trimmed.startsWith('<system-reminder>')) return '';
1854
1828
  if (trimmed.startsWith('<local-command-stdout>')) return '';
1829
+ if (trimmed.startsWith('<task-notification>')) return '';
1830
+ if (trimmed.startsWith('[SYSTEM NOTIFICATION')) return '';
1855
1831
 
1856
1832
  return text.slice(0, 500);
1857
1833
  }
@@ -1866,25 +1842,21 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1866
1842
  // NEW: anchor antigens only on OBSERVED user-reaction signals. Inferred
1867
1843
  // proxies (false_success/session_abandoned/user_intervention) never seed —
1868
1844
  // they only color severity. No fallback: a session with no observed reaction
1869
- // produces no candidate (silence is not an antigen).
1870
- // Seed only on genuine USER REACTIONS. tool_loop / repeated_question are
1871
- // agent-behavior signals (no user text to cluster, and repeated_question
1872
- // over-fires on tool output) — they corroborate severity, never seed.
1873
- const anchorSignals = [
1874
- 'user_correction',
1875
- 'user_curse',
1876
- 'interrupt_cascade',
1877
- ];
1878
-
1879
- const anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
1845
+ // produces no candidate (silence is not an antigen). See ANCHOR_SIGNALS.
1846
+ const anchors = signals.filter(s => s.session === sessionId && ANCHOR_SIGNALS.includes(s.signal));
1880
1847
 
1881
1848
  const candidates = [];
1849
+ if (anchors.length === 0) return candidates;
1850
+
1851
+ // Parse the session file once and reuse it for every anchor's context
1852
+ // window, instead of re-reading and re-parsing the whole file per anchor.
1853
+ const turns = buildTurns(sessionFile);
1882
1854
 
1883
1855
  for (const anchor of anchors) {
1884
1856
  const anchorTs = anchor.ts || '';
1885
1857
  const anchorSignal = anchor.signal || 'unknown';
1886
1858
 
1887
- const window = extractContextWindow(sessionFile, anchorTs, 5);
1859
+ const window = extractContextWindow(turns, anchorTs, 5);
1888
1860
  if (window.length === 0) continue;
1889
1861
 
1890
1862
  const allFiles = new Set();
@@ -1975,11 +1947,93 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1975
1947
  return candidates;
1976
1948
  }
1977
1949
 
1950
+ // =============================================================================
1951
+ // ANTIGEN EXTRACT - session dedup (forks/resumes of the same conversation)
1952
+ // =============================================================================
1953
+
1954
+ /**
1955
+ * One conversation can exist as several session files (forks/resumes), each
1956
+ * with its own filename/uuid — friction identifies a session by the first 8
1957
+ * chars of the filename, so without this, one user reaction gets counted as
1958
+ * N distinct sessions, which can falsely trip the recurrence gate that
1959
+ * promotes a cluster to an antigen. Sessions that share >= 1 message uuid
1960
+ * are the SAME conversation; collapse them to one canonical session id
1961
+ * (the lexicographically smallest id in the group) before clustering.
1962
+ *
1963
+ * Returns { canonicalOf, groups }: canonicalOf maps every input id to its
1964
+ * group's canonical id; groups maps that canonical id to EVERY member id in
1965
+ * its group (used to emit the full session_ids set on a cluster, since the
1966
+ * canonical pick itself is only a stable grouping key, not guaranteed to be
1967
+ * the same file across runs when its date prefix falls back to mtime).
1968
+ */
1969
+ function computeCanonicalSessionIds(sessionIds, fileFor) {
1970
+ const parent = new Map();
1971
+ for (const id of sessionIds) parent.set(id, id);
1972
+ const find = x => {
1973
+ while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); }
1974
+ return x;
1975
+ };
1976
+ const union = (a, b) => {
1977
+ const ra = find(a), rb = find(b);
1978
+ if (ra !== rb) parent.set(ra, rb);
1979
+ };
1980
+
1981
+ // A real uuid is a long, per-message-unique value. Guard against a
1982
+ // degenerate/constant emitter (e.g. a tool whose logs always write "" or a
1983
+ // fixed placeholder) unioning the whole corpus into one session: too-short
1984
+ // uuids never seed a union, and a uuid shared across an implausible number
1985
+ // of distinct sessions (a genuine fork/resume chain is a handful of files,
1986
+ // not a dozen+) is treated as noise rather than real shared history.
1987
+ const MIN_UUID_LEN = 8;
1988
+ const MAX_SESSIONS_PER_UUID = 12;
1989
+
1990
+ const uuidToSessions = new Map();
1991
+ for (const id of sessionIds) {
1992
+ const file = fileFor.get(id);
1993
+ if (!file) continue;
1994
+ let events;
1995
+ try {
1996
+ events = parseJsonl(fs.readFileSync(file, 'utf-8'), file);
1997
+ } catch {
1998
+ continue;
1999
+ }
2000
+ for (const e of events) {
2001
+ if (typeof e.uuid !== 'string' || e.uuid.length < MIN_UUID_LEN) continue;
2002
+ if (!uuidToSessions.has(e.uuid)) uuidToSessions.set(e.uuid, []);
2003
+ uuidToSessions.get(e.uuid).push(id);
2004
+ }
2005
+ }
2006
+ for (const ids of uuidToSessions.values()) {
2007
+ const distinctIds = [...new Set(ids)];
2008
+ if (distinctIds.length > MAX_SESSIONS_PER_UUID) {
2009
+ console.error(`warn: uuid shared by ${distinctIds.length} sessions (> ${MAX_SESSIONS_PER_UUID}) — treated as noise, not unioned`);
2010
+ continue;
2011
+ }
2012
+ for (let i = 1; i < distinctIds.length; i++) union(distinctIds[0], distinctIds[i]);
2013
+ }
2014
+
2015
+ const groups = new Map(); // root -> [ids]
2016
+ for (const id of sessionIds) {
2017
+ const root = find(id);
2018
+ if (!groups.has(root)) groups.set(root, []);
2019
+ groups.get(root).push(id);
2020
+ }
2021
+ const canonicalOf = new Map();
2022
+ const groupsByCanonical = new Map();
2023
+ for (const ids of groups.values()) {
2024
+ const canonical = ids.slice().sort()[0];
2025
+ for (const id of ids) canonicalOf.set(id, canonical);
2026
+ groupsByCanonical.set(canonical, ids);
2027
+ }
2028
+ return { canonicalOf, groups: groupsByCanonical };
2029
+ }
2030
+
1978
2031
  // =============================================================================
1979
2032
  // ANTIGEN EXTRACT - clusterCandidates
1980
2033
  // =============================================================================
1981
2034
 
1982
- function clusterCandidates(allCandidates) {
2035
+ function clusterCandidates(allCandidates, canonicalGroups) {
2036
+ canonicalGroups = canonicalGroups || new Map();
1983
2037
  // NEW: cluster by CONTENT (keyword overlap of what the user actually said),
1984
2038
  // not by (anchor_signal, tool_pattern). Inferred signals were already barred
1985
2039
  // from seeding upstream; here they survive only as corroborating "errors"
@@ -1989,7 +2043,10 @@ function clusterCandidates(allCandidates) {
1989
2043
  // Ubiquitous path/file tokens that carry no topical meaning — if we cluster on
1990
2044
  // these we re-create OLD's over-merge (everything touches README/package.json).
1991
2045
  const PATH_STOP = new Set([
1992
- 'home', 'hamr', 'documents', 'pycharmprojects', 'projects', 'claude', 'stash',
2046
+ // the current user's login name is a path token everywhere and carries no
2047
+ // topical meaning — detected at runtime, never hardcoded
2048
+ (os.userInfo().username || '').toLowerCase(),
2049
+ 'home', 'users', 'documents', 'pycharmprojects', 'projects', 'claude', 'stash',
1993
2050
  'memory', 'commands', 'command', 'skills', 'skill', 'src', 'lib', 'app', 'dist',
1994
2051
  'build', 'node_modules', 'public', 'assets', 'utils', 'util', 'config', 'scripts',
1995
2052
  'readme', 'package', 'index', 'main', 'test', 'tests', 'spec', 'lock',
@@ -2127,11 +2184,18 @@ function clusterCandidates(allCandidates) {
2127
2184
  const peaks = cl.peaks.slice().sort((a, b) => a - b);
2128
2185
  const topSh = [...cl.shCount.entries()]
2129
2186
  .sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([k]) => k);
2130
- const sessionIds = Object.keys(cl.sessions);
2187
+ // Canonical ids are the dedup/grouping key (a session can be pinned to a
2188
+ // different-but-equivalent group member across runs if its date prefix
2189
+ // falls back to file mtime) — emit every raw member id per canonical id
2190
+ // so a downstream matcher (e.g. /remember's ledger) can hit on ANY of
2191
+ // them, while `sessions`/`nSessions` below stays the deduped conversation
2192
+ // count (canonical ids), not the expanded file count.
2193
+ const canonicalIds = Object.keys(cl.sessions);
2194
+ const sessionIds = canonicalIds.flatMap(id => canonicalGroups.get(id) || [id]);
2131
2195
  const projects = [...new Set(
2132
2196
  sessionIds.map(s => s.includes('/') ? s.split('/')[0] : 'unknown')
2133
2197
  )].sort();
2134
- const nSessions = sessionIds.length;
2198
+ const nSessions = canonicalIds.length;
2135
2199
  const signalNames = Object.keys(cl.signals);
2136
2200
  const dominant = sortedEntries(cl.signals)[0] ? sortedEntries(cl.signals)[0][0] : 'unknown';
2137
2201
 
@@ -2140,10 +2204,17 @@ function clusterCandidates(allCandidates) {
2140
2204
  // corroboration (errors) also escalates. #3: judge self-correction from the
2141
2205
  // MATCHED quotes — a cluster whose grouping phrase is "wrong project" etc. is
2142
2206
  // the user redirecting themselves, not an antigen → not severe.
2207
+ // A 0-context cluster (no real user text survived, e.g. a terse-text
2208
+ // session whose sig came only from the file-referent fallback below) has
2209
+ // nothing to test against SELF_RE, so it must NOT auto-qualify as severe
2210
+ // via user_correction alone — that would bypass the self-suspect filter
2211
+ // by having nothing to filter. curse/interrupt_cascade (observed reaction
2212
+ // signals) and machine-corroborating errors still count without context.
2143
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;
2144
- const allSelf = cl.contexts.length > 0 && cl.contexts.every(q => SELF_RE.test(q || ''));
2214
+ const hasContext = cl.contexts.length > 0;
2215
+ const allSelf = hasContext && cl.contexts.every(q => SELF_RE.test(q || ''));
2145
2216
  const severe = signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
2146
- || (signalNames.includes('user_correction') && !allSelf)
2217
+ || (hasContext && signalNames.includes('user_correction') && !allSelf)
2147
2218
  || cl.errors.length > 0;
2148
2219
  const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
2149
2220
  let artifact;
@@ -2182,6 +2253,14 @@ function clusterCandidates(allCandidates) {
2182
2253
  // Final tiebreak on median peak friction — graded intensity discriminates
2183
2254
  // among clusters that tie on tier and recurrence. Ranking only: it reorders
2184
2255
  // within what recurrence already gated, never promotes across the 2x2.
2256
+ //
2257
+ // A cluster with no real user text is handled above by the `hasContext`
2258
+ // gate on severity (never auto-severe via user_correction alone), not by a
2259
+ // hard drop here — a 0-context cluster with genuine machine corroboration
2260
+ // (errors) or an observed curse/interrupt can still surface, and a
2261
+ // recurring 0-context cluster (file-referent fallback matched across
2262
+ // sessions) can still be surfaced as a 'fact'. A one-off, mild, 0-context
2263
+ // cluster still lands on 'drop' via the severity/recurrence grid itself.
2185
2264
  const kept = out.filter(c => c.suggested_artifact !== 'drop');
2186
2265
  kept.sort((a, b) =>
2187
2266
  b.score - a.score
@@ -2237,9 +2316,36 @@ function extractMain(sessionsDir) {
2237
2316
  ((b.friction_summary || {}).peak || 0) - ((a.friction_summary || {}).peak || 0)
2238
2317
  );
2239
2318
 
2319
+ // Locate each session's physical file once, then dedup sessions that are
2320
+ // really the same conversation (shared message uuids) before clustering.
2321
+ const fileFor = new Map();
2322
+ for (const analysis of sortedBad) {
2323
+ const f = findSessionFile(sessionsDir, analysis.session_id);
2324
+ if (f) fileFor.set(analysis.session_id, f);
2325
+ }
2326
+ // The dedup pass (computeCanonicalSessionIds) re-reads and re-parses every
2327
+ // candidate file's full JSONL to build its uuid graph — the expensive part
2328
+ // of a run. A session with no anchor signal never produces a candidate
2329
+ // anyway (see analyzeBadSession/ANCHOR_SIGNALS), so restricting the dedup
2330
+ // pass to only anchor-bearing sessions is behaviour-identical while
2331
+ // skipping every file that could never contribute to a cluster.
2332
+ const anchorSessionIds = new Set(
2333
+ signals.filter(s => ANCHOR_SIGNALS.includes(s.signal)).map(s => s.session)
2334
+ );
2335
+ const { canonicalOf, groups } = computeCanonicalSessionIds(
2336
+ sortedBad.map(a => a.session_id).filter(id => anchorSessionIds.has(id)),
2337
+ fileFor
2338
+ );
2339
+
2340
+ // Two files of the same conversation (a fork/resume) share identical
2341
+ // message uuids and therefore identical event timestamps for any reaction
2342
+ // that predates the fork — after remapping to the canonical session id,
2343
+ // dedupe on (session, anchor timestamp, anchor signal) so that shared
2344
+ // reaction contributes to `signals` only once, not once per file.
2345
+ const seenCandidateKeys = new Set();
2240
2346
  for (const analysis of sortedBad) {
2241
2347
  const sessionId = analysis.session_id;
2242
- const sessionFile = findSessionFile(sessionsDir, sessionId);
2348
+ const sessionFile = fileFor.get(sessionId);
2243
2349
 
2244
2350
  if (!sessionFile) {
2245
2351
  failed.push(sessionId);
@@ -2247,11 +2353,17 @@ function extractMain(sessionsDir) {
2247
2353
  }
2248
2354
 
2249
2355
  const candidates = analyzeBadSession(sessionFile, analysis, signals);
2250
- allCandidates.push(...candidates);
2356
+ for (const c of candidates) {
2357
+ c.session_id = canonicalOf.get(c.session_id) || c.session_id;
2358
+ const dedupeKey = `${c.session_id}|${c.anchor_ts}|${c.anchor_signal}`;
2359
+ if (seenCandidateKeys.has(dedupeKey)) continue;
2360
+ seenCandidateKeys.add(dedupeKey);
2361
+ allCandidates.push(c);
2362
+ }
2251
2363
  }
2252
2364
 
2253
2365
  // Cluster candidates by (anchor_signal, tool_pattern)
2254
- const clusters = clusterCandidates(allCandidates);
2366
+ const clusters = clusterCandidates(allCandidates, groups);
2255
2367
 
2256
2368
  // Terminal output
2257
2369
  console.log(`\u2713 ${allCandidates.length} raw candidates \u2192 ${clusters.length} clusters`);
@@ -2355,8 +2467,8 @@ function main() {
2355
2467
  Friction analysis pipeline - analyze sessions and extract antigens.
2356
2468
 
2357
2469
  Usage:
2358
- node friction.js <sessions-directory>
2359
- node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
2470
+ node friction.cjs <sessions-directory>
2471
+ node friction.cjs ~/.claude/projects/<encoded-project-dir>/
2360
2472
 
2361
2473
  Outputs (all in .claude/remember/friction/):
2362
2474
  friction_analysis.json - Per-session analysis