@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.
- package/README.md +43 -2
- package/bin/harness +374 -0
- package/package.json +30 -4
- package/scripts/next-agent-rules.sh +67 -0
- package/scripts/toolchain.sh +14 -0
- package/standards/conventions.md +68 -0
- package/standards/dod.md +53 -0
- package/standards/stack.md +42 -0
- package/standards/stacks/nestjs/architecture.md +75 -0
- package/standards/stacks/nestjs/conventions.md +33 -0
- package/standards/stacks/nestjs/stack.md +34 -0
- package/standards/stacks/nestjs/testing.md +31 -0
- package/standards/stacks/nextjs/architecture.md +97 -0
- package/standards/stacks/nextjs/conventions.md +32 -0
- package/standards/stacks/nextjs/stack.md +40 -0
- package/standards/stacks/nextjs/testing.md +33 -0
- package/standards/testing.md +58 -0
- package/templates/.claude/hooks/stop-gates.sh +43 -0
- package/templates/.claude/settings.json +72 -0
- package/templates/.github/dependabot.yml +19 -0
- package/templates/.github/workflows/gates.yml +41 -0
- package/templates/.harness/db-smoke.sh +75 -0
- package/templates/.harness/deps.sh +219 -0
- package/templates/.harness/gates.conf.example +31 -0
- package/templates/.harness/gates.sh +108 -0
- package/templates/.harness/lib.sh +169 -0
- package/templates/.harness/loop.sh +169 -0
- package/templates/.harness/model-flags.sh +34 -0
- package/templates/.harness/models.conf +31 -0
- package/templates/.harness/notify.sh +49 -0
- package/templates/.harness/openspec.sh +28 -0
- package/templates/.harness/pr-scope.sh +44 -0
- package/templates/.harness/run-tasks.sh +461 -0
- package/templates/.harness/spec-pr.sh +157 -0
- package/templates/.harness/status.sh +155 -0
- package/templates/CLAUDE.md.tmpl +159 -0
- package/templates/openspec/config.yaml +38 -0
- package/templates/stacks/nestjs/gates.conf +35 -0
- package/templates/stacks/nestjs/gates.yml +71 -0
- package/templates/stacks/nextjs/claude-imports.md +4 -0
- package/templates/stacks/nextjs/gates.conf +36 -0
- 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"
|