spec-and-loop 3.3.0 → 3.3.2

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.
@@ -21,6 +21,7 @@ Enforced rules:
21
21
  - Title is one outcome, not a list. If you need "and" twice, split.
22
22
  - Scope names files so the loop does not hunt.
23
23
  - `Done when` bullets are observable or runnable. No soft verbs (`ensure`, `support`, `validate`, `keep`) without attached evidence.
24
+ - Verifier commands use the narrowest runnable command that proves the scoped change. Prefer a named test file, spec pattern, package script, or static check over a full-suite command.
24
25
  - `Stop and hand off if` gives the loop written permission to halt.
25
26
 
26
27
  ## Ordering
@@ -52,22 +53,55 @@ Rules:
52
53
 
53
54
  Split test: if the loop stopped halfway, would the repo be clean and reviewable? If yes and there's a verifier for each half, split. If no half is meaningful alone, don't split.
54
55
 
56
+ ## Surgical validation
57
+
58
+ Task validators must be surgical and efficient so the loop spends tokens on implementation signal, not unrelated test noise.
59
+
60
+ - Start every task with the cheapest verifier that proves the task's stated scope: direct unit test file, targeted node/browser spec, exact lint/typecheck command for touched files if available, schema validator, or focused `rg` assertion.
61
+ - Verify command routing before writing it into `tasks.md`. If `npm test -- <pattern>` or similar still runs unrelated suites in that repo, write the direct runner command instead (for example, `pnpm exec vitest --config <config> --run <test-file>`).
62
+ - Use broad gates (`npm test`, `pnpm typecheck`, `make all`, browser/e2e suites) only when the task owns repo-wide integration behavior, when they are recorded as pre-flight baselines, or in a final integrated quality-gate task.
63
+ - If a broad gate is still required for a narrow task, pair it with explicit baseline classification: `` `<gate command>` exits 0, or failures match the pre-flight baseline with no new failures in this task's scope ``.
64
+ - Prefer one focused verifier per task. Add a second verifier only when it proves a different artifact class, such as a schema validator plus one targeted unit test.
65
+
55
66
  ## Quality gates
56
67
 
57
68
  - A failing `Done when` check means the task is NOT done. No rationalization.
58
69
  - "Pre-existing" requires a before-baseline. Without one, any failure could be a regression.
59
70
  - First task in a chain that needs clean gates must be a pre-flight baseline that records gate output.
60
71
  - Explicitly distinguish known-broken validators (document and continue) from required-clean validators (hard stop). If only one is named, the loop generalizes permissively.
72
+ - If a pre-flight baseline records a failing gate, later tasks MUST NOT require only a strict clean result for that same gate unless the task is intentionally responsible for fixing that baseline failure. Use one of these explicit forms:
73
+ - Baseline classification: `` `<gate command>` exits 0, or failures match the pre-flight baseline with no new failures in this task's scope ``
74
+ - Authorized cleanup: `` `<gate command>` exits 0 after fixing the named baseline failures in `<path/one.ts>` and `<path/two.ts>` ``
75
+ - Hard blocker: `` `<gate command>` exits 0; baseline failures are not allowed for this task ``
76
+ - 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.
77
+ - 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.
78
+ - 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.
79
+ - 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.
80
+ - 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.
61
81
 
62
82
  Pre-flight template:
63
83
  ```markdown
64
84
  - [ ] **Pre-flight: record quality gate baselines**
65
- - Scope: no code edits
85
+ - Scope: no code edits; writes only under `.ralph/baselines/`
66
86
  - Change: Capture current state of all gates later tasks require.
67
87
  - Done when:
68
- - `.ralph/baselines/<change>-<gate>.txt` exists for each gate with full output
69
- - `.ralph/baselines/<change>-readme.md` lists passing/failing gates and exact failing identifiers
70
- - Stop and hand off if: any gate is nondeterministic across two runs.
88
+ - `.ralph/baselines/<gate>.txt` or `.ralph/baselines/<change>-<gate>.txt` exists for each gate with full output
89
+ - every captured gate file ends with a literal `EXIT=<integer>` line
90
+ - `.ralph/baselines/<change>-readme.md` lists passing/failing gates, exit codes, and exact failing identifiers
91
+ - 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.
92
+ ```
93
+
94
+ Baseline artifact compatibility repair template:
95
+ ```markdown
96
+ - [ ] **Repair pre-flight baseline artifact compatibility**
97
+ - Scope: `.ralph/baselines/`, `tasks.md`
98
+ - Change: Restore or regenerate baseline artifacts so `ralph-run` can classify later quality-gate failures.
99
+ - Done when:
100
+ - change-local `.ralph/baselines/<gate>.txt` files exist for every gate referenced by later baseline-classified tasks
101
+ - every restored gate file ends with a literal `EXIT=<integer>` line
102
+ - the baseline readme records the source of any restored artifact and the exit code for each gate
103
+ - Stop and hand off if:
104
+ - the original gate output is missing, the original exit code cannot be recovered, or restoring the artifact would require rerunning a nondeterministic gate.
71
105
  ```
72
106
 
73
107
  ## Anti-patterns (do not do these)
@@ -80,6 +114,8 @@ Pre-flight template:
80
114
  - `Done when` that only checks unit tests when real behavior is end-to-end
81
115
  - Visual verification without splitting from code changes (context overflow risk)
82
116
  - "Maybe this, maybe that" wording in tasks or specs once loop starts
117
+ - Repo-wide or slow validators for a narrow task when a focused verifier exists (`npm test`, `make all`, full browser/e2e suites)
118
+ - Ambiguous package-manager forwarding such as `npm test -- event-schema` unless confirmed to execute only the intended test scope
83
119
 
84
120
  ## Examples
