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.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/adapters/amazon-q/README.md +23 -0
- package/adapters/antigravity/README.md +22 -0
- package/adapters/claude-code/README.md +30 -0
- package/adapters/cline/README.md +23 -0
- package/adapters/codex/README.md +31 -0
- package/adapters/copilot/README.md +23 -0
- package/adapters/cursor/README.md +29 -0
- package/adapters/gemini/README.md +23 -0
- package/adapters/generic/README.md +40 -0
- package/adapters/hermes/README.md +31 -0
- package/adapters/hermes/SKILL.md +89 -0
- package/adapters/hermes/scripts/init.mjs +27 -0
- package/adapters/hermes/scripts/phase.mjs +27 -0
- package/adapters/hermes/scripts/validate.mjs +27 -0
- package/adapters/kilo-code/README.md +23 -0
- package/adapters/openclaw/README.md +22 -0
- package/adapters/pi/README.md +22 -0
- package/adapters/roo/README.md +23 -0
- package/adapters/windsurf/README.md +23 -0
- package/cli/commands/checkpoint.mjs +94 -0
- package/cli/commands/config.mjs +268 -0
- package/cli/commands/contract.mjs +155 -0
- package/cli/commands/detect-tool.mjs +112 -0
- package/cli/commands/init.mjs +351 -0
- package/cli/commands/learn.mjs +47 -0
- package/cli/commands/pause.mjs +34 -0
- package/cli/commands/phase.mjs +182 -0
- package/cli/commands/resume.mjs +33 -0
- package/cli/commands/rollback.mjs +261 -0
- package/cli/commands/set-mode.mjs +75 -0
- package/cli/commands/status.mjs +168 -0
- package/cli/commands/validate.mjs +118 -0
- package/cli/commands/worktree.mjs +298 -0
- package/cli/harness-dev.mjs +88 -0
- package/cli/lib/args.mjs +111 -0
- package/cli/lib/command-helpers.mjs +50 -0
- package/cli/lib/config-registry.mjs +329 -0
- package/cli/lib/constants.mjs +30 -0
- package/cli/lib/contract.mjs +306 -0
- package/cli/lib/detect-stack.mjs +235 -0
- package/cli/lib/errors.mjs +71 -0
- package/cli/lib/file-io.mjs +90 -0
- package/cli/lib/gates.mjs +492 -0
- package/cli/lib/git.mjs +144 -0
- package/cli/lib/help.mjs +246 -0
- package/cli/lib/modes.mjs +92 -0
- package/cli/lib/output.mjs +49 -0
- package/cli/lib/paths.mjs +75 -0
- package/cli/lib/phases.mjs +58 -0
- package/cli/lib/platform.mjs +78 -0
- package/cli/lib/progress.mjs +357 -0
- package/cli/lib/ralph-inner.mjs +314 -0
- package/cli/lib/ralph-outer.mjs +249 -0
- package/cli/lib/ralph-output.mjs +178 -0
- package/cli/lib/scaffold.mjs +431 -0
- package/cli/lib/schemas/stacks.json +477 -0
- package/cli/lib/state.mjs +333 -0
- package/cli/lib/templates.mjs +264 -0
- package/cli/lib/tool-registry.mjs +218 -0
- package/cli/lib/validate-schema.mjs +131 -0
- package/cli/lib/vars.mjs +114 -0
- package/package.json +50 -0
- package/schema/harness-config.schema.json +127 -0
- package/templates/AGENTS.md +63 -0
- package/templates/ci/github-actions.yml +78 -0
- package/templates/ci/gitlab-ci.yml +59 -0
- package/templates/docs/agents/evaluator.md +14 -0
- package/templates/docs/agents/generator.md +13 -0
- package/templates/docs/agents/planner.md +13 -0
- package/templates/docs/agents/simplifier.md +13 -0
- package/templates/docs/phases/build.md +41 -0
- package/templates/docs/phases/define.md +51 -0
- package/templates/docs/phases/plan.md +36 -0
- package/templates/docs/phases/review.md +42 -0
- package/templates/docs/phases/ship.md +43 -0
- package/templates/docs/phases/simplify.md +40 -0
- package/templates/docs/phases/verify.md +38 -0
- package/templates/evaluator-rubric.md +28 -0
- package/templates/init.ps1 +97 -0
- package/templates/init.sh +102 -0
- 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
|
+
}
|