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,313 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // rlhf: scripts/ is 1 level below repo root (not 2 like Subway .claude/scripts/feedback/)
8
+ const ROOT = path.join(__dirname, '..');
9
+ const PATHS = {
10
+ actionLog: path.join(ROOT, '.claude', 'memory', 'feedback', 'action-log.jsonl'),
11
+ attributions: path.join(ROOT, '.claude', 'memory', 'feedback', 'feedback-attributions.jsonl'),
12
+ attributedFeedback: path.join(ROOT, '.claude', 'memory', 'feedback', 'attributed-feedback.jsonl'),
13
+ };
14
+
15
+ const STOPWORDS = new Set([
16
+ 'about', 'after', 'again', 'allow', 'already', 'always', 'because', 'before', 'being', 'between',
17
+ 'could', 'does', 'done', 'each', 'ensure', 'every', 'from', 'have', 'into', 'just', 'make', 'more',
18
+ 'most', 'must', 'never', 'only', 'other', 'over', 'repeat', 'same', 'should', 'since', 'that',
19
+ 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'under', 'until', 'very', 'what', 'when',
20
+ 'where', 'which', 'while', 'with', 'without', 'would', 'thumbs', 'down', 'up', 'please', 'avoid',
21
+ ]);
22
+
23
+ function readJsonl(filePath, maxLines = 500) {
24
+ if (!filePath || !fs.existsSync(filePath)) return [];
25
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
26
+ const out = [];
27
+ for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i -= 1) {
28
+ const line = lines[i].trim();
29
+ if (!line) continue;
30
+ try {
31
+ out.push(JSON.parse(line));
32
+ } catch {
33
+ // ignore malformed jsonl lines
34
+ }
35
+ }
36
+ return out.reverse();
37
+ }
38
+
39
+ function appendJsonl(filePath, obj) {
40
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
41
+ fs.appendFileSync(filePath, `${JSON.stringify(obj)}\n`);
42
+ }
43
+
44
+ function normalize(text) {
45
+ return String(text || '')
46
+ .replace(/\/Users\/[^\s/]+/g, '~')
47
+ .replace(/:[0-9]+/g, '')
48
+ .replace(/\s+/g, ' ')
49
+ .trim()
50
+ .toLowerCase();
51
+ }
52
+
53
+ function stripFeedbackPrefix(text) {
54
+ return String(text || '')
55
+ .replace(/^\s*thumbs?[ -]?down\s*[:\-]?\s*/i, '')
56
+ .replace(/^\s*thumbs?[ -]?up\s*[:\-]?\s*/i, '')
57
+ .replace(/^\s*(negative|positive)\s+feedback\s*[:\-]?\s*/i, '')
58
+ .trim();
59
+ }
60
+
61
+ function tokenize(text) {
62
+ return [...new Set(
63
+ normalize(text)
64
+ .split(/[^a-z0-9]+/)
65
+ .filter(Boolean)
66
+ .filter(w => w.length >= 3 && !STOPWORDS.has(w))
67
+ )].slice(0, 24);
68
+ }
69
+
70
+ function nowIso() {
71
+ return new Date().toISOString();
72
+ }
73
+
74
+ function hashText(text) {
75
+ let h = 2166136261;
76
+ const str = String(text || '');
77
+ for (let i = 0; i < str.length; i += 1) {
78
+ h ^= str.charCodeAt(i);
79
+ h = Math.imul(h, 16777619);
80
+ }
81
+ return (h >>> 0).toString(16).padStart(8, '0');
82
+ }
83
+
84
+ function summarizeToolInput(toolName, toolInput) {
85
+ const tool = String(toolName || 'unknown');
86
+ const input = (typeof toolInput === 'string') ? toolInput : JSON.stringify(toolInput || {});
87
+ if (tool === 'Bash') {
88
+ try {
89
+ const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
90
+ if (parsed && typeof parsed.command === 'string') {
91
+ return parsed.command;
92
+ }
93
+ } catch {}
94
+ return input;
95
+ }
96
+ if (tool === 'Edit' || tool === 'Write') {
97
+ try {
98
+ const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
99
+ const p = parsed?.file_path || parsed?.path || parsed?.filePath || '';
100
+ if (p) return `${tool} ${p}`;
101
+ } catch {}
102
+ }
103
+ return input;
104
+ }
105
+
106
+ function inferIntent(toolName, text) {
107
+ const t = normalize(text);
108
+ if (/git\s+push|--force|git\s+reset|git\s+checkout/.test(t)) return 'git-risk';
109
+ if (/rm\s+-rf|sudo|chmod\s+777|chown\s+-r/.test(t)) return 'destructive-shell';
110
+ if (/\.env|secret|token|credential/.test(t)) return 'sensitive-data';
111
+ if (toolName === 'Edit' || toolName === 'Write') return 'file-change';
112
+ if (toolName === 'Bash') return 'shell-command';
113
+ return 'general';
114
+ }
115
+
116
+ function riskScore(toolName, text) {
117
+ const t = normalize(text);
118
+ let score = 2;
119
+ if (toolName === 'Bash') score += 2;
120
+ if (toolName === 'Edit' || toolName === 'Write') score += 1;
121
+ if (/--force|rm\s+-rf|reset\s+--hard|chmod\s+777|sudo/.test(t)) score += 4;
122
+ if (/\.env|secret|token|credential/.test(t)) score += 3;
123
+ return Math.max(1, Math.min(10, score));
124
+ }
125
+
126
+ function overlapScore(tokensA, tokensB) {
127
+ const a = new Set(tokensA || []);
128
+ const b = new Set(tokensB || []);
129
+ if (a.size === 0 || b.size === 0) return 0;
130
+ let inter = 0;
131
+ for (const w of a) {
132
+ if (b.has(w)) inter += 1;
133
+ }
134
+ const union = a.size + b.size - inter;
135
+ return union > 0 ? inter / union : 0;
136
+ }
137
+
138
+ function scoreCandidate(action, feedbackNormalized, feedbackTokens, nowMs) {
139
+ const ts = Date.parse(action.timestamp || '') || nowMs;
140
+ const ageMs = Math.max(0, nowMs - ts);
141
+ const recency = Math.exp(-ageMs / (6 * 60 * 1000));
142
+ const actionTokens = Array.isArray(action.keywords) && action.keywords.length > 0
143
+ ? action.keywords
144
+ : tokenize(action.normalized_input || action.input || '');
145
+ const lexical = overlapScore(feedbackTokens, actionTokens);
146
+ const containment = feedbackNormalized && normalize(action.normalized_input || '').includes(feedbackNormalized) ? 1 : 0;
147
+ const risk = Number(action.risk_score || 0) / 10;
148
+ let score = 0.45 * lexical + 0.30 * recency + 0.20 * risk + 0.05 * containment;
149
+
150
+ const f = feedbackNormalized;
151
+ if (/\bgit\b|\bpush\b|\bforce\b/.test(f) && action.tool_name === 'Bash') score += 0.1;
152
+ if (/\bedit\b|\bwrite\b|\bfile\b/.test(f) && (action.tool_name === 'Edit' || action.tool_name === 'Write')) score += 0.1;
153
+
154
+ return Math.max(0, Math.min(1, score));
155
+ }
156
+
157
+ function recordAction(toolName, toolInput, opts = {}) {
158
+ const actionLogPath = opts.actionLogPath || process.env.RLHF_ACTION_LOG || PATHS.actionLog;
159
+ const tool = String(toolName || 'unknown');
160
+ const inputSummary = summarizeToolInput(tool, toolInput);
161
+ const normalized = normalize(inputSummary);
162
+ const row = {
163
+ action_id: `act_${Date.now()}_${hashText(`${tool}|${normalized}`)}`,
164
+ timestamp: nowIso(),
165
+ tool_name: tool,
166
+ input: inputSummary,
167
+ normalized_input: normalized,
168
+ intent: inferIntent(tool, inputSummary),
169
+ keywords: tokenize(inputSummary),
170
+ risk_score: riskScore(tool, inputSummary),
171
+ };
172
+ appendJsonl(actionLogPath, row);
173
+ return { ok: true, action: row, actionLogPath };
174
+ }
175
+
176
+ function attributeFeedback(signal, feedbackContext, opts = {}) {
177
+ const sig = String(signal || '').toLowerCase().trim();
178
+ const actionLogPath = opts.actionLogPath || process.env.RLHF_ACTION_LOG || PATHS.actionLog;
179
+ const attributionsPath = opts.attributionsPath || process.env.RLHF_FEEDBACK_ATTRIBUTIONS || PATHS.attributions;
180
+ const attributedFeedbackPath = opts.attributedFeedbackPath || process.env.RLHF_ATTRIBUTED_FEEDBACK || PATHS.attributedFeedback;
181
+
182
+ if (sig !== 'negative' && sig !== 'positive') {
183
+ return { ok: true, skipped: true, reason: 'signal_not_supported' };
184
+ }
185
+
186
+ const nowMs = Date.now();
187
+ const feedbackText = stripFeedbackPrefix(feedbackContext);
188
+ const feedbackNormalized = normalize(feedbackText);
189
+ const feedbackTokens = tokenize(feedbackText);
190
+ const windowMs = Number(opts.windowMs || process.env.RLHF_ATTRIBUTION_WINDOW_MS || (20 * 60 * 1000));
191
+ const maxActions = Number(opts.maxActions || 80);
192
+ const topK = Number(opts.topK || 3);
193
+ const threshold = Number(opts.threshold || (sig === 'negative' ? 0.33 : 0.38));
194
+
195
+ const actions = readJsonl(actionLogPath, maxActions)
196
+ .filter(a => {
197
+ const ts = Date.parse(a.timestamp || '') || nowMs;
198
+ return (nowMs - ts) <= windowMs;
199
+ })
200
+ .reverse();
201
+
202
+ const scored = actions.map(action => {
203
+ const confidence = scoreCandidate(action, feedbackNormalized, feedbackTokens, nowMs);
204
+ return { action, confidence };
205
+ }).sort((a, b) => b.confidence - a.confidence);
206
+
207
+ const selected = scored.filter(s => s.confidence >= threshold).slice(0, topK);
208
+
209
+ const attribution = {
210
+ attribution_id: `att_${Date.now()}_${hashText(`${sig}|${feedbackNormalized}`)}`,
211
+ timestamp: nowIso(),
212
+ signal: sig,
213
+ feedback_context: feedbackContext,
214
+ feedback_normalized: feedbackNormalized,
215
+ threshold,
216
+ candidates_considered: scored.length,
217
+ attributed_actions: selected.map(s => ({
218
+ action_id: s.action.action_id,
219
+ tool_name: s.action.tool_name,
220
+ input: s.action.input,
221
+ normalized_input: s.action.normalized_input,
222
+ intent: s.action.intent,
223
+ confidence: Number(s.confidence.toFixed(4)),
224
+ risk_score: s.action.risk_score,
225
+ })),
226
+ };
227
+
228
+ appendJsonl(attributionsPath, attribution);
229
+
230
+ if (sig === 'negative') {
231
+ for (const s of selected) {
232
+ appendJsonl(attributedFeedbackPath, {
233
+ timestamp: attribution.timestamp,
234
+ signal: sig,
235
+ feedback: sig,
236
+ source: 'attributed',
237
+ source_detail: 'feedback-attribution-engine',
238
+ context: s.action.input,
239
+ tool_name: s.action.tool_name,
240
+ attributed_action_id: s.action.action_id,
241
+ attribution_id: attribution.attribution_id,
242
+ confidence: Number(s.confidence.toFixed(4)),
243
+ intent: s.action.intent,
244
+ });
245
+ }
246
+ }
247
+
248
+ return {
249
+ ok: true,
250
+ signal: sig,
251
+ attributedCount: selected.length,
252
+ topConfidence: selected.length ? Number(selected[0].confidence.toFixed(4)) : 0,
253
+ attributionId: attribution.attribution_id,
254
+ actionLogPath,
255
+ attributionsPath,
256
+ attributedFeedbackPath,
257
+ };
258
+ }
259
+
260
+ function main() {
261
+ const args = process.argv.slice(2);
262
+ const recordIdx = args.indexOf('--record-action');
263
+ const attrIdx = args.indexOf('--attribute');
264
+
265
+ if (recordIdx >= 0) {
266
+ const toolName = args[recordIdx + 1] || 'unknown';
267
+ const toolInput = args[recordIdx + 2] || '{}';
268
+ const result = recordAction(toolName, toolInput);
269
+ process.stdout.write(JSON.stringify(result, null, 2));
270
+ return;
271
+ }
272
+
273
+ if (attrIdx >= 0) {
274
+ const signal = args[attrIdx + 1] || '';
275
+ const context = args[attrIdx + 2] || '';
276
+ const result = attributeFeedback(signal, context);
277
+ process.stdout.write(JSON.stringify(result, null, 2));
278
+ return;
279
+ }
280
+
281
+ process.stdout.write(JSON.stringify({
282
+ ok: true,
283
+ usage: [
284
+ '--record-action <toolName> <toolInputJson>',
285
+ '--attribute <signal> <feedbackContext>',
286
+ ],
287
+ }, null, 2));
288
+ }
289
+
290
+ if (require.main === module) {
291
+ try {
292
+ main();
293
+ } catch (error) {
294
+ console.error(`[ATTRIBUTION WARNING] ${error.message}`);
295
+ process.exit(0);
296
+ }
297
+ }
298
+
299
+ module.exports = {
300
+ PATHS,
301
+ readJsonl,
302
+ appendJsonl,
303
+ normalize,
304
+ stripFeedbackPrefix,
305
+ tokenize,
306
+ inferIntent,
307
+ riskScore,
308
+ summarizeToolInput,
309
+ overlapScore,
310
+ scoreCandidate,
311
+ recordAction,
312
+ attributeFeedback,
313
+ };
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Feedback Inbox Reader
4
+ *
5
+ * Reads new feedback entries from the inbox JSONL file, using a cursor
6
+ * to avoid reprocessing. External systems (Phoenix bridge, other agents)
7
+ * append feedback signals to the inbox; Amp's reflexion-preflight skill
8
+ * calls this script each turn to ingest new signals.
9
+ *
10
+ * Usage:
11
+ * node scripts/feedback-inbox-read.js # output new entries as JSON array
12
+ * node scripts/feedback-inbox-read.js --peek # show count without advancing cursor
13
+ * node scripts/feedback-inbox-read.js --reset # reset cursor to re-read all
14
+ * node scripts/feedback-inbox-read.js --test # run built-in tests
15
+ *
16
+ * Inbox: .claude/feedback-loop/inbox.jsonl
17
+ * Cursor: .claude/feedback-loop/inbox.cursor.json
18
+ */
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const PROJECT_ROOT = path.join(__dirname, '..');
25
+ const INBOX_PATH = path.join(PROJECT_ROOT, '.claude', 'feedback-loop', 'inbox.jsonl');
26
+ const CURSOR_PATH = path.join(PROJECT_ROOT, '.claude', 'feedback-loop', 'inbox.cursor.json');
27
+
28
+ function readInbox() {
29
+ if (!fs.existsSync(INBOX_PATH)) return [];
30
+ const raw = fs.readFileSync(INBOX_PATH, 'utf-8').trim();
31
+ if (!raw) return [];
32
+ return raw.split('\n').map((line, idx) => {
33
+ try {
34
+ return { _lineIndex: idx, ...JSON.parse(line) };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }).filter(Boolean);
39
+ }
40
+
41
+ function loadCursor() {
42
+ if (!fs.existsSync(CURSOR_PATH)) return { lastLineIndex: -1 };
43
+ try {
44
+ return JSON.parse(fs.readFileSync(CURSOR_PATH, 'utf-8'));
45
+ } catch {
46
+ return { lastLineIndex: -1 };
47
+ }
48
+ }
49
+
50
+ function saveCursor(cursor) {
51
+ const dir = path.dirname(CURSOR_PATH);
52
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
53
+ fs.writeFileSync(CURSOR_PATH, JSON.stringify(cursor, null, 2) + '\n');
54
+ }
55
+
56
+ function getNewEntries(advance) {
57
+ const entries = readInbox();
58
+ if (entries.length === 0) return [];
59
+
60
+ const cursor = loadCursor();
61
+ const newEntries = entries.filter((e) => e._lineIndex > cursor.lastLineIndex);
62
+
63
+ if (advance && newEntries.length > 0) {
64
+ const maxIdx = Math.max(...newEntries.map((e) => e._lineIndex));
65
+ saveCursor({ lastLineIndex: maxIdx, updatedAt: new Date().toISOString() });
66
+ }
67
+
68
+ // Strip internal _lineIndex before returning
69
+ return newEntries.map(({ _lineIndex, ...rest }) => rest);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // CLI
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function runTests() {
77
+ const os = require('os');
78
+ let passed = 0;
79
+ let failed = 0;
80
+
81
+ function assert(condition, name) {
82
+ if (condition) { passed++; console.log(` โœ… ${name}`); }
83
+ else { failed++; console.log(` โŒ ${name}`); }
84
+ }
85
+
86
+ console.log('\n๐Ÿงช feedback-inbox-read.js โ€” Tests\n');
87
+
88
+ // Setup temp inbox
89
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inbox-test-'));
90
+ const tmpInbox = path.join(tmpDir, 'inbox.jsonl');
91
+ const tmpCursor = path.join(tmpDir, 'inbox.cursor.json');
92
+
93
+ // Write test entries
94
+ const entries = [
95
+ { signal: 'negative', context: 'Bad thing happened', tags: ['testing'] },
96
+ { signal: 'positive', context: 'Good thing happened', tags: ['testing'] },
97
+ { signal: 'negative', context: 'Another bad thing', tags: ['rlhf'] },
98
+ ];
99
+ fs.writeFileSync(tmpInbox, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
100
+
101
+ // Test reading all (no cursor)
102
+ const allEntries = (() => {
103
+ const raw = fs.readFileSync(tmpInbox, 'utf-8').trim();
104
+ return raw.split('\n').map((line, idx) => {
105
+ try { return { _lineIndex: idx, ...JSON.parse(line) }; }
106
+ catch { return null; }
107
+ }).filter(Boolean);
108
+ })();
109
+ assert(allEntries.length === 3, 'reads all 3 entries from inbox');
110
+
111
+ // Test cursor-based filtering
112
+ fs.writeFileSync(tmpCursor, JSON.stringify({ lastLineIndex: 0 }));
113
+ const afterFirst = allEntries.filter((e) => e._lineIndex > 0);
114
+ assert(afterFirst.length === 2, 'cursor at 0 โ†’ 2 new entries');
115
+
116
+ fs.writeFileSync(tmpCursor, JSON.stringify({ lastLineIndex: 2 }));
117
+ const afterAll = allEntries.filter((e) => e._lineIndex > 2);
118
+ assert(afterAll.length === 0, 'cursor at 2 โ†’ 0 new entries');
119
+
120
+ // Test loadCursor with missing file
121
+ const missingCursor = path.join(tmpDir, 'missing.cursor.json');
122
+ const fakeMod = { loadCursor: () => {
123
+ if (!fs.existsSync(missingCursor)) return { lastLineIndex: -1 };
124
+ try { return JSON.parse(fs.readFileSync(missingCursor, 'utf-8')); } catch { return { lastLineIndex: -1 }; }
125
+ }};
126
+ assert(fakeMod.loadCursor().lastLineIndex === -1, 'loadCursor returns -1 for missing file');
127
+
128
+ // Test saveCursor creates directory
129
+ const deepDir = path.join(tmpDir, 'deep', 'nested');
130
+ const deepCursor = path.join(deepDir, 'cursor.json');
131
+ if (!fs.existsSync(deepDir)) fs.mkdirSync(deepDir, { recursive: true });
132
+ fs.writeFileSync(deepCursor, JSON.stringify({ lastLineIndex: 5, updatedAt: new Date().toISOString() }) + '\n');
133
+ const loaded = JSON.parse(fs.readFileSync(deepCursor, 'utf-8'));
134
+ assert(loaded.lastLineIndex === 5, 'saveCursor persists cursor value');
135
+
136
+ // Cleanup
137
+ fs.rmSync(tmpDir, { recursive: true, force: true });
138
+
139
+ console.log(`\n${'โ•'.repeat(50)}`);
140
+ console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`);
141
+ console.log(`${'โ•'.repeat(50)}\n`);
142
+ process.exit(failed > 0 ? 1 : 0);
143
+ }
144
+
145
+ if (require.main === module) {
146
+ if (process.argv.includes('--test')) {
147
+ runTests();
148
+ } else if (process.argv.includes('--reset')) {
149
+ if (fs.existsSync(CURSOR_PATH)) fs.unlinkSync(CURSOR_PATH);
150
+ console.log('Cursor reset.');
151
+ } else {
152
+ const peek = process.argv.includes('--peek');
153
+ const entries = getNewEntries(!peek);
154
+ if (entries.length === 0) {
155
+ // Silent โ€” no new entries
156
+ process.exit(0);
157
+ }
158
+ process.stdout.write(JSON.stringify(entries, null, 2) + '\n');
159
+ }
160
+ }
161
+
162
+ module.exports = { getNewEntries, readInbox, loadCursor, saveCursor, INBOX_PATH, CURSOR_PATH };