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,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
+ }
@@ -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
+ }