rlhf-feedback-loop 0.5.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 (73) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +308 -0
  4. package/adapters/README.md +8 -0
  5. package/adapters/amp/skills/rlhf-feedback/SKILL.md +20 -0
  6. package/adapters/chatgpt/INSTALL.md +80 -0
  7. package/adapters/chatgpt/openapi.yaml +292 -0
  8. package/adapters/claude/.mcp.json +8 -0
  9. package/adapters/codex/config.toml +4 -0
  10. package/adapters/gemini/function-declarations.json +95 -0
  11. package/adapters/mcp/server-stdio.js +444 -0
  12. package/bin/cli.js +167 -0
  13. package/config/mcp-allowlists.json +29 -0
  14. package/config/policy-bundles/constrained-v1.json +53 -0
  15. package/config/policy-bundles/default-v1.json +80 -0
  16. package/config/rubrics/default-v1.json +52 -0
  17. package/config/subagent-profiles.json +32 -0
  18. package/openapi/openapi.yaml +292 -0
  19. package/package.json +91 -0
  20. package/plugins/amp-skill/INSTALL.md +52 -0
  21. package/plugins/amp-skill/SKILL.md +31 -0
  22. package/plugins/claude-skill/INSTALL.md +55 -0
  23. package/plugins/claude-skill/SKILL.md +46 -0
  24. package/plugins/codex-profile/AGENTS.md +20 -0
  25. package/plugins/codex-profile/INSTALL.md +57 -0
  26. package/plugins/gemini-extension/INSTALL.md +74 -0
  27. package/plugins/gemini-extension/gemini_prompt.txt +10 -0
  28. package/plugins/gemini-extension/tool_contract.json +28 -0
  29. package/scripts/billing.js +471 -0
  30. package/scripts/budget-guard.js +173 -0
  31. package/scripts/code-reasoning.js +307 -0
  32. package/scripts/context-engine.js +547 -0
  33. package/scripts/contextfs.js +513 -0
  34. package/scripts/contract-audit.js +198 -0
  35. package/scripts/dpo-optimizer.js +208 -0
  36. package/scripts/export-dpo-pairs.js +316 -0
  37. package/scripts/export-training.js +448 -0
  38. package/scripts/feedback-attribution.js +313 -0
  39. package/scripts/feedback-inbox-read.js +162 -0
  40. package/scripts/feedback-loop.js +838 -0
  41. package/scripts/feedback-schema.js +300 -0
  42. package/scripts/feedback-to-memory.js +165 -0
  43. package/scripts/feedback-to-rules.js +109 -0
  44. package/scripts/generate-paperbanana-diagrams.sh +99 -0
  45. package/scripts/hybrid-feedback-context.js +676 -0
  46. package/scripts/intent-router.js +164 -0
  47. package/scripts/mcp-policy.js +92 -0
  48. package/scripts/meta-policy.js +194 -0
  49. package/scripts/plan-gate.js +154 -0
  50. package/scripts/prove-adapters.js +364 -0
  51. package/scripts/prove-attribution.js +364 -0
  52. package/scripts/prove-automation.js +393 -0
  53. package/scripts/prove-data-quality.js +219 -0
  54. package/scripts/prove-intelligence.js +256 -0
  55. package/scripts/prove-lancedb.js +370 -0
  56. package/scripts/prove-loop-closure.js +255 -0
  57. package/scripts/prove-rlaif.js +404 -0
  58. package/scripts/prove-subway-upgrades.js +250 -0
  59. package/scripts/prove-training-export.js +324 -0
  60. package/scripts/prove-v2-milestone.js +273 -0
  61. package/scripts/prove-v3-milestone.js +381 -0
  62. package/scripts/rlaif-self-audit.js +123 -0
  63. package/scripts/rubric-engine.js +230 -0
  64. package/scripts/self-heal.js +127 -0
  65. package/scripts/self-healing-check.js +111 -0
  66. package/scripts/skill-quality-tracker.js +284 -0
  67. package/scripts/subagent-profiles.js +79 -0
  68. package/scripts/sync-gh-secrets-from-env.sh +29 -0
  69. package/scripts/thompson-sampling.js +331 -0
  70. package/scripts/train_from_feedback.py +914 -0
  71. package/scripts/validate-feedback.js +580 -0
  72. package/scripts/vector-store.js +100 -0
  73. package/src/api/server.js +497 -0
