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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/adapters/README.md +8 -0
- package/adapters/amp/skills/rlhf-feedback/SKILL.md +20 -0
- package/adapters/chatgpt/INSTALL.md +80 -0
- package/adapters/chatgpt/openapi.yaml +292 -0
- package/adapters/claude/.mcp.json +8 -0
- package/adapters/codex/config.toml +4 -0
- package/adapters/gemini/function-declarations.json +95 -0
- package/adapters/mcp/server-stdio.js +444 -0
- package/bin/cli.js +167 -0
- package/config/mcp-allowlists.json +29 -0
- package/config/policy-bundles/constrained-v1.json +53 -0
- package/config/policy-bundles/default-v1.json +80 -0
- package/config/rubrics/default-v1.json +52 -0
- package/config/subagent-profiles.json +32 -0
- package/openapi/openapi.yaml +292 -0
- package/package.json +91 -0
- package/plugins/amp-skill/INSTALL.md +52 -0
- package/plugins/amp-skill/SKILL.md +31 -0
- package/plugins/claude-skill/INSTALL.md +55 -0
- package/plugins/claude-skill/SKILL.md +46 -0
- package/plugins/codex-profile/AGENTS.md +20 -0
- package/plugins/codex-profile/INSTALL.md +57 -0
- package/plugins/gemini-extension/INSTALL.md +74 -0
- package/plugins/gemini-extension/gemini_prompt.txt +10 -0
- package/plugins/gemini-extension/tool_contract.json +28 -0
- package/scripts/billing.js +471 -0
- package/scripts/budget-guard.js +173 -0
- package/scripts/code-reasoning.js +307 -0
- package/scripts/context-engine.js +547 -0
- package/scripts/contextfs.js +513 -0
- package/scripts/contract-audit.js +198 -0
- package/scripts/dpo-optimizer.js +208 -0
- package/scripts/export-dpo-pairs.js +316 -0
- package/scripts/export-training.js +448 -0
- package/scripts/feedback-attribution.js +313 -0
- package/scripts/feedback-inbox-read.js +162 -0
- package/scripts/feedback-loop.js +838 -0
- package/scripts/feedback-schema.js +300 -0
- package/scripts/feedback-to-memory.js +165 -0
- package/scripts/feedback-to-rules.js +109 -0
- package/scripts/generate-paperbanana-diagrams.sh +99 -0
- package/scripts/hybrid-feedback-context.js +676 -0
- package/scripts/intent-router.js +164 -0
- package/scripts/mcp-policy.js +92 -0
- package/scripts/meta-policy.js +194 -0
- package/scripts/plan-gate.js +154 -0
- package/scripts/prove-adapters.js +364 -0
- package/scripts/prove-attribution.js +364 -0
- package/scripts/prove-automation.js +393 -0
- package/scripts/prove-data-quality.js +219 -0
- package/scripts/prove-intelligence.js +256 -0
- package/scripts/prove-lancedb.js +370 -0
- package/scripts/prove-loop-closure.js +255 -0
- package/scripts/prove-rlaif.js +404 -0
- package/scripts/prove-subway-upgrades.js +250 -0
- package/scripts/prove-training-export.js +324 -0
- package/scripts/prove-v2-milestone.js +273 -0
- package/scripts/prove-v3-milestone.js +381 -0
- package/scripts/rlaif-self-audit.js +123 -0
- package/scripts/rubric-engine.js +230 -0
- package/scripts/self-heal.js +127 -0
- package/scripts/self-healing-check.js +111 -0
- package/scripts/skill-quality-tracker.js +284 -0
- package/scripts/subagent-profiles.js +79 -0
- package/scripts/sync-gh-secrets-from-env.sh +29 -0
- package/scripts/thompson-sampling.js +331 -0
- package/scripts/train_from_feedback.py +914 -0
- package/scripts/validate-feedback.js +580 -0
- package/scripts/vector-store.js +100 -0
- package/src/api/server.js +497 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* RLAIF Self-Audit Module (DPO-01)
|
|
4
|
+
*
|
|
5
|
+
* Heuristic self-scoring of feedback events against CLAUDE.md constraints.
|
|
6
|
+
* NO API calls — pure synchronous evaluation of event fields.
|
|
7
|
+
*
|
|
8
|
+
* Exports: selfAudit, selfAuditAndLog, CONSTRAINTS
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// CLAUDE.md Constraint Definitions (weight sum = 1.0)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const CONSTRAINTS = [
|
|
19
|
+
{
|
|
20
|
+
id: 'has_context',
|
|
21
|
+
weight: 0.20,
|
|
22
|
+
check: (e) => typeof e.context === 'string' && e.context.length >= 20,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'has_actionable_detail',
|
|
26
|
+
weight: 0.25,
|
|
27
|
+
check: (e) => {
|
|
28
|
+
if (e.signal === 'positive') return Boolean(e.whatWorked);
|
|
29
|
+
return Boolean(e.whatWentWrong) && Boolean(e.whatToChange);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'schema_valid',
|
|
34
|
+
weight: 0.15,
|
|
35
|
+
check: (e) =>
|
|
36
|
+
['positive', 'negative'].includes(e.signal) &&
|
|
37
|
+
Array.isArray(e.tags) &&
|
|
38
|
+
e.tags.length > 0,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'rubric_evaluated',
|
|
42
|
+
weight: 0.20,
|
|
43
|
+
check: (e) => e.rubric != null && e.rubric.promotionEligible != null,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'budget_compliant',
|
|
47
|
+
weight: 0.10,
|
|
48
|
+
check: (e) =>
|
|
49
|
+
!e.rubric ||
|
|
50
|
+
!e.rubric.failingGuardrails ||
|
|
51
|
+
!e.rubric.failingGuardrails.includes('budgetCompliant'),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'no_vague_signal',
|
|
55
|
+
weight: 0.10,
|
|
56
|
+
check: (e) => typeof e.context === 'string' && e.context.length > 10,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// selfAudit — pure function, no I/O, no API calls
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Evaluate a feedback event against all CLAUDE.md constraints.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} feedbackEvent - A feedback event object
|
|
68
|
+
* @returns {{ score: number, constraints: Array, timestamp: string }}
|
|
69
|
+
* score: float in [0, 1] rounded to 3 decimals
|
|
70
|
+
* constraints: array of { constraint, passed, weight }
|
|
71
|
+
* timestamp: ISO 8601 string at evaluation time
|
|
72
|
+
*/
|
|
73
|
+
function selfAudit(feedbackEvent) {
|
|
74
|
+
const e = feedbackEvent || {};
|
|
75
|
+
const results = CONSTRAINTS.map((c) => ({
|
|
76
|
+
constraint: c.id,
|
|
77
|
+
passed: Boolean(c.check(e)),
|
|
78
|
+
weight: c.weight,
|
|
79
|
+
}));
|
|
80
|
+
const score = results.reduce((sum, r) => sum + (r.passed ? r.weight : 0), 0);
|
|
81
|
+
return {
|
|
82
|
+
score: Math.round(score * 1000) / 1000,
|
|
83
|
+
constraints: results,
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// selfAuditAndLog — evaluates and appends to self-score-log.jsonl (sync)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Score a feedback event and append the result to self-score-log.jsonl.
|
|
94
|
+
*
|
|
95
|
+
* Non-critical: any filesystem error is swallowed — result is returned regardless.
|
|
96
|
+
*
|
|
97
|
+
* @param {Object} feedbackEvent - The feedback event to score
|
|
98
|
+
* @param {Object} mlPaths - Object with feedbackDir property (from getFeedbackPaths())
|
|
99
|
+
* @returns {{ score: number, constraints: Array, timestamp: string }}
|
|
100
|
+
*/
|
|
101
|
+
function selfAuditAndLog(feedbackEvent, mlPaths) {
|
|
102
|
+
const result = selfAudit(feedbackEvent);
|
|
103
|
+
try {
|
|
104
|
+
const feedbackDir = (mlPaths && mlPaths.FEEDBACK_DIR) || (mlPaths && mlPaths.feedbackDir);
|
|
105
|
+
if (feedbackDir) {
|
|
106
|
+
const logPath = path.join(feedbackDir, 'self-score-log.jsonl');
|
|
107
|
+
const entry = {
|
|
108
|
+
feedbackId: (feedbackEvent || {}).id || null,
|
|
109
|
+
...result,
|
|
110
|
+
};
|
|
111
|
+
fs.appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
|
|
112
|
+
}
|
|
113
|
+
} catch (_err) {
|
|
114
|
+
// Non-critical side-effect — swallow and return result anyway
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Exports
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
module.exports = { selfAudit, selfAuditAndLog, CONSTRAINTS };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
6
|
+
const DEFAULT_RUBRIC_PATH = path.join(PROJECT_ROOT, 'config', 'rubrics', 'default-v1.json');
|
|
7
|
+
|
|
8
|
+
function getRubricPath() {
|
|
9
|
+
return process.env.RLHF_RUBRIC_PATH || DEFAULT_RUBRIC_PATH;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadRubricConfig() {
|
|
13
|
+
const raw = fs.readFileSync(getRubricPath(), 'utf-8');
|
|
14
|
+
const rubric = JSON.parse(raw);
|
|
15
|
+
|
|
16
|
+
if (!rubric || typeof rubric !== 'object') {
|
|
17
|
+
throw new Error('Invalid rubric config: expected object');
|
|
18
|
+
}
|
|
19
|
+
if (!rubric.rubricId || typeof rubric.rubricId !== 'string') {
|
|
20
|
+
throw new Error('Invalid rubric config: rubricId is required');
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(rubric.criteria) || rubric.criteria.length === 0) {
|
|
23
|
+
throw new Error('Invalid rubric config: criteria must be a non-empty array');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
let totalWeight = 0;
|
|
28
|
+
rubric.criteria.forEach((criterion) => {
|
|
29
|
+
if (!criterion.id || typeof criterion.id !== 'string') {
|
|
30
|
+
throw new Error('Invalid rubric config: criterion id is required');
|
|
31
|
+
}
|
|
32
|
+
if (seen.has(criterion.id)) {
|
|
33
|
+
throw new Error(`Invalid rubric config: duplicate criterion '${criterion.id}'`);
|
|
34
|
+
}
|
|
35
|
+
seen.add(criterion.id);
|
|
36
|
+
|
|
37
|
+
const weight = Number(criterion.weight);
|
|
38
|
+
if (!Number.isFinite(weight) || weight <= 0) {
|
|
39
|
+
throw new Error(`Invalid rubric config: criterion '${criterion.id}' has invalid weight`);
|
|
40
|
+
}
|
|
41
|
+
totalWeight += weight;
|
|
42
|
+
|
|
43
|
+
const minPassingScore = Number(criterion.minPassingScore || 3);
|
|
44
|
+
if (!Number.isFinite(minPassingScore) || minPassingScore < 1 || minPassingScore > 5) {
|
|
45
|
+
throw new Error(`Invalid rubric config: criterion '${criterion.id}' has invalid minPassingScore`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (Math.abs(totalWeight - 1) > 0.001) {
|
|
50
|
+
throw new Error(`Invalid rubric config: weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return rubric;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseRubricScores(input) {
|
|
57
|
+
if (input == null) return [];
|
|
58
|
+
if (Array.isArray(input)) return input;
|
|
59
|
+
if (typeof input === 'string') {
|
|
60
|
+
const trimmed = input.trim();
|
|
61
|
+
if (!trimmed) return [];
|
|
62
|
+
const parsed = JSON.parse(trimmed);
|
|
63
|
+
if (!Array.isArray(parsed)) {
|
|
64
|
+
throw new Error('rubricScores must be an array');
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('rubricScores must be array or JSON string');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeRubricScores(rawScores, rubric = loadRubricConfig()) {
|
|
72
|
+
const scores = parseRubricScores(rawScores);
|
|
73
|
+
if (scores.length === 0) return [];
|
|
74
|
+
|
|
75
|
+
const criterionMap = new Map(rubric.criteria.map((c) => [c.id, c]));
|
|
76
|
+
return scores.map((item, index) => {
|
|
77
|
+
if (!item || typeof item !== 'object') {
|
|
78
|
+
throw new Error(`rubricScores[${index}] must be an object`);
|
|
79
|
+
}
|
|
80
|
+
const criterion = String(item.criterion || '').trim();
|
|
81
|
+
if (!criterionMap.has(criterion)) {
|
|
82
|
+
throw new Error(`rubricScores[${index}] unknown criterion '${criterion}'`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const score = Number(item.score);
|
|
86
|
+
if (!Number.isFinite(score) || score < 1 || score > 5) {
|
|
87
|
+
throw new Error(`rubricScores[${index}] score must be between 1 and 5`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
criterion,
|
|
92
|
+
score,
|
|
93
|
+
evidence: item.evidence ? String(item.evidence).trim() : '',
|
|
94
|
+
judge: item.judge ? String(item.judge).trim() : 'unknown',
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function groupByCriterion(scores) {
|
|
100
|
+
const grouped = {};
|
|
101
|
+
scores.forEach((scoreItem) => {
|
|
102
|
+
if (!grouped[scoreItem.criterion]) grouped[scoreItem.criterion] = [];
|
|
103
|
+
grouped[scoreItem.criterion].push(scoreItem);
|
|
104
|
+
});
|
|
105
|
+
return grouped;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function evaluateGuardrails(guardrails, rubric = loadRubricConfig()) {
|
|
109
|
+
const input = guardrails && typeof guardrails === 'object' ? guardrails : {};
|
|
110
|
+
const expected = Array.isArray(rubric.guardrails) ? rubric.guardrails : [];
|
|
111
|
+
|
|
112
|
+
const status = {};
|
|
113
|
+
const failed = [];
|
|
114
|
+
expected.forEach((g) => {
|
|
115
|
+
const value = input[g.key];
|
|
116
|
+
const normalized = value === true ? true : value === false ? false : null;
|
|
117
|
+
status[g.key] = normalized;
|
|
118
|
+
if (normalized === false) failed.push(g.key);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
status,
|
|
123
|
+
failed,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function evaluateJudgeAgreement(scoresByCriterion) {
|
|
128
|
+
const disagreements = [];
|
|
129
|
+
for (const [criterion, entries] of Object.entries(scoresByCriterion)) {
|
|
130
|
+
if (entries.length < 2) continue;
|
|
131
|
+
const values = entries.map((e) => e.score);
|
|
132
|
+
const max = Math.max(...values);
|
|
133
|
+
const min = Math.min(...values);
|
|
134
|
+
if (max - min >= 2) {
|
|
135
|
+
disagreements.push({
|
|
136
|
+
criterion,
|
|
137
|
+
max,
|
|
138
|
+
min,
|
|
139
|
+
judges: entries.map((e) => e.judge),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return disagreements;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildRubricEvaluation({ rubricScores, guardrails } = {}) {
|
|
147
|
+
const rubric = loadRubricConfig();
|
|
148
|
+
const normalizedScores = normalizeRubricScores(rubricScores, rubric);
|
|
149
|
+
const scoresByCriterion = groupByCriterion(normalizedScores);
|
|
150
|
+
const guardrailResult = evaluateGuardrails(guardrails, rubric);
|
|
151
|
+
|
|
152
|
+
const criterionBreakdown = {};
|
|
153
|
+
const failingCriteria = [];
|
|
154
|
+
const missingEvidenceClaims = [];
|
|
155
|
+
let weightedScore = 0;
|
|
156
|
+
|
|
157
|
+
rubric.criteria.forEach((criterion) => {
|
|
158
|
+
const entries = scoresByCriterion[criterion.id] || [];
|
|
159
|
+
const avg = entries.length > 0
|
|
160
|
+
? entries.reduce((sum, item) => sum + item.score, 0) / entries.length
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
criterionBreakdown[criterion.id] = {
|
|
164
|
+
averageScore: avg,
|
|
165
|
+
minPassingScore: Number(criterion.minPassingScore || 3),
|
|
166
|
+
judgeCount: entries.length,
|
|
167
|
+
label: criterion.label || criterion.id,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (avg != null) {
|
|
171
|
+
weightedScore += (avg / 5) * Number(criterion.weight);
|
|
172
|
+
if (avg < Number(criterion.minPassingScore || 3)) {
|
|
173
|
+
failingCriteria.push(criterion.id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (criterion.requiresEvidence && entries.some((entry) => entry.score >= 4 && !entry.evidence)) {
|
|
178
|
+
missingEvidenceClaims.push(criterion.id);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const judgeDisagreements = evaluateJudgeAgreement(scoresByCriterion);
|
|
183
|
+
const blockReasons = [];
|
|
184
|
+
if (failingCriteria.length > 0) blockReasons.push(`failing_criteria:${failingCriteria.join(',')}`);
|
|
185
|
+
if (guardrailResult.failed.length > 0) blockReasons.push(`failed_guardrails:${guardrailResult.failed.join(',')}`);
|
|
186
|
+
if (judgeDisagreements.length > 0) blockReasons.push('judge_disagreement');
|
|
187
|
+
if (missingEvidenceClaims.length > 0) blockReasons.push(`missing_evidence:${missingEvidenceClaims.join(',')}`);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
rubricId: rubric.rubricId,
|
|
191
|
+
rubricVersion: rubric.version || 1,
|
|
192
|
+
weightedScore: Math.round(weightedScore * 1000) / 1000,
|
|
193
|
+
criterionBreakdown,
|
|
194
|
+
failingCriteria,
|
|
195
|
+
guardrails: guardrailResult.status,
|
|
196
|
+
failingGuardrails: guardrailResult.failed,
|
|
197
|
+
judgeDisagreements,
|
|
198
|
+
missingEvidenceClaims,
|
|
199
|
+
promotionEligible: blockReasons.length === 0,
|
|
200
|
+
blockReasons,
|
|
201
|
+
rubricScores: normalizedScores,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
DEFAULT_RUBRIC_PATH,
|
|
207
|
+
getRubricPath,
|
|
208
|
+
loadRubricConfig,
|
|
209
|
+
parseRubricScores,
|
|
210
|
+
normalizeRubricScores,
|
|
211
|
+
evaluateGuardrails,
|
|
212
|
+
evaluateJudgeAgreement,
|
|
213
|
+
buildRubricEvaluation,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (require.main === module) {
|
|
217
|
+
const args = process.argv.slice(2);
|
|
218
|
+
const scoresArg = args.find((arg) => arg.startsWith('--scores='));
|
|
219
|
+
const guardrailsArg = args.find((arg) => arg.startsWith('--guardrails='));
|
|
220
|
+
|
|
221
|
+
if (!scoresArg) {
|
|
222
|
+
console.log(JSON.stringify(loadRubricConfig(), null, 2));
|
|
223
|
+
process.exit(0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const scores = scoresArg.replace('--scores=', '');
|
|
227
|
+
const guardrails = guardrailsArg ? JSON.parse(guardrailsArg.replace('--guardrails=', '')) : {};
|
|
228
|
+
const result = buildRubricEvaluation({ rubricScores: scores, guardrails });
|
|
229
|
+
console.log(JSON.stringify(result, null, 2));
|
|
230
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
const { traceForSelfHealFix, aggregateTraces } = require('./code-reasoning');
|
|
7
|
+
|
|
8
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
9
|
+
const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, 'package.json');
|
|
10
|
+
const KNOWN_FIX_SCRIPTS = ['lint:fix', 'format', 'fix', 'feedback:rules'];
|
|
11
|
+
|
|
12
|
+
function runCommand(command, { cwd = PROJECT_ROOT, timeoutMs = 5 * 60_000 } = {}) {
|
|
13
|
+
const [cmd, ...args] = command;
|
|
14
|
+
const started = Date.now();
|
|
15
|
+
const result = spawnSync(cmd, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
timeout: timeoutMs,
|
|
19
|
+
shell: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
command: command.join(' '),
|
|
24
|
+
exitCode: Number.isInteger(result.status) ? result.status : 1,
|
|
25
|
+
durationMs: Date.now() - started,
|
|
26
|
+
stdout: result.stdout || '',
|
|
27
|
+
stderr: result.stderr || '',
|
|
28
|
+
error: result.error ? result.error.message : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadPackageScripts(packageJsonPath = PACKAGE_JSON_PATH) {
|
|
33
|
+
const raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return parsed.scripts || {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildFixPlan(scripts) {
|
|
39
|
+
return KNOWN_FIX_SCRIPTS.filter((name) => Object.prototype.hasOwnProperty.call(scripts, name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listChangedFiles({ cwd = PROJECT_ROOT } = {}) {
|
|
43
|
+
const diff = runCommand(['git', 'diff', '--name-only'], { cwd, timeoutMs: 10_000 });
|
|
44
|
+
if (diff.exitCode !== 0) return [];
|
|
45
|
+
return diff.stdout
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runFixPlan({ plan, runner = runCommand, cwd = PROJECT_ROOT } = {}) {
|
|
52
|
+
const results = [];
|
|
53
|
+
plan.forEach((scriptName) => {
|
|
54
|
+
const filesBefore = new Set(listChangedFiles({ cwd }));
|
|
55
|
+
const run = runner(['npm', 'run', scriptName], { cwd, timeoutMs: 10 * 60_000 });
|
|
56
|
+
const filesAfter = listChangedFiles({ cwd });
|
|
57
|
+
const scriptChangedFiles = filesAfter.filter((f) => !filesBefore.has(f));
|
|
58
|
+
results.push({
|
|
59
|
+
script: scriptName,
|
|
60
|
+
status: run.exitCode === 0 ? 'success' : 'failed',
|
|
61
|
+
exitCode: run.exitCode,
|
|
62
|
+
durationMs: run.durationMs,
|
|
63
|
+
error: run.error,
|
|
64
|
+
outputTail: `${run.stdout}\n${run.stderr}`.trim().slice(-2000),
|
|
65
|
+
changedFiles: scriptChangedFiles,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const successful = results.filter((x) => x.status === 'success').length;
|
|
70
|
+
return {
|
|
71
|
+
successful,
|
|
72
|
+
failed: results.length - successful,
|
|
73
|
+
total: results.length,
|
|
74
|
+
results,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runSelfHeal({ reason = 'unknown', cwd = PROJECT_ROOT } = {}) {
|
|
79
|
+
const beforeChanges = listChangedFiles({ cwd });
|
|
80
|
+
const beforeSet = new Set(beforeChanges);
|
|
81
|
+
const scripts = loadPackageScripts();
|
|
82
|
+
const plan = buildFixPlan(scripts);
|
|
83
|
+
const execution = runFixPlan({ plan, cwd });
|
|
84
|
+
const afterChanges = listChangedFiles({ cwd });
|
|
85
|
+
const changedFiles = afterChanges.filter((filePath) => !beforeSet.has(filePath));
|
|
86
|
+
|
|
87
|
+
const traces = execution.results.map((fixResult) => {
|
|
88
|
+
return traceForSelfHealFix(fixResult, fixResult.changedFiles || []);
|
|
89
|
+
});
|
|
90
|
+
const reasoning = aggregateTraces(traces);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
reason,
|
|
95
|
+
plan,
|
|
96
|
+
execution,
|
|
97
|
+
preExistingChanges: beforeChanges,
|
|
98
|
+
changedFiles,
|
|
99
|
+
changed: changedFiles.length > 0,
|
|
100
|
+
healthy: execution.failed === 0,
|
|
101
|
+
reasoning,
|
|
102
|
+
traces,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runCli() {
|
|
107
|
+
const reasonArg = process.argv.slice(2).find((a) => a.startsWith('--reason='));
|
|
108
|
+
const reason = reasonArg ? reasonArg.slice('--reason='.length) : 'manual';
|
|
109
|
+
const report = runSelfHeal({ reason });
|
|
110
|
+
console.log(JSON.stringify(report, null, 2));
|
|
111
|
+
|
|
112
|
+
if (!report.healthy) {
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
KNOWN_FIX_SCRIPTS,
|
|
119
|
+
loadPackageScripts,
|
|
120
|
+
buildFixPlan,
|
|
121
|
+
runFixPlan,
|
|
122
|
+
runSelfHeal,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (require.main === module) {
|
|
126
|
+
runCli();
|
|
127
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { spawnSync } = require('node:child_process');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CHECKS = [
|
|
8
|
+
{ name: 'budget_status', command: ['npm', 'run', 'budget:status'], timeoutMs: 60_000 },
|
|
9
|
+
{ name: 'tests', command: ['npm', 'test'], timeoutMs: 15 * 60_000 },
|
|
10
|
+
{ name: 'prove_adapters', command: ['npm', 'run', 'prove:adapters'], timeoutMs: 10 * 60_000 },
|
|
11
|
+
{ name: 'prove_automation', command: ['npm', 'run', 'prove:automation'], timeoutMs: 10 * 60_000 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function runCommand(command, { cwd = PROJECT_ROOT, timeoutMs = 5 * 60_000 } = {}) {
|
|
15
|
+
const [cmd, ...args] = command;
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
const result = spawnSync(cmd, args, {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
timeout: timeoutMs,
|
|
21
|
+
shell: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const durationMs = Date.now() - started;
|
|
25
|
+
const status = Number.isInteger(result.status) ? result.status : 1;
|
|
26
|
+
return {
|
|
27
|
+
exitCode: status,
|
|
28
|
+
durationMs,
|
|
29
|
+
stdout: result.stdout || '',
|
|
30
|
+
stderr: result.stderr || '',
|
|
31
|
+
error: result.error ? result.error.message : null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectHealthReport({ checks = DEFAULT_CHECKS, runner = runCommand, cwd = PROJECT_ROOT } = {}) {
|
|
36
|
+
const startedAt = new Date();
|
|
37
|
+
const results = checks.map((check) => {
|
|
38
|
+
const run = runner(check.command, { cwd, timeoutMs: check.timeoutMs });
|
|
39
|
+
return {
|
|
40
|
+
name: check.name,
|
|
41
|
+
command: check.command.join(' '),
|
|
42
|
+
status: run.exitCode === 0 ? 'healthy' : 'unhealthy',
|
|
43
|
+
exitCode: run.exitCode,
|
|
44
|
+
durationMs: run.durationMs,
|
|
45
|
+
error: run.error,
|
|
46
|
+
outputTail: `${run.stdout}\n${run.stderr}`.trim().slice(-2000),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const healthyCount = results.filter((x) => x.status === 'healthy').length;
|
|
51
|
+
const unhealthyCount = results.length - healthyCount;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
generatedAt: startedAt.toISOString(),
|
|
55
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
56
|
+
overall_status: unhealthyCount === 0 ? 'healthy' : 'unhealthy',
|
|
57
|
+
summary: {
|
|
58
|
+
total: results.length,
|
|
59
|
+
healthy: healthyCount,
|
|
60
|
+
unhealthy: unhealthyCount,
|
|
61
|
+
},
|
|
62
|
+
checks: results,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reportToText(report) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
lines.push(`Self-Healing Health Check @ ${report.generatedAt}`);
|
|
69
|
+
lines.push(`Overall: ${report.overall_status.toUpperCase()}`);
|
|
70
|
+
lines.push(`Checks: ${report.summary.healthy}/${report.summary.total} healthy`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
|
|
73
|
+
report.checks.forEach((check) => {
|
|
74
|
+
const icon = check.status === 'healthy' ? '✅' : '❌';
|
|
75
|
+
lines.push(`${icon} ${check.name} (${check.durationMs}ms)`);
|
|
76
|
+
if (check.status !== 'healthy') {
|
|
77
|
+
lines.push(` command: ${check.command}`);
|
|
78
|
+
if (check.error) lines.push(` error: ${check.error}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return `${lines.join('\n')}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runCli() {
|
|
86
|
+
const args = new Set(process.argv.slice(2));
|
|
87
|
+
const emitJson = args.has('--json');
|
|
88
|
+
const noFail = args.has('--no-fail');
|
|
89
|
+
const report = collectHealthReport();
|
|
90
|
+
|
|
91
|
+
if (emitJson) {
|
|
92
|
+
console.log(JSON.stringify(report, null, 2));
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write(reportToText(report));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!noFail && report.overall_status !== 'healthy') {
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
DEFAULT_CHECKS,
|
|
104
|
+
runCommand,
|
|
105
|
+
collectHealthReport,
|
|
106
|
+
reportToText,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (require.main === module) {
|
|
110
|
+
runCli();
|
|
111
|
+
}
|