@data-matters.io/agent-harness 0.0.0-stage → 0.1.1

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.
Files changed (42) hide show
  1. package/README.md +43 -2
  2. package/bin/harness +374 -0
  3. package/package.json +30 -4
  4. package/scripts/next-agent-rules.sh +67 -0
  5. package/scripts/toolchain.sh +14 -0
  6. package/standards/conventions.md +68 -0
  7. package/standards/dod.md +53 -0
  8. package/standards/stack.md +42 -0
  9. package/standards/stacks/nestjs/architecture.md +75 -0
  10. package/standards/stacks/nestjs/conventions.md +33 -0
  11. package/standards/stacks/nestjs/stack.md +34 -0
  12. package/standards/stacks/nestjs/testing.md +31 -0
  13. package/standards/stacks/nextjs/architecture.md +97 -0
  14. package/standards/stacks/nextjs/conventions.md +32 -0
  15. package/standards/stacks/nextjs/stack.md +40 -0
  16. package/standards/stacks/nextjs/testing.md +33 -0
  17. package/standards/testing.md +58 -0
  18. package/templates/.claude/hooks/stop-gates.sh +43 -0
  19. package/templates/.claude/settings.json +72 -0
  20. package/templates/.github/dependabot.yml +19 -0
  21. package/templates/.github/workflows/gates.yml +41 -0
  22. package/templates/.harness/db-smoke.sh +75 -0
  23. package/templates/.harness/deps.sh +219 -0
  24. package/templates/.harness/gates.conf.example +31 -0
  25. package/templates/.harness/gates.sh +108 -0
  26. package/templates/.harness/lib.sh +169 -0
  27. package/templates/.harness/loop.sh +169 -0
  28. package/templates/.harness/model-flags.sh +34 -0
  29. package/templates/.harness/models.conf +31 -0
  30. package/templates/.harness/notify.sh +49 -0
  31. package/templates/.harness/openspec.sh +28 -0
  32. package/templates/.harness/pr-scope.sh +44 -0
  33. package/templates/.harness/run-tasks.sh +461 -0
  34. package/templates/.harness/spec-pr.sh +157 -0
  35. package/templates/.harness/status.sh +155 -0
  36. package/templates/CLAUDE.md.tmpl +159 -0
  37. package/templates/openspec/config.yaml +38 -0
  38. package/templates/stacks/nestjs/gates.conf +35 -0
  39. package/templates/stacks/nestjs/gates.yml +71 -0
  40. package/templates/stacks/nextjs/claude-imports.md +4 -0
  41. package/templates/stacks/nextjs/gates.conf +36 -0
  42. package/templates/stacks/nextjs/gates.yml +68 -0
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env bash
2
+ # Autonomous task loop for one OpenSpec change.
3
+ #
4
+ # For each pending task (`- [ ] ...`) in openspec/changes/<change-id>/tasks.md:
5
+ # 1. run Claude Code headless in a fresh session, scoped to that task;
6
+ # 2. run .harness/gates.sh independently of the agent;
7
+ # 3. pass → mark the task done and make one commit;
8
+ # fail → retry with the gate output as feedback, up to N attempts;
9
+ # still failing → stop and escalate to a human.
10
+ # Each attempt runs at a reasoning level (ADR 0020): the task's level (impl in
11
+ # .harness/models.conf, or a <!-- level: Rn --> marker on the task line), twice,
12
+ # then one level up per attempt (R2, R2, R3, R4). Attempts that changed nothing
13
+ # climb only once. The level's --model/--effort are passed when
14
+ # HARNESS_MODEL_ROUTING=on (measure mode only records the level). With routing
15
+ # on, a session that ends on a usage limit or missing usage credits is repeated
16
+ # one level down (R4 → R3 → R2 → R1) instead of pausing; the summary records
17
+ # that session as "fallback" and the next at the level it ran. Pausing (exit 3)
18
+ # is kept at R1 and with routing off.
19
+ # Before the first task it installs the dependencies the spec declares
20
+ # (.harness/deps.sh, ADR 0015) in their own commit; an agent that changes
21
+ # dependency files escalates the run.
22
+ #
23
+ # Usage: .harness/run-tasks.sh <change-id> [--max-attempts N] [--open-pr] [--force] [--check]
24
+ #
25
+ # Only approved changes run: the change must be on the base branch, merged there
26
+ # through its spec PR (ADR 0011), and the change files on the work branch must
27
+ # match the approved ones (tasks.md checkboxes aside). Work happens on
28
+ # change/<id>, created from the base branch; --open-pr links the spec PR.
29
+ # --check runs the preconditions only (approval, autonomy, clean worktree) and exits.
30
+ #
31
+ # The loop runs in a git worktree of its own (ADR 0012), never in the checkout it
32
+ # is started from: see harness_worktree in .harness/lib.sh for the location.
33
+ # Dependencies are installed there on first use. State and logs stay in the main
34
+ # checkout (.harness/state, .harness/logs). The worktree is removed once every
35
+ # task is done; on escalation or pause it is kept for a human or the next run.
36
+ #
37
+ # Exit codes: 0 all tasks done, 2 a task escalated after N attempts,
38
+ # 3 paused because the agent hit the account usage limit,
39
+ # 4 stopped between two tasks on request (.harness/state/stop-<id>,
40
+ # written by `loop.sh stop <id> --after-task`; the work is
41
+ # committed and the worktree kept).
42
+ #
43
+ # Environment:
44
+ # HARNESS_BASE_BRANCH branch holding approved specs (default main)
45
+ # HARNESS_WORKTREES_DIR parent directory of the loop worktrees
46
+ # HARNESS_WORKTREE_SETUP command run once in a new worktree (default: install
47
+ # dependencies from the lockfile); HARNESS_MAIN_CHECKOUT
48
+ # points at the main checkout, e.g. to copy a .env
49
+ # HARNESS_MAX_ATTEMPTS attempts per task (default 4: the level ladder, ADR 0020)
50
+ # HARNESS_TASK_TIMEOUT max seconds per agent session (default 1800)
51
+ # HARNESS_PERMISSION_MODE claude --permission-mode (default acceptEdits)
52
+ # HARNESS_CLAUDE_ARGS extra args for claude (experiments; models come from models.conf)
53
+ # HARNESS_COMMIT_TYPE conventional commit type (default feat)
54
+ set -euo pipefail
55
+
56
+ usage() { sed -n '2,50p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
57
+ die() { echo "run-tasks: $*" >&2; exit 1; }
58
+
59
+ [[ $# -ge 1 ]] || usage 1
60
+ [[ "$1" == "-h" || "$1" == "--help" ]] && usage 0
61
+ change="$1"; shift
62
+
63
+ max_attempts="${HARNESS_MAX_ATTEMPTS:-4}"
64
+ timeout_s="${HARNESS_TASK_TIMEOUT:-1800}"
65
+ permission_mode="${HARNESS_PERMISSION_MODE:-acceptEdits}"
66
+ commit_type="${HARNESS_COMMIT_TYPE:-feat}"
67
+ open_pr=0
68
+ force=0
69
+ check_only=0
70
+ while [[ $# -gt 0 ]]; do
71
+ case "$1" in
72
+ --max-attempts) max_attempts="$2"; shift 2 ;;
73
+ --open-pr) open_pr=1; shift ;;
74
+ --force) force=1; shift ;;
75
+ --check) check_only=1; shift ;;
76
+ *) die "unknown option: $1" ;;
77
+ esac
78
+ done
79
+
80
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"
81
+ # shellcheck source-path=SCRIPTDIR source=lib.sh
82
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
83
+ root="$(harness_root)"
84
+ cd "$root"
85
+ wt="$(harness_worktree "$change")"
86
+ # Machine settings (skipped gates, TELEGRAM_STATE_DIR for the agent sessions)
87
+ # also when run-tasks.sh is started directly instead of through loop.sh.
88
+ harness_load_env
89
+
90
+ change_dir="openspec/changes/$change"
91
+ tasks="$change_dir/tasks.md"
92
+ [[ -x .harness/gates.sh ]] || die ".harness/gates.sh missing or not executable"
93
+ command -v claude >/dev/null || die "claude CLI not found"
94
+
95
+ autonomy="$(cat .harness/autonomy 2>/dev/null || echo disabled)"
96
+ if [[ "$autonomy" != "enabled" && $force -eq 0 ]]; then
97
+ die "autonomy is '$autonomy' (.harness/autonomy). Add characterization tests first, or pass --force."
98
+ fi
99
+
100
+ # The spec is approved when its spec PR is merged: the change is on the base
101
+ # branch. Compare against the remote copy when there is one, so a stale local
102
+ # branch neither hides an approval nor fakes one.
103
+ base_branch="${HARNESS_BASE_BRANCH:-main}"
104
+ base_ref="$base_branch"
105
+ if git remote get-url origin >/dev/null 2>&1; then
106
+ git fetch -q origin "$base_branch" || die "cannot fetch origin/$base_branch"
107
+ base_ref="origin/$base_branch"
108
+ fi
109
+ git rev-parse -q --verify "$base_ref^{commit}" >/dev/null || die "base branch $base_ref not found"
110
+ if ! git cat-file -e "$base_ref:$tasks" 2>/dev/null; then
111
+ die "change $change is not on $base_ref, so its spec is not approved. Open the spec PR from spec/$change and merge it first (ADR 0011)."
112
+ fi
113
+
114
+ branch="change/$change"
115
+ if git rev-parse -q --verify "refs/heads/$branch" >/dev/null; then
116
+ start_ref="$branch"
117
+ else
118
+ start_ref="$base_ref"
119
+ fi
120
+ if [[ -d "$wt" ]]; then
121
+ [[ "$(git -C "$wt" branch --show-current 2>/dev/null)" == "$branch" ]] \
122
+ || die "$wt exists but is not a worktree on $branch; remove it (git worktree remove) and re-run"
123
+ [[ -z "$(git -C "$wt" status --porcelain)" ]] \
124
+ || die "worktree $wt has uncommitted work (left by an escalation?). Finish or discard it there first."
125
+ fi
126
+
127
+ # The work branch implements exactly the approved spec: every change file
128
+ # matches the base branch, except for the checkboxes the loop ticks in tasks.md.
129
+ unchecked() { sed -E 's/^([[:space:]]*- )\[x\] /\1[ ] /'; }
130
+ if ! git diff --quiet "$base_ref" "$start_ref" -- "$change_dir" ":(exclude)$tasks" \
131
+ || ! cmp -s <(git show "$base_ref:$tasks" | unchecked) <(git show "$start_ref:$tasks" 2>/dev/null | unchecked); then
132
+ die "$change_dir on $branch differs from the approved spec on $base_ref. Spec changes go through a new spec PR; then rebase $branch on $base_branch."
133
+ fi
134
+
135
+ if [[ $check_only -eq 1 ]]; then
136
+ echo "run-tasks: change $change is approved and ready to run on $branch in $wt."
137
+ exit 0
138
+ fi
139
+
140
+ # Work in the change's own worktree, so the checkout the loop was started from
141
+ # (where specs are written) is never switched or cleaned under anyone's feet.
142
+ if [[ ! -d "$wt" ]]; then
143
+ mkdir -p "$(dirname "$wt")"
144
+ if [[ "$start_ref" == "$branch" ]]; then
145
+ git worktree add -q "$wt" "$branch" \
146
+ || die "cannot check out $branch in $wt; if it is checked out elsewhere (git worktree list), switch that checkout away"
147
+ else
148
+ git worktree add -q --no-track -b "$branch" "$wt" "$base_ref" || die "cannot create worktree $wt"
149
+ fi
150
+ setup="${HARNESS_WORKTREE_SETUP:-}"
151
+ if [[ -z "$setup" ]]; then
152
+ if [[ -f "$wt/pnpm-lock.yaml" ]]; then setup="pnpm install --frozen-lockfile"
153
+ elif [[ -f "$wt/package-lock.json" ]]; then setup="npm ci"
154
+ fi
155
+ fi
156
+ if [[ -n "$setup" ]]; then
157
+ mkdir -p ".harness/logs/$change"
158
+ echo "==> worktree setup: $setup"
159
+ if ! (cd "$wt" && HARNESS_MAIN_CHECKOUT="$root" bash -c "$setup") >".harness/logs/$change/worktree-setup.log" 2>&1; then
160
+ # Without its dependencies the worktree is useless; the next run starts over.
161
+ git worktree remove --force "$wt"
162
+ die "worktree setup failed ($setup); see .harness/logs/$change/worktree-setup.log"
163
+ fi
164
+ fi
165
+ # A harness installed from the package (ADR 0021): the worktree gets the
166
+ # version its own lockfile pins, even when the install skipped "prepare".
167
+ if [[ -x "$wt/node_modules/.bin/harness" ]]; then
168
+ mkdir -p ".harness/logs/$change"
169
+ if ! (cd "$wt" && node_modules/.bin/harness install) >>".harness/logs/$change/worktree-setup.log" 2>&1; then
170
+ git worktree remove --force "$wt"
171
+ die "harness install failed in the worktree; see .harness/logs/$change/worktree-setup.log"
172
+ fi
173
+ fi
174
+ fi
175
+
176
+ log_dir="$root/.harness/logs/$change"
177
+ mkdir -p "$log_dir" "$root/.harness/state"
178
+
179
+ # Runner state for .harness/status.sh: key=value lines, rewritten on every step.
180
+ state_file="$root/.harness/state/run-$change"
181
+ run_status="running"
182
+ cur_task_id=""
183
+ cur_task=""
184
+ cur_attempt=0
185
+ attempt_started=""
186
+ write_state() {
187
+ {
188
+ echo "status=$run_status"
189
+ echo "pid=$$"
190
+ echo "started=$run_started"
191
+ echo "task_id=$cur_task_id"
192
+ echo "task=$cur_task"
193
+ echo "attempt=$cur_attempt"
194
+ echo "max_attempts=$max_attempts"
195
+ echo "attempt_started=$attempt_started"
196
+ echo "updated=$(date +%s)"
197
+ } >"$state_file"
198
+ }
199
+ run_started="$(date +%s)"
200
+ # Anything that ends the run without setting a final status was interrupted.
201
+ trap '[[ "$run_status" == running ]] && run_status=interrupted; write_state' EXIT
202
+ write_state
203
+ cd "$wt"
204
+ summary="$log_dir/summary.tsv"
205
+ summary_header=$'timestamp\ttask\tattempt\tduration_s\tresult\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens'
206
+ if [[ ! -f "$summary" ]]; then
207
+ printf '%s\n' "$summary_header" >"$summary"
208
+ elif [[ "$(head -n1 "$summary")" == $'timestamp\ttask\tattempt\tduration_s\tresult' ]]; then
209
+ sed -i "1s/.*/$summary_header/" "$summary" # older runs: same first columns, new ones empty
210
+ fi
211
+
212
+ next_task() { grep -n -m1 -E '^[[:space:]]*- \[ \] ' "$tasks" || true; }
213
+
214
+ # Dependencies the approved spec declares (design.md "## Dependencies", ADR 0015)
215
+ # are installed by the loop, never by the agent, in one commit before the tasks.
216
+ # Anything deps.sh refuses (an undeclared change, a version conflict) escalates.
217
+ dep_files=(package.json pnpm-lock.yaml pnpm-workspace.yaml)
218
+ if [[ -x .harness/deps.sh && -f package.json ]]; then
219
+ if ! .harness/deps.sh install "$change_dir" >"$log_dir/deps.log" 2>&1; then
220
+ run_status="escalated"
221
+ echo "run-tasks: the declared dependencies could not be installed; escalating." >&2
222
+ tail -n 20 "$log_dir/deps.log" >&2
223
+ exit 2
224
+ fi
225
+ if [[ -n "$(git status --porcelain -- "${dep_files[@]}")" ]]; then
226
+ git add -- "${dep_files[@]}"
227
+ printf 'chore(%s): add the dependencies declared by the spec\n\nInstalled by the task loop from the "## Dependencies" section of\n%s/design.md (exact versions, no other dependency changed):\n\n%s\n' \
228
+ "$change" "$change_dir" "$(grep '^deps: added' "$log_dir/deps.log" | sed 's/^deps: added //')" | git commit -q -F -
229
+ echo "==> dependencies declared by the spec installed and committed"
230
+ fi
231
+ fi
232
+
233
+ build_prompt() {
234
+ local task="$1" feedback="$2"
235
+ cat <<EOF
236
+ You are running inside the autonomous task loop of agent-harness.
237
+
238
+ Change: $change (files in $change_dir/: proposal.md, design.md if present, specs/, tasks.md)
239
+ Your task (exactly one):
240
+ $task
241
+
242
+ Instructions:
243
+ - Read the proposal, design and delta specs relevant to this task before coding.
244
+ - Implement ONLY this task, following CLAUDE.md and .harness/standards/.
245
+ - Add or update tests that prove the task's behaviour; write the failing test first.
246
+ - Run .harness/gates.sh and iterate until it exits 0.
247
+ - Do NOT edit files in $change_dir/: it is the approved spec. If it is wrong, stop and say so.
248
+ - Do NOT edit tasks.md, do NOT commit, do NOT push. The loop does that.
249
+ - Do NOT add, remove or update packages, and do NOT edit package.json, pnpm-lock.yaml or
250
+ pnpm-workspace.yaml: the loop already installed the dependencies the spec declares. If the
251
+ task needs a package the spec does not declare, stop and name it in your final message.
252
+ - Never change what an existing test asserts to make it match the code. If an existing
253
+ test conflicts with the code, stop and explain the conflict in your final message.
254
+ - If the task cannot be done as specified, stop and explain why in your final message.
255
+ - All artifacts in English.
256
+ EOF
257
+ if [[ -n "$feedback" ]]; then
258
+ cat <<EOF
259
+
260
+ A previous attempt left work in the tree but the gates failed with:
261
+ --------
262
+ $feedback
263
+ --------
264
+ Continue from the current state and fix these failures.
265
+ EOF
266
+ fi
267
+ }
268
+
269
+ # Conventional-commit summary from a task line: drop the task id and backticks,
270
+ # keep the first sentence, and cut at a word boundary to fit 72 chars with the prefix.
271
+ commit_summary() {
272
+ local text max
273
+ text="$(sed -E -e 's/^[0-9]+(\.[0-9]+)*[[:space:]]+//' -e 's/`//g' -e 's/\. .*$//' -e 's/\.$//' <<<"$1")"
274
+ max=$(( 72 - ${#commit_type} - ${#change} - 4 ))
275
+ if (( ${#text} > max )); then
276
+ text="${text:0:max-3}"
277
+ text="${text% *}..."
278
+ fi
279
+ printf '%s' "$text"
280
+ }
281
+
282
+ untrusted_warned=0
283
+ warn_if_untrusted() {
284
+ if [[ $untrusted_warned -eq 0 ]] && grep -q "workspace has not been trusted" "$1" 2>/dev/null; then
285
+ echo " warning: Claude Code ignores .claude/settings.json permissions in this untrusted" >&2
286
+ echo " workspace; the agent cannot run gates itself. Trust it once interactively." >&2
287
+ untrusted_warned=1
288
+ fi
289
+ }
290
+
291
+ # record <task> <attempt> <duration> <result>: one summary row for the attempt,
292
+ # with its level, model, effort, cost and tokens (ADR 0020); the same session
293
+ # also goes to the machine-wide ledger.
294
+ record() {
295
+ local usage effort model cost out cache
296
+ effort="$(harness_flag_value "${level_flags:-}" --effort)"
297
+ usage="$(harness_record_usage "$(basename "$root")" "$change" impl "${level:-}" "${effort:-default}" "$2" "$4" \
298
+ "$log_dir/task-$1-attempt-$2.json")"
299
+ IFS=$'\t' read -r model cost out cache <<<"$usage"
300
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds)" "$1" "$2" "$3" "$4" \
301
+ "${level:-}" "$model" "${effort:-default}" "$cost" "$out" "$cache" >>"$summary"
302
+ }
303
+
304
+ while line="$(next_task)"; [[ -n "$line" ]]; do
305
+ # A stop requested while the previous task ran is honoured here, between tasks,
306
+ # when everything is committed (ADR 0022, spec amendment of a change in progress).
307
+ if [[ -f "$root/.harness/state/stop-$change" ]]; then
308
+ rm -f "$root/.harness/state/stop-$change"
309
+ run_status="stopped"
310
+ echo "run-tasks: stopped between tasks on request; the work is committed on $branch in $wt."
311
+ exit 4
312
+ fi
313
+ ln="${line%%:*}"
314
+ task="$(sed -E 's/^[[:space:]]*- \[ \] //' <<<"${line#*:}")"
315
+ # The spec author's level for this task (ADR 0020), stripped from the text
316
+ # that becomes the prompt and the commit.
317
+ level_override="$(grep -oE '<!--[[:space:]]*level:[[:space:]]*R[1-4][[:space:]]*-->' <<<"$task" | grep -oE 'R[1-4]' || true)"
318
+ task="$(sed -E 's/[[:space:]]*<!--[[:space:]]*level:[^>]*-->//g' <<<"$task")"
319
+ base_level="$(harness_task_level impl "$level_override")" || base_level="R2"
320
+ gate_fails=0 nochange=0 levels_tried="" level_cap=""
321
+ task_id="$(grep -oE '^[0-9]+(\.[0-9]+)*' <<<"$task" || echo "L$ln")"
322
+ summary_line="$(commit_summary "$task")"
323
+ feedback=""
324
+ echo "==> task $task_id: $task (level $base_level${level_override:+, set by the spec})"
325
+
326
+ done_ok=0
327
+ for (( attempt = 1; attempt <= max_attempts; attempt++ )); do
328
+ # Two attempts at the task's level, then one level up per failure; attempts
329
+ # that changed nothing climb only once (they count as at most two failures).
330
+ fails=$(( gate_fails + (nochange > 2 ? 2 : nochange) ))
331
+ level="$(harness_level_climb "$base_level" $(( fails > 1 ? fails - 1 : 0 )))"
332
+ # After a usage-error fallback the task stays at or below that level.
333
+ if [[ -n "$level_cap" ]] && (( ${level#R} > ${level_cap#R} )); then level="$level_cap"; fi
334
+ level_flags="$(harness_level_flags "$level")"
335
+ [[ " $levels_tried " == *" $level "* ]] || levels_tried="${levels_tried:+$levels_tried }$level"
336
+ echo " attempt $attempt/$max_attempts at $level${level_flags:+ ($level_flags)}"
337
+ cur_task_id="$task_id"; cur_task="$summary_line"; cur_attempt="$attempt"; attempt_started="$(date +%s)"
338
+ write_state
339
+ start=$(date +%s)
340
+ session_json="$log_dir/task-$task_id-attempt-$attempt.json"
341
+ while :; do
342
+ # shellcheck disable=SC2086
343
+ build_prompt "$task" "$feedback" | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p \
344
+ --output-format json \
345
+ --permission-mode "$permission_mode" \
346
+ --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
347
+ "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
348
+ ${level_flags} ${HARNESS_CLAUDE_ARGS:-} \
349
+ >"$session_json" 2>&1 || true
350
+
351
+ warn_if_untrusted "$session_json"
352
+
353
+ # Out of usage (limit or credits) at this level: with routing on, the
354
+ # same attempt goes to the level below instead of pausing (ADR 0020,
355
+ # amendment). The interrupted session left partial work: discard it. The
356
+ # fallback level also caps the rest of this task's ladder.
357
+ if harness_usage_error "$session_json" && lower="$(harness_level_descend "$level")"; then
358
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fallback
359
+ git checkout -- . && git clean -fdq
360
+ echo " usage error at $level; falling back to $lower"
361
+ .harness/notify.sh info "$change: task $task_id: $level is out of usage, falling back to $lower" 2>/dev/null || true
362
+ level_cap="$lower"
363
+ level="$lower"
364
+ level_flags="$(harness_level_flags "$level")"
365
+ [[ " $levels_tried " == *" $level "* ]] || levels_tried="${levels_tried:+$levels_tried }$level"
366
+ start=$(date +%s)
367
+ continue
368
+ fi
369
+ break
370
+ done
371
+
372
+ # An account usage limit ends the session early; retrying only burns the
373
+ # remaining attempts on the same error. Pause and let a human resume (at R1,
374
+ # or with routing off, where there is no lower model to try).
375
+ if harness_usage_error "$session_json"; then
376
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" paused
377
+ run_status="paused"
378
+ # The interrupted attempt left partial, unverified work: discard it.
379
+ git checkout -- . && git clean -fdq
380
+ echo "run-tasks: the agent session hit the account usage limit; pausing." >&2
381
+ echo " Task $task_id is not done; its partial attempt was discarded. Re-run after the limit resets." >&2
382
+ exit 3
383
+ fi
384
+
385
+ # The agent must not touch the approved spec; restoring tasks.md also keeps
386
+ # its line numbers valid.
387
+ git checkout -- "$change_dir"
388
+ git clean -fdq -- "$change_dir"
389
+
390
+ # Only the spec declares dependencies: an agent that changed them needs a
391
+ # package the spec does not have. Retrying cannot fix that; a human decides.
392
+ if [[ -n "$(git status --porcelain -- "${dep_files[@]}")" ]]; then
393
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" deps
394
+ run_status="escalated"
395
+ echo "run-tasks: task $task_id changed dependency files; undeclared dependencies need a spec change." >&2
396
+ echo " Work in progress is left uncommitted on branch $branch in $wt." >&2
397
+ exit 2
398
+ fi
399
+
400
+ # A task that changed nothing is not done, even if the gates pass on the old code.
401
+ if [[ -z "$(git status --porcelain -- . ":(exclude)$tasks")" ]]; then
402
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fail
403
+ feedback="The previous attempt changed no files, so the task was not implemented. Implement it."
404
+ nochange=$(( nochange + 1 ))
405
+ echo " FAIL (no changes)"
406
+ continue
407
+ fi
408
+
409
+ if gate_out="$(.harness/gates.sh 2>&1)"; then
410
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" pass
411
+ sed -i "${ln}s/- \[ \]/- [x]/" "$tasks"
412
+ git add -A
413
+ printf '%s(%s): %s\n\nTask %s of OpenSpec change %s, completed by the task loop:\n\n%s\n' \
414
+ "$commit_type" "$change" "$summary_line" "$task_id" "$change" "$task" | fold -s -w 72 | git commit -q -F -
415
+ echo " PASS → committed"
416
+ if [[ -x .harness/notify.sh ]]; then
417
+ .harness/notify.sh task-passed "$change: task $task_id passed — $summary_line"
418
+ fi
419
+ done_ok=1
420
+ break
421
+ fi
422
+
423
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fail
424
+ printf '%s\n' "$gate_out" >"$log_dir/task-$task_id-attempt-$attempt.gates.log"
425
+ feedback="$(tail -n 120 <<<"$gate_out")"
426
+ gate_fails=$(( gate_fails + 1 ))
427
+ echo " FAIL"
428
+ done
429
+
430
+ if [[ $done_ok -eq 0 ]]; then
431
+ run_status="escalated"
432
+ echo "run-tasks: task $task_id failed $max_attempts attempts (levels $levels_tried); escalating." >&2
433
+ echo " Work in progress is left uncommitted on branch $branch in $wt." >&2
434
+ echo " Logs: $log_dir/task-$task_id-attempt-*" >&2
435
+ exit 2
436
+ fi
437
+ done
438
+
439
+ run_status="done"
440
+ cur_task_id=""; cur_task=""; cur_attempt=0; attempt_started=""
441
+ echo "run-tasks: all tasks in $tasks are done."
442
+
443
+ if [[ $open_pr -eq 1 ]]; then
444
+ command -v gh >/dev/null || die "gh CLI not found; cannot open PR"
445
+ git push -u origin "$branch"
446
+ spec_pr="$(gh pr list --state merged --head "spec/$change" --json url --jq '.[0].url // empty' 2>/dev/null || true)"
447
+ body="$(mktemp)"
448
+ {
449
+ echo "Implements OpenSpec change \`$change\`, approved in ${spec_pr:-its spec PR (branch spec/$change; not found)}."
450
+ echo
451
+ cat "$change_dir/proposal.md"
452
+ } >"$body"
453
+ gh pr create --head "$branch" --base "$base_branch" --title "change: $change" --body-file "$body"
454
+ rm -f "$body"
455
+ fi
456
+
457
+ # Everything is committed on $branch: the worktree is no longer needed.
458
+ cd "$root"
459
+ git worktree remove --force "$wt"
460
+ echo "run-tasks: removed worktree $wt (branch $branch keeps the work)."
461
+
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # Last step of proposing a change (ADR 0011, ADR 0013): opens, or updates, the
3
+ # spec-only pull request whose merge approves the spec.
4
+ #
5
+ # Usage: .harness/spec-pr.sh <change-id>
6
+ #
7
+ # 1. validates the change with the pinned OpenSpec CLI (--strict), its
8
+ # declared packages (.harness/deps.sh check), the changes listed under
9
+ # "## Depends on" in proposal.md (each must exist: open, merged or
10
+ # archived), and simulates its archive in a scratch copy of openspec/
11
+ # (after archiving the unarchived changes it depends on): the merged
12
+ # specs must pass `validate --all --strict`;
13
+ # 2. moves to branch spec/<change-id> (created from the base branch, carrying
14
+ # the uncommitted change files) unless already on it;
15
+ # 3. refuses anything outside openspec/changes/<change-id>/, in the working
16
+ # tree or in the branch's commits;
17
+ # 4. commits the change files, pushes, and opens the PR "spec: <change-id>",
18
+ # or reuses the open one, and prints its URL.
19
+ #
20
+ # Environment:
21
+ # HARNESS_BASE_BRANCH branch the spec PR targets (default main)
22
+ set -euo pipefail
23
+
24
+ usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
25
+ die() { echo "spec-pr: $*" >&2; exit 1; }
26
+
27
+ [[ $# -eq 1 ]] || usage 1
28
+ [[ "$1" == "-h" || "$1" == "--help" ]] && usage 0
29
+ change="$1"
30
+ [[ "$change" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "invalid change id '$change' (kebab-case expected)"
31
+
32
+ root="$(git rev-parse --show-toplevel)" || die "not inside a git repository"
33
+ cd "$root"
34
+ change_dir="openspec/changes/$change"
35
+ branch="spec/$change"
36
+ base_branch="${HARNESS_BASE_BRANCH:-main}"
37
+
38
+ [[ -f "$change_dir/proposal.md" && -f "$change_dir/tasks.md" ]] \
39
+ || die "$change_dir needs at least proposal.md and tasks.md"
40
+ command -v gh >/dev/null || die "gh CLI not found"
41
+
42
+ .harness/openspec.sh validate "$change" --type change --strict --no-interactive \
43
+ || die "openspec validate failed; fix the change before opening its spec PR"
44
+ # Task levels (ADR 0020): only <!-- level: R1 --> to <!-- level: R4 -->.
45
+ bad_levels="$(grep -nE '<!--[[:space:]]*level' "$change_dir/tasks.md" \
46
+ | grep -vE '<!--[[:space:]]*level:[[:space:]]*R[1-4][[:space:]]*-->' || true)"
47
+ [[ -z "$bad_levels" ]] || die "invalid task level in $change_dir/tasks.md (use <!-- level: R1 --> to R4):
48
+ $bad_levels"
49
+ # The loop installs exactly what design.md declares (ADR 0015): check it now.
50
+ if [[ -x .harness/deps.sh ]]; then
51
+ .harness/deps.sh check "$change_dir" || die "fix the ## Dependencies section of $change_dir/design.md"
52
+ fi
53
+
54
+ # Depends on (ADR 0022, section 5): optional list of change ids in proposal.md,
55
+ # one kebab-case id in backticks per bullet. In an item that is more than an id
56
+ # (prose), the first backticked token that is an existing change is the id; items
57
+ # without one (PR references such as #63) are ignored with a warning. An item
58
+ # that is only an id must exist.
59
+ dep_items="$(awk '/^## /{ on = ($0 ~ /^## Depends on[ \t]*$/); next } on && /^[ \t]*[-*][ \t]+/ { sub(/^[ \t]*[-*][ \t]+/, ""); print }' "$change_dir/proposal.md")"
60
+
61
+ # Only the change's own files may be pending: anything else would leak into the spec PR.
62
+ outside="$(git status --porcelain --untracked-files=all | cut -c4- | grep -v "^$change_dir/" || true)"
63
+ [[ -z "$outside" ]] || die "uncommitted files outside $change_dir/ (commit, stash or remove them first):
64
+ $outside"
65
+
66
+ git fetch -q origin "$base_branch" || die "cannot fetch origin/$base_branch"
67
+ # change_exists <id>: open (in the tree or as a spec/ or change/ branch), merged or archived.
68
+ change_exists() {
69
+ [[ -d "openspec/changes/$1" ]] \
70
+ || git cat-file -e "origin/$base_branch:openspec/changes/$1" 2>/dev/null \
71
+ || git ls-tree --name-only "origin/$base_branch" openspec/changes/archive/ 2>/dev/null | grep -qE "/[0-9]{4}-[0-9]{2}-[0-9]{2}-$1\$" \
72
+ || compgen -G "openspec/changes/archive/*-$1" >/dev/null \
73
+ || git rev-parse -q --verify "refs/remotes/origin/spec/$1" >/dev/null \
74
+ || git rev-parse -q --verify "refs/remotes/origin/change/$1" >/dev/null
75
+ }
76
+ depends=()
77
+ while IFS= read -r item; do
78
+ [[ -n "$item" ]] || continue
79
+ bare="$(sed -E 's/^[[:space:]]*`?"?//; s/"?`?[[:space:]]*$//' <<<"$item")"
80
+ if [[ "$bare" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
81
+ [[ "$bare" != "$change" ]] || die "$change lists itself under ## Depends on"
82
+ change_exists "$bare" || die "## Depends on lists '$bare', which is not a change (open, merged or archived); fix proposal.md"
83
+ depends+=("$bare"); continue
84
+ fi
85
+ found=""
86
+ while IFS= read -r tok; do
87
+ [[ "$tok" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ && "$tok" != "$change" ]] && change_exists "$tok" && { found="$tok"; break; }
88
+ done < <(grep -oE '`[^`]+`' <<<"$item" | tr -d '`')
89
+ if [[ -n "$found" ]]; then depends+=("$found"); else echo "spec-pr: warning: ignoring ## Depends on item without a change id: ${item:0:80}" >&2; fi
90
+ done <<<"$dep_items"
91
+
92
+ # Simulate the archive (ADR 0022, section 4) in a scratch copy of openspec/: the
93
+ # watcher archives this change after its implementation PR is merged, and
94
+ # `validate --all --strict` must still pass then (a merged requirement over 500
95
+ # characters, a MODIFIED requirement that is not there). The changes it depends
96
+ # on that are not archived yet are archived first, in the order listed.
97
+ scratch="$(mktemp -d)"
98
+ trap 'rm -rf "$scratch"' EXIT
99
+ cp -r openspec "$scratch/openspec"
100
+ for dep in "${depends[@]}" "$change"; do
101
+ [[ -d "$scratch/openspec/changes/$dep" ]] || continue
102
+ (cd "$scratch" && "$root/.harness/openspec.sh" archive "$dep" --yes </dev/null) >"$scratch/out" 2>&1 \
103
+ || die "simulated archive of $dep failed; its delta does not apply on top of the current specs (is a change it builds on still unarchived? list it under ## Depends on):
104
+ $(tail -n 8 "$scratch/out")"
105
+ done
106
+ (cd "$scratch" && "$root/.harness/openspec.sh" validate --all --strict --no-interactive </dev/null) >"$scratch/out" 2>&1 \
107
+ || die "openspec validate --all --strict fails after the simulated archive of $change; fix the spec (split a requirement over 500 characters, move detail into scenarios):
108
+ $(tail -n 12 "$scratch/out")"
109
+
110
+ if [[ "$(git branch --show-current)" != "$branch" ]]; then
111
+ if git rev-parse -q --verify "refs/heads/$branch" >/dev/null; then
112
+ git switch -q "$branch" || die "cannot switch to $branch"
113
+ else
114
+ git switch -q --no-track -c "$branch" "origin/$base_branch" \
115
+ || die "cannot create $branch from origin/$base_branch with the pending change files"
116
+ fi
117
+ fi
118
+
119
+ committed_outside="$(git diff --name-only "origin/$base_branch...HEAD" | grep -v "^$change_dir/" || true)"
120
+ [[ -z "$committed_outside" ]] || die "$branch has commits outside $change_dir/; a spec PR contains only the spec:
121
+ $committed_outside"
122
+
123
+ if [[ -n "$(git status --porcelain --untracked-files=all -- "$change_dir")" ]]; then
124
+ git add -A -- "$change_dir"
125
+ if git cat-file -e "origin/$base_branch:$change_dir/tasks.md" 2>/dev/null \
126
+ || [[ -n "$(git log --oneline "origin/$base_branch..HEAD")" ]]; then
127
+ msg="docs($change): update OpenSpec change"
128
+ else
129
+ msg="docs($change): propose OpenSpec change"
130
+ fi
131
+ git commit -q -m "$msg" -- "$change_dir"
132
+ fi
133
+ [[ -n "$(git log --oneline "origin/$base_branch..HEAD")" ]] \
134
+ || die "$change_dir is identical on origin/$base_branch: nothing to propose"
135
+
136
+ git push -q -u origin "$branch"
137
+
138
+ url="$(gh pr list --state open --head "$branch" --json url --jq '.[0].url // empty' 2>/dev/null || true)"
139
+ if [[ -n "$url" ]]; then
140
+ echo "spec-pr: updated $url"
141
+ exit 0
142
+ fi
143
+
144
+ body="$(mktemp)"
145
+ trap 'rm -rf "$scratch"; rm -f "$body"' EXIT
146
+ {
147
+ echo "OpenSpec change \`$change\`, for review. **Merging this PR approves the spec** (ADR 0011);"
148
+ echo "nothing is implemented until then. Comment here; adjustments land on \`$branch\`."
149
+ echo
150
+ cat "$change_dir/proposal.md"
151
+ echo
152
+ echo "## Tasks"
153
+ echo
154
+ grep -E '^[[:space:]]*- \[[ xX]\] ' "$change_dir/tasks.md" || true
155
+ } >"$body"
156
+ url="$(gh pr create --base "$base_branch" --head "$branch" --title "spec: $change" --body-file "$body")"
157
+ echo "spec-pr: opened $url"