@@ -0,0 +1,838 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RLHF Feedback Loop (local-first)
4
+ *
5
+ * Pipeline:
6
+ * thumbs up/down -> resolve action -> validate memory -> append logs
7
+ * -> compute analytics -> generate prevention rules
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const {
13
+ resolveFeedbackAction,
14
+ prepareForStorage,
15
+ parseTimestamp,
16
+ } = require('./feedback-schema');
17
+ const {
18
+ buildRubricEvaluation,
19
+ } = require('./rubric-engine');
20
+ const { recordAction, attributeFeedback } = require('./feedback-attribution');
21
+
22
+ const PROJECT_ROOT = path.join(__dirname, '..');
23
+ const DEFAULT_FEEDBACK_DIR = path.join(PROJECT_ROOT, '.claude', 'memory', 'feedback');
24
+
25
+ // ML sequence tracking constants (ML-03)
26
+ const SEQUENCE_WINDOW = 10;
27
+ const DOMAIN_CATEGORIES = [
28
+ 'testing', 'security', 'performance', 'ui-components', 'api-integration',
29
+ 'git-workflow', 'documentation', 'debugging', 'architecture', 'data-modeling',
30
+ ];
31
+
32
+ function getFeedbackPaths() {
33
+ const feedbackDir = process.env.RLHF_FEEDBACK_DIR || DEFAULT_FEEDBACK_DIR;
34
+ return {
35
+ FEEDBACK_DIR: feedbackDir,
36
+ FEEDBACK_LOG_PATH: path.join(feedbackDir, 'feedback-log.jsonl'),
37
+ MEMORY_LOG_PATH: path.join(feedbackDir, 'memory-log.jsonl'),
38
+ SUMMARY_PATH: path.join(feedbackDir, 'feedback-summary.json'),
39
+ PREVENTION_RULES_PATH: path.join(feedbackDir, 'prevention-rules.md'),
40
+ };
41
+ }
42
+
43
+ function getContextFsModule() {
44
+ try {
45
+ return require('./contextfs');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function getVectorStoreModule() {
52
+ try {
53
+ return require('./vector-store');
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function getSelfAuditModule() {
60
+ try {
61
+ return require('./rlaif-self-audit');
62
+ } catch (_) {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function ensureDir(dirPath) {
68
+ if (!fs.existsSync(dirPath)) {
69
+ fs.mkdirSync(dirPath, { recursive: true });
70
+ }
71
+ }
72
+
73
+ function appendJSONL(filePath, record) {
74
+ ensureDir(path.dirname(filePath));
75
+ fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`);
76
+ }
77
+
78
+ function readJSONL(filePath) {
79
+ if (!fs.existsSync(filePath)) return [];
80
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
81
+ if (!raw) return [];
82
+ return raw
83
+ .split('\n')
84
+ .map((line) => {
85
+ try {
86
+ return JSON.parse(line);
87
+ } catch {
88
+ return null;
89
+ }
90
+ })
91
+ .filter(Boolean);
92
+ }
93
+
94
+ function normalizeSignal(signal) {
95
+ const value = String(signal || '').trim().toLowerCase();
96
+ if (['up', 'thumbsup', 'thumbs-up', 'positive', 'good'].includes(value)) return 'positive';
97
+ if (['down', 'thumbsdown', 'thumbs-down', 'negative', 'bad'].includes(value)) return 'negative';
98
+ if (value === 'thumbs_up') return 'positive';
99
+ if (value === 'thumbs_down') return 'negative';
100
+ return null;
101
+ }
102
+
103
+ function parseOptionalObject(input, name) {
104
+ if (input == null) return {};
105
+ if (typeof input === 'object' && !Array.isArray(input)) return input;
106
+ if (typeof input === 'string') {
107
+ const trimmed = input.trim();
108
+ if (!trimmed) return {};
109
+ const parsed = JSON.parse(trimmed);
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
111
+ throw new Error(`${name} must be an object`);
112
+ }
113
+ return parsed;
114
+ }
115
+ throw new Error(`${name} must be object or JSON string`);
116
+ }
117
+
118
+ function loadSummary() {
119
+ const { SUMMARY_PATH } = getFeedbackPaths();
120
+ if (!fs.existsSync(SUMMARY_PATH)) {
121
+ return {
122
+ total: 0,
123
+ positive: 0,
124
+ negative: 0,
125
+ accepted: 0,
126
+ rejected: 0,
127
+ lastUpdated: null,
128
+ };
129
+ }
130
+ return JSON.parse(fs.readFileSync(SUMMARY_PATH, 'utf-8'));
131
+ }
132
+
133
+ function saveSummary(summary) {
134
+ const { SUMMARY_PATH } = getFeedbackPaths();
135
+ ensureDir(path.dirname(SUMMARY_PATH));
136
+ fs.writeFileSync(SUMMARY_PATH, `${JSON.stringify(summary, null, 2)}\n`);
137
+ }
138
+
139
+ // ============================================================
140
+ // ML Side-Effect Helpers — Sequence Tracking (ML-03) and
141
+ // Diversity Tracking (ML-04). Inline per Subway architecture.
142
+ // ============================================================
143
+
144
+ function inferDomain(tags, context) {
145
+ const tagSet = new Set((tags || []).map((t) => t.toLowerCase()));
146
+ const ctx = (context || '').toLowerCase();
147
+ if (tagSet.has('test') || tagSet.has('testing') || ctx.includes('test')) return 'testing';
148
+ if (tagSet.has('security') || ctx.includes('secret')) return 'security';
149
+ if (tagSet.has('perf') || tagSet.has('performance') || ctx.includes('performance')) return 'performance';
150
+ if (tagSet.has('ui') || tagSet.has('component') || ctx.includes('component')) return 'ui-components';
151
+ if (tagSet.has('api') || tagSet.has('endpoint') || ctx.includes('endpoint')) return 'api-integration';
152
+ if (tagSet.has('git') || tagSet.has('commit') || ctx.includes('commit')) return 'git-workflow';
153
+ if (tagSet.has('doc') || tagSet.has('readme') || ctx.includes('readme')) return 'documentation';
154
+ if (tagSet.has('debug') || tagSet.has('debugging') || ctx.includes('error')) return 'debugging';
155
+ if (tagSet.has('arch') || tagSet.has('architecture') || ctx.includes('design')) return 'architecture';
156
+ if (tagSet.has('data') || tagSet.has('schema') || ctx.includes('schema')) return 'data-modeling';
157
+ return 'general';
158
+ }
159
+
160
+ /**
161
+ * Infer granular outcome category from signal + context.
162
+ * Satisfies QUAL-03 — beyond binary up/down.
163
+ * @param {string} signal - 'positive' or 'negative'
164
+ * @param {string} context - feedback context string
165
+ * @returns {string} granular outcome category
166
+ */
167
+ function inferOutcome(signal, context) {
168
+ const cl = (context || '').toLowerCase();
169
+ if (signal === 'positive') {
170
+ if (cl.includes('first try') || cl.includes('immediately') || cl.includes('right away')) return 'quick-success';
171
+ if (cl.includes('thorough') || cl.includes('comprehensive') || cl.includes('in-depth')) return 'deep-success';
172
+ if (cl.includes('creative') || cl.includes('novel') || cl.includes('elegant')) return 'creative-success';
173
+ if (cl.includes('partial') || cl.includes('mostly') || cl.includes('some issues')) return 'partial-success';
174
+ return 'standard-success';
175
+ } else {
176
+ if (cl.includes('wrong') || cl.includes('incorrect') || cl.includes('factual')) return 'factual-error';
177
+ if (cl.includes('shallow') || cl.includes('surface') || cl.includes('superficial')) return 'insufficient-depth';
178
+ if (cl.includes('slow') || cl.includes('took too long') || cl.includes('inefficient')) return 'efficiency-issue';
179
+ if (cl.includes('assumption') || cl.includes('guessed') || cl.includes('assumed')) return 'false-assumption';
180
+ if (cl.includes('partial') || cl.includes('incomplete') || cl.includes('missing')) return 'incomplete';
181
+ return 'standard-failure';
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Enrich feedbackEvent with richContext metadata.
187
+ * Satisfies QUAL-02 — domain, filePaths, errorType, outcomeCategory.
188
+ * Non-throwing: returns original event on any error.
189
+ * @param {object} feedbackEvent - base feedback event
190
+ * @param {object} params - original captureFeedback params
191
+ * @returns {object} enriched feedbackEvent
192
+ */
193
+ function enrichFeedbackContext(feedbackEvent, params) {
194
+ try {
195
+ const domain = inferDomain(feedbackEvent.tags, feedbackEvent.context);
196
+ const outcomeCategory = inferOutcome(feedbackEvent.signal, feedbackEvent.context);
197
+ const filePaths = Array.isArray(params.filePaths)
198
+ ? params.filePaths
199
+ : typeof params.filePaths === 'string' && params.filePaths.trim()
200
+ ? params.filePaths.split(',').map((f) => f.trim()).filter(Boolean)
201
+ : [];
202
+ const errorType = params.errorType || null;
203
+
204
+ return {
205
+ ...feedbackEvent,
206
+ richContext: {
207
+ domain,
208
+ filePaths,
209
+ errorType,
210
+ outcomeCategory,
211
+ },
212
+ };
213
+ } catch (_err) {
214
+ return feedbackEvent;
215
+ }
216
+ }
217
+
218
+ function calculateTrend(rewards) {
219
+ if (rewards.length < 2) return 0;
220
+ const recent = rewards.slice(-3);
221
+ return recent.reduce((a, b) => a + b, 0) / recent.length;
222
+ }
223
+
224
+ function calculateTimeGaps(sequence) {
225
+ const gaps = [];
226
+ for (let i = 1; i < sequence.length; i++) {
227
+ const prev = parseTimestamp(sequence[i - 1].timestamp);
228
+ const curr = parseTimestamp(sequence[i].timestamp);
229
+ if (prev && curr) {
230
+ gaps.push((curr - prev) / 1000 / 60); // minutes
231
+ }
232
+ }
233
+ return gaps;
234
+ }
235
+
236
+ function extractActionPatterns(sequence) {
237
+ const patterns = {};
238
+ sequence.forEach((f) => {
239
+ (f.tags || []).forEach((tag) => {
240
+ if (!patterns[tag]) patterns[tag] = { positive: 0, negative: 0 };
241
+ if (f.signal === 'positive') patterns[tag].positive++;
242
+ else patterns[tag].negative++;
243
+ });
244
+ });
245
+ return patterns;
246
+ }
247
+
248
+ function buildSequenceFeatures(recentEntries, currentEntry) {
249
+ const sequence = [...recentEntries, currentEntry];
250
+ return {
251
+ rewardSequence: sequence.map((f) => (f.signal === 'positive' ? 1 : -1)),
252
+ tagFrequency: sequence.reduce((acc, f) => {
253
+ (f.tags || []).forEach((tag) => {
254
+ acc[tag] = (acc[tag] || 0) + 1;
255
+ });
256
+ return acc;
257
+ }, {}),
258
+ recentTrend: calculateTrend(sequence.slice(-5).map((f) => (f.signal === 'positive' ? 1 : -1))),
259
+ timeGaps: calculateTimeGaps(sequence),
260
+ actionPatterns: extractActionPatterns(sequence),
261
+ };
262
+ }
263
+
264
+ function appendSequence(feedbackEvent, paths) {
265
+ const sequencePath = path.join(paths.FEEDBACK_DIR, 'feedback-sequences.jsonl');
266
+ const recent = readJSONL(paths.FEEDBACK_LOG_PATH).slice(-SEQUENCE_WINDOW);
267
+ const features = buildSequenceFeatures(recent, feedbackEvent);
268
+ const entry = {
269
+ id: `seq_${Date.now()}`,
270
+ timestamp: new Date().toISOString(),
271
+ targetReward: feedbackEvent.signal === 'positive' ? 1 : -1,
272
+ targetTags: feedbackEvent.tags,
273
+ features,
274
+ label: feedbackEvent.signal === 'positive' ? 'positive' : 'negative',
275
+ };
276
+ appendJSONL(sequencePath, entry);
277
+ }
278
+
279
+ function updateDiversityTracking(feedbackEvent, paths) {
280
+ const diversityPath = path.join(paths.FEEDBACK_DIR, 'diversity-tracking.json');
281
+ let diversity = { domains: {}, lastUpdated: null, diversityScore: 0 };
282
+ if (fs.existsSync(diversityPath)) {
283
+ try {
284
+ diversity = JSON.parse(fs.readFileSync(diversityPath, 'utf-8'));
285
+ } catch {
286
+ // start fresh on parse error
287
+ }
288
+ }
289
+
290
+ const domain = inferDomain(feedbackEvent.tags, feedbackEvent.context);
291
+ if (!diversity.domains[domain]) {
292
+ diversity.domains[domain] = { count: 0, positive: 0, negative: 0, lastSeen: null };
293
+ }
294
+
295
+ diversity.domains[domain].count++;
296
+ diversity.domains[domain].lastSeen = feedbackEvent.timestamp;
297
+ if (feedbackEvent.signal === 'positive') diversity.domains[domain].positive++;
298
+ else diversity.domains[domain].negative++;
299
+
300
+ const totalFeedback = Object.values(diversity.domains).reduce((s, d) => s + d.count, 0);
301
+ const domainCount = Object.keys(diversity.domains).length;
302
+ const idealPerDomain = totalFeedback / DOMAIN_CATEGORIES.length;
303
+ const variance = Object.values(diversity.domains).reduce((s, d) => {
304
+ return s + Math.pow(d.count - idealPerDomain, 2);
305
+ }, 0) / Math.max(domainCount, 1);
306
+
307
+ diversity.diversityScore = Math.max(0, 100 - Math.sqrt(variance) * 10).toFixed(1);
308
+ diversity.lastUpdated = new Date().toISOString();
309
+ diversity.recommendation = Number(diversity.diversityScore) < 50
310
+ ? `Low diversity (${diversity.diversityScore}%). Try feedback in: ${DOMAIN_CATEGORIES.filter((d) => !diversity.domains[d]).join(', ')}`
311
+ : `Good diversity (${diversity.diversityScore}%)`;
312
+
313
+ fs.writeFileSync(diversityPath, JSON.stringify(diversity, null, 2) + '\n');
314
+ }
315
+
316
+ function captureFeedback(params) {
317
+ const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = getFeedbackPaths();
318
+ const signal = normalizeSignal(params.signal);
319
+ if (!signal) {
320
+ return {
321
+ accepted: false,
322
+ reason: `Invalid signal "${params.signal}". Use up/down or positive/negative.`,
323
+ };
324
+ }
325
+
326
+ const tags = Array.isArray(params.tags)
327
+ ? params.tags
328
+ : String(params.tags || '')
329
+ .split(',')
330
+ .map((t) => t.trim())
331
+ .filter(Boolean);
332
+
333
+ let rubricEvaluation = null;
334
+ try {
335
+ if (params.rubricScores != null || params.guardrails != null) {
336
+ rubricEvaluation = buildRubricEvaluation({
337
+ rubricScores: params.rubricScores,
338
+ guardrails: parseOptionalObject(params.guardrails, 'guardrails'),
339
+ });
340
+ }
341
+ } catch (err) {
342
+ return {
343
+ accepted: false,
344
+ reason: `Invalid rubric payload: ${err.message}`,
345
+ };
346
+ }
347
+
348
+ const action = resolveFeedbackAction({
349
+ signal,
350
+ context: params.context || '',
351
+ whatWentWrong: params.whatWentWrong,
352
+ whatToChange: params.whatToChange,
353
+ whatWorked: params.whatWorked,
354
+ tags,
355
+ rubricEvaluation,
356
+ });
357
+
358
+ const now = new Date().toISOString();
359
+ const rawFeedbackEvent = {
360
+ id: `fb_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
361
+ signal,
362
+ context: params.context || '',
363
+ whatWentWrong: params.whatWentWrong || null,
364
+ whatToChange: params.whatToChange || null,
365
+ whatWorked: params.whatWorked || null,
366
+ tags,
367
+ skill: params.skill || null,
368
+ rubric: rubricEvaluation
369
+ ? {
370
+ rubricId: rubricEvaluation.rubricId,
371
+ weightedScore: rubricEvaluation.weightedScore,
372
+ failingCriteria: rubricEvaluation.failingCriteria,
373
+ failingGuardrails: rubricEvaluation.failingGuardrails,
374
+ judgeDisagreements: rubricEvaluation.judgeDisagreements,
375
+ promotionEligible: rubricEvaluation.promotionEligible,
376
+ }
377
+ : null,
378
+ actionType: action.type,
379
+ actionReason: action.reason || null,
380
+ timestamp: now,
381
+ };
382
+
383
+ // Rich context enrichment (QUAL-02, QUAL-03) — non-blocking
384
+ const feedbackEvent = enrichFeedbackContext(rawFeedbackEvent, params);
385
+
386
+ const summary = loadSummary();
387
+ summary.total += 1;
388
+ summary[signal] += 1;
389
+
390
+ if (action.type === 'no-action') {
391
+ summary.rejected += 1;
392
+ summary.lastUpdated = now;
393
+ saveSummary(summary);
394
+ appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
395
+ return {
396
+ accepted: false,
397
+ reason: action.reason,
398
+ feedbackEvent,
399
+ };
400
+ }
401
+
402
+ const prepared = prepareForStorage(action.memory);
403
+ if (!prepared.ok) {
404
+ summary.rejected += 1;
405
+ summary.lastUpdated = now;
406
+ saveSummary(summary);
407
+ appendJSONL(FEEDBACK_LOG_PATH, {
408
+ ...feedbackEvent,
409
+ validationIssues: prepared.issues,
410
+ });
411
+ return {
412
+ accepted: false,
413
+ reason: `Schema validation failed: ${prepared.issues.join('; ')}`,
414
+ feedbackEvent,
415
+ issues: prepared.issues,
416
+ };
417
+ }
418
+
419
+ const memoryRecord = {
420
+ id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
421
+ ...prepared.memory,
422
+ sourceFeedbackId: feedbackEvent.id,
423
+ timestamp: now,
424
+ };
425
+
426
+ appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
427
+ appendJSONL(MEMORY_LOG_PATH, memoryRecord);
428
+
429
+ const contextFs = getContextFsModule();
430
+ if (contextFs && typeof contextFs.registerFeedback === 'function') {
431
+ try {
432
+ contextFs.registerFeedback(feedbackEvent, memoryRecord);
433
+ } catch {
434
+ // Non-critical; feedback remains in primary logs
435
+ }
436
+ }
437
+
438
+ // ML side-effects: sequence tracking and diversity (non-blocking — primary write already succeeded)
439
+ const mlPaths = getFeedbackPaths();
440
+ try {
441
+ appendSequence(feedbackEvent, mlPaths);
442
+ } catch (err) {
443
+ // Sequence tracking failure is non-critical
444
+ }
445
+ try {
446
+ updateDiversityTracking(feedbackEvent, mlPaths);
447
+ } catch (err) {
448
+ // Diversity tracking failure is non-critical
449
+ }
450
+
451
+ // Vector storage side-effect (non-blocking — primary write already succeeded)
452
+ const vectorStore = getVectorStoreModule();
453
+ if (vectorStore) {
454
+ vectorStore.upsertFeedback(feedbackEvent).catch(() => {
455
+ // Non-critical; primary feedback log is the source of truth
456
+ });
457
+ }
458
+
459
+ // RLAIF self-audit side-effect (non-blocking — 4th enrichment layer)
460
+ try {
461
+ const sam = getSelfAuditModule();
462
+ if (sam) sam.selfAuditAndLog(feedbackEvent, mlPaths);
463
+ } catch (_err) { /* non-critical */ }
464
+
465
+ // Attribution side-effects — fire-and-forget, never throw
466
+ try {
467
+ const toolName = feedbackEvent.toolName || feedbackEvent.tool_name || 'unknown';
468
+ const toolInput = feedbackEvent.context || feedbackEvent.input || '';
469
+ recordAction(toolName, toolInput);
470
+ if (feedbackEvent.signal === 'negative') {
471
+ attributeFeedback('negative', feedbackEvent.context || '');
472
+ } else if (feedbackEvent.signal === 'positive') {
473
+ attributeFeedback('positive', feedbackEvent.context || '');
474
+ }
475
+ } catch (e) {
476
+ // attribution is non-blocking
477
+ }
478
+
479
+ summary.accepted += 1;
480
+ summary.lastUpdated = now;
481
+ saveSummary(summary);
482
+
483
+ return {
484
+ accepted: true,
485
+ feedbackEvent,
486
+ memoryRecord,
487
+ };
488
+ }
489
+
490
+ function analyzeFeedback(logPath) {
491
+ const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
492
+ const entries = readJSONL(logPath || FEEDBACK_LOG_PATH);
493
+ const skills = {};
494
+ const tags = {};
495
+ const rubricCriteria = {};
496
+ let rubricSamples = 0;
497
+ let blockedPromotions = 0;
498
+
499
+ let totalPositive = 0;
500
+ let totalNegative = 0;
501
+
502
+ for (const entry of entries) {
503
+ if (entry.signal === 'positive') totalPositive++;
504
+ if (entry.signal === 'negative') totalNegative++;
505
+
506
+ if (entry.skill) {
507
+ if (!skills[entry.skill]) skills[entry.skill] = { positive: 0, negative: 0, total: 0 };
508
+ skills[entry.skill][entry.signal] += 1;
509
+ skills[entry.skill].total += 1;
510
+ }
511
+
512
+ for (const tag of entry.tags || []) {
513
+ if (!tags[tag]) tags[tag] = { positive: 0, negative: 0, total: 0 };
514
+ tags[tag][entry.signal] += 1;
515
+ tags[tag].total += 1;
516
+ }
517
+
518
+ if (entry.actionType === 'no-action' && typeof entry.actionReason === 'string' && entry.actionReason.includes('Rubric gate')) {
519
+ blockedPromotions += 1;
520
+ }
521
+
522
+ if (entry.rubric && entry.rubric.weightedScore != null) {
523
+ rubricSamples += 1;
524
+ }
525
+
526
+ if (entry.rubric && Array.isArray(entry.rubric.failingCriteria)) {
527
+ for (const criterion of entry.rubric.failingCriteria) {
528
+ if (!rubricCriteria[criterion]) rubricCriteria[criterion] = { failures: 0 };
529
+ rubricCriteria[criterion].failures += 1;
530
+ }
531
+ }
532
+ }
533
+
534
+ const total = totalPositive + totalNegative;
535
+ const approvalRate = total > 0 ? Math.round((totalPositive / total) * 1000) / 1000 : 0;
536
+ const recent = entries.slice(-20);
537
+ const recentPos = recent.filter((e) => e.signal === 'positive').length;
538
+ const recentRate = recent.length > 0 ? Math.round((recentPos / recent.length) * 1000) / 1000 : 0;
539
+
540
+ const recommendations = [];
541
+
542
+ for (const [skill, stat] of Object.entries(skills)) {
543
+ const negRate = stat.total > 0 ? stat.negative / stat.total : 0;
544
+ if (stat.total >= 3 && negRate >= 0.5) {
545
+ recommendations.push(`IMPROVE skill '${skill}' (${stat.negative}/${stat.total} negative)`);
546
+ }
547
+ }
548
+
549
+ for (const [tag, stat] of Object.entries(tags)) {
550
+ const posRate = stat.total > 0 ? stat.positive / stat.total : 0;
551
+ if (stat.total >= 3 && posRate >= 0.8) {
552
+ recommendations.push(`REUSE pattern '${tag}' (${stat.positive}/${stat.total} positive)`);
553
+ }
554
+ }
555
+
556
+ if (recent.length >= 10 && recentRate < approvalRate - 0.1) {
557
+ recommendations.push('DECLINING trend in last 20 signals; tighten verification before response.');
558
+ }
559
+
560
+ return {
561
+ total,
562
+ totalPositive,
563
+ totalNegative,
564
+ approvalRate,
565
+ recentRate,
566
+ skills,
567
+ tags,
568
+ rubric: {
569
+ samples: rubricSamples,
570
+ blockedPromotions,
571
+ failingCriteria: rubricCriteria,
572
+ },
573
+ recommendations,
574
+ };
575
+ }
576
+
577
+ function buildPreventionRules(minOccurrences = 2) {
578
+ const { MEMORY_LOG_PATH } = getFeedbackPaths();
579
+ const memories = readJSONL(MEMORY_LOG_PATH).filter((m) => m.category === 'error');
580
+ if (memories.length === 0) {
581
+ return '# Prevention Rules\n\nNo mistake memories recorded yet.';
582
+ }
583
+
584
+ const buckets = {};
585
+ const rubricBuckets = {};
586
+ for (const m of memories) {
587
+ const key = (m.tags || []).find((t) => !['feedback', 'negative', 'positive'].includes(t)) || 'general';
588
+ if (!buckets[key]) buckets[key] = [];
589
+ buckets[key].push(m);
590
+
591
+ const failed = m.rubricSummary && Array.isArray(m.rubricSummary.failingCriteria)
592
+ ? m.rubricSummary.failingCriteria
593
+ : [];
594
+ failed.forEach((criterion) => {
595
+ if (!rubricBuckets[criterion]) rubricBuckets[criterion] = [];
596
+ rubricBuckets[criterion].push(m);
597
+ });
598
+ }
599
+
600
+ const lines = ['# Prevention Rules', '', 'Generated from negative feedback memories.'];
601
+
602
+ Object.entries(buckets)
603
+ .sort((a, b) => b[1].length - a[1].length)
604
+ .forEach(([domain, items]) => {
605
+ if (items.length < minOccurrences) return;
606
+ const latest = items[items.length - 1];
607
+ const avoid = (latest.content || '').split('\n').find((l) => l.toLowerCase().startsWith('how to avoid:')) || 'How to avoid: Investigate and prevent recurrence';
608
+ lines.push('');
609
+ lines.push(`## ${domain}`);
610
+ lines.push(`- Recurrence count: ${items.length}`);
611
+ lines.push(`- Rule: ${avoid.replace(/^How to avoid:\s*/i, '')}`);
612
+ lines.push(`- Latest mistake: ${latest.title}`);
613
+ });
614
+
615
+ const rubricEntries = Object.entries(rubricBuckets).sort((a, b) => b[1].length - a[1].length);
616
+ if (rubricEntries.length > 0) {
617
+ lines.push('');
618
+ lines.push('## Rubric Failure Dimensions');
619
+ rubricEntries.forEach(([criterion, items]) => {
620
+ if (items.length < minOccurrences) return;
621
+ lines.push(`- ${criterion}: ${items.length} failures`);
622
+ });
623
+ }
624
+
625
+ if (lines.length === 3) {
626
+ lines.push('');
627
+ lines.push(`No domain has reached the threshold (${minOccurrences}) yet.`);
628
+ }
629
+
630
+ return lines.join('\n');
631
+ }
632
+
633
+ function writePreventionRules(filePath, minOccurrences = 2) {
634
+ const { PREVENTION_RULES_PATH } = getFeedbackPaths();
635
+ const outPath = filePath || PREVENTION_RULES_PATH;
636
+ const markdown = buildPreventionRules(minOccurrences);
637
+ ensureDir(path.dirname(outPath));
638
+ fs.writeFileSync(outPath, `${markdown}\n`);
639
+
640
+ const contextFs = getContextFsModule();
641
+ if (contextFs && typeof contextFs.registerPreventionRules === 'function') {
642
+ try {
643
+ contextFs.registerPreventionRules(markdown, { minOccurrences, outputPath: outPath });
644
+ } catch {
645
+ // Non-critical
646
+ }
647
+ }
648
+ return { path: outPath, markdown };
649
+ }
650
+
651
+ function feedbackSummary(recentN = 20) {
652
+ const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
653
+ const entries = readJSONL(FEEDBACK_LOG_PATH);
654
+ if (entries.length === 0) {
655
+ return '## Feedback Summary\nNo feedback recorded yet.';
656
+ }
657
+
658
+ const recent = entries.slice(-recentN);
659
+ const positive = recent.filter((e) => e.signal === 'positive').length;
660
+ const negative = recent.filter((e) => e.signal === 'negative').length;
661
+ const pct = Math.round((positive / recent.length) * 100);
662
+
663
+ const analysis = analyzeFeedback(FEEDBACK_LOG_PATH);
664
+
665
+ const lines = [
666
+ `## Feedback Summary (last ${recent.length})`,
667
+ `- Positive: ${positive}`,
668
+ `- Negative: ${negative}`,
669
+ `- Approval: ${pct}%`,
670
+ `- Overall approval: ${Math.round(analysis.approvalRate * 100)}%`,
671
+ ];
672
+
673
+ if (analysis.recommendations.length > 0) {
674
+ lines.push('- Recommendations:');
675
+ analysis.recommendations.slice(0, 5).forEach((r) => lines.push(` - ${r}`));
676
+ }
677
+
678
+ return lines.join('\n');
679
+ }
680
+
681
+ function parseArgs(argv) {
682
+ const args = {};
683
+ argv.forEach((arg) => {
684
+ if (!arg.startsWith('--')) return;
685
+ const [key, ...rest] = arg.slice(2).split('=');
686
+ args[key] = rest.length > 0 ? rest.join('=') : true;
687
+ });
688
+ return args;
689
+ }
690
+
691
+ function runCli() {
692
+ const args = parseArgs(process.argv.slice(2));
693
+
694
+ if (args.test) {
695
+ runTests();
696
+ return;
697
+ }
698
+
699
+ if (args.capture) {
700
+ const result = captureFeedback({
701
+ signal: args.signal,
702
+ context: args.context || '',
703
+ whatWentWrong: args['what-went-wrong'],
704
+ whatToChange: args['what-to-change'],
705
+ whatWorked: args['what-worked'],
706
+ rubricScores: args['rubric-scores'],
707
+ guardrails: args.guardrails,
708
+ tags: args.tags,
709
+ skill: args.skill,
710
+ });
711
+ console.log(JSON.stringify(result, null, 2));
712
+ process.exit(result.accepted ? 0 : 2);
713
+ }
714
+
715
+ if (args.analyze) {
716
+ console.log(JSON.stringify(analyzeFeedback(), null, 2));
717
+ return;
718
+ }
719
+
720
+ if (args.summary) {
721
+ console.log(feedbackSummary(Number(args.recent || 20)));
722
+ return;
723
+ }
724
+
725
+ if (args.rules) {
726
+ const result = writePreventionRules(args.output, Number(args.min || 2));
727
+ console.log(`Wrote prevention rules to ${result.path}`);
728
+ return;
729
+ }
730
+
731
+ console.log(`Usage:
732
+ node scripts/feedback-loop.js --capture --signal=up --context="..." --tags="verification,fix"
733
+ node scripts/feedback-loop.js --capture --signal=up --context="..." --rubric-scores='[{\"criterion\":\"correctness\",\"score\":4}]' --guardrails='{\"testsPassed\":true}'
734
+ node scripts/feedback-loop.js --analyze
735
+ node scripts/feedback-loop.js --summary --recent=20
736
+ node scripts/feedback-loop.js --rules [--min=2] [--output=path]
737
+ node scripts/feedback-loop.js --test`);
738
+ }
739
+
740
+ function runTests() {
741
+ let passed = 0;
742
+ let failed = 0;
743
+
744
+ function assert(condition, name) {
745
+ if (condition) {
746
+ passed++;
747
+ console.log(` PASS ${name}`);
748
+ } else {
749
+ failed++;
750
+ console.log(` FAIL ${name}`);
751
+ }
752
+ }
753
+
754
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'rlhf-loop-test-'));
755
+ const localFeedbackLog = path.join(tmpDir, 'feedback-log.jsonl');
756
+ process.env.RLHF_FEEDBACK_DIR = tmpDir;
757
+
758
+ appendJSONL(localFeedbackLog, { signal: 'positive', tags: ['testing'], skill: 'verify' });
759
+ appendJSONL(localFeedbackLog, { signal: 'negative', tags: ['testing'], skill: 'verify' });
760
+ appendJSONL(localFeedbackLog, { signal: 'positive', tags: ['testing'], skill: 'verify' });
761
+
762
+ const stats = analyzeFeedback(localFeedbackLog);
763
+ assert(stats.total === 3, 'analyzeFeedback counts total events');
764
+ assert(stats.totalPositive === 2, 'analyzeFeedback counts positives');
765
+ assert(stats.totalNegative === 1, 'analyzeFeedback counts negatives');
766
+ assert(stats.tags.testing.total === 3, 'analyzeFeedback tracks tags');
767
+
768
+ const good = captureFeedback({
769
+ signal: 'up',
770
+ context: 'Ran tests and included output',
771
+ whatWorked: 'Evidence-first flow',
772
+ tags: ['verification', 'testing'],
773
+ skill: 'executor',
774
+ });
775
+ assert(good.accepted, 'captureFeedback accepts valid positive feedback');
776
+
777
+ const blocked = captureFeedback({
778
+ signal: 'up',
779
+ context: 'Looks good',
780
+ whatWorked: 'Skipped proof',
781
+ tags: ['verification'],
782
+ rubricScores: JSON.stringify([
783
+ { criterion: 'verification_evidence', score: 5, judge: 'judge-a' },
784
+ { criterion: 'verification_evidence', score: 2, judge: 'judge-b', evidence: 'no test output present' },
785
+ ]),
786
+ guardrails: JSON.stringify({
787
+ testsPassed: false,
788
+ pathSafety: true,
789
+ budgetCompliant: true,
790
+ }),
791
+ });
792
+ assert(!blocked.accepted, 'captureFeedback blocks unsafe positive promotion via rubric gate');
793
+
794
+ const bad = captureFeedback({ signal: 'down' });
795
+ assert(!bad.accepted, 'captureFeedback rejects vague negative feedback');
796
+
797
+ const summary = feedbackSummary(5);
798
+ assert(summary.includes('Feedback Summary'), 'feedbackSummary returns text output');
799
+
800
+ const rules = writePreventionRules(path.join(tmpDir, 'rules.md'), 1);
801
+ assert(rules.markdown.includes('# Prevention Rules'), 'writePreventionRules writes markdown rules');
802
+ const postStats = analyzeFeedback(path.join(tmpDir, 'feedback-log.jsonl'));
803
+ assert(postStats.rubric.blockedPromotions >= 1, 'analyzeFeedback tracks blocked rubric promotions');
804
+
805
+ fs.rmSync(tmpDir, { recursive: true, force: true });
806
+ delete process.env.RLHF_FEEDBACK_DIR;
807
+ console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
808
+ process.exit(failed > 0 ? 1 : 0);
809
+ }
810
+
811
+ module.exports = {
812
+ captureFeedback,
813
+ analyzeFeedback,
814
+ buildPreventionRules,
815
+ writePreventionRules,
816
+ feedbackSummary,
817
+ readJSONL,
818
+ getFeedbackPaths,
819
+ inferDomain,
820
+ inferOutcome,
821
+ enrichFeedbackContext,
822
+ get FEEDBACK_LOG_PATH() {
823
+ return getFeedbackPaths().FEEDBACK_LOG_PATH;
824
+ },
825
+ get MEMORY_LOG_PATH() {
826
+ return getFeedbackPaths().MEMORY_LOG_PATH;
827
+ },
828
+ get SUMMARY_PATH() {
829
+ return getFeedbackPaths().SUMMARY_PATH;
830
+ },
831
+ get PREVENTION_RULES_PATH() {
832
+ return getFeedbackPaths().PREVENTION_RULES_PATH;
833
+ },
834
+ };
835
+
836
+ if (require.main === module) {
837
+ runCli();
838
+ }