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,357 @@
1
+ /**
2
+ * progress — Dual-structure progress.md reader/writer.
3
+ *
4
+ * Manages the Session State (overwritten) and Lessons Learned (appended)
5
+ * sections of progress.md.
6
+ *
7
+ * Usage:
8
+ * import { readProgress, writeSessionState, appendLesson } from './progress.mjs';
9
+ * const { session, lessons } = readProgress('/path/to/project');
10
+ * writeSessionState('/path/to/project', { phase: 'build', nextAction: 'fix tests' });
11
+ * appendLesson('/path/to/project', 'Found gotcha in X middleware', 'agent');
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { PROGRESS_PATH } from './paths.mjs';
16
+ import { loadConfig } from './state.mjs';
17
+
18
+ // ── Constants ────────────────────────────────────────────────────────────────
19
+
20
+ const SESSION_HEADER = '## Session State';
21
+ const LESSONS_HEADER = '## Lessons';
22
+
23
+ const DEFAULT_SESSION_FIELDS = {
24
+ 'Current Phase': 'not started',
25
+ 'Current Feature': '—',
26
+ 'Gate Status': 'pending',
27
+ 'Next Action': '—',
28
+ 'Retry Count': '0/3',
29
+ };
30
+
31
+ const FIELD_ORDER = [
32
+ 'Current Phase',
33
+ 'Current Feature',
34
+ 'Gate Status',
35
+ 'Next Action',
36
+ 'Retry Count',
37
+ ];
38
+
39
+ // ── Helpers ──────────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Get the path to progress.md for a given project directory.
43
+ * @param {string} targetDir
44
+ * @returns {string}
45
+ */
46
+ export function getProgressPath(targetDir) {
47
+ return PROGRESS_PATH(targetDir);
48
+ }
49
+
50
+ /**
51
+ * Format a date as YYYY-MM-DD.
52
+ * @param {Date} [date]
53
+ * @returns {string}
54
+ */
55
+ function fmtDate(date) {
56
+ const d = date || new Date();
57
+ return d.toISOString().slice(0, 10);
58
+ }
59
+
60
+ // ── Read ─────────────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Parse session state lines into a record.
64
+ * @param {string[]} lines
65
+ * @returns {Record<string, string>}
66
+ */
67
+ function parseSessionLines(lines) {
68
+ const session = {};
69
+ for (const line of lines) {
70
+ const match = line.match(/^(\w[\w ]+):\s*(.*)/);
71
+ if (match) {
72
+ session[match[1].trim()] = match[2].trim();
73
+ }
74
+ }
75
+ return session;
76
+ }
77
+
78
+ /**
79
+ * Read progress.md and return parsed session state + lessons.
80
+ *
81
+ * Returns empty/fallback values when file is missing or malformed
82
+ * (never throws — always returns structured result).
83
+ *
84
+ * @param {string} targetDir
85
+ * @returns {{ session: Record<string,string>, lessons: Array<{date:string,author:string,text:string}>, ok: boolean, path: string }}
86
+ */
87
+ export function readProgress(targetDir) {
88
+ const progPath = getProgressPath(targetDir);
89
+ const fallback = {
90
+ session: { ...DEFAULT_SESSION_FIELDS },
91
+ lessons: [],
92
+ ok: false,
93
+ path: progPath,
94
+ };
95
+
96
+ if (!existsSync(progPath)) {
97
+ return fallback;
98
+ }
99
+
100
+ let content;
101
+ try {
102
+ content = readFileSync(progPath, 'utf-8');
103
+ } catch {
104
+ return fallback;
105
+ }
106
+
107
+ const lines = content.split('\n');
108
+
109
+ // Find section boundaries
110
+ let sessionStart = -1;
111
+ let sessionEnd = -1;
112
+ let lessonsStart = -1;
113
+ let lessonsEnd = -1;
114
+
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const line = lines[i].trim();
117
+
118
+ // Detect section headers
119
+ if (line === SESSION_HEADER) {
120
+ sessionStart = i;
121
+ }
122
+ if (line === LESSONS_HEADER) {
123
+ lessonsStart = i;
124
+ }
125
+ // Detect section boundaries (next ## header after a section started)
126
+ if (sessionStart >= 0 && sessionEnd === -1 && line.startsWith('## ') && i > sessionStart) {
127
+ sessionEnd = i;
128
+ }
129
+ if (lessonsStart >= 0 && lessonsEnd === -1 && line.startsWith('## ') && i > lessonsStart) {
130
+ lessonsEnd = i;
131
+ }
132
+ }
133
+
134
+ if (sessionEnd === -1 && sessionStart >= 0) {
135
+ sessionEnd = lines.length;
136
+ }
137
+ if (lessonsEnd === -1 && lessonsStart >= 0) {
138
+ lessonsEnd = lines.length;
139
+ }
140
+
141
+ // Parse session state
142
+ const session = { ...DEFAULT_SESSION_FIELDS };
143
+ if (sessionStart >= 0) {
144
+ const sessionLines = lines.slice(sessionStart + 1, sessionEnd);
145
+ const parsed = parseSessionLines(sessionLines);
146
+ // Merge parsed over defaults (keep defaults for missing fields)
147
+ for (const key of FIELD_ORDER) {
148
+ if (parsed[key] !== undefined) {
149
+ session[key] = parsed[key];
150
+ }
151
+ }
152
+ }
153
+
154
+ // Parse lessons
155
+ const lessons = [];
156
+ const lessonRe = /^(\d{4}-\d{2}-\d{2})\s*\|\s*(.+?)\s*\|\s*(.+)/;
157
+ if (lessonsStart >= 0) {
158
+ const lessonLines = lines.slice(lessonsStart + 1, lessonsEnd);
159
+ for (const line of lessonLines) {
160
+ const m = line.match(lessonRe);
161
+ if (m) {
162
+ lessons.push({
163
+ date: m[1],
164
+ author: m[2].trim(),
165
+ text: m[3].trim(),
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ return { session, lessons, ok: true, path: progPath };
172
+ }
173
+
174
+ // ── Write session state ──────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Overwrite the Session State section of progress.md.
178
+ *
179
+ * If the file doesn't exist, creates it with the minimal structure.
180
+ * If the ## Session State header doesn't exist, inserts it.
181
+ *
182
+ * @param {string} targetDir
183
+ * @param {Record<string,string>} fields — partial or full session state
184
+ * @returns {{ ok: boolean, error: string|null }}
185
+ */
186
+ export function writeSessionState(targetDir, fields) {
187
+ const progPath = getProgressPath(targetDir);
188
+
189
+ // Build the full session state block
190
+ const merged = { ...DEFAULT_SESSION_FIELDS, ...fields };
191
+ const stateBlockLines = [
192
+ SESSION_HEADER,
193
+ '',
194
+ ...FIELD_ORDER.map(k => `${k}: ${merged[k] ?? DEFAULT_SESSION_FIELDS[k]}`),
195
+ '',
196
+ ];
197
+
198
+ const stateBlock = stateBlockLines.join('\n') + '\n';
199
+
200
+ // Read existing content
201
+ let content;
202
+ if (existsSync(progPath)) {
203
+ try {
204
+ content = readFileSync(progPath, 'utf-8');
205
+ } catch {
206
+ content = '';
207
+ }
208
+ } else {
209
+ content = '';
210
+ }
211
+
212
+ const lines = content.split('\n');
213
+
214
+ // Find Session State section boundaries
215
+ let sessionIdx = -1;
216
+ let sessionEndIdx = -1;
217
+ for (let i = 0; i < lines.length; i++) {
218
+ const line = lines[i].trim();
219
+ if (line === SESSION_HEADER) {
220
+ sessionIdx = i;
221
+ } else if (sessionIdx >= 0 && sessionEndIdx === -1 && line.startsWith('## ') && i > sessionIdx) {
222
+ sessionEndIdx = i;
223
+ break;
224
+ }
225
+ }
226
+ if (sessionIdx >= 0 && sessionEndIdx === -1) {
227
+ sessionEndIdx = lines.length;
228
+ }
229
+
230
+ let newContent;
231
+ if (sessionIdx >= 0) {
232
+ // Replace existing session state section
233
+ const before = lines.slice(0, sessionIdx).join('\n');
234
+ const after = lines.slice(sessionEndIdx).join('\n');
235
+ newContent = (before ? before + '\n' : '') + stateBlock + (after ? after + '\n' : '');
236
+ } else {
237
+ // No session state section — prepend to file
238
+ if (lines.length > 0 && lines[0].trim() !== '') {
239
+ // File has content — insert after the title
240
+ newContent = content.replace(/\n## /, '\n' + stateBlock + '\n## ');
241
+ if (newContent === content) {
242
+ // Fallback: append
243
+ newContent = content + '\n' + stateBlock;
244
+ }
245
+ } else {
246
+ // Empty or nearly empty — start fresh
247
+ newContent = '# Progress\n\n' + stateBlock;
248
+ }
249
+ }
250
+
251
+ // Ensure trailing newline and write
252
+ try {
253
+ mkdirSync(dirname(progPath), { recursive: true });
254
+ writeFileSync(progPath, newContent.replace(/\n*$/, '\n'), 'utf-8');
255
+ return { ok: true, error: null };
256
+ } catch (err) {
257
+ return { ok: false, error: err.message };
258
+ }
259
+ }
260
+
261
+ // ── Append lesson ────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Append a lesson line to the Lessons section of progress.md.
265
+ *
266
+ * Creates ## Lessons section if it doesn't exist.
267
+ *
268
+ * @param {string} targetDir
269
+ * @param {string} text — lesson text
270
+ * @param {string} [author] — defaults to config.agentTool or 'agent'
271
+ * @param {Date} [date] — defaults to today
272
+ * @returns {{ ok: boolean, error: string|null }}
273
+ */
274
+ export function appendLesson(targetDir, text, author, date) {
275
+ const progPath = getProgressPath(targetDir);
276
+ // Resolve author: explicit param > config.agentTool > 'agent' (tool-agnostic default)
277
+ const resolvedAuthor = author || (() => {
278
+ try {
279
+ const { config, ok } = loadConfig(targetDir);
280
+ return ok && config.agentTool ? config.agentTool : 'agent';
281
+ } catch {
282
+ return 'agent';
283
+ }
284
+ })();
285
+ const lessonLine = `${fmtDate(date)} | ${resolvedAuthor} | ${text}`;
286
+
287
+ let content;
288
+ if (existsSync(progPath)) {
289
+ try {
290
+ content = readFileSync(progPath, 'utf-8');
291
+ } catch {
292
+ content = '';
293
+ }
294
+ } else {
295
+ content = '';
296
+ }
297
+
298
+ const lines = content.split('\n');
299
+
300
+ // Find Lessons section
301
+ let lessonsIdx = -1;
302
+ for (let i = 0; i < lines.length; i++) {
303
+ if (lines[i].trim() === LESSONS_HEADER) {
304
+ lessonsIdx = i;
305
+ break;
306
+ }
307
+ }
308
+
309
+ let newContent;
310
+ if (lessonsIdx >= 0) {
311
+ // Find where to insert — after the last lesson line or after the header
312
+ let insertAfter = lessonsIdx;
313
+ for (let i = lessonsIdx + 1; i < lines.length; i++) {
314
+ const line = lines[i].trim();
315
+ if (line === '' || line.match(/^(\d{4}-\d{2}-\d{2})\s*\|/)) {
316
+ insertAfter = i;
317
+ } else if (line.startsWith('## ')) {
318
+ break;
319
+ }
320
+ }
321
+ const before = lines.slice(0, insertAfter + 1).join('\n');
322
+ const after = lines.slice(insertAfter + 1).join('\n');
323
+ newContent = (before ? before + '\n' : '') + lessonLine + '\n' + (after ? after + '\n' : '');
324
+ } else {
325
+ // No Lessons section — append at end
326
+ newContent = content.replace(/\n*$/, '') + '\n\n' + LESSONS_HEADER + '\n\n' + lessonLine + '\n';
327
+ }
328
+
329
+ try {
330
+ mkdirSync(dirname(progPath), { recursive: true });
331
+ writeFileSync(progPath, newContent.replace(/\n*$/, '\n'), 'utf-8');
332
+ return { ok: true, error: null };
333
+ } catch (err) {
334
+ return { ok: false, error: err.message };
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Convenience read of just the session state fields.
340
+ * Returns defaults for any field not found in the file.
341
+ * @param {string} targetDir
342
+ * @returns {Record<string,string>}
343
+ */
344
+ export function readSessionState(targetDir) {
345
+ const { session } = readProgress(targetDir);
346
+ return session;
347
+ }
348
+
349
+ /**
350
+ * Convenience read of just the lessons list.
351
+ * @param {string} targetDir
352
+ * @returns {Array<{date:string,author:string,text:string}>}
353
+ */
354
+ export function readLessons(targetDir) {
355
+ const { lessons } = readProgress(targetDir);
356
+ return lessons;
357
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * ralph-inner — Inner Ralph Loop Engine.
3
+ *
4
+ * Runs the work → validate → pass/retry loop for every phase.
5
+ * Two modes:
6
+ * - Feature-iterate (BUILD, VERIFY, SIMPLIFY): iterates features/tasks
7
+ * - Deliverable-retry (INIT, DEFINE, PLAN, REVIEW, SHIP): retries same deliverable
8
+ *
9
+ * The engine prints instructions for the agent. It does NOT do the work itself.
10
+ *
11
+ * Usage:
12
+ * import { runPhase } from './ralph-inner.mjs';
13
+ * const result = runPhase('/path/to/project', 'build');
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import { loadConfig } from './state.mjs';
18
+ import { validateAgainstSchema } from './validate-schema.mjs';
19
+ import { gitHardResetClean } from './git.mjs';
20
+ import { phaseLabel } from './command-helpers.mjs';
21
+ import { FEATURE_LIST_SCHEMA_PATH, FEATURE_LIST_PATH } from './paths.mjs';
22
+ import { buildFeatureIterateOutput, buildDeliverableRetryOutput } from './ralph-output.mjs';
23
+ import { DEFAULT_MAX_RETRIES } from './constants.mjs';
24
+
25
+ // ── Phase type classification ────────────────────────────────────────────────
26
+
27
+ /** Phases that iterate features (each feature has tasks). */
28
+ const FEATURE_ITERATE = new Set(['build', 'verify', 'simplify']);
29
+
30
+ /** Phases that produce a single deliverable and retry it on failure. */
31
+ const DELIVERABLE_RETRY = new Set(['init', 'define', 'plan', 'review', 'ship']);
32
+
33
+ /**
34
+ * Determine the loop mode for a given phase.
35
+ * @param {string} phase
36
+ * @returns {'feature-iterate'|'deliverable-retry'|null}
37
+ */
38
+ export function getPhaseType(phase) {
39
+ if (FEATURE_ITERATE.has(phase)) {return 'feature-iterate';}
40
+ if (DELIVERABLE_RETRY.has(phase)) {return 'deliverable-retry';}
41
+ return null;
42
+ }
43
+
44
+ // ── Feature list I/O ─────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Get path to feature_list.json.
48
+ * @param {string} targetDir
49
+ * @returns {string}
50
+ */
51
+ function getFeatureListPath(targetDir) {
52
+ return FEATURE_LIST_PATH(targetDir);
53
+ }
54
+
55
+ /**
56
+ * Default feature list (empty, one placeholder feature).
57
+ * @returns {object}
58
+ */
59
+ function getDefaultFeatureList() {
60
+ return {
61
+ version: '0.1',
62
+ features: [
63
+ {
64
+ id: 'feature-001',
65
+ name: 'Feature 1',
66
+ description: 'Replace with actual feature description',
67
+ passes: false,
68
+ tasks: [
69
+ { id: 'task-001', description: 'First task', status: 'pending' },
70
+ ],
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Load feature_list.json. Returns defaults if missing/invalid.
78
+ * @param {string} targetDir
79
+ * @returns {{ features: Array, ok: boolean, path: string }}
80
+ */
81
+ export function loadFeatureList(targetDir) {
82
+ const flPath = getFeatureListPath(targetDir);
83
+ if (!existsSync(flPath)) {
84
+ return { ...getDefaultFeatureList(), ok: false, path: flPath };
85
+ }
86
+ try {
87
+ const raw = readFileSync(flPath, 'utf-8');
88
+ const parsed = JSON.parse(raw);
89
+ const result = { version: parsed.version || '0.1', features: parsed.features || [], ok: true, path: flPath };
90
+ // Schema validation — non-blocking: return errors in result. Library does
91
+ // NOT write to stderr (keeps error contract clean for --json consumers).
92
+ const schemaResult = validateAgainstSchema(parsed, FEATURE_LIST_SCHEMA_PATH);
93
+ result.schemaErrors = schemaResult.ok ? [] : schemaResult.errors;
94
+ return result;
95
+ } catch {
96
+ return { ...getDefaultFeatureList(), ok: false, path: flPath };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Save feature_list.json.
102
+ * @param {string} targetDir
103
+ * @param {object} data
104
+ * @returns {{ ok: boolean, error: string|null }}
105
+ */
106
+ export function saveFeatureList(targetDir, data) {
107
+ try {
108
+ const flPath = getFeatureListPath(targetDir);
109
+ mkdirSync(dirname(flPath), { recursive: true });
110
+ writeFileSync(flPath, JSON.stringify({ version: '0.1', features: data.features }, null, 2) + '\n', 'utf-8');
111
+ return { ok: true, error: null };
112
+ } catch (err) {
113
+ return { ok: false, error: err.message };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Find the next incomplete feature (passes=false).
119
+ * @param {Array} features
120
+ * @returns {object|null}
121
+ */
122
+ export function getNextFeature(features) {
123
+ return features.find(f => !f.passes) || null;
124
+ }
125
+
126
+ /**
127
+ * Find the next uncompleted task in a feature.
128
+ * @param {object} feature
129
+ * @returns {object|null}
130
+ */
131
+ export function getNextTask(feature) {
132
+ if (!feature.tasks) {return null;}
133
+ return feature.tasks.find(t => t.status === 'pending' || t.status === 'in_progress') || null;
134
+ }
135
+
136
+ // ── Inner loop ───────────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Run the inner Ralph loop for a phase.
140
+ *
141
+ * This function prints instructions and returns a result object.
142
+ * It does NOT modify files — the agent reads the instructions and does the work.
143
+ *
144
+ * @param {string} targetDir
145
+ * @param {string} phase
146
+ * @param {object} [options]
147
+ * @param {boolean} [options.json] — JSON output mode
148
+ * @param {boolean} [options.gitOps] — opt-in: execute git reset/clean on retry (default off)
149
+ * @returns {{ ok: boolean, status: string, message: string, phase: string, iteration: number, mode: string, details: object }}
150
+ */
151
+ export function runPhase(targetDir, phase, options = {}) {
152
+ const { json = false, gitOps = false } = options;
153
+
154
+ // Load config
155
+ const { config, ok: configOk } = loadConfig(targetDir);
156
+ if (!configOk) {
157
+ return { ok: false, status: 'error', message: 'Cannot load config', phase, iteration: 0, mode: 'unknown', details: {} };
158
+ }
159
+
160
+ const mode = config.mode ?? 'copilot';
161
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
162
+ const resetOnRetry = config.git?.resetOnRetry === true;
163
+ const autoCommit = config.git?.autoCommit === true;
164
+ const phaseType = getPhaseType(phase);
165
+
166
+ if (!phaseType) {
167
+ return { ok: false, status: 'error', message: `Unknown phase type for "${phase}"`, phase, iteration: 0, mode, details: {} };
168
+ }
169
+
170
+ // Retry count check: escalate if retries exhausted
171
+ const retryCount = config.retryCount ?? 0;
172
+ if (retryCount >= maxRetries) {
173
+ return {
174
+ ok: false,
175
+ status: 'escalated',
176
+ message: `Retries exhausted (${retryCount}/${maxRetries}) for phase "${phase}". Escalating to human.`,
177
+ phase,
178
+ iteration: retryCount,
179
+ mode,
180
+ details: { retryCount, maxRetries },
181
+ };
182
+ }
183
+
184
+ // ── Opt-in git ops: fresh context on retry ──────────────────────────────
185
+ // When --git-ops is passed AND this is a retry (retryCount > 0), execute a
186
+ // hard reset to the last commit + clean untracked files. This gives the
187
+ // "fresh context" Ralph requires without forcing it on agent-agnostic users.
188
+ let gitResetPerformed = false;
189
+ if (gitOps && retryCount > 0) {
190
+ const resetResult = gitHardResetClean(targetDir);
191
+ if (resetResult.ok) {
192
+ gitResetPerformed = true;
193
+ if (!json) {
194
+ process.stdout.write(` ↻ Git reset performed (retry ${retryCount}): fresh context restored.\n`);
195
+ }
196
+ } else {
197
+ // Non-fatal: if git ops fail (e.g. not a repo), continue with instructions.
198
+ process.stderr.write(`Warning: --git-ops reset failed: ${resetResult.error}\n`);
199
+ }
200
+ }
201
+
202
+ // ── Feature-iterate mode (BUILD, VERIFY, SIMPLIFY) ────────────────────────
203
+
204
+ if (phaseType === 'feature-iterate') {
205
+ const fl = loadFeatureList(targetDir);
206
+ const feature = getNextFeature(fl.features);
207
+ const featuresTotal = fl.features.length;
208
+ const featuresDone = fl.features.filter(f => f.passes).length;
209
+
210
+ if (!feature) {
211
+ // All features pass — phase gate passes
212
+ return {
213
+ ok: true,
214
+ status: 'complete',
215
+ message: `All ${featuresTotal} feature(s) pass. Phase gate passes.`,
216
+ phase,
217
+ iteration: 0,
218
+ mode,
219
+ details: { featuresTotal, featuresDone, currentFeature: null, currentTask: null },
220
+ };
221
+ }
222
+
223
+ const task = getNextTask(feature);
224
+ if (!task) {
225
+ // Feature has all tasks complete but passes=false → mark it passing
226
+ feature.passes = true;
227
+ saveFeatureList(targetDir, fl);
228
+ return runPhase(targetDir, phase, options); // Recurse to get next feature
229
+ }
230
+
231
+ const output = buildFeatureIterateOutput(phase, feature, task, mode, maxRetries, resetOnRetry, autoCommit);
232
+
233
+ if (json) {
234
+ return {
235
+ ok: true,
236
+ status: 'instruction',
237
+ message: `${phaseLabel(phase)} — Feature: ${feature.name} — Task: ${task.description}`,
238
+ phase,
239
+ iteration: 1,
240
+ mode,
241
+ details: {
242
+ featuresTotal,
243
+ featuresDone,
244
+ featureId: feature.id,
245
+ featureName: feature.name,
246
+ taskId: task.id,
247
+ taskDescription: task.description,
248
+ phaseType,
249
+ maxRetries,
250
+ resetOnRetry,
251
+ autoCommit,
252
+ gitOps,
253
+ gitResetPerformed,
254
+ instructions: output,
255
+ },
256
+ };
257
+ }
258
+
259
+ // Human output
260
+ process.stdout.write(output);
261
+ process.stdout.write(`\n═══════════════════════════════════════\n`);
262
+ process.stdout.write(`Run: harness-dev validate --feature ${feature.id} --task ${task.id}\n`);
263
+ process.stdout.write(`═══════════════════════════════════════\n`);
264
+
265
+ return {
266
+ ok: true,
267
+ status: 'instruction',
268
+ message: `Working on: ${feature.name} — ${task.description}`,
269
+ phase,
270
+ iteration: 1,
271
+ mode,
272
+ details: { featureId: feature.id, taskId: task.id },
273
+ };
274
+ }
275
+
276
+ // ── Deliverable-retry mode (INIT, DEFINE, PLAN, REVIEW, SHIP) ────────────
277
+
278
+ const output = buildDeliverableRetryOutput(phase, mode, maxRetries, resetOnRetry, autoCommit);
279
+
280
+ if (json) {
281
+ return {
282
+ ok: true,
283
+ status: 'instruction',
284
+ message: `${phaseLabel(phase)}: produce the deliverable`,
285
+ phase,
286
+ iteration: 1,
287
+ mode,
288
+ details: {
289
+ phaseType,
290
+ maxRetries,
291
+ resetOnRetry,
292
+ autoCommit,
293
+ instructions: output,
294
+ },
295
+ };
296
+ }
297
+
298
+ // Human output
299
+ process.stdout.write(output);
300
+ process.stdout.write(`\n═══════════════════════════════════════\n`);
301
+ process.stdout.write(`Run: harness-dev validate\n`);
302
+ process.stdout.write(`═══════════════════════════════════════\n`);
303
+
304
+ return {
305
+ ok: true,
306
+ status: 'instruction',
307
+ message: `${phaseLabel(phase)}: produce the deliverable`,
308
+ phase,
309
+ iteration: 1,
310
+ mode,
311
+ details: {},
312
+ };
313
+ }
314
+