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,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gates — Phase gate validation engine.
|
|
3
|
+
*
|
|
4
|
+
* Each phase has a set of deterministic checks. Checks are functions
|
|
5
|
+
* that return { name, pass, detail }.
|
|
6
|
+
*
|
|
7
|
+
* Phase gates are disabled by default (gates.enabled: false).
|
|
8
|
+
* Run via: harness-dev validate
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { runChecks, getPhase } from './gates.mjs';
|
|
12
|
+
* const result = runChecks('/path/to/project', 'build');
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { resolve, relative } from 'node:path';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { loadConfig } from './state.mjs';
|
|
18
|
+
import { getStackMeta, detectStack } from './detect-stack.mjs';
|
|
19
|
+
import { validateContract } from './contract.mjs';
|
|
20
|
+
import { execGitCheck as execCheck } from './git.mjs';
|
|
21
|
+
import { COVERAGE_TIMEOUT, COVERAGE_THRESHOLD_DEFAULT } from './constants.mjs';
|
|
22
|
+
|
|
23
|
+
function getStackLabel(targetDir) {
|
|
24
|
+
const stack = detectStack(targetDir);
|
|
25
|
+
return stack.name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Individual check functions ───────────────────────────────────────────────
|
|
29
|
+
// Each takes (targetDir) and returns { name, pass, detail }
|
|
30
|
+
|
|
31
|
+
function checkGitRepo(targetDir) {
|
|
32
|
+
const { exitCode, out } = execCheck('git rev-parse --git-dir 2>/dev/null', targetDir);
|
|
33
|
+
return {
|
|
34
|
+
name: 'git-repo',
|
|
35
|
+
pass: exitCode === 0,
|
|
36
|
+
detail: exitCode === 0 ? `Git dir: ${out}` : 'Not a git repository',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkConfigExists(targetDir) {
|
|
41
|
+
const cfgPath = resolve(targetDir, 'harness-config.json');
|
|
42
|
+
const exists = existsSync(cfgPath);
|
|
43
|
+
return {
|
|
44
|
+
name: 'config-exists',
|
|
45
|
+
pass: exists,
|
|
46
|
+
detail: exists ? 'harness-config.json present' : 'Missing: harness-config.json',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkInitExecutable(targetDir) {
|
|
51
|
+
// Windows has no POSIX executable bit — skip the exec-bit check there.
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
const initSh = resolve(targetDir, 'init.sh');
|
|
54
|
+
return {
|
|
55
|
+
name: 'init-executable',
|
|
56
|
+
pass: existsSync(initSh),
|
|
57
|
+
detail: existsSync(initSh) ? 'init.sh present (exec bit not checked on Windows)' : 'init.sh not found',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const { exitCode } = execCheck('test -x init.sh', targetDir);
|
|
62
|
+
return {
|
|
63
|
+
name: 'init-executable',
|
|
64
|
+
pass: exitCode === 0,
|
|
65
|
+
detail: exitCode === 0 ? 'init.sh is executable' : 'init.sh not found or not executable',
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return { name: 'init-executable', pass: false, detail: 'init.sh not found' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkFeatureBranch(targetDir) {
|
|
73
|
+
const { out, exitCode } = execCheck('git symbolic-ref HEAD 2>/dev/null', targetDir);
|
|
74
|
+
if (exitCode !== 0) {
|
|
75
|
+
return { name: 'feature-branch', pass: false, detail: 'Not on a branch (detached HEAD or no git repo)' };
|
|
76
|
+
}
|
|
77
|
+
const ref = out.replace('refs/heads/', '');
|
|
78
|
+
const isMain = ref === 'main' || ref === 'master';
|
|
79
|
+
return {
|
|
80
|
+
name: 'feature-branch',
|
|
81
|
+
pass: !isMain,
|
|
82
|
+
detail: isMain ? `On main/master branch: ${ref}` : `Feature branch: ${ref}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkGitCleanSimple(targetDir) {
|
|
87
|
+
const { exitCode } = execCheck('git diff --quiet 2>/dev/null', targetDir);
|
|
88
|
+
if (exitCode !== 0) {
|
|
89
|
+
// There are uncommitted changes
|
|
90
|
+
return { name: 'git-clean', pass: false, detail: 'Uncommitted changes (git diff --quiet failed)' };
|
|
91
|
+
}
|
|
92
|
+
return { name: 'git-clean', pass: true, detail: 'Working tree clean' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkGitStatusClean(targetDir) {
|
|
96
|
+
const { out, exitCode } = execCheck('git status --porcelain 2>/dev/null', targetDir);
|
|
97
|
+
if (exitCode !== 0) {
|
|
98
|
+
return { name: 'git-clean', pass: false, detail: 'Unable to check git status' };
|
|
99
|
+
}
|
|
100
|
+
const clean = out.length === 0;
|
|
101
|
+
return {
|
|
102
|
+
name: 'git-clean',
|
|
103
|
+
pass: clean,
|
|
104
|
+
detail: clean ? 'Working tree fully clean (no untracked files)' : `Unclean: ${out.slice(0, 200)}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkLint(targetDir) {
|
|
109
|
+
const stack = getStackLabel(targetDir);
|
|
110
|
+
const meta = getStackMeta(stack, targetDir);
|
|
111
|
+
const lintCmd = meta?.lintCmd;
|
|
112
|
+
if (!lintCmd) {
|
|
113
|
+
return { name: 'lint', pass: true, detail: `No lint command configured for ${stack}` };
|
|
114
|
+
}
|
|
115
|
+
const { exitCode, out } = execCheck(lintCmd, targetDir);
|
|
116
|
+
return {
|
|
117
|
+
name: 'lint',
|
|
118
|
+
pass: exitCode === 0,
|
|
119
|
+
detail: exitCode === 0 ? `${lintCmd} — 0 issues` : `${lintCmd} — failed\n${out.slice(0, 200)}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function checkTests(targetDir) {
|
|
124
|
+
const stack = getStackLabel(targetDir);
|
|
125
|
+
const meta = getStackMeta(stack, targetDir);
|
|
126
|
+
const testCmd = meta?.testCmd;
|
|
127
|
+
if (!testCmd) {
|
|
128
|
+
return { name: 'tests', pass: true, detail: `No test command configured for ${stack}` };
|
|
129
|
+
}
|
|
130
|
+
const { exitCode, out } = execCheck(testCmd, targetDir);
|
|
131
|
+
return {
|
|
132
|
+
name: 'tests',
|
|
133
|
+
pass: exitCode === 0,
|
|
134
|
+
detail: exitCode === 0 ? `${testCmd} — passed` : `${testCmd} — failed\n${out.slice(0, 200)}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function checkChangelog(targetDir) {
|
|
139
|
+
const paths = ['CHANGELOG.md', 'changelog.md', 'history/changelog.md', 'CHANGELOG'];
|
|
140
|
+
const found = paths.find(p => existsSync(resolve(targetDir, p)));
|
|
141
|
+
return {
|
|
142
|
+
name: 'changelog',
|
|
143
|
+
pass: !!found,
|
|
144
|
+
detail: found ? `Changelog: ${found}` : 'No CHANGELOG.md found',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkTagged(targetDir) {
|
|
149
|
+
const { out, exitCode } = execCheck('git describe --exact-match --tags HEAD 2>/dev/null', targetDir);
|
|
150
|
+
return {
|
|
151
|
+
name: 'tagged',
|
|
152
|
+
pass: exitCode === 0,
|
|
153
|
+
detail: exitCode === 0 ? `Tagged: ${out}` : 'HEAD is not tagged',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function checkBranchUpToDate(targetDir) {
|
|
158
|
+
const { exitCode } = execCheck(
|
|
159
|
+
'git fetch origin 2>/dev/null; git merge-base --is-ancestor HEAD @{u} 2>/dev/null',
|
|
160
|
+
targetDir,
|
|
161
|
+
);
|
|
162
|
+
if (exitCode === 0) {
|
|
163
|
+
return { name: 'branch-up-to-date', pass: true, detail: 'Branch is up to date with upstream' };
|
|
164
|
+
}
|
|
165
|
+
// Check if there's an upstream at all
|
|
166
|
+
const { out: upstream } = execCheck('git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null', targetDir);
|
|
167
|
+
if (!upstream) {
|
|
168
|
+
return { name: 'branch-up-to-date', pass: true, detail: 'No upstream configured — skipped' };
|
|
169
|
+
}
|
|
170
|
+
return { name: 'branch-up-to-date', pass: false, detail: 'Branch is behind upstream' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Check that sprint contract exists and is agreed. */
|
|
174
|
+
function checkContractAgreed(targetDir) {
|
|
175
|
+
return validateContract(targetDir);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Check that evaluator-rubric.md exists in the project. */
|
|
179
|
+
function checkRubricExists(targetDir) {
|
|
180
|
+
const found = existsSync(resolve(targetDir, 'evaluator-rubric.md'));
|
|
181
|
+
return {
|
|
182
|
+
name: 'rubric-exists',
|
|
183
|
+
pass: found,
|
|
184
|
+
detail: found ? 'evaluator-rubric.md found' : 'evaluator-rubric.md missing — run init to scaffold',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Check test coverage against configured threshold. */
|
|
189
|
+
function checkCoverage(targetDir) {
|
|
190
|
+
const { config } = loadConfig(targetDir);
|
|
191
|
+
const enabled = config?.gates?.coverage?.enabled;
|
|
192
|
+
const threshold = config?.gates?.coverage?.threshold ?? COVERAGE_THRESHOLD_DEFAULT;
|
|
193
|
+
|
|
194
|
+
if (!enabled) {
|
|
195
|
+
return { name: 'coverage', pass: true, detail: 'Coverage gate disabled (set gates.coverage.enabled=true)' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const stack = detectStack(targetDir);
|
|
199
|
+
const meta = getStackMeta(stack.name, targetDir);
|
|
200
|
+
const cmd = meta?.coverageCmd;
|
|
201
|
+
|
|
202
|
+
if (!cmd) {
|
|
203
|
+
return { name: 'coverage', pass: true, detail: `No coverage command for ${stack.label}` };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const { stdout, exitCode } = execSync(cmd, { cwd: targetDir, stdio: 'pipe', encoding: 'utf-8', timeout: COVERAGE_TIMEOUT });
|
|
208
|
+
// Parse percentage from common coverage tool outputs
|
|
209
|
+
const pctMatch = stdout.match(/(\d+(?:\.\d+)?)%/);
|
|
210
|
+
if (!pctMatch) {
|
|
211
|
+
return { name: 'coverage', pass: exitCode === 0, detail: exitCode === 0 ? 'Coverage ran (no percentage parsed)' : 'Coverage command failed' };
|
|
212
|
+
}
|
|
213
|
+
const pct = parseFloat(pctMatch[1]);
|
|
214
|
+
if (pct >= threshold) {
|
|
215
|
+
return { name: 'coverage', pass: true, detail: `${Math.round(pct)}% >= ${threshold}% threshold` };
|
|
216
|
+
}
|
|
217
|
+
return { name: 'coverage', pass: false, detail: `${Math.round(pct)}% < ${threshold}% threshold` };
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { name: 'coverage', pass: false, detail: `Coverage check failed: ${e.message?.split('\n')[0] || e}` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Project deliverable gates (end-user/developer files) ────────────────────
|
|
224
|
+
// These verify that shipped deliverables (README, LICENSE, CHANGELOG,
|
|
225
|
+
// ARCHITECTURE, etc.) are present and meaningfully filled in — not stubs.
|
|
226
|
+
// Separate from workflow gates (harness files like config, contract, rubric).
|
|
227
|
+
|
|
228
|
+
/** Minimum content lines for a doc to be considered "filled in" (not a stub). */
|
|
229
|
+
const MIN_DOC_LINES = 5;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Count non-empty, non-comment, non-heading lines in a file.
|
|
233
|
+
* @param {string} filePath
|
|
234
|
+
* @returns {number}
|
|
235
|
+
*/
|
|
236
|
+
function countContentLines(filePath) {
|
|
237
|
+
try {
|
|
238
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
239
|
+
return content
|
|
240
|
+
.split('\n')
|
|
241
|
+
.filter(l => {
|
|
242
|
+
const t = l.trim();
|
|
243
|
+
return t && !t.startsWith('<!--') && !t.startsWith('#') && !t.startsWith('|--');
|
|
244
|
+
})
|
|
245
|
+
.length;
|
|
246
|
+
} catch {
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Check that README.md exists and has meaningful content. */
|
|
252
|
+
function checkReadme(targetDir) {
|
|
253
|
+
const readmePath = resolve(targetDir, 'README.md');
|
|
254
|
+
if (!existsSync(readmePath)) {
|
|
255
|
+
return { name: 'readme-exists', pass: false, detail: 'README.md missing — every project needs one' };
|
|
256
|
+
}
|
|
257
|
+
const lines = countContentLines(readmePath);
|
|
258
|
+
if (lines < MIN_DOC_LINES) {
|
|
259
|
+
return { name: 'readme-exists', pass: false, detail: `README.md has only ${lines} content lines — needs description, install, usage` };
|
|
260
|
+
}
|
|
261
|
+
return { name: 'readme-exists', pass: true, detail: `README.md present (${lines} content lines)` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Check that a LICENSE file exists. */
|
|
265
|
+
function checkLicense(targetDir) {
|
|
266
|
+
const candidates = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'];
|
|
267
|
+
const found = candidates.find(f => existsSync(resolve(targetDir, f)));
|
|
268
|
+
return {
|
|
269
|
+
name: 'license-exists',
|
|
270
|
+
pass: !!found,
|
|
271
|
+
detail: found ? `${found} present` : 'No LICENSE file found — required for distribution',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Check that CHANGELOG.md exists and has at least one version entry. */
|
|
276
|
+
function checkChangelogContent(targetDir) {
|
|
277
|
+
const candidates = ['CHANGELOG.md', 'CHANGES.md', 'HISTORY.md'];
|
|
278
|
+
const found = candidates.find(f => existsSync(resolve(targetDir, f)));
|
|
279
|
+
if (!found) {
|
|
280
|
+
return { name: 'changelog-content', pass: false, detail: 'No CHANGELOG.md found' };
|
|
281
|
+
}
|
|
282
|
+
const content = readFileSync(resolve(targetDir, found), 'utf-8');
|
|
283
|
+
// Look for version-like patterns: ## [v]1.0.0, ## 2024-01-01, etc.
|
|
284
|
+
const hasVersion = /\n##\s*(\[?v?\d+\.\d+|\d{4}-\d{2})/.test(content);
|
|
285
|
+
return {
|
|
286
|
+
name: 'changelog-content',
|
|
287
|
+
pass: hasVersion,
|
|
288
|
+
detail: hasVersion ? `${found} has version entries` : `${found} exists but no version entries found`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Check that ARCHITECTURE.md is filled in (if file exists, not just stub). */
|
|
293
|
+
function checkArchitectureDoc(targetDir) {
|
|
294
|
+
const archPath = resolve(targetDir, 'ARCHITECTURE.md');
|
|
295
|
+
if (!existsSync(archPath)) {
|
|
296
|
+
return { name: 'architecture-doc', pass: true, detail: 'ARCHITECTURE.md not present (optional)' };
|
|
297
|
+
}
|
|
298
|
+
const lines = countContentLines(archPath);
|
|
299
|
+
if (lines < MIN_DOC_LINES) {
|
|
300
|
+
return { name: 'architecture-doc', pass: false, detail: `ARCHITECTURE.md is a stub (${lines} content lines) — fill in module structure` };
|
|
301
|
+
}
|
|
302
|
+
return { name: 'architecture-doc', pass: true, detail: `ARCHITECTURE.md documented (${lines} content lines)` };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Check that DECISIONS.md has at least one recorded decision (if file exists). */
|
|
306
|
+
function checkDecisionsLogged(targetDir) {
|
|
307
|
+
const decPath = resolve(targetDir, 'DECISIONS.md');
|
|
308
|
+
if (!existsSync(decPath)) {
|
|
309
|
+
return { name: 'decisions-logged', pass: true, detail: 'DECISIONS.md not present (optional)' };
|
|
310
|
+
}
|
|
311
|
+
const content = readFileSync(decPath, 'utf-8');
|
|
312
|
+
const hasEntry = /\n##\s*\d{4}-\d{2}-\d{2}/.test(content) || /\*\*Status:\s*(accepted|proposed)/.test(content);
|
|
313
|
+
return {
|
|
314
|
+
name: 'decisions-logged',
|
|
315
|
+
pass: hasEntry,
|
|
316
|
+
detail: hasEntry ? 'DECISIONS.md has recorded decisions' : 'DECISIONS.md is a stub — record at least one decision',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Check that no empty directories exist (excluding .git, node_modules, build artifacts). */
|
|
321
|
+
function checkNoEmptyDirs(targetDir) {
|
|
322
|
+
const emptyDirs = [];
|
|
323
|
+
const skipDirs = new Set([
|
|
324
|
+
'.git', 'node_modules', '.venv', 'venv', '__pycache__',
|
|
325
|
+
'dist', 'build', 'target', '.next', '.cache', '.pytest_cache',
|
|
326
|
+
'.mypy_cache', '.ruff_cache', '.tox', 'coverage', '.nyc_output',
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
function walk(dir) {
|
|
330
|
+
let entries;
|
|
331
|
+
try {
|
|
332
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
333
|
+
} catch {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let hasContent = false;
|
|
337
|
+
for (const e of entries) {
|
|
338
|
+
if (e.isDirectory()) {
|
|
339
|
+
if (skipDirs.has(e.name)) { hasContent = true; continue; }
|
|
340
|
+
walk(resolve(dir, e.name));
|
|
341
|
+
hasContent = true;
|
|
342
|
+
} else {
|
|
343
|
+
hasContent = true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!hasContent && dir !== targetDir) {
|
|
347
|
+
emptyDirs.push(relative(targetDir, dir));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
walk(targetDir);
|
|
353
|
+
} catch {
|
|
354
|
+
return { name: 'no-empty-dirs', pass: true, detail: 'Could not scan directories' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (emptyDirs.length === 0) {
|
|
358
|
+
return { name: 'no-empty-dirs', pass: true, detail: 'No empty directories' };
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
name: 'no-empty-dirs',
|
|
362
|
+
pass: false,
|
|
363
|
+
detail: `${emptyDirs.length} empty dir(s): ${emptyDirs.slice(0, 5).join(', ')}${emptyDirs.length > 5 ? '...' : ''}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Check that CONTRIBUTING.md exists (optional, recommended for open source). */
|
|
368
|
+
function checkContributing(targetDir) {
|
|
369
|
+
const candidates = ['CONTRIBUTING.md', '.github/CONTRIBUTING.md', 'docs/CONTRIBUTING.md'];
|
|
370
|
+
const found = candidates.find(f => existsSync(resolve(targetDir, f)));
|
|
371
|
+
if (!found) {
|
|
372
|
+
return { name: 'contributing-exists', pass: true, detail: 'CONTRIBUTING.md not present (recommended for open source)' };
|
|
373
|
+
}
|
|
374
|
+
return { name: 'contributing-exists', pass: true, detail: `${found} present` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Check registry ───────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Map phase name → array of check functions.
|
|
381
|
+
* Each check function receives (targetDir) and returns { name, pass, detail }.
|
|
382
|
+
*
|
|
383
|
+
* Two groups of gates:
|
|
384
|
+
* 1. Workflow gates — verify harness files (config, contract, rubric, etc.)
|
|
385
|
+
* 2. Project deliverable gates — verify end-user files (README, LICENSE, etc.)
|
|
386
|
+
*
|
|
387
|
+
* Workflow gates run in early phases; deliverable gates ramp up through
|
|
388
|
+
* SIMPLIFY, REVIEW, and SHIP to ensure the final output is well-documented.
|
|
389
|
+
*/
|
|
390
|
+
const PHASE_CHECKS = {
|
|
391
|
+
init: [
|
|
392
|
+
checkGitRepo,
|
|
393
|
+
checkConfigExists,
|
|
394
|
+
checkInitExecutable,
|
|
395
|
+
],
|
|
396
|
+
define: [
|
|
397
|
+
checkFeatureBranch,
|
|
398
|
+
checkContractAgreed,
|
|
399
|
+
],
|
|
400
|
+
plan: [
|
|
401
|
+
checkGitCleanSimple,
|
|
402
|
+
],
|
|
403
|
+
build: [
|
|
404
|
+
checkGitCleanSimple,
|
|
405
|
+
checkLint,
|
|
406
|
+
checkTests,
|
|
407
|
+
checkContractAgreed,
|
|
408
|
+
checkCoverage,
|
|
409
|
+
],
|
|
410
|
+
verify: [
|
|
411
|
+
checkGitCleanSimple,
|
|
412
|
+
checkTests,
|
|
413
|
+
checkCoverage,
|
|
414
|
+
],
|
|
415
|
+
simplify: [
|
|
416
|
+
checkGitCleanSimple,
|
|
417
|
+
checkNoEmptyDirs,
|
|
418
|
+
],
|
|
419
|
+
review: [
|
|
420
|
+
checkBranchUpToDate,
|
|
421
|
+
checkRubricExists,
|
|
422
|
+
checkReadme,
|
|
423
|
+
checkArchitectureDoc,
|
|
424
|
+
checkDecisionsLogged,
|
|
425
|
+
],
|
|
426
|
+
ship: [
|
|
427
|
+
checkGitStatusClean,
|
|
428
|
+
checkTagged,
|
|
429
|
+
checkChangelog,
|
|
430
|
+
checkReadme,
|
|
431
|
+
checkLicense,
|
|
432
|
+
checkChangelogContent,
|
|
433
|
+
checkContributing,
|
|
434
|
+
checkNoEmptyDirs,
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get the check functions for a given phase.
|
|
440
|
+
* @param {string} phase
|
|
441
|
+
* @returns {Array<Function>}
|
|
442
|
+
*/
|
|
443
|
+
export function getPhaseChecks(phase) {
|
|
444
|
+
return PHASE_CHECKS[phase] || [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Run all checks for a given phase.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} targetDir
|
|
451
|
+
* @param {string} phase
|
|
452
|
+
* @param {object} [options]
|
|
453
|
+
* @param {string} [options.feature] — scope to a specific feature
|
|
454
|
+
* @param {string} [options.task] — scope to a specific task
|
|
455
|
+
* @returns {{ phase: string, checks: Array<{name:string,pass:boolean,detail:string}>, overall: boolean, failures: string[], feature?: string, task?: string }}
|
|
456
|
+
*/
|
|
457
|
+
export function runChecks(targetDir, phase, options = {}) {
|
|
458
|
+
const checks = getPhaseChecks(phase);
|
|
459
|
+
const results = checks.map(fn => fn(targetDir));
|
|
460
|
+
const failures = results.filter(r => !r.pass).map(r => r.name);
|
|
461
|
+
const result = {
|
|
462
|
+
phase,
|
|
463
|
+
checks: results,
|
|
464
|
+
overall: failures.length === 0,
|
|
465
|
+
failures,
|
|
466
|
+
};
|
|
467
|
+
if (options.feature) { result.feature = options.feature; }
|
|
468
|
+
if (options.task) { result.task = options.task; }
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Determine current phase from config, returning the phase name or null.
|
|
474
|
+
* @param {string} targetDir
|
|
475
|
+
* @returns {string|null}
|
|
476
|
+
*/
|
|
477
|
+
export function getPhase(targetDir) {
|
|
478
|
+
const { config, ok } = loadConfig(targetDir);
|
|
479
|
+
if (!ok) {return null;}
|
|
480
|
+
return config.currentPhase || null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Check if gates are enabled in the project config.
|
|
485
|
+
* @param {string} targetDir
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
export function areGatesEnabled(targetDir) {
|
|
489
|
+
const { config, ok } = loadConfig(targetDir);
|
|
490
|
+
if (!ok) {return false;}
|
|
491
|
+
return config.gates?.enabled === true;
|
|
492
|
+
}
|
package/cli/lib/git.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git — Centralized git command execution helpers.
|
|
3
|
+
*
|
|
4
|
+
* All git operations in the CLI go through this module so timeout handling,
|
|
5
|
+
* error recovery, and result shaping are consistent.
|
|
6
|
+
*
|
|
7
|
+
* Result shape: { ok: boolean, stdout: string, stderr: string, exitCode: number }
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { execGit, getGitRoot, getGitBranch, isGitClean } from './git.mjs';
|
|
11
|
+
* const r = execGit('git status --porcelain', cwd);
|
|
12
|
+
* if (!r.ok) { ... }
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import { COMMAND_TIMEOUT } from './constants.mjs';
|
|
17
|
+
|
|
18
|
+
/** Default timeout for git commands. */
|
|
19
|
+
const GIT_TIMEOUT = COMMAND_TIMEOUT;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run a git command and return a normalized result. Never throws.
|
|
23
|
+
* @param {string} cmd — shell command (typically `git ...`)
|
|
24
|
+
* @param {string} cwd — working directory
|
|
25
|
+
* @returns {{ ok: boolean, stdout: string, stderr: string, exitCode: number }}
|
|
26
|
+
*/
|
|
27
|
+
export function execGit(cmd, cwd) {
|
|
28
|
+
try {
|
|
29
|
+
const out = execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8', timeout: GIT_TIMEOUT });
|
|
30
|
+
return { ok: true, stdout: out.trim(), stderr: '', exitCode: 0 };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
stdout: (err.stdout || '').toString().trim(),
|
|
35
|
+
stderr: (err.stderr || '').toString().trim(),
|
|
36
|
+
exitCode: err.status || 1,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run a git command and return trimmed stdout + exitCode (gates.mjs shape).
|
|
43
|
+
* Kept for compatibility with the gates check helper convention.
|
|
44
|
+
* @param {string} cmd
|
|
45
|
+
* @param {string} cwd
|
|
46
|
+
* @returns {{ out: string, exitCode: number }}
|
|
47
|
+
*/
|
|
48
|
+
export function execGitCheck(cmd, cwd) {
|
|
49
|
+
const r = execGit(cmd, cwd);
|
|
50
|
+
return { out: r.ok ? r.stdout : r.stderr, exitCode: r.exitCode };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the absolute path of the git repo root containing `cwd`, or null.
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
export function getGitRoot(cwd) {
|
|
59
|
+
const r = execGit('git rev-parse --show-toplevel', cwd);
|
|
60
|
+
if (!r.ok) { return null; }
|
|
61
|
+
return resolve(r.stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read current git branch name, or null if not on a branch / not a repo.
|
|
66
|
+
* @param {string} cwd
|
|
67
|
+
* @returns {string|null}
|
|
68
|
+
*/
|
|
69
|
+
export function getGitBranch(cwd) {
|
|
70
|
+
const r = execGit('git rev-parse --abbrev-ref HEAD 2>/dev/null', cwd);
|
|
71
|
+
return r.ok && r.stdout ? r.stdout : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if the git working tree is clean. Non-repo → true (assume clean).
|
|
76
|
+
* @param {string} cwd
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function isGitClean(cwd) {
|
|
80
|
+
const r = execGit('git status --porcelain 2>/dev/null', cwd);
|
|
81
|
+
if (!r.ok) { return true; }
|
|
82
|
+
return r.stdout === '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get last commit message subject, or null.
|
|
87
|
+
* @param {string} cwd
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
export function getLastCommitMessage(cwd) {
|
|
91
|
+
const r = execGit('git log -1 --format=%s 2>/dev/null', cwd);
|
|
92
|
+
return r.ok && r.stdout ? r.stdout : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether HEAD has an upstream tracking branch.
|
|
97
|
+
* @param {string} cwd
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function hasGitUpstream(cwd) {
|
|
101
|
+
const r = execGit('git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null', cwd);
|
|
102
|
+
return r.ok && r.stdout !== '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check whether a git tag exists.
|
|
107
|
+
* @param {string} tag
|
|
108
|
+
* @param {string} cwd
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function gitTagExists(tag, cwd) {
|
|
112
|
+
const r = execGit(`git rev-parse --verify "${tag}"`, cwd);
|
|
113
|
+
return r.ok;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a git tag (annotated). Returns true on success.
|
|
118
|
+
* @param {string} tag
|
|
119
|
+
* @param {string} message
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
export function createGitTag(tag, message, cwd) {
|
|
124
|
+
const r = execGit(`git tag -a "${tag}" -m "${message.replace(/"/g, '\\"')}"`, cwd);
|
|
125
|
+
return r.ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Hard reset to HEAD and remove untracked/ignored files (fresh context).
|
|
130
|
+
* Used by the Ralph inner loop when --git-ops is enabled.
|
|
131
|
+
* @param {string} cwd
|
|
132
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
133
|
+
*/
|
|
134
|
+
export function gitHardResetClean(cwd) {
|
|
135
|
+
const reset = execGit('git reset --hard HEAD', cwd);
|
|
136
|
+
if (!reset.ok) {
|
|
137
|
+
return { ok: false, error: reset.stderr || reset.stdout || 'git reset failed' };
|
|
138
|
+
}
|
|
139
|
+
const clean = execGit('git clean -fdx', cwd);
|
|
140
|
+
if (!clean.ok) {
|
|
141
|
+
return { ok: false, error: clean.stderr || clean.stdout || 'git clean failed' };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, error: null };
|
|
144
|
+
}
|