85
121
 
@@ -119,7 +155,7 @@ Pre-flight template:
119
155
  - Change: Harbor components registered once at boot, typed for TSX.
120
156
  - Done when:
121
157
  - `rg "registerHarbor" src` returns exactly one call site
122
- - `npm test -- harbor-bootstrap` passes
158
+ - `npm exec vitest --run src/components/harbor-bootstrap.test.tsx` exits 0
123
159
  - Stop and hand off if: more than one registration site is required.
124
160
  ```
125
161
 
@@ -136,7 +172,7 @@ Pre-flight template:
136
172
  - Change: ReleaseCard renders timestamps through the shared helper.
137
173
  - Done when:
138
174
  - `rg "toLocaleDateString" src/components/ReleaseCard.tsx` returns no matches
139
- - `npm test -- ReleaseCard` passes
175
+ - `npm exec vitest --run src/components/ReleaseCard.test.tsx` exits 0
140
176
  - Stop and hand off if: `formatDate` does not cover a required locale.
141
177
  ```
142
178
 
@@ -488,6 +488,9 @@ async function run(opts) {
488
488
  resumeIteration > 1 && existingState && existingState.startedAt
489
489
  ? existingState.startedAt
490
490
  : nowIso;
491
+ let pendingDirtyPaths = _normalizePendingDirtyPaths(
492
+ existingState && existingState.pendingDirtyPaths
493
+ );
491
494
 
492
495
  state.init(ralphDir, {
493
496
  active: true,
@@ -508,6 +511,7 @@ async function run(opts) {
508
511
  completedAt: null,
509
512
  stoppedAt: null,
510
513
  exitReason: null,
514
+ pendingDirtyPaths,
511
515
  });
512
516
  stateInitialized = true;
513
517
 
@@ -532,6 +536,20 @@ async function run(opts) {
532
536
  : [];
533
537
  const currentTask = _getCurrentTaskDescription(tasksBefore);
534
538
  const currentTaskMeta = _getCurrentTaskMeta(tasksBefore);
539
+ pendingDirtyPaths = _refreshPendingDirtyPaths(pendingDirtyPaths);
540
+ state.update(ralphDir, { pendingDirtyPaths });
541
+
542
+ if (
543
+ pendingDirtyPaths &&
544
+ !_samePendingTask(pendingDirtyPaths, currentTaskMeta, currentTask)
545
+ ) {
546
+ reporter.note(
547
+ _formatPendingDirtyPathsBlock(pendingDirtyPaths, currentTaskMeta, currentTask),
548
+ 'error'
549
+ );
550
+ exitReason = 'pending_dirty_paths';
551
+ break;
552
+ }
535
553
 
536
554
  reporter.iterationStarted({
537
555
  iteration: iterationCount,
@@ -542,6 +560,7 @@ async function run(opts) {
542
560
  let result;
543
561
  let promptSize = null;
544
562
  let responseSize = { bytes: 0, chars: 0, tokens: 0 };
563
+ let baselineGateConflict = null;
545
564
 
546
565
  try {
547
566
  // Build the prompt for this iteration
@@ -558,6 +577,7 @@ async function run(opts) {
558
577
  // iteration N" line, so the 3-entry window is sufficient to surface
559
578
  // recurring patterns without bloating the prompt.
560
579
  const recentHistory = history.recent(ralphDir, 3);
580
+ const fullHistory = history.read(ralphDir);
561
581
  const errorEntries = errors.readEntries(ralphDir, 3);
562
582
  const blockerArtifacts = _detectBlockerArtifacts(ralphDir, {
563
583
  repoRoot: process.cwd(),
@@ -570,6 +590,13 @@ async function run(opts) {
570
590
  errorEntries,
571
591
  blockerArtifacts,
572
592
  );
593
+ baselineGateConflict = _analyzeBaselineGateConflict(
594
+ ralphDir,
595
+ options.tasksFile,
596
+ currentTaskMeta,
597
+ fullHistory,
598
+ );
599
+ const baselineGateFeedback = _formatBaselineGateFeedback(baselineGateConflict);
573
600
 
574
601
  // Inject any pending context
575
602
  const pendingContext = context.consume(ralphDir);
@@ -577,6 +604,10 @@ async function run(opts) {
577
604
  const lessonsSection = lessons.inject(ralphDir, { limit: 15 });
578
605
  const promptSections = [renderedPrompt];
579
606
 
607
+ if (baselineGateFeedback) {
608
+ promptSections.push(`## Baseline Gate Conflict\n\n${baselineGateFeedback}`);
609
+ }
610
+
580
611
  if (iterationFeedback) {
581
612
  promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
582
613
  }
@@ -701,13 +732,42 @@ async function run(opts) {
701
732
  result.filesChanged.length > 0 &&
702
733
  (hasCompletion || (options.tasksMode && hasTask))
703
734
  ) {
735
+ const filesToStage = _buildAutoCommitAllowlist(
736
+ _mergePathLists(result.filesChanged, pendingDirtyPaths ? pendingDirtyPaths.files : []),
737
+ completedTasks,
738
+ options.tasksFile
739
+ );
704
740
  commitResult = _autoCommit(iterationCount, {
705
741
  completedTasks,
706
- filesToStage: _buildAutoCommitAllowlist(result.filesChanged, completedTasks, options.tasksFile),
742
+ filesToStage,
707
743
  tasksFile: options.tasksFile,
708
744
  verbose: options.verbose,
709
745
  reporter,
710
746
  });
747
+ if (commitResult.committed && pendingDirtyPaths) {
748
+ pendingDirtyPaths = _remainingPendingDirtyPathsAfterCommit(
749
+ pendingDirtyPaths,
750
+ commitResult.anomaly
751
+ );
752
+ state.update(ralphDir, { pendingDirtyPaths });
753
+ }
754
+ }
755
+
756
+ if (
757
+ !commitResult.committed &&
758
+ Array.isArray(result.filesChanged) &&
759
+ result.filesChanged.length > 0 &&
760
+ (_isFailedIteration(result) || hasBlockedHandoff)
761
+ ) {
762
+ pendingDirtyPaths = _recordPendingDirtyPaths(pendingDirtyPaths, {
763
+ iteration: iterationCount,
764
+ reason: hasBlockedHandoff ? 'blocked_handoff' : 'failed_iteration',
765
+ task: currentTask,
766
+ taskNumber: currentTaskMeta.number,
767
+ taskDescription: currentTaskMeta.description,
768
+ files: result.filesChanged,
769
+ });
770
+ state.update(ralphDir, { pendingDirtyPaths });
711
771
  }
712
772
 
713
773
  // Record iteration in history after commit handling so operator-visible
@@ -732,6 +792,16 @@ async function run(opts) {
732
792
  commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
733
793
  commitAnomalyType: commitResult.anomaly ? commitResult.anomaly.type : '',
734
794
  protectedArtifacts: commitResult.anomaly ? commitResult.anomaly.protectedArtifacts || [] : [],
795
+ ...(baselineGateConflict
796
+ ? {
797
+ baselineGateConflictMode: baselineGateConflict.mode,
798
+ baselineGateRepairAllowedFiles: baselineGateConflict.allowedFiles || [],
799
+ baselineGateRepairAttempted: _baselineGateRepairAttempted(
800
+ baselineGateConflict,
801
+ result.filesChanged || []
802
+ ),
803
+ }
804
+ : {}),
735
805
  ...(commitResult.anomaly && commitResult.anomaly.ignoredPaths && commitResult.anomaly.ignoredPaths.length > 0
736
806
  ? { ignoredPaths: commitResult.anomaly.ignoredPaths }
737
807
  : {}),
@@ -915,6 +985,145 @@ function _containsPromise(text, promiseName) {
915
985
  .some((line) => line.trim() === expectedTag);
916
986
  }
917
987
 
988
+ function _normalizePendingDirtyPaths(pending) {
989
+ if (!pending || typeof pending !== 'object') return null;
990
+ const files = _mergePathLists(pending.files || pending.paths || []);
991
+ if (files.length === 0) return null;
992
+
993
+ return {
994
+ iteration: typeof pending.iteration === 'number' ? pending.iteration : null,
995
+ reason: pending.reason || 'blocked_handoff',
996
+ task: pending.task || '',
997
+ taskNumber: pending.taskNumber || '',
998
+ taskDescription: pending.taskDescription || '',
999
+ files,
1000
+ recordedAt: pending.recordedAt || new Date().toISOString(),
1001
+ };
1002
+ }
1003
+
1004
+ function _recordPendingDirtyPaths(existing, update) {
1005
+ const normalized = _normalizePendingDirtyPaths({
1006
+ iteration: update && typeof update.iteration === 'number' ? update.iteration : null,
1007
+ reason: update && update.reason ? update.reason : 'blocked_handoff',
1008
+ task: update && update.task ? update.task : '',
1009
+ taskNumber: update && update.taskNumber ? update.taskNumber : '',
1010
+ taskDescription: update && update.taskDescription ? update.taskDescription : '',
1011
+ files: _mergePathLists(
1012
+ existing && existing.files ? existing.files : [],
1013
+ update && update.files ? update.files : []
1014
+ ),
1015
+ recordedAt: update && update.recordedAt ? update.recordedAt : new Date().toISOString(),
1016
+ });
1017
+
1018
+ return normalized;
1019
+ }
1020
+
1021
+ function _remainingPendingDirtyPathsAfterCommit(pending, anomaly) {
1022
+ const normalized = _normalizePendingDirtyPaths(pending);
1023
+ if (!normalized) return null;
1024
+
1025
+ const ignoredPaths = anomaly && Array.isArray(anomaly.ignoredPaths)
1026
+ ? anomaly.ignoredPaths.map(_repoRelativePath).filter(Boolean)
1027
+ : [];
1028
+ if (ignoredPaths.length === 0) return null;
1029
+
1030
+ const ignoredSet = new Set(ignoredPaths);
1031
+ const files = normalized.files.filter((file) => ignoredSet.has(file));
1032
+ if (files.length === 0) return null;
1033
+ return Object.assign({}, normalized, { files });
1034
+ }
1035
+
1036
+ function _refreshPendingDirtyPaths(pending) {
1037
+ const normalized = _normalizePendingDirtyPaths(pending);
1038
+ if (!normalized) return null;
1039
+
1040
+ const dirtyPaths = _currentDirtyPathSet();
1041
+ if (!dirtyPaths) return normalized;
1042
+ const files = normalized.files.filter((file) => dirtyPaths.has(file));
1043
+ if (files.length === 0) return null;
1044
+
1045
+ return Object.assign({}, normalized, { files });
1046
+ }
1047
+
1048
+ function _samePendingTask(pending, currentTaskMeta, currentTask) {
1049
+ if (!pending) return true;
1050
+ const currentNumber = currentTaskMeta && currentTaskMeta.number ? currentTaskMeta.number : '';
1051
+ const currentDescription = currentTaskMeta && currentTaskMeta.description ? currentTaskMeta.description : '';
1052
+ const currentFull = currentTask || '';
1053
+
1054
+ if (pending.taskNumber && currentNumber) {
1055
+ return pending.taskNumber === currentNumber;
1056
+ }
1057
+
1058
+ if (pending.taskDescription && currentDescription) {
1059
+ return pending.taskDescription === currentDescription;
1060
+ }
1061
+
1062
+ return Boolean(pending.task && currentFull && pending.task === currentFull);
1063
+ }
1064
+
1065
+ function _formatPendingDirtyPathsBlock(pending, currentTaskMeta, currentTask) {
1066
+ const currentStamp = currentTaskMeta && currentTaskMeta.number
1067
+ ? `${currentTaskMeta.number} ${currentTaskMeta.description || ''}`.trim()
1068
+ : (currentTask || 'the current task');
1069
+ const pendingStamp = pending.taskNumber
1070
+ ? `${pending.taskNumber} ${pending.taskDescription || ''}`.trim()
1071
+ : (pending.task || 'a prior blocked handoff');
1072
+ const files = (pending.files || []).slice(0, 8);
1073
+ const extra = (pending.files || []).length - files.length;
1074
+ const fileLines = files.map((file) => ` - ${file}`).join('\n');
1075
+ const suffix = extra > 0 ? `\n - (+${extra} more)` : '';
1076
+
1077
+ return [
1078
+ `pending dirty paths from ${pending.reason || 'blocked_handoff'} iteration ${pending.iteration || 'unknown'} remain unresolved.`,
1079
+ `Prior task: ${pendingStamp}`,
1080
+ `Current task: ${currentStamp}`,
1081
+ 'Resolve the prior patch before Ralph can safely continue: commit it with the same task, revert it, or move it to a separate change.',
1082
+ 'Pending paths:',
1083
+ `${fileLines}${suffix}`,
1084
+ ].join('\n');
1085
+ }
1086
+
1087
+ function _currentDirtyPathSet() {
1088
+ try {
1089
+ const output = childProcess.execFileSync('git', ['status', '--porcelain'], {
1090
+ encoding: 'utf8',
1091
+ stdio: ['pipe', 'pipe', 'pipe'],
1092
+ });
1093
+ const paths = new Set();
1094
+ for (const line of output.split('\n')) {
1095
+ for (const file of _parseGitStatusPaths(line)) {
1096
+ if (file) paths.add(file);
1097
+ }
1098
+ }
1099
+ return paths;
1100
+ } catch (_) {
1101
+ return null;
1102
+ }
1103
+ }
1104
+
1105
+ function _parseGitStatusPaths(line) {
1106
+ if (!line || typeof line !== 'string') return [];
1107
+ const rawPath = line.slice(3).trim();
1108
+ if (!rawPath) return [];
1109
+ if (rawPath.includes(' -> ')) {
1110
+ return rawPath.split(' -> ').map(_stripGitStatusQuotes).filter(Boolean);
1111
+ }
1112
+ return [_stripGitStatusQuotes(rawPath)].filter(Boolean);
1113
+ }
1114
+
1115
+ function _stripGitStatusQuotes(value) {
1116
+ if (!value) return '';
1117
+ const trimmed = value.trim();
1118
+ if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
1119
+ return trimmed;
1120
+ }
1121
+ return trimmed
1122
+ .slice(1, -1)
1123
+ .replace(/\\"/g, '"')
1124
+ .replace(/\\\\/g, '\\');
1125
+ }
1126
+
918
1127
  /**
919
1128
  * Validate required options and throw descriptive errors.
920
1129
  *
@@ -1163,6 +1372,19 @@ function _filterGitignored(paths, cwd) {
1163
1372
  }
1164
1373
  }
1165
1374
 
1375
+ function _mergePathLists(...lists) {
1376
+ const merged = new Set();
1377
+ for (const list of lists) {
1378
+ for (const file of list || []) {
1379
+ const relativeFile = _repoRelativePath(file);
1380
+ if (relativeFile) {
1381
+ merged.add(relativeFile);
1382
+ }
1383
+ }
1384
+ }
1385
+ return Array.from(merged);
1386
+ }
1387
+
1166
1388
  /**
1167
1389
  * Build the explicit per-iteration git staging allowlist.
1168
1390
  *
@@ -1558,6 +1780,394 @@ function _buildIterationFeedback(recentHistory, errorEntries, blockerArtifacts)
1558
1780
  return sections.join('\n');
1559
1781
  }
1560
1782
 
1783
+ function _buildBaselineGateFeedback(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
1784
+ return _formatBaselineGateFeedback(
1785
+ _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory)
1786
+ );
1787
+ }
1788
+
1789
+ function _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
1790
+ if (!ralphDir || !tasksFile || !currentTaskMeta || !currentTaskMeta.description) {
1791
+ return null;
1792
+ }
1793
+
1794
+ const taskBlock = _extractCurrentTaskBlock(tasksFile, currentTaskMeta);
1795
+ if (!taskBlock) return null;
1796
+
1797
+ const strictGates = _detectStrictCleanGates(taskBlock);
1798
+ if (strictGates.length === 0) return null;
1799
+
1800
+ const recordedBaselines = _detectRecordedBaselineGates(ralphDir);
1801
+ const missingBaselines = _detectMissingBaselineGates(
1802
+ strictGates,
1803
+ recordedBaselines,
1804
+ taskBlock,
1805
+ tasksFile
1806
+ );
1807
+
1808
+ if (missingBaselines.length > 0) {
1809
+ return {
1810
+ mode: 'missing_baseline',
1811
+ conflicts: [],
1812
+ missingBaselines,
1813
+ allowedFiles: [],
1814
+ budgetUsed: false,
1815
+ };
1816
+ }
1817
+
1818
+ const failingBaselines = recordedBaselines.filter((gate) => gate.exitCode !== 0);
1819
+ if (failingBaselines.length === 0) return null;
1820
+
1821
+ const baselineByGate = new Map(failingBaselines.map((gate) => [gate.name, gate]));
1822
+ const conflicts = strictGates
1823
+ .map((gate) => ({ gate, baseline: baselineByGate.get(gate.name) }))
1824
+ .filter((item) => item.baseline);
1825
+
1826
+ if (conflicts.length === 0) return null;
1827
+
1828
+ const cleanup = _detectAuthorizedBaselineCleanup(taskBlock);
1829
+ if (cleanup.allowedFiles.length > 0) {
1830
+ return {
1831
+ mode: 'authorized_cleanup',
1832
+ conflicts,
1833
+ allowedFiles: cleanup.allowedFiles,
1834
+ budgetUsed: _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, cleanup.allowedFiles),
1835
+ };
1836
+ }
1837
+
1838
+ if (_taskExplicitlyHandlesBaselineFailures(taskBlock)) {
1839
+ return {
1840
+ mode: 'baseline_classification',
1841
+ conflicts,
1842
+ allowedFiles: [],
1843
+ budgetUsed: false,
1844
+ };
1845
+ }
1846
+
1847
+ return {
1848
+ mode: 'missing_policy',
1849
+ conflicts,
1850
+ allowedFiles: [],
1851
+ budgetUsed: false,
1852
+ };
1853
+ }
1854
+
1855
+ function _formatBaselineGateFeedback(conflict) {
1856
+ const conflicts = Array.isArray(conflict && conflict.conflicts) ? conflict.conflicts : [];
1857
+ const missingBaselines = Array.isArray(conflict && conflict.missingBaselines)
1858
+ ? conflict.missingBaselines
1859
+ : [];
1860
+
1861
+ if (!conflict || (conflicts.length === 0 && missingBaselines.length === 0)) {
1862
+ return '';
1863
+ }
1864
+
1865
+ const conflictLines = conflicts.map(({ gate, baseline }) =>
1866
+ `- ${gate.command}: baseline ${baseline.file} exits ${baseline.exitCode}.`
1867
+ );
1868
+ const missingLines = missingBaselines.map((gate) =>
1869
+ `- ${gate.command}: no matching baseline artifact found under .ralph/baselines.`
1870
+ );
1871
+
1872
+ if (conflict.mode === 'missing_baseline') {
1873
+ return [
1874
+ 'The current task uses a strict clean quality gate and the task plan indicates a pre-flight baseline should exist, but the matching baseline artifact is missing.',
1875
+ 'Do not classify failures as pre-existing or spend an implementation iteration trying to satisfy an impossible task contract.',
1876
+ 'emit BLOCKED_HANDOFF and ask the operator to rerun or restore the pre-flight baseline artifact, or update the task spec to authorize a different gate policy.',
1877
+ '',
1878
+ ...missingLines,
1879
+ ].join('\n');
1880
+ }
1881
+
1882
+ if (conflict.mode === 'authorized_cleanup') {
1883
+ if (conflict.budgetUsed) {
1884
+ return [
1885
+ 'The current task explicitly authorized cleanup for baseline gate failures, but its one repair attempt has already been used.',
1886
+ 'Do not keep iterating on cleanup or broaden the edit scope.',
1887
+ 'If the gate is still failing, emit BLOCKED_HANDOFF with the remaining failing identifiers and ask for either a broader cleanup task or a task-spec change.',
1888
+ '',
1889
+ `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
1890
+ ...conflictLines,
1891
+ ].join('\n');
1892
+ }
1893
+
1894
+ return [
1895
+ 'The current task explicitly authorizes cleanup for baseline gate failures in named files.',
1896
+ 'You have exactly one repair attempt for this task. Limit edits to compiler/lint-only fixes in the authorized files; do not change behavior or edit other files for this cleanup.',
1897
+ 'If this attempt does not clear the gate, emit BLOCKED_HANDOFF instead of continuing to retry.',
1898
+ '',
1899
+ `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
1900
+ ...conflictLines,
1901
+ ].join('\n');
1902
+ }
1903
+
1904
+ if (conflict.mode === 'baseline_classification') {
1905
+ return [
1906
+ 'The current task has strict quality-gate checks, and matching pre-flight baselines are already failing.',
1907
+ 'The task text appears to authorize baseline classification, so do not repair unrelated baseline failures unless the task explicitly names those files.',
1908
+ 'Complete the task only if the current run has no new failures beyond the named baseline failures.',
1909
+ '',
1910
+ ...conflictLines,
1911
+ ].join('\n');
1912
+ }
1913
+
1914
+ return [
1915
+ 'The current task requires a clean gate that already has a failing pre-flight baseline, but the task text does not say whether baseline-matching failures may be classified.',
1916
+ 'Do not spend iterations repairing unrelated files outside the current task scope.',
1917
+ 'If the only remaining gate failures match the baseline, emit BLOCKED_HANDOFF with a task-spec correction request: either allow baseline classification for this gate, or explicitly authorize the named out-of-scope repair.',
1918
+ '',
1919
+ ...conflictLines,
1920
+ ].join('\n');
1921
+ }
1922
+
1923
+ function _extractCurrentTaskBlock(tasksFile, currentTaskMeta) {
1924
+ const fs = require('fs');
1925
+ if (!tasksFile || !fs.existsSync(tasksFile)) return '';
1926
+
1927
+ const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
1928
+ const taskHeader = /^-\s+\[[ x/]\]\s+(.+)$/;
1929
+ const targetNumber = currentTaskMeta.number || '';
1930
+ const targetDescription = (currentTaskMeta.description || '').trim();
1931
+ let start = -1;
1932
+
1933
+ for (let i = 0; i < lines.length; i++) {
1934
+ const match = lines[i].match(taskHeader);
1935
+ if (!match) continue;
1936
+
1937
+ const fullDescription = match[1].trim();
1938
+ const numMatch = fullDescription.match(/^(\d+\.\d+)\s+(.+)$/);
1939
+ const number = numMatch ? numMatch[1] : '';
1940
+ const description = (numMatch ? numMatch[2] : fullDescription).trim();
1941
+
1942
+ if (
1943
+ (targetNumber && number === targetNumber) ||
1944
+ (!targetNumber && description === targetDescription) ||
1945
+ (targetNumber && description === targetDescription)
1946
+ ) {
1947
+ start = i;
1948
+ break;
1949
+ }
1950
+ }
1951
+
1952
+ if (start === -1) return '';
1953
+
1954
+ let end = lines.length;
1955
+ for (let i = start + 1; i < lines.length; i++) {
1956
+ if (taskHeader.test(lines[i])) {
1957
+ end = i;
1958
+ break;
1959
+ }
1960
+ }
1961
+
1962
+ return lines.slice(start, end).join('\n');
1963
+ }
1964
+
1965
+ function _detectStrictCleanGates(taskBlock) {
1966
+ if (!taskBlock) return [];
1967
+
1968
+ const gates = [
1969
+ {
1970
+ name: 'typecheck',
1971
+ command: 'pnpm typecheck',
1972
+ pattern: /`?pnpm\s+typecheck`?[^\n]*(?:exits?|returns?)\s+0/i,
1973
+ },
1974
+ {
1975
+ name: 'lint',
1976
+ command: 'pnpm lint',
1977
+ pattern: /`?pnpm\s+lint`?[^\n]*(?:exits?|returns?)\s+0/i,
1978
+ },
1979
+ {
1980
+ name: 'test',
1981
+ command: 'pnpm test',
1982
+ pattern: /`?pnpm\s+test`?[^\n]*(?:exits?|returns?)\s+0/i,
1983
+ },
1984
+ ];
1985
+
1986
+ return gates.filter((gate) => gate.pattern.test(taskBlock));
1987
+ }
1988
+
1989
+ function _detectFailingBaselineGates(ralphDir) {
1990
+ return _detectRecordedBaselineGates(ralphDir).filter((gate) => gate.exitCode !== 0);
1991
+ }
1992
+
1993
+ function _detectRecordedBaselineGates(ralphDir) {
1994
+ const fs = require('fs');
1995
+ const fsPath = require('path');
1996
+ const baselinesDir = fsPath.join(ralphDir, 'baselines');
1997
+ if (!fs.existsSync(baselinesDir) || !fs.statSync(baselinesDir).isDirectory()) {
1998
+ return [];
1999
+ }
2000
+
2001
+ const gates = [];
2002
+ for (const name of fs.readdirSync(baselinesDir)) {
2003
+ if (!/\.txt$/i.test(name)) continue;
2004
+
2005
+ const gateName = _gateNameFromBaselineFile(name);
2006
+ if (!gateName) continue;
2007
+
2008
+ const file = fsPath.join(baselinesDir, name);
2009
+ const tail = _readFileTail(file, 16384);
2010
+ const exitMatch = tail.match(/(?:^|\n)EXIT=(\d+)(?:\n|$)/);
2011
+ if (!exitMatch) continue;
2012
+
2013
+ const exitCode = Number(exitMatch[1]);
2014
+ if (!Number.isInteger(exitCode)) continue;
2015
+
2016
+ gates.push({ name: gateName, file: fsPath.join('baselines', name), exitCode });
2017
+ }
2018
+
2019
+ const priority = { typecheck: 1, lint: 2, test: 3 };
2020
+ return gates.sort((a, b) =>
2021
+ (priority[a.name] || 99) - (priority[b.name] || 99) ||
2022
+ a.file.localeCompare(b.file)
2023
+ );
2024
+ }
2025
+
2026
+ function _detectMissingBaselineGates(strictGates, recordedBaselines, taskBlock, tasksFile) {
2027
+ if (!Array.isArray(strictGates) || strictGates.length === 0) return [];
2028
+
2029
+ const expectsBaseline =
2030
+ _taskExplicitlyHandlesBaselineFailures(taskBlock) ||
2031
+ _completedPreflightBaselineExists(tasksFile);
2032
+
2033
+ if (!expectsBaseline) return [];
2034
+
2035
+ const recordedNames = new Set((recordedBaselines || []).map((gate) => gate.name));
2036
+ return strictGates.filter((gate) => !recordedNames.has(gate.name));
2037
+ }
2038
+
2039
+ function _completedPreflightBaselineExists(tasksFile) {
2040
+ const fs = require('fs');
2041
+ if (!tasksFile || !fs.existsSync(tasksFile)) return false;
2042
+
2043
+ const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
2044
+ return lines.some((line) =>
2045
+ /^-\s+\[x\]\s+.*\bpre-?flight\b.*\bbaselines?\b/i.test(line)
2046
+ );
2047
+ }
2048
+
2049
+ function _gateNameFromBaselineFile(fileName) {
2050
+ const normalized = fileName.toLowerCase();
2051
+ if (/(^|[-_.])typecheck([-_.]|\.|$)/.test(normalized)) return 'typecheck';
2052
+ if (/(^|[-_.])lint([-_.]|\.|$)/.test(normalized)) return 'lint';
2053
+ if (/(^|[-_.])test([-_.]|\.|$)/.test(normalized)) return 'test';
2054
+ return '';
2055
+ }
2056
+
2057
+ function _readFileTail(file, maxBytes) {
2058
+ const fs = require('fs');
2059
+ let fd = null;
2060
+ try {
2061
+ const stat = fs.statSync(file);
2062
+ const length = Math.min(stat.size, maxBytes);
2063
+ const offset = Math.max(0, stat.size - length);
2064
+ const buffer = Buffer.alloc(length);
2065
+ fd = fs.openSync(file, 'r');
2066
+ fs.readSync(fd, buffer, 0, length, offset);
2067
+ return buffer.toString('utf8');
2068
+ } catch {
2069
+ return '';
2070
+ } finally {
2071
+ if (fd !== null) {
2072
+ try {
2073
+ fs.closeSync(fd);
2074
+ } catch {
2075
+ // Ignore close failures while building best-effort feedback.
2076
+ }
2077
+ }
2078
+ }
2079
+ }
2080
+
2081
+ function _taskExplicitlyHandlesBaselineFailures(taskBlock) {
2082
+ return /\bbaseline\b/i.test(taskBlock) &&
2083
+ /\b(match|matches|matching|classif(?:y|ied|ication)|pre-existing|preexisting|no new failures?)\b/i.test(taskBlock);
2084
+ }
2085
+
2086
+ function _detectAuthorizedBaselineCleanup(taskBlock) {
2087
+ if (!taskBlock || !/\b(authori[sz]ed cleanup|after fixing|fixing the named baseline failures?)\b/i.test(taskBlock)) {
2088
+ return { allowedFiles: [] };
2089
+ }
2090
+
2091
+ const allowedFiles = [];
2092
+ const seen = new Set();
2093
+ const backtickPattern = /`([^`]+)`/g;
2094
+ let match;
2095
+
2096
+ while ((match = backtickPattern.exec(taskBlock)) !== null) {
2097
+ const candidate = match[1].trim();
2098
+ if (!_looksLikeCleanupPath(candidate)) continue;
2099
+
2100
+ const normalized = candidate.replace(/\\/g, '/');
2101
+ if (seen.has(normalized)) continue;
2102
+
2103
+ seen.add(normalized);
2104
+ allowedFiles.push(normalized);
2105
+ }
2106
+
2107
+ return { allowedFiles };
2108
+ }
2109
+
2110
+ function _looksLikeCleanupPath(value) {
2111
+ if (!value || /\s/.test(value)) return false;
2112
+ if (/^(pnpm|npm|yarn|node|gtimeout|timeout|rg|git)(\s|$)/i.test(value)) return false;
2113
+ if (/^--?/.test(value)) return false;
2114
+ if (/[*{}]/.test(value)) return false;
2115
+ return value.includes('/') || /\.[A-Za-z0-9]+$/.test(value);
2116
+ }
2117
+
2118
+ function _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, allowedFiles) {
2119
+ if (!Array.isArray(recentHistory) || recentHistory.length === 0) return false;
2120
+
2121
+ return recentHistory.some((entry) => {
2122
+ if (!_historyEntryMatchesTask(entry, currentTaskMeta)) return false;
2123
+ if (entry.baselineGateRepairAttempted === true) return true;
2124
+
2125
+ return _baselineGateRepairAttempted(
2126
+ { mode: 'authorized_cleanup', allowedFiles },
2127
+ entry.filesChanged || []
2128
+ );
2129
+ });
2130
+ }
2131
+
2132
+ function _baselineGateRepairAttempted(conflict, filesChanged) {
2133
+ if (
2134
+ !conflict ||
2135
+ conflict.mode !== 'authorized_cleanup' ||
2136
+ !Array.isArray(conflict.allowedFiles) ||
2137
+ conflict.allowedFiles.length === 0 ||
2138
+ !Array.isArray(filesChanged) ||
2139
+ filesChanged.length === 0
2140
+ ) {
2141
+ return false;
2142
+ }
2143
+
2144
+ return _pathsIntersect(conflict.allowedFiles, filesChanged);
2145
+ }
2146
+
2147
+ function _historyEntryMatchesTask(entry, currentTaskMeta) {
2148
+ if (!entry || !currentTaskMeta) return false;
2149
+
2150
+ const currentNumber = currentTaskMeta.number || '';
2151
+ const currentDescription = currentTaskMeta.description || '';
2152
+
2153
+ if (currentNumber && entry.taskNumber === currentNumber) return true;
2154
+ if (!currentNumber && currentDescription && entry.taskDescription === currentDescription) return true;
2155
+
2156
+ return false;
2157
+ }
2158
+
2159
+ function _pathsIntersect(left, right) {
2160
+ const normalizedLeft = new Set((left || []).map(_normalizeComparablePath));
2161
+ return (right || []).some((pathValue) => normalizedLeft.has(_normalizeComparablePath(pathValue)));
2162
+ }
2163
+
2164
+ function _normalizeComparablePath(pathValue) {
2165
+ return String(pathValue || '')
2166
+ .replace(/\\/g, '/')
2167
+ .replace(/^\.\//, '')
2168
+ .replace(/\/+$/, '');
2169
+ }
2170
+
1561
2171
  function _extractErrorForIteration(errorEntries, iteration) {
1562
2172
  if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
1563
2173
 
@@ -1769,12 +2379,30 @@ module.exports = {
1769
2379
  _validateOptions,
1770
2380
  _autoCommit,
1771
2381
  _buildAutoCommitAllowlist,
2382
+ _mergePathLists,
2383
+ _normalizePendingDirtyPaths,
2384
+ _recordPendingDirtyPaths,
2385
+ _remainingPendingDirtyPathsAfterCommit,
2386
+ _refreshPendingDirtyPaths,
2387
+ _samePendingTask,
2388
+ _currentDirtyPathSet,
1772
2389
  _filterGitignored,
1773
2390
  _resolveStartIteration,
1774
2391
  _completedTaskDelta,
1775
2392
  _formatAutoCommitMessage,
1776
2393
  _truncateSubjectSummary,
1777
2394
  _buildIterationFeedback,
2395
+ _buildBaselineGateFeedback,
2396
+ _analyzeBaselineGateConflict,
2397
+ _formatBaselineGateFeedback,
2398
+ _extractCurrentTaskBlock,
2399
+ _detectStrictCleanGates,
2400
+ _detectFailingBaselineGates,
2401
+ _detectRecordedBaselineGates,
2402
+ _detectMissingBaselineGates,
2403
+ _detectAuthorizedBaselineCleanup,
2404
+ _baselineGateRepairBudgetUsed,
2405
+ _baselineGateRepairAttempted,
1778
2406
  _extractErrorForIteration,
1779
2407
  _getCurrentTaskDescription,
1780
2408
  _getCurrentTaskMeta,
@@ -60,6 +60,31 @@ function render(ralphDir, tasksFile) {
60
60
  lines.push(`Exit reason: ${loopState.exitReason}`);
61
61
  }
62
62
 
63
+ const pendingDirtyPaths = _pendingDirtyPaths(loopState);
64
+ if (pendingDirtyPaths) {
65
+ lines.push('');
66
+ lines.push('--- Pending Dirty Paths ---');
67
+ lines.push(` Reason: ${pendingDirtyPaths.reason || 'blocked_handoff'}`);
68
+ if (pendingDirtyPaths.iteration) {
69
+ lines.push(` From iteration: ${pendingDirtyPaths.iteration}`);
70
+ }
71
+ const task = pendingDirtyPaths.taskNumber
72
+ ? `${pendingDirtyPaths.taskNumber} ${pendingDirtyPaths.taskDescription || ''}`.trim()
73
+ : (pendingDirtyPaths.task || '');
74
+ if (task) {
75
+ lines.push(` Prior task: ${task}`);
76
+ }
77
+ const files = pendingDirtyPaths.files.slice(0, 10);
78
+ for (const file of files) {
79
+ lines.push(` - ${file}`);
80
+ }
81
+ if (pendingDirtyPaths.files.length > files.length) {
82
+ lines.push(` - (+${pendingDirtyPaths.files.length - files.length} more)`);
83
+ }
84
+ lines.push(' Resolve before continuing: commit with the same task, revert, or move to a separate change.');
85
+ lines.push('-'.repeat(50));
86
+ }
87
+
63
88
  const latestCommitAnomaly = _latestCommitAnomaly(history.recent(ralphDir, 20));
64
89
  if (latestCommitAnomaly) {
65
90
  lines.push(`Commit issue: ${latestCommitAnomaly.commitAnomaly}`);
@@ -186,6 +211,16 @@ function _promptSummary(loopState) {
186
211
  return '';
187
212
  }
188
213
 
214
+ function _pendingDirtyPaths(loopState) {
215
+ const pending = loopState && loopState.pendingDirtyPaths;
216
+ if (!pending || typeof pending !== 'object') return null;
217
+ const files = Array.isArray(pending.files)
218
+ ? pending.files.filter((file) => typeof file === 'string' && file.trim())
219
+ : [];
220
+ if (files.length === 0) return null;
221
+ return Object.assign({}, pending, { files });
222
+ }
223
+
189
224
  /**
190
225
  * Try to find a tasks file path from loop state.
191
226
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1182,6 +1182,7 @@ rules:
1182
1182
  tasks:
1183
1183
  - Use the task template from OPENSPEC-RALPH-BP.md
1184
1184
  - Each task has one dominant outcome and one verification cluster
1185
+ - Use surgical, scope-targeted validation commands; reserve broad gates for pre-flight baselines or final integration tasks
1185
1186
  - Include explicit stop-and-hand-off conditions
1186
1187
  design:
1187
1188
  - Do not leave core policy choices unresolved
@@ -1203,6 +1204,7 @@ Before generating any OpenSpec artifacts, you MUST:
1203
1204
  - Read `openspec/OPENSPEC-RALPH-BP.md` (Ralph Wiggum authoring guide)
1204
1205
  - Verify proposals against the Ralph authoring checklist
1205
1206
  - Ensure tasks use the task template with objective done-when conditions
1207
+ - Ensure each task uses the narrowest verifier that proves its scope; use broad gates only with baseline classification or final integration tasks
1206
1208
  - Include explicit stop-and-hand-off conditions in every task
1207
1209
  RALPH_AGENTS
1208
1210
  log_verbose "Updated $agents_file with Ralph Wiggum compliance section"
@@ -1311,7 +1313,7 @@ WARNING_BOX
1311
1313
  fi
1312
1314
  local ralph_guidance=""
1313
1315
  if [[ -f "$bp_file" ]]; then
1314
- ralph_guidance=" When creating artifacts, read ${bp_file} and follow the Ralph Wiggum task template and authoring checklist. Ensure the proposal includes explicit scope, non-goals, first-rollout boundaries, and capabilities that map to Ralph-friendly tasks. Ensure tasks use the task template with objective done-when conditions and explicit stop-and-hand-off conditions. Do NOT restore or copy from any .bak backup files - write fresh artifacts from scratch."
1316
+ ralph_guidance=" When creating artifacts, read ${bp_file} and follow the Ralph Wiggum task template and authoring checklist. Ensure the proposal includes explicit scope, non-goals, first-rollout boundaries, and capabilities that map to Ralph-friendly tasks. Ensure tasks use the task template with objective done-when conditions, surgical scope-targeted verifier commands, and explicit stop-and-hand-off conditions. Prefer direct test-file or validator commands over full-suite commands; reserve broad gates for pre-flight baselines or final integration tasks. Do NOT restore or copy from any .bak backup files - write fresh artifacts from scratch."
1315
1317
  fi
1316
1318
 
1317
1319
  log_info "Invoking opencode to regenerate proposal and tasks with Ralph Wiggum best practices..."