liteagents 2.8.3 → 2.10.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 (113) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +25 -31
  3. package/installer/cli.js +6 -2
  4. package/package.json +3 -3
  5. package/packages/ampcode/AGENT.md +9 -15
  6. package/packages/ampcode/agents/code-developer.md +11 -12
  7. package/packages/ampcode/agents/quality-assurance.md +1 -1
  8. package/packages/{droid/commands/systematic-debugging.md → ampcode/commands/debug-method.md} +9 -9
  9. package/packages/ampcode/commands/diff-review.md +78 -0
  10. package/packages/ampcode/commands/friction/friction.js +348 -122
  11. package/packages/ampcode/commands/optimize.md +45 -4
  12. package/packages/ampcode/commands/refactor.md +33 -1
  13. package/packages/ampcode/commands/remember.md +107 -46
  14. package/packages/ampcode/commands/security.md +28 -1
  15. package/packages/ampcode/commands/stash.md +7 -0
  16. package/packages/{droid/commands/test-driven-development.md → ampcode/commands/tdd-flow.md} +2 -2
  17. package/packages/ampcode/commands/test-generate.md +64 -15
  18. package/packages/{opencode/command/testing-anti-patterns.md → ampcode/commands/test-traps.md} +77 -3
  19. package/packages/{droid/commands/root-cause-tracing.md → ampcode/commands/trace-back.md} +3 -3
  20. package/packages/ampcode/commands/{verification-before-completion.md → verify-done.md} +3 -3
  21. package/packages/claude/CLAUDE.md +9 -15
  22. package/packages/claude/agents/code-developer.md +11 -12
  23. package/packages/claude/agents/quality-assurance.md +1 -1
  24. package/packages/claude/commands/diff-review.md +78 -0
  25. package/packages/claude/commands/friction/friction.js +348 -122
  26. package/packages/claude/commands/optimize.md +45 -4
  27. package/packages/claude/commands/refactor.md +33 -1
  28. package/packages/claude/commands/remember.md +107 -46
  29. package/packages/claude/commands/security.md +28 -1
  30. package/packages/claude/commands/stash.md +7 -0
  31. package/packages/claude/commands/test-generate.md +64 -15
  32. package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +3 -3
  33. package/packages/claude/skills/{systematic-debugging → debug-method}/CREATION-LOG.md +1 -1
  34. package/packages/claude/skills/{systematic-debugging → debug-method}/SKILL.md +9 -9
  35. package/packages/claude/skills/{systematic-debugging → debug-method}/test-academic.md +1 -1
  36. package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-1.md +1 -1
  37. package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-2.md +1 -1
  38. package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-3.md +1 -1
  39. package/packages/claude/skills/{test-driven-development → tdd-flow}/SKILL.md +3 -3
  40. package/packages/claude/skills/{testing-anti-patterns → test-traps}/SKILL.md +77 -3
  41. package/packages/claude/skills/{root-cause-tracing → trace-back}/SKILL.md +3 -3
  42. package/packages/claude/skills/{verification-before-completion → verify-done}/SKILL.md +3 -3
  43. package/packages/droid/AGENTS.md +8 -14
  44. package/packages/{opencode/command/systematic-debugging.md → droid/commands/debug-method.md} +9 -9
  45. package/packages/droid/commands/diff-review.md +78 -0
  46. package/packages/droid/commands/friction/friction.js +348 -122
  47. package/packages/droid/commands/optimize.md +45 -4
  48. package/packages/droid/commands/refactor.md +33 -1
  49. package/packages/droid/commands/remember.md +107 -46
  50. package/packages/droid/commands/security.md +28 -1
  51. package/packages/droid/commands/stash.md +7 -0
  52. package/packages/{opencode/command/test-driven-development.md → droid/commands/tdd-flow.md} +2 -2
  53. package/packages/droid/commands/test-generate.md +64 -15
  54. package/packages/droid/commands/{testing-anti-patterns.md → test-traps.md} +77 -3
  55. package/packages/{opencode/command/root-cause-tracing.md → droid/commands/trace-back.md} +3 -3
  56. package/packages/droid/commands/{verification-before-completion.md → verify-done.md} +3 -3
  57. package/packages/droid/droids/code-developer.md +11 -12
  58. package/packages/droid/droids/quality-assurance.md +1 -1
  59. package/packages/opencode/AGENTS.md +8 -14
  60. package/packages/opencode/agent/code-developer.md +11 -12
  61. package/packages/opencode/agent/quality-assurance.md +1 -1
  62. package/packages/{ampcode/commands/systematic-debugging.md → opencode/command/debug-method.md} +9 -9
  63. package/packages/opencode/command/diff-review.md +78 -0
  64. package/packages/opencode/command/friction/friction.js +348 -122
  65. package/packages/opencode/command/optimize.md +45 -4
  66. package/packages/opencode/command/refactor.md +33 -1
  67. package/packages/opencode/command/remember.md +107 -46
  68. package/packages/opencode/command/security.md +28 -1
  69. package/packages/opencode/command/stash.md +7 -0
  70. package/packages/{ampcode/commands/test-driven-development.md → opencode/command/tdd-flow.md} +2 -2
  71. package/packages/opencode/command/test-generate.md +64 -15
  72. package/packages/{ampcode/commands/testing-anti-patterns.md → opencode/command/test-traps.md} +77 -3
  73. package/packages/{ampcode/commands/root-cause-tracing.md → opencode/command/trace-back.md} +3 -3
  74. package/packages/opencode/command/{verification-before-completion.md → verify-done.md} +3 -3
  75. package/packages/opencode/opencode.jsonc +13 -37
  76. package/packages/subagentic-manual.md +55 -51
  77. package/packages/ampcode/commands/code-review.md +0 -107
  78. package/packages/ampcode/commands/condition-based-waiting.md +0 -122
  79. package/packages/ampcode/commands/debug.md +0 -20
  80. package/packages/ampcode/commands/explain.md +0 -18
  81. package/packages/ampcode/commands/friction.md +0 -139
  82. package/packages/ampcode/commands/git-commit.md +0 -14
  83. package/packages/ampcode/commands/review.md +0 -18
  84. package/packages/claude/commands/debug.md +0 -20
  85. package/packages/claude/commands/explain.md +0 -18
  86. package/packages/claude/commands/friction.md +0 -139
  87. package/packages/claude/commands/git-commit.md +0 -14
  88. package/packages/claude/commands/review.md +0 -18
  89. package/packages/claude/skills/code-review/SKILL.md +0 -107
  90. package/packages/claude/skills/code-review/code-reviewer.md +0 -146
  91. package/packages/claude/skills/condition-based-waiting/SKILL.md +0 -122
  92. package/packages/droid/commands/code-review.md +0 -107
  93. package/packages/droid/commands/condition-based-waiting.md +0 -122
  94. package/packages/droid/commands/debug.md +0 -20
  95. package/packages/droid/commands/explain.md +0 -18
  96. package/packages/droid/commands/friction.md +0 -139
  97. package/packages/droid/commands/git-commit.md +0 -14
  98. package/packages/droid/commands/review.md +0 -18
  99. package/packages/opencode/command/code-review.md +0 -107
  100. package/packages/opencode/command/condition-based-waiting.md +0 -122
  101. package/packages/opencode/command/debug.md +0 -20
  102. package/packages/opencode/command/explain.md +0 -18
  103. package/packages/opencode/command/friction.md +0 -139
  104. package/packages/opencode/command/git-commit.md +0 -14
  105. package/packages/opencode/command/review.md +0 -18
  106. /package/packages/ampcode/commands/{condition-based-waiting → test-traps}/example.ts +0 -0
  107. /package/packages/ampcode/commands/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
  108. /package/packages/claude/skills/{condition-based-waiting → test-traps}/example.ts +0 -0
  109. /package/packages/claude/skills/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
  110. /package/packages/droid/commands/{condition-based-waiting → test-traps}/example.ts +0 -0
  111. /package/packages/droid/commands/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
  112. /package/packages/opencode/command/{condition-based-waiting → test-traps}/example.ts +0 -0
  113. /package/packages/opencode/command/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
