@colin4k1024/tsp 2.5.2 → 2.5.4

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.
@@ -14,9 +14,11 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { execSync } = require('child_process');
17
- const yaml = require ? undefined : undefined; // yaml parsing fallback below
17
+ const yaml = require('js-yaml');
18
18
 
19
19
  const { createGoal, saveGoal } = require('./completion-oracle');
20
+ const loopStateStore = require('./loop-state-store');
21
+ const { parseLoopSpecContent } = require('./loop-spec');
20
22
 
21
23
  const DEFAULT_CONFIG = {
22
24
  interval: '30m',
@@ -75,12 +77,40 @@ function parseSimpleYaml(content) {
75
77
  }
76
78
 
77
79
  function loadConfig(projectRoot) {
78
- const configPath = path.join(projectRoot || process.cwd(), '.claude', 'heartbeat.yaml');
79
- if (!fs.existsSync(configPath)) return DEFAULT_CONFIG;
80
+ const root = projectRoot || process.cwd();
81
+ const loopSpecPath = path.join(root, '.tsp', 'loop.yaml');
82
+ if (fs.existsSync(loopSpecPath)) {
83
+ try {
84
+ const loopSpec = parseLoopSpecContent(fs.readFileSync(loopSpecPath, 'utf-8'), loopSpecPath);
85
+ return {
86
+ ...DEFAULT_CONFIG,
87
+ interval: loopSpec.cadence,
88
+ scans: loopSpec.gates.map(gate => ({
89
+ name: gate.name,
90
+ command: `${gate.command} 2>&1; echo EXIT:$?`,
91
+ onFailure: SCAN_ACTIONS.autoGoal,
92
+ description: gate.description || gate.name,
93
+ })),
94
+ budget: {
95
+ ...DEFAULT_CONFIG.budget,
96
+ maxDollarsPerHour: loopSpec.budget.maxDollars,
97
+ },
98
+ };
99
+ } catch {
100
+ return DEFAULT_CONFIG;
101
+ }
102
+ }
103
+
104
+ const configPath = [
105
+ path.join(root, '.tsp', 'heartbeat.yaml'),
106
+ path.join(root, '.claude', 'heartbeat.yaml'),
107
+ ].find(candidate => fs.existsSync(candidate));
108
+
109
+ if (!configPath) return DEFAULT_CONFIG;
80
110
 
81
111
  try {
82
112
  const content = fs.readFileSync(configPath, 'utf-8');
83
- const parsed = parseSimpleYaml(content);
113
+ const parsed = yaml.load(content, { schema: yaml.JSON_SCHEMA }) || parseSimpleYaml(content);
84
114
  return { ...DEFAULT_CONFIG, ...parsed.heartbeat };
85
115
  } catch {
86
116
  return DEFAULT_CONFIG;
@@ -147,14 +177,7 @@ function createTriageItem(scan, result) {
147
177
  }
148
178
 
149
179
  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;
180
+ return loopStateStore.appendTriageItem(item);
158
181
  }
159
182
 
160
183
  function createGoalFromScanFailure(scan, result) {
@@ -181,7 +204,7 @@ function runHeartbeat(projectRoot) {
181
204
  if (config.scans.length === 0) {
182
205
  return {
183
206
  status: 'no_scans',
184
- message: 'No scans configured in .claude/heartbeat.yaml',
207
+ message: 'No scans configured in .tsp/loop.yaml, .tsp/heartbeat.yaml, or .claude/heartbeat.yaml',
185
208
  results: [],
186
209
  };
187
210
  }
@@ -216,14 +239,10 @@ function runHeartbeat(projectRoot) {
216
239
  function getHeartbeatStatus(projectRoot) {
217
240
  const config = loadConfig(projectRoot);
218
241
 
219
- const home = process.env.HOME || process.env.USERPROFILE || '';
220
- const lastRunPath = path.join(home, '.claude', 'heartbeat-last-run.json');
221
242
  let lastRun = null;
222
- if (fs.existsSync(lastRunPath)) {
223
- try {
224
- lastRun = JSON.parse(fs.readFileSync(lastRunPath, 'utf-8'));
225
- } catch { /* ignore */ }
226
- }
243
+ try {
244
+ lastRun = loopStateStore.loadHeartbeat('last-run');
245
+ } catch { /* ignore */ }
227
246
 
228
247
  return {
229
248
  configured: config.scans.length > 0,
@@ -236,11 +255,7 @@ function getHeartbeatStatus(projectRoot) {
236
255
  }
237
256
 
238
257
  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');
258
+ return loopStateStore.saveHeartbeat('last-run', result);
244
259
  }
245
260
 
246
261
  function parseInterval(interval) {
@@ -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',
@@ -0,0 +1,143 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const {
5
+ createInstallTargetAdapter,
6
+ createManagedOperation,
7
+ normalizeRelativePath,
8
+ } = require('./helpers');
9
+
10
+ const PLUGIN_NAME = require('../team-skills-data.json').plugin.name;
11
+
12
+ function addOperation(operations, seen, operation) {
13
+ const key = `${operation.sourceRelativePath}=>${operation.destinationPath}`;
14
+ if (seen.has(key)) {
15
+ return;
16
+ }
17
+ seen.add(key);
18
+ operations.push(operation);
19
+ }
20
+
21
+ function addCopyOperation(operations, seen, moduleId, sourceRelativePath, destinationPath, strategy = 'preserve-relative-path') {
22
+ addOperation(operations, seen, createManagedOperation({
23
+ moduleId,
24
+ sourceRelativePath,
25
+ destinationPath,
26
+ strategy,
27
+ }));
28
+ }
29
+
30
+ function sourceDir(input, sourceRelativePath) {
31
+ return path.join(input.repoRoot || '', normalizeRelativePath(sourceRelativePath));
32
+ }
33
+
34
+ function rootAgentFileName(sourcePath, fileName) {
35
+ const normalizedSourcePath = normalizeRelativePath(sourcePath);
36
+ if (normalizedSourcePath === 'agents/specialists' || normalizedSourcePath.startsWith('agents/specialists/')) {
37
+ return `specialist-${fileName}`;
38
+ }
39
+ return fileName;
40
+ }
41
+
42
+ function addRootAgentOperations(operations, seen, moduleId, sourceRelativePath, input, targetRoot) {
43
+ const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
44
+ if (normalizedSourcePath !== 'agents' && !normalizedSourcePath.startsWith('agents/')) {
45
+ return;
46
+ }
47
+
48
+ const agentDirs = normalizedSourcePath === 'agents'
49
+ ? ['roles', 'specialists']
50
+ : [''];
51
+
52
+ for (const agentDir of agentDirs) {
53
+ const sourcePath = agentDir ? path.join(normalizedSourcePath, agentDir) : normalizedSourcePath;
54
+ const absoluteSourceDir = sourceDir(input, sourcePath);
55
+ if (!input.repoRoot || !fs.existsSync(absoluteSourceDir)) {
56
+ continue;
57
+ }
58
+
59
+ const stat = fs.statSync(absoluteSourceDir);
60
+ if (stat.isFile() && path.extname(absoluteSourceDir) === '.md') {
61
+ const fileName = rootAgentFileName(path.dirname(sourcePath), path.basename(sourcePath));
62
+ addCopyOperation(
63
+ operations,
64
+ seen,
65
+ moduleId,
66
+ sourcePath,
67
+ path.join(targetRoot, 'agents', fileName),
68
+ 'flatten-agent-copy'
69
+ );
70
+ continue;
71
+ }
72
+
73
+ if (!stat.isDirectory()) {
74
+ continue;
75
+ }
76
+
77
+ for (const entry of fs.readdirSync(absoluteSourceDir, { withFileTypes: true }).sort((left, right) => (
78
+ left.name.localeCompare(right.name)
79
+ ))) {
80
+ if (entry.isFile() && entry.name.endsWith('.md')) {
81
+ const fileName = rootAgentFileName(sourcePath, entry.name);
82
+ addCopyOperation(
83
+ operations,
84
+ seen,
85
+ moduleId,
86
+ path.join(sourcePath, entry.name),
87
+ path.join(targetRoot, 'agents', fileName),
88
+ 'flatten-agent-copy'
89
+ );
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ function planCangmingOperations(input, adapter) {
96
+ const targetRoot = adapter.resolveRoot(input);
97
+ const pluginRoot = path.join(targetRoot, 'plugins', PLUGIN_NAME);
98
+ const operations = [];
99
+ const seen = new Set();
100
+
101
+ for (const module of Array.isArray(input.modules) ? input.modules : []) {
102
+ for (const rawSourcePath of Array.isArray(module.paths) ? module.paths : []) {
103
+ const sourceRelativePath = normalizeRelativePath(rawSourcePath);
104
+
105
+ addCopyOperation(
106
+ operations,
107
+ seen,
108
+ module.id,
109
+ sourceRelativePath,
110
+ path.join(pluginRoot, sourceRelativePath),
111
+ 'plugin-copy'
112
+ );
113
+
114
+ if (sourceRelativePath === 'commands' || sourceRelativePath.startsWith('commands/')) {
115
+ const commandSuffix = sourceRelativePath === 'commands'
116
+ ? ''
117
+ : sourceRelativePath.slice('commands/'.length);
118
+ addCopyOperation(
119
+ operations,
120
+ seen,
121
+ module.id,
122
+ sourceRelativePath,
123
+ path.join(targetRoot, 'command', commandSuffix),
124
+ 'opencode-command-copy'
125
+ );
126
+ }
127
+
128
+ addRootAgentOperations(operations, seen, module.id, sourceRelativePath, input, targetRoot);
129
+ }
130
+ }
131
+
132
+ return operations;
133
+ }
134
+
135
+ module.exports = createInstallTargetAdapter({
136
+ id: 'cangming-home',
137
+ target: 'cangming',
138
+ kind: 'home',
139
+ rootSegments: ['.config', 'cangming'],
140
+ installStatePathSegments: ['ecc-install-state.json'],
141
+ nativeRootRelativePath: '.cangming',
142
+ planOperations: planCangmingOperations,
143
+ });
@@ -0,0 +1,187 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const {
5
+ createInstallTargetAdapter,
6
+ createManagedOperation,
7
+ normalizeRelativePath,
8
+ } = require('./helpers');
9
+
10
+ const PLUGIN_NAME = require('../team-skills-data.json').plugin.name;
11
+
12
+ function addOperation(operations, seen, operation) {
13
+ const key = `${operation.sourceRelativePath}=>${operation.destinationPath}`;
14
+ if (seen.has(key)) {
15
+ return;
16
+ }
17
+ seen.add(key);
18
+ operations.push(operation);
19
+ }
20
+
21
+ function addCopyOperation(operations, seen, moduleId, sourceRelativePath, destinationPath, strategy = 'preserve-relative-path') {
22
+ addOperation(operations, seen, createManagedOperation({
23
+ moduleId,
24
+ sourceRelativePath,
25
+ destinationPath,
26
+ strategy,
27
+ }));
28
+ }
29
+
30
+ function sourceDir(input, sourceRelativePath) {
31
+ return path.join(input.repoRoot || '', normalizeRelativePath(sourceRelativePath));
32
+ }
33
+
34
+ function addAgentOperations(operations, seen, moduleId, sourceRelativePath, input, targetRoot) {
35
+ const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
36
+ if (normalizedSourcePath !== 'agents' && !normalizedSourcePath.startsWith('agents/')) {
37
+ return;
38
+ }
39
+
40
+ const agentDirs = normalizedSourcePath === 'agents'
41
+ ? ['roles', 'specialists']
42
+ : [''];
43
+
44
+ for (const agentDir of agentDirs) {
45
+ const sourcePath = agentDir ? path.join(normalizedSourcePath, agentDir) : normalizedSourcePath;
46
+ const absoluteSourceDir = sourceDir(input, sourcePath);
47
+ if (!input.repoRoot || !fs.existsSync(absoluteSourceDir)) {
48
+ continue;
49
+ }
50
+
51
+ const stat = fs.statSync(absoluteSourceDir);
52
+ if (stat.isFile() && path.extname(absoluteSourceDir) === '.md') {
53
+ const fileName = path.basename(sourcePath);
54
+ addCopyOperation(
55
+ operations, seen, moduleId, sourcePath,
56
+ path.join(targetRoot, 'agents', fileName),
57
+ 'flatten-agent-copy'
58
+ );
59
+ continue;
60
+ }
61
+
62
+ if (!stat.isDirectory()) {
63
+ continue;
64
+ }
65
+
66
+ for (const entry of fs.readdirSync(absoluteSourceDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
67
+ if (entry.isFile() && entry.name.endsWith('.md')) {
68
+ const prefix = agentDir === 'specialists' ? 'specialist-' : '';
69
+ addCopyOperation(
70
+ operations, seen, moduleId,
71
+ path.join(sourcePath, entry.name),
72
+ path.join(targetRoot, 'agents', `${prefix}${entry.name}`),
73
+ 'flatten-agent-copy'
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ function addRuleOperations(operations, seen, moduleId, sourceRelativePath, input, targetRoot) {
81
+ const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
82
+ if (normalizedSourcePath !== 'rules' && !normalizedSourcePath.startsWith('rules/')) {
83
+ return;
84
+ }
85
+
86
+ const absoluteSourceDir = sourceDir(input, normalizedSourcePath);
87
+ if (!input.repoRoot || !fs.existsSync(absoluteSourceDir) || !fs.statSync(absoluteSourceDir).isDirectory()) {
88
+ return;
89
+ }
90
+
91
+ const entries = fs.readdirSync(absoluteSourceDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
92
+ for (const entry of entries) {
93
+ const entryPath = path.join(absoluteSourceDir, entry.name);
94
+ if (entry.isDirectory()) {
95
+ const subEntries = fs.readdirSync(entryPath, { withFileTypes: true });
96
+ for (const sub of subEntries) {
97
+ if (sub.isFile() && sub.name.endsWith('.md')) {
98
+ addCopyOperation(
99
+ operations, seen, moduleId,
100
+ path.join(normalizedSourcePath, entry.name, sub.name),
101
+ path.join(targetRoot, 'rules', entry.name, sub.name),
102
+ 'rule-copy'
103
+ );
104
+ }
105
+ }
106
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
107
+ addCopyOperation(
108
+ operations, seen, moduleId,
109
+ path.join(normalizedSourcePath, entry.name),
110
+ path.join(targetRoot, 'rules', entry.name),
111
+ 'rule-copy'
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ function planCodeWhaleOperations(input, adapter) {
118
+ const targetRoot = adapter.resolveRoot(input);
119
+ const operations = [];
120
+ const seen = new Set();
121
+
122
+ for (const module of Array.isArray(input.modules) ? input.modules : []) {
123
+ for (const rawSourcePath of Array.isArray(module.paths) ? module.paths : []) {
124
+ const sourceRelativePath = normalizeRelativePath(rawSourcePath);
125
+
126
+ // Skills: preserve directory structure
127
+ if (sourceRelativePath === 'skills' || sourceRelativePath.startsWith('skills/')) {
128
+ addCopyOperation(
129
+ operations, seen, module.id, sourceRelativePath,
130
+ path.join(targetRoot, 'skills', sourceRelativePath.replace(/^skills\/?/, '')),
131
+ 'skill-copy'
132
+ );
133
+ }
134
+
135
+ // Commands: flatten to commands/
136
+ if (sourceRelativePath === 'commands' || sourceRelativePath.startsWith('commands/')) {
137
+ const commandSuffix = sourceRelativePath === 'commands'
138
+ ? ''
139
+ : sourceRelativePath.slice('commands/'.length);
140
+ addCopyOperation(
141
+ operations, seen, module.id, sourceRelativePath,
142
+ path.join(targetRoot, 'commands', commandSuffix),
143
+ 'command-copy'
144
+ );
145
+ }
146
+
147
+ // Agents: flatten roles + specialists
148
+ addAgentOperations(operations, seen, module.id, sourceRelativePath, input, targetRoot);
149
+
150
+ // Rules: preserve namespace/file structure
151
+ addRuleOperations(operations, seen, module.id, sourceRelativePath, input, targetRoot);
152
+
153
+ // Contexts: direct copy
154
+ if (sourceRelativePath === 'contexts' || sourceRelativePath.startsWith('contexts/')) {
155
+ const contextSuffix = sourceRelativePath === 'contexts'
156
+ ? ''
157
+ : sourceRelativePath.slice('contexts/'.length);
158
+ addCopyOperation(
159
+ operations, seen, module.id, sourceRelativePath,
160
+ path.join(targetRoot, 'contexts', contextSuffix),
161
+ 'context-copy'
162
+ );
163
+ }
164
+
165
+ // Hooks: copy hooks directory for reference, config.toml injection handled by post-install
166
+ if (sourceRelativePath === 'hooks' || sourceRelativePath.startsWith('hooks/')) {
167
+ addCopyOperation(
168
+ operations, seen, module.id, sourceRelativePath,
169
+ path.join(targetRoot, 'hooks', sourceRelativePath.replace(/^hooks\/?/, '')),
170
+ 'hook-copy'
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ return operations;
177
+ }
178
+
179
+ module.exports = createInstallTargetAdapter({
180
+ id: 'codewhale-home',
181
+ target: 'codewhale',
182
+ kind: 'home',
183
+ rootSegments: ['.codewhale'],
184
+ installStatePathSegments: ['ecc-install-state.json'],
185
+ nativeRootRelativePath: '.codewhale',
186
+ planOperations: planCodeWhaleOperations,
187
+ });
@@ -1,8 +1,10 @@
1
1
  const antigravityProject = require('./antigravity-project');
2
2
  const augmentProject = require('./augment-project');
3
+ const cangmingHome = require('./cangming-home');
3
4
  const claudeHome = require('./claude-home');
4
5
  const codebuddyProject = require('./codebuddy-project');
5
6
  const codexHome = require('./codex-home');
7
+ const codewhaleHome = require('./codewhale-home');
6
8
  const copilotHome = require('./copilot-home');
7
9
  const cursorProject = require('./cursor-project');
8
10
  const geminiProject = require('./gemini-project');
@@ -22,6 +24,8 @@ const ADAPTERS = Object.freeze([
22
24
  codexHome,
23
25
  geminiProject,
24
26
  opencodeHome,
27
+ cangmingHome,
28
+ codewhaleHome,
25
29
  codebuddyProject,
26
30
  copilotHome,
27
31
  windsurfProject,
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ // Compatibility layer for the loop-engineering runtime. The existing goal
4
+ // oracle remains the implementation while callers migrate to loop naming.
5
+ module.exports = require('./completion-oracle');
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const yaml = require('js-yaml');
6
+
7
+ class LoopSpecError extends Error {
8
+ constructor(message, details = {}) {
9
+ super(message);
10
+ this.name = 'LoopSpecError';
11
+ this.details = details;
12
+ this.code = details.code || 'loop_spec_error';
13
+ }
14
+ }
15
+
16
+ function ensureObject(value, label, filePath) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
18
+ throw new LoopSpecError(`${label} must be an object`, {
19
+ filePath,
20
+ code: 'invalid_payload',
21
+ });
22
+ }
23
+ }
24
+
25
+ function ensureNonEmptyString(value, label, filePath) {
26
+ if (typeof value !== 'string' || value.trim() === '') {
27
+ throw new LoopSpecError(`${label} must be a non-empty string`, {
28
+ filePath,
29
+ code: 'invalid_field',
30
+ });
31
+ }
32
+ }
33
+
34
+ function ensurePositiveNumber(value, label, filePath) {
35
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
36
+ throw new LoopSpecError(`${label} must be a positive number`, {
37
+ filePath,
38
+ code: 'invalid_budget',
39
+ });
40
+ }
41
+ }
42
+
43
+ function normalizeGate(gate, index, filePath) {
44
+ ensureObject(gate, `loop.gates[${index}]`, filePath);
45
+ ensureNonEmptyString(gate.name, `loop.gates[${index}].name`, filePath);
46
+ ensureNonEmptyString(gate.command, `loop.gates[${index}].command`, filePath);
47
+
48
+ return {
49
+ name: gate.name.trim(),
50
+ command: gate.command.trim(),
51
+ description: typeof gate.description === 'string' && gate.description.trim()
52
+ ? gate.description.trim()
53
+ : null,
54
+ };
55
+ }
56
+
57
+ function normalizeBudget(budget, filePath) {
58
+ ensureObject(budget, 'loop.budget', filePath);
59
+ ensurePositiveNumber(budget.maxIterations, 'loop.budget.maxIterations', filePath);
60
+ ensurePositiveNumber(budget.maxDollars, 'loop.budget.maxDollars', filePath);
61
+ ensureNonEmptyString(budget.maxDuration, 'loop.budget.maxDuration', filePath);
62
+
63
+ if (!/^(\d+)(m|h)$/.test(budget.maxDuration)) {
64
+ throw new LoopSpecError('loop.budget.maxDuration must use Nm or Nh format', {
65
+ filePath,
66
+ code: 'invalid_budget_duration',
67
+ });
68
+ }
69
+
70
+ return {
71
+ maxIterations: budget.maxIterations,
72
+ maxDuration: budget.maxDuration,
73
+ maxDollars: budget.maxDollars,
74
+ };
75
+ }
76
+
77
+ function normalizeActor(actor, label, filePath) {
78
+ ensureObject(actor, label, filePath);
79
+ ensureNonEmptyString(actor.role, `${label}.role`, filePath);
80
+
81
+ return {
82
+ role: actor.role.trim(),
83
+ writeAccess: actor.writeAccess === true,
84
+ };
85
+ }
86
+
87
+ function normalizeLoopSpec(payload, filePath = '<inline>') {
88
+ ensureObject(payload, 'loop spec', filePath);
89
+ ensureObject(payload.loop, 'loop', filePath);
90
+
91
+ const loop = payload.loop;
92
+ ensureNonEmptyString(loop.id, 'loop.id', filePath);
93
+ ensureNonEmptyString(loop.description, 'loop.description', filePath);
94
+ ensureNonEmptyString(loop.cadence, 'loop.cadence', filePath);
95
+ ensureNonEmptyString(loop.skill, 'loop.skill', filePath);
96
+ ensureNonEmptyString(loop.stateFile, 'loop.stateFile', filePath);
97
+
98
+ if (!/^(\d+)(m|h|d)$/.test(loop.cadence)) {
99
+ throw new LoopSpecError('loop.cadence must use Nm, Nh, or Nd format', {
100
+ filePath,
101
+ code: 'invalid_cadence',
102
+ });
103
+ }
104
+
105
+ if (!Array.isArray(loop.gates) || loop.gates.length === 0) {
106
+ throw new LoopSpecError('loop.gates must include at least one hard verification gate', {
107
+ filePath,
108
+ code: 'missing_gates',
109
+ });
110
+ }
111
+
112
+ return {
113
+ id: loop.id.trim(),
114
+ description: loop.description.trim(),
115
+ cadence: loop.cadence.trim(),
116
+ skill: loop.skill.trim(),
117
+ stateFile: loop.stateFile.trim(),
118
+ gates: loop.gates.map((gate, index) => normalizeGate(gate, index, filePath)),
119
+ maker: normalizeActor(loop.maker, 'loop.maker', filePath),
120
+ checker: normalizeActor(loop.checker, 'loop.checker', filePath),
121
+ budget: normalizeBudget(loop.budget, filePath),
122
+ escalation: {
123
+ onBudgetExhausted: loop.escalation?.onBudgetExhausted || 'triage',
124
+ onSecurityFinding: loop.escalation?.onSecurityFinding || 'human',
125
+ },
126
+ };
127
+ }
128
+
129
+ function parseLoopSpecContent(content, filePath = '<inline>') {
130
+ let payload;
131
+ try {
132
+ payload = yaml.load(content, {
133
+ filename: path.basename(filePath),
134
+ schema: yaml.JSON_SCHEMA,
135
+ });
136
+ } catch (error) {
137
+ throw new LoopSpecError(`Failed to parse loop spec YAML: ${error.message}`, {
138
+ filePath,
139
+ code: 'yaml_parse_error',
140
+ });
141
+ }
142
+
143
+ return normalizeLoopSpec(payload, filePath);
144
+ }
145
+
146
+ function loadLoopSpecFile(filePath) {
147
+ let content;
148
+ try {
149
+ content = fs.readFileSync(filePath, 'utf8');
150
+ } catch (error) {
151
+ throw new LoopSpecError(`Failed to read loop spec file: ${error.message}`, {
152
+ filePath,
153
+ code: 'read_error',
154
+ });
155
+ }
156
+
157
+ return {
158
+ path: filePath,
159
+ loop: parseLoopSpecContent(content, filePath),
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ LoopSpecError,
165
+ normalizeLoopSpec,
166
+ parseLoopSpecContent,
167
+ loadLoopSpecFile,
168
+ };