@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,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,26 @@
|
|
|
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
|
+
# Gates run by the Claude Code Stop hook (fast profile). Empty = all gates.
|
|
19
|
+
# The task loop and CI always run every gate.
|
|
20
|
+
STOP_GATES=()
|
|
21
|
+
|
|
22
|
+
# Optional extra gates, run after the required ones, in order: "name:command".
|
|
23
|
+
GATE_EXTRA=(
|
|
24
|
+
# "coverage:npm run test:coverage -- --coverage.thresholds.lines=80"
|
|
25
|
+
# "security:npm audit --audit-level=high"
|
|
26
|
+
)
|
|
@@ -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,129 @@
|
|
|
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_flag_value <flags> <--flag>: e.g. the --effort of a level's flags.
|
|
95
|
+
harness_flag_value() {
|
|
96
|
+
# shellcheck disable=SC2206
|
|
97
|
+
local words=($1) i
|
|
98
|
+
for (( i = 0; i < ${#words[@]}; i++ )); do
|
|
99
|
+
[[ "${words[$i]}" == "$2" ]] && { echo "${words[$((i + 1))]:-}"; return 0; }
|
|
100
|
+
done
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------- usage ledger (ADR 0020) --
|
|
104
|
+
# harness_session_usage <session.json>: "model<TAB>cost_usd<TAB>output_tokens<TAB>cache_read_tokens"
|
|
105
|
+
# from a claude -p --output-format json result (models joined by "+").
|
|
106
|
+
harness_session_usage() {
|
|
107
|
+
node -e '
|
|
108
|
+
let d = {}; try { d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); } catch {}
|
|
109
|
+
const mu = d.modelUsage || {};
|
|
110
|
+
const sum = (k) => Object.values(mu).reduce((a, m) => a + (m[k] || 0), 0);
|
|
111
|
+
const cost = typeof d.total_cost_usd === "number" ? d.total_cost_usd : sum("costUSD");
|
|
112
|
+
process.stdout.write([Object.keys(mu).join("+") || "unknown", cost.toFixed(4),
|
|
113
|
+
sum("outputTokens"), sum("cacheReadInputTokens")].join("\t"));' "$1" 2>/dev/null \
|
|
114
|
+
|| printf 'unknown\t0.0000\t0\t0'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# harness_record_usage <project> <ref> <task-type> <level> <effort> <attempt> <result> <session.json>:
|
|
118
|
+
# one line in the machine-wide ledger (HARNESS_USAGE_LEDGER, default
|
|
119
|
+
# ~/.local/state/harness/usage.tsv); prints the session's usage columns.
|
|
120
|
+
harness_record_usage() {
|
|
121
|
+
local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage
|
|
122
|
+
usage="$(harness_session_usage "$8")"
|
|
123
|
+
mkdir -p "$(dirname "$ledger")"
|
|
124
|
+
[[ -s "$ledger" ]] || printf 'timestamp\tproject\tref\ttask_type\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tresult\tattempt\n' >"$ledger"
|
|
125
|
+
IFS=$'\t' read -r model cost out cache <<<"$usage"
|
|
126
|
+
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" \
|
|
127
|
+
"$model" "${5:-default}" "$cost" "$out" "$cache" "$7" "$6" >>"$ledger"
|
|
128
|
+
printf '%s' "$usage"
|
|
129
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runs the task loop for an approved OpenSpec change (spec PR merged) in the
|
|
3
|
+
# background, detached from the calling session (a Claude Code session, SSH, ...),
|
|
4
|
+
# and keeps it going.
|
|
5
|
+
#
|
|
6
|
+
# Usage: .harness/loop.sh start <change-id> [run-tasks options]
|
|
7
|
+
# .harness/loop.sh status [change-id]
|
|
8
|
+
# .harness/loop.sh stop <change-id>
|
|
9
|
+
# .harness/loop.sh logs <change-id>
|
|
10
|
+
#
|
|
11
|
+
# The supervisor runs run-tasks.sh --open-pr and reacts to its exit code:
|
|
12
|
+
# 0 done → notify with the PR link
|
|
13
|
+
# 2 escalated → notify; a human takes over
|
|
14
|
+
# 3 paused (account usage limit; run-tasks discarded the partial attempt)
|
|
15
|
+
# → notify, wait HARNESS_RESUME_INTERVAL seconds (default 900), try again
|
|
16
|
+
# run-tasks works in the change's own git worktree (ADR 0012): the checkout this
|
|
17
|
+
# runs from is never switched or cleaned, so specs can be written meanwhile.
|
|
18
|
+
# Notifications go through .harness/notify.sh (Telegram when configured).
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
# shellcheck source-path=SCRIPTDIR source=lib.sh
|
|
22
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
23
|
+
root="$(harness_root)"
|
|
24
|
+
cd "$root"
|
|
25
|
+
|
|
26
|
+
# Machine-level harness settings (Telegram, skipped gates, resume interval),
|
|
27
|
+
# kept outside the repository: HARNESS_ENV_FILE or ~/.config/harness/env.
|
|
28
|
+
harness_load_env
|
|
29
|
+
state_dir=".harness/state"
|
|
30
|
+
mkdir -p "$state_dir" .harness/logs
|
|
31
|
+
|
|
32
|
+
die() { echo "loop: $*" >&2; exit 1; }
|
|
33
|
+
pid_file() { echo "$state_dir/loop-$1.pid"; }
|
|
34
|
+
running() { local f; f="$(pid_file "$1")"; [[ -f "$f" ]] && kill -0 "$(cat "$f")" 2>/dev/null; }
|
|
35
|
+
|
|
36
|
+
supervise() {
|
|
37
|
+
local change="$1"; shift
|
|
38
|
+
local interval="${HARNESS_RESUME_INTERVAL:-900}" log=".harness/logs/$change/runner.log" rc pr every reason
|
|
39
|
+
mkdir -p ".harness/logs/$change"
|
|
40
|
+
.harness/notify.sh started "Loop started for change $change"
|
|
41
|
+
while true; do
|
|
42
|
+
set +e
|
|
43
|
+
.harness/run-tasks.sh "$change" --open-pr "$@" >>"$log" 2>&1
|
|
44
|
+
rc=$?
|
|
45
|
+
set -e
|
|
46
|
+
case "$rc" in
|
|
47
|
+
0)
|
|
48
|
+
pr="$(grep -oE 'https://github.com/[^ ]+/pull/[0-9]+' "$log" | tail -n1 || true)"
|
|
49
|
+
.harness/notify.sh "done" "Change $change is done${pr:+: $pr}"
|
|
50
|
+
break ;;
|
|
51
|
+
2)
|
|
52
|
+
reason="$(grep -E '^run-tasks: ' "$log" | tail -n1 | sed 's/^run-tasks: //')"
|
|
53
|
+
.harness/notify.sh escalated "Change $change escalated: ${reason:-see the runner log}. See .harness/status.sh $change"
|
|
54
|
+
break ;;
|
|
55
|
+
3)
|
|
56
|
+
if (( interval >= 60 )); then every="$((interval / 60)) min"; else every="${interval}s"; fi
|
|
57
|
+
.harness/notify.sh paused "Change $change paused by the account usage limit; retrying every $every"
|
|
58
|
+
sleep "$interval"
|
|
59
|
+
.harness/notify.sh resumed "Resuming change $change" ;;
|
|
60
|
+
*)
|
|
61
|
+
.harness/notify.sh error "Change $change stopped with exit code $rc. See .harness/logs/$change/runner.log"
|
|
62
|
+
break ;;
|
|
63
|
+
esac
|
|
64
|
+
done
|
|
65
|
+
rm -f "$(pid_file "$change")"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cmd="${1:-}"; shift || true
|
|
69
|
+
case "$cmd" in
|
|
70
|
+
start)
|
|
71
|
+
change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh start <change-id>"; shift
|
|
72
|
+
running "$change" && die "a loop for $change is already running (pid $(cat "$(pid_file "$change")"))"
|
|
73
|
+
# Fail here, not in the background: the spec must be approved (merged into
|
|
74
|
+
# the base branch through its spec PR, ADR 0011) and its worktree clean.
|
|
75
|
+
.harness/run-tasks.sh "$change" --check "$@" >/dev/null || die "change $change cannot start (see above)"
|
|
76
|
+
setsid nohup "$0" __supervise "$change" "$@" >/dev/null 2>&1 </dev/null &
|
|
77
|
+
echo $! >"$(pid_file "$change")"
|
|
78
|
+
echo "loop: started $change in the background (pid $!). Follow it with .harness/loop.sh status $change"
|
|
79
|
+
;;
|
|
80
|
+
__supervise)
|
|
81
|
+
supervise "$@" ;;
|
|
82
|
+
status)
|
|
83
|
+
if [[ -n "${1:-}" ]]; then
|
|
84
|
+
running "$1" && echo "supervisor: running (pid $(cat "$(pid_file "$1")"))" || echo "supervisor: not running"
|
|
85
|
+
.harness/status.sh "$1"
|
|
86
|
+
else
|
|
87
|
+
.harness/status.sh
|
|
88
|
+
fi ;;
|
|
89
|
+
stop)
|
|
90
|
+
change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh stop <change-id>"
|
|
91
|
+
running "$change" || die "no loop running for $change"
|
|
92
|
+
pkill -TERM -s "$(ps -o sid= -p "$(cat "$(pid_file "$change")")" | tr -d ' ')" || true
|
|
93
|
+
rm -f "$(pid_file "$change")"
|
|
94
|
+
.harness/notify.sh error "Loop for change $change stopped by request"
|
|
95
|
+
echo "loop: stopped $change" ;;
|
|
96
|
+
logs)
|
|
97
|
+
change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh logs <change-id>"
|
|
98
|
+
tail -n 40 ".harness/logs/$change/runner.log" ;;
|
|
99
|
+
*)
|
|
100
|
+
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
|
101
|
+
esac
|