spec-and-loop 3.3.4 → 3.3.5

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.
@@ -77,7 +77,10 @@ Task validators must be surgical and efficient so the loop spends tokens on impl
77
77
  - Hard blocker: `` `<gate command>` exits 0; baseline failures are not allowed for this task ``
78
78
  - 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.
79
79
  - 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.
80
- - 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.
80
+ - 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:
81
+ - flat unprefixed: `.ralph/baselines/<gate>.txt`
82
+ - flat prefixed: `.ralph/baselines/<change>-<gate>.txt`
83
+ - 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)
81
84
  - 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.
82
85
  - 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.
83
86
 
@@ -87,9 +90,9 @@ Pre-flight template:
87
90
  - Scope: no code edits; writes only under `.ralph/baselines/`
88
91
  - Change: Capture current state of all gates later tasks require.
89
92
  - Done when:
90
- - `.ralph/baselines/<gate>.txt` or `.ralph/baselines/<change>-<gate>.txt` exists for each gate with full output
93
+ - one of `.ralph/baselines/<gate>.txt`, `.ralph/baselines/<change>-<gate>.txt`, or `.ralph/baselines/<change>/<gate>.txt` exists for each gate with full output
91
94
  - every captured gate file ends with a literal `EXIT=<integer>` line
92
- - `.ralph/baselines/<change>-readme.md` lists passing/failing gates, exit codes, and exact failing identifiers
95
+ - `.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
93
96
  - 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.
94
97
  ```
95
98
 
@@ -235,27 +235,85 @@ function _detectFailingBaselineGates(ralphDir) {
235
235
  }
236
236
 