@@ -26,22 +26,25 @@ const path = require('path');
26
26
 
27
27
  const CONFIG = {
28
28
  weights: {
29
- exit_error: 1,
30
- exit_success: 0,
31
- user_curse: 5,
32
- user_negation: 0.5,
33
- user_intervention: 10,
29
+ // OBSERVED (primary) — the user reacted; hard to fake, high trust.
30
+ user_correction: 8,
31
+ user_curse: 8,
32
+ interrupt_cascade: 8,
34
33
  tool_loop: 6,
35
- false_success: 8,
36
- request_interrupted: 2.5,
34
+ repeated_question: 5,
35
+ request_interrupted: 3,
36
+ // INFERRED (corroboration only) — machine proxies; noisy, never seed alone.
37
+ exit_error: 0.5,
38
+ false_success: 1,
39
+ no_resolution: 0.5,
40
+ session_abandoned: 1,
41
+ user_intervention: 1,
42
+ rapid_exit: 1,
37
43
  long_silence: 0.5,
38
- repeated_question: 1,
39
44
  compaction: 0.5,
40
- interrupt_cascade: 5,
41
- rapid_exit: 6,
42
- no_resolution: 8,
43
- session_abandoned: 10,
44
45
  sibling_tool_error: 0.5,
46
+ exit_success: 0,
47
+ checkpoint: 0, // a gated stash/abandon/silence with no preceding reaction — routine, ignored
45
48
  },
46
49
  thresholds: {
47
50
  friction_peak: 15,
@@ -78,6 +81,24 @@ function loadConfig() {
78
81
  return CONFIG;
79
82
  }
80
83
 
84
+ /**
85
+ * Parse newline-delimited JSON, skipping (and warning about) malformed lines
86
+ * instead of crashing the whole run on one bad record.
87
+ */
88
+ function parseJsonl(raw, source) {
89
+ const records = [];
90
+ const lines = raw.split('\n');
91
+ for (let i = 0; i < lines.length; i++) {
92
+ if (!lines[i].trim()) continue;
93
+ try {
94
+ records.push(JSON.parse(lines[i]));
95
+ } catch (e) {
96
+ console.error(`Warning: skipping malformed JSONL line ${i + 1}${source ? ` in ${source}` : ''}: ${e.message}`);
97
+ }
98
+ }
99
+ return records;
100
+ }
101
+
81
102
  function parseISODate(s) {
82
103
  if (!s) return null;
83
104
  try {
@@ -196,6 +217,31 @@ function extractToolNameFromResult(result) {
196
217
  return match ? match[1] : 'unknown';
197
218
  }
198
219
 
220
+ // A user turn that is mostly pasted shell prompts/output (SSH session dumps,
221
+ // command logs) is context the user pasted — not a reaction to the agent.
222
+ // Treating it as friction pollutes antigens (e.g. keywords like "postconf",
223
+ // "sendmail"). A *prompted* command line ("> sudo …", "$ git …") is an
224
+ // unambiguous paste even at 2 lines; otherwise require shell lines to dominate
225
+ // a 3+ line block, so a real 2-line correction ("no\nls the logs please")
226
+ // stays a correction.
227
+ const SHELL_CMD = /(sudo|ls|cd|cat|rm|cp|mv|mkdir|chmod|chown|export|source|ssh|scp|sed|awk|grep|echo|curl|wget|tar|systemctl|service|journalctl|apt|apt-get|dpkg|yum|dnf|rpm|npm|npx|node|pip|git|docker|postconf|postfix|opendkim|certbot|nginx|dig|host|nslookup|ping|traceroute|df|du|free|ps|uname|tail|head)\b/;
228
+ const SHELL_OUT = /(No such file or directory|command not found|cannot access|Permission denied|Exit code\s*\d|Traceback \(most recent|: line \d+:|^E: )/;
229
+ function looksLikeTerminalPaste(text) {
230
+ if (typeof text !== 'string') return false;
231
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
232
+ if (lines.length < 2) return false;
233
+ const prompted = lines.filter(l =>
234
+ /^[\w.-]+@[\w.-]+:\S*[#$]/.test(l) || // host prompt: "root@terribic:~#"
235
+ (/^[>$#]\s+/.test(l) && SHELL_CMD.test(l)) // "> dig …", "$ npm ci"
236
+ ).length;
237
+ if (prompted >= 1) return true;
238
+ if (lines.length < 3) return false;
239
+ const shellish = lines.filter(l =>
240
+ new RegExp('^' + SHELL_CMD.source).test(l) || SHELL_OUT.test(l)
241
+ ).length;
242
+ return shellish / lines.length >= 0.5;
243
+ }
244
+
199
245
  // =============================================================================
200
246
  // FRICTION ANALYZE - extract_signals
201
247
  // =============================================================================
@@ -207,9 +253,7 @@ function extractSignals(sessionFile) {
207
253
  const metadata = {};
208
254
 
209
255
  const raw = fs.readFileSync(sessionFile, 'utf-8');
210
- const events = raw.split('\n')
211
- .filter(line => line.trim())
212
- .map(line => JSON.parse(line));
256
+ const events = parseJsonl(raw, sessionFile);
213
257
 
214
258
  let turnCount = 0;
215
259
  const userMessages = [];
@@ -419,20 +463,34 @@ function extractSignals(sessionFile) {
419
463
  });
420
464
  }
421
465
 
422
- if (/\b(fuck|shit|damn)\b/i.test(content)) {
423
- signals.push({
424
- ts,
425
- source: 'user',
426
- signal: 'user_curse',
427
- details: content.slice(0, 50),
428
- });
466
+ const isPaste = looksLikeTerminalPaste(content);
467
+
468
+ // Curse only counts as friction when aimed at the agent's work — not
469
+ // rhetorical/narrative profanity ("does anyone search any shit?", a
470
+ // pasted reddit story). Keep it when the turn is a short reaction, or
471
+ // when an agent-directed token sits next to the curse; otherwise the
472
+ // profanity is buried in a long narrative and isn't friction.
473
+ const curseMatch = content.match(/\b(fuck|shit|damn)\b/i);
474
+ if (curseMatch && !isPaste) {
475
+ const i = curseMatch.index;
476
+ const near = content.slice(Math.max(0, i - 40), i + 40);
477
+ const shortReaction = content.trim().length <= 120;
478
+ const directed = /\b(you|your|you'?ve|you'?re|stop|quit|keep|again|why)\b/i.test(near);
479
+ if (shortReaction || directed) {
480
+ signals.push({
481
+ ts,
482
+ source: 'user',
483
+ signal: 'user_curse',
484
+ details: content.slice(0, 50),
485
+ });
486
+ }
429
487
  }
430
488
 
431
- if (isInteractive && /\b(no|didn't work|still broken)\b/i.test(content)) {
489
+ if (!isPaste && isInteractive && /^\s*(no\b|nope\b|wrong\b|don'?t\b|didn'?t work|that'?s not|not what|stop\b|revert\b|undo\b|still broken)/i.test(content)) {
432
490
  signals.push({
433
491
  ts,
434
492
  source: 'user',
435
- signal: 'user_negation',
493
+ signal: 'user_correction',
436
494
  details: content.slice(0, 50),
437
495
  });
438
496
  }
@@ -578,7 +636,7 @@ function extractSignals(sessionFile) {
578
636
  const frictionWeights = {
579
637
  exit_error: 1,
580
638
  user_curse: 5,
581
- user_negation: 1,
639
+ user_correction: 1,
582
640
  tool_loop: 6,
583
641
  false_success: 8,
584
642
  request_interrupted: 4,
@@ -625,6 +683,29 @@ function extractSignals(sessionFile) {
625
683
  });
626
684
  }
627
685
 
686
+ // FIX #1/#2: stash, abandonment and silence are "unresolved markers", not
687
+ // friction on their own. A clean-start stash, a context-switch, or an idle gap
688
+ // is routine. They only count when a real user reaction (correction / curse /
689
+ // interrupt) preceded them in the recent signals — i.e. a frustrated thread
690
+ // that was then dropped. Otherwise demote to a zero-weight checkpoint.
691
+ const GATED = new Set(['user_intervention', 'session_abandoned', 'long_silence']);
692
+ const REACTION = new Set(['user_correction', 'user_curse', 'interrupt_cascade']);
693
+ for (let i = 0; i < finalSignals.length; i++) {
694
+ if (!GATED.has(finalSignals[i].signal)) continue;
695
+ let precededByReaction = false;
696
+ for (let j = i - 1, seen = 0; j >= 0 && seen < 8; j--, seen++) {
697
+ if (REACTION.has(finalSignals[j].signal)) { precededByReaction = true; break; }
698
+ }
699
+ if (!precededByReaction) {
700
+ finalSignals[i] = {
701
+ ...finalSignals[i],
702
+ signal: 'checkpoint',
703
+ gated_from: finalSignals[i].signal,
704
+ details: 'routine/idle — no preceding user reaction',
705
+ };
706
+ }
707
+ }
708
+
628
709
  return [finalSignals, metadata];
629
710
  }
630
711
 
@@ -1630,7 +1711,7 @@ function extractContextWindow(sessionFile, anchorTs, windowSize) {
1630
1711
  windowSize = windowSize || 5;
1631
1712
 
1632
1713
  const raw = fs.readFileSync(sessionFile, 'utf-8');
1633
- const events = raw.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1714
+ const events = parseJsonl(raw, sessionFile);
1634
1715
 
1635
1716
  const turns = [];
1636
1717
  for (const event of events) {
@@ -1782,21 +1863,20 @@ function extractUserMessage(event) {
1782
1863
  function analyzeBadSession(sessionFile, analysis, signals) {
1783
1864
  const sessionId = analysis.session_id;
1784
1865
 
1866
+ // NEW: anchor antigens only on OBSERVED user-reaction signals. Inferred
1867
+ // proxies (false_success/session_abandoned/user_intervention) never seed —
1868
+ // 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.
1785
1873
  const anchorSignals = [
1786
- 'user_intervention',
1787
- 'session_abandoned',
1788
- 'false_success',
1874
+ 'user_correction',
1875
+ 'user_curse',
1789
1876
  'interrupt_cascade',
1790
1877
  ];
1791
1878
 
1792
- let anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
1793
-
1794
- if (anchors.length === 0) {
1795
- const sessionSignals = signals.filter(s => s.session === sessionId);
1796
- if (sessionSignals.length > 0) {
1797
- anchors = [sessionSignals[sessionSignals.length - 1]];
1798
- }
1799
- }
1879
+ const anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
1800
1880
 
1801
1881
  const candidates = [];
1802
1882
 
@@ -1819,7 +1899,7 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1819
1899
  allErrors.push(...extractErrorsFromTurn(event));
1820
1900
  if (turn.type === 'user') {
1821
1901
  const msg = extractUserMessage(event);
1822
- if (msg && !msg.startsWith('[Request interrupted')) {
1902
+ if (msg && !msg.startsWith('[Request interrupted') && !looksLikeTerminalPaste(msg)) {
1823
1903
  userMessagesArr.push(msg);
1824
1904
  }
1825
1905
  }
@@ -1854,6 +1934,25 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1854
1934
  ]);
1855
1935
  for (const c of common) keywords.delete(c);
1856
1936
 
1937
+ // FIX #4: capture the agent's last action + result just before the reaction.
1938
+ // Often it's a *claimed* success (exit 0) the user is contradicting, not a
1939
+ // crash — so record the action and whether it reported ok/error, plus any
1940
+ // error line. This is the technical half of the antigen (the trigger).
1941
+ const calls = allTools.filter(t => t.action === 'call').map(t => t.tool);
1942
+ const sawError = allTools.some(t => t.action === 'error');
1943
+ const sawSuccess = allTools.some(t => t.action === 'success');
1944
+ const preceding = {
1945
+ action: calls.slice(-2).join(' → ') || 'none',
1946
+ result: sawError ? 'error' : (sawSuccess ? 'claimed success (exit 0)' : 'unknown'),
1947
+ error: allErrors[allErrors.length - 1] || null,
1948
+ };
1949
+
1950
+ // FIX #3: self/context corrections ("wrong project", "nevermind", "my bad")
1951
+ // are the user redirecting THEMSELVES, not an antigen against the agent. Flag
1952
+ // them so the clusterer won't mark them severe; the LLM makes the final call.
1953
+ const selfPhrase = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
1954
+ const self_suspect = userMessagesArr.some(m => selfPhrase.test(m));
1955
+
1857
1956
  const candidate = {
1858
1957
  session_id: sessionId,
1859
1958
  anchor_signal: anchorSignal,
@@ -1863,6 +1962,8 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1863
1962
  files: Array.from(allFiles).sort().slice(0, 10),
1864
1963
  tool_sequence: toolSeq.slice(0, 15),
1865
1964
  errors: allErrors.slice(0, 5),
1965
+ preceding,
1966
+ self_suspect,
1866
1967
  keywords: Array.from(keywords).sort().slice(0, 15),
1867
1968
  user_context: userMessagesArr.slice(0, 3),
1868
1969
  inhibitory_instruction: '# TODO: Write prevention instruction based on pattern above',
@@ -1879,93 +1980,208 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1879
1980
  // =============================================================================
1880
1981
 
1881
1982
  function clusterCandidates(allCandidates) {
1882
- const signalWeights = {
1883
- user_intervention: 10,
1884
- session_abandoned: 10,
1885
- false_success: 8,
1886
- no_resolution: 8,
1887
- interrupt_cascade: 5,
1888
- tool_loop: 6,
1889
- rapid_exit: 6,
1983
+ // NEW: cluster by CONTENT (keyword overlap of what the user actually said),
1984
+ // not by (anchor_signal, tool_pattern). Inferred signals were already barred
1985
+ // from seeding upstream; here they survive only as corroborating "errors"
1986
+ // that color a cluster's severity. Recurrence across sessions is the score.
1987
+ const SIM = 0.5; // overlap-coefficient threshold to join a content cluster
1988
+
1989
+ // Ubiquitous path/file tokens that carry no topical meaning — if we cluster on
1990
+ // these we re-create OLD's over-merge (everything touches README/package.json).
1991
+ const PATH_STOP = new Set([
1992
+ 'home', 'hamr', 'documents', 'pycharmprojects', 'projects', 'claude', 'stash',
1993
+ 'memory', 'commands', 'command', 'skills', 'skill', 'src', 'lib', 'app', 'dist',
1994
+ 'build', 'node_modules', 'public', 'assets', 'utils', 'util', 'config', 'scripts',
1995
+ 'readme', 'package', 'index', 'main', 'test', 'tests', 'spec', 'lock',
1996
+ 'components', 'component', 'styles', 'style', 'types', 'data', 'templates',
1997
+ 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'sh', 'txt', 'html', 'css', 'json',
1998
+ 'yaml', 'yml', 'toml', 'env', 'log', 'tmp',
1999
+ ]);
2000
+
2001
+ // English fillers to drop from phrase matching (path tokens use PATH_STOP).
2002
+ const STOP = new Set([
2003
+ 'the', 'and', 'you', 'for', 'not', 'but', 'was', 'are', 'get', 'use', 'one', 'out',
2004
+ 'can', 'all', 'any', 'has', 'had', 'have', 'this', 'that', 'with', 'from', 'what',
2005
+ 'when', 'where', 'which', 'there', 'their', 'would', 'could', 'should', 'about',
2006
+ 'been', 'were', 'they', 'them', 'then', 'than', 'these', 'those', 'some', 'into',
2007
+ 'only', 'other', 'also', 'just', 'more', 'very', 'here', 'after', 'before', 'being',
2008
+ 'doing', 'make', 'made', 'like', 'want', 'need', 'your', 'dont', 'did', 'does',
2009
+ 'done', 'now', 'yet', 'too', 'will', 'wont', 'cant', 'got', 'let',
2010
+ ]);
2011
+
2012
+ // Significant words in order (>=3 chars, not a filler / path token).
2013
+ const unigrams = (text) =>
2014
+ (String(text).toLowerCase().match(/\b[a-z']{3,}\b/g) || [])
2015
+ .filter(w => !STOP.has(w) && !PATH_STOP.has(w));
2016
+
2017
+ // SHINGLES = unigrams + adjacent bigrams (word proximity + phrase repetition).
2018
+ // A repeated phrase like "wrong project" scores as one shingle AND its two words.
2019
+ const shingles = (texts) => {
2020
+ const m = new Map();
2021
+ for (const t of texts) {
2022
+ const u = unigrams(t);
2023
+ for (const w of u) m.set(w, (m.get(w) || 0) + 1);
2024
+ for (let i = 0; i < u.length - 1; i++) {
2025
+ const bg = u[i] + ' ' + u[i + 1];
2026
+ m.set(bg, (m.get(bg) || 0) + 1);
2027
+ }
2028
+ }
2029
+ return m;
1890
2030
  };
1891
2031
 
1892
- const clusterMap = {};
1893
-
1894
- for (const c of allCandidates) {
1895
- // Normalize tool sequence: strip :error/:ok suffixes for grouping
1896
- const toolNorm = c.tool_sequence
1897
- .map(t => t.replace(/:error|:ok/g, ''))
1898
- .join(',') || '(none)';
1899
- const key = c.anchor_signal + '|' + toolNorm;
1900
-
1901
- if (!(key in clusterMap)) {
1902
- clusterMap[key] = {
1903
- anchor_signal: c.anchor_signal,
1904
- tool_pattern: toolNorm,
1905
- count: 0,
1906
- sessions: {},
1907
- contexts: [],
1908
- errors: [],
1909
- files: {},
1910
- keywords: {},
1911
- peaks: [],
1912
- };
2032
+ const fileTokens = (sessionId, files) => {
2033
+ const proj = (sessionId || '').split('/')[0].toLowerCase();
2034
+ const out = [];
2035
+ for (const f of (files || [])) {
2036
+ for (const seg of String(f).toLowerCase().split(/[/._\-\s]+/)) {
2037
+ if (seg.length >= 4 && seg !== proj && !PATH_STOP.has(seg)) out.push(seg);
2038
+ }
1913
2039
  }
2040
+ return out;
2041
+ };
1914
2042
 
1915
- const cl = clusterMap[key];
1916
- cl.count++;
1917
- cl.sessions[c.session_id] = true;
1918
- cl.peaks.push(c.peak_friction);
2043
+ // Pick the quote from a session that best contains a cluster's seed phrase —
2044
+ // a multi-topic session should be shown by the line that actually matched.
2045
+ const bestQuote = (texts, seedSig) => {
2046
+ let best = texts[0] || '', bestN = -1;
2047
+ for (const t of texts) {
2048
+ const u = unigrams(t);
2049
+ let n = 0;
2050
+ for (const w of u) if (seedSig.has(w)) n++;
2051
+ for (let i = 0; i < u.length - 1; i++) if (seedSig.has(u[i] + ' ' + u[i + 1])) n++;
2052
+ if (n > bestN) { bestN = n; best = t; }
2053
+ }
2054
+ return best;
2055
+ };
1919
2056
 
1920
- // Collect unique user contexts (up to 5 per cluster, deduplicated)
1921
- if (c.user_context.length > 0 && cl.contexts.length < 5) {
1922
- for (const ctx of c.user_context.slice(0, 1)) {
1923
- if (ctx.length > 10 && !cl.contexts.includes(ctx)) cl.contexts.push(ctx);
1924
- }
2057
+ // ---- Stage 1: INTRA-SESSION one consolidated signal per session ----
2058
+ // Frustration/repetition is short and reuses words; pool a session's reaction
2059
+ // texts so the repeated/overlapping part becomes that session's signal.
2060
+ const bySession = {};
2061
+ for (const c of allCandidates) {
2062
+ const id = c.session_id;
2063
+ if (!bySession[id]) bySession[id] = { id, texts: [], files: new Set(), signals: {}, errors: [], peak: 0, preceding: null, selfVotes: 0, total: 0 };
2064
+ const b = bySession[id];
2065
+ for (const m of (c.user_context || [])) if (m && m.length > 2) b.texts.push(m);
2066
+ for (const f of (c.files || [])) b.files.add(f);
2067
+ b.signals[c.anchor_signal] = (b.signals[c.anchor_signal] || 0) + 1;
2068
+ for (const e of (c.errors || [])) if (!b.errors.includes(e)) b.errors.push(e);
2069
+ b.peak = Math.max(b.peak, c.peak_friction || 0);
2070
+ b.total++;
2071
+ if (c.self_suspect) b.selfVotes++;
2072
+ // keep the most informative preceding action/error (#4)
2073
+ if (c.preceding && (!b.preceding || (c.preceding.action !== 'none' || c.preceding.error))) b.preceding = c.preceding;
2074
+ }
2075
+
2076
+ const sessionSignals = Object.values(bySession).map(b => {
2077
+ const sh = shingles(b.texts);
2078
+ if (sh.size < 2) { // terse/empty → fall back to file referent
2079
+ for (const seg of fileTokens(b.id, b.files)) sh.set(seg, (sh.get(seg) || 0) + 1);
1925
2080
  }
2081
+ return {
2082
+ id: b.id,
2083
+ sig: new Set(sh.keys()),
2084
+ signals: b.signals,
2085
+ errors: b.errors,
2086
+ peak: b.peak,
2087
+ texts: b.texts,
2088
+ preceding: b.preceding,
2089
+ anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2090
+ };
2091
+ });
1926
2092
 
1927
- // Collect unique errors (up to 5 per cluster)
1928
- if (c.errors.length > 0 && cl.errors.length < 5) {
1929
- for (const err of c.errors.slice(0, 1)) {
1930
- if (!cl.errors.includes(err)) cl.errors.push(err);
2093
+ // ---- Stage 2: CROSS-SESSION match session signals by shared shingles ----
2094
+ const clusters = [];
2095
+ for (const ss of sessionSignals) {
2096
+ // Merge on a shared PHRASE (bigram = word proximity) — the strong signal —
2097
+ // or on strong unigram overlap. A single generic shared word won't merge.
2098
+ let best = null, bestSim = 0;
2099
+ if (ss.sig.size >= 2) {
2100
+ for (const cl of clusters) {
2101
+ let bi = 0, uni = 0;
2102
+ for (const x of ss.sig) if (cl.seedSig.has(x)) { x.includes(' ') ? bi++ : uni++; }
2103
+ const sim = (bi + uni) / Math.min(ss.sig.size, cl.seedSig.size);
2104
+ const mergeable = bi >= 1 && sim >= SIM;
2105
+ if (mergeable && sim > bestSim) { bestSim = sim; best = cl; }
1931
2106
  }
1932
2107
  }
1933
-
1934
- // Tally files and keywords
1935
- for (const f of c.files) cl.files[f] = (cl.files[f] || 0) + 1;
1936
- for (const kw of c.keywords) cl.keywords[kw] = (cl.keywords[kw] || 0) + 1;
1937
- }
1938
-
1939
- // Score and sort clusters
1940
- const clusters = Object.values(clusterMap).map(cl => {
1941
- const weight = signalWeights[cl.anchor_signal] || 1;
1942
- const peaks = cl.peaks.sort((a, b) => a - b);
1943
- // Session IDs follow "project/dateStr-shortId", so the first segment is
1944
- // the project. Preserve both the ID list and the deduped project set so
1945
- // downstream renderers can surface which repos produced the cluster.
2108
+ let cl;
2109
+ if (best) {
2110
+ cl = best;
2111
+ } else {
2112
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2113
+ clusters.push(cl);
2114
+ }
2115
+ for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
2116
+ cl.sessions[ss.id] = true;
2117
+ for (const [k, v] of Object.entries(ss.signals)) cl.signals[k] = (cl.signals[k] || 0) + v;
2118
+ const q = bestQuote(ss.texts, cl.seedSig);
2119
+ if (q && cl.contexts.length < 5 && !cl.contexts.includes(q)) cl.contexts.push(q);
2120
+ for (const e of ss.errors) if (!cl.errors.includes(e) && cl.errors.length < 5) cl.errors.push(e);
2121
+ cl.peaks.push(ss.peak);
2122
+ if (ss.anySelf) cl.anySelf = true;
2123
+ if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2124
+ }
2125
+
2126
+ const out = clusters.map(cl => {
2127
+ const peaks = cl.peaks.slice().sort((a, b) => a - b);
2128
+ const topSh = [...cl.shCount.entries()]
2129
+ .sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([k]) => k);
1946
2130
  const sessionIds = Object.keys(cl.sessions);
1947
2131
  const projects = [...new Set(
1948
2132
  sessionIds.map(s => s.includes('/') ? s.split('/')[0] : 'unknown')
1949
2133
  )].sort();
2134
+ const nSessions = sessionIds.length;
2135
+ const signalNames = Object.keys(cl.signals);
2136
+ const dominant = sortedEntries(cl.signals)[0] ? sortedEntries(cl.signals)[0][0] : 'unknown';
2137
+
2138
+ // Severity: an explicit AGENT-directed reaction (curse / interrupt, or a
2139
+ // correction that isn't the user redirecting themselves) is severe; machine
2140
+ // corroboration (errors) also escalates. #3: judge self-correction from the
2141
+ // MATCHED quotes — a cluster whose grouping phrase is "wrong project" etc. is
2142
+ // the user redirecting themselves, not an antigen → not severe.
2143
+ 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 || ''));
2145
+ const severe = signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
2146
+ || (signalNames.includes('user_correction') && !allSelf)
2147
+ || cl.errors.length > 0;
2148
+ const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
2149
+ let artifact;
2150
+ if (recurring && severe) artifact = 'antigen';
2151
+ else if (recurring && !severe) artifact = 'fact';
2152
+ else if (!recurring && severe) artifact = 'episode';
2153
+ else artifact = 'drop';
2154
+ const confidence = nSessions >= 5 ? 'high' : nSessions >= 3 ? 'medium' : 'low';
2155
+ const theme = topSh.slice(0, 4).join(' / ') || '(thin)';
2156
+
1950
2157
  return {
1951
- anchor_signal: cl.anchor_signal,
1952
- tool_pattern: cl.tool_pattern,
1953
- count: cl.count,
1954
- score: cl.count * weight,
1955
- sessions: sessionIds.length,
2158
+ theme,
2159
+ suggested_artifact: artifact,
2160
+ confidence,
2161
+ severity: severe ? 'severe' : 'mild',
2162
+ signals: cl.signals,
2163
+ // backward-compat fields for existing renderers:
2164
+ anchor_signal: dominant,
2165
+ tool_pattern: `${artifact}/${severe ? 'severe' : 'mild'}`,
2166
+ count: nSessions,
2167
+ score: nSessions * (severe ? 2 : 1),
2168
+ sessions: nSessions,
1956
2169
  session_ids: sessionIds,
1957
- projects: projects,
2170
+ projects,
1958
2171
  median_peak: peaks[Math.floor(peaks.length / 2)],
1959
2172
  max_peak: peaks[peaks.length - 1],
1960
2173
  contexts: cl.contexts,
1961
2174
  errors: cl.errors,
1962
- top_files: sortedEntries(cl.files).slice(0, 5).map(([f]) => f),
1963
- top_keywords: sortedEntries(cl.keywords).slice(0, 10).map(([k]) => k),
2175
+ preceding: cl.preceding, // #4: agent action + result just before the reaction
2176
+ self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2177
+ top_keywords: topSh.slice(0, 10),
1964
2178
  };
1965
2179
  });
1966
2180
 
1967
- clusters.sort((a, b) => b.score - a.score);
1968
- return clusters;
2181
+ // Drop the noise tier (one-off + mild); rank by recurrence then severity.
2182
+ const kept = out.filter(c => c.suggested_artifact !== 'drop');
2183
+ kept.sort((a, b) => b.score - a.score || b.sessions - a.sessions);
2184
+ return kept;
1969
2185
  }
1970
2186
 
1971
2187
  // =============================================================================
@@ -1980,25 +2196,32 @@ function extractMain(sessionsDir) {
1980
2196
  return 1;
1981
2197
  }
1982
2198
 
1983
- const analyses = JSON.parse(fs.readFileSync(analysisFile, 'utf-8'));
2199
+ let analyses;
2200
+ try {
2201
+ analyses = JSON.parse(fs.readFileSync(analysisFile, 'utf-8'));
2202
+ } catch (e) {
2203
+ console.log(`Error: ${analysisFile} is not valid JSON (${e.message}). Re-run friction analysis.`);
2204
+ return 1;
2205
+ }
1984
2206
 
1985
2207
  // Load raw signals
1986
2208
  const rawFile = '.amp/friction/friction_raw.jsonl';
1987
2209
  let signals = [];
1988
2210
  if (fs.existsSync(rawFile)) {
1989
2211
  const rawContent = fs.readFileSync(rawFile, 'utf-8');
1990
- signals = rawContent.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
2212
+ signals = parseJsonl(rawContent, rawFile);
1991
2213
  }
1992
2214
 
1993
- // Find BAD sessions
1994
- const badSessions = analyses.filter(a => a.quality === 'BAD');
2215
+ // NEW: no per-session BAD verdict. Seed from ALL sessions; the observed-only
2216
+ // anchor filter in analyzeBadSession decides which produce candidates.
2217
+ const badSessions = analyses;
1995
2218
 
1996
2219
  if (badSessions.length === 0) {
1997
- console.log('No BAD sessions found. Nothing to extract.');
2220
+ console.log('No sessions to analyze.');
1998
2221
  return 0;
1999
2222
  }
2000
2223
 
2001
- console.log(`Extracting antigens from ${badSessions.length} BAD sessions...\n`);
2224
+ console.log(`Scanning ${badSessions.length} sessions for observed user-reaction signals...\n`);
2002
2225
 
2003
2226
  // Extract antigens
2004
2227
  const allCandidates = [];
@@ -2053,17 +2276,17 @@ function extractMain(sessionsDir) {
2053
2276
 
2054
2277
  reviewLines.push('# Friction Antigen Clusters\n\n');
2055
2278
  reviewLines.push(`Generated: ${new Date().toISOString()}\n`);
2056
- reviewLines.push(`BAD sessions: ${badSessions.length} | Raw candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
2279
+ reviewLines.push(`Sessions scanned: ${badSessions.length} | Reaction candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
2057
2280
 
2058
2281
  // Summary table
2059
2282
  reviewLines.push('## Cluster Summary\n\n');
2060
- reviewLines.push('| # | Signal | Tool Pattern | Count | Sessions | Projects | Score | Median Peak |\n');
2061
- reviewLines.push('|---|--------|-------------|-------|----------|----------|-------|-------------|\n');
2283
+ reviewLines.push('| # | Signal | Artifact/Severity | Sessions | Projects | Score | Median Peak |\n');
2284
+ reviewLines.push('|---|--------|-------------------|----------|----------|-------|-------------|\n');
2062
2285
  reviewClusters.forEach((cl, idx) => {
2063
2286
  const projs = cl.projects || [];
2064
2287
  const projectsShort = projs.slice(0, 3).join(', ') +
2065
2288
  (projs.length > 3 ? `, +${projs.length - 3}` : '');
2066
- reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.count} | ${cl.sessions} | ${projectsShort || '-'} | ${cl.score} | ${cl.median_peak} |\n`);
2289
+ reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.sessions} | ${projectsShort || '-'} | ${cl.score} | ${cl.median_peak} |\n`);
2067
2290
  });
2068
2291
  reviewLines.push('\n---\n\n');
2069
2292
 
@@ -2075,6 +2298,10 @@ function extractMain(sessionsDir) {
2075
2298
  reviewLines.push(`**Projects:** ${cl.projects.join(', ')}\n\n`);
2076
2299
  }
2077
2300
 
2301
+ if (cl.self_suspect) {
2302
+ reviewLines.push('> ⚠️ **Looks like user self-correction** (e.g. "wrong project") — LLM should confirm target before treating as an antigen.\n\n');
2303
+ }
2304
+
2078
2305
  if (cl.contexts.length > 0) {
2079
2306
  reviewLines.push('### User Context (what the user said)\n\n');
2080
2307
  for (const ctx of cl.contexts.slice(0, 3)) {
@@ -2083,6 +2310,13 @@ function extractMain(sessionsDir) {
2083
2310
  }
2084
2311
  }
2085
2312
 
2313
+ if (cl.preceding && (cl.preceding.action !== 'none' || cl.preceding.error)) {
2314
+ reviewLines.push('### Trigger (agent action just before)\n\n');
2315
+ reviewLines.push(`- **Action:** ${cl.preceding.action} → ${cl.preceding.result}\n`);
2316
+ if (cl.preceding.error) reviewLines.push(`- **Error:** \`${cl.preceding.error}\`\n`);
2317
+ reviewLines.push('\n');
2318
+ }
2319
+
2086
2320
  if (cl.errors.length > 0) {
2087
2321
  reviewLines.push('### Errors\n\n');
2088
2322
  reviewLines.push('```\n');
@@ -2092,14 +2326,6 @@ function extractMain(sessionsDir) {
2092
2326
  reviewLines.push('```\n\n');
2093
2327
  }
2094
2328
 
2095
- if (cl.top_files.length > 0) {
2096
- reviewLines.push('### Files involved\n\n');
2097
- for (const f of cl.top_files) {
2098
- reviewLines.push(`- \`${f}\`\n`);
2099
- }
2100
- reviewLines.push('\n');
2101
- }
2102
-
2103
2329
  if (cl.top_keywords.length > 0) {
2104
2330
  reviewLines.push(`**Keywords:** ${cl.top_keywords.join(', ')}\n\n`);
2105
2331
  }
@@ -2161,7 +2387,7 @@ Outputs (all in .amp/friction/):
2161
2387
 
2162
2388
  // Step 2: Extract antigens
2163
2389
  console.log('\n' + '='.repeat(60));
2164
- console.log('\n[2/2] Extracting antigens from BAD sessions...\n');
2390
+ console.log('\n[2/2] Extracting antigens from user-reaction signals...\n');
2165
2391
  extractMain(sessionsDir);
2166
2392
 
2167
2393
  // Final summary