Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions OPENSPEC-RALPH-BP.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ Task validators must be surgical and efficient so the loop spends tokens on impl
- Hard blocker: `` `<gate command>` exits 0; baseline failures are not allowed for this task ``
- When strict clean-gate text conflicts with a failing pre-flight baseline and no classification/cleanup rule is written, `ralph-run` will warn the agent to stop with `BLOCKED_HANDOFF` instead of spending iterations on unauthorized cleanup.
- When a task refers to a pre-flight baseline, or follows a completed pre-flight baseline task, but the matching `.ralph/baselines/<change>-<gate>.txt` artifact is missing, `ralph-run` will warn the agent to stop with `BLOCKED_HANDOFF` instead of treating undocumented failures as known.
- A pre-flight baseline task must produce runner-recognizable artifacts, not just human-readable logs: baseline files must live under the change-local `.ralph/baselines/` directory that `ralph-run` reads, their filenames must identify the gate (`typecheck`, `lint`, `test`, etc.), and every captured gate file must end with a literal `EXIT=<integer>` line.
- A pre-flight baseline task must produce runner-recognizable artifacts, not just human-readable logs: baseline files must live under the change-local `.ralph/baselines/` directory that `ralph-run` reads, their filenames must identify the gate (`typecheck`, `lint`, `test`, etc.), and every captured gate file must end with a literal `EXIT=<integer>` line. The runner discovers baselines in three supported layouts:
- flat unprefixed: `.ralph/baselines/<gate>.txt`
- flat prefixed: `.ralph/baselines/<change>-<gate>.txt`
- nested: `.ralph/baselines/<change>/<gate>.txt` (one level of subdirectory; useful when a single change emits many gate files and authors want to group them under a slug folder)
- If a later task is allowed to repair baseline artifact compatibility, say so explicitly. Its `Scope:` must name the change-local `.ralph/baselines/` directory and its `Done when:` bullets must require the missing or malformed baseline files to be restored with parseable `EXIT=<integer>` footers. Without that authorization, baseline artifact repair remains an operator handoff, not product implementation work.
- Authorized cleanup is intentionally narrow: the named files must be backticked, the cleanup is limited to compiler/lint-only fixes, and `ralph-run` gives the agent one repair attempt for those files on that task. If the gate still fails after that attempt, the next prompt tells the agent to hand off instead of retrying.

