@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,169 @@
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> [--after-task]
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
+ # 4 stopped between two tasks (`stop --after-task`) → notify; restart later
17
+ # run-tasks works in the change's own git worktree (ADR 0012): the checkout this
18
+ # runs from is never switched or cleaned, so specs can be written meanwhile.
19
+ # Notifications go through .harness/notify.sh (Telegram when configured).
20
+ #
21
+ # Started from inside a systemd unit (the spec PR watcher's oneshot service, any
22
+ # other service), the supervisor gets a transient scope of its own
23
+ # (systemd-run --user --scope, unit harness-loop-<project>-<change>-<time>):
24
+ # setsid alone keeps it in the unit's cgroup, and systemd kills the cgroup when
25
+ # the unit ends. `status` reports a supervisor that died before finishing.
26
+ set -euo pipefail
27
+
28
+ # shellcheck source-path=SCRIPTDIR source=lib.sh
29
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
30
+ root="$(harness_root)"
31
+ cd "$root"
32
+
33
+ # Machine-level harness settings (Telegram, skipped gates, resume interval),
34
+ # kept outside the repository: HARNESS_ENV_FILE or ~/.config/harness/env.
35
+ harness_load_env
36
+ state_dir=".harness/state"
37
+ mkdir -p "$state_dir" .harness/logs
38
+
39
+ die() { echo "loop: $*" >&2; exit 1; }
40
+ pid_file() { echo "$state_dir/loop-$1.pid"; }
41
+ running() { local f; f="$(pid_file "$1")"; [[ -f "$f" ]] && kill -0 "$(cat "$f")" 2>/dev/null; }
42
+
43
+ # scope_prefix <change>: the systemd-run prefix that puts the supervisor in a
44
+ # scope of its own when this runs inside a systemd unit (INVOCATION_ID), unless
45
+ # it already is in a harness-loop scope; nothing otherwise or without a user
46
+ # manager to talk to. It runs in a subshell (mapfile < <(...)), so the caller
47
+ # must have XDG_RUNTIME_DIR set already: an export here would never reach the
48
+ # systemd-run of the parent ("Failed to connect to bus").
49
+ scope_prefix() {
50
+ [[ -n "${INVOCATION_ID:-}" ]] || return 0
51
+ grep -q '/harness-loop-[^/]*\.scope$' /proc/self/cgroup 2>/dev/null && return 0
52
+ command -v systemd-run >/dev/null 2>&1 || return 0
53
+ systemd-run --user --scope --quiet true >/dev/null 2>&1 || return 0
54
+ printf '%s\n' systemd-run --user --scope --quiet --collect \
55
+ --unit "harness-loop-$(basename "$root" | tr -c 'A-Za-z0-9_.\n-' '-')-$1-$(date +%s)" --
56
+ }
57
+
58
+ # close_inherited_fds: the supervisor outlives whoever started it, and a lock
59
+ # fd it inherited (the spec PR watcher's, a project lock) would stay locked for
60
+ # as long as the loop runs. Everything above stderr is closed.
61
+ close_inherited_fds() {
62
+ local f fd
63
+ for f in /proc/$$/fd/*; do
64
+ fd="${f##*/}"
65
+ [[ "$fd" =~ ^[0-9]+$ ]] && (( fd > 2 )) && eval "exec $fd>&-" 2>/dev/null
66
+ done
67
+ return 0
68
+ }
69
+
70
+ supervise() {
71
+ local change="$1"; shift
72
+ close_inherited_fds
73
+ local interval="${HARNESS_RESUME_INTERVAL:-900}" log=".harness/logs/$change/runner.log" rc pr every reason
74
+ mkdir -p ".harness/logs/$change"
75
+ .harness/notify.sh started "Loop started for change $change"
76
+ while true; do
77
+ set +e
78
+ .harness/run-tasks.sh "$change" --open-pr "$@" >>"$log" 2>&1
79
+ rc=$?
80
+ set -e
81
+ case "$rc" in
82
+ 0)
83
+ pr="$(grep -oE 'https://github.com/[^ ]+/pull/[0-9]+' "$log" | tail -n1 || true)"
84
+ .harness/notify.sh "done" "Change $change is done${pr:+: $pr}"
85
+ break ;;
86
+ 2)
87
+ reason="$(grep -E '^run-tasks: ' "$log" | tail -n1 | sed 's/^run-tasks: //')"
88
+ .harness/notify.sh escalated "Change $change escalated: ${reason:-see the runner log}. See .harness/status.sh $change"
89
+ break ;;
90
+ 3)
91
+ if (( interval >= 60 )); then every="$((interval / 60)) min"; else every="${interval}s"; fi
92
+ .harness/notify.sh paused "Change $change paused by the account usage limit; retrying every $every"
93
+ sleep "$interval"
94
+ .harness/notify.sh resumed "Resuming change $change" ;;
95
+ 4)
96
+ .harness/notify.sh info "Change $change stopped at the end of a task on request; restart with .harness/loop.sh start $change"
97
+ break ;;
98
+ *)
99
+ .harness/notify.sh error "Change $change stopped with exit code $rc. See .harness/logs/$change/runner.log"
100
+ break ;;
101
+ esac
102
+ done
103
+ rm -f "$(pid_file "$change")"
104
+ }
105
+
106
+ cmd="${1:-}"; shift || true
107
+ case "$cmd" in
108
+ start)
109
+ change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh start <change-id>"; shift
110
+ running "$change" && die "a loop for $change is already running (pid $(cat "$(pid_file "$change")"))"
111
+ # Fail here, not in the background: the spec must be approved (merged into
112
+ # the base branch through its spec PR, ADR 0011) and its worktree clean.
113
+ .harness/run-tasks.sh "$change" --check "$@" >/dev/null || die "change $change cannot start (see above)"
114
+ rm -f "$state_dir/stop-$change" # a stop requested for an earlier run
115
+ # A systemd unit (a service) has no XDG_RUNTIME_DIR; the user bus is there.
116
+ [[ -z "${INVOCATION_ID:-}" ]] || export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
117
+ mapfile -t scope < <(scope_prefix "$change")
118
+ setsid nohup "${scope[@]}" "$0" __supervise "$change" "$@" >/dev/null 2>&1 </dev/null &
119
+ echo $! >"$(pid_file "$change")"
120
+ # Do not return before the supervisor is in its scope: the unit that called
121
+ # this may end right after, and its cgroup is killed with whatever is left.
122
+ if (( ${#scope[@]} )); then
123
+ for (( i = 0; i < 100; i++ )); do
124
+ grep -q '/harness-loop-[^/]*\.scope$' "/proc/$!/cgroup" 2>/dev/null && break
125
+ kill -0 $! 2>/dev/null || break
126
+ sleep 0.1
127
+ done
128
+ grep -q '/harness-loop-[^/]*\.scope$' "/proc/$!/cgroup" 2>/dev/null \
129
+ || echo "loop: warning: the supervisor is not in its own systemd scope; it may stop with the calling unit" >&2
130
+ fi
131
+ echo "loop: started $change in the background (pid $!). Follow it with .harness/loop.sh status $change"
132
+ ;;
133
+ __supervise)
134
+ supervise "$@" ;;
135
+ status)
136
+ if [[ -n "${1:-}" ]]; then
137
+ if running "$1"; then
138
+ echo "supervisor: running (pid $(cat "$(pid_file "$1")"))"
139
+ elif [[ -f "$(pid_file "$1")" ]]; then
140
+ # The supervisor removes its pid file when it finishes, even on errors.
141
+ echo "supervisor: DIED before finishing (pid $(cat "$(pid_file "$1")"), no final notification)." \
142
+ "Last runner and worktree-setup logs are in .harness/logs/$1/; restart with .harness/loop.sh start $1"
143
+ else
144
+ echo "supervisor: not running"
145
+ fi
146
+ .harness/status.sh "$1"
147
+ else
148
+ .harness/status.sh
149
+ fi ;;
150
+ stop)
151
+ change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh stop <change-id> [--after-task]"
152
+ running "$change" || die "no loop running for $change"
153
+ if [[ "${2:-}" == --after-task ]]; then
154
+ # Graceful: the task in progress finishes and is committed, then run-tasks
155
+ # exits between tasks and the supervisor ends (the worktree is kept).
156
+ touch "$state_dir/stop-$change"
157
+ echo "loop: $change will stop at the end of its current task"
158
+ exit 0
159
+ fi
160
+ pkill -TERM -s "$(ps -o sid= -p "$(cat "$(pid_file "$change")")" | tr -d ' ')" || true
161
+ rm -f "$(pid_file "$change")"
162
+ .harness/notify.sh error "Loop for change $change stopped by request"
163
+ echo "loop: stopped $change" ;;
164
+ logs)
165
+ change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh logs <change-id>"
166
+ tail -n 40 ".harness/logs/$change/runner.log" ;;
167
+ *)
168
+ sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
169
+ esac
@@ -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,31 @@
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_archive_fix=R2 # editorial split of a merged requirement over 500 characters (ADR 0022)
31
+ 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