@data-matters.io/agent-harness 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +43 -2
  2. package/bin/harness +374 -0
  3. package/package.json +30 -4
  4. package/scripts/next-agent-rules.sh +67 -0
  5. package/scripts/toolchain.sh +14 -0
  6. package/standards/conventions.md +68 -0
  7. package/standards/dod.md +53 -0
  8. package/standards/stack.md +42 -0
  9. package/standards/stacks/nestjs/architecture.md +75 -0
  10. package/standards/stacks/nestjs/conventions.md +33 -0
  11. package/standards/stacks/nestjs/stack.md +34 -0
  12. package/standards/stacks/nestjs/testing.md +31 -0
  13. package/standards/stacks/nextjs/architecture.md +97 -0
  14. package/standards/stacks/nextjs/conventions.md +32 -0
  15. package/standards/stacks/nextjs/stack.md +40 -0
  16. package/standards/stacks/nextjs/testing.md +33 -0
  17. package/standards/testing.md +58 -0
  18. package/templates/.claude/hooks/stop-gates.sh +43 -0
  19. package/templates/.claude/settings.json +72 -0
  20. package/templates/.github/dependabot.yml +19 -0
  21. package/templates/.github/workflows/gates.yml +41 -0
  22. package/templates/.harness/db-smoke.sh +75 -0
  23. package/templates/.harness/deps.sh +219 -0
  24. package/templates/.harness/gates.conf.example +31 -0
  25. package/templates/.harness/gates.sh +108 -0
  26. package/templates/.harness/lib.sh +169 -0
  27. package/templates/.harness/loop.sh +169 -0
  28. package/templates/.harness/model-flags.sh +34 -0
  29. package/templates/.harness/models.conf +31 -0
  30. package/templates/.harness/notify.sh +49 -0
  31. package/templates/.harness/openspec.sh +28 -0
  32. package/templates/.harness/pr-scope.sh +44 -0
  33. package/templates/.harness/run-tasks.sh +461 -0
  34. package/templates/.harness/spec-pr.sh +157 -0
  35. package/templates/.harness/status.sh +155 -0
  36. package/templates/CLAUDE.md.tmpl +159 -0
  37. package/templates/openspec/config.yaml +38 -0
  38. package/templates/stacks/nestjs/gates.conf +35 -0
  39. package/templates/stacks/nestjs/gates.yml +71 -0
  40. package/templates/stacks/nextjs/claude-imports.md +4 -0
  41. package/templates/stacks/nextjs/gates.conf +36 -0
  42. package/templates/stacks/nextjs/gates.yml +68 -0
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # @data-matters.io/agent-harness
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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".
@@ -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.