Expand All @@ -87,9 +90,9 @@ Pre-flight template:
- Scope: no code edits; writes only under `.ralph/baselines/`
- Change: Capture current state of all gates later tasks require.
- Done when:
- `.ralph/baselines/<gate>.txt` or `.ralph/baselines/<change>-<gate>.txt` exists for each gate with full output
- one of `.ralph/baselines/<gate>.txt`, `.ralph/baselines/<change>-<gate>.txt`, or `.ralph/baselines/<change>/<gate>.txt` exists for each gate with full output
- every captured gate file ends with a literal `EXIT=<integer>` line
- `.ralph/baselines/<change>-readme.md` lists passing/failing gates, exit codes, and exact failing identifiers
- `.ralph/baselines/<change>-readme.md` (flat layout) or `.ralph/baselines/<change>/readme.md` (nested layout) lists passing/failing gates, exit codes, and exact failing identifiers
- Stop and hand off if: any gate is nondeterministic across two runs, or any captured baseline file is missing the `EXIT=<integer>` final line after retrying the capture command.
```

Expand Down
108 changes: 99 additions & 9 deletions lib/mini-ralph/runner-baseline-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,27 +235,85 @@ function _detectFailingBaselineGates(ralphDir) {
}

function _detectRecordedBaselineGates(ralphDir) {
// Collect candidate `.txt` files from three supported layouts:
// - flat (per-change `.ralph`):
// `<ralphDir>/baselines/<gate>.txt` or `<change>-<gate>.txt`
// - nested (per-change `.ralph`):
// `<ralphDir>/baselines/<change>/<gate>.txt`
// - repo-root (shared across changes; written by pre-flight tasks that
// say `.ralph/baselines/<change-slug>/...` in tasks.md when `ralphDir`
// is itself a per-change directory under `openspec/changes/<slug>/.ralph`):
// `<repoRoot>/.ralph/baselines/<change-slug>/<gate>.txt`
// The repo-root layout is what `ralph-run.sh:setup_ralph_directory()`
// implicitly produces when the change's pre-flight task scope is
// `.ralph/baselines/<slug>/` (a repo-relative path) while `ralphDir` is
// the per-change `.ralph` sibling of `tasks.md`. All three layouts are
// documented in OPENSPEC-RALPH-BP.md.
const candidates = [];

const baselinesDir = fsPath.join(ralphDir, 'baselines');
if (!fs.existsSync(baselinesDir) || !fs.statSync(baselinesDir).isDirectory()) {
return [];
if (fs.existsSync(baselinesDir) && fs.statSync(baselinesDir).isDirectory()) {
for (const entry of fs.readdirSync(baselinesDir, { withFileTypes: true })) {
if (entry.isFile() && /\.txt$/i.test(entry.name)) {
candidates.push({
fileName: entry.name,
absPath: fsPath.join(baselinesDir, entry.name),
relPath: fsPath.join('baselines', entry.name),
});
continue;
}
if (!entry.isDirectory()) continue;

const subDir = fsPath.join(baselinesDir, entry.name);
let inner = [];
try {
inner = fs.readdirSync(subDir, { withFileTypes: true });
} catch (_err) {
continue;
}
for (const innerEntry of inner) {
if (!innerEntry.isFile() || !/\.txt$/i.test(innerEntry.name)) continue;
candidates.push({
fileName: innerEntry.name,
absPath: fsPath.join(subDir, innerEntry.name),
relPath: fsPath.join('baselines', entry.name, innerEntry.name),
});
}
}
}

const gates = [];
for (const name of fs.readdirSync(baselinesDir)) {
if (!/\.txt$/i.test(name)) continue;
const repoRootFallback = _resolveRepoRootBaselineDir(ralphDir);
if (repoRootFallback) {
const { dir: repoBaselineDir, slug: repoSlug } = repoRootFallback;
try {
for (const innerEntry of fs.readdirSync(repoBaselineDir, { withFileTypes: true })) {
if (!innerEntry.isFile() || !/\.txt$/i.test(innerEntry.name)) continue;
candidates.push({
fileName: innerEntry.name,
absPath: fsPath.join(repoBaselineDir, innerEntry.name),
relPath: fsPath.join('baselines', repoSlug, innerEntry.name),
});
}
} catch (_err) {
// best-effort: missing or unreadable fallback dir is treated as "no baselines here"
}
}

if (candidates.length === 0) return [];

const gateName = _gateNameFromBaselineFile(name);
const gates = [];
for (const candidate of candidates) {
const gateName = _gateNameFromBaselineFile(candidate.fileName);
if (!gateName) continue;

const file = fsPath.join(baselinesDir, name);
const tail = _readFileTail(file, 16384);
const tail = _readFileTail(candidate.absPath, 16384);
const exitMatch = tail.match(/(?:^|\n)EXIT=(\d+)(?:\n|$)/);
if (!exitMatch) continue;

const exitCode = Number(exitMatch[1]);
if (!Number.isInteger(exitCode)) continue;

gates.push({ name: gateName, file: fsPath.join('baselines', name), exitCode });
gates.push({ name: gateName, file: candidate.relPath, exitCode });
}

const priority = { typecheck: 1, lint: 2, test: 3 };
Expand All @@ -265,6 +323,37 @@ function _detectRecordedBaselineGates(ralphDir) {
);
}

function _resolveRepoRootBaselineDir(ralphDir) {
if (!ralphDir) return null;

// Match `<repoRoot>/openspec/changes/<slug>/.ralph` exactly. The slug must
// be a single non-empty path segment with no separators or `..` to avoid
// escaping the changes directory. We intentionally do not fall back to
// process.cwd(): the repoRoot is derived from the ralphDir path itself so
// this stays a pure function of its argument and remains unit-testable
// without chdir.
const normalized = ralphDir.replace(/\\/g, '/').replace(/\/+$/, '');
const match = normalized.match(/^(.*)\/openspec\/changes\/([^/]+)\/\.ralph$/);
if (!match) return null;

const repoRoot = match[1];
const slug = match[2];
if (!slug || slug === '.' || slug === '..') return null;

const dir = fsPath.join(repoRoot, '.ralph', 'baselines', slug);
if (!fs.existsSync(dir)) return null;

let stat;
try {
stat = fs.statSync(dir);
} catch (_err) {
return null;
}
if (!stat.isDirectory()) return null;

return { dir, slug };
}

function _detectMissingBaselineGates(strictGates, recordedBaselines, taskBlock, tasksFile) {
if (!Array.isArray(strictGates) || strictGates.length === 0) return [];

Expand Down Expand Up @@ -416,6 +505,7 @@ module.exports = {
_detectStrictCleanGates,
_detectFailingBaselineGates,
_detectRecordedBaselineGates,
_resolveRepoRootBaselineDir,
_detectMissingBaselineGates,
_completedPreflightBaselineExists,
_gateNameFromBaselineFile,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "spec-and-loop",
"version": "3.3.4",
"version": "3.3.5",
"description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
"main": "index.js",
"bin": {
Expand Down
139 changes: 139 additions & 0 deletions tests/unit/javascript/mini-ralph-runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,34 @@ describe('_buildBaselineGateFeedback()', () => {
})).toBe('');
});

test('nested change-slug baselines satisfy the completed pre-flight gate', () => {
const ralphDir = path.join(tmpDir, '.ralph-nested-pf');
const tasksFile = path.join(tmpDir, 'tasks-nested-pf.md');
const nestedDir = path.join(ralphDir, 'baselines', 'migrate-hero-dark');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'typecheck.txt'), 'gate output\n\nEXIT=1\n', 'utf8');
fs.writeFileSync(tasksFile, [
'- [x] 0.1 Pre-flight: record quality gate baselines',
' - Done when:',
' - `.ralph/baselines/migrate-hero-dark/typecheck.txt` exists',
'- [ ] 0.3 Implement hero',
' - Done when:',
' - `pnpm typecheck` exits 0, or failures match the pre-flight baseline with no new failures',
'',
].join('\n'), 'utf8');

const feedback = _buildBaselineGateFeedback(ralphDir, tasksFile, {
number: '0.3',
description: 'Implement hero',
});

expect(feedback).not.toContain('matching baseline artifact is missing');
expect(feedback).toContain('appears to authorize baseline classification');
expect(feedback).toContain(
`baselines${path.sep}migrate-hero-dark${path.sep}typecheck.txt exits 1`,
);
});

test('extracts current task block and detects strict gates', () => {
const tasksFile = path.join(tmpDir, 'tasks.md');
fs.writeFileSync(tasksFile, [
Expand Down Expand Up @@ -1313,6 +1341,117 @@ describe('_buildBaselineGateFeedback()', () => {
]);
});

test('discovers baselines nested one level under a change-slug subdirectory', () => {
const ralphDir = path.join(tmpDir, '.ralph-nested');
const nestedDir = path.join(ralphDir, 'baselines', 'migrate-hero-dark');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'typecheck.txt'), 'gate output\n\nEXIT=1\n', 'utf8');
fs.writeFileSync(path.join(nestedDir, 'lint.txt'), 'gate output\n\nEXIT=0\n', 'utf8');
fs.writeFileSync(path.join(nestedDir, 'test-full.txt'), 'gate output\n\nEXIT=1\n', 'utf8');

expect(_detectRecordedBaselineGates(ralphDir)).toEqual([
{
name: 'typecheck',
file: path.join('baselines', 'migrate-hero-dark', 'typecheck.txt'),
exitCode: 1,
},
{
name: 'lint',
file: path.join('baselines', 'migrate-hero-dark', 'lint.txt'),
exitCode: 0,
},
{
name: 'test',
file: path.join('baselines', 'migrate-hero-dark', 'test-full.txt'),
exitCode: 1,
},
]);
});

test('merges flat and nested baseline layouts in the same baselines dir', () => {
const ralphDir = path.join(tmpDir, '.ralph-mixed');
writeBaseline(ralphDir, 'shared-lint.txt', 0);
const nestedDir = path.join(ralphDir, 'baselines', 'feature-x');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'typecheck.txt'), 'gate output\n\nEXIT=2\n', 'utf8');

const gates = _detectRecordedBaselineGates(ralphDir);
expect(gates).toEqual([
{
name: 'typecheck',
file: path.join('baselines', 'feature-x', 'typecheck.txt'),
exitCode: 2,
},
{
name: 'lint',
file: path.join('baselines', 'shared-lint.txt'),
exitCode: 0,
},
]);
});

test('discovers baselines at repo-root <repoRoot>/.ralph/baselines/<slug>/ when ralphDir is a per-change .ralph dir', () => {
// Mirrors the layout that ralph-run.sh produces for OpenSpec changes:
// ralphDir = <repoRoot>/openspec/changes/<slug>/.ralph
// baselines = <repoRoot>/.ralph/baselines/<slug>/<gate>.txt
// The per-change `.ralph/baselines` is intentionally empty in this test
// to prove the repo-root fallback is what surfaces the gates.
const repoRoot = path.join(tmpDir, 'repo-root-fallback');
const slug = 'migrate-uxep-hero-dark-to-hbr-mode-dark';
const ralphDir = path.join(repoRoot, 'openspec', 'changes', slug, '.ralph');
fs.mkdirSync(ralphDir, { recursive: true });

const repoBaselineDir = path.join(repoRoot, '.ralph', 'baselines', slug);
fs.mkdirSync(repoBaselineDir, { recursive: true });
fs.writeFileSync(path.join(repoBaselineDir, 'typecheck.txt'), 'gate output\n\nEXIT=1\n', 'utf8');
fs.writeFileSync(path.join(repoBaselineDir, 'lint.txt'), 'gate output\n\nEXIT=0\n', 'utf8');

expect(_detectRecordedBaselineGates(ralphDir)).toEqual([
{
name: 'typecheck',
file: path.join('baselines', slug, 'typecheck.txt'),
exitCode: 1,
},
{
name: 'lint',
file: path.join('baselines', slug, 'lint.txt'),
exitCode: 0,
},
]);
});

test('repo-root fallback satisfies a completed pre-flight baseline gate', () => {
const repoRoot = path.join(tmpDir, 'repo-root-fallback-gate');
const slug = 'migrate-uxep-hero-dark-to-hbr-mode-dark';
const ralphDir = path.join(repoRoot, 'openspec', 'changes', slug, '.ralph');
fs.mkdirSync(ralphDir, { recursive: true });

const repoBaselineDir = path.join(repoRoot, '.ralph', 'baselines', slug);
fs.mkdirSync(repoBaselineDir, { recursive: true });
fs.writeFileSync(path.join(repoBaselineDir, 'typecheck.txt'), 'gate output\n\nEXIT=1\n', 'utf8');

const tasksFile = path.join(ralphDir, '..', 'tasks.md');
fs.writeFileSync(tasksFile, [
'- [x] Pre-flight: record quality gate baselines',
' - Done when:',
` - \`.ralph/baselines/${slug}/typecheck.txt\` exists`,
'- [ ] Implement hero',
' - Done when:',
' - `pnpm typecheck` exits 0, or failures match the pre-flight baseline with no new failures',
'',
].join('\n'), 'utf8');

const feedback = _buildBaselineGateFeedback(ralphDir, tasksFile, {
description: 'Implement hero',
});

expect(feedback).not.toContain('matching baseline artifact is missing');
expect(feedback).toContain('appears to authorize baseline classification');
expect(feedback).toContain(
`baselines${path.sep}${slug}${path.sep}typecheck.txt exits 1`,
);
});

test('detects missing baseline gates only when a baseline policy exists', () => {
const strictGates = [
{ name: 'typecheck', command: 'pnpm typecheck' },
Expand Down
Loading