@colin4k1024/tsp 2.5.1 → 2.5.3
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/bin/lib/install-surface.js +5 -0
- package/commands/dashboard.md +105 -0
- package/commands/goal.md +142 -0
- package/commands/heartbeat.md +129 -0
- package/commands/triage.md +108 -0
- package/hooks/harness-context-monitor.js +4 -0
- package/hooks/harness-statusline.js +34 -11
- package/hooks/hooks.json +23 -23
- package/manifests/install-modules.json +98 -31
- package/package.json +2 -1
- package/schemas/goal.schema.json +172 -0
- package/scripts/hooks/session-start-goal-resume.js +95 -0
- package/scripts/hooks/suggest-compact.js +122 -19
- package/scripts/lib/blame-attribution.js +210 -0
- package/scripts/lib/completion-oracle.js +351 -0
- package/scripts/lib/heartbeat-scheduler.js +265 -0
- package/scripts/lib/install/request.js +1 -1
- package/scripts/lib/install-manifests.js +9 -1
- package/scripts/lib/install-targets/cangming-home.js +143 -0
- package/scripts/lib/install-targets/codewhale-home.js +187 -0
- package/scripts/lib/install-targets/registry.js +5 -1
- package/scripts/lib/transcript-usage.js +183 -0
- package/scripts/lib/wave-cost-advisor.js +155 -0
- package/scripts/test-cangming-install.js +105 -0
- package/skills/goal-convergence/SKILL.md +150 -0
- package/skills/loop-heartbeat/SKILL.md +120 -0
- package/skills/mcp-connector-bridge/SKILL.md +132 -0
- package/skills/rework-loop/SKILL.md +131 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const GOAL_STATES = {
|
|
9
|
+
active: 'active',
|
|
10
|
+
paused: 'paused',
|
|
11
|
+
converged: 'converged',
|
|
12
|
+
escalated: 'escalated',
|
|
13
|
+
failed: 'failed',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ESCALATION_REASONS = {
|
|
17
|
+
budgetExhausted: 'budget_exhausted',
|
|
18
|
+
repeatedFailure: 'repeated_failure',
|
|
19
|
+
oracleUncertain: 'oracle_uncertain',
|
|
20
|
+
manual: 'manual',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function generateGoalId() {
|
|
24
|
+
return `goal-${crypto.randomBytes(4).toString('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createGoal(objective, options = {}) {
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
return {
|
|
30
|
+
goalId: generateGoalId(),
|
|
31
|
+
objective,
|
|
32
|
+
stoppingConditions: options.stoppingConditions || inferStoppingConditions(objective),
|
|
33
|
+
budget: {
|
|
34
|
+
maxIterations: options.maxIterations || 15,
|
|
35
|
+
maxDuration: options.maxDuration || '2h',
|
|
36
|
+
maxDollars: options.maxDollars || 10,
|
|
37
|
+
},
|
|
38
|
+
oracle: {
|
|
39
|
+
model: options.checkerModel || 'haiku',
|
|
40
|
+
allowedTools: ['Read', 'Bash'],
|
|
41
|
+
prompt: options.oraclePrompt || null,
|
|
42
|
+
},
|
|
43
|
+
state: GOAL_STATES.active,
|
|
44
|
+
currentIteration: 0,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
history: [],
|
|
48
|
+
escalation: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function inferStoppingConditions(objective) {
|
|
53
|
+
const lower = objective.toLowerCase();
|
|
54
|
+
const conditions = [];
|
|
55
|
+
|
|
56
|
+
if (lower.includes('test') && (lower.includes('pass') || lower.includes('fix'))) {
|
|
57
|
+
conditions.push({
|
|
58
|
+
type: 'test_pass',
|
|
59
|
+
command: 'npm test 2>&1; echo "EXIT:$?"',
|
|
60
|
+
description: 'All tests pass',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (lower.includes('lint') || lower.includes('eslint')) {
|
|
65
|
+
conditions.push({
|
|
66
|
+
type: 'lint_clean',
|
|
67
|
+
command: 'npm run lint -- --quiet 2>&1; echo "EXIT:$?"',
|
|
68
|
+
description: 'Linter reports no errors',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (lower.includes('coverage')) {
|
|
73
|
+
const match = lower.match(/(\d+)\s*%/);
|
|
74
|
+
const threshold = match ? parseInt(match[1], 10) : 80;
|
|
75
|
+
conditions.push({
|
|
76
|
+
type: 'coverage_threshold',
|
|
77
|
+
command: 'npm test -- --coverage --coverageReporters=text-summary 2>&1',
|
|
78
|
+
threshold,
|
|
79
|
+
description: `Test coverage >= ${threshold}%`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (lower.includes('build') && (lower.includes('pass') || lower.includes('fix'))) {
|
|
84
|
+
conditions.push({
|
|
85
|
+
type: 'build_pass',
|
|
86
|
+
command: 'npm run build 2>&1; echo "EXIT:$?"',
|
|
87
|
+
description: 'Build succeeds',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (conditions.length === 0) {
|
|
92
|
+
conditions.push({
|
|
93
|
+
type: 'custom_command',
|
|
94
|
+
command: 'echo "Manual verification required"; exit 1',
|
|
95
|
+
description: 'Requires explicit stopping condition — use --condition flag',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return conditions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function evaluateCondition(condition) {
|
|
103
|
+
try {
|
|
104
|
+
const output = execSync(condition.command, {
|
|
105
|
+
encoding: 'utf-8',
|
|
106
|
+
timeout: 60000,
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
}).trim();
|
|
109
|
+
|
|
110
|
+
let passed = true;
|
|
111
|
+
|
|
112
|
+
if (condition.type === 'coverage_threshold' && condition.threshold) {
|
|
113
|
+
const coverageMatch = output.match(/(?:All files|Statements)\s*[:|]\s*([\d.]+)%/);
|
|
114
|
+
if (coverageMatch) {
|
|
115
|
+
passed = parseFloat(coverageMatch[1]) >= condition.threshold;
|
|
116
|
+
} else {
|
|
117
|
+
passed = false;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
const exitMatch = output.match(/EXIT:(\d+)$/);
|
|
121
|
+
if (exitMatch) {
|
|
122
|
+
passed = exitMatch[1] === '0';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { type: condition.type, passed, output: output.slice(0, 500) };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
type: condition.type,
|
|
130
|
+
passed: false,
|
|
131
|
+
output: (error.stderr || error.message || '').slice(0, 500),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildOraclePrompt(goal, conditionResults, iterationSummary) {
|
|
137
|
+
const recentHistory = goal.history.slice(-3);
|
|
138
|
+
|
|
139
|
+
return `You are a completion oracle. Your job is to evaluate whether a goal has been achieved.
|
|
140
|
+
You CANNOT modify code — you can only read and evaluate.
|
|
141
|
+
|
|
142
|
+
## Goal
|
|
143
|
+
Objective: ${goal.objective}
|
|
144
|
+
|
|
145
|
+
## Stopping Conditions Results
|
|
146
|
+
${conditionResults.map(r => `- [${r.passed ? 'PASS' : 'FAIL'}] ${r.type}: ${r.output.slice(0, 200)}`).join('\n')}
|
|
147
|
+
|
|
148
|
+
## Maker's Iteration Summary
|
|
149
|
+
${iterationSummary || '(no summary provided)'}
|
|
150
|
+
|
|
151
|
+
## Recent History
|
|
152
|
+
${recentHistory.map(h => `Iteration ${h.iteration}: ${h.oracleVerdict} — ${(h.failReasons || []).join(', ')}`).join('\n') || '(first iteration)'}
|
|
153
|
+
|
|
154
|
+
## Your Task
|
|
155
|
+
Evaluate whether ALL stopping conditions are met. Respond with EXACTLY this JSON:
|
|
156
|
+
{
|
|
157
|
+
"converged": <true if ALL conditions pass, false otherwise>,
|
|
158
|
+
"conditionResults": [{"type": "...", "passed": true/false, "output": "brief"}],
|
|
159
|
+
"reasons": ["why not converged, if applicable"],
|
|
160
|
+
"nextHint": "specific guidance for the maker's next iteration (if not converged)",
|
|
161
|
+
"confidence": <0.0 to 1.0>
|
|
162
|
+
}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkBudget(goal) {
|
|
166
|
+
if (goal.currentIteration >= goal.budget.maxIterations) {
|
|
167
|
+
return { exhausted: true, reason: ESCALATION_REASONS.budgetExhausted, detail: 'Max iterations reached' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const durationMs = parseDuration(goal.budget.maxDuration);
|
|
171
|
+
const elapsed = Date.now() - new Date(goal.createdAt).getTime();
|
|
172
|
+
if (elapsed >= durationMs) {
|
|
173
|
+
return { exhausted: true, reason: ESCALATION_REASONS.budgetExhausted, detail: 'Max duration reached' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const totalCost = goal.history.reduce((sum, h) => sum + (h.costDollars || 0), 0);
|
|
177
|
+
if (totalCost >= goal.budget.maxDollars) {
|
|
178
|
+
return { exhausted: true, reason: ESCALATION_REASONS.budgetExhausted, detail: 'Max cost reached' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const recentFails = goal.history.slice(-5).filter(h => h.oracleVerdict === 'fail');
|
|
182
|
+
if (recentFails.length >= 5) {
|
|
183
|
+
return { exhausted: true, reason: ESCALATION_REASONS.repeatedFailure, detail: '5 consecutive failures' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { exhausted: false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseDuration(duration) {
|
|
190
|
+
const match = duration.match(/^(\d+)(m|h)$/);
|
|
191
|
+
if (!match) return 2 * 60 * 60 * 1000;
|
|
192
|
+
const value = parseInt(match[1], 10);
|
|
193
|
+
return match[2] === 'h' ? value * 60 * 60 * 1000 : value * 60 * 1000;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function recordIteration(goal, verdict, costDollars = 0) {
|
|
197
|
+
const entry = {
|
|
198
|
+
iteration: goal.currentIteration,
|
|
199
|
+
makerSummary: verdict.makerSummary || null,
|
|
200
|
+
oracleVerdict: verdict.converged ? 'pass' : 'fail',
|
|
201
|
+
failReasons: verdict.reasons || [],
|
|
202
|
+
nextHint: verdict.nextHint || null,
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
costDollars,
|
|
205
|
+
};
|
|
206
|
+
goal.history.push(entry);
|
|
207
|
+
goal.currentIteration += 1;
|
|
208
|
+
goal.updatedAt = new Date().toISOString();
|
|
209
|
+
|
|
210
|
+
if (verdict.converged) {
|
|
211
|
+
goal.state = GOAL_STATES.converged;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return entry;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function escalateGoal(goal, reason, detail) {
|
|
218
|
+
goal.state = GOAL_STATES.escalated;
|
|
219
|
+
goal.escalation = {
|
|
220
|
+
reason,
|
|
221
|
+
details: detail,
|
|
222
|
+
escalatedAt: new Date().toISOString(),
|
|
223
|
+
};
|
|
224
|
+
goal.updatedAt = new Date().toISOString();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pauseGoal(goal) {
|
|
228
|
+
goal.state = GOAL_STATES.paused;
|
|
229
|
+
goal.updatedAt = new Date().toISOString();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resumeGoal(goal) {
|
|
233
|
+
if (goal.state === GOAL_STATES.paused || goal.state === GOAL_STATES.escalated) {
|
|
234
|
+
goal.state = GOAL_STATES.active;
|
|
235
|
+
goal.updatedAt = new Date().toISOString();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Goal persistence
|
|
240
|
+
|
|
241
|
+
function getGoalsDir() {
|
|
242
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
243
|
+
const dir = path.join(home, '.claude', 'goals');
|
|
244
|
+
if (!fs.existsSync(dir)) {
|
|
245
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
246
|
+
}
|
|
247
|
+
return dir;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function saveGoal(goal) {
|
|
251
|
+
const filePath = path.join(getGoalsDir(), `${goal.goalId}.json`);
|
|
252
|
+
fs.writeFileSync(filePath, JSON.stringify(goal, null, 2), 'utf-8');
|
|
253
|
+
return filePath;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function loadGoal(goalId) {
|
|
257
|
+
const filePath = path.join(getGoalsDir(), `${goalId}.json`);
|
|
258
|
+
if (!fs.existsSync(filePath)) return null;
|
|
259
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function listGoals(filter) {
|
|
263
|
+
const dir = getGoalsDir();
|
|
264
|
+
if (!fs.existsSync(dir)) return [];
|
|
265
|
+
|
|
266
|
+
return fs.readdirSync(dir)
|
|
267
|
+
.filter(f => f.endsWith('.json'))
|
|
268
|
+
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8')))
|
|
269
|
+
.filter(g => !filter || g.state === filter)
|
|
270
|
+
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getActiveGoals() {
|
|
274
|
+
return listGoals(GOAL_STATES.active);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Oracle execution (integration point for claude -p or sub-agent)
|
|
278
|
+
|
|
279
|
+
function runOracleEvaluation(goal, iterationSummary) {
|
|
280
|
+
const conditionResults = goal.stoppingConditions.map(evaluateCondition);
|
|
281
|
+
const allPassed = conditionResults.every(r => r.passed);
|
|
282
|
+
|
|
283
|
+
if (allPassed) {
|
|
284
|
+
return {
|
|
285
|
+
converged: true,
|
|
286
|
+
conditionResults,
|
|
287
|
+
reasons: [],
|
|
288
|
+
nextHint: null,
|
|
289
|
+
confidence: 1.0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const failedConditions = conditionResults.filter(r => !r.passed);
|
|
294
|
+
return {
|
|
295
|
+
converged: false,
|
|
296
|
+
conditionResults,
|
|
297
|
+
reasons: failedConditions.map(r => `${r.type}: ${r.output.slice(0, 100)}`),
|
|
298
|
+
nextHint: `Focus on: ${failedConditions.map(r => r.type).join(', ')}. ${failedConditions[0].output.slice(0, 200)}`,
|
|
299
|
+
confidence: 0.7,
|
|
300
|
+
oraclePrompt: buildOraclePrompt(goal, conditionResults, iterationSummary),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Main loop driver (called by the goal command handler)
|
|
305
|
+
|
|
306
|
+
async function runGoalIteration(goal, makerFn) {
|
|
307
|
+
const budgetCheck = checkBudget(goal);
|
|
308
|
+
if (budgetCheck.exhausted) {
|
|
309
|
+
escalateGoal(goal, budgetCheck.reason, budgetCheck.detail);
|
|
310
|
+
saveGoal(goal);
|
|
311
|
+
return { action: 'escalated', goal };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const makerResult = await makerFn(goal);
|
|
315
|
+
|
|
316
|
+
const oracleResult = runOracleEvaluation(goal, makerResult.summary);
|
|
317
|
+
|
|
318
|
+
const entry = recordIteration(goal, {
|
|
319
|
+
...oracleResult,
|
|
320
|
+
makerSummary: makerResult.summary,
|
|
321
|
+
}, makerResult.costDollars || 0);
|
|
322
|
+
|
|
323
|
+
saveGoal(goal);
|
|
324
|
+
|
|
325
|
+
if (oracleResult.converged) {
|
|
326
|
+
return { action: 'converged', goal, entry };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { action: 'continue', goal, entry, nextHint: oracleResult.nextHint };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
GOAL_STATES,
|
|
334
|
+
ESCALATION_REASONS,
|
|
335
|
+
generateGoalId,
|
|
336
|
+
createGoal,
|
|
337
|
+
inferStoppingConditions,
|
|
338
|
+
evaluateCondition,
|
|
339
|
+
buildOraclePrompt,
|
|
340
|
+
checkBudget,
|
|
341
|
+
recordIteration,
|
|
342
|
+
escalateGoal,
|
|
343
|
+
pauseGoal,
|
|
344
|
+
resumeGoal,
|
|
345
|
+
saveGoal,
|
|
346
|
+
loadGoal,
|
|
347
|
+
listGoals,
|
|
348
|
+
getActiveGoals,
|
|
349
|
+
runOracleEvaluation,
|
|
350
|
+
runGoalIteration,
|
|
351
|
+
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* heartbeat-scheduler.js
|
|
5
|
+
*
|
|
6
|
+
* Heartbeat engine for Loop Engineering.
|
|
7
|
+
* Runs configured discovery scans on a schedule, classifies results,
|
|
8
|
+
* and routes findings to goals or triage inbox.
|
|
9
|
+
*
|
|
10
|
+
* This module provides the logic; scheduling is handled by CronCreate/CronDelete
|
|
11
|
+
* or ScheduleWakeup in the Claude Code runtime.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
const yaml = require ? undefined : undefined; // yaml parsing fallback below
|
|
18
|
+
|
|
19
|
+
const { createGoal, saveGoal } = require('./completion-oracle');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG = {
|
|
22
|
+
interval: '30m',
|
|
23
|
+
scans: [],
|
|
24
|
+
budget: {
|
|
25
|
+
maxDollarsPerHour: 2.0,
|
|
26
|
+
pauseOnExhaust: true,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SCAN_ACTIONS = {
|
|
31
|
+
autoGoal: 'auto-goal',
|
|
32
|
+
triage: 'triage',
|
|
33
|
+
notify: 'notify',
|
|
34
|
+
ignore: 'ignore',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Simple YAML parser for heartbeat config (avoids external dependency)
|
|
38
|
+
function parseSimpleYaml(content) {
|
|
39
|
+
try {
|
|
40
|
+
// Try JSON first (YAML superset)
|
|
41
|
+
return JSON.parse(content);
|
|
42
|
+
} catch {
|
|
43
|
+
// Minimal YAML parsing for heartbeat.yaml structure
|
|
44
|
+
const lines = content.split('\n');
|
|
45
|
+
const result = { heartbeat: { scans: [], budget: {} } };
|
|
46
|
+
let currentScan = null;
|
|
47
|
+
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
51
|
+
|
|
52
|
+
const indentLevel = line.search(/\S/);
|
|
53
|
+
|
|
54
|
+
if (trimmed.startsWith('interval:')) {
|
|
55
|
+
result.heartbeat.interval = trimmed.split(':')[1].trim().replace(/['"]/g, '');
|
|
56
|
+
} else if (trimmed.startsWith('maxDollarsPerHour:')) {
|
|
57
|
+
result.heartbeat.budget.maxDollarsPerHour = parseFloat(trimmed.split(':')[1].trim());
|
|
58
|
+
} else if (trimmed.startsWith('pauseOnExhaust:')) {
|
|
59
|
+
result.heartbeat.budget.pauseOnExhaust = trimmed.split(':')[1].trim() === 'true';
|
|
60
|
+
} else if (trimmed.startsWith('- name:')) {
|
|
61
|
+
if (currentScan) result.heartbeat.scans.push(currentScan);
|
|
62
|
+
currentScan = { name: trimmed.replace('- name:', '').trim().replace(/['"]/g, '') };
|
|
63
|
+
} else if (currentScan && indentLevel >= 6) {
|
|
64
|
+
const [key, ...valueParts] = trimmed.split(':');
|
|
65
|
+
const value = valueParts.join(':').trim().replace(/['"]/g, '');
|
|
66
|
+
if (key === 'command') currentScan.command = value;
|
|
67
|
+
else if (key === 'onFailure') currentScan.onFailure = value;
|
|
68
|
+
else if (key === 'threshold') currentScan.threshold = parseFloat(value);
|
|
69
|
+
else if (key === 'description') currentScan.description = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (currentScan) result.heartbeat.scans.push(currentScan);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadConfig(projectRoot) {
|
|
78
|
+
const configPath = path.join(projectRoot || process.cwd(), '.claude', 'heartbeat.yaml');
|
|
79
|
+
if (!fs.existsSync(configPath)) return DEFAULT_CONFIG;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
83
|
+
const parsed = parseSimpleYaml(content);
|
|
84
|
+
return { ...DEFAULT_CONFIG, ...parsed.heartbeat };
|
|
85
|
+
} catch {
|
|
86
|
+
return DEFAULT_CONFIG;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runScan(scan) {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const output = execSync(scan.command, {
|
|
94
|
+
encoding: 'utf-8',
|
|
95
|
+
timeout: 60000,
|
|
96
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
97
|
+
}).trim();
|
|
98
|
+
|
|
99
|
+
let passed = true;
|
|
100
|
+
|
|
101
|
+
if (scan.threshold !== undefined) {
|
|
102
|
+
const numeric = parseInt(output.match(/\d+/)?.[0] || '0', 10);
|
|
103
|
+
passed = numeric <= scan.threshold;
|
|
104
|
+
} else {
|
|
105
|
+
const exitMatch = output.match(/EXIT:(\d+)$/);
|
|
106
|
+
passed = exitMatch ? exitMatch[1] === '0' : true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name: scan.name,
|
|
111
|
+
passed,
|
|
112
|
+
output: output.slice(0, 500),
|
|
113
|
+
durationMs: Date.now() - startTime,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
name: scan.name,
|
|
119
|
+
passed: false,
|
|
120
|
+
output: (error.stderr || error.message || 'Unknown error').slice(0, 500),
|
|
121
|
+
durationMs: Date.now() - startTime,
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function classifyResult(scan, result) {
|
|
128
|
+
if (result.passed) return { action: 'pass', scan, result };
|
|
129
|
+
return {
|
|
130
|
+
action: scan.onFailure || SCAN_ACTIONS.triage,
|
|
131
|
+
scan,
|
|
132
|
+
result,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createTriageItem(scan, result) {
|
|
137
|
+
return {
|
|
138
|
+
id: `triage-${Date.now().toString(36)}`,
|
|
139
|
+
source: `heartbeat:${scan.name}`,
|
|
140
|
+
severity: scan.onFailure === SCAN_ACTIONS.autoGoal ? 'high' : 'medium',
|
|
141
|
+
summary: `${scan.description || scan.name}: ${result.output.slice(0, 100)}`,
|
|
142
|
+
detail: result.output,
|
|
143
|
+
suggestedActions: ['Create goal to fix', 'Ignore for now', 'Defer to next session'],
|
|
144
|
+
createdAt: new Date().toISOString(),
|
|
145
|
+
status: 'pending',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function appendToTriageInbox(item) {
|
|
150
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
151
|
+
const inboxDir = path.join(home, '.claude', 'triage');
|
|
152
|
+
if (!fs.existsSync(inboxDir)) {
|
|
153
|
+
fs.mkdirSync(inboxDir, { recursive: true });
|
|
154
|
+
}
|
|
155
|
+
const inboxPath = path.join(inboxDir, 'inbox.jsonl');
|
|
156
|
+
fs.appendFileSync(inboxPath, JSON.stringify(item) + '\n', 'utf-8');
|
|
157
|
+
return inboxPath;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function createGoalFromScanFailure(scan, result) {
|
|
161
|
+
const objective = `Fix ${scan.description || scan.name}: ${result.output.slice(0, 80)}`;
|
|
162
|
+
|
|
163
|
+
const stoppingConditions = [{
|
|
164
|
+
type: 'custom_command',
|
|
165
|
+
command: scan.command,
|
|
166
|
+
description: scan.description || scan.name,
|
|
167
|
+
}];
|
|
168
|
+
|
|
169
|
+
if (scan.threshold !== undefined) {
|
|
170
|
+
stoppingConditions[0].threshold = scan.threshold;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const goal = createGoal(objective, { stoppingConditions });
|
|
174
|
+
saveGoal(goal);
|
|
175
|
+
return goal;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runHeartbeat(projectRoot) {
|
|
179
|
+
const config = loadConfig(projectRoot);
|
|
180
|
+
|
|
181
|
+
if (config.scans.length === 0) {
|
|
182
|
+
return {
|
|
183
|
+
status: 'no_scans',
|
|
184
|
+
message: 'No scans configured in .claude/heartbeat.yaml',
|
|
185
|
+
results: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const results = config.scans.map(scan => {
|
|
190
|
+
const result = runScan(scan);
|
|
191
|
+
const classified = classifyResult(scan, result);
|
|
192
|
+
|
|
193
|
+
if (classified.action === SCAN_ACTIONS.autoGoal) {
|
|
194
|
+
const goal = createGoalFromScanFailure(scan, result);
|
|
195
|
+
classified.goalId = goal.goalId;
|
|
196
|
+
} else if (classified.action === SCAN_ACTIONS.triage) {
|
|
197
|
+
const item = createTriageItem(scan, result);
|
|
198
|
+
appendToTriageInbox(item);
|
|
199
|
+
classified.triageId = item.id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return classified;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const passed = results.filter(r => r.action === 'pass').length;
|
|
206
|
+
const failed = results.length - passed;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
status: failed > 0 ? 'issues_found' : 'all_clear',
|
|
210
|
+
timestamp: new Date().toISOString(),
|
|
211
|
+
summary: `${passed}/${results.length} scans passed`,
|
|
212
|
+
results,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getHeartbeatStatus(projectRoot) {
|
|
217
|
+
const config = loadConfig(projectRoot);
|
|
218
|
+
|
|
219
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
220
|
+
const lastRunPath = path.join(home, '.claude', 'heartbeat-last-run.json');
|
|
221
|
+
let lastRun = null;
|
|
222
|
+
if (fs.existsSync(lastRunPath)) {
|
|
223
|
+
try {
|
|
224
|
+
lastRun = JSON.parse(fs.readFileSync(lastRunPath, 'utf-8'));
|
|
225
|
+
} catch { /* ignore */ }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
configured: config.scans.length > 0,
|
|
230
|
+
interval: config.interval,
|
|
231
|
+
scanCount: config.scans.length,
|
|
232
|
+
scans: config.scans.map(s => ({ name: s.name, onFailure: s.onFailure, description: s.description })),
|
|
233
|
+
budget: config.budget,
|
|
234
|
+
lastRun,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function saveLastRun(result) {
|
|
239
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
240
|
+
const lastRunPath = path.join(home, '.claude', 'heartbeat-last-run.json');
|
|
241
|
+
const dir = path.dirname(lastRunPath);
|
|
242
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
243
|
+
fs.writeFileSync(lastRunPath, JSON.stringify(result, null, 2), 'utf-8');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseInterval(interval) {
|
|
247
|
+
const match = (interval || '30m').match(/^(\d+)(m|h)$/);
|
|
248
|
+
if (!match) return 30 * 60;
|
|
249
|
+
const value = parseInt(match[1], 10);
|
|
250
|
+
return match[2] === 'h' ? value * 60 * 60 : value * 60;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
SCAN_ACTIONS,
|
|
255
|
+
loadConfig,
|
|
256
|
+
runScan,
|
|
257
|
+
classifyResult,
|
|
258
|
+
createTriageItem,
|
|
259
|
+
appendToTriageInbox,
|
|
260
|
+
createGoalFromScanFailure,
|
|
261
|
+
runHeartbeat,
|
|
262
|
+
getHeartbeatStatus,
|
|
263
|
+
saveLastRun,
|
|
264
|
+
parseInterval,
|
|
265
|
+
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { validateInstallModuleIds } = require('../install-manifests');
|
|
4
4
|
const { normalizeInstallTarget } = require('../install-targets/registry');
|
|
5
5
|
|
|
6
|
-
const LEGACY_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'opencode'];
|
|
6
|
+
const LEGACY_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'opencode', 'cangming'];
|
|
7
7
|
|
|
8
8
|
function dedupeStrings(values) {
|
|
9
9
|
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
|
@@ -8,7 +8,7 @@ const {
|
|
|
8
8
|
} = require('./install-targets/registry');
|
|
9
9
|
|
|
10
10
|
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
|
|
11
|
-
const SUPPORTED_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'copilot', 'windsurf', 'augment'];
|
|
11
|
+
const SUPPORTED_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'cangming', 'codewhale', 'codebuddy', 'copilot', 'windsurf', 'augment'];
|
|
12
12
|
const COMPONENT_FAMILY_PREFIXES = {
|
|
13
13
|
baseline: 'baseline:',
|
|
14
14
|
language: 'lang:',
|
|
@@ -54,6 +54,14 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
|
|
|
54
54
|
'platform-configs',
|
|
55
55
|
'workflow-quality',
|
|
56
56
|
],
|
|
57
|
+
codewhale: [
|
|
58
|
+
'rules-core',
|
|
59
|
+
'agents-core',
|
|
60
|
+
'commands-core',
|
|
61
|
+
'hooks-runtime',
|
|
62
|
+
'platform-configs',
|
|
63
|
+
'workflow-quality',
|
|
64
|
+
],
|
|
57
65
|
});
|
|
58
66
|
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
|
|
59
67
|
cpp: 'cpp',
|