@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
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @data-matters.io/agent-harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Spec-Driven Development with Claude Code, installed into a product repository:
|
|
4
|
+
the Definition of Done as executable gates, a task loop that implements an
|
|
5
|
+
approved OpenSpec change one task per session, and the standards the agent
|
|
6
|
+
follows.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Pin the package exactly and let the project's install generate the harness.
|
|
11
|
+
pnpm refuses a version younger than its minimum release age: to adopt a
|
|
12
|
+
release on its first day, list it in `pnpm-workspace.yaml` first.
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
minimumReleaseAgeExclude:
|
|
16
|
+
- '@data-matters.io/agent-harness@<version>'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add -D -E @data-matters.io/agent-harness
|
|
21
|
+
pnpm exec harness init --new --stack nextjs # or nestjs; --existing for a codebase without tests
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{ "scripts": { "prepare": "harness install" } }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`harness init` also writes `.github/dependabot.yml`: weekly bumps of the
|
|
29
|
+
harness only, after a 2-day cooldown, so each bump is past pnpm's minimum
|
|
30
|
+
release age. `harness install` writes the generated files (`.harness/*.sh`,
|
|
31
|
+
`.harness/standards/`, `.harness/CLAUDE.harness.md`, the Claude Code Stop hook)
|
|
32
|
+
and they stay git-ignored, so they always match the pinned version.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | What it does |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `harness init` | First adoption: the project-owned and seeded files, `CLAUDE.md`, the `.gitignore` entries |
|
|
39
|
+
| `harness install` | (Re)generates the git-ignored files; idempotent |
|
|
40
|
+
| `harness check` | Fails when the generated files are stale or edited, or a required file is missing |
|
|
41
|
+
| `harness update-files` | Three-way merge of the seeded files (`.claude/settings.json`, `openspec/config.yaml`) after a bump |
|
|
42
|
+
| `harness pr-scope` | Classifies a pull request for CI (spec-only or not) without installing the project |
|
|
43
|
+
|
|
44
|
+
Run `harness --help` for the options.
|
package/bin/harness
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The harness CLI (ADR 0021): installs agent-harness into a product repository
|
|
3
|
+
# from the @data-matters.io/agent-harness package pinned in its devDependencies.
|
|
4
|
+
#
|
|
5
|
+
# Usage: harness <command> [options] (run from the product repository root)
|
|
6
|
+
#
|
|
7
|
+
# init (--new | --existing | --template) [--name <name>] [--stack <stack>]
|
|
8
|
+
# first adoption: writes the project-owned and seeded files
|
|
9
|
+
# (with .github/dependabot.yml), the .gitignore entries,
|
|
10
|
+
# CLAUDE.md and this version's minimumReleaseAgeExclude entry
|
|
11
|
+
# in pnpm-workspace.yaml, then runs `install`.
|
|
12
|
+
# Never overwrites an existing file; conflicts are reported.
|
|
13
|
+
# install (re)generates the git-ignored harness files from this
|
|
14
|
+
# package; idempotent. Run by the project's
|
|
15
|
+
# "prepare": "harness install".
|
|
16
|
+
# check fails when a generated file is missing or differs from this
|
|
17
|
+
# package, a generated file is tracked by git, a .gitignore
|
|
18
|
+
# entry or a required committed file is missing, or a seeded
|
|
19
|
+
# file needs `update-files`. Reports, without failing, drift
|
|
20
|
+
# of the project-owned gate files from the stack preset.
|
|
21
|
+
# update-files three-way merge of the seeded, committed files
|
|
22
|
+
# (.claude/settings.json, openspec/config.yaml) with this
|
|
23
|
+
# package's templates; exits 2 on a conflict.
|
|
24
|
+
# pr-scope runs this package's pr-scope.sh (CI scope job, no install).
|
|
25
|
+
# version prints the package version.
|
|
26
|
+
#
|
|
27
|
+
# Environment:
|
|
28
|
+
# HARNESS_PROJECT_DIR product repository (default: the current directory)
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
usage() { sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
|
|
32
|
+
die() { echo "harness: $*" >&2; exit 1; }
|
|
33
|
+
|
|
34
|
+
pkg="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|
35
|
+
templates="$pkg/templates"
|
|
36
|
+
project="${HARNESS_PROJECT_DIR:-$PWD}"
|
|
37
|
+
|
|
38
|
+
version() {
|
|
39
|
+
local v=""
|
|
40
|
+
[[ -f "$pkg/package.json" ]] \
|
|
41
|
+
&& v="$(sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$pkg/package.json" | head -n1)"
|
|
42
|
+
[[ -n "$v" ]] || v="$(git -C "$pkg" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
43
|
+
echo "$v"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# The .gitignore block for the generated files.
|
|
47
|
+
ignore_entries=(
|
|
48
|
+
".harness/*.sh"
|
|
49
|
+
".harness/models.conf"
|
|
50
|
+
".harness/standards/"
|
|
51
|
+
".harness/CLAUDE.harness.md"
|
|
52
|
+
".harness/.generated"
|
|
53
|
+
".claude/hooks/stop-gates.sh"
|
|
54
|
+
".harness/state/"
|
|
55
|
+
".harness/logs/"
|
|
56
|
+
)
|
|
57
|
+
# Seeded files: committed, merged by update-files against the template they
|
|
58
|
+
# were last merged from, kept in .harness/seeded/ (committed too).
|
|
59
|
+
seeded=(.claude/settings.json openspec/config.yaml)
|
|
60
|
+
seed_src() {
|
|
61
|
+
case "$1" in
|
|
62
|
+
.claude/settings.json) echo "$templates/.claude/settings.json" ;;
|
|
63
|
+
openspec/config.yaml) echo "$templates/openspec/config.yaml" ;;
|
|
64
|
+
esac
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stack_of() { cat "$project/.harness/STACK" 2>/dev/null || true; }
|
|
68
|
+
|
|
69
|
+
# render <dest>: every generated file, as this package produces it for the
|
|
70
|
+
# project's stack. Prints their paths, relative to <dest>.
|
|
71
|
+
render() {
|
|
72
|
+
local dest="$1" stack f
|
|
73
|
+
stack="$(stack_of)"
|
|
74
|
+
mkdir -p "$dest/.harness/standards" "$dest/.claude/hooks"
|
|
75
|
+
for f in "$templates"/.harness/*.sh; do
|
|
76
|
+
cp "$f" "$dest/.harness/$(basename "$f")"
|
|
77
|
+
done
|
|
78
|
+
cp "$templates/.harness/models.conf" "$dest/.harness/models.conf"
|
|
79
|
+
cp "$templates/.claude/hooks/stop-gates.sh" "$dest/.claude/hooks/stop-gates.sh"
|
|
80
|
+
chmod +x "$dest"/.harness/*.sh "$dest"/.claude/hooks/*.sh
|
|
81
|
+
local imports=()
|
|
82
|
+
for f in "$pkg"/standards/*.md; do
|
|
83
|
+
cp "$f" "$dest/.harness/standards/"
|
|
84
|
+
imports+=("@standards/$(basename "$f")")
|
|
85
|
+
done
|
|
86
|
+
if [[ -n "$stack" ]]; then
|
|
87
|
+
[[ -d "$pkg/standards/stacks/$stack" ]] || die "unknown stack '$stack' in .harness/STACK"
|
|
88
|
+
mkdir -p "$dest/.harness/standards/stack"
|
|
89
|
+
for f in "$pkg/standards/stacks/$stack"/*.md; do
|
|
90
|
+
[[ -e "$f" ]] || continue
|
|
91
|
+
cp "$f" "$dest/.harness/standards/stack/"
|
|
92
|
+
imports+=("@standards/stack/$(basename "$f")")
|
|
93
|
+
done
|
|
94
|
+
fi
|
|
95
|
+
render_claude "$stack" "${imports[@]}" >"$dest/.harness/CLAUDE.harness.md"
|
|
96
|
+
(cd "$dest" && find . -type f | sed 's|^\./||' | sort)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# render_claude <stack> <import...>: the harness part of CLAUDE.md, i.e.
|
|
100
|
+
# CLAUDE.md.tmpl from its first section to the "## Project" one. Imports are
|
|
101
|
+
# relative to .harness/, where the file lives.
|
|
102
|
+
render_claude() {
|
|
103
|
+
local stack="$1" line on=0; shift
|
|
104
|
+
echo "<!-- Generated by \`harness install\` from @data-matters.io/agent-harness"
|
|
105
|
+
echo " $(version). Do not edit: the next install overwrites it. -->"
|
|
106
|
+
echo
|
|
107
|
+
while IFS= read -r line; do
|
|
108
|
+
[[ "$line" == "## Project" ]] && break
|
|
109
|
+
[[ "$line" == "## "* ]] && on=1
|
|
110
|
+
(( on )) || continue
|
|
111
|
+
if [[ "$line" == "{{STANDARDS_IMPORTS}}" ]]; then
|
|
112
|
+
printf '%s\n' "$@"
|
|
113
|
+
if [[ -n "$stack" && -f "$templates/stacks/$stack/claude-imports.md" ]]; then
|
|
114
|
+
echo
|
|
115
|
+
sed -E 's|^@\.harness/|@|; t; s|^@|@../|' "$templates/stacks/$stack/claude-imports.md"
|
|
116
|
+
fi
|
|
117
|
+
continue
|
|
118
|
+
fi
|
|
119
|
+
printf '%s\n' "$line"
|
|
120
|
+
done <"$templates/CLAUDE.md.tmpl" | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
initialized() { [[ -f "$project/.harness/autonomy" ]]; }
|
|
124
|
+
|
|
125
|
+
# release_age_exclude <version>: lists this package's exact version in
|
|
126
|
+
# pnpm-workspace.yaml's minimumReleaseAgeExclude. pnpm refuses a version younger
|
|
127
|
+
# than its minimum release age, also in a frozen CI install, and a harness
|
|
128
|
+
# release is installed the day it is published. Prints added, present, or
|
|
129
|
+
# manual (an inline list this cannot edit safely).
|
|
130
|
+
release_age_exclude() {
|
|
131
|
+
local f="$project/pnpm-workspace.yaml" entry="'@data-matters.io/agent-harness@$1'"
|
|
132
|
+
if grep -qF "@data-matters.io/agent-harness@$1" "$f" 2>/dev/null; then echo present; return; fi
|
|
133
|
+
if grep -qE '^minimumReleaseAgeExclude:[[:space:]]*$' "$f" 2>/dev/null; then
|
|
134
|
+
sed -i "/^minimumReleaseAgeExclude:[[:space:]]*\$/a\\ - $entry" "$f"
|
|
135
|
+
elif grep -q '^minimumReleaseAgeExclude:' "$f" 2>/dev/null; then
|
|
136
|
+
echo manual; return
|
|
137
|
+
else
|
|
138
|
+
[[ ! -s "$f" || -z "$(tail -c1 "$f")" ]] || echo >>"$f"
|
|
139
|
+
printf 'minimumReleaseAgeExclude:\n - %s\n' "$entry" >>"$f"
|
|
140
|
+
fi
|
|
141
|
+
echo added
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cmd_install() {
|
|
145
|
+
if ! initialized; then
|
|
146
|
+
echo "harness: $project is not initialized (no .harness/autonomy); run \`harness init\`"
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
local tmp p
|
|
150
|
+
tmp="$(mktemp -d)"
|
|
151
|
+
render "$tmp/out" >"$tmp/list"
|
|
152
|
+
# Generated files this version no longer ships.
|
|
153
|
+
if [[ -f "$project/.harness/.generated" ]]; then
|
|
154
|
+
while IFS= read -r p; do
|
|
155
|
+
if [[ -n "$p" ]] && ! grep -qxF "$p" "$tmp/list"; then rm -f "$project/$p"; fi
|
|
156
|
+
done <"$project/.harness/.generated"
|
|
157
|
+
fi
|
|
158
|
+
(cd "$tmp/out" && cp -R --preserve=mode . "$project/")
|
|
159
|
+
cp "$tmp/list" "$project/.harness/.generated"
|
|
160
|
+
rm -rf "$tmp"
|
|
161
|
+
echo "harness: agent-harness $(version) installed in $project"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
cmd_check() {
|
|
165
|
+
local tmp p problems=() notes=() stack src tracked
|
|
166
|
+
for p in .harness/STACK .harness/autonomy .harness/gates.conf .github/workflows/gates.yml CLAUDE.md "${seeded[@]}"; do
|
|
167
|
+
[[ -f "$project/$p" ]] || problems+=("$p: missing (committed file; \`harness init\` writes it)")
|
|
168
|
+
done
|
|
169
|
+
[[ -f "$project/.harness/VERSION" ]] \
|
|
170
|
+
&& problems+=(".harness/VERSION: left from sync-project.sh; remove it (the version is the devDependency)")
|
|
171
|
+
if [[ -f "$project/CLAUDE.md" ]] && ! grep -qxF '@.harness/CLAUDE.harness.md' "$project/CLAUDE.md"; then
|
|
172
|
+
problems+=("CLAUDE.md: does not import the harness part (add the line @.harness/CLAUDE.harness.md)")
|
|
173
|
+
fi
|
|
174
|
+
for p in "${ignore_entries[@]}"; do
|
|
175
|
+
grep -qxF "$p" "$project/.gitignore" 2>/dev/null || problems+=(".gitignore: missing entry $p")
|
|
176
|
+
done
|
|
177
|
+
tmp="$(mktemp -d)"
|
|
178
|
+
render "$tmp/out" >"$tmp/list"
|
|
179
|
+
if git -C "$project" rev-parse --git-dir >/dev/null 2>&1; then
|
|
180
|
+
tracked="$(cd "$project" && xargs -r git ls-files -- <"$tmp/list")"
|
|
181
|
+
[[ -z "$tracked" ]] || while IFS= read -r p; do
|
|
182
|
+
problems+=("$p: generated but tracked by git (git rm --cached $p)")
|
|
183
|
+
done <<<"$tracked"
|
|
184
|
+
fi
|
|
185
|
+
while IFS= read -r p; do
|
|
186
|
+
if [[ ! -f "$project/$p" ]]; then problems+=("$p: not installed (run harness install)")
|
|
187
|
+
elif ! cmp -s "$project/$p" "$tmp/out/$p"; then problems+=("$p: differs from agent-harness $(version) (run harness install; never edit it)")
|
|
188
|
+
fi
|
|
189
|
+
done <"$tmp/list"
|
|
190
|
+
for p in "${seeded[@]}"; do
|
|
191
|
+
src="$(seed_src "$p")"
|
|
192
|
+
if [[ ! -f "$project/.harness/seeded/$p" ]]; then problems+=(".harness/seeded/$p: missing merge base (run harness update-files)")
|
|
193
|
+
elif ! cmp -s "$project/.harness/seeded/$p" "$src"; then problems+=("$p: the template changed (run harness update-files and commit the result)")
|
|
194
|
+
fi
|
|
195
|
+
done
|
|
196
|
+
stack="$(stack_of)"
|
|
197
|
+
if [[ -n "$stack" && -d "$templates/stacks/$stack" ]]; then
|
|
198
|
+
for p in .harness/gates.conf:gates.conf .github/workflows/gates.yml:gates.yml; do
|
|
199
|
+
if [[ -f "$project/${p%%:*}" ]] && ! cmp -s "$project/${p%%:*}" "$templates/stacks/$stack/${p#*:}"; then
|
|
200
|
+
notes+=("${p%%:*}: differs from the $stack preset (project-owned; review templates/stacks/$stack/${p#*:})")
|
|
201
|
+
fi
|
|
202
|
+
done
|
|
203
|
+
fi
|
|
204
|
+
[[ -f "$project/.github/dependabot.yml" ]] \
|
|
205
|
+
|| notes+=(".github/dependabot.yml: missing; harness bumps arrive as Dependabot PRs (templates/.github/dependabot.yml)")
|
|
206
|
+
rm -rf "$tmp"
|
|
207
|
+
(( ${#notes[@]} == 0 )) || printf 'harness: note: %s\n' "${notes[@]}"
|
|
208
|
+
if (( ${#problems[@]} > 0 )); then
|
|
209
|
+
printf 'harness: %s\n' "${problems[@]}" >&2
|
|
210
|
+
echo "harness: check failed (${#problems[@]} problem(s)) against agent-harness $(version)" >&2
|
|
211
|
+
return 1
|
|
212
|
+
fi
|
|
213
|
+
echo "harness: check passed (agent-harness $(version))"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
cmd_update_files() {
|
|
217
|
+
local p src base local_file conflicts=0
|
|
218
|
+
for p in "${seeded[@]}"; do
|
|
219
|
+
src="$(seed_src "$p")"
|
|
220
|
+
base="$project/.harness/seeded/$p"
|
|
221
|
+
local_file="$project/$p"
|
|
222
|
+
mkdir -p "$(dirname "$base")" "$(dirname "$local_file")"
|
|
223
|
+
if [[ ! -f "$local_file" ]]; then
|
|
224
|
+
cp "$src" "$local_file"; echo " $p: added"
|
|
225
|
+
elif cmp -s "$local_file" "$src"; then
|
|
226
|
+
:
|
|
227
|
+
elif [[ ! -f "$base" ]]; then
|
|
228
|
+
cp "$src" "$local_file.harness-new"
|
|
229
|
+
echo " $p: CONFLICT (customized, no merge base; new version in $p.harness-new)"
|
|
230
|
+
conflicts=$((conflicts + 1))
|
|
231
|
+
continue
|
|
232
|
+
elif cmp -s "$base" "$src"; then
|
|
233
|
+
continue
|
|
234
|
+
elif cmp -s "$local_file" "$base"; then
|
|
235
|
+
cp "$src" "$local_file"; echo " $p: updated"
|
|
236
|
+
elif git merge-file -L local -L base -L "agent-harness $(version)" "$local_file" "$base" "$src"; then
|
|
237
|
+
echo " $p: merged (local changes kept)"
|
|
238
|
+
else
|
|
239
|
+
echo " $p: CONFLICT (markers in the file)"
|
|
240
|
+
conflicts=$((conflicts + 1))
|
|
241
|
+
fi
|
|
242
|
+
cp "$src" "$base"
|
|
243
|
+
done
|
|
244
|
+
if (( conflicts > 0 )); then
|
|
245
|
+
echo "harness: $conflicts conflict(s) to resolve before committing" >&2
|
|
246
|
+
return 2
|
|
247
|
+
fi
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
cmd_init() {
|
|
251
|
+
local mode="" name="" stack="" conflicts=() gates_conf_src ci_src p
|
|
252
|
+
while [[ $# -gt 0 ]]; do
|
|
253
|
+
case "$1" in
|
|
254
|
+
--new|--existing|--template) mode="${1#--}"; shift ;;
|
|
255
|
+
--name) name="$2"; shift 2 ;;
|
|
256
|
+
--stack) stack="$2"; shift 2 ;;
|
|
257
|
+
*) die "init: unexpected argument: $1" ;;
|
|
258
|
+
esac
|
|
259
|
+
done
|
|
260
|
+
[[ -n "$mode" ]] || usage 1
|
|
261
|
+
[[ ! -f "$project/.harness/VERSION" ]] \
|
|
262
|
+
|| die "init: $project has a harness installed by sync-project.sh (.harness/VERSION); migrate it instead"
|
|
263
|
+
gates_conf_src="$templates/.harness/gates.conf.example"
|
|
264
|
+
ci_src="$templates/.github/workflows/gates.yml"
|
|
265
|
+
if [[ -n "$stack" ]]; then
|
|
266
|
+
[[ -d "$templates/stacks/$stack" ]] || die "init: unknown stack '$stack' (available: $(ls "$templates/stacks" | tr '\n' ' '))"
|
|
267
|
+
gates_conf_src="$templates/stacks/$stack/gates.conf"
|
|
268
|
+
ci_src="$templates/stacks/$stack/gates.yml"
|
|
269
|
+
fi
|
|
270
|
+
name="${name:-$(basename "$project")}"
|
|
271
|
+
[[ "$mode" == template ]] && name="__REPO_NAME__"
|
|
272
|
+
|
|
273
|
+
# seed <src> <dest>: writes a committed file unless it exists.
|
|
274
|
+
seed() {
|
|
275
|
+
if [[ -e "$project/$2" ]]; then conflicts+=("$2"); return; fi
|
|
276
|
+
mkdir -p "$(dirname "$project/$2")"
|
|
277
|
+
cp "$1" "$project/$2"
|
|
278
|
+
}
|
|
279
|
+
seed "$gates_conf_src" .harness/gates.conf
|
|
280
|
+
seed "$ci_src" .github/workflows/gates.yml
|
|
281
|
+
seed "$templates/.github/dependabot.yml" .github/dependabot.yml
|
|
282
|
+
for p in "${seeded[@]}"; do
|
|
283
|
+
seed "$(seed_src "$p")" "$p"
|
|
284
|
+
mkdir -p "$(dirname "$project/.harness/seeded/$p")"
|
|
285
|
+
cp "$(seed_src "$p")" "$project/.harness/seeded/$p"
|
|
286
|
+
done
|
|
287
|
+
mkdir -p "$project/.harness"
|
|
288
|
+
echo "$stack" >"$project/.harness/STACK"
|
|
289
|
+
if [[ ! -e "$project/.harness/autonomy" ]]; then
|
|
290
|
+
if [[ "$mode" == existing ]]; then echo disabled; else echo enabled; fi >"$project/.harness/autonomy"
|
|
291
|
+
fi
|
|
292
|
+
if [[ -e "$project/CLAUDE.md" ]]; then
|
|
293
|
+
grep -qxF '@.harness/CLAUDE.harness.md' "$project/CLAUDE.md" \
|
|
294
|
+
|| conflicts+=("CLAUDE.md (add the line @.harness/CLAUDE.harness.md above the project section)")
|
|
295
|
+
else
|
|
296
|
+
cat >"$project/CLAUDE.md" <<EOF
|
|
297
|
+
# CLAUDE.md — \`$name\`
|
|
298
|
+
|
|
299
|
+
<!-- The harness part (workflow, Definition of Done, standards) is generated
|
|
300
|
+
by \`harness install\` from @data-matters.io/agent-harness into
|
|
301
|
+
.harness/CLAUDE.harness.md; never copy it here. Project-specific context
|
|
302
|
+
goes in the "Project" section. -->
|
|
303
|
+
|
|
304
|
+
@.harness/CLAUDE.harness.md
|
|
305
|
+
|
|
306
|
+
## Project
|
|
307
|
+
|
|
308
|
+
<!-- Architecture overview, key commands, domain vocabulary, gotchas. -->
|
|
309
|
+
EOF
|
|
310
|
+
fi
|
|
311
|
+
touch "$project/.gitignore"
|
|
312
|
+
if ! grep -qxF "${ignore_entries[0]}" "$project/.gitignore"; then
|
|
313
|
+
[[ ! -s "$project/.gitignore" ]] || echo >>"$project/.gitignore"
|
|
314
|
+
echo "# agent-harness: generated by \`harness install\` (ADR 0021)" >>"$project/.gitignore"
|
|
315
|
+
fi
|
|
316
|
+
for p in "${ignore_entries[@]}"; do
|
|
317
|
+
grep -qxF "$p" "$project/.gitignore" || echo "$p" >>"$project/.gitignore"
|
|
318
|
+
done
|
|
319
|
+
# A release-version (not a git commit) is excluded from pnpm's minimum age.
|
|
320
|
+
if [[ "$(version)" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
|
321
|
+
case "$(release_age_exclude "$(version)")" in
|
|
322
|
+
added) echo "pnpm-workspace.yaml: @data-matters.io/agent-harness@$(version) added to minimumReleaseAgeExclude" ;;
|
|
323
|
+
manual) conflicts+=("pnpm-workspace.yaml (add '@data-matters.io/agent-harness@$(version)' to minimumReleaseAgeExclude)") ;;
|
|
324
|
+
esac
|
|
325
|
+
fi
|
|
326
|
+
# Next.js keeps its agent rules in AGENTS.md (ADR 0018).
|
|
327
|
+
if [[ "$stack" == nextjs ]]; then
|
|
328
|
+
echo "AGENTS.md: $("$pkg/scripts/next-agent-rules.sh" "$project")"
|
|
329
|
+
fi
|
|
330
|
+
cmd_install
|
|
331
|
+
|
|
332
|
+
echo "harness: initialized $project ($mode project${stack:+, stack: $stack})"
|
|
333
|
+
if (( ${#conflicts[@]} > 0 )); then
|
|
334
|
+
echo
|
|
335
|
+
echo "Existing files left untouched (merge manually):"
|
|
336
|
+
printf ' - %s\n' "${conflicts[@]}"
|
|
337
|
+
fi
|
|
338
|
+
# shellcheck source=scripts/toolchain.sh
|
|
339
|
+
. "$pkg/scripts/toolchain.sh"
|
|
340
|
+
cat <<EOF
|
|
341
|
+
|
|
342
|
+
Next steps:
|
|
343
|
+
1. Pin the harness and run it on every install (package.json):
|
|
344
|
+
pnpm add -D -E @data-matters.io/agent-harness@$(version)
|
|
345
|
+
"scripts": { "prepare": "harness install" }
|
|
346
|
+
pnpm refuses a release younger than its minimum release age: the version
|
|
347
|
+
is in pnpm-workspace.yaml's minimumReleaseAgeExclude; Dependabot bumps
|
|
348
|
+
(.github/dependabot.yml) wait 2 days, so later versions need no entry.
|
|
349
|
+
2. Review .harness/gates.conf, .github/workflows/gates.yml and
|
|
350
|
+
.github/dependabot.yml (project-owned).
|
|
351
|
+
3. Pin pnpm (ADR 0014): "packageManager": "pnpm@$HARNESS_PNPM_VERSION" and
|
|
352
|
+
devEngines.packageManager { name pnpm, version $HARNESS_PNPM_VERSION, onFail error }.
|
|
353
|
+
Add OpenSpec as a pinned devDependency (version in .harness/openspec.sh).
|
|
354
|
+
4. Commit the project-owned and seeded files, .harness/seeded/, CLAUDE.md,
|
|
355
|
+
.gitignore and pnpm-workspace.yaml; run harness check, then
|
|
356
|
+
.harness/gates.sh until it is green.
|
|
357
|
+
EOF
|
|
358
|
+
if [[ "$mode" == existing ]]; then
|
|
359
|
+
echo " 5. Add characterization tests for the critical flows, then: echo enabled > .harness/autonomy"
|
|
360
|
+
fi
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
[[ $# -gt 0 ]] || usage 1
|
|
364
|
+
command="$1"; shift
|
|
365
|
+
case "$command" in
|
|
366
|
+
-h|--help|help) usage 0 ;;
|
|
367
|
+
version|--version) version ;;
|
|
368
|
+
init) cmd_init "$@" ;;
|
|
369
|
+
install) [[ $# -eq 0 ]] || usage 1; cmd_install ;;
|
|
370
|
+
check) [[ $# -eq 0 ]] || usage 1; cmd_check ;;
|
|
371
|
+
update-files) [[ $# -eq 0 ]] || usage 1; cmd_update_files ;;
|
|
372
|
+
pr-scope) cd "$project" && exec bash "$templates/.harness/pr-scope.sh" "$@" ;;
|
|
373
|
+
*) echo "harness: unknown command '$command'" >&2; usage 1 ;;
|
|
374
|
+
esac
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@data-matters.io/agent-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Spec-Driven Development with Claude Code: gates, task loop and standards installed into a product repository",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/data-matters-io/data-matters-agent-harness.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"harness": "bin/harness"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/harness",
|
|
15
|
+
"templates/CLAUDE.md.tmpl",
|
|
16
|
+
"templates/.claude/",
|
|
17
|
+
"templates/.github/",
|
|
18
|
+
"templates/.harness/",
|
|
19
|
+
"templates/openspec/",
|
|
20
|
+
"templates/stacks/",
|
|
21
|
+
"standards/",
|
|
22
|
+
"scripts/next-agent-rules.sh",
|
|
23
|
+
"scripts/toolchain.sh"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"packageManager": "pnpm@12.11.2",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@changesets/cli": "3.0.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Keeps the Next.js agent-rules block in AGENTS.md, out of CLAUDE.md (ADR 0018).
|
|
3
|
+
#
|
|
4
|
+
# Usage: scripts/next-agent-rules.sh <project-dir>
|
|
5
|
+
#
|
|
6
|
+
# `next dev` (Next.js 16+) writes a managed block between
|
|
7
|
+
# <!-- BEGIN:nextjs-agent-rules --> and <!-- END:nextjs-agent-rules --> when an
|
|
8
|
+
# AI agent runs it: into AGENTS.md when that file exists, otherwise at the end of
|
|
9
|
+
# CLAUDE.md, which the harness renders. This script:
|
|
10
|
+
# - moves a block found in CLAUDE.md into AGENTS.md (created if missing);
|
|
11
|
+
# - creates AGENTS.md when there is none, with the block generated by the
|
|
12
|
+
# project's own Next.js (node_modules/next) or, without it, empty markers
|
|
13
|
+
# that `next dev` fills in;
|
|
14
|
+
# - never changes an AGENTS.md that already holds the block.
|
|
15
|
+
# Prints what it did ("moved", "created", "unchanged"); CLAUDE.md imports
|
|
16
|
+
# AGENTS.md through the harness-rendered nextjs section.
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
|
|
20
|
+
[[ $# -eq 1 && "$1" != -h && "$1" != --help ]] || usage 1
|
|
21
|
+
dir="$1"
|
|
22
|
+
start='<!-- BEGIN:nextjs-agent-rules -->'
|
|
23
|
+
end='<!-- END:nextjs-agent-rules -->'
|
|
24
|
+
agents="$dir/AGENTS.md"
|
|
25
|
+
claude="$dir/CLAUDE.md"
|
|
26
|
+
|
|
27
|
+
has_block() { [[ -f "$1" ]] && grep -qF "$start" "$1" && grep -qF "$end" "$1"; }
|
|
28
|
+
|
|
29
|
+
if has_block "$claude"; then
|
|
30
|
+
block="$(awk -v s="$start" -v e="$end" 'index($0, s) { on = 1 } on { print } index($0, e) { on = 0 }' "$claude")"
|
|
31
|
+
# Drop the block and the blank lines `next dev` put before it.
|
|
32
|
+
awk -v s="$start" -v e="$end" '
|
|
33
|
+
index($0, s) { skip = 1 }
|
|
34
|
+
!skip { lines[++n] = $0 }
|
|
35
|
+
skip && index($0, e) { skip = 0 }
|
|
36
|
+
END { while (n > 0 && lines[n] == "") n--; for (i = 1; i <= n; i++) print lines[i] }' "$claude" >"$claude.tmp"
|
|
37
|
+
mv "$claude.tmp" "$claude"
|
|
38
|
+
if has_block "$agents"; then
|
|
39
|
+
echo "moved (AGENTS.md already had the block; removed it from CLAUDE.md)"
|
|
40
|
+
else
|
|
41
|
+
if [[ -s "$agents" ]]; then printf '\n%s\n' "$block" >>"$agents"; else printf '%s\n' "$block" >"$agents"; fi
|
|
42
|
+
echo "moved"
|
|
43
|
+
fi
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
if [[ -f "$agents" ]]; then
|
|
48
|
+
echo "unchanged"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Ask the project's Next.js for the block of its own version.
|
|
53
|
+
block=""
|
|
54
|
+
if [[ -f "$dir/node_modules/next/dist/server/lib/generate-agent-files.js" ]] && command -v node >/dev/null; then
|
|
55
|
+
tmp="$(mktemp -d)"
|
|
56
|
+
: >"$tmp/AGENTS.md"
|
|
57
|
+
if node -e 'require(process.argv[1]).writeAgentFiles(process.argv[2])' \
|
|
58
|
+
"$dir/node_modules/next/dist/server/lib/generate-agent-files.js" "$tmp" >/dev/null 2>&1 \
|
|
59
|
+
&& has_block "$tmp/AGENTS.md"; then
|
|
60
|
+
block="$(sed -e '/./,$!d' "$tmp/AGENTS.md")"
|
|
61
|
+
fi
|
|
62
|
+
rm -rf "$tmp"
|
|
63
|
+
fi
|
|
64
|
+
[[ -n "$block" ]] || block="$start
|
|
65
|
+
$end"
|
|
66
|
+
printf '%s\n' "$block" >"$agents"
|
|
67
|
+
echo "created"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Package-manager versions for every harnessed project and machine (ADR 0014).
|
|
2
|
+
# Sourced by vps-doctor.sh, sync-project.sh and the tests; change them here only,
|
|
3
|
+
# after checking the registry (`npm view pnpm dist-tags.latest`) and the
|
|
4
|
+
# pnpm/action-setup release that supports the pnpm major.
|
|
5
|
+
# shellcheck shell=bash disable=SC2034
|
|
6
|
+
|
|
7
|
+
# Projects: package.json "packageManager" and "devEngines.packageManager", exact.
|
|
8
|
+
HARNESS_PNPM_VERSION="12.11.2"
|
|
9
|
+
# CI: reads the version from packageManager. pnpm 12 needs v6.1.0 or newer;
|
|
10
|
+
# the floating @v6 tag still points to v6.0.10.
|
|
11
|
+
HARNESS_PNPM_ACTION="pnpm/action-setup@v6.1.0"
|
|
12
|
+
# Machines (VPS): npm installed at the user level (npm config get prefix).
|
|
13
|
+
# Projects never run npm: devEngines.packageManager makes it refuse.
|
|
14
|
+
HARNESS_NPM_VERSION="12.2.0"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Conventions
|
|
2
|
+
|
|
3
|
+
Conventions shared by every stack. Stack-specific naming and layout live in
|
|
4
|
+
`stack/conventions.md` (in a product repo, `.harness/standards/stack/`).
|
|
5
|
+
|
|
6
|
+
## Language
|
|
7
|
+
|
|
8
|
+
- **All artifacts are in English**: code, identifiers, comments, docs, specs,
|
|
9
|
+
ADRs, commit messages, branch names, PR titles/descriptions, logs, file names.
|
|
10
|
+
- User-facing strings go through the stack's i18n mechanism, not hardcoded.
|
|
11
|
+
- **Chat is the only exception**: agents reply in the user's language.
|
|
12
|
+
|
|
13
|
+
## Formatting and lint
|
|
14
|
+
|
|
15
|
+
Owned entirely by `@data-matters.io/eslint-config`, which also ships `eslint`
|
|
16
|
+
and `prettier` with pinned versions. Do not declare them as direct
|
|
17
|
+
dependencies and do not add local rules that contradict the preset; propose
|
|
18
|
+
changes upstream instead.
|
|
19
|
+
|
|
20
|
+
- pnpm >= 10 no longer hoists their binaries by default: expose them with
|
|
21
|
+
`publicHoistPattern` in `pnpm-workspace.yaml`.
|
|
22
|
+
- Prettier (runs inside ESLint for JS/TS; the `prettier` CLI formats JSON,
|
|
23
|
+
Markdown, YAML and CSS): single quotes, semicolons, trailing commas,
|
|
24
|
+
2-space indent, 120-column print width, LF.
|
|
25
|
+
- Severity convention: `error` = correctness/security, `warn` =
|
|
26
|
+
style/quality. **The lint gate runs with `--max-warnings 0`**, so warnings
|
|
27
|
+
still block "done".
|
|
28
|
+
- Scripts: `lint` is read-only (CI), `lint:fix` fixes; `format` checks,
|
|
29
|
+
`format:fix` writes.
|
|
30
|
+
- Architecture rules are lint rules from the preset (see
|
|
31
|
+
`stack/architecture.md`); they fail the build instead of relying on review.
|
|
32
|
+
- Pre-commit hook: `data-matters-eslint-init` as `postinstall`. No husky.
|
|
33
|
+
- `.editorconfig` comes from the eslint-config package.
|
|
34
|
+
|
|
35
|
+
## TypeScript
|
|
36
|
+
|
|
37
|
+
- `strict` mode; no `any` (use `unknown` and narrow). Test files are exempt.
|
|
38
|
+
- Unused parameters/variables must be prefixed with `_`.
|
|
39
|
+
- No non-null assertions (`!`) outside tests.
|
|
40
|
+
- Cross-folder imports use path aliases (`@/…`), never `../../`.
|
|
41
|
+
- Validation at boundaries with Zod; types derive from schemas (`z.infer`) or
|
|
42
|
+
from Prisma rather than being duplicated by hand.
|
|
43
|
+
|
|
44
|
+
## Commits and branches
|
|
45
|
+
|
|
46
|
+
- [Conventional Commits](https://www.conventionalcommits.org/):
|
|
47
|
+
`<type>(<scope>): <summary>`, imperative, ≤ 72 chars. Breaking changes use
|
|
48
|
+
`!` and a `BREAKING CHANGE:` body.
|
|
49
|
+
- Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `perf`.
|
|
50
|
+
- One task, one commit. In the task loop the runner commits; agents never
|
|
51
|
+
commit or push.
|
|
52
|
+
- `main` is protected; everything lands via PR, and CI must pass.
|
|
53
|
+
- Spec branches: `spec/<openspec-change-id>`, only
|
|
54
|
+
`openspec/changes/<change-id>/`, PR titled `spec: <change-id>`, opened by
|
|
55
|
+
`.harness/spec-pr.sh` (it and CI's `pr-scope.sh` refuse anything else).
|
|
56
|
+
Change branches: `change/<openspec-change-id>`, created from `main` after
|
|
57
|
+
the spec PR is merged. Other work: `<type>/<short-kebab-description>`.
|
|
58
|
+
|
|
59
|
+
## Comments
|
|
60
|
+
|
|
61
|
+
- Explain _why_, not _what_. Public functions and classes get a short JSDoc.
|
|
62
|
+
- Delete commented-out code. `TODO` references a change id: `TODO(change-id): …`.
|
|
63
|
+
|
|
64
|
+
## Local workflow
|
|
65
|
+
|
|
66
|
+
`make` is the entry point (`make help`): `make setup` on first run, then the
|
|
67
|
+
stack's dev target, and `make ci-check` for CI parity. Agents use
|
|
68
|
+
`.harness/gates.sh` as the source of truth for "done".
|
package/standards/dod.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Definition of Done
|
|
2
|
+
|
|
3
|
+
A task is done when **every gate passes**. The agent's own assessment is never
|
|
4
|
+
a completion criterion.
|
|
5
|
+
|
|
6
|
+
## Human DoD → executable gate
|
|
7
|
+
|
|
8
|
+
| Human criterion | Gate | Required |
|
|
9
|
+
| --------------------------------------------------------------- | --------------------- | --------- |
|
|
10
|
+
| Behaviour matches the spec scenarios; coverage floor holds | `test` | yes |
|
|
11
|
+
| Code follows conventions and architecture rules (zero warnings) | `lint` | yes |
|
|
12
|
+
| No type errors | `typecheck` | yes |
|
|
13
|
+
| The artifact builds | `build` | yes |
|
|
14
|
+
| Adapters work against real dependencies | `integration` (extra) | per stack |
|
|
15
|
+
| Migrations apply and the seed runs on an empty database | `db` (extra) | Prisma |
|
|
16
|
+
| Flows work end to end | `e2e` (extra) | per stack |
|
|
17
|
+
| No known vulnerable dependencies | `security` (extra) | optional |
|
|
18
|
+
|
|
19
|
+
The commands behind each gate are in the stack preset (`.harness/gates.conf`,
|
|
20
|
+
source `templates/stacks/<stack>/gates.conf`) and summarized in
|
|
21
|
+
`stack/stack.md`. `.harness/gates.sh` runs them:
|
|
22
|
+
|
|
23
|
+
| Where | Gates |
|
|
24
|
+
| ----------------------- | -------------------------------------------------- |
|
|
25
|
+
| Claude Code `Stop` hook | `STOP_GATES` (fast profile: lint, typecheck, test) |
|
|
26
|
+
| Task loop | all |
|
|
27
|
+
| CI (`gates.yml`) | all |
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- A required gate that is not configured **fails**. To skip one, set it
|
|
32
|
+
explicitly to `skip:<reason>`; the reason shows up in every gate run.
|
|
33
|
+
- A task is not done if it passes because tests were deleted, weakened or
|
|
34
|
+
skipped, or because lint rules were disabled inline (`eslint-disable`)
|
|
35
|
+
without a justification comment.
|
|
36
|
+
- A task is not done if it changes the intent of an existing test (what it
|
|
37
|
+
asserts) without that change being part of the approved spec.
|
|
38
|
+
- Each task that changes behaviour adds or updates at least one test that
|
|
39
|
+
would fail without the change.
|
|
40
|
+
- Every spec scenario maps to at least one test by the time the change is
|
|
41
|
+
archived.
|
|
42
|
+
- All artifacts are in English (see `conventions.md`). Not machine-checked
|
|
43
|
+
yet; enforced during PR review.
|
|
44
|
+
|
|
45
|
+
## Human checkpoints
|
|
46
|
+
|
|
47
|
+
1. **Before execution**: proposal, delta specs and `tasks.md` are reviewed in
|
|
48
|
+
a spec-only PR (`spec: <change-id>`) and approved by merging it (ADR 0011).
|
|
49
|
+
`run-tasks.sh` refuses changes that are not on `main`, and CI validates
|
|
50
|
+
spec PRs with `openspec validate --strict` instead of the gates. Only the
|
|
51
|
+
task loop implements: `/opsx:apply` is disabled (ADR 0013).
|
|
52
|
+
2. **Before merge**: the PR is reviewed. CI re-runs the gates independently,
|
|
53
|
+
and the `main` ruleset requires it to pass.
|