@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,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Last step of proposing a change (ADR 0011, ADR 0013): opens, or updates, the
|
|
3
|
+
# spec-only pull request whose merge approves the spec.
|
|
4
|
+
#
|
|
5
|
+
# Usage: .harness/spec-pr.sh <change-id>
|
|
6
|
+
#
|
|
7
|
+
# 1. validates the change with the pinned OpenSpec CLI (--strict) and its
|
|
8
|
+
# declared dependencies (.harness/deps.sh check);
|
|
9
|
+
# 2. moves to branch spec/<change-id> (created from the base branch, carrying
|
|
10
|
+
# the uncommitted change files) unless already on it;
|
|
11
|
+
# 3. refuses anything outside openspec/changes/<change-id>/, in the working
|
|
12
|
+
# tree or in the branch's commits;
|
|
13
|
+
# 4. commits the change files, pushes, and opens the PR "spec: <change-id>",
|
|
14
|
+
# or reuses the open one, and prints its URL.
|
|
15
|
+
#
|
|
16
|
+
# Environment:
|
|
17
|
+
# HARNESS_BASE_BRANCH branch the spec PR targets (default main)
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
usage() { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
|
|
21
|
+
die() { echo "spec-pr: $*" >&2; exit 1; }
|
|
22
|
+
|
|
23
|
+
[[ $# -eq 1 ]] || usage 1
|
|
24
|
+
[[ "$1" == "-h" || "$1" == "--help" ]] && usage 0
|
|
25
|
+
change="$1"
|
|
26
|
+
[[ "$change" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "invalid change id '$change' (kebab-case expected)"
|
|
27
|
+
|
|
28
|
+
root="$(git rev-parse --show-toplevel)" || die "not inside a git repository"
|
|
29
|
+
cd "$root"
|
|
30
|
+
change_dir="openspec/changes/$change"
|
|
31
|
+
branch="spec/$change"
|
|
32
|
+
base_branch="${HARNESS_BASE_BRANCH:-main}"
|
|
33
|
+
|
|
34
|
+
[[ -f "$change_dir/proposal.md" && -f "$change_dir/tasks.md" ]] \
|
|
35
|
+
|| die "$change_dir needs at least proposal.md and tasks.md"
|
|
36
|
+
command -v gh >/dev/null || die "gh CLI not found"
|
|
37
|
+
|
|
38
|
+
.harness/openspec.sh validate "$change" --type change --strict --no-interactive \
|
|
39
|
+
|| die "openspec validate failed; fix the change before opening its spec PR"
|
|
40
|
+
# Task levels (ADR 0020): only <!-- level: R1 --> to <!-- level: R4 -->.
|
|
41
|
+
bad_levels="$(grep -nE '<!--[[:space:]]*level' "$change_dir/tasks.md" \
|
|
42
|
+
| grep -vE '<!--[[:space:]]*level:[[:space:]]*R[1-4][[:space:]]*-->' || true)"
|
|
43
|
+
[[ -z "$bad_levels" ]] || die "invalid task level in $change_dir/tasks.md (use <!-- level: R1 --> to R4):
|
|
44
|
+
$bad_levels"
|
|
45
|
+
# The loop installs exactly what design.md declares (ADR 0015): check it now.
|
|
46
|
+
if [[ -x .harness/deps.sh ]]; then
|
|
47
|
+
.harness/deps.sh check "$change_dir" || die "fix the ## Dependencies section of $change_dir/design.md"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Only the change's own files may be pending: anything else would leak into the spec PR.
|
|
51
|
+
outside="$(git status --porcelain --untracked-files=all | cut -c4- | grep -v "^$change_dir/" || true)"
|
|
52
|
+
[[ -z "$outside" ]] || die "uncommitted files outside $change_dir/ (commit, stash or remove them first):
|
|
53
|
+
$outside"
|
|
54
|
+
|
|
55
|
+
git fetch -q origin "$base_branch" || die "cannot fetch origin/$base_branch"
|
|
56
|
+
if [[ "$(git branch --show-current)" != "$branch" ]]; then
|
|
57
|
+
if git rev-parse -q --verify "refs/heads/$branch" >/dev/null; then
|
|
58
|
+
git switch -q "$branch" || die "cannot switch to $branch"
|
|
59
|
+
else
|
|
60
|
+
git switch -q --no-track -c "$branch" "origin/$base_branch" \
|
|
61
|
+
|| die "cannot create $branch from origin/$base_branch with the pending change files"
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
committed_outside="$(git diff --name-only "origin/$base_branch...HEAD" | grep -v "^$change_dir/" || true)"
|
|
66
|
+
[[ -z "$committed_outside" ]] || die "$branch has commits outside $change_dir/; a spec PR contains only the spec:
|
|
67
|
+
$committed_outside"
|
|
68
|
+
|
|
69
|
+
if [[ -n "$(git status --porcelain --untracked-files=all -- "$change_dir")" ]]; then
|
|
70
|
+
git add -A -- "$change_dir"
|
|
71
|
+
if git cat-file -e "origin/$base_branch:$change_dir/tasks.md" 2>/dev/null \
|
|
72
|
+
|| [[ -n "$(git log --oneline "origin/$base_branch..HEAD")" ]]; then
|
|
73
|
+
msg="docs($change): update OpenSpec change"
|
|
74
|
+
else
|
|
75
|
+
msg="docs($change): propose OpenSpec change"
|
|
76
|
+
fi
|
|
77
|
+
git commit -q -m "$msg" -- "$change_dir"
|
|
78
|
+
fi
|
|
79
|
+
[[ -n "$(git log --oneline "origin/$base_branch..HEAD")" ]] \
|
|
80
|
+
|| die "$change_dir is identical on origin/$base_branch: nothing to propose"
|
|
81
|
+
|
|
82
|
+
git push -q -u origin "$branch"
|
|
83
|
+
|
|
84
|
+
url="$(gh pr list --state open --head "$branch" --json url --jq '.[0].url // empty' 2>/dev/null || true)"
|
|
85
|
+
if [[ -n "$url" ]]; then
|
|
86
|
+
echo "spec-pr: updated $url"
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
body="$(mktemp)"
|
|
91
|
+
trap 'rm -f "$body"' EXIT
|
|
92
|
+
{
|
|
93
|
+
echo "OpenSpec change \`$change\`, for review. **Merging this PR approves the spec** (ADR 0011);"
|
|
94
|
+
echo "nothing is implemented until then. Comment here; adjustments land on \`$branch\`."
|
|
95
|
+
echo
|
|
96
|
+
cat "$change_dir/proposal.md"
|
|
97
|
+
echo
|
|
98
|
+
echo "## Tasks"
|
|
99
|
+
echo
|
|
100
|
+
grep -E '^[[:space:]]*- \[[ xX]\] ' "$change_dir/tasks.md" || true
|
|
101
|
+
} >"$body"
|
|
102
|
+
url="$(gh pr create --base "$base_branch" --head "$branch" --title "spec: $change" --body-file "$body")"
|
|
103
|
+
echo "spec-pr: opened $url"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shows the state of OpenSpec changes and of the task loop.
|
|
3
|
+
#
|
|
4
|
+
# Usage: .harness/status.sh # overview of every active change
|
|
5
|
+
# .harness/status.sh <change-id> # details: tasks, runner, attempts, cost, last failure
|
|
6
|
+
# .harness/status.sh --usage # machine ledger: cost and first-attempt success
|
|
7
|
+
# # per task type, level and model (ADR 0020)
|
|
8
|
+
#
|
|
9
|
+
# Progress of a change the loop is working on is read from its worktree (ADR 0012),
|
|
10
|
+
# so it does not depend on the branch checked out here.
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
# shellcheck source-path=SCRIPTDIR source=lib.sh
|
|
14
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
15
|
+
root="$(harness_root)"
|
|
16
|
+
cd "$root"
|
|
17
|
+
|
|
18
|
+
now=$(date +%s)
|
|
19
|
+
changes_dir="openspec/changes"
|
|
20
|
+
|
|
21
|
+
human_duration() {
|
|
22
|
+
local s=$1
|
|
23
|
+
if (( s >= 3600 )); then printf '%dh%02dm' $((s / 3600)) $((s % 3600 / 60))
|
|
24
|
+
elif (( s >= 60 )); then printf '%dm%02ds' $((s / 60)) $((s % 60))
|
|
25
|
+
else printf '%ds' "$s"; fi
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# tasks_of <change-id>: prints its tasks.md from the loop worktree, else from the
|
|
29
|
+
# change/<id> branch (worktree already removed), else from this checkout.
|
|
30
|
+
tasks_of() {
|
|
31
|
+
local wt file="$changes_dir/$1/tasks.md"
|
|
32
|
+
wt="$(harness_worktree "$1")"
|
|
33
|
+
if [[ -f "$wt/$file" ]]; then cat "$wt/$file"
|
|
34
|
+
elif git cat-file -e "refs/heads/change/$1:$file" 2>/dev/null; then git show "refs/heads/change/$1:$file"
|
|
35
|
+
elif [[ -f "$file" ]]; then cat "$file"
|
|
36
|
+
else return 1; fi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Changes active here or in a loop worktree, one per line.
|
|
40
|
+
active_changes() {
|
|
41
|
+
local dir wt_parent
|
|
42
|
+
wt_parent="$(dirname "$(harness_worktree x)")"
|
|
43
|
+
for dir in "$changes_dir"/*/ "$wt_parent"/*/"$changes_dir"/*/; do
|
|
44
|
+
[[ -f "$dir/tasks.md" ]] && basename "$dir"
|
|
45
|
+
done | grep -vx archive | sort -u || true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# count_tasks <tasks.md content> → "<done> <total>"
|
|
49
|
+
count_tasks() {
|
|
50
|
+
local n_done total
|
|
51
|
+
n_done=$(grep -cE '^[[:space:]]*- \[[xX]\] ' <<<"$1" || true)
|
|
52
|
+
total=$(grep -cE '^[[:space:]]*- \[[ xX]\] ' <<<"$1" || true)
|
|
53
|
+
echo "$n_done $total"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Reads the runner state file into state_* variables; returns 1 if absent.
|
|
57
|
+
load_state() {
|
|
58
|
+
local file=".harness/state/run-$1" key value
|
|
59
|
+
state_status="" state_pid="" state_task_id="" state_task="" state_attempt="" state_max_attempts=""
|
|
60
|
+
# shellcheck disable=SC2034 # state_updated is part of the file format, unused here
|
|
61
|
+
state_started="" state_attempt_started="" state_updated=""
|
|
62
|
+
[[ -f "$file" ]] || return 1
|
|
63
|
+
while IFS='=' read -r key value; do
|
|
64
|
+
printf -v "state_$key" '%s' "$value"
|
|
65
|
+
done <"$file"
|
|
66
|
+
# A "running" state whose process is gone was killed without cleanup.
|
|
67
|
+
if [[ "$state_status" == running ]] && ! kill -0 "$state_pid" 2>/dev/null; then
|
|
68
|
+
state_status="stale"
|
|
69
|
+
fi
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
total_cost() {
|
|
73
|
+
local dir=".harness/logs/$1"
|
|
74
|
+
[[ -d "$dir" ]] || { echo "0.00"; return; }
|
|
75
|
+
cat "$dir"/task-*-attempt-*.json 2>/dev/null \
|
|
76
|
+
| grep -o '"total_cost_usd":[0-9.]*' | cut -d: -f2 \
|
|
77
|
+
| awk '{ s += $1 } END { printf "%.2f", s }'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
overview() {
|
|
81
|
+
local found=0 change counts
|
|
82
|
+
printf '%-36s %-8s %-12s %s\n' CHANGE TASKS LOOP COST
|
|
83
|
+
while IFS= read -r change; do
|
|
84
|
+
[[ -n "$change" ]] || continue
|
|
85
|
+
found=1
|
|
86
|
+
read -r d t <<<"$(count_tasks "$(tasks_of "$change")")"
|
|
87
|
+
counts="$d/$t"
|
|
88
|
+
if load_state "$change"; then loop="$state_status"; else loop="-"; fi
|
|
89
|
+
printf '%-36s %-8s %-12s US$ %s\n' "$change" "$counts" "$loop" "$(total_cost "$change")"
|
|
90
|
+
done < <(active_changes)
|
|
91
|
+
(( found )) || echo "(no active changes in $changes_dir)"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
details() {
|
|
95
|
+
local change="$1" tasks log_dir=".harness/logs/$1"
|
|
96
|
+
tasks="$(tasks_of "$1")" || { echo "status: no change $1 (no $changes_dir/$1/tasks.md here, in its worktree or on change/$1)" >&2; exit 1; }
|
|
97
|
+
|
|
98
|
+
read -r d t <<<"$(count_tasks "$tasks")"
|
|
99
|
+
echo "Change: $change ($d/$t tasks done, US\$ $(total_cost "$change") spent)"
|
|
100
|
+
echo
|
|
101
|
+
grep -E '^[[:space:]]*- \[[ xX]\] ' <<<"$tasks" | sed -E \
|
|
102
|
+
-e 's/^[[:space:]]*- \[[xX]\] / [x] /' -e 's/^[[:space:]]*- \[ \] / [ ] /' \
|
|
103
|
+
| cut -c1-100
|
|
104
|
+
echo
|
|
105
|
+
|
|
106
|
+
if load_state "$change"; then
|
|
107
|
+
echo "Loop: $state_status (started $(human_duration $((now - state_started))) ago)"
|
|
108
|
+
if [[ "$state_status" == running || "$state_status" == stale ]]; then
|
|
109
|
+
echo " task $state_task_id, attempt $state_attempt/$state_max_attempts," \
|
|
110
|
+
"running for $(human_duration $((now - state_attempt_started)))"
|
|
111
|
+
echo " $state_task"
|
|
112
|
+
fi
|
|
113
|
+
else
|
|
114
|
+
echo "Loop: never run"
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
if [[ -f "$log_dir/summary.tsv" ]] && (( $(wc -l <"$log_dir/summary.tsv") > 1 )); then
|
|
118
|
+
echo
|
|
119
|
+
echo "Attempts:"
|
|
120
|
+
tail -n +2 "$log_dir/summary.tsv" | while IFS=$'\t' read -r ts task attempt dur result level model effort cost _; do
|
|
121
|
+
printf ' %-6s attempt %-2s %-5s %8s %s' "$task" "$attempt" "$result" "$(human_duration "$dur")" "${ts:0:19}"
|
|
122
|
+
[[ -n "${level:-}" ]] && printf ' %s %s/%s US$ %s' "$level" "$model" "$effort" "$cost"
|
|
123
|
+
echo
|
|
124
|
+
done
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
local last_fail
|
|
128
|
+
last_fail="$(ls -t "$log_dir"/task-*-attempt-*.gates.log 2>/dev/null | head -n1 || true)"
|
|
129
|
+
if [[ -n "$last_fail" ]]; then
|
|
130
|
+
echo
|
|
131
|
+
echo "Last failure: $last_fail"
|
|
132
|
+
grep -E '^(FAIL|gates:)' "$last_fail" | sed 's/^/ /'
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# usage: aggregates the machine-wide ledger (ADR 0020, calibration).
|
|
137
|
+
usage() {
|
|
138
|
+
local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}"
|
|
139
|
+
[[ -s "$ledger" ]] || { echo "(no sessions recorded in $ledger)"; return 0; }
|
|
140
|
+
printf '%-16s %-5s %-30s %8s %10s %10s %s\n' TASK LEVEL MODEL SESSIONS "COST US\$" "AVG US\$" "1ST-ATTEMPT PASS"
|
|
141
|
+
tail -n +2 "$ledger" | awk -F'\t' '
|
|
142
|
+
{ k = $4 "\t" $5 "\t" $6; n[k]++; c[k] += $8
|
|
143
|
+
if ($12 == 1) { first[k]++; if ($11 == "pass" || $11 == "fixed" || $11 == "replied") ok[k]++ } }
|
|
144
|
+
END { for (k in n) { split(k, f, "\t")
|
|
145
|
+
rate = first[k] ? sprintf("%d%% (%d/%d)", 100 * ok[k] / first[k], ok[k], first[k]) : "-"
|
|
146
|
+
printf "%-16s %-5s %-30s %8d %10.2f %10.2f %s\n", f[1], f[2], f[3], n[k], c[k], c[k] / n[k], rate } }' | sort
|
|
147
|
+
printf '%-16s %-5s %-30s %8s %10.2f\n' total "" "" "$(( $(wc -l <"$ledger") - 1 ))" \
|
|
148
|
+
"$(tail -n +2 "$ledger" | awk -F'\t' '{ s += $8 } END { print s + 0 }')"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "${1:-}" in
|
|
152
|
+
"") overview ;;
|
|
153
|
+
--usage) usage ;;
|
|
154
|
+
*) details "$1" ;;
|
|
155
|
+
esac
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# CLAUDE.md — `{{PROJECT_NAME}}`
|
|
2
|
+
|
|
3
|
+
<!-- Project-specific context goes in the "Project" section at the end.
|
|
4
|
+
Everything above it comes from agent-harness (version in
|
|
5
|
+
.harness/VERSION). -->
|
|
6
|
+
|
|
7
|
+
## Language rule (mandatory)
|
|
8
|
+
|
|
9
|
+
- **Every artifact you produce MUST be in English**: code, identifiers,
|
|
10
|
+
comments, docs, specs, commit messages, PR text, logs.
|
|
11
|
+
- **Chat is the only exception**: reply in the same language the user writes in.
|
|
12
|
+
|
|
13
|
+
## Standards
|
|
14
|
+
|
|
15
|
+
Follow these; they are the source of truth for how this product is built.
|
|
16
|
+
Shared standards are in `.harness/standards/`; the ones specific to this
|
|
17
|
+
product's stack are in `.harness/standards/stack/`.
|
|
18
|
+
|
|
19
|
+
{{STANDARDS_IMPORTS}}
|
|
20
|
+
|
|
21
|
+
## Workflow: Spec-Driven Development (OpenSpec)
|
|
22
|
+
|
|
23
|
+
The OpenSpec CLI is a pinned devDependency, never a global install: wherever
|
|
24
|
+
an OpenSpec workflow or skill says `openspec <args>`, run
|
|
25
|
+
`.harness/openspec.sh <args>` instead.
|
|
26
|
+
|
|
27
|
+
1. **Propose**: every non-trivial change starts as an OpenSpec change under
|
|
28
|
+
`openspec/changes/<change-id>/` (`proposal.md`, delta `specs/`, optional
|
|
29
|
+
`design.md`, `tasks.md`), e.g. with `/opsx:propose`. Write only those
|
|
30
|
+
files; nothing else in the repository changes in a proposal.
|
|
31
|
+
2. **Spec PR (last step of proposing)**: run `.harness/spec-pr.sh <change-id>`.
|
|
32
|
+
It validates the change (`--strict`), moves it to branch `spec/<change-id>`
|
|
33
|
+
from `main`, refuses files outside the change directory, commits, pushes
|
|
34
|
+
and opens (or updates) the PR `spec: <change-id>`, printing its URL. CI
|
|
35
|
+
validates a spec PR with OpenSpec instead of the gates.
|
|
36
|
+
3. **Human checkpoint**: the user reviews and comments on the spec PR.
|
|
37
|
+
Address comments by editing the change on `spec/<change-id>` and running
|
|
38
|
+
`.harness/spec-pr.sh <change-id>` again. **Merging the spec PR is the
|
|
39
|
+
approval**; never merge it yourself and never implement before it is
|
|
40
|
+
merged. `.harness/run-tasks.sh` refuses a change that is not on `main`.
|
|
41
|
+
4. **Apply**: only the task loop implements a change:
|
|
42
|
+
`.harness/loop.sh start <change-id>` (or `.harness/run-tasks.sh`), one
|
|
43
|
+
fresh session per task, on a new branch `change/<change-id>` created from
|
|
44
|
+
`main`; it opens the implementation PR linking the spec PR. Do not
|
|
45
|
+
implement tasks in an interactive session: `/opsx:apply` and the
|
|
46
|
+
`openspec-apply-change` skill are disabled in `.claude/settings.json`. Do
|
|
47
|
+
not edit the approved spec while implementing; a spec change needs a new
|
|
48
|
+
spec PR.
|
|
49
|
+
5. **Archive**: right after the implementation PR is merged, archive the
|
|
50
|
+
change in a new branch and PR so the delta is merged into
|
|
51
|
+
`openspec/specs/`, then run the formatter on `openspec/` (the archive
|
|
52
|
+
output is not Prettier-formatted).
|
|
53
|
+
|
|
54
|
+
A change that builds on another change's requirements is proposed only after
|
|
55
|
+
that change is archived, and uses `MODIFIED` on the existing requirement
|
|
56
|
+
instead of `ADDED` next to it. Otherwise the archived spec ends up with
|
|
57
|
+
contradicting requirements that `openspec validate` does not detect.
|
|
58
|
+
|
|
59
|
+
Write tasks so each one is small, independently verifiable by the gates, and
|
|
60
|
+
ends in a green state. The loop picks the model from the task's reasoning
|
|
61
|
+
level (ADR 0020): mark a task `<!-- level: R3 -->` at the end of its line when
|
|
62
|
+
it needs design judgement the spec cannot fully pin down, `<!-- level: R1 -->`
|
|
63
|
+
when it is a mechanical edit, and nothing otherwise (R2).
|
|
64
|
+
|
|
65
|
+
**Dependencies (ADR 0015).** A change that adds packages declares them in
|
|
66
|
+
`design.md` under `## Dependencies`, one bullet each, with exact versions:
|
|
67
|
+
`- dependency: <name>@<x.y.z>`, `- devDependency: <name>@<x.y.z>`, and
|
|
68
|
+
`- allow-build: <name>` only for a package that must run its install script.
|
|
69
|
+
The reviewer approves them with the spec. The task loop installs exactly these
|
|
70
|
+
before the first task (`.harness/deps.sh`), denies any other install script,
|
|
71
|
+
and refuses changes to other dependencies. No task adds packages, and an agent
|
|
72
|
+
in the loop never edits `package.json`, the lockfile or `pnpm-workspace.yaml`:
|
|
73
|
+
a missing package escalates and needs a spec change.
|
|
74
|
+
|
|
75
|
+
## Definition of Done
|
|
76
|
+
|
|
77
|
+
- Done means `.harness/gates.sh` exits 0. Your own assessment is not enough.
|
|
78
|
+
- A `Stop` hook runs the gates when you finish with uncommitted changes and
|
|
79
|
+
sends failures back to you.
|
|
80
|
+
- Never make gates pass by deleting, skipping or weakening tests, or by
|
|
81
|
+
editing `.harness/gates.*` or `.claude/`.
|
|
82
|
+
- Never invert the intent of an existing test to match the code. If an
|
|
83
|
+
existing test conflicts with the code or cannot pass honestly, stop and
|
|
84
|
+
report the conflict; the human decides which one is wrong. A confirmed bug
|
|
85
|
+
that is out of scope is recorded with `it.failing` and an exact assertion.
|
|
86
|
+
|
|
87
|
+
## When the user talks to you through a channel (Telegram)
|
|
88
|
+
|
|
89
|
+
You are the session the user reaches from the channel. You plan and start
|
|
90
|
+
work; the task loop implements it.
|
|
91
|
+
|
|
92
|
+
- A request for a change becomes an OpenSpec change (`/opsx:propose`) and
|
|
93
|
+
ends with `.harness/spec-pr.sh <change-id>` (workflow steps 1–2). Reply in
|
|
94
|
+
the channel with the PR link and a short summary: why, the requirements and
|
|
95
|
+
scenarios, the tasks, and any behaviour change. Then wait.
|
|
96
|
+
- Review comments on the spec PR or in the channel are addressed on
|
|
97
|
+
`spec/<change-id>` and pushed with `.harness/spec-pr.sh <change-id>`; reply
|
|
98
|
+
with what changed.
|
|
99
|
+
- Merging the spec PR starts the work: the spec PR watcher (ADR 0017) runs
|
|
100
|
+
`.harness/loop.sh start <change-id>` and notifies the channel, and it also
|
|
101
|
+
addresses the user's review comments on the spec PR by itself. With
|
|
102
|
+
`HARNESS_WATCH_PR_HEALTH=fix` it also fixes failing checks and conflicts on
|
|
103
|
+
every PR and handles review comments on implementation and sync PRs
|
|
104
|
+
(ADR 0019). Do not start the loop again or address those comments in this
|
|
105
|
+
session. If the user asks
|
|
106
|
+
you to start and `.harness/loop.sh status <change-id>` shows no loop (no
|
|
107
|
+
watcher on this machine), check the merge with `gh pr view spec/<change-id>`
|
|
108
|
+
and run `.harness/loop.sh start <change-id>`, then reply that it started.
|
|
109
|
+
The loop works in its own git worktree, so you can keep writing other specs
|
|
110
|
+
here meanwhile; never edit that worktree yourself. Never merge the spec PR,
|
|
111
|
+
never implement the tasks yourself in this session, and never approve on
|
|
112
|
+
the user's behalf.
|
|
113
|
+
- The loop reports progress to the channel by itself (task passed, paused by
|
|
114
|
+
the usage limit, escalated, done with the PR link). When asked for status,
|
|
115
|
+
answer from `.harness/loop.sh status <change-id>`.
|
|
116
|
+
- If the loop escalates, read `.harness/status.sh <change-id>` and the last
|
|
117
|
+
gate failure, explain the cause in the channel and propose the next step.
|
|
118
|
+
The work in progress stays uncommitted in the loop worktree (path in the
|
|
119
|
+
runner log); the loop refuses to restart until it is finished or discarded.
|
|
120
|
+
- After the user merges the implementation PR, archive the change in a new
|
|
121
|
+
branch and PR.
|
|
122
|
+
- Every comment you post on a pull request ends with the line
|
|
123
|
+
`<!-- harness-agent -->`: GitHub may show it under the user's account, and
|
|
124
|
+
the watcher ignores marked comments instead of treating them as review.
|
|
125
|
+
|
|
126
|
+
## When running inside the task loop
|
|
127
|
+
|
|
128
|
+
The loop prompt tells you when you are in it. In that case:
|
|
129
|
+
|
|
130
|
+
- Implement **only** the assigned task. Do not start other tasks.
|
|
131
|
+
- Do **not** edit `tasks.md`, commit, or push; the loop does that.
|
|
132
|
+
- If the task is impossible or the spec is ambiguous, stop and explain why in
|
|
133
|
+
your final message instead of guessing.
|
|
134
|
+
|
|
135
|
+
## Project
|
|
136
|
+
|
|
137
|
+
<!-- Architecture overview, key commands, domain vocabulary, gotchas. -->
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
schema: spec-driven
|
|
2
|
+
|
|
3
|
+
context: |
|
|
4
|
+
Language: English. All artifacts must be written in English.
|
|
5
|
+
Keep OpenSpec structural headings and SHALL/MUST keywords in English.
|
|
6
|
+
|
|
7
|
+
This project follows the agent-harness standards. Read them before writing
|
|
8
|
+
any artifact and do not contradict them:
|
|
9
|
+
- .harness/standards/*.md shared: conventions, testing, dod, stack
|
|
10
|
+
- .harness/standards/stack/*.md this product's stack: stack, architecture,
|
|
11
|
+
conventions, testing
|
|
12
|
+
Done means `.harness/gates.sh` exits 0 (see dod.md).
|
|
13
|
+
|
|
14
|
+
rules:
|
|
15
|
+
proposal:
|
|
16
|
+
- Include a "Non-goals" section.
|
|
17
|
+
- Call out any behaviour change to existing code explicitly.
|
|
18
|
+
specs:
|
|
19
|
+
- Every requirement has at least one "#### Scenario:" that maps to an automated test.
|
|
20
|
+
- Security-relevant requirements include a negative scenario (what must be rejected).
|
|
21
|
+
- To change an existing requirement use MODIFIED with its full text; do not ADD a contradicting one.
|
|
22
|
+
design:
|
|
23
|
+
- "Packages the change adds go in a '## Dependencies' section, one bullet each: '- dependency: <name>@<x.y.z>' or '- devDependency: <name>@<x.y.z>' (exact versions; '- allow-build: <name>' only when a package must run its install script). The task loop installs exactly these; anything else escalates."
|
|
24
|
+
tasks:
|
|
25
|
+
- Do not write tasks that add, remove or update packages; the loop installs the dependencies declared in design.md before the first task.
|
|
26
|
+
- 'Mark a task <!-- level: R3 --> at the end of its line when it needs design judgement the spec cannot fully pin down, <!-- level: R1 --> when it is a mechanical edit; leave it out otherwise (R2). The loop picks the model from the level (ADR 0020).'
|
|
27
|
+
- Use checkbox lines "- [ ] N.M <description>"; the task loop parses this exact format.
|
|
28
|
+
- Start each task with a short imperative title sentence; it becomes the commit subject.
|
|
29
|
+
- Each task is small, independently implementable, and leaves every gate green.
|
|
30
|
+
- Each task names the files it touches and the tests it adds.
|
|
31
|
+
- Do not include tasks for manual QA, deployment or archiving.
|
|
32
|
+
|
|
33
|
+
operations:
|
|
34
|
+
apply:
|
|
35
|
+
guidance:
|
|
36
|
+
- Only the harness task loop applies changes (.harness/loop.sh start <change-id>), after the spec PR is merged.
|
|
37
|
+
- Implement one task at a time; run .harness/gates.sh before finishing.
|
|
38
|
+
- Never change what an existing test asserts; report conflicts instead.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Gate commands for a NestJS service created from data-matters-io/nestjs-new-app.
|
|
2
|
+
# Sourced by .harness/gates.sh.
|
|
3
|
+
#
|
|
4
|
+
# Gates call node_modules/.bin directly instead of `pnpm exec`: pnpm 11 runs a
|
|
5
|
+
# dependency check before `exec` that may trigger `pnpm install`, and a gate must
|
|
6
|
+
# never install or mutate anything. Dependencies are installed beforehand
|
|
7
|
+
# (`make setup` locally, `pnpm install --frozen-lockfile` in CI).
|
|
8
|
+
bin="node_modules/.bin"
|
|
9
|
+
|
|
10
|
+
GATE_SETUP="$bin/prisma generate"
|
|
11
|
+
|
|
12
|
+
# Coverage floor: set just below the template's measured coverage. Raise it
|
|
13
|
+
# as coverage grows; never lower it (standards/testing.md).
|
|
14
|
+
GATE_TEST="$bin/jest --silent --coverage --coverageThreshold='{\"global\":{\"lines\":85,\"statements\":85,\"branches\":85,\"functions\":85}}'"
|
|
15
|
+
# Read-only and zero warnings: warnings still count as not done.
|
|
16
|
+
GATE_LINT="$bin/eslint . --max-warnings 0"
|
|
17
|
+
# The root tsconfig also covers tests (tsconfig.build.json excludes them).
|
|
18
|
+
GATE_TYPECHECK="$bin/tsc --noEmit -p tsconfig.json"
|
|
19
|
+
GATE_BUILD="$bin/nest build"
|
|
20
|
+
|
|
21
|
+
# Stop hook runs the fast gates; e2e and integration run in the loop and CI.
|
|
22
|
+
STOP_GATES=(lint typecheck test)
|
|
23
|
+
|
|
24
|
+
GATE_EXTRA=(
|
|
25
|
+
"e2e:NODE_OPTIONS=--experimental-vm-modules $bin/jest --silent --config ./test/jest-e2e.json --runInBand"
|
|
26
|
+
# Integration tests start PostgreSQL with Testcontainers: needs Docker or Podman.
|
|
27
|
+
"integration:NODE_OPTIONS=--experimental-vm-modules $bin/jest --silent --config ./test/jest-integration.json --runInBand"
|
|
28
|
+
# Database smoke (ADR 0016): migrations and seed on an empty PostgreSQL, through
|
|
29
|
+
# the app's Prisma setup. Throwaway container locally (HARNESS_SKIP_GATES=db
|
|
30
|
+
# without one); the postgres service of gates.yml in CI.
|
|
31
|
+
"db:.harness/db-smoke.sh '$bin/prisma migrate deploy' '$bin/prisma db seed'"
|
|
32
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
timeout-minutes: 20
|
|
12
|
+
# PostgreSQL for the db gate (.harness/db-smoke.sh, ADR 0016): migrations and
|
|
13
|
+
# seed run here, inside the required gates job.
|
|
14
|
+
services:
|
|
15
|
+
postgres:
|
|
16
|
+
image: postgres:16-alpine
|
|
17
|
+
env:
|
|
18
|
+
POSTGRES_USER: smoke
|
|
19
|
+
POSTGRES_PASSWORD: smoke
|
|
20
|
+
POSTGRES_DB: smoke
|
|
21
|
+
ports:
|
|
22
|
+
- 5432:5432
|
|
23
|
+
options: >-
|
|
24
|
+
--health-cmd "pg_isready -U smoke -d smoke"
|
|
25
|
+
--health-interval 5s
|
|
26
|
+
--health-timeout 5s
|
|
27
|
+
--health-retries 20
|
|
28
|
+
env:
|
|
29
|
+
HARNESS_DB_SMOKE_URL: postgresql://smoke:smoke@localhost:5432/smoke
|
|
30
|
+
steps:
|
|
31
|
+
# fetch-depth 2: HEAD is the PR merge commit and HEAD^1 its base, enough
|
|
32
|
+
# to list the files the pull request changes.
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
with:
|
|
35
|
+
fetch-depth: 2
|
|
36
|
+
|
|
37
|
+
# Reads the exact pnpm version from package.json#packageManager (ADR 0014).
|
|
38
|
+
# pnpm 12 needs v6.1.0+; the floating @v6 tag is older.
|
|
39
|
+
- uses: pnpm/action-setup@v6.1.0
|
|
40
|
+
|
|
41
|
+
- uses: actions/setup-node@v4
|
|
42
|
+
with:
|
|
43
|
+
node-version: 22
|
|
44
|
+
cache: pnpm
|
|
45
|
+
|
|
46
|
+
- name: Install dependencies
|
|
47
|
+
run: pnpm install --frozen-lockfile
|
|
48
|
+
|
|
49
|
+
# A harness installed from the package (ADR 0021) is generated by the
|
|
50
|
+
# install's "prepare"; a missing or stale one fails here.
|
|
51
|
+
- name: Check the harness
|
|
52
|
+
run: if [[ -x node_modules/.bin/harness ]]; then node_modules/.bin/harness check; fi
|
|
53
|
+
|
|
54
|
+
# After the install: with the package, .harness/ is generated by it.
|
|
55
|
+
# Spec-only PRs (ADR 0011, 0013) validate the specs instead of running the
|
|
56
|
+
# DoD gates; pr-scope.sh also fails a spec/<id> PR that changes anything
|
|
57
|
+
# outside openspec/changes/<id>/. Pushes to main always run the gates.
|
|
58
|
+
- name: Classify pull request
|
|
59
|
+
id: scope
|
|
60
|
+
run: .harness/pr-scope.sh >>"$GITHUB_OUTPUT"
|
|
61
|
+
|
|
62
|
+
# OpenSpec is a pinned devDependency (.harness/openspec.sh).
|
|
63
|
+
- name: Validate OpenSpec changes and specs
|
|
64
|
+
if: steps.scope.outputs.spec_only == 'true'
|
|
65
|
+
run: .harness/openspec.sh validate --all --strict --no-interactive
|
|
66
|
+
|
|
67
|
+
# The integration gate starts PostgreSQL with Testcontainers, using the
|
|
68
|
+
# runner's Docker daemon.
|
|
69
|
+
- name: Run DoD gates
|
|
70
|
+
if: steps.scope.outputs.spec_only != 'true'
|
|
71
|
+
run: .harness/gates.sh
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Gate commands for a Next.js product created from data-matters-io/nextjs-new-app.
|
|
2
|
+
# Sourced by .harness/gates.sh. Validated against nextjs-new-app@61c7a10.
|
|
3
|
+
|
|
4
|
+
# Gates call node_modules/.bin directly instead of `pnpm exec`: pnpm 11 runs a
|
|
5
|
+
# dependency check before `exec` that may trigger `pnpm install`, and a gate must
|
|
6
|
+
# never install or mutate anything. Dependencies are installed beforehand
|
|
7
|
+
# (`make install` locally, `pnpm install --frozen-lockfile` in CI).
|
|
8
|
+
bin="node_modules/.bin"
|
|
9
|
+
|
|
10
|
+
# `prisma generate` resolves DATABASE_URL from the config even though it never
|
|
11
|
+
# connects; a placeholder keeps gates working in clean environments (CI, VPS, loop).
|
|
12
|
+
export DATABASE_URL="${DATABASE_URL:-postgresql://gate:gate@localhost:5432/gate}"
|
|
13
|
+
GATE_SETUP="$bin/prisma generate"
|
|
14
|
+
|
|
15
|
+
# Coverage floor: set just below the template's measured coverage. Raise it
|
|
16
|
+
# as coverage grows; never lower it (standards/testing.md).
|
|
17
|
+
GATE_TEST="$bin/jest --silent --coverageThreshold='{\"global\":{\"lines\":85,\"statements\":85,\"branches\":85,\"functions\":85}}'"
|
|
18
|
+
# Read-only and zero warnings: warnings are style/quality rules from
|
|
19
|
+
# @data-matters.io/eslint-config and still count as not done.
|
|
20
|
+
GATE_LINT="$bin/eslint . --max-warnings 0"
|
|
21
|
+
GATE_TYPECHECK="$bin/tsc --noEmit"
|
|
22
|
+
GATE_BUILD="$bin/next build"
|
|
23
|
+
|
|
24
|
+
# Stop hook runs the fast gates (~40s); build (~60s) runs in the loop and CI.
|
|
25
|
+
STOP_GATES=(lint typecheck test)
|
|
26
|
+
|
|
27
|
+
GATE_EXTRA=(
|
|
28
|
+
# Database smoke (ADR 0016): migrations and seed on an empty PostgreSQL, through
|
|
29
|
+
# the app's Prisma setup. Throwaway container locally (HARNESS_SKIP_GATES=db
|
|
30
|
+
# without one); the postgres service of gates.yml in CI.
|
|
31
|
+
"db:.harness/db-smoke.sh '$bin/prisma migrate deploy' '$bin/prisma db seed'"
|
|
32
|
+
# "security:pnpm audit --audit-level high"
|
|
33
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
# PostgreSQL for the db gate (.harness/db-smoke.sh, ADR 0016): migrations and
|
|
12
|
+
# seed run here, inside the required gates job.
|
|
13
|
+
services:
|
|
14
|
+
postgres:
|
|
15
|
+
image: postgres:16-alpine
|
|
16
|
+
env:
|
|
17
|
+
POSTGRES_USER: smoke
|
|
18
|
+
POSTGRES_PASSWORD: smoke
|
|
19
|
+
POSTGRES_DB: smoke
|
|
20
|
+
ports:
|
|
21
|
+
- 5432:5432
|
|
22
|
+
options: >-
|
|
23
|
+
--health-cmd "pg_isready -U smoke -d smoke"
|
|
24
|
+
--health-interval 5s
|
|
25
|
+
--health-timeout 5s
|
|
26
|
+
--health-retries 20
|
|
27
|
+
env:
|
|
28
|
+
HARNESS_DB_SMOKE_URL: postgresql://smoke:smoke@localhost:5432/smoke
|
|
29
|
+
steps:
|
|
30
|
+
# fetch-depth 2: HEAD is the PR merge commit and HEAD^1 its base, enough
|
|
31
|
+
# to list the files the pull request changes.
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 2
|
|
35
|
+
|
|
36
|
+
# Reads the exact pnpm version from package.json#packageManager (ADR 0014).
|
|
37
|
+
# pnpm 12 needs v6.1.0+; the floating @v6 tag is older.
|
|
38
|
+
- uses: pnpm/action-setup@v6.1.0
|
|
39
|
+
|
|
40
|
+
- uses: actions/setup-node@v4
|
|
41
|
+
with:
|
|
42
|
+
node-version: 24
|
|
43
|
+
cache: pnpm
|
|
44
|
+
|
|
45
|
+
- name: Install dependencies
|
|
46
|
+
run: pnpm install --frozen-lockfile
|
|
47
|
+
|
|
48
|
+
# A harness installed from the package (ADR 0021) is generated by the
|
|
49
|
+
# install's "prepare"; a missing or stale one fails here.
|
|
50
|
+
- name: Check the harness
|
|
51
|
+
run: if [[ -x node_modules/.bin/harness ]]; then node_modules/.bin/harness check; fi
|
|
52
|
+
|
|
53
|
+
# After the install: with the package, .harness/ is generated by it.
|
|
54
|
+
# Spec-only PRs (ADR 0011, 0013) validate the specs instead of running the
|
|
55
|
+
# DoD gates; pr-scope.sh also fails a spec/<id> PR that changes anything
|
|
56
|
+
# outside openspec/changes/<id>/. Pushes to main always run the gates.
|
|
57
|
+
- name: Classify pull request
|
|
58
|
+
id: scope
|
|
59
|
+
run: .harness/pr-scope.sh >>"$GITHUB_OUTPUT"
|
|
60
|
+
|
|
61
|
+
# OpenSpec is a pinned devDependency (.harness/openspec.sh).
|
|
62
|
+
- name: Validate OpenSpec changes and specs
|
|
63
|
+
if: steps.scope.outputs.spec_only == 'true'
|
|
64
|
+
run: .harness/openspec.sh validate --all --strict --no-interactive
|
|
65
|
+
|
|
66
|
+
- name: Run DoD gates
|
|
67
|
+
if: steps.scope.outputs.spec_only != 'true'
|
|
68
|
+
run: .harness/gates.sh
|