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