spec-and-loop 3.0.3 → 3.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "3.0.3",
3
+ "version": "3.3.0",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -22,6 +22,11 @@
22
22
  * --stall-threshold <n> Halt after N consecutive no-op iterations (default: 3; 0 disables)
23
23
  * --completion-promise <s> Completion promise string (default: COMPLETE)
24
24
  * --task-promise <s> Task promise string (default: READY_FOR_NEXT_TASK)
25
+ * --blocked-handoff-promise <s>
26
+ * Blocked-handoff promise string (default: BLOCKED_HANDOFF).
27
+ * Loop exits cleanly with `blocked_handoff` when the
28
+ * agent emits this tag and writes the agent's note
29
+ * to <ralph-dir>/HANDOFF.md.
25
30
  * --no-commit Suppress auto-commit
26
31
  * --model <name> Optional model override
27
32
  * --verbose Verbose output
@@ -53,6 +58,7 @@ function parseArgs(argv) {
53
58
  stallThreshold: 3,
54
59
  completionPromise: 'COMPLETE',
55
60
  taskPromise: 'READY_FOR_NEXT_TASK',
61
+ blockedHandoffPromise: 'BLOCKED_HANDOFF',
56
62
  noCommit: false,
57
63
  model: '',
58
64
  verbose: false,
@@ -101,6 +107,9 @@ function parseArgs(argv) {
101
107
  case '--task-promise':
102
108
  opts.taskPromise = args[++i];
103
109
  break;
110
+ case '--blocked-handoff-promise':
111
+ opts.blockedHandoffPromise = args[++i];
112
+ break;
104
113
  case '--no-commit':
105
114
  opts.noCommit = true;
106
115
  break;
@@ -154,6 +163,8 @@ Options:
154
163
  --stall-threshold <n> Halt after N consecutive no-op iterations (default: 3; 0 disables)
155
164
  --completion-promise <s> Completion promise string
156
165
  --task-promise <s> Task promise string
166
+ --blocked-handoff-promise <s>
167
+ Blocked-handoff promise string (default: BLOCKED_HANDOFF)
157
168
  --no-commit Suppress auto-commit
158
169
  --model <name> Model override
159
170
  --verbose Verbose output
@@ -212,6 +223,7 @@ async function main() {
212
223
  stallThreshold: opts.stallThreshold,
213
224
  completionPromise: opts.completionPromise,
214
225
  taskPromise: opts.taskPromise,
226
+ blockedHandoffPromise: opts.blockedHandoffPromise,
215
227
  noCommit: opts.noCommit,
216
228
  model: opts.model,
217
229
  verbose: opts.verbose,
@@ -333,69 +333,58 @@ validate_dependencies() {
333
333
  log_verbose "All dependencies validated"
334
334
  }
335
335
 
336
+ should_auto_fix_artifacts() {
337
+ case "${RALPH_RUN_AUTO_FIX_ARTIFACTS:-}" in
338
+ 1|true|TRUE|yes|YES)
339
+ return 0
340
+ ;;
341
+ 0|false|FALSE|no|NO)
342
+ return 1
343
+ ;;
344
+ esac
345
+
346
+ [[ -t 0 ]]
347
+ }
348
+
336
349
  ensure_artifacts_present() {
337
350
  local change_dir="$1"
338
351
  local change_name="$2"
339
352
 
340
- local required_files=(
341
- "proposal.md"
342
- "tasks.md"
343
- "design.md"
344
- )
345
-
346
- local missing=()
347
- for file in "${required_files[@]}"; do
348
- if [[ ! -f "$change_dir/$file" ]]; then
349
- missing+=("$file")
350
- fi
351
- done
353
+ local status_json
354
+ status_json=$(openspec status --change "$change_name" --json 2>/dev/null)
355
+ if [[ $? -ne 0 ]]; then
356
+ log_error "Failed to query openspec status for change: $change_name"
357
+ exit 1
358
+ fi
352
359
 
353
- if [[ ${#missing[@]} -eq 0 ]]; then
360
+ local blocked
361
+ blocked=$(echo "$status_json" | jq -r '.artifacts[] | select(.status == "blocked") | .id' 2>/dev/null)
362
+ if [[ -z "$blocked" ]]; then
354
363
  return 0
355
364
  fi
356
365
 
357
- log_info "Missing artifacts: ${missing[*]}"
366
+ log_info "Blocked artifacts detected: $blocked"
367
+ if ! should_auto_fix_artifacts; then
368
+ log_error "OpenSpec artifacts are blocked and this is a non-interactive run."
369
+ log_error 'Run `ralph-run init` or complete the artifacts manually, then rerun.'
370
+ log_error "Set RALPH_RUN_AUTO_FIX_ARTIFACTS=true to opt into opencode artifact repair in automation."
371
+ exit 1
372
+ fi
373
+
358
374
  log_info "Invoking opencode to complete missing artifacts..."
359
375
 
360
376
  opencode run "/opsx-ff $change_name" || true
361
377
 
362
- for file in "${required_files[@]}"; do
363
- if [[ ! -f "$change_dir/$file" ]]; then
364
- log_error "Required artifact still not found after /opsx-ff: $file"
365
- exit 1
366
- fi
367
- done
378
+ status_json=$(openspec status --change "$change_name" --json 2>/dev/null)
379
+ blocked=$(echo "$status_json" | jq -r '.artifacts[] | select(.status == "blocked") | .id' 2>/dev/null)
380
+ if [[ -n "$blocked" ]]; then
381
+ log_error "Artifacts still blocked after /opsx-ff: $blocked"
382
+ exit 1
383
+ fi
368
384
 
369
385
  log_info "All missing artifacts generated"
370
386
  }
371
387
 
372
- validate_openspec_artifacts() {
373
- local change_dir="$1"
374
-
375
- log_verbose "Validating OpenSpec artifacts..."
376
-
377
- local required_files=(
378
- "proposal.md"
379
- "tasks.md"
380
- "design.md"
381
- )
382
-
383
- for file in "${required_files[@]}"; do
384
- if [[ ! -f "$change_dir/$file" ]]; then
385
- log_error "Required artifact not found: $file"
386
- exit 1
387
- fi
388
- log_verbose "Found artifact: $file"
389
- done
390
-
391
- if [[ ! -d "$change_dir/specs" ]]; then
392
- log_error "Required directory not found: specs/"
393
- exit 1
394
- fi
395
- log_verbose "Found directory: specs/"
396
-
397
- log_info "All OpenSpec artifacts validated"
398
- }
399
388
 
400
389
  setup_ralph_directory() {
401
390
  local change_dir="$1"
@@ -682,15 +671,21 @@ validate_script_state() {
682
671
 
683
672
  log_verbose "Validating script state..."
684
673
 
685
- local required_dirs=(
686
- ".ralph"
687
- )
688
-
689
- for dir in "${required_dirs[@]}"; do
690
- if [[ ! -d "$change_dir/$dir" ]]; then
691
- log_verbose "Required directory not found: $dir (will be created)"
692
- fi
693
- done
674
+ if [[ ! -d "$change_dir/.ralph" ]]; then
675
+ log_verbose "Required directory not found: .ralph (will be created)"
676
+ fi
677
+
678
+ if [[ ! -d "$change_dir/specs" ]]; then
679
+ log_error "Required directory not found: specs"
680
+ return 1
681
+ fi
682
+
683
+ local first_spec=""
684
+ first_spec=$(find "$change_dir/specs" -name "spec.md" -type f -print -quit 2>/dev/null || true)
685
+ if [[ -z "$first_spec" ]]; then
686
+ log_error "No spec.md files found under specs"
687
+ return 1
688
+ fi
694
689
 
695
690
  local required_files=(
696
691
  "tasks.md"
@@ -810,111 +805,7 @@ sync_tasks_to_ralph() {
810
805
  log_verbose "Symlink configured: $ralph_tasks_file -> $abs_tasks_file"
811
806
  }
812
807
 
813
- create_prompt_template() {
814
- local change_dir="$1"
815
- local template_file="$2"
816
-
817
- log_verbose "Creating custom prompt template..."
818
-
819
- local abs_change_dir
820
- abs_change_dir=$(get_realpath "$change_dir")
821
-
822
- cat > "$template_file" << 'EOF'
823
- # Ralph Wiggum Task Execution - Iteration {{iteration}} / {{max_iterations}}
824
-
825
- Change directory: {{change_dir}}
826
-
827
- ## OpenSpec Artifacts
828
-
829
- {{_openspec_manifest}}
830
-
831
- ## Fresh Task Context
832
-
833
- {{task_context}}
834
-
835
- ## Instructions
836
-
837
- Before implementing, read the OpenSpec artifacts listed above that are relevant to the current task.
838
-
839
- Follow this loop contract EXACTLY. Do not skip steps. Do not batch. Do not output a promise until every step is done.
840
-
841
- 1. Work on the task shown in `## Fresh Task Context` above. Before editing any marker, open `tasks.md` at `{{change_dir}}/tasks.md` and verify that same task is still `- [ ] ` or `- [/] ` on disk (it may have been closed by a prior iteration if you are resuming).
842
- 2. Edit `tasks.md` in place to change that line's marker to `- [/] ` (in-progress). You MUST use your file edit tool to modify the file on disk — a shell `cp`, `sed`, or print-to-stdout does not count. Verify by re-reading the file.
843
- 3. Implement the smallest change that fully satisfies the task's Done-when conditions. Run the task's verification command if one is specified.
844
- 4. On success, edit `tasks.md` again in place to change that line's marker from `- [/] ` to `- [x] `. Verify by re-reading the file and confirming the `[x]` is present on that exact line.
845
- 5. ONLY after step 4 writes `[x]` to disk, output `<promise>{{task_promise}}</promise>` on its own line.
846
- 6. If and only if EVERY task line in `tasks.md` is `- [x] `, output `<promise>{{completion_promise}}</promise>` instead.
847
808
 
848
- Hard rules:
849
- - If you do not actually modify `tasks.md` on disk in this iteration, DO NOT output any promise tag. Output a short failure note instead and stop.
850
- - Never output `<promise>{{task_promise}}</promise>` while the task you just worked on is still `- [ ]` on disk. That causes the same task to repeat forever.
851
- - Promise tags must be on their own line, literal, unquoted, and not described in prose.
852
- - If an approach fails twice, try a different one.
853
- - If the task is already satisfied by prior work (e.g. target file already exists with the right content), you STILL must flip the checkbox to `[x]` in `tasks.md` before emitting the promise.
854
-
855
- ## Commit Contract
856
-
857
- {{commit_contract}}
858
- EOF
859
-
860
- # Determine repo root for AGENTS.md probe
861
- local repo_root
862
- repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root=""
863
-
864
- # Build the manifest body
865
- local manifest_body
866
- manifest_body="Read these as needed (source of truth for this change):"$'\n'$'\n'
867
- manifest_body+="- $abs_change_dir/proposal.md"$'\n'
868
- manifest_body+="- $abs_change_dir/design.md"$'\n'
869
-
870
- # Pre-expand specs/*/spec.md into concrete paths
871
- if [[ -d "$abs_change_dir/specs" ]]; then
872
- while IFS= read -r spec_path; do
873
- [[ -n "$spec_path" ]] && manifest_body+="- $spec_path"$'\n'
874
- done < <(find "$abs_change_dir/specs" -name spec.md -type f 2>/dev/null | sort)
875
- fi
876
-
877
- # Optionally append AGENTS.md reference
878
- local agents_line
879
- agents_line=$(probe_agents_md "$repo_root")
880
- if [[ -n "$agents_line" ]]; then
881
- manifest_body+=$'\n'"$agents_line"
882
- fi
883
-
884
- # Append Ralph best practices guide if project is ralphified
885
- if check_ralphified; then
886
- local bp_manifest_path="$abs_change_dir/../../OPENSPEC-RALPH-BP.md"
887
- if [[ ! -f "$bp_manifest_path" ]]; then
888
- bp_manifest_path="$repo_root/openspec/OPENSPEC-RALPH-BP.md"
889
- fi
890
- if [[ -f "$bp_manifest_path" ]]; then
891
- manifest_body+=$'\n'"- $bp_manifest_path (Ralph best practices guide)"
892
- fi
893
- fi
894
-
895
- # Substitute {{_openspec_manifest}} using awk with a manifest temp file
896
- # (awk -v cannot handle multi-line values; use getline from a file instead)
897
- local _manifest_file
898
- _manifest_file=$(mktemp 2>/dev/null || mktemp -t ralph-manifest)
899
- printf '%s' "$manifest_body" > "$_manifest_file"
900
- local _tmpfile
901
- _tmpfile=$(mktemp 2>/dev/null || mktemp -t ralph-template)
902
- awk -v mf="$_manifest_file" '
903
- {
904
- if ($0 == "{{_openspec_manifest}}") {
905
- while ((getline line < mf) > 0) { print line }
906
- close(mf)
907
- } else { print }
908
- }
909
- ' "$template_file" > "$_tmpfile" && mv "$_tmpfile" "$template_file"
910
- rm -f "$_manifest_file"
911
-
912
- # Substitute {{change_dir}}
913
- _tmpfile=$(mktemp 2>/dev/null || mktemp -t ralph-template)
914
- sed "s|{{change_dir}}|$abs_change_dir|g" "$template_file" > "$_tmpfile" && mv "$_tmpfile" "$template_file"
915
-
916
- log_verbose "Prompt template created: $template_file"
917
- }
918
809
 
919
810
  probe_agents_md() {
920
811
  local repo_root="$1"
@@ -1064,14 +955,11 @@ execute_ralph_loop() {
1064
955
  return 1
1065
956
  fi
1066
957
 
1067
- local template_file="$ralph_dir/prompt-template.md"
1068
-
1069
958
  # Clean up old output directories and setup new one
1070
959
  cleanup_old_output
1071
960
  local output_dir=$(setup_output_capture "$ralph_dir")
1072
961
 
1073
962
  sync_tasks_to_ralph "$change_dir" "$ralph_dir"
1074
- create_prompt_template "$change_dir" "$template_file"
1075
963
 
1076
964
  # Output files
1077
965
  local stdout_log="$output_dir/ralph-stdout.log"
@@ -1080,13 +968,38 @@ execute_ralph_loop() {
1080
968
  log_info "Invoking internal mini Ralph runtime..."
1081
969
  log_info "Capturing output to: $output_dir"
1082
970
 
971
+ local ralph_prompt_text="/opsx-apply $CHANGE_NAME
972
+
973
+ You are operating inside an automated loop. Follow these constraints EXACTLY:
974
+
975
+ 1. Implement exactly ONE pending task from the task list /opsx-apply shows you.
976
+ 2. After marking the task checkbox [x] on disk, output <promise>READY_FOR_NEXT_TASK</promise> on its own line.
977
+ 3. If and only if EVERY task checkbox is [x], output <promise>COMPLETE</promise> instead.
978
+ 4. Do not ask questions or wait for input. If you cannot make progress on the current task because an external decision is required (revert protected drift outside the change scope, file an out-of-scope refactor, escalate to a human reviewer, etc.), STOP and emit a structured handoff in this exact form:
979
+
980
+ ## Blocker Note
981
+ <one paragraph describing what is blocked>
982
+
983
+ ## Why
984
+ <one paragraph: which task spec clause / invariant fired, and what evidence (file paths, hashes, test names) supports the diagnosis>
985
+
986
+ ## Suggested Next Step
987
+ <one or two bullets the human can execute to unblock>
988
+
989
+ <promise>BLOCKED_HANDOFF</promise>
990
+
991
+ The runner will save this note to .ralph/HANDOFF.md and exit cleanly with reason=blocked_handoff. Do NOT keep retrying the same task; emit the handoff and stop. Do NOT emit BLOCKED_HANDOFF for transient errors that a retry could fix (network blips, tool-not-found that is fixable by an absolute path, etc.) — those are normal failures the loop will retry on its own.
992
+ 5. If the task is already satisfied by prior work, still flip the checkbox to [x] before emitting the promise.
993
+
994
+ Do not create git commits yourself. The Ralph runner manages automatic task commits when auto-commit is enabled."
995
+
1083
996
  # Build the mini-ralph-cli arguments
1084
997
  local mini_ralph_args=(
1085
- "--prompt-template" "$template_file"
1086
998
  "--ralph-dir" "$ralph_dir"
1087
999
  "--tasks-file" "$change_dir/tasks.md"
1088
1000
  "--tasks"
1089
1001
  "--max-iterations" "$max_iterations"
1002
+ "--prompt-text" "$ralph_prompt_text"
1090
1003
  )
1091
1004
 
1092
1005
  if [[ "$no_commit" == true ]]; then
@@ -1101,12 +1014,53 @@ execute_ralph_loop() {
1101
1014
  mini_ralph_args+=("--quiet")
1102
1015
  fi
1103
1016
 
1104
- # Run the internal mini Ralph CLI and capture output
1105
- {
1106
- node "$MINI_RALPH_CLI" "${mini_ralph_args[@]}"
1107
- } > >(tee "$stdout_log") 2> >(tee "$stderr_log")
1108
- local node_exit_code=$?
1109
- wait
1017
+ # Run the internal mini Ralph CLI and capture output.
1018
+ #
1019
+ # Avoid Bash process substitution here. macOS ships Bash 3.2, and under
1020
+ # Bats' captured `run` wrapper a bare `wait` after `> >(tee ...)` can hang
1021
+ # after the node child has already exited. Explicit FIFOs give us concrete
1022
+ # tee PIDs to wait on and work consistently on macOS and Linux.
1023
+ local stdout_pipe="$output_dir/ralph-stdout.pipe"
1024
+ local stderr_pipe="$output_dir/ralph-stderr.pipe"
1025
+ local node_exit_code=0
1026
+ local tee_stdout_pid=""
1027
+ local tee_stderr_pid=""
1028
+ local had_errexit=false
1029
+ case $- in
1030
+ *e*)
1031
+ had_errexit=true
1032
+ set +e
1033
+ ;;
1034
+ esac
1035
+
1036
+ if mkfifo "$stdout_pipe" "$stderr_pipe" 2>/dev/null; then
1037
+ tee "$stdout_log" < "$stdout_pipe" &
1038
+ tee_stdout_pid=$!
1039
+ tee "$stderr_log" < "$stderr_pipe" >&2 &
1040
+ tee_stderr_pid=$!
1041
+
1042
+ node "$MINI_RALPH_CLI" "${mini_ralph_args[@]}" > "$stdout_pipe" 2> "$stderr_pipe"
1043
+ node_exit_code=$?
1044
+
1045
+ wait "$tee_stdout_pid" 2>/dev/null || true
1046
+ wait "$tee_stderr_pid" 2>/dev/null || true
1047
+ rm -f "$stdout_pipe" "$stderr_pipe"
1048
+ else
1049
+ log_verbose "mkfifo unavailable; capturing output without live tee"
1050
+ node "$MINI_RALPH_CLI" "${mini_ralph_args[@]}" > "$stdout_log" 2> "$stderr_log"
1051
+ node_exit_code=$?
1052
+ if [[ -s "$stdout_log" ]]; then
1053
+ cat "$stdout_log"
1054
+ fi
1055
+ if [[ -s "$stderr_log" ]]; then
1056
+ cat "$stderr_log" >&2
1057
+ fi
1058
+ fi
1059
+
1060
+ if [[ "$had_errexit" == true ]]; then
1061
+ set -e
1062
+ fi
1063
+
1110
1064
  return $node_exit_code
1111
1065
  }
1112
1066
 
@@ -1285,6 +1239,7 @@ check_ralphified() {
1285
1239
 
1286
1240
  show_ralphify_warning() {
1287
1241
  local change_name="$1"
1242
+ local preset_choice="${RALPH_RUN_RALPHIFY_CHOICE:-}"
1288
1243
 
1289
1244
  cat >&2 << 'WARNING_BOX'
1290
1245
  ┌─────────────────────────────────────────────────────────────────────┐
@@ -1299,16 +1254,29 @@ show_ralphify_warning() {
1299
1254
  └─────────────────────────────────────────────────────────────────────┘
1300
1255
  WARNING_BOX
1301
1256
 
1257
+ if [[ -z "$preset_choice" && ! -t 0 ]]; then
1258
+ log_info "Non-interactive environment detected. Continuing without Ralph Wiggum configuration."
1259
+ log_info 'Run `ralph-run init` to configure Ralph Wiggum best practices before the next interactive run.'
1260
+ return 0
1261
+ fi
1262
+
1302
1263
  while true; do
1303
- echo "" >&2
1304
- echo "Choose an option:" >&2
1305
- echo " [A] Run ralphify init and redo the proposal, then continue" >&2
1306
- echo " [C] Continue without init" >&2
1307
- echo " [Q] Quit" >&2
1308
- printf "Enter choice: " >&2
1309
- if ! read -r choice; then
1310
- log_info "Non-interactive environment detected. Continuing without Ralph Wiggum configuration."
1311
- return 0
1264
+ local choice=""
1265
+ if [[ -n "$preset_choice" ]]; then
1266
+ choice="$preset_choice"
1267
+ preset_choice=""
1268
+ log_info "Using RALPH_RUN_RALPHIFY_CHOICE=$choice"
1269
+ else
1270
+ echo "" >&2
1271
+ echo "Choose an option:" >&2
1272
+ echo " [A] Run ralphify init and redo the proposal, then continue" >&2
1273
+ echo " [C] Continue without init" >&2
1274
+ echo " [Q] Quit" >&2
1275
+ printf "Enter choice: " >&2
1276
+ if ! read -r choice; then
1277
+ log_info "Non-interactive environment detected. Continuing without Ralph Wiggum configuration."
1278
+ return 0
1279
+ fi
1312
1280
  fi
1313
1281
 
1314
1282
  case "$choice" in
@@ -1474,7 +1442,6 @@ main() {
1474
1442
 
1475
1443
  ensure_artifacts_present "$change_dir" "$CHANGE_NAME"
1476
1444
 
1477
- validate_openspec_artifacts "$change_dir"
1478
1445
  validate_script_state "$change_dir"
1479
1446
  local ralph_dir=$(setup_ralph_directory "$change_dir")
1480
1447