@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.
- package/README.md +43 -2
- package/bin/harness +374 -0
- package/package.json +30 -4
- 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/dependabot.yml +19 -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 +31 -0
- package/templates/.harness/gates.sh +108 -0
- package/templates/.harness/lib.sh +169 -0
- package/templates/.harness/loop.sh +169 -0
- package/templates/.harness/model-flags.sh +34 -0
- package/templates/.harness/models.conf +31 -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 +461 -0
- package/templates/.harness/spec-pr.sh +157 -0
- package/templates/.harness/status.sh +155 -0
- package/templates/CLAUDE.md.tmpl +159 -0
- package/templates/openspec/config.yaml +38 -0
- package/templates/stacks/nestjs/gates.conf +35 -0
- package/templates/stacks/nestjs/gates.yml +71 -0
- package/templates/stacks/nextjs/claude-imports.md +4 -0
- package/templates/stacks/nextjs/gates.conf +36 -0
- 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
|