@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.
- package/README.md +34 -2
- package/bin/harness +338 -0
- package/package.json +28 -3
- 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/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 +26 -0
- package/templates/.harness/gates.sh +108 -0
- package/templates/.harness/lib.sh +129 -0
- package/templates/.harness/loop.sh +101 -0
- package/templates/.harness/model-flags.sh +34 -0
- package/templates/.harness/models.conf +30 -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 +422 -0
- package/templates/.harness/spec-pr.sh +103 -0
- package/templates/.harness/status.sh +155 -0
- package/templates/CLAUDE.md.tmpl +137 -0
- package/templates/openspec/config.yaml +38 -0
- package/templates/stacks/nestjs/gates.conf +32 -0
- package/templates/stacks/nestjs/gates.yml +71 -0
- package/templates/stacks/nextjs/claude-imports.md +4 -0
- package/templates/stacks/nextjs/gates.conf +33 -0
- 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
|
+
|