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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +43 -2
  2. package/bin/harness +374 -0
  3. package/package.json +30 -4
  4. package/scripts/next-agent-rules.sh +67 -0
  5. package/scripts/toolchain.sh +14 -0
  6. package/standards/conventions.md +68 -0
  7. package/standards/dod.md +53 -0
  8. package/standards/stack.md +42 -0
  9. package/standards/stacks/nestjs/architecture.md +75 -0
  10. package/standards/stacks/nestjs/conventions.md +33 -0
  11. package/standards/stacks/nestjs/stack.md +34 -0
  12. package/standards/stacks/nestjs/testing.md +31 -0
  13. package/standards/stacks/nextjs/architecture.md +97 -0
  14. package/standards/stacks/nextjs/conventions.md +32 -0
  15. package/standards/stacks/nextjs/stack.md +40 -0
  16. package/standards/stacks/nextjs/testing.md +33 -0
  17. package/standards/testing.md +58 -0
  18. package/templates/.claude/hooks/stop-gates.sh +43 -0
  19. package/templates/.claude/settings.json +72 -0
  20. package/templates/.github/dependabot.yml +19 -0
  21. package/templates/.github/workflows/gates.yml +41 -0
  22. package/templates/.harness/db-smoke.sh +75 -0
  23. package/templates/.harness/deps.sh +219 -0
  24. package/templates/.harness/gates.conf.example +31 -0
  25. package/templates/.harness/gates.sh +108 -0
  26. package/templates/.harness/lib.sh +169 -0
  27. package/templates/.harness/loop.sh +169 -0
  28. package/templates/.harness/model-flags.sh +34 -0
  29. package/templates/.harness/models.conf +31 -0
  30. package/templates/.harness/notify.sh +49 -0
  31. package/templates/.harness/openspec.sh +28 -0
  32. package/templates/.harness/pr-scope.sh +44 -0
  33. package/templates/.harness/run-tasks.sh +461 -0
  34. package/templates/.harness/spec-pr.sh +157 -0
  35. package/templates/.harness/status.sh +155 -0
  36. package/templates/CLAUDE.md.tmpl +159 -0
  37. package/templates/openspec/config.yaml +38 -0
  38. package/templates/stacks/nestjs/gates.conf +35 -0
  39. package/templates/stacks/nestjs/gates.yml +71 -0
  40. package/templates/stacks/nextjs/claude-imports.md +4 -0
  41. package/templates/stacks/nextjs/gates.conf +36 -0
  42. package/templates/stacks/nextjs/gates.yml +68 -0
