dev-harness-cli 1.0.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/adapters/amazon-q/README.md +23 -0
  4. package/adapters/antigravity/README.md +22 -0
  5. package/adapters/claude-code/README.md +30 -0
  6. package/adapters/cline/README.md +23 -0
  7. package/adapters/codex/README.md +31 -0
  8. package/adapters/copilot/README.md +23 -0
  9. package/adapters/cursor/README.md +29 -0
  10. package/adapters/gemini/README.md +23 -0
  11. package/adapters/generic/README.md +40 -0
  12. package/adapters/hermes/README.md +31 -0
  13. package/adapters/hermes/SKILL.md +89 -0
  14. package/adapters/hermes/scripts/init.mjs +27 -0
  15. package/adapters/hermes/scripts/phase.mjs +27 -0
  16. package/adapters/hermes/scripts/validate.mjs +27 -0
  17. package/adapters/kilo-code/README.md +23 -0
  18. package/adapters/openclaw/README.md +22 -0
  19. package/adapters/pi/README.md +22 -0
  20. package/adapters/roo/README.md +23 -0
  21. package/adapters/windsurf/README.md +23 -0
  22. package/cli/commands/checkpoint.mjs +94 -0
  23. package/cli/commands/config.mjs +268 -0
  24. package/cli/commands/contract.mjs +155 -0
  25. package/cli/commands/detect-tool.mjs +112 -0
  26. package/cli/commands/init.mjs +351 -0
  27. package/cli/commands/learn.mjs +47 -0
  28. package/cli/commands/pause.mjs +34 -0
  29. package/cli/commands/phase.mjs +182 -0
  30. package/cli/commands/resume.mjs +33 -0
  31. package/cli/commands/rollback.mjs +261 -0
  32. package/cli/commands/set-mode.mjs +75 -0
  33. package/cli/commands/status.mjs +168 -0
  34. package/cli/commands/validate.mjs +118 -0
  35. package/cli/commands/worktree.mjs +298 -0
  36. package/cli/harness-dev.mjs +88 -0
  37. package/cli/lib/args.mjs +111 -0
  38. package/cli/lib/command-helpers.mjs +50 -0
  39. package/cli/lib/config-registry.mjs +329 -0
  40. package/cli/lib/constants.mjs +30 -0
  41. package/cli/lib/contract.mjs +306 -0
  42. package/cli/lib/detect-stack.mjs +235 -0
  43. package/cli/lib/errors.mjs +71 -0
  44. package/cli/lib/file-io.mjs +90 -0
  45. package/cli/lib/gates.mjs +492 -0
  46. package/cli/lib/git.mjs +144 -0
  47. package/cli/lib/help.mjs +246 -0
  48. package/cli/lib/modes.mjs +92 -0
  49. package/cli/lib/output.mjs +49 -0
  50. package/cli/lib/paths.mjs +75 -0
  51. package/cli/lib/phases.mjs +58 -0
  52. package/cli/lib/platform.mjs +78 -0
  53. package/cli/lib/progress.mjs +357 -0
  54. package/cli/lib/ralph-inner.mjs +314 -0
  55. package/cli/lib/ralph-outer.mjs +249 -0
  56. package/cli/lib/ralph-output.mjs +178 -0
  57. package/cli/lib/scaffold.mjs +431 -0
  58. package/cli/lib/schemas/stacks.json +477 -0
  59. package/cli/lib/state.mjs +333 -0
  60. package/cli/lib/templates.mjs +264 -0
  61. package/cli/lib/tool-registry.mjs +218 -0
  62. package/cli/lib/validate-schema.mjs +131 -0
  63. package/cli/lib/vars.mjs +114 -0
  64. package/package.json +50 -0
  65. package/schema/harness-config.schema.json +127 -0
  66. package/templates/AGENTS.md +63 -0
  67. package/templates/ci/github-actions.yml +78 -0
  68. package/templates/ci/gitlab-ci.yml +59 -0
  69. package/templates/docs/agents/evaluator.md +14 -0
  70. package/templates/docs/agents/generator.md +13 -0
  71. package/templates/docs/agents/planner.md +13 -0
  72. package/templates/docs/agents/simplifier.md +13 -0
  73. package/templates/docs/phases/build.md +41 -0
  74. package/templates/docs/phases/define.md +51 -0
  75. package/templates/docs/phases/plan.md +36 -0
  76. package/templates/docs/phases/review.md +42 -0
  77. package/templates/docs/phases/ship.md +43 -0
  78. package/templates/docs/phases/simplify.md +40 -0
  79. package/templates/docs/phases/verify.md +38 -0
  80. package/templates/evaluator-rubric.md +28 -0
  81. package/templates/init.ps1 +97 -0
  82. package/templates/init.sh +102 -0
  83. package/templates/sprint-contract.md +31 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * contract — Sprint Contract management.
