@colin4k1024/tsp 2.5.1 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/commands/dashboard.md +105 -0
  2. package/commands/goal.md +142 -0
  3. package/commands/heartbeat.md +129 -0
  4. package/commands/triage.md +108 -0
  5. package/hooks/harness-context-monitor.js +4 -0
  6. package/hooks/hooks.json +23 -23
  7. package/package.json +1 -1
  8. package/schemas/goal.schema.json +172 -0
  9. package/scripts/hooks/session-start-goal-resume.js +95 -0
  10. package/scripts/hooks/suggest-compact.js +81 -19
  11. package/scripts/lib/blame-attribution.js +210 -0
  12. package/scripts/lib/completion-oracle.js +351 -0
  13. package/scripts/lib/heartbeat-scheduler.js +265 -0
  14. package/scripts/lib/wave-cost-advisor.js +155 -0
  15. package/skills/goal-convergence/SKILL.md +150 -0
  16. package/skills/goframe-v2/examples/practices/quick-demo/manifest/config/config.yaml +14 -14
  17. package/skills/loop-heartbeat/SKILL.md +120 -0
  18. package/skills/mcp-connector-bridge/SKILL.md +132 -0
  19. package/skills/repo-scan/SKILL.md +63 -63
  20. package/skills/rework-loop/SKILL.md +131 -0
  21. package/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/build_platform_artifacts.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/install_platform.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/langfuse_trace.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/query_audit_logs.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/scan_leaked_keys.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/team_skills_platform.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/team_skills_platform.cpython-313.pyc +0 -0
  29. package/scripts/__pycache__/validate_library.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/validate_workflow_state.cpython-311.pyc +0 -0
  31. package/scripts/evolution/__pycache__/__init__.cpython-311.pyc +0 -0
  32. package/scripts/evolution/__pycache__/store.cpython-311.pyc +0 -0
  33. package/scripts/hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  34. package/scripts/hooks/__pycache__/mcp_health_check.cpython-311.pyc +0 -0
  35. package/scripts/hooks/__pycache__/observe.cpython-311.pyc +0 -0
  36. package/scripts/hooks/__pycache__/session_end.cpython-311.pyc +0 -0
  37. package/scripts/hooks/__pycache__/session_start.cpython-311.pyc +0 -0
  38. package/scripts/lib/__pycache__/audit_logger.cpython-311.pyc +0 -0
  39. package/scripts/lib/__pycache__/audit_query.cpython-311.pyc +0 -0
  40. package/scripts/lib/__pycache__/hook_contract.cpython-311.pyc +0 -0
  41. package/scripts/lib/__pycache__/memory_store.cpython-311.pyc +0 -0
  42. package/scripts/lib/__pycache__/utils.cpython-311.pyc +0 -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
+ };