@data-matters.io/agent-harness 0.0.0-stage → 0.1.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.
Files changed (41) hide show
  1. package/README.md +34 -2
  2. package/bin/harness +338 -0
  3. package/package.json +28 -3
  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/workflows/gates.yml +41 -0
  21. package/templates/.harness/db-smoke.sh +75 -0
  22. package/templates/.harness/deps.sh +219 -0
  23. package/templates/.harness/gates.conf.example +26 -0
  24. package/templates/.harness/gates.sh +108 -0
  25. package/templates/.harness/lib.sh +129 -0
  26. package/templates/.harness/loop.sh +101 -0
  27. package/templates/.harness/model-flags.sh +34 -0
  28. package/templates/.harness/models.conf +30 -0
  29. package/templates/.harness/notify.sh +49 -0
  30. package/templates/.harness/openspec.sh +28 -0
  31. package/templates/.harness/pr-scope.sh +44 -0
  32. package/templates/.harness/run-tasks.sh +422 -0
  33. package/templates/.harness/spec-pr.sh +103 -0
  34. package/templates/.harness/status.sh +155 -0
  35. package/templates/CLAUDE.md.tmpl +137 -0
  36. package/templates/openspec/config.yaml +38 -0
  37. package/templates/stacks/nestjs/gates.conf +32 -0
  38. package/templates/stacks/nestjs/gates.yml +71 -0
  39. package/templates/stacks/nextjs/claude-imports.md +4 -0
  40. package/templates/stacks/nextjs/gates.conf +33 -0
  41. package/templates/stacks/nextjs/gates.yml +68 -0
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Prints the Claude Code flags for a task type, from .harness/models.conf
3
+ # (ADR 0020). For anything that starts sessions outside the harness scripts,
4
+ # e.g. a spec-authoring session started by another tool (ADR 0020):
5
+ #
6
+ # claude $(.harness/model-flags.sh spec_author) -p "..."
7
+ #
8
+ # Usage: .harness/model-flags.sh <task-type> [<level override>]
9
+ # .harness/model-flags.sh --level <task-type> [<level override>]
10
+ # .harness/model-flags.sh --list
11
+ #
12
+ # Prints nothing while HARNESS_MODEL_ROUTING is not "on" (measure mode): the
13
+ # session then keeps the account's default model. Reads the machine env file
14
+ # (HARNESS_ENV_FILE, default ~/.config/harness/env) for HARNESS_MODEL_ROUTING
15
+ # and HARNESS_MODELS_FILE. Exit 1 on an unknown task type or level.
16
+ set -euo pipefail
17
+
18
+ usage() { sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
19
+ # shellcheck source-path=SCRIPTDIR source=lib.sh
20
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
21
+ harness_load_env
22
+
23
+ case "${1:-}" in
24
+ ""|-h|--help) usage 0 ;;
25
+ --level) shift; harness_task_level "$@" ;;
26
+ --list)
27
+ (
28
+ harness_models
29
+ echo "routing: ${HARNESS_MODEL_ROUTING:-measure}"
30
+ for l in R4 R3 R2 R1; do echo "$l: ${!l}"; done
31
+ compgen -v TASK_ | while read -r v; do echo "${v#TASK_}: ${!v}"; done
32
+ ) ;;
33
+ *) harness_claude_flags "$@" ;;
34
+ esac
@@ -0,0 +1,30 @@
1
+ # Reasoning level → Claude Code flags, and task type → default level (ADR 0020).
2
+ # The only place model choices live; scripts call harness_claude_flags
3
+ # (.harness/lib.sh) or .harness/model-flags.sh. Aliases by default, so a model
4
+ # release is picked up without editing this file; pin a full model id on a
5
+ # level once the ledger has calibrated it.
6
+ #
7
+ # Overrides, applied in this order after this file:
8
+ # .harness/models.local.conf per project (project-owned, never synced)
9
+ # $HARNESS_MODELS_FILE per machine (e.g. no Fable: R4="--model opus --effort xhigh")
10
+ #
11
+ # Rollout (decision 7): with HARNESS_MODEL_ROUTING=measure (the default) the
12
+ # levels are computed and recorded but no flags are passed, so sessions keep
13
+ # the account's default model; set HARNESS_MODEL_ROUTING=on (env file) to switch.
14
+ # shellcheck shell=bash disable=SC2034
15
+
16
+ R4="--model fable --effort high" # design: specs, decisions no gate checks
17
+ R3="--model opus --effort high" # hard: ambiguous work, unclear diagnosis
18
+ R2="--model sonnet --effort medium" # build: a well-specified, gate-verified task
19
+ R1="--model haiku --effort medium" # routine: a mechanical, local edit
20
+
21
+ TASK_spec_author=R4 # /opsx:propose in an interactive or channel session
22
+ TASK_spec_adjust=R4 # spec PR review comments (watcher, ADR 0017)
23
+ TASK_impl=R2 # task loop (run-tasks.sh); tasks.md may override
24
+ TASK_conflict=R3 # merge conflicts on a PR (watcher, ADR 0019)
25
+ TASK_ci_fix=R2 # failing checks, clear cause (watcher, ADR 0019)
26
+ TASK_ci_fix_retry=R3 # failing checks after a failed fix (watcher, ADR 0019)
27
+ TASK_review_comments=R2 # owner comments on implementation/sync PRs (ADR 0019, 1b)
28
+ TASK_diagnosis=R3 # explaining an escalation
29
+ TASK_review=R3 # PR review
30
+ TASK_routine=R1 # hand edits around sync and archive
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # Sends a harness notification: always to .harness/logs/notifications.log, and to
3
+ # Telegram when HARNESS_TELEGRAM_BOT_TOKEN and HARNESS_TELEGRAM_CHAT_ID are set
4
+ # (in the environment or in HARNESS_ENV_FILE, default ~/.config/harness/env).
5
+ #
6
+ # Usage: .harness/notify.sh <event> <message>
7
+ # event: started | task-passed | escalated | paused | resumed | done | error
8
+ #
9
+ # Never fails the caller: a notification problem is logged, not raised.
10
+ set -uo pipefail
11
+
12
+ event="${1:-info}"
13
+ message="${2:-}"
14
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15
+ project="$(basename "$root")"
16
+
17
+ # HARNESS_NOTIFY_LOG: another log file, for events that belong to no project
18
+ # (the watcher's runtime, ADR 0019).
19
+ log_file="${HARNESS_NOTIFY_LOG:-$root/.harness/logs/notifications.log}"
20
+ mkdir -p "$(dirname "$log_file")"
21
+ printf '%s\t%s\t%s\n' "$(date -Iseconds)" "$event" "$message" >>"$log_file"
22
+
23
+ env_file="${HARNESS_ENV_FILE:-$HOME/.config/harness/env}"
24
+ if [[ -z "${HARNESS_TELEGRAM_BOT_TOKEN:-}" && -f "$env_file" ]]; then
25
+ # shellcheck source=/dev/null
26
+ . "$env_file"
27
+ fi
28
+ token="${HARNESS_TELEGRAM_BOT_TOKEN:-}"
29
+ chat="${HARNESS_TELEGRAM_CHAT_ID:-}"
30
+ [[ -n "$token" && -n "$chat" ]] || exit 0
31
+
32
+ case "$event" in
33
+ started) icon="▶️" ;;
34
+ task-passed) icon="✅" ;;
35
+ done) icon="🎉" ;;
36
+ escalated) icon="🛑" ;;
37
+ paused) icon="⏸️" ;;
38
+ resumed) icon="🔁" ;;
39
+ error) icon="❌" ;;
40
+ *) icon="ℹ️" ;;
41
+ esac
42
+
43
+ if ! curl -fsS --max-time 15 "https://api.telegram.org/bot${token}/sendMessage" \
44
+ --data-urlencode "chat_id=${chat}" \
45
+ --data-urlencode "text=${icon} [${project}] ${message}" \
46
+ --data-urlencode "disable_web_page_preview=true" >/dev/null 2>&1; then
47
+ printf '%s\t%s\t%s\n' "$(date -Iseconds)" "notify-failed" "telegram sendMessage failed" >>"$log_file"
48
+ fi
49
+ exit 0
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Runs the project's pinned OpenSpec CLI (ADR 0013). Same arguments as `openspec`.
3
+ #
4
+ # Usage: .harness/openspec.sh <openspec args...> e.g. validate my-change --strict
5
+ #
6
+ # OpenSpec is a devDependency with an exact version, so every machine and CI
7
+ # run use the same CLI. There is no global install and no npx fallback: when
8
+ # the package is missing or not pinned exactly, this fails and says how to fix it.
9
+ set -euo pipefail
10
+
11
+ # The version the harness was validated against. Projects may pin another one
12
+ # deliberately; this only warns.
13
+ validated="1.14.1"
14
+ pkg="@fission-ai/openspec"
15
+
16
+ die() { echo "openspec: $*" >&2; exit 1; }
17
+
18
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19
+ [[ -f "$root/package.json" ]] || die "no package.json in $root"
20
+ pin="$(grep -oE "\"$pkg\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$root/package.json" | head -n1 | sed -E 's/.*"([^"]*)"$/\1/' || true)"
21
+ [[ -n "$pin" ]] || die "$pkg is not a devDependency; add it with: pnpm add -D -E $pkg@$validated"
22
+ [[ "$pin" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "$pkg must be pinned to an exact version, not '$pin': pnpm add -D -E $pkg@$validated"
23
+ [[ "$pin" == "$validated" ]] || echo "openspec: note: $pkg $pin is pinned; the harness was validated with $validated" >&2
24
+
25
+ bin="$root/node_modules/.bin/openspec"
26
+ [[ -x "$bin" ]] || die "$bin not found; install dependencies first (pnpm install)"
27
+ export OPENSPEC_TELEMETRY="${OPENSPEC_TELEMETRY:-0}"
28
+ exec "$bin" "$@"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Classifies a pull request for CI (ADR 0011, ADR 0013). Prints `spec_only=true`
3
+ # or `spec_only=false`, ready to append to $GITHUB_OUTPUT.
4
+ #
5
+ # Usage: .harness/pr-scope.sh >>"$GITHUB_OUTPUT"
6
+ #
7
+ # spec_only=true when the event is a pull request and every changed file is under
8
+ # openspec/: CI then validates the specs instead of running the DoD gates and the
9
+ # heavy jobs. Pushes, and anything else, are never spec-only.
10
+ #
11
+ # A spec PR (head branch spec/<change-id>) may only change
12
+ # openspec/changes/<change-id>/; otherwise this fails, which fails the job.
13
+ #
14
+ # Environment (set by GitHub Actions):
15
+ # GITHUB_EVENT_NAME pull_request → classify; anything else → spec_only=false
16
+ # GITHUB_HEAD_REF head branch of the pull request
17
+ # PR_SCOPE_BASE commit to diff against (default HEAD^1: on a pull_request
18
+ # run HEAD is the merge commit and HEAD^1 its base, so the
19
+ # checkout needs fetch-depth: 2)
20
+ set -euo pipefail
21
+
22
+ if [[ "${GITHUB_EVENT_NAME:-}" != pull_request ]]; then
23
+ echo "spec_only=false"
24
+ exit 0
25
+ fi
26
+
27
+ changed="$(git diff --name-only "${PR_SCOPE_BASE:-HEAD^1}" HEAD)"
28
+ head_ref="${GITHUB_HEAD_REF:-}"
29
+
30
+ if [[ "$head_ref" == spec/* ]]; then
31
+ allowed="openspec/changes/${head_ref#spec/}/"
32
+ outside="$(grep -v "^$allowed" <<<"$changed" || true)"
33
+ if [[ -n "$outside" ]]; then
34
+ echo "::error::A spec PR ($head_ref) may only change $allowed. Changed outside it:" >&2
35
+ printf '%s\n' "$outside" >&2
36
+ exit 1
37
+ fi
38
+ fi
39
+
40
+ if [[ -n "$changed" ]] && ! grep -qv '^openspec/' <<<"$changed"; then
41
+ echo "spec_only=true"
42
+ else
43
+ echo "spec_only=false"
44
+ fi
@@ -0,0 +1,422 @@
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).
15
+ # Before the first task it installs the dependencies the spec declares
16
+ # (.harness/deps.sh, ADR 0015) in their own commit; an agent that changes
17
+ # dependency files escalates the run.
18
+ #
19
+ # Usage: .harness/run-tasks.sh <change-id> [--max-attempts N] [--open-pr] [--force] [--check]
20
+ #
21
+ # Only approved changes run: the change must be on the base branch, merged there
22
+ # through its spec PR (ADR 0011), and the change files on the work branch must
23
+ # match the approved ones (tasks.md checkboxes aside). Work happens on
24
+ # change/<id>, created from the base branch; --open-pr links the spec PR.
25
+ # --check runs the preconditions only (approval, autonomy, clean worktree) and exits.
26
+ #
27
+ # The loop runs in a git worktree of its own (ADR 0012), never in the checkout it
28
+ # is started from: see harness_worktree in .harness/lib.sh for the location.
29
+ # Dependencies are installed there on first use. State and logs stay in the main
30
+ # checkout (.harness/state, .harness/logs). The worktree is removed once every
31
+ # task is done; on escalation or pause it is kept for a human or the next run.
32
+ #
33
+ # Exit codes: 0 all tasks done, 2 a task escalated after N attempts,
34
+ # 3 paused because the agent hit the account usage limit.
35
+ #
36
+ # Environment:
37
+ # HARNESS_BASE_BRANCH branch holding approved specs (default main)
38
+ # HARNESS_WORKTREES_DIR parent directory of the loop worktrees
39
+ # HARNESS_WORKTREE_SETUP command run once in a new worktree (default: install
40
+ # dependencies from the lockfile); HARNESS_MAIN_CHECKOUT
41
+ # points at the main checkout, e.g. to copy a .env
42
+ # HARNESS_MAX_ATTEMPTS attempts per task (default 4: the level ladder, ADR 0020)
43
+ # HARNESS_TASK_TIMEOUT max seconds per agent session (default 1800)
44
+ # HARNESS_PERMISSION_MODE claude --permission-mode (default acceptEdits)
45
+ # HARNESS_CLAUDE_ARGS extra args for claude (experiments; models come from models.conf)
46
+ # HARNESS_COMMIT_TYPE conventional commit type (default feat)
47
+ set -euo pipefail
48
+
49
+ usage() { sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
50
+ die() { echo "run-tasks: $*" >&2; exit 1; }
51
+
52
+ [[ $# -ge 1 ]] || usage 1
53
+ [[ "$1" == "-h" || "$1" == "--help" ]] && usage 0
54
+ change="$1"; shift
55
+
56
+ max_attempts="${HARNESS_MAX_ATTEMPTS:-4}"
57
+ timeout_s="${HARNESS_TASK_TIMEOUT:-1800}"
58
+ permission_mode="${HARNESS_PERMISSION_MODE:-acceptEdits}"
59
+ commit_type="${HARNESS_COMMIT_TYPE:-feat}"
60
+ open_pr=0
61
+ force=0
62
+ check_only=0
63
+ while [[ $# -gt 0 ]]; do
64
+ case "$1" in
65
+ --max-attempts) max_attempts="$2"; shift 2 ;;
66
+ --open-pr) open_pr=1; shift ;;
67
+ --force) force=1; shift ;;
68
+ --check) check_only=1; shift ;;
69
+ *) die "unknown option: $1" ;;
70
+ esac
71
+ done
72
+
73
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"
74
+ # shellcheck source-path=SCRIPTDIR source=lib.sh
75
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
76
+ root="$(harness_root)"
77
+ cd "$root"
78
+ wt="$(harness_worktree "$change")"
79
+ # Machine settings (skipped gates, TELEGRAM_STATE_DIR for the agent sessions)
80
+ # also when run-tasks.sh is started directly instead of through loop.sh.
81
+ harness_load_env
82
+
83
+ change_dir="openspec/changes/$change"
84
+ tasks="$change_dir/tasks.md"
85
+ [[ -x .harness/gates.sh ]] || die ".harness/gates.sh missing or not executable"
86
+ command -v claude >/dev/null || die "claude CLI not found"
87
+
88
+ autonomy="$(cat .harness/autonomy 2>/dev/null || echo disabled)"
89
+ if [[ "$autonomy" != "enabled" && $force -eq 0 ]]; then
90
+ die "autonomy is '$autonomy' (.harness/autonomy). Add characterization tests first, or pass --force."
91
+ fi
92
+
93
+ # The spec is approved when its spec PR is merged: the change is on the base
94
+ # branch. Compare against the remote copy when there is one, so a stale local
95
+ # branch neither hides an approval nor fakes one.
96
+ base_branch="${HARNESS_BASE_BRANCH:-main}"
97
+ base_ref="$base_branch"
98
+ if git remote get-url origin >/dev/null 2>&1; then
99
+ git fetch -q origin "$base_branch" || die "cannot fetch origin/$base_branch"
100
+ base_ref="origin/$base_branch"
101
+ fi
102
+ git rev-parse -q --verify "$base_ref^{commit}" >/dev/null || die "base branch $base_ref not found"
103
+ if ! git cat-file -e "$base_ref:$tasks" 2>/dev/null; then
104
+ 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)."
105
+ fi
106
+
107
+ branch="change/$change"
108
+ if git rev-parse -q --verify "refs/heads/$branch" >/dev/null; then
109
+ start_ref="$branch"
110
+ else
111
+ start_ref="$base_ref"
112
+ fi
113
+ if [[ -d "$wt" ]]; then
114
+ [[ "$(git -C "$wt" branch --show-current 2>/dev/null)" == "$branch" ]] \
115
+ || die "$wt exists but is not a worktree on $branch; remove it (git worktree remove) and re-run"
116
+ [[ -z "$(git -C "$wt" status --porcelain)" ]] \
117
+ || die "worktree $wt has uncommitted work (left by an escalation?). Finish or discard it there first."
118
+ fi
119
+
120
+ # The work branch implements exactly the approved spec: every change file
121
+ # matches the base branch, except for the checkboxes the loop ticks in tasks.md.
122
+ unchecked() { sed -E 's/^([[:space:]]*- )\[x\] /\1[ ] /'; }
123
+ if ! git diff --quiet "$base_ref" "$start_ref" -- "$change_dir" ":(exclude)$tasks" \
124
+ || ! cmp -s <(git show "$base_ref:$tasks" | unchecked) <(git show "$start_ref:$tasks" 2>/dev/null | unchecked); then
125
+ 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."
126
+ fi
127
+
128
+ if [[ $check_only -eq 1 ]]; then
129
+ echo "run-tasks: change $change is approved and ready to run on $branch in $wt."
130
+ exit 0
131
+ fi
132
+
133
+ # Work in the change's own worktree, so the checkout the loop was started from
134
+ # (where specs are written) is never switched or cleaned under anyone's feet.
135
+ if [[ ! -d "$wt" ]]; then
136
+ mkdir -p "$(dirname "$wt")"
137
+ if [[ "$start_ref" == "$branch" ]]; then
138
+ git worktree add -q "$wt" "$branch" \
139
+ || die "cannot check out $branch in $wt; if it is checked out elsewhere (git worktree list), switch that checkout away"
140
+ else
141
+ git worktree add -q --no-track -b "$branch" "$wt" "$base_ref" || die "cannot create worktree $wt"
142
+ fi
143
+ setup="${HARNESS_WORKTREE_SETUP:-}"
144
+ if [[ -z "$setup" ]]; then
145
+ if [[ -f "$wt/pnpm-lock.yaml" ]]; then setup="pnpm install --frozen-lockfile"
146
+ elif [[ -f "$wt/package-lock.json" ]]; then setup="npm ci"
147
+ fi
148
+ fi
149
+ if [[ -n "$setup" ]]; then
150
+ mkdir -p ".harness/logs/$change"
151
+ echo "==> worktree setup: $setup"
152
+ if ! (cd "$wt" && HARNESS_MAIN_CHECKOUT="$root" bash -c "$setup") >".harness/logs/$change/worktree-setup.log" 2>&1; then
153
+ # Without its dependencies the worktree is useless; the next run starts over.
154
+ git worktree remove --force "$wt"
155
+ die "worktree setup failed ($setup); see .harness/logs/$change/worktree-setup.log"
156
+ fi
157
+ fi
158
+ # A harness installed from the package (ADR 0021): the worktree gets the
159
+ # version its own lockfile pins, even when the install skipped "prepare".
160
+ if [[ -x "$wt/node_modules/.bin/harness" ]]; then
161
+ mkdir -p ".harness/logs/$change"
162
+ if ! (cd "$wt" && node_modules/.bin/harness install) >>".harness/logs/$change/worktree-setup.log" 2>&1; then
163
+ git worktree remove --force "$wt"
164
+ die "harness install failed in the worktree; see .harness/logs/$change/worktree-setup.log"
165
+ fi
166
+ fi
167
+ fi
168
+
169
+ log_dir="$root/.harness/logs/$change"
170
+ mkdir -p "$log_dir" "$root/.harness/state"
171
+
172
+ # Runner state for .harness/status.sh: key=value lines, rewritten on every step.
173
+ state_file="$root/.harness/state/run-$change"
174
+ run_status="running"
175
+ cur_task_id=""
176
+ cur_task=""
177
+ cur_attempt=0
178
+ attempt_started=""
179
+ write_state() {
180
+ {
181
+ echo "status=$run_status"
182
+ echo "pid=$$"
183
+ echo "started=$run_started"
184
+ echo "task_id=$cur_task_id"
185
+ echo "task=$cur_task"
186
+ echo "attempt=$cur_attempt"
187
+ echo "max_attempts=$max_attempts"
188
+ echo "attempt_started=$attempt_started"
189
+ echo "updated=$(date +%s)"
190
+ } >"$state_file"
191
+ }
192
+ run_started="$(date +%s)"
193
+ # Anything that ends the run without setting a final status was interrupted.
194
+ trap '[[ "$run_status" == running ]] && run_status=interrupted; write_state' EXIT
195
+ write_state
196
+ cd "$wt"
197
+ summary="$log_dir/summary.tsv"
198
+ summary_header=$'timestamp\ttask\tattempt\tduration_s\tresult\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens'
199
+ if [[ ! -f "$summary" ]]; then
200
+ printf '%s\n' "$summary_header" >"$summary"
201
+ elif [[ "$(head -n1 "$summary")" == $'timestamp\ttask\tattempt\tduration_s\tresult' ]]; then
202
+ sed -i "1s/.*/$summary_header/" "$summary" # older runs: same first columns, new ones empty
203
+ fi
204
+
205
+ next_task() { grep -n -m1 -E '^[[:space:]]*- \[ \] ' "$tasks" || true; }
206
+
207
+ # Dependencies the approved spec declares (design.md "## Dependencies", ADR 0015)
208
+ # are installed by the loop, never by the agent, in one commit before the tasks.
209
+ # Anything deps.sh refuses (an undeclared change, a version conflict) escalates.
210
+ dep_files=(package.json pnpm-lock.yaml pnpm-workspace.yaml)
211
+ if [[ -x .harness/deps.sh && -f package.json ]]; then
212
+ if ! .harness/deps.sh install "$change_dir" >"$log_dir/deps.log" 2>&1; then
213
+ run_status="escalated"
214
+ echo "run-tasks: the declared dependencies could not be installed; escalating." >&2
215
+ tail -n 20 "$log_dir/deps.log" >&2
216
+ exit 2
217
+ fi
218
+ if [[ -n "$(git status --porcelain -- "${dep_files[@]}")" ]]; then
219
+ git add -- "${dep_files[@]}"
220
+ 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' \
221
+ "$change" "$change_dir" "$(grep '^deps: added' "$log_dir/deps.log" | sed 's/^deps: added //')" | git commit -q -F -
222
+ echo "==> dependencies declared by the spec installed and committed"
223
+ fi
224
+ fi
225
+
226
+ build_prompt() {
227
+ local task="$1" feedback="$2"
228
+ cat <<EOF
229
+ You are running inside the autonomous task loop of agent-harness.
230
+
231
+ Change: $change (files in $change_dir/: proposal.md, design.md if present, specs/, tasks.md)
232
+ Your task (exactly one):
233
+ $task
234
+
235
+ Instructions:
236
+ - Read the proposal, design and delta specs relevant to this task before coding.
237
+ - Implement ONLY this task, following CLAUDE.md and .harness/standards/.
238
+ - Add or update tests that prove the task's behaviour; write the failing test first.
239
+ - Run .harness/gates.sh and iterate until it exits 0.
240
+ - Do NOT edit files in $change_dir/: it is the approved spec. If it is wrong, stop and say so.
241
+ - Do NOT edit tasks.md, do NOT commit, do NOT push. The loop does that.
242
+ - Do NOT add, remove or update packages, and do NOT edit package.json, pnpm-lock.yaml or
243
+ pnpm-workspace.yaml: the loop already installed the dependencies the spec declares. If the
244
+ task needs a package the spec does not declare, stop and name it in your final message.
245
+ - Never change what an existing test asserts to make it match the code. If an existing
246
+ test conflicts with the code, stop and explain the conflict in your final message.
247
+ - If the task cannot be done as specified, stop and explain why in your final message.
248
+ - All artifacts in English.
249
+ EOF
250
+ if [[ -n "$feedback" ]]; then
251
+ cat <<EOF
252
+
253
+ A previous attempt left work in the tree but the gates failed with:
254
+ --------
255
+ $feedback
256
+ --------
257
+ Continue from the current state and fix these failures.
258
+ EOF
259
+ fi
260
+ }
261
+
262
+ # Conventional-commit summary from a task line: drop the task id and backticks,
263
+ # keep the first sentence, and cut at a word boundary to fit 72 chars with the prefix.
264
+ commit_summary() {
265
+ local text max
266
+ text="$(sed -E -e 's/^[0-9]+(\.[0-9]+)*[[:space:]]+//' -e 's/`//g' -e 's/\. .*$//' -e 's/\.$//' <<<"$1")"
267
+ max=$(( 72 - ${#commit_type} - ${#change} - 4 ))
268
+ if (( ${#text} > max )); then
269
+ text="${text:0:max-3}"
270
+ text="${text% *}..."
271
+ fi
272
+ printf '%s' "$text"
273
+ }
274
+
275
+ untrusted_warned=0
276
+ warn_if_untrusted() {
277
+ if [[ $untrusted_warned -eq 0 ]] && grep -q "workspace has not been trusted" "$1" 2>/dev/null; then
278
+ echo " warning: Claude Code ignores .claude/settings.json permissions in this untrusted" >&2
279
+ echo " workspace; the agent cannot run gates itself. Trust it once interactively." >&2
280
+ untrusted_warned=1
281
+ fi
282
+ }
283
+
284
+ # record <task> <attempt> <duration> <result>: one summary row for the attempt,
285
+ # with its level, model, effort, cost and tokens (ADR 0020); the same session
286
+ # also goes to the machine-wide ledger.
287
+ record() {
288
+ local usage effort model cost out cache
289
+ effort="$(harness_flag_value "${level_flags:-}" --effort)"
290
+ usage="$(harness_record_usage "$(basename "$root")" "$change" impl "${level:-}" "${effort:-default}" "$2" "$4" \
291
+ "$log_dir/task-$1-attempt-$2.json")"
292
+ IFS=$'\t' read -r model cost out cache <<<"$usage"
293
+ 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" \
294
+ "${level:-}" "$model" "${effort:-default}" "$cost" "$out" "$cache" >>"$summary"
295
+ }
296
+
297
+ while line="$(next_task)"; [[ -n "$line" ]]; do
298
+ ln="${line%%:*}"
299
+ task="$(sed -E 's/^[[:space:]]*- \[ \] //' <<<"${line#*:}")"
300
+ # The spec author's level for this task (ADR 0020), stripped from the text
301
+ # that becomes the prompt and the commit.
302
+ level_override="$(grep -oE '<!--[[:space:]]*level:[[:space:]]*R[1-4][[:space:]]*-->' <<<"$task" | grep -oE 'R[1-4]' || true)"
303
+ task="$(sed -E 's/[[:space:]]*<!--[[:space:]]*level:[^>]*-->//g' <<<"$task")"
304
+ base_level="$(harness_task_level impl "$level_override")" || base_level="R2"
305
+ gate_fails=0 nochange=0 levels_tried=""
306
+ task_id="$(grep -oE '^[0-9]+(\.[0-9]+)*' <<<"$task" || echo "L$ln")"
307
+ summary_line="$(commit_summary "$task")"
308
+ feedback=""
309
+ echo "==> task $task_id: $task (level $base_level${level_override:+, set by the spec})"
310
+
311
+ done_ok=0
312
+ for (( attempt = 1; attempt <= max_attempts; attempt++ )); do
313
+ # Two attempts at the task's level, then one level up per failure; attempts
314
+ # that changed nothing climb only once (they count as at most two failures).
315
+ fails=$(( gate_fails + (nochange > 2 ? 2 : nochange) ))
316
+ level="$(harness_level_climb "$base_level" $(( fails > 1 ? fails - 1 : 0 )))"
317
+ level_flags="$(harness_level_flags "$level")"
318
+ [[ " $levels_tried " == *" $level "* ]] || levels_tried="${levels_tried:+$levels_tried }$level"
319
+ echo " attempt $attempt/$max_attempts at $level${level_flags:+ ($level_flags)}"
320
+ cur_task_id="$task_id"; cur_task="$summary_line"; cur_attempt="$attempt"; attempt_started="$(date +%s)"
321
+ write_state
322
+ start=$(date +%s)
323
+ # shellcheck disable=SC2086
324
+ build_prompt "$task" "$feedback" | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p \
325
+ --output-format json \
326
+ --permission-mode "$permission_mode" \
327
+ --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
328
+ "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
329
+ ${level_flags} ${HARNESS_CLAUDE_ARGS:-} \
330
+ >"$log_dir/task-$task_id-attempt-$attempt.json" 2>&1 || true
331
+
332
+ warn_if_untrusted "$log_dir/task-$task_id-attempt-$attempt.json"
333
+
334
+ # An account usage limit ends the session early; retrying only burns the
335
+ # remaining attempts on the same error. Pause and let a human resume.
336
+ if grep -qiE "hit your (session|usage) limit|usage limit reached" "$log_dir/task-$task_id-attempt-$attempt.json"; then
337
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" paused
338
+ run_status="paused"
339
+ # The interrupted attempt left partial, unverified work: discard it.
340
+ git checkout -- . && git clean -fdq
341
+ echo "run-tasks: the agent session hit the account usage limit; pausing." >&2
342
+ echo " Task $task_id is not done; its partial attempt was discarded. Re-run after the limit resets." >&2
343
+ exit 3
344
+ fi
345
+
346
+ # The agent must not touch the approved spec; restoring tasks.md also keeps
347
+ # its line numbers valid.
348
+ git checkout -- "$change_dir"
349
+ git clean -fdq -- "$change_dir"
350
+
351
+ # Only the spec declares dependencies: an agent that changed them needs a
352
+ # package the spec does not have. Retrying cannot fix that; a human decides.
353
+ if [[ -n "$(git status --porcelain -- "${dep_files[@]}")" ]]; then
354
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" deps
355
+ run_status="escalated"
356
+ echo "run-tasks: task $task_id changed dependency files; undeclared dependencies need a spec change." >&2
357
+ echo " Work in progress is left uncommitted on branch $branch in $wt." >&2
358
+ exit 2
359
+ fi
360
+
361
+ # A task that changed nothing is not done, even if the gates pass on the old code.
362
+ if [[ -z "$(git status --porcelain -- . ":(exclude)$tasks")" ]]; then
363
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fail
364
+ feedback="The previous attempt changed no files, so the task was not implemented. Implement it."
365
+ nochange=$(( nochange + 1 ))
366
+ echo " FAIL (no changes)"
367
+ continue
368
+ fi
369
+
370
+ if gate_out="$(.harness/gates.sh 2>&1)"; then
371
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" pass
372
+ sed -i "${ln}s/- \[ \]/- [x]/" "$tasks"
373
+ git add -A
374
+ printf '%s(%s): %s\n\nTask %s of OpenSpec change %s, completed by the task loop:\n\n%s\n' \
375
+ "$commit_type" "$change" "$summary_line" "$task_id" "$change" "$task" | fold -s -w 72 | git commit -q -F -
376
+ echo " PASS → committed"
377
+ if [[ -x .harness/notify.sh ]]; then
378
+ .harness/notify.sh task-passed "$change: task $task_id passed — $summary_line"
379
+ fi
380
+ done_ok=1
381
+ break
382
+ fi
383
+
384
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fail
385
+ printf '%s\n' "$gate_out" >"$log_dir/task-$task_id-attempt-$attempt.gates.log"
386
+ feedback="$(tail -n 120 <<<"$gate_out")"
387
+ gate_fails=$(( gate_fails + 1 ))
388
+ echo " FAIL"
389
+ done
390
+
391
+ if [[ $done_ok -eq 0 ]]; then
392
+ run_status="escalated"
393
+ echo "run-tasks: task $task_id failed $max_attempts attempts (levels $levels_tried); escalating." >&2
394
+ echo " Work in progress is left uncommitted on branch $branch in $wt." >&2
395
+ echo " Logs: $log_dir/task-$task_id-attempt-*" >&2
396
+ exit 2
397
+ fi
398
+ done
399
+
400
+ run_status="done"
401
+ cur_task_id=""; cur_task=""; cur_attempt=0; attempt_started=""
402
+ echo "run-tasks: all tasks in $tasks are done."
403
+
404
+ if [[ $open_pr -eq 1 ]]; then
405
+ command -v gh >/dev/null || die "gh CLI not found; cannot open PR"
406
+ git push -u origin "$branch"
407
+ spec_pr="$(gh pr list --state merged --head "spec/$change" --json url --jq '.[0].url // empty' 2>/dev/null || true)"
408
+ body="$(mktemp)"
409
+ {
410
+ echo "Implements OpenSpec change \`$change\`, approved in ${spec_pr:-its spec PR (branch spec/$change; not found)}."
411
+ echo
412
+ cat "$change_dir/proposal.md"
413
+ } >"$body"
414
+ gh pr create --head "$branch" --base "$base_branch" --title "change: $change" --body-file "$body"
415
+ rm -f "$body"
416
+ fi
417
+
418
+ # Everything is committed on $branch: the worktree is no longer needed.
419
+ cd "$root"
420
+ git worktree remove --force "$wt"
421
+ echo "run-tasks: removed worktree $wt (branch $branch keeps the work)."
422
+