3
+ *
4
+ * Manages the generator-evaluator negotiation loop for pre-build agreement.
5
+ * The contract is stored in sprint-contract.md in the project root.
6
+ *
7
+ * Status flow:
8
+ * pending → in-negotiation → agreed (or needs-revision → back to in-negotiation)
9
+ *
10
+ * Usage:
11
+ * import { proposeContract, reviewContract, getContractStatus, validateContract } from './contract.mjs';
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { CONTRACT_PATH } from './paths.mjs';
16
+ import { MAX_NEGOTIATION_ROUNDS } from './constants.mjs';
17
+
18
+ // ── Status detection ─────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Read the agreement status from a sprint-contract.md file.
22
+ * Returns the current status, or null if file doesn't exist.
23
+ * @param {string} targetDir
24
+ * @returns {{ status: string|null, rounds: number, path: string }}
25
+ */
26
+ export function getContractStatus(targetDir) {
27
+ const path = CONTRACT_PATH(targetDir);
28
+ if (!existsSync(path)) {
29
+ return { status: null, rounds: 0, path };
30
+ }
31
+
32
+ try {
33
+ const content = readFileSync(path, 'utf-8');
34
+ const lines = content.split('\n');
35
+
36
+ let status = null;
37
+ let rounds = 0;
38
+
39
+ for (const line of lines) {
40
+ const statusMatch = line.match(/\*\*Status:\*\*\s*(.+)/);
41
+ if (statusMatch) {
42
+ const raw = statusMatch[1].trim();
43
+ // Strip HTML comments from status value
44
+ const cleanRaw = raw.replace(/<!--.*?-->/g, '').trim();
45
+ // Map to canonical values
46
+ if (cleanRaw.toLowerCase().includes('agreed')) {status = 'agreed';}
47
+ else if (cleanRaw.toLowerCase().includes('needs revision')) {status = 'needs-revision';}
48
+ else if (cleanRaw.toLowerCase().includes('revision')) {status = 'needs-revision';}
49
+ else if (cleanRaw.toLowerCase().includes('escalated')) {status = 'escalated';}
50
+ else if (cleanRaw.length > 0) {status = 'pending';}
51
+ }
52
+
53
+ // Handle both `rounds: 0/5` and `rounds:** 0/5` (bold formatting)
54
+ const roundsMatch = line.match(/rounds?:\s*\*{0,2}\s*(\d+)\/(\d+)/);
55
+ if (roundsMatch) {
56
+ rounds = parseInt(roundsMatch[1], 10);
57
+ }
58
+ }
59
+
60
+ // If file was parsed but no status set, default to pending
61
+ if (status === null) {status = 'pending';}
62
+
63
+ return { status, rounds, path };
64
+ } catch {
65
+ return { status: 'error', rounds: 0, path };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check if the contract is agreed (status === 'agreed').
71
+ * @param {string} targetDir
72
+ * @returns {boolean}
73
+ */
74
+ export function isContractAgreed(targetDir) {
75
+ const { status } = getContractStatus(targetDir);
76
+ return status === 'agreed';
77
+ }
78
+
79
+ // ── Propose ──────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Propose or update a sprint contract.
83
+ *
84
+ * Writes sprint-contract.md with the Generator's proposed scope and criteria.
85
+ * If the file already exists, preserves the Evaluator Review section
86
+ * and only overwrites the Scope + Verification Criteria sections.
87
+ *
88
+ * @param {string} targetDir
89
+ * @param {object} proposal
90
+ * @param {string} proposal.scope — what will be built
91
+ * @param {string} [proposal.exclusions] — what will NOT be built
92
+ * @param {string[]} [proposal.criteria] — verification criteria
93
+ * @returns {{ ok: boolean, error: string|null }}
94
+ */
95
+ export function proposeContract(targetDir, proposal) {
96
+ const path = CONTRACT_PATH(targetDir);
97
+
98
+ let existingReview = '';
99
+ let existingStatus = '';
100
+
101
+ // Preserve evaluator review and status from existing contract
102
+ if (existsSync(path)) {
103
+ try {
104
+ const existing = readFileSync(path, 'utf-8');
105
+ const reviewMatch = existing.match(/## Evaluator Review[\s\S]*?(?=## Agreement|$)/);
106
+ if (reviewMatch) {existingReview = reviewMatch[0];}
107
+
108
+ const statusMatch = existing.match(/## Agreement Status[\s\S]*?(?=#|$)/);
109
+ if (statusMatch) {existingStatus = statusMatch[0];}
110
+ } catch {
111
+ // Ignore read errors
112
+ }
113
+ }
114
+
115
+ const criteriaList = (proposal.criteria || ['']).map(c => `${c}`).join('\n');
116
+
117
+ const content = `# Sprint Contract
118
+
119
+ ## Scope (Generator proposes)
120
+
121
+ **I will build:**
122
+ ${proposal.scope || '<!-- Describe what will be built -->'}
123
+
124
+ **I will NOT build:**
125
+ ${proposal.exclusions || '<!-- Explicit exclusions -->'}
126
+
127
+ ## Verification Criteria (Generator proposes)
128
+
129
+ ${criteriaList || '1. ...'}
130
+
131
+ ${existingReview || `## Evaluator Review (Evaluator fills in)
132
+
133
+ - [ ] Scope is clear and bounded: <!-- yes/no — if no, explain -->
134
+ - [ ] Verification criteria are sufficient: <!-- yes/no — if no, explain -->
135
+ - [ ] Exclusions are reasonable: <!-- yes/no — if no, explain -->
136
+
137
+ **Review notes:**
138
+ <!-- Evaluator's feedback to Generator if revision is needed -->`}
139
+
140
+ ${existingStatus || `## Agreement Status
141
+
142
+ **Status:** <!-- Agreed / Needs Revision -->
143
+ **Negotiation rounds:** 0/${MAX_NEGOTIATION_ROUNDS}`}
144
+ `;
145
+
146
+ try {
147
+ mkdirSync(dirname(path), { recursive: true });
148
+ writeFileSync(path, content, 'utf-8');
149
+ return { ok: true, error: null };
150
+ } catch (err) {
151
+ return { ok: false, error: err.message };
152
+ }
153
+ }
154
+
155
+ // ── Review ───────────────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Review the current contract and update its status.
159
+ *
160
+ * Increments negotiation rounds and sets status to 'agreed' or 'needs-revision'.
161
+ * If rounds >= 5 and still not agreed, automatically escalates.
162
+ *
163
+ * @param {string} targetDir
164
+ * @param {'agreed'|'needs-revision'} decision
165
+ * @param {string} [notes] — evaluator's feedback
166
+ * @returns {{ ok: boolean, error: string|null, escalated: boolean }}
167
+ */
168
+ export function reviewContract(targetDir, decision, notes) {
169
+ const path = CONTRACT_PATH(targetDir);
170
+ if (!existsSync(path)) {
171
+ return { ok: false, error: 'No sprint-contract.md found. Run: harness-dev contract propose first', escalated: false };
172
+ }
173
+
174
+ try {
175
+ let content = readFileSync(path, 'utf-8');
176
+ const { rounds } = getContractStatus(targetDir);
177
+ // Agreement is not a negotiation round — only increment on revision.
178
+ const newRounds = (decision === 'agreed') ? rounds : rounds + 1;
179
+ const escalated = newRounds >= MAX_NEGOTIATION_ROUNDS && decision !== 'agreed';
180
+
181
+ // Update agreement status
182
+ const displayStatus = escalated
183
+ ? 'Escalated — awaiting human adjudication'
184
+ : (decision === 'agreed' ? 'Agreed' : 'Needs Revision');
185
+ const escapedDecision = displayStatus;
186
+
187
+ content = content.replace(
188
+ /\*\*Status:\*\*.*/,
189
+ `**Status:** ${escapedDecision}`,
190
+ );
191
+ content = content.replace(
192
+ /(rounds?:\s*\*{0,2}\s*)\d+\/\d+/,
193
+ `$1${newRounds}/${MAX_NEGOTIATION_ROUNDS}`,
194
+ );
195
+
196
+ // Update review notes if provided
197
+ if (notes) {
198
+ const notesSection = `**Review notes:**\n${notes}\n`;
199
+ content = content.replace(
200
+ /\*\*Review notes:\*\*[\s\S]*?(?=\n##|$)/,
201
+ notesSection,
202
+ );
203
+ }
204
+
205
+ // Auto-escalation: append escalation section to file
206
+ if (escalated) {
207
+ // Remove old escalation section if present
208
+ if (content.includes('## Escalation')) {
209
+ content = content.replace(/\n## Escalation[\s\S]*$/, '');
210
+ }
211
+ content += `\n\n## Escalation\n\n**Reason:** Agents could not reach agreement after ${newRounds} rounds\n\n**Escalated at:** ${new Date().toISOString()}`;
212
+ }
213
+
214
+ writeFileSync(path, content, 'utf-8');
215
+
216
+ return { ok: true, error: null, escalated };
217
+ } catch (err) {
218
+ return { ok: false, error: err.message, escalated: false };
219
+ }
220
+ }
221
+
222
+ // ── Escalate ─────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Escalate a stalled contract negotiation to human.
226
+ * Sets status to 'escalated' and records the escalation reason.
227
+ * @param {string} targetDir
228
+ * @param {string} reason
229
+ * @returns {{ ok: boolean, error: string|null }}
230
+ */
231
+ export function escalateContract(targetDir, reason) {
232
+ const path = CONTRACT_PATH(targetDir);
233
+ if (!existsSync(path)) {
234
+ return { ok: false, error: 'No sprint-contract.md found. Nothing to escalate.' };
235
+ }
236
+
237
+ try {
238
+ let content = readFileSync(path, 'utf-8');
239
+
240
+ content = content.replace(
241
+ /\*\*Status:\*\*.*/,
242
+ `**Status:** Escalated — awaiting human adjudication`,
243
+ );
244
+
245
+ const escalationNote = `\n\n## Escalation\n\n**Reason:** ${reason || `Agents could not reach agreement after ${MAX_NEGOTIATION_ROUNDS} rounds`}\n\n**Escalated at:** ${new Date().toISOString()}`;
246
+
247
+ // Append escalation section before the end
248
+ if (content.includes('## Escalation')) {
249
+ content = content.replace(
250
+ /## Escalation[\s\S]*$/,
251
+ escalationNote.trim(),
252
+ );
253
+ } else {
254
+ content += escalationNote;
255
+ }
256
+
257
+ writeFileSync(path, content, 'utf-8');
258
+ return { ok: true, error: null };
259
+ } catch (err) {
260
+ return { ok: false, error: err.message };
261
+ }
262
+ }
263
+
264
+ // ── Validation (for gates) ───────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Validate contract for gate checking.
268
+ * Returns pass/fail with detail message.
269
+ * @param {string} targetDir
270
+ * @returns {{ name: string, pass: boolean, detail: string }}
271
+ */
272
+ export function validateContract(targetDir) {
273
+ const { status, rounds } = getContractStatus(targetDir);
274
+
275
+ if (status === null) {
276
+ return {
277
+ name: 'contract-agreed',
278
+ pass: false,
279
+ detail: 'Sprint contract not yet proposed. Run: harness-dev contract propose',
280
+ };
281
+ }
282
+
283
+ if (status === 'agreed') {
284
+ return {
285
+ name: 'contract-agreed',
286
+ pass: true,
287
+ detail: `Sprint contract agreed after ${rounds} round(s)`,
288
+ };
289
+ }
290
+
291
+ if (status === 'escalated') {
292
+ return {
293
+ name: 'contract-agreed',
294
+ pass: false,
295
+ detail: 'Sprint contract escalated to human. Awaiting resolution.',
296
+ };
297
+ }
298
+
299
+ // needs-revision or pending
300
+ const noun = status === 'needs-revision' ? 'needs revision' : 'pending';
301
+ return {
302
+ name: 'contract-agreed',
303
+ pass: false,
304
+ detail: `Sprint contract ${noun} (round ${rounds}/${MAX_NEGOTIATION_ROUNDS}). Run: harness-dev contract review`,
305
+ };
306
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Stack detection engine.
3
+ *
4
+ * Scans a directory up to 2 levels deep, identifies the project stack
5
+ * by matching config files and source extensions in priority order.
6
+ * Pure file-I/O — no external deps, no heavy parsing.
7
+ */
8
+
9
+ import { readdirSync } from 'node:fs';
10
+ import { join, extname, basename, resolve } from 'node:path';
11
+ import { readJson } from './file-io.mjs';
12
+ import { STACKS_SCHEMA_PATH } from './paths.mjs';
13
+ import { STACK_SCAN_DEPTH } from './constants.mjs';
14
+ import { loadConfig } from './state.mjs';
15
+
16
+ /** Directories to skip when scanning. */
17
+ const IGNORE_DIRS = new Set([
18
+ '.git', 'node_modules', 'venv', '.venv', '__pycache__',
19
+ 'dist', 'build', '.next', 'target',
20
+ '.tox', '.nox', '.eggs', '*.egg-info',
21
+ '.mypy_cache', '.pytest_cache', '.ruff_cache',
22
+ '.dart_tool', '.packages',
23
+ 'third_party', 'vendor',
24
+ ]);
25
+
26
+ /** Maximum directory depth to scan (0 = current dir only). */
27
+ const SCAN_DEPTH = STACK_SCAN_DEPTH;
28
+
29
+ /**
30
+ * Recursively collect all file paths up to maxDepth.
31
+ * @param {string} dir — absolute path to start from
32
+ * @param {number} maxDepth
33
+ * @returns {string[]}
34
+ */
35
+ function scanFiles(dir, maxDepth = SCAN_DEPTH) {
36
+ const files = [];
37
+ const queue = [{ path: dir, depth: 0 }];
38
+
39
+ while (queue.length > 0) {
40
+ const { path: current, depth } = queue.shift();
41
+ if (depth > maxDepth) {continue;}
42
+
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(current, { withFileTypes: true });
46
+ } catch {
47
+ continue; // permission denied, not found, etc.
48
+ }
49
+
50
+ for (const entry of entries) {
51
+ const fullPath = join(current, entry.name);
52
+ if (entry.isDirectory()) {
53
+ if (!IGNORE_DIRS.has(entry.name)) {
54
+ queue.push({ path: fullPath, depth: depth + 1 });
55
+ }
56
+ } else if (entry.isFile()) {
57
+ files.push(fullPath);
58
+ }
59
+ }
60
+ }
61
+
62
+ return files;
63
+ }
64
+
65
+ /**
66
+ * Detect the project stack in a directory.
67
+ *
68
+ * @param {string} targetDir — directory to scan (default: cwd)
69
+ * @returns {{ name: string, label: string, evidence: string[] }}
70
+ */
71
+ export function detectStack(targetDir = '.') {
72
+ const absDir = resolve(targetDir);
73
+
74
+ // Quick check: does the directory exist at all?
75
+ try {
76
+ if (readdirSync(absDir).length === 0) {
77
+ process.stderr.write(
78
+ `Warning: ${absDir} is empty. Could not detect project stack; falling back to "generic".\n`,
79
+ );
80
+ return { name: 'generic', label: 'Generic', evidence: ['directory empty or unreadable'] };
81
+ }
82
+ } catch {
83
+ process.stderr.write(
84
+ `Warning: cannot read ${absDir}. Could not detect project stack; falling back to "generic".\n`,
85
+ );
86
+ return { name: 'generic', label: 'Generic', evidence: ['cannot read directory'] };
87
+ }
88
+
89
+ const files = scanFiles(absDir, SCAN_DEPTH);
90
+
91
+ // Build detection primitives
92
+ const topFiles = new Set(); // basenames in target dir only
93
+ const allExts = new Set(); // all unique extensions found
94
+
95
+ // Extension-group booleans for pair rules
96
+ let hasC = false;
97
+ let hasCpp = false;
98
+ let hasVhdl = false;
99
+ let hasVerilog = false;
100
+
101
+ for (const f of files) {
102
+ const ext = extname(f).toLowerCase();
103
+ const name = basename(f);
104
+ const dir = resolve(f, '..');
105
+
106
+ if (ext) {allExts.add(ext);}
107
+ if (dir === absDir) {topFiles.add(name);}
108
+
109
+ // Classify for pair / ext-only rules (avoid re-iterating)
110
+ if (ext === '.c') {hasC = true;}
111
+ if (['.cpp','.hpp','.cc','.cxx'].includes(ext)) {hasCpp = true;}
112
+ if (['.vhdl','.vhd'].includes(ext)) {hasVhdl = true;}
113
+ if (['.v','.sv'].includes(ext)) {hasVerilog = true;}
114
+ }
115
+
116
+ // ── helpers ────────────────────────────────────────────────────────────
117
+ const hasTop = (name) => topFiles.has(name);
118
+ const hasAnyTop = (names) => names.some(n => topFiles.has(n));
119
+ const hasExt = (ext) => allExts.has(ext);
120
+ const hasAnyExt = (exts) => exts.some(e => allExts.has(e));
121
+
122
+ // ── detection rules (priority order — first wins) ──────────────────────
123
+
124
+ // 1. Python
125
+ if (hasAnyTop(['pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile'])) {
126
+ return { name: 'python', label: 'Python', evidence: ['config file found'] };
127
+ }
128
+ if (hasExt('.py')) {
129
+ return { name: 'python', label: 'Python', evidence: ['.py files found'] };
130
+ }
131
+
132
+ // 2. Java
133
+ if (hasAnyTop(['pom.xml', 'build.gradle'])) {
134
+ return { name: 'java', label: 'Java', evidence: ['config file found'] };
135
+ }
136
+ if (hasExt('.java')) {
137
+ return { name: 'java', label: 'Java', evidence: ['.java files found'] };
138
+ }
139
+
140
+ // 3. Kotlin
141
+ if (hasTop('build.gradle.kts')) {
142
+ return { name: 'kotlin', label: 'Kotlin', evidence: ['build.gradle.kts found'] };
143
+ }
144
+ if (hasAnyExt(['.kt', '.kts'])) {
145
+ return { name: 'kotlin', label: 'Kotlin', evidence: ['.kt/.kts files found'] };
146
+ }
147
+
148
+ // 4. Node
149
+ if (hasAnyTop(['package.json', 'tsconfig.json', 'yarn.lock', 'pnpm-lock.yaml'])) {
150
+ return { name: 'node', label: 'Node.js', evidence: ['config file found'] };
151
+ }
152
+ if (hasAnyExt(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'])) {
153
+ return { name: 'node', label: 'Node.js', evidence: ['JS/TS source files found'] };
154
+ }
155
+
156
+ // 5. Go
157
+ if (hasTop('go.mod')) {
158
+ return { name: 'go', label: 'Go', evidence: ['go.mod found'] };
159
+ }
160
+ if (hasExt('.go')) {
161
+ return { name: 'go', label: 'Go', evidence: ['.go files found'] };
162
+ }
163
+
164
+ // 6. Rust
165
+ if (hasTop('Cargo.toml')) {
166
+ return { name: 'rust', label: 'Rust', evidence: ['Cargo.toml found'] };
167
+ }
168
+ if (hasExt('.rs')) {
169
+ return { name: 'rust', label: 'Rust', evidence: ['.rs files found'] };
170
+ }
171
+
172
+ // 7. C — .c files found
173
+ if (hasC) {
174
+ return { name: 'c', label: 'C', evidence: ['.c files found'] };
175
+ }
176
+
177
+ // 8. C++ — .cpp/.hpp/.cc/.cxx files found
178
+ if (hasCpp) {
179
+ return { name: 'cpp', label: 'C++', evidence: ['.cpp/.hpp files found'] };
180
+ }
181
+
182
+ // 9. .NET — .cs/.fs/.vb files found
183
+ if (hasAnyExt(['.cs', '.fs', '.vb'])) {
184
+ return { name: 'dotnet', label: '.NET', evidence: ['.cs/.fs/.vb files found'] };
185
+ }
186
+
187
+ // 10. MATLAB — .m files found (low priority to avoid conflicting with other stacks)
188
+ if (hasExt('.m')) {
189
+ return { name: 'matlab', label: 'MATLAB', evidence: ['.m files found'] };
190
+ }
191
+
192
+ // 11. VHDL
193
+ if (hasVhdl) {
194
+ return { name: 'vhdl', label: 'VHDL', evidence: ['.vhdl/.vhd files found'] };
195
+ }
196
+
197
+ // 12. Verilog
198
+ if (hasVerilog) {
199
+ return { name: 'verilog', label: 'Verilog/SystemVerilog', evidence: ['.v/.sv files found'] };
200
+ }
201
+
202
+ // Fallback: no known stack indicators. Warn so the user knows detection
203
+ // failed rather than silently getting a generic stack.
204
+ process.stderr.write(
205
+ `Warning: could not detect project stack in ${absDir}. Falling back to "generic".\n`,
206
+ );
207
+ return { name: 'generic', label: 'Generic', evidence: ['no known stack indicators'] };
208
+ }
209
+
210
+ /**
211
+ * Load stack metadata from the stacks schema, with optional config.stackMeta override.
212
+ * Priority: config.stackMeta (if targetDir given and config has it) > built-in stacks.json.
213
+ * @param {string} stackName
214
+ * @param {string} [targetDir] — optional project dir to read config.stackMeta from
215
+ * @returns {object|null}
216
+ */
217
+ export function getStackMeta(stackName, targetDir) {
218
+ // 1. Read built-in metadata from stacks.json
219
+ const { ok, data } = readJson(STACKS_SCHEMA_PATH);
220
+ const builtIn = (ok && data) ? (data[stackName] || data.generic || null) : null;
221
+
222
+ // 2. If targetDir given, check config.stackMeta for user/agent overrides
223
+ if (targetDir) {
224
+ try {
225
+ const { config, ok: cfgOk } = loadConfig(targetDir);
226
+ if (cfgOk && config.stackMeta && typeof config.stackMeta === 'object') {
227
+ return { ...builtIn, ...config.stackMeta };
228
+ }
229
+ } catch {
230
+ // config unreadable — use built-in
231
+ }
232
+ }
233
+
234
+ return builtIn;
235
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Error handling — exit codes, error classes, formatting.
3
+ * Every output path supports --json for machine parsing.
4
+ */
5
+
6
+ export const EXIT = Object.freeze({
7
+ SUCCESS: 0,
8
+ VALIDATION_FAILURE: 1,
9
+ USAGE_ERROR: 2,
10
+ INTERNAL_ERROR: 3,
11
+ });
12
+
13
+ /**
14
+ * Thrown for user-facing errors (bad args, unknown commands, etc.)
15
+ */
16
+ export class CliError extends Error {
17
+ constructor(message, exitCode = EXIT.USAGE_ERROR) {
18
+ super(message);
19
+ this.exitCode = exitCode;
20
+ this.name = 'CliError';
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Thrown when a gate validation check fails.
26
+ */
27
+ export class ValidationError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.exitCode = EXIT.VALIDATION_FAILURE;
31
+ this.name = 'ValidationError';
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Format error for output.
37
+ * @param {Error} err
38
+ * @param {boolean} json
39
+ * @returns {string}
40
+ */
41
+ export function formatError(err, json = false) {
42
+ const code = err.exitCode ?? EXIT.INTERNAL_ERROR;
43
+ if (json) {
44
+ const payload = {
45
+ error: err.name ?? 'Error',
46
+ message: err.message,
47
+ exitCode: code,
48
+ };
49
+ // Include stack trace for internal errors (exit 3) to aid debugging.
50
+ // User-facing errors (exit 1/2) stay clean for machine parsing.
51
+ if (code === EXIT.INTERNAL_ERROR && err.stack) {
52
+ payload.stack = err.stack;
53
+ }
54
+ return JSON.stringify(payload);
55
+ }
56
+ const label = code === 2 ? 'Usage error' : code === 1 ? 'Validation' : 'Error';
57
+ return `${label}: ${err.message}`;
58
+ }
59
+
60
+ /**
61
+ * Print error to stderr and exit with the appropriate code.
62
+ * @param {Error} err
63
+ * @param {boolean} json
64
+ */
65
+ export function die(err, json = false) {
66
+ const msg = formatError(err, json);
67
+ const code = err.exitCode ?? EXIT.INTERNAL_ERROR;
68
+ // JSON errors always go to stderr so stdout stays parseable
69
+ process.stderr.write(msg + '\n');
70
+ process.exit(code);
71
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * file-io — Centralized JSON and text file I/O helpers.
3
+ *
4
+ * Standardizes the readFileSync + JSON.parse + try/catch pattern duplicated
5
+ * across state.mjs, contract.mjs, detect-stack.mjs, ralph-inner.mjs, etc.
6
+ * All helpers return result objects ({ ok, data, error }) and never throw.
7
+ *
8
+ * Usage:
9
+ * import { readJson, writeJson, readText, writeText } from './file-io.mjs';
10
+ * const { ok, data, error } = readJson('/path/to/config.json');
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+
15
+ /**
16
+ * Read and parse a JSON file. Never throws.
17
+ * @param {string} filePath — absolute path
18
+ * @returns {{ ok: boolean, data: object|null, error: string|null }}
19
+ */
20
+ export function readJson(filePath) {
21
+ if (!existsSync(filePath)) {
22
+ return { ok: false, data: null, error: `Not found: ${filePath}` };
23
+ }
24
+ try {
25
+ const raw = readFileSync(filePath, 'utf-8');
26
+ const data = JSON.parse(raw);
27
+ return { ok: true, data, error: null };
28
+ } catch (err) {
29
+ return { ok: false, data: null, error: `Invalid JSON in ${filePath}: ${err.message}` };
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Serialize and write a JSON file (pretty-printed, 2-space indent, trailing newline).
35
+ * Creates parent directories if needed. Never throws.
36
+ * @param {string} filePath
37
+ * @param {object} data
38
+ * @returns {{ ok: boolean, error: string|null }}
39
+ */
40
+ export function writeJson(filePath, data) {
41
+ try {
42
+ mkdirSync(dirname(filePath), { recursive: true });
43
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
44
+ return { ok: true, error: null };
45
+ } catch (err) {
46
+ return { ok: false, error: err.message };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Read a text file. Never throws.
52
+ * @param {string} filePath
53
+ * @returns {{ ok: boolean, data: string|null, error: string|null }}
54
+ */
55
+ export function readText(filePath) {
56
+ if (!existsSync(filePath)) {
57
+ return { ok: false, data: null, error: `Not found: ${filePath}` };
58
+ }
59
+ try {
60
+ const data = readFileSync(filePath, 'utf-8');
61
+ return { ok: true, data, error: null };
62
+ } catch (err) {
63
+ return { ok: false, data: null, error: err.message };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Write a text file. Creates parent directories if needed. Never throws.
69
+ * @param {string} filePath
70
+ * @param {string} text
71
+ * @returns {{ ok: boolean, error: string|null }}
72
+ */
73
+ export function writeText(filePath, text) {
74
+ try {
75
+ mkdirSync(dirname(filePath), { recursive: true });
76
+ writeFileSync(filePath, text, 'utf-8');
77
+ return { ok: true, error: null };
78
+ } catch (err) {
79
+ return { ok: false, error: err.message };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check whether a file exists.
85
+ * @param {string} filePath
86
+ * @returns {boolean}
87
+ */
88
+ export function fileExists(filePath) {
89
+ return existsSync(filePath);
90
+ }