@@ -0,0 +1,41 @@
1
+ name: gates
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ gates:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ # fetch-depth 2: HEAD is the PR merge commit and HEAD^1 its base, enough
13
+ # to list the files the pull request changes.
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 2
17
+
18
+ # TODO: toolchain setup and dependency install for this project's stack
19
+ # (see .harness/standards/stack.md): actions/setup-node, then
20
+ # pnpm/action-setup@v6.1.0 (reads package.json#packageManager; ADR 0014)
21
+ # and `pnpm install --frozen-lockfile`. Every step below needs it: OpenSpec
22
+ # is a pinned devDependency, and a harness installed from the package
23
+ # (ADR 0021) generates .harness/ during the install.
24
+
25
+ - name: Check the harness
26
+ run: if [[ -x node_modules/.bin/harness ]]; then node_modules/.bin/harness check; fi
27
+
28
+ # Spec-only PRs (ADR 0011, 0013) validate the specs instead of running the
29
+ # DoD gates; pr-scope.sh also fails a spec/<id> PR that changes anything
30
+ # outside openspec/changes/<id>/. Pushes to main always run the gates.
31
+ - name: Classify pull request
32
+ id: scope
33
+ run: .harness/pr-scope.sh >>"$GITHUB_OUTPUT"
34
+
35
+ - name: Validate OpenSpec changes and specs
36
+ if: steps.scope.outputs.spec_only == 'true'
37
+ run: .harness/openspec.sh validate --all --strict --no-interactive
38
+
39
+ - name: Run DoD gates
40
+ if: steps.scope.outputs.spec_only != 'true'
41
+ run: .harness/gates.sh
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Database smoke gate (ADR 0016): applies the migrations and runs the seed on an
3
+ # empty PostgreSQL, through the same Prisma setup the app uses. Unit tests mock
4
+ # the database, so this is the gate that sees a broken migration, seed or driver.
5
+ #
6
+ # Usage: .harness/db-smoke.sh <migrate-command> <seed-command> [<check-command>]
7
+ # e.g. in gates.conf:
8
+ # GATE_EXTRA=("db:.harness/db-smoke.sh '$bin/prisma migrate deploy' '$bin/prisma db seed'")
9
+ #
10
+ # A seed that does not run fails too: `prisma db seed` exits 0 when no seed is
11
+ # configured, so its output must show that the seed command was executed.
12
+ #
13
+ # Database: HARNESS_DB_SMOKE_URL when set (CI: the postgres service of gates.yml),
14
+ # otherwise a throwaway container (docker or podman) removed afterwards. Without
15
+ # a container runtime it fails; skip it on such a machine with
16
+ # HARNESS_SKIP_GATES=db (CI still runs it). The commands get DATABASE_URL.
17
+ #
18
+ # Environment:
19
+ # HARNESS_DB_SMOKE_URL use this database instead of a container
20
+ # HARNESS_DB_SMOKE_IMAGE container image (default postgres:16-alpine)
21
+ set -euo pipefail
22
+
23
+ usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
24
+ die() { echo "db-smoke: $*" >&2; exit 1; }
25
+
26
+ [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage 0
27
+ [[ $# -ge 2 ]] || usage 1
28
+
29
+ url="${HARNESS_DB_SMOKE_URL:-}"
30
+ runtime="" container=""
31
+ cleanup() { if [[ -n "$container" ]]; then "$runtime" rm -f "$container" >/dev/null 2>&1 || true; fi; }
32
+ trap cleanup EXIT
33
+
34
+ if [[ -z "$url" ]]; then
35
+ for candidate in docker podman; do
36
+ if command -v "$candidate" >/dev/null 2>&1 && "$candidate" info >/dev/null 2>&1; then runtime="$candidate"; break; fi
37
+ done
38
+ [[ -n "$runtime" ]] || die "no usable container runtime; set HARNESS_SKIP_GATES=db on this machine (CI still runs it)"
39
+ password="smoke$RANDOM$RANDOM"
40
+ container="$("$runtime" run -d --rm -e POSTGRES_USER=smoke -e POSTGRES_PASSWORD="$password" -e POSTGRES_DB=smoke \
41
+ -p 127.0.0.1::5432 "${HARNESS_DB_SMOKE_IMAGE:-postgres:16-alpine}")" || die "could not start a PostgreSQL container"
42
+ port="$("$runtime" port "$container" 5432/tcp | head -n1 | sed 's/.*://')"
43
+ # TCP only answers once the image's init server is gone and the real one is up.
44
+ for (( i = 0; i < 60; i++ )); do
45
+ "$runtime" exec "$container" pg_isready -q -h 127.0.0.1 -U smoke -d smoke 2>/dev/null && break
46
+ sleep 1
47
+ done
48
+ "$runtime" exec "$container" pg_isready -q -h 127.0.0.1 -U smoke -d smoke || die "PostgreSQL did not become ready"
49
+ url="postgresql://smoke:$password@127.0.0.1:$port/smoke"
50
+ fi
51
+
52
+ export DATABASE_URL="$url"
53
+ # Prisma runs the seed command from its config (e.g. `tsx db/seed.ts`) the way
54
+ # pnpm scripts do: with the project's binaries on PATH.
55
+ export PATH="$PWD/node_modules/.bin:$PATH"
56
+ echo "db-smoke: migrate: $1"
57
+ bash -c "$1" || die "migrations failed"
58
+ echo "db-smoke: seed: $2"
59
+ seed_log="$(mktemp)"
60
+ trap 'rm -f "$seed_log"; cleanup' EXIT
61
+ bash -c "$2" 2>&1 | tee "$seed_log"
62
+ [[ "${PIPESTATUS[0]}" -eq 0 ]] || die "seed failed"
63
+ # `prisma db seed` exits 0 when no seed is configured (Prisma 7 reads only
64
+ # migrations.seed in prisma.config.ts): that is a seed that never ran.
65
+ if grep -qi 'No seed command configured' "$seed_log"; then
66
+ die "no seed ran: Prisma found no seed command (Prisma 7 reads migrations.seed in prisma.config.ts)"
67
+ fi
68
+ if [[ "$2" == *"prisma db seed"* ]] && ! grep -q 'seed command has been executed' "$seed_log"; then
69
+ die "no seed ran: Prisma did not report executing the seed command"
70
+ fi
71
+ if [[ -n "${3:-}" ]]; then
72
+ echo "db-smoke: check: $3"
73
+ bash -c "$3" || die "check failed"
74
+ fi
75
+ echo "db-smoke: migrations and seed passed"
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env bash
2
+ # Dependencies an approved spec declares (ADR 0015): checks the declaration and
3
+ # installs exactly those packages, touching nothing else.
4
+ #
5
+ # Usage: .harness/deps.sh check <change-dir> validate the declaration (spec-pr.sh)
6
+ # .harness/deps.sh list <change-dir> print it, one "kind name version" per line
7
+ # .harness/deps.sh install <change-dir> add what package.json lacks (run-tasks.sh)
8
+ #
9
+ # The declaration is the "## Dependencies" section of <change-dir>/design.md.
10
+ # Every bullet in it is one of:
11
+ # - dependency: <name>@<x.y.z>
12
+ # - devDependency: <name>@<x.y.z>
13
+ # - allow-build: <name> (lets an installed package run its install script)
14
+ # Values may be in backticks; other text in the section is prose.
15
+ #
16
+ # install runs `pnpm add --save-exact` without a terminal. A package with an
17
+ # install script that is not declared as allow-build is denied in
18
+ # pnpm-workspace.yaml (`allowBuilds: <name>: false`). Afterwards it verifies that
19
+ # only the declared entries were added to package.json, that no locked package
20
+ # was removed or changed version, and that pnpm-workspace.yaml only gained
21
+ # allowBuilds entries. On any surprise it restores the files and exits 2.
22
+ #
23
+ # Exit codes: 0 ok, 1 usage or invalid declaration, 2 install refused.
24
+ #
25
+ # Environment:
26
+ # HARNESS_DEPS_TIMEOUT seconds per pnpm call (default 600)
27
+ # HARNESS_DEPS_OFFLINE 1 = check skips the registry lookup
28
+ set -euo pipefail
29
+
30
+ usage() { sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
31
+ die() { echo "deps: $*" >&2; exit 1; }
32
+ refuse() { echo "deps: $*" >&2; exit 2; }
33
+
34
+ [[ $# -eq 2 ]] || usage 1
35
+ cmd="$1"
36
+ change_dir="${2%/}"
37
+ design="$change_dir/design.md"
38
+
39
+ name_re='(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*'
40
+ version_re='[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?'
41
+
42
+ # section: the lines of "## Dependencies" in design.md (empty when absent).
43
+ section() {
44
+ [[ -f "$design" ]] || return 0
45
+ awk '/^## /{ on = ($0 ~ /^## Dependencies[[:space:]]*$/); next } on' "$design"
46
+ }
47
+
48
+ # parse: prints "kind name version" per declared entry (version "-" for
49
+ # allow-build); fails on a bullet that does not follow the grammar.
50
+ parse() {
51
+ local line body kind value bad=0
52
+ while IFS= read -r line; do
53
+ [[ "$line" =~ ^[[:space:]]*[-*][[:space:]]+(.*)$ ]] || continue
54
+ body="${BASH_REMATCH[1]//\`/}"
55
+ if [[ "$body" =~ ^(dependency|devDependency|allow-build):[[:space:]]*([^[:space:]]+)[[:space:]]*$ ]]; then
56
+ kind="${BASH_REMATCH[1]}"
57
+ value="${BASH_REMATCH[2]}"
58
+ if [[ "$kind" == allow-build ]]; then
59
+ if [[ "$value" =~ ^${name_re}$ ]]; then echo "$kind $value -"; continue; fi
60
+ elif [[ "$value" =~ ^(${name_re})@(${version_re})$ ]]; then
61
+ echo "$kind ${BASH_REMATCH[1]} ${BASH_REMATCH[3]}"
62
+ continue
63
+ fi
64
+ fi
65
+ echo "deps: $design: '${line#"${line%%[![:space:]]*}"}' is not '- dependency|devDependency: <name>@<x.y.z>' or '- allow-build: <name>'" >&2
66
+ bad=1
67
+ done < <(section)
68
+ return "$bad"
69
+ }
70
+
71
+ declared="$(parse)" || die "invalid ## Dependencies section in $design"
72
+
73
+ case "$cmd" in
74
+ list)
75
+ [[ -z "$declared" ]] || echo "$declared"
76
+ exit 0 ;;
77
+ check)
78
+ if [[ -n "$declared" && "${HARNESS_DEPS_OFFLINE:-0}" != 1 ]]; then
79
+ while read -r kind name version; do
80
+ [[ "$kind" == allow-build ]] && continue
81
+ found="$(pnpm view "$name@$version" version 2>/dev/null </dev/null || true)"
82
+ [[ "$found" == "$version" ]] || die "$name@$version is not published in the registry"
83
+ done <<<"$declared"
84
+ fi
85
+ echo "deps: $(grep -c . <<<"$declared" || true) declared entr(y/ies) in $design are valid"
86
+ exit 0 ;;
87
+ install) ;;
88
+ *) usage 1 ;;
89
+ esac
90
+
91
+ # ---------------------------------------------------------------- install --
92
+
93
+ [[ -f package.json ]] || die "run it from the project root (no package.json here)"
94
+ [[ -n "$declared" ]] || { echo "deps: nothing declared"; exit 0; }
95
+
96
+ # current <section> <name>: the specifier package.json has for it, if any.
97
+ current() {
98
+ node -e 'const p = require("./package.json"); process.stdout.write((p[process.argv[1]] || {})[process.argv[2]] || "")' "$1" "$2"
99
+ }
100
+
101
+ prod=() dev=() allow=() wanted=()
102
+ while read -r kind name version; do
103
+ case "$kind" in
104
+ allow-build) allow+=("$name"); continue ;;
105
+ dependency) section_name=dependencies other=devDependencies ;;
106
+ devDependency) section_name=devDependencies other=dependencies ;;
107
+ esac
108
+ have="$(current "$section_name" "$name")"
109
+ [[ -z "$(current "$other" "$name")" ]] || refuse "$name is in $other, the spec declares it as $kind"
110
+ if [[ "$have" == "$version" ]]; then continue; fi
111
+ [[ -z "$have" ]] || refuse "package.json has $name $have, the spec declares $version; changing it needs a spec change"
112
+ wanted+=("$section_name $name $version")
113
+ if [[ "$kind" == dependency ]]; then prod+=("$name@$version"); else dev+=("$name@$version"); fi
114
+ done <<<"$declared"
115
+ if [[ ${#wanted[@]} -eq 0 ]]; then
116
+ echo "deps: every declared dependency is already in package.json"
117
+ exit 0
118
+ fi
119
+
120
+ dep_files=(package.json pnpm-lock.yaml pnpm-workspace.yaml)
121
+ [[ -z "$(git status --porcelain -- "${dep_files[@]}")" ]] || refuse "dependency files have uncommitted changes"
122
+ status_before="$(git status --porcelain --untracked-files=all | grep -vE ' (package.json|pnpm-lock.yaml|pnpm-workspace.yaml)$' || true)"
123
+ before="$(mktemp -d)"
124
+ trap 'rm -rf "$before"' EXIT
125
+ cp package.json "$before/package.json"
126
+ [[ -f pnpm-lock.yaml ]] && cp pnpm-lock.yaml "$before/pnpm-lock.yaml"
127
+ [[ -f pnpm-workspace.yaml ]] && cp pnpm-workspace.yaml "$before/pnpm-workspace.yaml"
128
+
129
+ restore() {
130
+ local f
131
+ for f in "${dep_files[@]}"; do
132
+ if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then git checkout -q -- "$f"; else rm -f "$f"; fi
133
+ done
134
+ refuse "$1"
135
+ }
136
+
137
+ # pnpm_add <flags...> -- <specs...>: one non-interactive pnpm add. A package with
138
+ # an undeclared install script is denied and the add is repeated once.
139
+ pnpm_add() {
140
+ local flags=() specs=() out denied=() name a
141
+ while [[ "$1" != -- ]]; do flags+=("$1"); shift; done
142
+ shift
143
+ specs=("$@")
144
+ for a in "${allow[@]}"; do flags+=(--allow-build "$a"); done
145
+ if out="$(CI=1 timeout "${HARNESS_DEPS_TIMEOUT:-600}" pnpm add --save-exact "${flags[@]}" "${specs[@]}" </dev/null 2>&1)"; then
146
+ printf '%s\n' "$out"
147
+ return 0
148
+ fi
149
+ printf '%s\n' "$out"
150
+ grep -q ERR_PNPM_IGNORED_BUILDS <<<"$out" || return 1
151
+ while read -r name; do
152
+ [[ -n "$name" ]] && denied+=(--allow-build "!$name")
153
+ done < <(grep -oE 'Ignored build scripts: .*' <<<"$out" | sed 's/^Ignored build scripts: //' | tr ',' '\n' \
154
+ | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/@[^@]+$//')
155
+ [[ ${#denied[@]} -gt 0 ]] || return 1
156
+ echo "deps: denying install scripts not declared as allow-build:$(printf ' %s' "${denied[@]}" | sed 's/ --allow-build !/ /g')"
157
+ CI=1 timeout "${HARNESS_DEPS_TIMEOUT:-600}" pnpm add --save-exact "${flags[@]}" "${denied[@]}" "${specs[@]}" </dev/null 2>&1
158
+ }
159
+
160
+ if [[ ${#prod[@]} -gt 0 ]]; then pnpm_add -- "${prod[@]}" || restore "pnpm add ${prod[*]} failed"; fi
161
+ if [[ ${#dev[@]} -gt 0 ]]; then pnpm_add --save-dev -- "${dev[@]}" || restore "pnpm add -D ${dev[*]} failed"; fi
162
+
163
+ # ----------------------------------------------------------------- verify --
164
+
165
+ # package.json: exactly the declared entries were added, nothing else changed.
166
+ if ! node -e '
167
+ const fs = require("fs");
168
+ const [beforeFile, ...wanted] = process.argv.slice(1);
169
+ const a = JSON.parse(fs.readFileSync(beforeFile, "utf8")), b = JSON.parse(fs.readFileSync("package.json", "utf8"));
170
+ for (const w of wanted) { const [s, n, v] = w.split(" "); a[s] = { ...(a[s] || {}), [n]: v }; }
171
+ const norm = (o) => JSON.stringify(o, Object.keys(o).sort());
172
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
173
+ const x = a[k], y = b[k];
174
+ const same = (x && typeof x === "object" && y && typeof y === "object") ? norm(x) === norm(y) : JSON.stringify(x) === JSON.stringify(y);
175
+ if (!same) { console.error("deps: package.json field " + k + " differs from the declaration"); process.exit(1); }
176
+ }' "$before/package.json" "${wanted[@]}"; then
177
+ restore "package.json changed beyond the declared dependencies"
178
+ fi
179
+
180
+ # lock_keys <file>: locked packages and snapshots.
181
+ lock_keys() {
182
+ [[ -f "$1" ]] || return 0
183
+ awk '/^---/{ sec = ""; next } /^[A-Za-z]/{ sec = $0; next }
184
+ (sec == "packages:" || sec == "snapshots:") && /^ [^ ]/ { k = $0; sub(/^ +/, "", k); gsub(/'\''/, "", k); print sec k }' "$1" | sort -u
185
+ }
186
+ # importer_versions <file>: "name version" of every direct dependency.
187
+ importer_versions() {
188
+ [[ -f "$1" ]] || return 0
189
+ awk '/^---/{ sec = ""; next } /^[A-Za-z]/{ sec = $0; next }
190
+ sec == "importers:" && /^ [^ ].*:$/ { n = $0; sub(/^ +/, "", n); sub(/:$/, "", n); gsub(/'\''/, "", n); next }
191
+ sec == "importers:" && /^ version: / { v = $0; sub(/^ +version: /, "", v); print n " " v }' "$1" | sort -u
192
+ }
193
+
194
+ removed="$(comm -23 <(lock_keys "$before/pnpm-lock.yaml") <(lock_keys pnpm-lock.yaml))"
195
+ [[ -z "$removed" ]] || restore "the lockfile lost or changed locked packages (other dependencies would move):
196
+ $(head -n 20 <<<"$removed")"
197
+
198
+ added_names=" $(printf '%s\n' "${wanted[@]}" | awk '{ print $2 }' | tr '\n' ' ')"
199
+ moved="$(comm -3 <(importer_versions "$before/pnpm-lock.yaml") <(importer_versions pnpm-lock.yaml) \
200
+ | sed 's/^[[:space:]]*//' | while read -r n v; do [[ "$added_names" == *" $n "* ]] || echo "$n $v"; done)"
201
+ [[ -z "$moved" ]] || restore "direct dependencies outside the declaration changed:
202
+ $moved"
203
+
204
+ if [[ -f pnpm-workspace.yaml ]]; then
205
+ unexpected="$(diff "$before/pnpm-workspace.yaml" pnpm-workspace.yaml 2>/dev/null | grep -E '^[<>]' \
206
+ | grep -vE "^> +'?${name_re}'?: (true|false)$" | grep -vE '^> allowBuilds:$' || true)"
207
+ if [[ ! -f "$before/pnpm-workspace.yaml" ]]; then
208
+ unexpected="$(grep -vE "^ +'?${name_re}'?: (true|false)$|^allowBuilds:$|^$" pnpm-workspace.yaml || true)"
209
+ fi
210
+ [[ -z "$unexpected" ]] || restore "pnpm-workspace.yaml changed beyond allowBuilds entries:
211
+ $unexpected"
212
+ fi
213
+
214
+ others="$(comm -13 <(sort <<<"$status_before") \
215
+ <(git status --porcelain --untracked-files=all | grep -vE ' (package.json|pnpm-lock.yaml|pnpm-workspace.yaml)$' | sort) || true)"
216
+ [[ -z "$others" ]] || restore "pnpm add changed other files:
217
+ $others"
218
+
219
+ echo "deps: added ${wanted[*]}"
@@ -0,0 +1,31 @@
1
+ # Gate commands for this project. Sourced by .harness/gates.sh.
2
+ # Each command runs with `bash -c` from the repo root; exit 0 = pass.
3
+ #
4
+ # Required gates: test, lint, typecheck, build.
5
+ # An empty/unset required gate FAILS. To skip one deliberately, use
6
+ # GATE_X="skip:<reason>"
7
+ # and revisit it: the reason is printed on every run.
8
+
9
+ GATE_TEST=""
10
+ GATE_LINT="" # must include architecture rules (see .harness/standards/stack/architecture.md)
11
+ GATE_TYPECHECK=""
12
+ GATE_BUILD=""
13
+
14
+ # Optional: command run once before the gates (code generation, etc.).
15
+ # Its failure fails the whole run.
16
+ GATE_SETUP=""
17
+
18
+ # Optional: command that formats the OpenSpec files, run from the repo root by
19
+ # the archive PR action (the archive output is not formatted; ADR 0022).
20
+ # Empty = no formatting step.
21
+ FORMAT_SPECS=""
22
+
23
+ # Gates run by the Claude Code Stop hook (fast profile). Empty = all gates.
24
+ # The task loop and CI always run every gate.
25
+ STOP_GATES=()
26
+
27
+ # Optional extra gates, run after the required ones, in order: "name:command".
28
+ GATE_EXTRA=(
29
+ # "coverage:npm run test:coverage -- --coverage.thresholds.lines=80"
30
+ # "security:npm audit --audit-level=high"
31
+ )
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # Runs every Definition-of-Done gate declared in .harness/gates.conf.
3
+ # Exit 0 only if all gates pass. Used by the Stop hook, the task loop and CI.
4
+ #
5
+ # Usage: .harness/gates.sh [--stop] [gate ...]
6
+ # no args run every gate (task loop, CI)
7
+ # --stop run only STOP_GATES from gates.conf (fast profile for the Stop hook)
8
+ # gate ... run only the named gates
9
+ #
10
+ # HARNESS_SKIP_GATES="name ..." skips the named gates on this machine (e.g.
11
+ # integration where no container runtime exists). Skips are printed on every
12
+ # run; CI never sets it.
13
+ set -uo pipefail
14
+
15
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16
+ cd "$root" || exit 1
17
+
18
+ conf="$root/.harness/gates.conf"
19
+ if [[ ! -f "$conf" ]]; then
20
+ echo "gates: missing $conf (copy gates.conf.example and fill it in)" >&2
21
+ exit 1
22
+ fi
23
+
24
+ REQUIRED_GATES=(test lint typecheck build)
25
+ GATE_EXTRA=()
26
+ GATE_SETUP=""
27
+ STOP_GATES=()
28
+ # shellcheck source=/dev/null
29
+ source "$conf"
30
+
31
+ selected=("$@")
32
+ if [[ "${1-}" == "--stop" ]]; then
33
+ selected=("${STOP_GATES[@]}")
34
+ fi
35
+
36
+ log_dir="$root/.harness/state/gates"
37
+ mkdir -p "$log_dir"
38
+ tail_lines="${HARNESS_GATE_TAIL:-60}"
39
+
40
+ # Build the ordered list of (name, command) pairs.
41
+ names=()
42
+ cmds=()
43
+ for gate in "${REQUIRED_GATES[@]}"; do
44
+ var="GATE_${gate^^}"
45
+ names+=("$gate")
46
+ cmds+=("${!var-}")
47
+ done
48
+ for entry in "${GATE_EXTRA[@]}"; do
49
+ names+=("${entry%%:*}")
50
+ cmds+=("${entry#*:}")
51
+ done
52
+
53
+ is_selected() {
54
+ [[ ${#selected[@]} -eq 0 ]] && return 0
55
+ local s
56
+ for s in "${selected[@]}"; do [[ "$s" == "$1" ]] && return 0; done
57
+ return 1
58
+ }
59
+
60
+ # Setup runs before any gate (e.g. code generation); its failure fails everything.
61
+ if [[ -n "$GATE_SETUP" ]]; then
62
+ if ! bash -c "$GATE_SETUP" >"$log_dir/setup.log" 2>&1; then
63
+ echo "FAIL setup \$ $GATE_SETUP"
64
+ tail -n "$tail_lines" "$log_dir/setup.log"
65
+ echo "gates: FAILED (setup)"
66
+ exit 1
67
+ fi
68
+ fi
69
+
70
+ failed=()
71
+ for i in "${!names[@]}"; do
72
+ name="${names[$i]}"
73
+ cmd="${cmds[$i]}"
74
+ is_selected "$name" || continue
75
+
76
+ if [[ " ${HARNESS_SKIP_GATES:-} " == *" $name "* ]]; then
77
+ echo "SKIP $name (HARNESS_SKIP_GATES on this machine)"
78
+ continue
79
+ fi
80
+
81
+ if [[ -z "$cmd" ]]; then
82
+ echo "FAIL $name (not configured; set GATE_${name^^} or 'skip:<reason>')"
83
+ failed+=("$name")
84
+ continue
85
+ fi
86
+ if [[ "$cmd" == skip:* ]]; then
87
+ echo "SKIP $name (${cmd#skip:})"
88
+ continue
89
+ fi
90
+
91
+ log="$log_dir/$name.log"
92
+ start=$(date +%s)
93
+ if bash -c "$cmd" >"$log" 2>&1; then
94
+ echo "PASS $name ($(( $(date +%s) - start ))s)"
95
+ else
96
+ echo "FAIL $name ($(( $(date +%s) - start ))s) \$ $cmd"
97
+ echo "----- last $tail_lines lines of $name output -----"
98
+ tail -n "$tail_lines" "$log"
99
+ echo "-----"
100
+ failed+=("$name")
101
+ fi
102
+ done
103
+
104
+ if [[ ${#failed[@]} -gt 0 ]]; then
105
+ echo "gates: FAILED (${failed[*]})"
106
+ exit 1
107
+ fi
108
+ echo "gates: all passed"
@@ -0,0 +1,169 @@
1
+ # Shared helpers for the .harness scripts. Source it; do not run it.
2
+ # shellcheck shell=bash
3
+
4
+ # Main checkout of the repository, also when called from one of its worktrees:
5
+ # loop state and logs always live there.
6
+ harness_root() {
7
+ local common
8
+ common="$(git rev-parse --path-format=absolute --git-common-dir)" || return 1
9
+ dirname "$common"
10
+ }
11
+
12
+ # Loads the machine-level harness settings (HARNESS_ENV_FILE, default
13
+ # ~/.config/harness/env) and exports every variable in them, so they reach the
14
+ # gates and every Claude Code session the harness starts, also when it is started
15
+ # by a timer with an empty environment. TELEGRAM_STATE_DIR there keeps those
16
+ # sessions away from the operator's Telegram channel (see env.example).
17
+ harness_load_env() {
18
+ local env_file="${HARNESS_ENV_FILE:-$HOME/.config/harness/env}"
19
+ [[ -f "$env_file" ]] || return 0
20
+ set -a
21
+ # shellcheck source=/dev/null
22
+ . "$env_file"
23
+ set +a
24
+ }
25
+
26
+ # HARNESS_CLAUDE: the command that starts Claude Code for an agent session (an
27
+ # array, so it works after `timeout`). The bot token and chat id are left out of
28
+ # the session's environment: only notify.sh needs them, and it reads the env file
29
+ # itself. TELEGRAM_STATE_DIR from the env file is kept.
30
+ # shellcheck disable=SC2034 # used by the scripts that source this file
31
+ HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID claude)
32
+
33
+ # Worktree where the task loop runs a change (ADR 0012), outside the checkout so
34
+ # lint, tests and type-checks of the main checkout never see it:
35
+ # ${HARNESS_WORKTREES_DIR:-<parent of the checkout>/.harness-worktrees/<repo>}/<change-id>
36
+ harness_worktree() {
37
+ local root
38
+ root="$(harness_root)" || return 1
39
+ echo "${HARNESS_WORKTREES_DIR:-$(dirname "$root")/.harness-worktrees/$(basename "$root")}/$1"
40
+ }
41
+
42
+ # ------------------------------------------------- model routing (ADR 0020) --
43
+ # harness_models: loads models.conf next to this file (or in HARNESS_MODELS_DIR), then the project's
44
+ # models.local.conf and $HARNESS_MODELS_FILE, in a subshell-safe way (callers
45
+ # use the functions below, which run it in a subshell).
46
+ harness_models() {
47
+ # HARNESS_MODELS_DIR: read another project's mapping with this code (the
48
+ # watcher runs the harness's newest lib.sh against each project's models.conf).
49
+ local base="${HARNESS_MODELS_DIR:-$(dirname "${BASH_SOURCE[0]}")}"
50
+ # shellcheck source=/dev/null
51
+ . "$base/models.conf" || return 1
52
+ # shellcheck source=/dev/null
53
+ [[ -f "$base/models.local.conf" ]] && . "$base/models.local.conf"
54
+ # shellcheck source=/dev/null
55
+ [[ -n "${HARNESS_MODELS_FILE:-}" && -f "$HARNESS_MODELS_FILE" ]] && . "$HARNESS_MODELS_FILE"
56
+ return 0
57
+ }
58
+
59
+ # harness_task_level <task-type> [<level override>]: the level (R1-R4).
60
+ harness_task_level() {
61
+ (
62
+ harness_models || exit 1
63
+ local level="${2:-}" var="TASK_$1"
64
+ [[ -n "$level" ]] || level="${!var:-}"
65
+ [[ "$level" =~ ^R[1-4]$ ]] || { echo "harness: unknown task type or level '$1${2:+/$2}'" >&2; exit 1; }
66
+ echo "$level"
67
+ )
68
+ }
69
+
70
+ # harness_level_climb <level> <steps>: the level <steps> above, capped at R4.
71
+ harness_level_climb() {
72
+ local n="${1#R}"; n=$(( n + $2 )); (( n > 4 )) && n=4; (( n < 1 )) && n=1
73
+ echo "R$n"
74
+ }
75
+
76
+ # harness_level_flags <level>: the claude flags for a level; empty while
77
+ # HARNESS_MODEL_ROUTING is not "on" (measure mode, decision 7).
78
+ harness_level_flags() {
79
+ (
80
+ harness_models || exit 1
81
+ [[ "$1" =~ ^R[1-4]$ ]] || { echo "harness: unknown level '$1'" >&2; exit 1; }
82
+ [[ "${HARNESS_MODEL_ROUTING:-measure}" == on ]] || exit 0
83
+ echo "${!1}"
84
+ )
85
+ }
86
+
87
+ # harness_claude_flags <task-type> [<level override>]: the claude flags for a
88
+ # task. The single entry point from task to model and effort.
89
+ harness_claude_flags() {
90
+ local level; level="$(harness_task_level "$@")" || return 1
91
+ harness_level_flags "$level"
92
+ }
93
+
94
+ # harness_usage_error <session.json>: the session ended on an account usage
95
+ # limit or on missing usage credits ("You're out of usage credits. Switch to
96
+ # another model..."), not on the work.
97
+ harness_usage_error() {
98
+ grep -qiE "hit your (session|usage) limit|usage limit reached|out of usage credits|credit balance is too low" "$1" 2>/dev/null
99
+ }
100
+
101
+ # harness_level_descend <level>: the level one below, for the fallback after a
102
+ # usage error (ADR 0020, amendment). Fails at R1, and unless
103
+ # HARNESS_MODEL_ROUTING=on: without routing no model is chosen, so there is
104
+ # nothing to switch to and the callers keep their pause (or escalation).
105
+ harness_level_descend() {
106
+ [[ "${HARNESS_MODEL_ROUTING:-measure}" == on ]] || return 1
107
+ local n="${1#R}"
108
+ [[ "$1" =~ ^R[1-4]$ ]] && (( n > 1 )) || return 1
109
+ echo "R$((n - 1))"
110
+ }
111
+
112
+ # harness_flag_value <flags> <--flag>: e.g. the --effort of a level's flags.
113
+ harness_flag_value() {
114
+ # shellcheck disable=SC2206
115
+ local words=($1) i
116
+ for (( i = 0; i < ${#words[@]}; i++ )); do
117
+ [[ "${words[$i]}" == "$2" ]] && { echo "${words[$((i + 1))]:-}"; return 0; }
118
+ done
119
+ }
120
+
121
+ # ------------------------------------------------- usage ledger (ADR 0020) --
122
+ # A session log is claude -p --output-format json with stderr merged in, so
123
+ # warnings such as "Ignoring N permissions.allow entries ... this workspace has
124
+ # not been trusted" can surround the JSON. session(<file>) parses the whole file
125
+ # or, failing that, the last line that is a JSON object ({} when none is).
126
+ harness_session_js='const session = (f) => {
127
+ let s = ""; try { s = require("fs").readFileSync(f, "utf8"); } catch { return {}; }
128
+ try { return JSON.parse(s) || {}; } catch {}
129
+ for (const l of s.split("\n").reverse()) {
130
+ if (l.trimStart().startsWith("{")) { try { return JSON.parse(l) || {}; } catch {} }
131
+ }
132
+ return {};
133
+ };'
134
+
135
+ # harness_session_reply <session.json>: the session's final message (empty when
136
+ # there is none); fails when the session ended on an error (is_error).
137
+ harness_session_reply() {
138
+ node -e "$harness_session_js"'
139
+ const d = session(process.argv[1]);
140
+ process.stdout.write(typeof d.result === "string" ? d.result : "");
141
+ process.exitCode = d.is_error ? 1 : 0;' "$1" 2>/dev/null
142
+ }
143
+
144
+ # harness_session_usage <session.json>: "model<TAB>cost_usd<TAB>output_tokens<TAB>cache_read_tokens"
145
+ # from a claude -p --output-format json result (models joined by "+").
146
+ harness_session_usage() {
147
+ node -e "$harness_session_js"'
148
+ const d = session(process.argv[1]);
149
+ const mu = d.modelUsage || {};
150
+ const sum = (k) => Object.values(mu).reduce((a, m) => a + (m[k] || 0), 0);
151
+ const cost = typeof d.total_cost_usd === "number" ? d.total_cost_usd : sum("costUSD");
152
+ process.stdout.write([Object.keys(mu).join("+") || "unknown", cost.toFixed(4),
153
+ sum("outputTokens"), sum("cacheReadInputTokens")].join("\t"));' "$1" 2>/dev/null \
154
+ || printf 'unknown\t0.0000\t0\t0'
155
+ }
156
+
157
+ # harness_record_usage <project> <ref> <task-type> <level> <effort> <attempt> <result> <session.json>:
158
+ # one line in the machine-wide ledger (HARNESS_USAGE_LEDGER, default
159
+ # ~/.local/state/harness/usage.tsv); prints the session's usage columns.
160
+ harness_record_usage() {
161
+ local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage
162
+ usage="$(harness_session_usage "$8")"
163
+ mkdir -p "$(dirname "$ledger")"
164
+ [[ -s "$ledger" ]] || printf 'timestamp\tproject\tref\ttask_type\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tresult\tattempt\n' >"$ledger"
165
+ IFS=$'\t' read -r model cost out cache <<<"$usage"
166
+ printf '%s\t%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" \
167
+ "$model" "${5:-default}" "$cost" "$out" "$cache" "$7" "$6" >>"$ledger"
168
+ printf '%s' "$usage"
169
+ }