237
237
  function _detectRecordedBaselineGates(ralphDir) {
238
+ // Collect candidate `.txt` files from three supported layouts:
239
+ // - flat (per-change `.ralph`):
240
+ // `<ralphDir>/baselines/<gate>.txt` or `<change>-<gate>.txt`
241
+ // - nested (per-change `.ralph`):
242
+ // `<ralphDir>/baselines/<change>/<gate>.txt`
243
+ // - repo-root (shared across changes; written by pre-flight tasks that
244
+ // say `.ralph/baselines/<change-slug>/...` in tasks.md when `ralphDir`
245
+ // is itself a per-change directory under `openspec/changes/<slug>/.ralph`):
246
+ // `<repoRoot>/.ralph/baselines/<change-slug>/<gate>.txt`
247
+ // The repo-root layout is what `ralph-run.sh:setup_ralph_directory()`
248
+ // implicitly produces when the change's pre-flight task scope is
249
+ // `.ralph/baselines/<slug>/` (a repo-relative path) while `ralphDir` is
250
+ // the per-change `.ralph` sibling of `tasks.md`. All three layouts are
251
+ // documented in OPENSPEC-RALPH-BP.md.
252
+ const candidates = [];
253
+
238
254
  const baselinesDir = fsPath.join(ralphDir, 'baselines');
239
- if (!fs.existsSync(baselinesDir) || !fs.statSync(baselinesDir).isDirectory()) {
240
- return [];
255
+ if (fs.existsSync(baselinesDir) && fs.statSync(baselinesDir).isDirectory()) {
256
+ for (const entry of fs.readdirSync(baselinesDir, { withFileTypes: true })) {
257
+ if (entry.isFile() && /\.txt$/i.test(entry.name)) {
258
+ candidates.push({
259
+ fileName: entry.name,
260
+ absPath: fsPath.join(baselinesDir, entry.name),
261
+ relPath: fsPath.join('baselines', entry.name),
262
+ });
263
+ continue;
264
+ }
265
+ if (!entry.isDirectory()) continue;
266
+
267
+ const subDir = fsPath.join(baselinesDir, entry.name);
268
+ let inner = [];
269
+ try {
270
+ inner = fs.readdirSync(subDir, { withFileTypes: true });
271
+ } catch (_err) {
272
+ continue;
273
+ }
274
+ for (const innerEntry of inner) {
275
+ if (!innerEntry.isFile() || !/\.txt$/i.test(innerEntry.name)) continue;
276
+ candidates.push({
277
+ fileName: innerEntry.name,
278
+ absPath: fsPath.join(subDir, innerEntry.name),
279
+ relPath: fsPath.join('baselines', entry.name, innerEntry.name),
280
+ });
281
+ }
282
+ }
241
283
  }
242
284
 
243
- const gates = [];
244
- for (const name of fs.readdirSync(baselinesDir)) {
245
- if (!/\.txt$/i.test(name)) continue;
285
+ const repoRootFallback = _resolveRepoRootBaselineDir(ralphDir);
286
+ if (repoRootFallback) {
287
+ const { dir: repoBaselineDir, slug: repoSlug } = repoRootFallback;
288
+ try {
289
+ for (const innerEntry of fs.readdirSync(repoBaselineDir, { withFileTypes: true })) {
290
+ if (!innerEntry.isFile() || !/\.txt$/i.test(innerEntry.name)) continue;
291
+ candidates.push({
292
+ fileName: innerEntry.name,
293
+ absPath: fsPath.join(repoBaselineDir, innerEntry.name),
294
+ relPath: fsPath.join('baselines', repoSlug, innerEntry.name),
295
+ });
296
+ }
297
+ } catch (_err) {
298
+ // best-effort: missing or unreadable fallback dir is treated as "no baselines here"
299
+ }
300
+ }
301
+
302
+ if (candidates.length === 0) return [];
246
303
 
247
- const gateName = _gateNameFromBaselineFile(name);
304
+ const gates = [];
305
+ for (const candidate of candidates) {
306
+ const gateName = _gateNameFromBaselineFile(candidate.fileName);
248
307
  if (!gateName) continue;
249
308
 
250
- const file = fsPath.join(baselinesDir, name);
251
- const tail = _readFileTail(file, 16384);
309
+ const tail = _readFileTail(candidate.absPath, 16384);
252
310
  const exitMatch = tail.match(/(?:^|\n)EXIT=(\d+)(?:\n|$)/);
253
311
  if (!exitMatch) continue;
254
312
 
255
313
  const exitCode = Number(exitMatch[1]);
256
314
  if (!Number.isInteger(exitCode)) continue;
257
315
 
258
- gates.push({ name: gateName, file: fsPath.join('baselines', name), exitCode });
316
+ gates.push({ name: gateName, file: candidate.relPath, exitCode });
259
317
  }
260
318
 
261
319
  const priority = { typecheck: 1, lint: 2, test: 3 };
@@ -265,6 +323,37 @@ function _detectRecordedBaselineGates(ralphDir) {
265
323
  );
266
324
  }
267
325
 
326
+ function _resolveRepoRootBaselineDir(ralphDir) {
327
+ if (!ralphDir) return null;
328
+
329
+ // Match `<repoRoot>/openspec/changes/<slug>/.ralph` exactly. The slug must
330
+ // be a single non-empty path segment with no separators or `..` to avoid
331
+ // escaping the changes directory. We intentionally do not fall back to
332
+ // process.cwd(): the repoRoot is derived from the ralphDir path itself so
333
+ // this stays a pure function of its argument and remains unit-testable
334
+ // without chdir.
335
+ const normalized = ralphDir.replace(/\\/g, '/').replace(/\/+$/, '');
336
+ const match = normalized.match(/^(.*)\/openspec\/changes\/([^/]+)\/\.ralph$/);
337
+ if (!match) return null;
338
+
339
+ const repoRoot = match[1];
340
+ const slug = match[2];
341
+ if (!slug || slug === '.' || slug === '..') return null;
342
+
343
+ const dir = fsPath.join(repoRoot, '.ralph', 'baselines', slug);
344
+ if (!fs.existsSync(dir)) return null;
345
+
346
+ let stat;
347
+ try {
348
+ stat = fs.statSync(dir);
349
+ } catch (_err) {
350
+ return null;
351
+ }
352
+ if (!stat.isDirectory()) return null;
353
+
354
+ return { dir, slug };
355
+ }
356
+
268
357
  function _detectMissingBaselineGates(strictGates, recordedBaselines, taskBlock, tasksFile) {
269
358
  if (!Array.isArray(strictGates) || strictGates.length === 0) return [];
270
359
 
@@ -416,6 +505,7 @@ module.exports = {
416
505
  _detectStrictCleanGates,
417
506
  _detectFailingBaselineGates,
418
507
  _detectRecordedBaselineGates,
508
+ _resolveRepoRootBaselineDir,
419
509
  _detectMissingBaselineGates,
420
510
  _completedPreflightBaselineExists,
421
511
  _gateNameFromBaselineFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "3.3.4",
3
+ "version": "3.3.5",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {