spec-and-loop 3.3.2 → 3.3.3

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.
@@ -50,6 +50,10 @@ const DEFAULTS = {
50
50
  // toward the streak because their signal is already surfaced via the
51
51
  // `Recent Loop Signals` feedback block.
52
52
  stallThreshold: 3,
53
+ // Opt-in continuation after a BLOCKED_HANDOFF only when the handoff note has
54
+ // explicit evidence for a safe, bounded resolution class.
55
+ autoResolveHandoffs: true,
56
+ autoResolveHandoffMaxPerRun: 6,
53
57
  };
54
58
 
55
59
  /**
@@ -80,6 +84,170 @@ function _iterationIsStalled(iterationSignals) {
80
84
  return true;
81
85
  }
82
86
 
87
+ function _resolveAutoResolveHandoffConfig(options, existingState) {
88
+ const enabled = options.autoResolveHandoffs === true;
89
+ const maxPerRun =
90
+ Number.isInteger(options.autoResolveHandoffMaxPerRun) &&
91
+ options.autoResolveHandoffMaxPerRun > 0
92
+ ? options.autoResolveHandoffMaxPerRun
93
+ : DEFAULTS.autoResolveHandoffMaxPerRun;
94
+ const previous =
95
+ existingState &&
96
+ existingState.autoResolveHandoffs &&
97
+ typeof existingState.autoResolveHandoffs === 'object'
98
+ ? existingState.autoResolveHandoffs
99
+ : {};
100
+ const previousAttempts =
101
+ previous.attempts && typeof previous.attempts === 'object'
102
+ ? previous.attempts
103
+ : {};
104
+ const previousTotal = Number.isInteger(previous.totalAttempts)
105
+ ? previous.totalAttempts
106
+ : 0;
107
+
108
+ return {
109
+ enabled,
110
+ maxPerRun,
111
+ state: {
112
+ enabled,
113
+ maxPerRun,
114
+ totalAttempts: previousTotal,
115
+ attempts: Object.assign({}, previousAttempts),
116
+ lastDecision: previous.lastDecision || null,
117
+ },
118
+ };
119
+ }
120
+
121
+ function _handoffHasFocusedVerifierEvidence(note) {
122
+ if (!note) return false;
123
+ const text = String(note);
124
+ const mentionsFocusedVerifier =
125
+ /\bfocused\b[\s\S]{0,500}\b(verifier|command|test|spec|vitest)\b/i.test(text) ||
126
+ /\b(verifier|command|test|spec|vitest)\b[\s\S]{0,500}\bfocused\b/i.test(text);
127
+ const saysFocusedPasses =
128
+ /\b(passes?|passed|exits?\s+0|exit(?:ed)?\s+0|green)\b/i.test(text);
129
+ const saysBroadFails =
130
+ /\b(broad|full|required|suite|repo-wide)\b[\s\S]{0,500}\b(fails?|failed|red|non[-\s]?zero)\b/i.test(text) ||
131
+ /\b(fails?|failed|red|non[-\s]?zero)\b[\s\S]{0,500}\b(broad|full|required|suite|repo-wide)\b/i.test(text);
132
+ const saysFailuresAreUnrelated =
133
+ /\b(unrelated|pre[-\s]?existing|out[-\s]?of[-\s]?scope|known failures?|not introduced|baseline)\b/i.test(text);
134
+
135
+ return mentionsFocusedVerifier && saysFocusedPasses && saysBroadFails && saysFailuresAreUnrelated;
136
+ }
137
+
138
+ function _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict) {
139
+ if (_handoffHasFocusedVerifierEvidence(blockerNote)) {
140
+ return {
141
+ className: 'verifier_narrowing',
142
+ summary: 'focused verifier passes while the broad verifier fails on unrelated/pre-existing failures',
143
+ allowedFiles: [],
144
+ };
145
+ }
146
+
147
+ if (
148
+ baselineGateConflict &&
149
+ baselineGateConflict.mode === 'authorized_cleanup' &&
150
+ baselineGateConflict.budgetUsed !== true &&
151
+ Array.isArray(baselineGateConflict.allowedFiles) &&
152
+ baselineGateConflict.allowedFiles.length > 0
153
+ ) {
154
+ return {
155
+ className: 'authorized_cleanup',
156
+ summary: 'task text explicitly authorizes one cleanup attempt for named files',
157
+ allowedFiles: baselineGateConflict.allowedFiles.slice(),
158
+ };
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ function _autoResolveHandoffBudgetKey(currentTaskMeta, className) {
165
+ const taskId =
166
+ currentTaskMeta && currentTaskMeta.number
167
+ ? currentTaskMeta.number
168
+ : currentTaskMeta && currentTaskMeta.description
169
+ ? currentTaskMeta.description
170
+ : 'unknown-task';
171
+ return `${taskId}:${className || 'unknown'}`;
172
+ }
173
+
174
+ function _decideAutoResolveHandoff(config, blockerNote, currentTaskMeta, baselineGateConflict) {
175
+ const disabledDecision = { allowed: false, reason: 'disabled', className: '', budgetKey: '' };
176
+ if (!config || config.enabled !== true) return disabledDecision;
177
+
178
+ const classification = _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict);
179
+ if (!classification) {
180
+ return {
181
+ allowed: false,
182
+ reason: 'ambiguous_or_unsupported_handoff',
183
+ className: '',
184
+ budgetKey: '',
185
+ };
186
+ }
187
+
188
+ const budgetKey = _autoResolveHandoffBudgetKey(currentTaskMeta, classification.className);
189
+ const totalAttempts = Number.isInteger(config.state && config.state.totalAttempts)
190
+ ? config.state.totalAttempts
191
+ : 0;
192
+ const maxPerRun = Number.isInteger(config.maxPerRun)
193
+ ? config.maxPerRun
194
+ : DEFAULTS.autoResolveHandoffMaxPerRun;
195
+ const attempts = config.state && config.state.attempts ? config.state.attempts : {};
196
+
197
+ if (totalAttempts >= maxPerRun) {
198
+ return Object.assign({}, classification, {
199
+ allowed: false,
200
+ reason: 'global_budget_exhausted',
201
+ budgetKey,
202
+ });
203
+ }
204
+
205
+ if (attempts[budgetKey]) {
206
+ return Object.assign({}, classification, {
207
+ allowed: false,
208
+ reason: 'task_class_budget_exhausted',
209
+ budgetKey,
210
+ });
211
+ }
212
+
213
+ return Object.assign({}, classification, {
214
+ allowed: true,
215
+ reason: 'authorized',
216
+ budgetKey,
217
+ });
218
+ }
219
+
220
+ function _consumeAutoResolveHandoffBudget(config, decision, iteration) {
221
+ if (!config || !config.state || !decision || decision.allowed !== true || !decision.budgetKey) {
222
+ return null;
223
+ }
224
+
225
+ const attempts = Object.assign({}, config.state.attempts || {});
226
+ attempts[decision.budgetKey] = {
227
+ className: decision.className,
228
+ iteration,
229
+ attemptedAt: new Date().toISOString(),
230
+ };
231
+
232
+ const totalAttempts = (Number.isInteger(config.state.totalAttempts)
233
+ ? config.state.totalAttempts
234
+ : 0) + 1;
235
+
236
+ config.state = Object.assign({}, config.state, {
237
+ totalAttempts,
238
+ attempts,
239
+ lastDecision: {
240
+ className: decision.className,
241
+ reason: decision.reason,
242
+ budgetKey: decision.budgetKey,
243
+ iteration,
244
+ allowedFiles: decision.allowedFiles || [],
245
+ },
246
+ });
247
+
248
+ return config.state;
249
+ }
250
+
83
251
  function _isFailedIteration(result) {
84
252
  if (!result || typeof result !== 'object') return false;
85
253
  if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
@@ -462,6 +630,7 @@ async function run(opts) {
462
630
  const resumeIteration = _resolveStartIteration(existingState, options);
463
631
  const priorRunWasBlockedHandoff =
464
632
  existingState && existingState.exitReason === 'blocked_handoff';
633
+ const autoResolveHandoffs = _resolveAutoResolveHandoffConfig(options, existingState);
465
634
 
466
635
  if (options.verbose && resumeIteration > 1) {
467
636
  process.stderr.write(
@@ -512,6 +681,7 @@ async function run(opts) {
512
681
  stoppedAt: null,
513
682
  exitReason: null,
514
683
  pendingDirtyPaths,
684
+ autoResolveHandoffs: autoResolveHandoffs.state,
515
685
  });
516
686
  stateInitialized = true;
517
687
 
@@ -597,6 +767,7 @@ async function run(opts) {
597
767
  fullHistory,
598
768
  );
599
769
  const baselineGateFeedback = _formatBaselineGateFeedback(baselineGateConflict);
770
+ const autoResolveHandoffFeedback = _buildAutoResolveHandoffFeedback(recentHistory);
600
771
 
601
772
  // Inject any pending context
602
773
  const pendingContext = context.consume(ralphDir);
@@ -612,6 +783,10 @@ async function run(opts) {
612
783
  promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
613
784
  }
614
785
 
786
+ if (autoResolveHandoffFeedback) {
787
+ promptSections.push(`## Auto-Resolve Handoff\n\n${autoResolveHandoffFeedback}`);
788
+ }
789
+
615
790
  if (lessonsSection) {
616
791
  promptSections.push(lessonsSection);
617
792
  }
@@ -705,6 +880,24 @@ async function run(opts) {
705
880
  const blockerNote = hasBlockedHandoff
706
881
  ? _extractBlockerNote(outputText, blockedHandoffPromise)
707
882
  : '';
883
+ const autoResolveHandoffDecision = hasBlockedHandoff
884
+ ? _decideAutoResolveHandoff(
885
+ autoResolveHandoffs,
886
+ blockerNote,
887
+ currentTaskMeta,
888
+ baselineGateConflict,
889
+ )
890
+ : null;
891
+ if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
892
+ const nextAutoResolveState = _consumeAutoResolveHandoffBudget(
893
+ autoResolveHandoffs,
894
+ autoResolveHandoffDecision,
895
+ iterationCount,
896
+ );
897
+ if (nextAutoResolveState) {
898
+ state.update(ralphDir, { autoResolveHandoffs: nextAutoResolveState });
899
+ }
900
+ }
708
901
  const tasksAfter = options.tasksMode && options.tasksFile
709
902
  ? tasks.parseTasks(options.tasksFile)
710
903
  : [];
@@ -787,6 +980,15 @@ async function run(opts) {
787
980
  signal: result.signal || '',
788
981
  failureStage: result.failureStage || '',
789
982
  completedTasks: completedTasks.map((task) => task.fullDescription || task.description),
983
+ ...(autoResolveHandoffDecision
984
+ ? {
985
+ autoResolveHandoffAttempted: autoResolveHandoffDecision.allowed === true,
986
+ autoResolveHandoffClass: autoResolveHandoffDecision.className || '',
987
+ autoResolveHandoffReason: autoResolveHandoffDecision.reason || '',
988
+ autoResolveHandoffBudgetKey: autoResolveHandoffDecision.budgetKey || '',
989
+ autoResolveHandoffAllowedFiles: autoResolveHandoffDecision.allowedFiles || [],
990
+ }
991
+ : {}),
790
992
  commitAttempted: commitResult.attempted,
791
993
  commitCreated: commitResult.committed,
792
994
  commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
@@ -894,9 +1096,21 @@ async function run(opts) {
894
1096
  reporter.note(
895
1097
  handoffPath
896
1098
  ? `agent emitted ${blockedHandoffPromise}; blocker note saved to ${handoffPath}.`
897
- : `agent emitted ${blockedHandoffPromise}; halting (HANDOFF.md write failed; see stderr).`,
1099
+ : `agent emitted ${blockedHandoffPromise}; HANDOFF.md write failed (see stderr).`,
898
1100
  'warn'
899
1101
  );
1102
+ if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
1103
+ reporter.note(
1104
+ `auto-resolve handoffs: continuing once for ${autoResolveHandoffDecision.className} (${autoResolveHandoffDecision.budgetKey}).`,
1105
+ 'warn'
1106
+ );
1107
+ if (options.verbose) {
1108
+ process.stderr.write(
1109
+ `[mini-ralph] auto-resolve handoff consumed budget key ${autoResolveHandoffDecision.budgetKey}; continuing.\n`
1110
+ );
1111
+ }
1112
+ continue;
1113
+ }
900
1114
  if (options.verbose) {
901
1115
  process.stderr.write(
902
1116
  `[mini-ralph] ${blockedHandoffPromise} detected at iteration ${iterationCount}; halting.\n`
@@ -1780,6 +1994,42 @@ function _buildIterationFeedback(recentHistory, errorEntries, blockerArtifacts)
1780
1994
  return sections.join('\n');
1781
1995
  }
1782
1996
 
1997
+ function _buildAutoResolveHandoffFeedback(recentHistory) {
1998
+ if (!Array.isArray(recentHistory) || recentHistory.length === 0) return '';
1999
+
2000
+ const entry = recentHistory
2001
+ .slice()
2002
+ .reverse()
2003
+ .find((item) => item && item.autoResolveHandoffAttempted === true);
2004
+
2005
+ if (!entry) return '';
2006
+
2007
+ const className = entry.autoResolveHandoffClass || 'unknown';
2008
+ const lines = [
2009
+ `The previous iteration emitted BLOCKED_HANDOFF, but auto-resolution is enabled and spent its bounded attempt for ${className}.`,
2010
+ 'You have exactly one continuation attempt for this task/blocker class. Do not broaden task scope, do not repair unrelated snapshots or UI behavior, and do not keep retrying if the evidence does not hold.',
2011
+ ];
2012
+
2013
+ if (className === 'verifier_narrowing') {
2014
+ lines.push(
2015
+ 'Allowed action: if the handoff explicitly names a focused verifier that passes and a broad verifier that fails only on unrelated/pre-existing failures, update only the current task verifier from the broad command to that focused command, run the focused command once, and complete the task only if it passes. If the focused command is absent, ambiguous, or fails, emit BLOCKED_HANDOFF instead of retrying.'
2016
+ );
2017
+ } else if (className === 'authorized_cleanup') {
2018
+ const files = Array.isArray(entry.autoResolveHandoffAllowedFiles)
2019
+ ? entry.autoResolveHandoffAllowedFiles.filter(Boolean)
2020
+ : [];
2021
+ lines.push(
2022
+ `Allowed action: make one cleanup attempt only in the task-authorized file list${files.length > 0 ? ` (${files.join(', ')})` : ''}. If the gate still fails, emit BLOCKED_HANDOFF instead of continuing.`
2023
+ );
2024
+ } else {
2025
+ lines.push(
2026
+ 'Allowed action: continue only if the blocker evidence remains explicit and within the runner-approved safe class; otherwise emit BLOCKED_HANDOFF.'
2027
+ );
2028
+ }
2029
+
2030
+ return lines.join('\n');
2031
+ }
2032
+
1783
2033
  function _buildBaselineGateFeedback(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
1784
2034
  return _formatBaselineGateFeedback(
1785
2035
  _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory)
@@ -2392,6 +2642,12 @@ module.exports = {
2392
2642
  _formatAutoCommitMessage,
2393
2643
  _truncateSubjectSummary,
2394
2644
  _buildIterationFeedback,
2645
+ _buildAutoResolveHandoffFeedback,
2646
+ _resolveAutoResolveHandoffConfig,
2647
+ _handoffHasFocusedVerifierEvidence,
2648
+ _classifyAutoResolvableHandoff,
2649
+ _decideAutoResolveHandoff,
2650
+ _consumeAutoResolveHandoffBudget,
2395
2651
  _buildBaselineGateFeedback,
2396
2652
  _analyzeBaselineGateConflict,
2397
2653
  _formatBaselineGateFeedback,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "3.3.2",
3
+ "version": "3.3.3",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -27,6 +27,10 @@
27
27
  * Loop exits cleanly with `blocked_handoff` when the
28
28
  * agent emits this tag and writes the agent's note
29
29
  * to <ralph-dir>/HANDOFF.md.
30
+ * --auto-resolve-handoffs Enable bounded continuation attempts for
31
+ * explicit, safe BLOCKED_HANDOFF classes
32
+ * --no-auto-resolve-handoffs
33
+ * Disable auto-resolution even when enabled by env
30
34
  * --no-commit Suppress auto-commit
31
35
  * --model <name> Optional model override
32
36
  * --verbose Verbose output
@@ -44,6 +48,11 @@ const miniRalph = require('../lib/mini-ralph/index');
44
48
  // Argument parsing
45
49
  // ---------------------------------------------------------------------------
46
50
 
51
+ function _envFlagDefaultEnabled(value) {
52
+ if (value === undefined) return true;
53
+ return !/^(0|false|no|off)$/i.test(String(value || '').trim());
54
+ }
55
+
47
56
  function parseArgs(argv) {
48
57
  const args = argv.slice(2);
49
58
  const opts = {
@@ -59,6 +68,7 @@ function parseArgs(argv) {
59
68
  completionPromise: 'COMPLETE',
60
69
  taskPromise: 'READY_FOR_NEXT_TASK',
61
70
  blockedHandoffPromise: 'BLOCKED_HANDOFF',
71
+ autoResolveHandoffs: _envFlagDefaultEnabled(process.env.RALPH_AUTO_RESOLVE_HANDOFFS),
62
72
  noCommit: false,
63
73
  model: '',
64
74
  verbose: false,
@@ -110,6 +120,12 @@ function parseArgs(argv) {
110
120
  case '--blocked-handoff-promise':
111
121
  opts.blockedHandoffPromise = args[++i];
112
122
  break;
123
+ case '--auto-resolve-handoffs':
124
+ opts.autoResolveHandoffs = true;
125
+ break;
126
+ case '--no-auto-resolve-handoffs':
127
+ opts.autoResolveHandoffs = false;
128
+ break;
113
129
  case '--no-commit':
114
130
  opts.noCommit = true;
115
131
  break;
@@ -165,6 +181,8 @@ Options:
165
181
  --task-promise <s> Task promise string
166
182
  --blocked-handoff-promise <s>
167
183
  Blocked-handoff promise string (default: BLOCKED_HANDOFF)
184
+ --auto-resolve-handoffs Enable bounded continuation for explicit safe handoffs
185
+ --no-auto-resolve-handoffs Disable bounded continuation for explicit safe handoffs
168
186
  --no-commit Suppress auto-commit
169
187
  --model <name> Model override
170
188
  --verbose Verbose output
@@ -224,6 +242,7 @@ async function main() {
224
242
  completionPromise: opts.completionPromise,
225
243
  taskPromise: opts.taskPromise,
226
244
  blockedHandoffPromise: opts.blockedHandoffPromise,
245
+ autoResolveHandoffs: opts.autoResolveHandoffs,
227
246
  noCommit: opts.noCommit,
228
247
  model: opts.model,
229
248
  verbose: opts.verbose,
@@ -251,4 +270,11 @@ async function main() {
251
270
  }
252
271
  }
253
272
 
254
- main();
273
+ if (require.main === module) {
274
+ main();
275
+ }
276
+
277
+ module.exports = {
278
+ _envFlagDefaultEnabled,
279
+ _parseArgs: parseArgs,
280
+ };
@@ -129,6 +129,7 @@ resolve_ralph_command() {
129
129
  CHANGE_NAME=""
130
130
  MAX_ITERATIONS=""
131
131
  NO_COMMIT=false
132
+ AUTO_RESOLVE_HANDOFFS=""
132
133
  SHOW_STATUS=false
133
134
  SHOW_VERSION=false
134
135
  ADD_CONTEXT=""
@@ -186,6 +187,9 @@ OPTIONS:
186
187
  --change <name> Specify the OpenSpec change to execute (default: auto-detect)
187
188
  --max-iterations <n> Maximum iterations for Ralph loop (default: 50)
188
189
  --no-commit Suppress automatic git commits during the loop
190
+ --auto-resolve-handoffs Enable bounded continuation for explicit safe handoffs
191
+ --no-auto-resolve-handoffs
192
+ Disable bounded continuation for explicit safe handoffs
189
193
  --verbose, -v Enable verbose mode for debugging
190
194
  --quiet Suppress the per-iteration progress stream
191
195
  --version Print the version and exit
@@ -232,6 +236,14 @@ parse_arguments() {
232
236
  NO_COMMIT=true
233
237
  shift
234
238
  ;;
239
+ --auto-resolve-handoffs)
240
+ AUTO_RESOLVE_HANDOFFS=true
241
+ shift
242
+ ;;
243
+ --no-auto-resolve-handoffs)
244
+ AUTO_RESOLVE_HANDOFFS=false
245
+ shift
246
+ ;;
235
247
  --verbose|-v)
236
248
  VERBOSE=true
237
249
  shift
@@ -1006,6 +1018,12 @@ Do not create git commits yourself. The Ralph runner manages automatic task comm
1006
1018
  mini_ralph_args+=("--no-commit")
1007
1019
  fi
1008
1020
 
1021
+ if [[ "$AUTO_RESOLVE_HANDOFFS" == true ]]; then
1022
+ mini_ralph_args+=("--auto-resolve-handoffs")
1023
+ elif [[ "$AUTO_RESOLVE_HANDOFFS" == false ]]; then
1024
+ mini_ralph_args+=("--no-auto-resolve-handoffs")
1025
+ fi
1026
+
1009
1027
  if [[ "$VERBOSE" == true ]]; then
1010
1028
  mini_ralph_args+=("--verbose")
1011
1029
  fi