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.
- package/OPENSPEC-RALPH-BP.md +42 -6
- package/lib/mini-ralph/runner.js +629 -1
- package/lib/mini-ralph/status.js +35 -0
- package/package.json +1 -1
- package/scripts/ralph-run.sh +3 -1
package/OPENSPEC-RALPH-BP.md
CHANGED
|
@@ -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
|
-
-
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
|
package/lib/mini-ralph/runner.js
CHANGED
|
@@ -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
|
|
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,
|
package/lib/mini-ralph/status.js
CHANGED
|
@@ -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
package/scripts/ralph-run.sh
CHANGED
|
@@ -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..."
|