thumbgate 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/README.md +25 -0
- package/.claude-plugin/marketplace.json +32 -13
- package/.claude-plugin/plugin.json +15 -2
- package/.well-known/llms.txt +60 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +242 -126
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/INSTALL.md +59 -4
- package/adapters/chatgpt/openapi.yaml +168 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +84 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +204 -13
- package/bin/postinstall.js +8 -2
- package/config/budget.json +18 -0
- package/config/gates/code-edit.json +61 -0
- package/config/gates/db-write.json +61 -0
- package/config/gates/default.json +154 -3
- package/config/gates/deploy.json +61 -0
- package/config/github-about.json +2 -1
- package/config/merge-quality-checks.json +23 -0
- package/openapi/openapi.yaml +168 -0
- package/package.json +47 -11
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +1 -1
- package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +1 -1
- package/plugins/codex-profile/INSTALL.md +27 -4
- package/plugins/codex-profile/README.md +33 -9
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/blog.html +73 -0
- package/public/compare/mem0.html +189 -0
- package/public/compare/speclock.html +180 -0
- package/public/compare.html +10 -2
- package/public/guide.html +2 -2
- package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
- package/public/guides/codex-cli-guardrails.html +158 -0
- package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
- package/public/guides/pre-action-gates.html +162 -0
- package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
- package/public/index.html +172 -65
- package/public/lessons.html +33 -24
- package/public/llm-context.md +140 -0
- package/public/pro.html +24 -22
- package/scripts/access-anomaly-detector.js +1 -1
- package/scripts/adk-consolidator.js +1 -5
- package/scripts/agent-security-hardening.js +4 -6
- package/scripts/agentic-data-pipeline.js +1 -3
- package/scripts/async-job-runner.js +1 -5
- package/scripts/audit-trail.js +1 -5
- package/scripts/auto-promote-gates.js +5 -3
- package/scripts/background-agent-governance.js +2 -10
- package/scripts/billing-setup.js +109 -0
- package/scripts/billing.js +2 -16
- package/scripts/budget-enforcer.js +173 -0
- package/scripts/build-claude-mcpb.js +71 -5
- package/scripts/build-codex-plugin.js +152 -0
- package/scripts/check-congruence.js +132 -14
- package/scripts/commercial-offer.js +5 -7
- package/scripts/content-engine/linkedin-content-generator.js +154 -0
- package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
- package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
- package/scripts/content-engine/reddit-thread-finder.js +154 -0
- package/scripts/context-engine.js +21 -6
- package/scripts/contextfs.js +1 -21
- package/scripts/dashboard.js +20 -0
- package/scripts/decision-journal.js +341 -0
- package/scripts/delegation-runtime.js +1 -5
- package/scripts/distribution-surfaces.js +54 -0
- package/scripts/document-intake.js +927 -0
- package/scripts/ephemeral-agent-store.js +1 -8
- package/scripts/evolution-state.js +1 -5
- package/scripts/experiment-tracker.js +1 -5
- package/scripts/export-databricks-bundle.js +1 -5
- package/scripts/export-hf-dataset.js +1 -5
- package/scripts/export-training.js +1 -5
- package/scripts/feedback-attribution.js +1 -16
- package/scripts/feedback-history-distiller.js +1 -16
- package/scripts/feedback-loop.js +1 -5
- package/scripts/feedback-root-consolidator.js +2 -21
- package/scripts/feedback-session.js +49 -0
- package/scripts/feedback-to-rules.js +215 -36
- package/scripts/filesystem-search.js +1 -9
- package/scripts/fs-utils.js +104 -0
- package/scripts/gates-engine.js +200 -11
- package/scripts/github-about.js +32 -8
- package/scripts/gtm-revenue-loop.js +1 -5
- package/scripts/harness-selector.js +148 -0
- package/scripts/hosted-config.js +2 -0
- package/scripts/hosted-job-launcher.js +1 -5
- package/scripts/hybrid-feedback-context.js +33 -49
- package/scripts/intervention-policy.js +58 -1
- package/scripts/lesson-db.js +3 -18
- package/scripts/lesson-inference.js +194 -16
- package/scripts/lesson-retrieval.js +60 -24
- package/scripts/llm-client.js +59 -0
- package/scripts/managed-lesson-agent.js +183 -0
- package/scripts/marketing-experiment.js +8 -22
- package/scripts/meta-agent-loop.js +624 -0
- package/scripts/metered-billing.js +1 -1
- package/scripts/money-watcher.js +1 -4
- package/scripts/obsidian-export.js +1 -5
- package/scripts/operational-integrity.js +15 -3
- package/scripts/operational-summary.js +41 -5
- package/scripts/org-dashboard.js +6 -1
- package/scripts/per-step-scoring.js +2 -4
- package/scripts/pr-manager.js +201 -19
- package/scripts/pro-features.js +3 -2
- package/scripts/prompt-dlp.js +3 -3
- package/scripts/prove-adapters.js +1 -5
- package/scripts/prove-attribution.js +1 -5
- package/scripts/prove-automation.js +1 -3
- package/scripts/prove-cloudflare-sandbox.js +1 -3
- package/scripts/prove-data-pipeline.js +1 -3
- package/scripts/prove-intelligence.js +1 -3
- package/scripts/prove-lancedb.js +1 -5
- package/scripts/prove-local-intelligence.js +1 -3
- package/scripts/prove-packaged-runtime.js +75 -9
- package/scripts/prove-predictive-insights.js +1 -3
- package/scripts/prove-training-export.js +1 -3
- package/scripts/prove-workflow-contract.js +1 -5
- package/scripts/ralph-loop.js +376 -0
- package/scripts/ralph-mode-ci.js +331 -0
- package/scripts/rate-limiter.js +3 -1
- package/scripts/reddit-dm-outreach.js +14 -4
- package/scripts/rotate-stripe-webhook-secret.js +314 -0
- package/scripts/schedule-manager.js +3 -5
- package/scripts/security-scanner.js +448 -0
- package/scripts/self-distill-agent.js +579 -0
- package/scripts/semantic-dedup.js +115 -0
- package/scripts/skill-exporter.js +1 -3
- package/scripts/skill-generator.js +1 -5
- package/scripts/social-analytics/engagement-audit.js +1 -18
- package/scripts/social-analytics/pollers/linkedin.js +26 -16
- package/scripts/social-analytics/publishers/linkedin.js +1 -1
- package/scripts/social-analytics/publishers/zernio.js +51 -0
- package/scripts/social-pipeline.js +1 -3
- package/scripts/social-post-hourly.js +47 -4
- package/scripts/statusline-links.js +6 -5
- package/scripts/statusline.sh +29 -153
- package/scripts/sync-branch-protection.js +340 -0
- package/scripts/tessl-export.js +1 -3
- package/scripts/thumbgate-search.js +32 -1
- package/scripts/tool-kpi-tracker.js +1 -1
- package/scripts/tool-registry.js +106 -2
- package/scripts/vector-store.js +1 -5
- package/scripts/weekly-auto-post.js +1 -1
- package/scripts/workflow-sentinel.js +91 -0
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +296 -7
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
- /package/scripts/social-analytics/db/{social-analytics.db-wal → analytics.sqlite} +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Reddit Thread Finder for ThumbGate Engagement
|
|
4
|
+
*
|
|
5
|
+
* Add to package.json:
|
|
6
|
+
* "content:reddit": "node scripts/content-engine/reddit-thread-finder.js"
|
|
7
|
+
* "content:reddit:dry": "node scripts/content-engine/reddit-thread-finder.js --dry-run"
|
|
8
|
+
* "content:reddit:limit": "node scripts/content-engine/reddit-thread-finder.js --limit 20"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const SUBREDDITS = [
|
|
16
|
+
'ChatGPTCoding', 'ClaudeAI', 'cursor', 'devops',
|
|
17
|
+
'SoftwareEngineering', 'ExperiencedDevs', 'MachineLearning', 'LocalLLaMA'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const KEYWORDS = [
|
|
21
|
+
'agent broke', 'agent deleted', 'force push', 'prevent AI from',
|
|
22
|
+
'guardrails', 'agent governance', 'coding agent mistakes'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const USER_AGENT = 'script:thumbgate-content:v1.0';
|
|
26
|
+
const DELAY = 2000; // ms between requests
|
|
27
|
+
const DEFAULT_LIMIT = 10;
|
|
28
|
+
|
|
29
|
+
let dryRun = false;
|
|
30
|
+
let outputLimit = DEFAULT_LIMIT;
|
|
31
|
+
|
|
32
|
+
process.argv.slice(2).forEach(arg => {
|
|
33
|
+
if (arg === '--dry-run') dryRun = true;
|
|
34
|
+
if (arg.startsWith('--limit')) outputLimit = parseInt(arg.split('=')[1] || arg.split(' ')[1], 10);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function fetchReddit(url) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const req = https.get(url, { headers: { 'User-Agent': USER_AGENT } }, res => {
|
|
40
|
+
let data = '';
|
|
41
|
+
res.on('data', chunk => data += chunk);
|
|
42
|
+
res.on('end', () => {
|
|
43
|
+
try {
|
|
44
|
+
resolve(JSON.parse(data));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
reject(e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function searchSubreddit(sub, keyword) {
|
|
55
|
+
const url = `https://www.reddit.com/r/${sub}/search.json?q=${encodeURIComponent(keyword)}&sort=new&t=week&limit=5`;
|
|
56
|
+
try {
|
|
57
|
+
const data = await fetchReddit(url);
|
|
58
|
+
return (data.data?.children || []).map(post => ({
|
|
59
|
+
id: post.data.id,
|
|
60
|
+
title: post.data.title,
|
|
61
|
+
url: `https://reddit.com${post.data.permalink}`,
|
|
62
|
+
subreddit: post.data.subreddit,
|
|
63
|
+
score: post.data.score,
|
|
64
|
+
numComments: post.data.num_comments,
|
|
65
|
+
created: post.data.created_utc,
|
|
66
|
+
selftext: post.data.selftext
|
|
67
|
+
}));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`Error searching ${sub} for "${keyword}": ${err.message}`);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreThread(thread) {
|
|
75
|
+
const now = Math.floor(Date.now() / 1000);
|
|
76
|
+
const ageHours = (now - thread.created) / 3600;
|
|
77
|
+
const recencyScore = Math.max(0, 1 - ageHours / 168); // 0-1 over a week
|
|
78
|
+
const upvoteScore = Math.log(Math.max(1, thread.score)) / Math.log(100);
|
|
79
|
+
const commentScore = Math.log(Math.max(1, thread.numComments)) / Math.log(100);
|
|
80
|
+
|
|
81
|
+
return (recencyScore * 0.5) + (upvoteScore * 0.3) + (commentScore * 0.2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function generateReply(thread) {
|
|
85
|
+
const context = thread.selftext.substring(0, 200);
|
|
86
|
+
return `
|
|
87
|
+
**ThumbGate can help prevent this.** Our pre-action gates catch agent mistakes before they happen:
|
|
88
|
+
- Stop force pushes on protected branches
|
|
89
|
+
- Prevent deletions of critical files
|
|
90
|
+
- Verify AI actions before execution
|
|
91
|
+
- Capture lessons from failures to block similar mistakes
|
|
92
|
+
|
|
93
|
+
Learn more: https://thumbgate-production.up.railway.app/dashboard
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
console.log(`[${new Date().toISOString()}] Starting Reddit thread finder...`);
|
|
99
|
+
|
|
100
|
+
const threads = {};
|
|
101
|
+
let requestCount = 0;
|
|
102
|
+
|
|
103
|
+
for (const sub of SUBREDDITS) {
|
|
104
|
+
for (const keyword of KEYWORDS) {
|
|
105
|
+
if (requestCount > 0) await new Promise(r => setTimeout(r, DELAY));
|
|
106
|
+
|
|
107
|
+
console.log(` Searching r/${sub} for "${keyword}"...`);
|
|
108
|
+
const results = await searchSubreddit(sub, keyword);
|
|
109
|
+
|
|
110
|
+
results.forEach(thread => {
|
|
111
|
+
if (!threads[thread.id]) {
|
|
112
|
+
threads[thread.id] = thread;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
requestCount++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sorted = Object.values(threads)
|
|
120
|
+
.sort((a, b) => scoreThread(b) - scoreThread(a))
|
|
121
|
+
.slice(0, outputLimit);
|
|
122
|
+
|
|
123
|
+
const date = new Date().toISOString().split('T')[0];
|
|
124
|
+
const outputDir = path.join(__dirname, 'output');
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(outputDir)) {
|
|
127
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let markdown = `# Reddit Threads - ${date}\n\nFound ${sorted.length} high-relevance threads.\n\n`;
|
|
131
|
+
|
|
132
|
+
sorted.forEach((thread, idx) => {
|
|
133
|
+
const score = scoreThread(thread);
|
|
134
|
+
markdown += `## ${idx + 1}. ${thread.title}\n`;
|
|
135
|
+
markdown += `**r/${thread.subreddit}** | [Link](${thread.url}) | Score: ${thread.score} | Comments: ${thread.numComments}\n`;
|
|
136
|
+
markdown += `**Relevance Score:** ${score.toFixed(2)}\n\n`;
|
|
137
|
+
|
|
138
|
+
if (!dryRun) {
|
|
139
|
+
markdown += `**Suggested Reply:**\n${generateReply(thread)}\n\n`;
|
|
140
|
+
}
|
|
141
|
+
markdown += '---\n\n';
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const outputFile = path.join(outputDir, `reddit-threads-${date}.md`);
|
|
145
|
+
fs.writeFileSync(outputFile, markdown);
|
|
146
|
+
|
|
147
|
+
console.log(`\n✅ Generated ${sorted.length} threads to ${outputFile}`);
|
|
148
|
+
if (dryRun) console.log(' (--dry-run: no reply suggestions included)');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
main().catch(err => {
|
|
152
|
+
console.error('❌ Fatal error:', err.message);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
});
|
|
@@ -20,6 +20,7 @@ const fs = require('fs');
|
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const crypto = require('crypto');
|
|
22
22
|
const { constructContextPack } = require('./contextfs');
|
|
23
|
+
const { ensureDir } = require('./fs-utils');
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Default paths
|
|
@@ -75,11 +76,6 @@ const TOOL_CONSOLIDATION = {
|
|
|
75
76
|
// Utility: ensure directory exists
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
|
|
78
|
-
function ensureDir(dirPath) {
|
|
79
|
-
if (!fs.existsSync(dirPath)) {
|
|
80
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
79
|
|
|
84
80
|
// ---------------------------------------------------------------------------
|
|
85
81
|
// Knowledge Bundle Builder
|
|
@@ -649,10 +645,29 @@ function compactContext(entries, anchors, opts) {
|
|
|
649
645
|
return true;
|
|
650
646
|
});
|
|
651
647
|
|
|
648
|
+
// Stage 6: Global token budget — drop entries (oldest first) until total chars fit
|
|
649
|
+
let finalStage = 5;
|
|
650
|
+
const totalMaxChars = typeof options.totalMaxChars === 'number' ? options.totalMaxChars : null;
|
|
651
|
+
if (totalMaxChars !== null) {
|
|
652
|
+
let budget = totalMaxChars;
|
|
653
|
+
const budgeted = [];
|
|
654
|
+
// Iterate newest-first so most recent entries are preserved
|
|
655
|
+
for (let i = working.length - 1; i >= 0; i--) {
|
|
656
|
+
const entrySize = JSON.stringify(working[i]).length;
|
|
657
|
+
if (budget - entrySize < 0) break;
|
|
658
|
+
budget -= entrySize;
|
|
659
|
+
budgeted.unshift(working[i]);
|
|
660
|
+
}
|
|
661
|
+
if (budgeted.length < working.length) {
|
|
662
|
+
working = budgeted;
|
|
663
|
+
finalStage = 6;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
652
667
|
const removedCount = initial - working.length;
|
|
653
668
|
return {
|
|
654
669
|
entries: [...anchorEntries, ...working],
|
|
655
|
-
stage:
|
|
670
|
+
stage: finalStage,
|
|
656
671
|
removedCount,
|
|
657
672
|
compacted: removedCount > 0,
|
|
658
673
|
};
|
package/scripts/contextfs.js
CHANGED
|
@@ -12,6 +12,7 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const crypto = require('crypto');
|
|
14
14
|
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
15
|
+
const { ensureDir, readJsonl } = require('./fs-utils');
|
|
15
16
|
const {
|
|
16
17
|
retrieveHierarchicalDocuments,
|
|
17
18
|
shouldUseHierarchicalRetrieval,
|
|
@@ -99,11 +100,6 @@ const PACK_TEMPLATES = {
|
|
|
99
100
|
},
|
|
100
101
|
};
|
|
101
102
|
|
|
102
|
-
function ensureDir(dirPath) {
|
|
103
|
-
if (!fs.existsSync(dirPath)) {
|
|
104
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
103
|
|
|
108
104
|
function ensureContextFs() {
|
|
109
105
|
Object.values(NAMESPACES).forEach((subPath) => {
|
|
@@ -140,22 +136,6 @@ function appendJsonl(filePath, payload) {
|
|
|
140
136
|
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`);
|
|
141
137
|
}
|
|
142
138
|
|
|
143
|
-
function readJsonl(filePath) {
|
|
144
|
-
if (!fs.existsSync(filePath)) return [];
|
|
145
|
-
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
146
|
-
if (!raw) return [];
|
|
147
|
-
return raw
|
|
148
|
-
.split('\n')
|
|
149
|
-
.map((line) => {
|
|
150
|
-
try {
|
|
151
|
-
return JSON.parse(line);
|
|
152
|
-
} catch {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
.filter(Boolean);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
139
|
function listJsonFiles(dirPath) {
|
|
160
140
|
if (!fs.existsSync(dirPath)) return [];
|
|
161
141
|
const files = fs.readdirSync(dirPath, { withFileTypes: true });
|
package/scripts/dashboard.js
CHANGED
|
@@ -21,6 +21,7 @@ const { getSettingsStatus } = require('./settings-hierarchy');
|
|
|
21
21
|
const { summarizeWorkflowRuns } = require('./workflow-runs');
|
|
22
22
|
const { searchLessons } = require('./lesson-search');
|
|
23
23
|
const { getInterventionPolicySummary } = require('./intervention-policy');
|
|
24
|
+
const { computeDecisionMetrics } = require('./decision-journal');
|
|
24
25
|
|
|
25
26
|
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
26
27
|
const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
|
|
@@ -787,6 +788,7 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
787
788
|
const readiness = generateAgentReadinessReport({ projectRoot: PROJECT_ROOT });
|
|
788
789
|
const harness = computeHarnessOverview(feedbackDir, entries);
|
|
789
790
|
const interventionPolicy = getInterventionPolicySummary(feedbackDir);
|
|
791
|
+
const decisions = computeDecisionMetrics(feedbackDir);
|
|
790
792
|
const settingsStatus = getSettingsStatus({ projectRoot: PROJECT_ROOT });
|
|
791
793
|
settingsStatus.routingPreview = {
|
|
792
794
|
dashboardTool: routeProfile({
|
|
@@ -820,6 +822,13 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
820
822
|
lessonEffectiveness: { rate: totalNeg > 0 ? Math.round((autoGates / totalNeg) * 10000) / 100 : 0, totalNegative: totalNeg, autoGatesCreated: autoGates },
|
|
821
823
|
errorTrend: { direction: lastWeekNeg > 0 ? (negRecent.length < lastWeekNeg ? 'improving' : negRecent.length > lastWeekNeg ? 'worsening' : 'stable') : (negRecent.length > 0 ? 'new-errors' : 'clean'), thisWeek: negRecent.length, lastWeek: lastWeekNeg },
|
|
822
824
|
weeklyActivity: { positive: posRecent.length, negative: negRecent.length, total: recentEntries.length },
|
|
825
|
+
decisionLoop: {
|
|
826
|
+
fastPathRate: decisions.fastPathRate,
|
|
827
|
+
overrideRate: decisions.overrideRate,
|
|
828
|
+
rollbackRate: decisions.rollbackRate,
|
|
829
|
+
medianLatencyMs: decisions.medianLatencyMs,
|
|
830
|
+
resolvedCount: decisions.resolvedCount,
|
|
831
|
+
},
|
|
823
832
|
};
|
|
824
833
|
|
|
825
834
|
const team = generateOrgDashboard({
|
|
@@ -857,6 +866,7 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
857
866
|
instrumentation,
|
|
858
867
|
readiness,
|
|
859
868
|
interventionPolicy,
|
|
869
|
+
decisions,
|
|
860
870
|
settingsStatus,
|
|
861
871
|
team,
|
|
862
872
|
templateLibrary,
|
|
@@ -886,6 +896,7 @@ function printDashboard(data) {
|
|
|
886
896
|
instrumentation,
|
|
887
897
|
readiness,
|
|
888
898
|
interventionPolicy,
|
|
899
|
+
decisions,
|
|
889
900
|
settingsStatus,
|
|
890
901
|
team,
|
|
891
902
|
templateLibrary,
|
|
@@ -945,6 +956,14 @@ function printDashboard(data) {
|
|
|
945
956
|
console.log(` Top Deny Signal : ${interventionPolicy.topTokens.deny[0].token}`);
|
|
946
957
|
}
|
|
947
958
|
|
|
959
|
+
console.log('');
|
|
960
|
+
console.log('🧭 Decision Loop');
|
|
961
|
+
console.log(` Evaluations : ${decisions.evaluationCount}`);
|
|
962
|
+
console.log(` Fast Path : ${Math.round((decisions.fastPathRate || 0) * 100)}%`);
|
|
963
|
+
console.log(` Override Rate : ${Math.round((decisions.overrideRate || 0) * 100)}%`);
|
|
964
|
+
console.log(` Rollback Rate : ${Math.round((decisions.rollbackRate || 0) * 100)}%`);
|
|
965
|
+
console.log(` Median Latency : ${Math.round((decisions.medianLatencyMs || 0) / 1000)}s`);
|
|
966
|
+
|
|
948
967
|
console.log('');
|
|
949
968
|
console.log('🎯 North Star');
|
|
950
969
|
console.log(` Weekly Proof Runs: ${analytics.northStar.weeklyActiveProofBackedWorkflowRuns}`);
|
|
@@ -1120,6 +1139,7 @@ module.exports = {
|
|
|
1120
1139
|
generateDashboard,
|
|
1121
1140
|
printDashboard,
|
|
1122
1141
|
computeApprovalStats,
|
|
1142
|
+
computeDecisionMetrics,
|
|
1123
1143
|
computeGateStats,
|
|
1124
1144
|
computePreventionImpact,
|
|
1125
1145
|
computeSessionTrend,
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
8
|
+
const { sanitizeToolInput } = require('./audit-trail');
|
|
9
|
+
const { ensureDir } = require('./fs-utils');
|
|
10
|
+
|
|
11
|
+
const DECISION_LOG_FILENAME = 'decision-journal.jsonl';
|
|
12
|
+
const DEFAULT_DAY_COUNT = 14;
|
|
13
|
+
const RESOLVED_OUTCOMES = new Set(['accepted', 'completed', 'overridden', 'rolled_back', 'blocked', 'aborted']);
|
|
14
|
+
const DECISION_OUTCOMES = new Set([...RESOLVED_OUTCOMES, 'warned']);
|
|
15
|
+
|
|
16
|
+
function getDecisionLogPath(feedbackDir) {
|
|
17
|
+
return path.join(resolveFeedbackDir({ feedbackDir }), DECISION_LOG_FILENAME);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function buildActionId(prefix = 'decision') {
|
|
22
|
+
return `${prefix}_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readDecisionLog(logPath) {
|
|
26
|
+
const targetPath = logPath || getDecisionLogPath();
|
|
27
|
+
if (!fs.existsSync(targetPath)) return [];
|
|
28
|
+
const raw = fs.readFileSync(targetPath, 'utf8').trim();
|
|
29
|
+
if (!raw) return [];
|
|
30
|
+
return raw
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(line);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendDecisionRecord(record, feedbackDir) {
|
|
43
|
+
const logPath = getDecisionLogPath(feedbackDir);
|
|
44
|
+
ensureDir(path.dirname(logPath));
|
|
45
|
+
fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
46
|
+
return record;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toLocalDayKey(value) {
|
|
50
|
+
const ts = value instanceof Date ? value : new Date(value);
|
|
51
|
+
if (Number.isNaN(ts.getTime())) return null;
|
|
52
|
+
const year = ts.getFullYear();
|
|
53
|
+
const month = String(ts.getMonth() + 1).padStart(2, '0');
|
|
54
|
+
const day = String(ts.getDate()).padStart(2, '0');
|
|
55
|
+
return `${year}-${month}-${day}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeOutcome(value) {
|
|
59
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
60
|
+
if (DECISION_OUTCOMES.has(normalized)) return normalized;
|
|
61
|
+
return 'completed';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inferActualDecision(outcome, fallback) {
|
|
65
|
+
if (fallback) return String(fallback);
|
|
66
|
+
if (outcome === 'blocked') return 'deny';
|
|
67
|
+
if (outcome === 'warned') return 'warn';
|
|
68
|
+
return 'allow';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function median(values) {
|
|
72
|
+
const sorted = values
|
|
73
|
+
.map((value) => Number(value))
|
|
74
|
+
.filter((value) => Number.isFinite(value))
|
|
75
|
+
.sort((left, right) => left - right);
|
|
76
|
+
if (sorted.length === 0) return 0;
|
|
77
|
+
const middle = Math.floor(sorted.length / 2);
|
|
78
|
+
if (sorted.length % 2 === 1) return sorted[middle];
|
|
79
|
+
return Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function summarizeBlastRadius(report = {}) {
|
|
83
|
+
const blastRadius = report.blastRadius || {};
|
|
84
|
+
return {
|
|
85
|
+
severity: blastRadius.severity || 'low',
|
|
86
|
+
fileCount: Number(blastRadius.fileCount || 0),
|
|
87
|
+
surfaceCount: Number(blastRadius.surfaceCount || 0),
|
|
88
|
+
releaseSensitiveCount: Array.isArray(blastRadius.releaseSensitiveFiles) ? blastRadius.releaseSensitiveFiles.length : 0,
|
|
89
|
+
protectedWithoutApprovalCount: Array.isArray(blastRadius.unapprovedProtectedFiles) ? blastRadius.unapprovedProtectedFiles.length : 0,
|
|
90
|
+
summary: blastRadius.summary || '',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeRecommendation(report = {}) {
|
|
95
|
+
const control = report.decisionControl || {};
|
|
96
|
+
return {
|
|
97
|
+
decision: report.decision || 'allow',
|
|
98
|
+
riskScore: Number(report.riskScore || 0),
|
|
99
|
+
riskBand: report.band || 'low',
|
|
100
|
+
executionMode: control.executionMode || (report.decision === 'deny' ? 'blocked' : report.decision === 'warn' ? 'checkpoint_required' : 'auto_execute'),
|
|
101
|
+
decisionOwner: control.decisionOwner || (report.decision === 'allow' ? 'agent' : 'shared'),
|
|
102
|
+
reversibility: control.reversibility || 'reviewable',
|
|
103
|
+
requiresHumanApproval: control.requiresHumanApproval === true,
|
|
104
|
+
summary: report.summary || '',
|
|
105
|
+
recommendedAction: control.recommendedAction || (report.decision === 'deny' ? 'halt' : report.decision === 'warn' ? 'review' : 'proceed'),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordDecisionEvaluation(report, params = {}, options = {}) {
|
|
110
|
+
const actionId = params.actionId || buildActionId();
|
|
111
|
+
const changedFiles = Array.isArray(params.changedFiles)
|
|
112
|
+
? params.changedFiles.slice()
|
|
113
|
+
: Array.isArray(report && report.blastRadius && report.blastRadius.affectedFiles)
|
|
114
|
+
? report.blastRadius.affectedFiles.slice()
|
|
115
|
+
: [];
|
|
116
|
+
const record = {
|
|
117
|
+
recordType: 'evaluation',
|
|
118
|
+
actionId,
|
|
119
|
+
timestamp: params.timestamp || new Date().toISOString(),
|
|
120
|
+
source: params.source || 'workflow-sentinel',
|
|
121
|
+
toolName: params.toolName || report.toolName || 'unknown',
|
|
122
|
+
toolInput: sanitizeToolInput(params.toolInput || {}),
|
|
123
|
+
changedFiles,
|
|
124
|
+
recommendation: normalizeRecommendation(report),
|
|
125
|
+
blastRadius: summarizeBlastRadius(report),
|
|
126
|
+
learnedPolicy: report.learnedPolicy && report.learnedPolicy.enabled
|
|
127
|
+
? {
|
|
128
|
+
label: report.learnedPolicy.prediction && report.learnedPolicy.prediction.label || null,
|
|
129
|
+
confidence: Number((report.learnedPolicy.prediction && report.learnedPolicy.prediction.confidence) || 0),
|
|
130
|
+
}
|
|
131
|
+
: null,
|
|
132
|
+
topRemediations: Array.isArray(report.remediations)
|
|
133
|
+
? report.remediations.slice(0, 3).map((entry) => ({ id: entry.id, title: entry.title }))
|
|
134
|
+
: [],
|
|
135
|
+
evidence: Array.isArray(report.evidence) ? report.evidence.slice(0, 4) : [],
|
|
136
|
+
};
|
|
137
|
+
return appendDecisionRecord(record, options.feedbackDir);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function recordDecisionOutcome(params = {}, options = {}) {
|
|
141
|
+
const actionId = params.actionId || buildActionId('decision_outcome');
|
|
142
|
+
const entries = readDecisionLog(getDecisionLogPath(options.feedbackDir));
|
|
143
|
+
const evaluation = [...entries]
|
|
144
|
+
.reverse()
|
|
145
|
+
.find((entry) => entry && entry.recordType === 'evaluation' && entry.actionId === actionId) || null;
|
|
146
|
+
const outcome = normalizeOutcome(params.outcome);
|
|
147
|
+
const timestamp = params.timestamp || new Date().toISOString();
|
|
148
|
+
const latencyMs = Number.isFinite(params.latencyMs)
|
|
149
|
+
? Number(params.latencyMs)
|
|
150
|
+
: evaluation && evaluation.timestamp
|
|
151
|
+
? Math.max(0, new Date(timestamp).getTime() - new Date(evaluation.timestamp).getTime())
|
|
152
|
+
: null;
|
|
153
|
+
const record = {
|
|
154
|
+
recordType: 'outcome',
|
|
155
|
+
actionId,
|
|
156
|
+
timestamp,
|
|
157
|
+
source: params.source || 'api',
|
|
158
|
+
actor: params.actor || 'human',
|
|
159
|
+
outcome,
|
|
160
|
+
actualDecision: inferActualDecision(outcome, params.actualDecision),
|
|
161
|
+
notes: params.notes || '',
|
|
162
|
+
metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {},
|
|
163
|
+
latencyMs: Number.isFinite(latencyMs) ? latencyMs : null,
|
|
164
|
+
recommendation: evaluation ? evaluation.recommendation : (params.recommendation || null),
|
|
165
|
+
toolName: evaluation ? evaluation.toolName : (params.toolName || 'unknown'),
|
|
166
|
+
changedFiles: evaluation ? evaluation.changedFiles : (Array.isArray(params.changedFiles) ? params.changedFiles.slice() : []),
|
|
167
|
+
};
|
|
168
|
+
return appendDecisionRecord(record, options.feedbackDir);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function collapseDecisionTimeline(records) {
|
|
172
|
+
const actions = new Map();
|
|
173
|
+
for (const record of records) {
|
|
174
|
+
if (!record || !record.actionId) continue;
|
|
175
|
+
if (!actions.has(record.actionId)) {
|
|
176
|
+
actions.set(record.actionId, { actionId: record.actionId, evaluation: null, outcomes: [] });
|
|
177
|
+
}
|
|
178
|
+
const bucket = actions.get(record.actionId);
|
|
179
|
+
if (record.recordType === 'evaluation') {
|
|
180
|
+
bucket.evaluation = record;
|
|
181
|
+
} else if (record.recordType === 'outcome') {
|
|
182
|
+
bucket.outcomes.push(record);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const bucket of actions.values()) {
|
|
186
|
+
bucket.outcomes.sort((left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime());
|
|
187
|
+
}
|
|
188
|
+
return [...actions.values()].sort((left, right) => {
|
|
189
|
+
const leftTs = left.evaluation ? new Date(left.evaluation.timestamp).getTime() : 0;
|
|
190
|
+
const rightTs = right.evaluation ? new Date(right.evaluation.timestamp).getTime() : 0;
|
|
191
|
+
return leftTs - rightTs;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function initializeDaySeries(dayCount) {
|
|
196
|
+
const today = new Date();
|
|
197
|
+
today.setHours(0, 0, 0, 0);
|
|
198
|
+
const days = [];
|
|
199
|
+
for (let offset = dayCount - 1; offset >= 0; offset -= 1) {
|
|
200
|
+
const day = new Date(today);
|
|
201
|
+
day.setDate(today.getDate() - offset);
|
|
202
|
+
days.push({
|
|
203
|
+
dayKey: toLocalDayKey(day),
|
|
204
|
+
evaluations: 0,
|
|
205
|
+
fastPath: 0,
|
|
206
|
+
checkpoint: 0,
|
|
207
|
+
blockedRecommendations: 0,
|
|
208
|
+
overrides: 0,
|
|
209
|
+
rollbacks: 0,
|
|
210
|
+
completions: 0,
|
|
211
|
+
blockedOutcomes: 0,
|
|
212
|
+
latencies: [],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return days;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function safeRate(numerator, denominator) {
|
|
219
|
+
if (!denominator) return 0;
|
|
220
|
+
return Number((numerator / denominator).toFixed(4));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function computeDecisionMetrics(feedbackDir, options = {}) {
|
|
224
|
+
const dayCount = Number.isInteger(options.dayCount) ? options.dayCount : DEFAULT_DAY_COUNT;
|
|
225
|
+
const records = readDecisionLog(getDecisionLogPath(feedbackDir));
|
|
226
|
+
const actions = collapseDecisionTimeline(records).filter((entry) => entry.evaluation);
|
|
227
|
+
const series = initializeDaySeries(dayCount);
|
|
228
|
+
const dayMap = new Map(series.map((day) => [day.dayKey, day]));
|
|
229
|
+
const outcomeCounts = {
|
|
230
|
+
accepted: 0,
|
|
231
|
+
completed: 0,
|
|
232
|
+
overridden: 0,
|
|
233
|
+
rolled_back: 0,
|
|
234
|
+
blocked: 0,
|
|
235
|
+
aborted: 0,
|
|
236
|
+
warned: 0,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let fastPathCount = 0;
|
|
240
|
+
let checkpointCount = 0;
|
|
241
|
+
let blockedRecommendationCount = 0;
|
|
242
|
+
let overrideCount = 0;
|
|
243
|
+
let rollbackCount = 0;
|
|
244
|
+
let resolvedCount = 0;
|
|
245
|
+
const latencyValues = [];
|
|
246
|
+
|
|
247
|
+
for (const action of actions) {
|
|
248
|
+
const evaluation = action.evaluation;
|
|
249
|
+
const recommendation = evaluation.recommendation || {};
|
|
250
|
+
const evalDay = dayMap.get(toLocalDayKey(evaluation.timestamp));
|
|
251
|
+
if (evalDay) {
|
|
252
|
+
evalDay.evaluations += 1;
|
|
253
|
+
if (recommendation.executionMode === 'auto_execute') evalDay.fastPath += 1;
|
|
254
|
+
if (recommendation.executionMode === 'checkpoint_required') evalDay.checkpoint += 1;
|
|
255
|
+
if (recommendation.executionMode === 'blocked') evalDay.blockedRecommendations += 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (recommendation.executionMode === 'auto_execute') fastPathCount += 1;
|
|
259
|
+
if (recommendation.executionMode === 'checkpoint_required') checkpointCount += 1;
|
|
260
|
+
if (recommendation.executionMode === 'blocked') blockedRecommendationCount += 1;
|
|
261
|
+
|
|
262
|
+
const hasOverride = action.outcomes.some((outcome) => outcome.outcome === 'overridden');
|
|
263
|
+
const hasRollback = action.outcomes.some((outcome) => outcome.outcome === 'rolled_back');
|
|
264
|
+
if (hasOverride) overrideCount += 1;
|
|
265
|
+
if (hasRollback) rollbackCount += 1;
|
|
266
|
+
|
|
267
|
+
const latestOutcome = action.outcomes.length > 0 ? action.outcomes[action.outcomes.length - 1] : null;
|
|
268
|
+
if (latestOutcome) {
|
|
269
|
+
outcomeCounts[latestOutcome.outcome] = (outcomeCounts[latestOutcome.outcome] || 0) + 1;
|
|
270
|
+
const outcomeDay = dayMap.get(toLocalDayKey(latestOutcome.timestamp));
|
|
271
|
+
if (outcomeDay) {
|
|
272
|
+
if (latestOutcome.outcome === 'overridden') outcomeDay.overrides += 1;
|
|
273
|
+
if (latestOutcome.outcome === 'rolled_back') outcomeDay.rollbacks += 1;
|
|
274
|
+
if (latestOutcome.outcome === 'completed' || latestOutcome.outcome === 'accepted') outcomeDay.completions += 1;
|
|
275
|
+
if (latestOutcome.outcome === 'blocked') outcomeDay.blockedOutcomes += 1;
|
|
276
|
+
}
|
|
277
|
+
if (RESOLVED_OUTCOMES.has(latestOutcome.outcome)) {
|
|
278
|
+
resolvedCount += 1;
|
|
279
|
+
}
|
|
280
|
+
if (Number.isFinite(latestOutcome.latencyMs)) {
|
|
281
|
+
latencyValues.push(latestOutcome.latencyMs);
|
|
282
|
+
if (outcomeDay) outcomeDay.latencies.push(latestOutcome.latencyMs);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const days = series.map((day) => ({
|
|
288
|
+
dayKey: day.dayKey,
|
|
289
|
+
evaluations: day.evaluations,
|
|
290
|
+
fastPath: day.fastPath,
|
|
291
|
+
checkpoint: day.checkpoint,
|
|
292
|
+
blockedRecommendations: day.blockedRecommendations,
|
|
293
|
+
overrides: day.overrides,
|
|
294
|
+
rollbacks: day.rollbacks,
|
|
295
|
+
completions: day.completions,
|
|
296
|
+
blockedOutcomes: day.blockedOutcomes,
|
|
297
|
+
medianLatencyMs: median(day.latencies),
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
evaluationCount: actions.length,
|
|
302
|
+
resolvedCount,
|
|
303
|
+
fastPathCount,
|
|
304
|
+
checkpointCount,
|
|
305
|
+
blockedRecommendationCount,
|
|
306
|
+
overrideCount,
|
|
307
|
+
rollbackCount,
|
|
308
|
+
outcomeCounts,
|
|
309
|
+
fastPathRate: safeRate(fastPathCount, actions.length),
|
|
310
|
+
checkpointRate: safeRate(checkpointCount, actions.length),
|
|
311
|
+
overrideRate: safeRate(overrideCount, resolvedCount || actions.length),
|
|
312
|
+
rollbackRate: safeRate(rollbackCount, resolvedCount || actions.length),
|
|
313
|
+
followRate: safeRate(Math.max(0, resolvedCount - overrideCount), resolvedCount || actions.length),
|
|
314
|
+
medianLatencyMs: median(latencyValues),
|
|
315
|
+
averageLatencyMs: latencyValues.length > 0
|
|
316
|
+
? Math.round(latencyValues.reduce((sum, value) => sum + value, 0) / latencyValues.length)
|
|
317
|
+
: 0,
|
|
318
|
+
dayCount,
|
|
319
|
+
days,
|
|
320
|
+
activeDays: days.filter((day) => {
|
|
321
|
+
return day.evaluations > 0 || day.overrides > 0 || day.rollbacks > 0 || day.completions > 0 || day.blockedOutcomes > 0;
|
|
322
|
+
}).length,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
DECISION_LOG_FILENAME,
|
|
328
|
+
RESOLVED_OUTCOMES,
|
|
329
|
+
buildActionId,
|
|
330
|
+
collapseDecisionTimeline,
|
|
331
|
+
computeDecisionMetrics,
|
|
332
|
+
getDecisionLogPath,
|
|
333
|
+
normalizeOutcome,
|
|
334
|
+
readDecisionLog,
|
|
335
|
+
recordDecisionEvaluation,
|
|
336
|
+
recordDecisionOutcome,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (require.main === module) {
|
|
340
|
+
console.log(JSON.stringify(computeDecisionMetrics(process.argv[2]), null, 2));
|
|
341
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { ensureDir } = require('./fs-utils');
|
|
7
8
|
const {
|
|
8
9
|
loadSubagentProfiles,
|
|
9
10
|
getAllowedTools,
|
|
@@ -32,11 +33,6 @@ function getVerificationLoopModule() {
|
|
|
32
33
|
return require('./verification-loop');
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function ensureDir(dirPath) {
|
|
36
|
-
if (!fs.existsSync(dirPath)) {
|
|
37
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
36
|
|
|
41
37
|
function readJSONL(filePath) {
|
|
42
38
|
if (!fs.existsSync(filePath)) return [];
|