@data-matters.io/agent-harness 0.1.0 → 0.1.2

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 CHANGED
@@ -7,18 +7,27 @@ follows.
7
7
 
8
8
  ## Install
9
9
 
10
- Pin the package exactly and let the project's install generate the harness:
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
+ ```
11
18
 
12
19
  ```sh
13
20
  pnpm add -D -E @data-matters.io/agent-harness
14
- pnpm exec harness init --new --stack nextjs # or nestjs; --existing for a codebase without tests
21
+ pnpm exec harness init --new --stack nextjs # or nestjs, vite; --existing for a codebase without tests
15
22
  ```
16
23
 
17
24
  ```json
18
25
  { "scripts": { "prepare": "harness install" } }
19
26
  ```
20
27
 
21
- `harness install` writes the generated files (`.harness/*.sh`,
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`,
22
31
  `.harness/standards/`, `.harness/CLAUDE.harness.md`, the Claude Code Stop hook)
23
32
  and they stay git-ignored, so they always match the pinned version.
24
33
 
package/bin/harness CHANGED
@@ -5,8 +5,10 @@
5
5
  # Usage: harness <command> [options] (run from the product repository root)
6
6
  #
7
7
  # init (--new | --existing | --template) [--name <name>] [--stack <stack>]
8
- # first adoption: writes the project-owned and seeded files,
9
- # the .gitignore entries and CLAUDE.md, then runs `install`.
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`.
10
12
  # Never overwrites an existing file; conflicts are reported.
11
13
  # install (re)generates the git-ignored harness files from this
12
14
  # package; idempotent. Run by the project's
@@ -26,7 +28,7 @@
26
28
  # HARNESS_PROJECT_DIR product repository (default: the current directory)
27
29
  set -euo pipefail
28
30
 
29
- usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
31
+ usage() { sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
30
32
  die() { echo "harness: $*" >&2; exit 1; }
31
33
 
32
34
  pkg="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
@@ -120,6 +122,25 @@ render_claude() {
120
122
 
121
123
  initialized() { [[ -f "$project/.harness/autonomy" ]]; }
122
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
+
123
144
  cmd_install() {
124
145
  if ! initialized; then
125
146
  echo "harness: $project is not initialized (no .harness/autonomy); run \`harness init\`"
@@ -180,6 +201,8 @@ cmd_check() {
180
201
  fi
181
202
  done
182
203
  fi
204
+ [[ -f "$project/.github/dependabot.yml" ]] \
205
+ || notes+=(".github/dependabot.yml: missing; harness bumps arrive as Dependabot PRs (templates/.github/dependabot.yml)")
183
206
  rm -rf "$tmp"
184
207
  (( ${#notes[@]} == 0 )) || printf 'harness: note: %s\n' "${notes[@]}"
185
208
  if (( ${#problems[@]} > 0 )); then
@@ -255,6 +278,7 @@ cmd_init() {
255
278
  }
256
279
  seed "$gates_conf_src" .harness/gates.conf
257
280
  seed "$ci_src" .github/workflows/gates.yml
281
+ seed "$templates/.github/dependabot.yml" .github/dependabot.yml
258
282
  for p in "${seeded[@]}"; do
259
283
  seed "$(seed_src "$p")" "$p"
260
284
  mkdir -p "$(dirname "$project/.harness/seeded/$p")"
@@ -292,6 +316,13 @@ EOF
292
316
  for p in "${ignore_entries[@]}"; do
293
317
  grep -qxF "$p" "$project/.gitignore" || echo "$p" >>"$project/.gitignore"
294
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
295
326
  # Next.js keeps its agent rules in AGENTS.md (ADR 0018).
296
327
  if [[ "$stack" == nextjs ]]; then
297
328
  echo "AGENTS.md: $("$pkg/scripts/next-agent-rules.sh" "$project")"
@@ -312,12 +343,17 @@ Next steps:
312
343
  1. Pin the harness and run it on every install (package.json):
313
344
  pnpm add -D -E @data-matters.io/agent-harness@$(version)
314
345
  "scripts": { "prepare": "harness install" }
315
- 2. Review .harness/gates.conf and .github/workflows/gates.yml (project-owned).
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).
316
351
  3. Pin pnpm (ADR 0014): "packageManager": "pnpm@$HARNESS_PNPM_VERSION" and
317
352
  devEngines.packageManager { name pnpm, version $HARNESS_PNPM_VERSION, onFail error }.
318
353
  Add OpenSpec as a pinned devDependency (version in .harness/openspec.sh).
319
- 4. Commit the project-owned and seeded files, .harness/seeded/, CLAUDE.md and
320
- .gitignore; run harness check, then .harness/gates.sh until it is green.
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.
321
357
  EOF
322
358
  if [[ "$mode" == existing ]]; then
323
359
  echo " 5. Add characterization tests for the critical flows, then: echo enabled > .harness/autonomy"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-matters.io/agent-harness",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Spec-Driven Development with Claude Code: gates, task loop and standards installed into a product repository",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -25,7 +25,8 @@
25
25
  "engines": {
26
26
  "node": ">=22"
27
27
  },
28
+ "packageManager": "pnpm@12.11.2",
28
29
  "devDependencies": {
29
30
  "@changesets/cli": "3.0.3"
30
31
  }
31
- }
32
+ }
@@ -8,6 +8,7 @@ standards, and differ only in the framework.
8
8
  | -------- | -------------------------------- | -------------------------------------- | ------------------------ |
9
9
  | `nextjs` | `data-matters-io/nextjs-new-app` | Web applications (UI + route handlers) | `stacks/nextjs/stack.md` |
10
10
  | `nestjs` | `data-matters-io/nestjs-new-app` | Backend services (HTTP APIs) | `stacks/nestjs/stack.md` |
11
+ | `vite` | none (ADR 0023) | Static Vite + TypeScript sites, no DB | `stacks/vite/stack.md` |
11
12
 
12
13
  In a product repo, the chosen stack's files are in `.harness/standards/stack/`.
13
14
 
@@ -40,3 +41,4 @@ workflow (`gates.yml`) validated against each template:
40
41
  | -------- | ---------------------------- |
41
42
  | `nextjs` | see `stacks/nextjs/stack.md` |
42
43
  | `nestjs` | see `stacks/nestjs/stack.md` |
44
+ | `vite` | see `stacks/vite/stack.md` |
@@ -0,0 +1,10 @@
1
+ # Architecture: Vite static sites
2
+
3
+ A static site: everything runs in the browser and `vite build` emits `dist/`.
4
+ Deviations need an ADR in the product repo.
5
+
6
+ - Source in `src/`, entry from `index.html`; tests beside the code.
7
+ - No server, no database, no secrets: nothing in `src/` or `dist/` may hold one.
8
+ - Keep pure logic (data, rules) in modules apart from DOM code, so it is
9
+ testable without a browser.
10
+ - Dependencies are added only when the spec declares them (ADR 0015).
@@ -0,0 +1,13 @@
1
+ # Conventions: Vite static sites
2
+
3
+ Extends the shared `conventions.md`.
4
+
5
+ - Type-only imports inline: `import { type Foo } from '…'`.
6
+ - Import order is auto-fixed by lint (`@data-matters.io/eslint-config`).
7
+ - Files are kebab-case `.ts`; tests are `<file>.test.ts` beside the code.
8
+ - Env vars exposed to the client use the `VITE_` prefix and are never secret.
9
+
10
+ ## Local workflow
11
+
12
+ `pnpm dev`, `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` and
13
+ `pnpm gates` (all gates).
@@ -0,0 +1,27 @@
1
+ # Stack: Vite static sites
2
+
3
+ No app template: the preset is derived from `data-matters-io/play-around-world`
4
+ (ADR 0023), with `@data-matters.io/eslint-config` 4.0.0.
5
+
6
+ | Area | Choice | Version |
7
+ | ------------ | ----------------------------------------- | ------- |
8
+ | Runtime | Node.js (build and tooling only) | 24.x |
9
+ | Bundler | Vite, static output in `dist/` | 8.x |
10
+ | Language | TypeScript, `strict: true` | 5.9.x |
11
+ | Testing | Vitest + jsdom, coverage with istanbul | 5.x |
12
+ | Lint preset | `typescript` | 4.x |
13
+ | Backend / DB | None: no server, no Prisma, no PostgreSQL | — |
14
+
15
+ ## Gates (`templates/stacks/vite/gates.conf`)
16
+
17
+ | Gate | Command |
18
+ | --------- | ------------------------------------------------------------------------------ |
19
+ | setup | none |
20
+ | test | `vitest run` with istanbul (floor: 85% lines, statements, branches, functions) |
21
+ | lint | `eslint . --max-warnings 0` |
22
+ | typecheck | `tsc --noEmit` |
23
+ | build | `vite build` |
24
+ | db | none (no database) |
25
+
26
+ The coverage floor lives in `gates.conf`, not in `vite.config.ts`, so an agent
27
+ cannot lower it.
@@ -0,0 +1,25 @@
1
+ # Testing: Vite static sites
2
+
3
+ Extends the shared `testing.md`.
4
+
5
+ Tooling: Vitest 5 with jsdom, coverage provider istanbul
6
+ (`@vitest/coverage-istanbul`).
7
+
8
+ ## Tiers
9
+
10
+ | Tier | Pattern | Status |
11
+ | ----------- | ------------------------------ | ------------------- |
12
+ | Unit | `src/**/*.test.ts` beside code | in use; `test` gate |
13
+ | Integration | none: there is no backend | not applicable |
14
+ | E2E | none yet | not a harness gate |
15
+
16
+ ## Rules
17
+
18
+ - Unit tests do not touch the network.
19
+ - DOM code is tested through jsdom with accessible queries, not implementation
20
+ details.
21
+
22
+ ## Coverage floor
23
+
24
+ 85% on lines, statements, branches and functions, over every file matching
25
+ `src/**/*.ts` (set in `gates.conf`).
@@ -0,0 +1,19 @@
1
+ # Harness bumps (ADR 0021): a weekly PR for the exactly pinned
2
+ # @data-matters.io/agent-harness, with its changelog in the PR body. Other
3
+ # dependencies are not managed by Dependabot here.
4
+ version: 2
5
+ updates:
6
+ - package-ecosystem: npm
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ allow:
11
+ - dependency-name: '@data-matters.io/agent-harness'
12
+ # pnpm refuses versions younger than its minimum release age unless they are
13
+ # listed in minimumReleaseAgeExclude (pnpm-workspace.yaml); waiting keeps the
14
+ # frozen install in CI green without editing that list on every bump.
15
+ cooldown:
16
+ default-days: 2
17
+ commit-message:
18
+ prefix: chore
19
+ include: scope
@@ -15,6 +15,11 @@ GATE_BUILD=""
15
15
  # Its failure fails the whole run.
16
16
  GATE_SETUP=""
17
17
 
18
+ # Optional: command that formats the OpenSpec files, run from the repo root by
19
+ # the archive PR action (the archive output is not formatted; ADR 0022).
20
+ # Empty = no formatting step.
21
+ FORMAT_SPECS=""
22
+
18
23
  # Gates run by the Claude Code Stop hook (fast profile). Empty = all gates.
19
24
  # The task loop and CI always run every gate.
20
25
  STOP_GATES=()
@@ -91,6 +91,24 @@ harness_claude_flags() {
91
91
  harness_level_flags "$level"
92
92
  }
93
93
 
94
+ # harness_usage_error <session.json>: the session ended on an account usage
95
+ # limit or on missing usage credits ("You're out of usage credits. Switch to
96
+ # another model..."), not on the work.
97
+ harness_usage_error() {
98
+ grep -qiE "hit your (session|usage) limit|usage limit reached|out of usage credits|credit balance is too low" "$1" 2>/dev/null
99
+ }
100
+
101
+ # harness_level_descend <level>: the level one below, for the fallback after a
102
+ # usage error (ADR 0020, amendment). Fails at R1, and unless
103
+ # HARNESS_MODEL_ROUTING=on: without routing no model is chosen, so there is
104
+ # nothing to switch to and the callers keep their pause (or escalation).
105
+ harness_level_descend() {
106
+ [[ "${HARNESS_MODEL_ROUTING:-measure}" == on ]] || return 1
107
+ local n="${1#R}"
108
+ [[ "$1" =~ ^R[1-4]$ ]] && (( n > 1 )) || return 1
109
+ echo "R$((n - 1))"
110
+ }
111
+
94
112
  # harness_flag_value <flags> <--flag>: e.g. the --effort of a level's flags.
95
113
  harness_flag_value() {
96
114
  # shellcheck disable=SC2206
@@ -101,11 +119,33 @@ harness_flag_value() {
101
119
  }
102
120
 
103
121
  # ------------------------------------------------- usage ledger (ADR 0020) --
122
+ # A session log is claude -p --output-format json with stderr merged in, so
123
+ # warnings such as "Ignoring N permissions.allow entries ... this workspace has
124
+ # not been trusted" can surround the JSON. session(<file>) parses the whole file
125
+ # or, failing that, the last line that is a JSON object ({} when none is).
126
+ harness_session_js='const session = (f) => {
127
+ let s = ""; try { s = require("fs").readFileSync(f, "utf8"); } catch { return {}; }
128
+ try { return JSON.parse(s) || {}; } catch {}
129
+ for (const l of s.split("\n").reverse()) {
130
+ if (l.trimStart().startsWith("{")) { try { return JSON.parse(l) || {}; } catch {} }
131
+ }
132
+ return {};
133
+ };'
134
+
135
+ # harness_session_reply <session.json>: the session's final message (empty when
136
+ # there is none); fails when the session ended on an error (is_error).
137
+ harness_session_reply() {
138
+ node -e "$harness_session_js"'
139
+ const d = session(process.argv[1]);
140
+ process.stdout.write(typeof d.result === "string" ? d.result : "");
141
+ process.exitCode = d.is_error ? 1 : 0;' "$1" 2>/dev/null
142
+ }
143
+
104
144
  # harness_session_usage <session.json>: "model<TAB>cost_usd<TAB>output_tokens<TAB>cache_read_tokens"
105
145
  # from a claude -p --output-format json result (models joined by "+").
106
146
  harness_session_usage() {
107
- node -e '
108
- let d = {}; try { d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); } catch {}
147
+ node -e "$harness_session_js"'
148
+ const d = session(process.argv[1]);
109
149
  const mu = d.modelUsage || {};
110
150
  const sum = (k) => Object.values(mu).reduce((a, m) => a + (m[k] || 0), 0);
111
151
  const cost = typeof d.total_cost_usd === "number" ? d.total_cost_usd : sum("costUSD");
@@ -5,7 +5,7 @@
5
5
  #
6
6
  # Usage: .harness/loop.sh start <change-id> [run-tasks options]
7
7
  # .harness/loop.sh status [change-id]
8
- # .harness/loop.sh stop <change-id>
8
+ # .harness/loop.sh stop <change-id> [--after-task]
9
9
  # .harness/loop.sh logs <change-id>
10
10
  #
11
11
  # The supervisor runs run-tasks.sh --open-pr and reacts to its exit code:
@@ -13,9 +13,16 @@
13
13
  # 2 escalated → notify; a human takes over
14
14
  # 3 paused (account usage limit; run-tasks discarded the partial attempt)
15
15
  # → notify, wait HARNESS_RESUME_INTERVAL seconds (default 900), try again
16
+ # 4 stopped between two tasks (`stop --after-task`) → notify; restart later
16
17
  # run-tasks works in the change's own git worktree (ADR 0012): the checkout this
17
18
  # runs from is never switched or cleaned, so specs can be written meanwhile.
18
19
  # Notifications go through .harness/notify.sh (Telegram when configured).
20
+ #
21
+ # Started from inside a systemd unit (the spec PR watcher's oneshot service, any
22
+ # other service), the supervisor gets a transient scope of its own
23
+ # (systemd-run --user --scope, unit harness-loop-<project>-<change>-<time>):
24
+ # setsid alone keeps it in the unit's cgroup, and systemd kills the cgroup when
25
+ # the unit ends. `status` reports a supervisor that died before finishing.
19
26
  set -euo pipefail
20
27
 
21
28
  # shellcheck source-path=SCRIPTDIR source=lib.sh
@@ -33,8 +40,36 @@ die() { echo "loop: $*" >&2; exit 1; }
33
40
  pid_file() { echo "$state_dir/loop-$1.pid"; }
34
41
  running() { local f; f="$(pid_file "$1")"; [[ -f "$f" ]] && kill -0 "$(cat "$f")" 2>/dev/null; }
35
42
 
43
+ # scope_prefix <change>: the systemd-run prefix that puts the supervisor in a
44
+ # scope of its own when this runs inside a systemd unit (INVOCATION_ID), unless
45
+ # it already is in a harness-loop scope; nothing otherwise or without a user
46
+ # manager to talk to. It runs in a subshell (mapfile < <(...)), so the caller
47
+ # must have XDG_RUNTIME_DIR set already: an export here would never reach the
48
+ # systemd-run of the parent ("Failed to connect to bus").
49
+ scope_prefix() {
50
+ [[ -n "${INVOCATION_ID:-}" ]] || return 0
51
+ grep -q '/harness-loop-[^/]*\.scope$' /proc/self/cgroup 2>/dev/null && return 0
52
+ command -v systemd-run >/dev/null 2>&1 || return 0
53
+ systemd-run --user --scope --quiet true >/dev/null 2>&1 || return 0
54
+ printf '%s\n' systemd-run --user --scope --quiet --collect \
55
+ --unit "harness-loop-$(basename "$root" | tr -c 'A-Za-z0-9_.\n-' '-')-$1-$(date +%s)" --
56
+ }
57
+
58
+ # close_inherited_fds: the supervisor outlives whoever started it, and a lock
59
+ # fd it inherited (the spec PR watcher's, a project lock) would stay locked for
60
+ # as long as the loop runs. Everything above stderr is closed.
61
+ close_inherited_fds() {
62
+ local f fd
63
+ for f in /proc/$$/fd/*; do
64
+ fd="${f##*/}"
65
+ [[ "$fd" =~ ^[0-9]+$ ]] && (( fd > 2 )) && eval "exec $fd>&-" 2>/dev/null
66
+ done
67
+ return 0
68
+ }
69
+
36
70
  supervise() {
37
71
  local change="$1"; shift
72
+ close_inherited_fds
38
73
  local interval="${HARNESS_RESUME_INTERVAL:-900}" log=".harness/logs/$change/runner.log" rc pr every reason
39
74
  mkdir -p ".harness/logs/$change"
40
75
  .harness/notify.sh started "Loop started for change $change"
@@ -57,6 +92,9 @@ supervise() {
57
92
  .harness/notify.sh paused "Change $change paused by the account usage limit; retrying every $every"
58
93
  sleep "$interval"
59
94
  .harness/notify.sh resumed "Resuming change $change" ;;
95
+ 4)
96
+ .harness/notify.sh info "Change $change stopped at the end of a task on request; restart with .harness/loop.sh start $change"
97
+ break ;;
60
98
  *)
61
99
  .harness/notify.sh error "Change $change stopped with exit code $rc. See .harness/logs/$change/runner.log"
62
100
  break ;;
@@ -73,22 +111,52 @@ case "$cmd" in
73
111
  # Fail here, not in the background: the spec must be approved (merged into
74
112
  # the base branch through its spec PR, ADR 0011) and its worktree clean.
75
113
  .harness/run-tasks.sh "$change" --check "$@" >/dev/null || die "change $change cannot start (see above)"
76
- setsid nohup "$0" __supervise "$change" "$@" >/dev/null 2>&1 </dev/null &
114
+ rm -f "$state_dir/stop-$change" # a stop requested for an earlier run
115
+ # A systemd unit (a service) has no XDG_RUNTIME_DIR; the user bus is there.
116
+ [[ -z "${INVOCATION_ID:-}" ]] || export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
117
+ mapfile -t scope < <(scope_prefix "$change")
118
+ setsid nohup "${scope[@]}" "$0" __supervise "$change" "$@" >/dev/null 2>&1 </dev/null &
77
119
  echo $! >"$(pid_file "$change")"
120
+ # Do not return before the supervisor is in its scope: the unit that called
121
+ # this may end right after, and its cgroup is killed with whatever is left.
122
+ if (( ${#scope[@]} )); then
123
+ for (( i = 0; i < 100; i++ )); do
124
+ grep -q '/harness-loop-[^/]*\.scope$' "/proc/$!/cgroup" 2>/dev/null && break
125
+ kill -0 $! 2>/dev/null || break
126
+ sleep 0.1
127
+ done
128
+ grep -q '/harness-loop-[^/]*\.scope$' "/proc/$!/cgroup" 2>/dev/null \
129
+ || echo "loop: warning: the supervisor is not in its own systemd scope; it may stop with the calling unit" >&2
130
+ fi
78
131
  echo "loop: started $change in the background (pid $!). Follow it with .harness/loop.sh status $change"
79
132
  ;;
80
133
  __supervise)
81
134
  supervise "$@" ;;
82
135
  status)
83
136
  if [[ -n "${1:-}" ]]; then
84
- running "$1" && echo "supervisor: running (pid $(cat "$(pid_file "$1")"))" || echo "supervisor: not running"
137
+ if running "$1"; then
138
+ echo "supervisor: running (pid $(cat "$(pid_file "$1")"))"
139
+ elif [[ -f "$(pid_file "$1")" ]]; then
140
+ # The supervisor removes its pid file when it finishes, even on errors.
141
+ echo "supervisor: DIED before finishing (pid $(cat "$(pid_file "$1")"), no final notification)." \
142
+ "Last runner and worktree-setup logs are in .harness/logs/$1/; restart with .harness/loop.sh start $1"
143
+ else
144
+ echo "supervisor: not running"
145
+ fi
85
146
  .harness/status.sh "$1"
86
147
  else
87
148
  .harness/status.sh
88
149
  fi ;;
89
150
  stop)
90
- change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh stop <change-id>"
151
+ change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh stop <change-id> [--after-task]"
91
152
  running "$change" || die "no loop running for $change"
153
+ if [[ "${2:-}" == --after-task ]]; then
154
+ # Graceful: the task in progress finishes and is committed, then run-tasks
155
+ # exits between tasks and the supervisor ends (the worktree is kept).
156
+ touch "$state_dir/stop-$change"
157
+ echo "loop: $change will stop at the end of its current task"
158
+ exit 0
159
+ fi
92
160
  pkill -TERM -s "$(ps -o sid= -p "$(cat "$(pid_file "$change")")" | tr -d ' ')" || true
93
161
  rm -f "$(pid_file "$change")"
94
162
  .harness/notify.sh error "Loop for change $change stopped by request"
@@ -97,5 +165,5 @@ case "$cmd" in
97
165
  change="${1:-}"; [[ -n "$change" ]] || die "usage: loop.sh logs <change-id>"
98
166
  tail -n 40 ".harness/logs/$change/runner.log" ;;
99
167
  *)
100
- sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
168
+ sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
101
169
  esac
@@ -27,4 +27,5 @@ TASK_ci_fix_retry=R3 # failing checks after a failed fix (watcher, ADR 0019)
27
27
  TASK_review_comments=R2 # owner comments on implementation/sync PRs (ADR 0019, 1b)
28
28
  TASK_diagnosis=R3 # explaining an escalation
29
29
  TASK_review=R3 # PR review
30
+ TASK_archive_fix=R2 # editorial split of a merged requirement over 500 characters (ADR 0022)
30
31
  TASK_routine=R1 # hand edits around sync and archive
@@ -11,7 +11,11 @@
11
11
  # .harness/models.conf, or a <!-- level: Rn --> marker on the task line), twice,
12
12
  # then one level up per attempt (R2, R2, R3, R4). Attempts that changed nothing
13
13
  # climb only once. The level's --model/--effort are passed when
14
- # HARNESS_MODEL_ROUTING=on (measure mode only records the level).
14
+ # HARNESS_MODEL_ROUTING=on (measure mode only records the level). With routing
15
+ # on, a session that ends on a usage limit or missing usage credits is repeated
16
+ # one level down (R4 → R3 → R2 → R1) instead of pausing; the summary records
17
+ # that session as "fallback" and the next at the level it ran. Pausing (exit 3)
18
+ # is kept at R1 and with routing off.
15
19
  # Before the first task it installs the dependencies the spec declares
16
20
  # (.harness/deps.sh, ADR 0015) in their own commit; an agent that changes
17
21
  # dependency files escalates the run.
@@ -31,7 +35,10 @@
31
35
  # task is done; on escalation or pause it is kept for a human or the next run.
32
36
  #
33
37
  # Exit codes: 0 all tasks done, 2 a task escalated after N attempts,
34
- # 3 paused because the agent hit the account usage limit.
38
+ # 3 paused because the agent hit the account usage limit,
39
+ # 4 stopped between two tasks on request (.harness/state/stop-<id>,
40
+ # written by `loop.sh stop <id> --after-task`; the work is
41
+ # committed and the worktree kept).
35
42
  #
36
43
  # Environment:
37
44
  # HARNESS_BASE_BRANCH branch holding approved specs (default main)
@@ -46,7 +53,7 @@
46
53
  # HARNESS_COMMIT_TYPE conventional commit type (default feat)
47
54
  set -euo pipefail
48
55
 
49
- usage() { sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
56
+ usage() { sed -n '2,50p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
50
57
  die() { echo "run-tasks: $*" >&2; exit 1; }
51
58
 
52
59
  [[ $# -ge 1 ]] || usage 1
@@ -295,6 +302,14 @@ record() {
295
302
  }
296
303
 
297
304
  while line="$(next_task)"; [[ -n "$line" ]]; do
305
+ # A stop requested while the previous task ran is honoured here, between tasks,
306
+ # when everything is committed (ADR 0022, spec amendment of a change in progress).
307
+ if [[ -f "$root/.harness/state/stop-$change" ]]; then
308
+ rm -f "$root/.harness/state/stop-$change"
309
+ run_status="stopped"
310
+ echo "run-tasks: stopped between tasks on request; the work is committed on $branch in $wt."
311
+ exit 4
312
+ fi
298
313
  ln="${line%%:*}"
299
314
  task="$(sed -E 's/^[[:space:]]*- \[ \] //' <<<"${line#*:}")"
300
315
  # The spec author's level for this task (ADR 0020), stripped from the text
@@ -302,7 +317,7 @@ while line="$(next_task)"; [[ -n "$line" ]]; do
302
317
  level_override="$(grep -oE '<!--[[:space:]]*level:[[:space:]]*R[1-4][[:space:]]*-->' <<<"$task" | grep -oE 'R[1-4]' || true)"
303
318
  task="$(sed -E 's/[[:space:]]*<!--[[:space:]]*level:[^>]*-->//g' <<<"$task")"
304
319
  base_level="$(harness_task_level impl "$level_override")" || base_level="R2"
305
- gate_fails=0 nochange=0 levels_tried=""
320
+ gate_fails=0 nochange=0 levels_tried="" level_cap=""
306
321
  task_id="$(grep -oE '^[0-9]+(\.[0-9]+)*' <<<"$task" || echo "L$ln")"
307
322
  summary_line="$(commit_summary "$task")"
308
323
  feedback=""
@@ -314,26 +329,50 @@ while line="$(next_task)"; [[ -n "$line" ]]; do
314
329
  # that changed nothing climb only once (they count as at most two failures).
315
330
  fails=$(( gate_fails + (nochange > 2 ? 2 : nochange) ))
316
331
  level="$(harness_level_climb "$base_level" $(( fails > 1 ? fails - 1 : 0 )))"
332
+ # After a usage-error fallback the task stays at or below that level.
333
+ if [[ -n "$level_cap" ]] && (( ${level#R} > ${level_cap#R} )); then level="$level_cap"; fi
317
334
  level_flags="$(harness_level_flags "$level")"
318
335
  [[ " $levels_tried " == *" $level "* ]] || levels_tried="${levels_tried:+$levels_tried }$level"
319
336
  echo " attempt $attempt/$max_attempts at $level${level_flags:+ ($level_flags)}"
320
337
  cur_task_id="$task_id"; cur_task="$summary_line"; cur_attempt="$attempt"; attempt_started="$(date +%s)"
321
338
  write_state
322
339
  start=$(date +%s)
323
- # shellcheck disable=SC2086
324
- build_prompt "$task" "$feedback" | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p \
325
- --output-format json \
326
- --permission-mode "$permission_mode" \
327
- --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
328
- "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
329
- ${level_flags} ${HARNESS_CLAUDE_ARGS:-} \
330
- >"$log_dir/task-$task_id-attempt-$attempt.json" 2>&1 || true
331
-
332
- warn_if_untrusted "$log_dir/task-$task_id-attempt-$attempt.json"
340
+ session_json="$log_dir/task-$task_id-attempt-$attempt.json"
341
+ while :; do
342
+ # shellcheck disable=SC2086
343
+ build_prompt "$task" "$feedback" | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p \
344
+ --output-format json \
345
+ --permission-mode "$permission_mode" \
346
+ --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
347
+ "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
348
+ ${level_flags} ${HARNESS_CLAUDE_ARGS:-} \
349
+ >"$session_json" 2>&1 || true
350
+
351
+ warn_if_untrusted "$session_json"
352
+
353
+ # Out of usage (limit or credits) at this level: with routing on, the
354
+ # same attempt goes to the level below instead of pausing (ADR 0020,
355
+ # amendment). The interrupted session left partial work: discard it. The
356
+ # fallback level also caps the rest of this task's ladder.
357
+ if harness_usage_error "$session_json" && lower="$(harness_level_descend "$level")"; then
358
+ record "$task_id" "$attempt" "$(( $(date +%s) - start ))" fallback
359
+ git checkout -- . && git clean -fdq
360
+ echo " usage error at $level; falling back to $lower"
361
+ .harness/notify.sh info "$change: task $task_id: $level is out of usage, falling back to $lower" 2>/dev/null || true
362
+ level_cap="$lower"
363
+ level="$lower"
364
+ level_flags="$(harness_level_flags "$level")"
365
+ [[ " $levels_tried " == *" $level "* ]] || levels_tried="${levels_tried:+$levels_tried }$level"
366
+ start=$(date +%s)
367
+ continue
368
+ fi
369
+ break
370
+ done
333
371
 
334
372
  # An account usage limit ends the session early; retrying only burns the
335
- # remaining attempts on the same error. Pause and let a human resume.
336
- if grep -qiE "hit your (session|usage) limit|usage limit reached" "$log_dir/task-$task_id-attempt-$attempt.json"; then
373
+ # remaining attempts on the same error. Pause and let a human resume (at R1,
374
+ # or with routing off, where there is no lower model to try).
375
+ if harness_usage_error "$session_json"; then
337
376
  record "$task_id" "$attempt" "$(( $(date +%s) - start ))" paused
338
377
  run_status="paused"
339
378
  # The interrupted attempt left partial, unverified work: discard it.
@@ -4,8 +4,12 @@
4
4
  #
5
5
  # Usage: .harness/spec-pr.sh <change-id>
6
6
  #
7
- # 1. validates the change with the pinned OpenSpec CLI (--strict) and its
8
- # declared dependencies (.harness/deps.sh check);
7
+ # 1. validates the change with the pinned OpenSpec CLI (--strict), its
8
+ # declared packages (.harness/deps.sh check), the changes listed under
9
+ # "## Depends on" in proposal.md (each must exist: open, merged or
10
+ # archived), and simulates its archive in a scratch copy of openspec/
11
+ # (after archiving the unarchived changes it depends on): the merged
12
+ # specs must pass `validate --all --strict`;
9
13
  # 2. moves to branch spec/<change-id> (created from the base branch, carrying
10
14
  # the uncommitted change files) unless already on it;
11
15
  # 3. refuses anything outside openspec/changes/<change-id>/, in the working
@@ -17,7 +21,7 @@
17
21
  # HARNESS_BASE_BRANCH branch the spec PR targets (default main)
18
22
  set -euo pipefail
19
23
 
20
- usage() { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
24
+ usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
21
25
  die() { echo "spec-pr: $*" >&2; exit 1; }
22
26
 
23
27
  [[ $# -eq 1 ]] || usage 1
@@ -47,12 +51,62 @@ if [[ -x .harness/deps.sh ]]; then
47
51
  .harness/deps.sh check "$change_dir" || die "fix the ## Dependencies section of $change_dir/design.md"
48
52
  fi
49
53
 
54
+ # Depends on (ADR 0022, section 5): optional list of change ids in proposal.md,
55
+ # one kebab-case id in backticks per bullet. In an item that is more than an id
56
+ # (prose), the first backticked token that is an existing change is the id; items
57
+ # without one (PR references such as #63) are ignored with a warning. An item
58
+ # that is only an id must exist.
59
+ dep_items="$(awk '/^## /{ on = ($0 ~ /^## Depends on[ \t]*$/); next } on && /^[ \t]*[-*][ \t]+/ { sub(/^[ \t]*[-*][ \t]+/, ""); print }' "$change_dir/proposal.md")"
60
+
50
61
  # Only the change's own files may be pending: anything else would leak into the spec PR.
51
62
  outside="$(git status --porcelain --untracked-files=all | cut -c4- | grep -v "^$change_dir/" || true)"
52
63
  [[ -z "$outside" ]] || die "uncommitted files outside $change_dir/ (commit, stash or remove them first):
53
64
  $outside"
54
65
 
55
66
  git fetch -q origin "$base_branch" || die "cannot fetch origin/$base_branch"
67
+ # change_exists <id>: open (in the tree or as a spec/ or change/ branch), merged or archived.
68
+ change_exists() {
69
+ [[ -d "openspec/changes/$1" ]] \
70
+ || git cat-file -e "origin/$base_branch:openspec/changes/$1" 2>/dev/null \
71
+ || git ls-tree --name-only "origin/$base_branch" openspec/changes/archive/ 2>/dev/null | grep -qE "/[0-9]{4}-[0-9]{2}-[0-9]{2}-$1\$" \
72
+ || compgen -G "openspec/changes/archive/*-$1" >/dev/null \
73
+ || git rev-parse -q --verify "refs/remotes/origin/spec/$1" >/dev/null \
74
+ || git rev-parse -q --verify "refs/remotes/origin/change/$1" >/dev/null
75
+ }
76
+ depends=()
77
+ while IFS= read -r item; do
78
+ [[ -n "$item" ]] || continue
79
+ bare="$(sed -E 's/^[[:space:]]*`?"?//; s/"?`?[[:space:]]*$//' <<<"$item")"
80
+ if [[ "$bare" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
81
+ [[ "$bare" != "$change" ]] || die "$change lists itself under ## Depends on"
82
+ change_exists "$bare" || die "## Depends on lists '$bare', which is not a change (open, merged or archived); fix proposal.md"
83
+ depends+=("$bare"); continue
84
+ fi
85
+ found=""
86
+ while IFS= read -r tok; do
87
+ [[ "$tok" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ && "$tok" != "$change" ]] && change_exists "$tok" && { found="$tok"; break; }
88
+ done < <(grep -oE '`[^`]+`' <<<"$item" | tr -d '`')
89
+ if [[ -n "$found" ]]; then depends+=("$found"); else echo "spec-pr: warning: ignoring ## Depends on item without a change id: ${item:0:80}" >&2; fi
90
+ done <<<"$dep_items"
91
+
92
+ # Simulate the archive (ADR 0022, section 4) in a scratch copy of openspec/: the
93
+ # watcher archives this change after its implementation PR is merged, and
94
+ # `validate --all --strict` must still pass then (a merged requirement over 500
95
+ # characters, a MODIFIED requirement that is not there). The changes it depends
96
+ # on that are not archived yet are archived first, in the order listed.
97
+ scratch="$(mktemp -d)"
98
+ trap 'rm -rf "$scratch"' EXIT
99
+ cp -r openspec "$scratch/openspec"
100
+ for dep in "${depends[@]}" "$change"; do
101
+ [[ -d "$scratch/openspec/changes/$dep" ]] || continue
102
+ (cd "$scratch" && "$root/.harness/openspec.sh" archive "$dep" --yes </dev/null) >"$scratch/out" 2>&1 \
103
+ || die "simulated archive of $dep failed; its delta does not apply on top of the current specs (is a change it builds on still unarchived? list it under ## Depends on):
104
+ $(tail -n 8 "$scratch/out")"
105
+ done
106
+ (cd "$scratch" && "$root/.harness/openspec.sh" validate --all --strict --no-interactive </dev/null) >"$scratch/out" 2>&1 \
107
+ || die "openspec validate --all --strict fails after the simulated archive of $change; fix the spec (split a requirement over 500 characters, move detail into scenarios):
108
+ $(tail -n 12 "$scratch/out")"
109
+
56
110
  if [[ "$(git branch --show-current)" != "$branch" ]]; then
57
111
  if git rev-parse -q --verify "refs/heads/$branch" >/dev/null; then
58
112
  git switch -q "$branch" || die "cannot switch to $branch"
@@ -88,7 +142,7 @@ if [[ -n "$url" ]]; then
88
142
  fi
89
143
 
90
144
  body="$(mktemp)"
91
- trap 'rm -f "$body"' EXIT
145
+ trap 'rm -rf "$scratch"; rm -f "$body"' EXIT
92
146
  {
93
147
  echo "OpenSpec change \`$change\`, for review. **Merging this PR approves the spec** (ADR 0011);"
94
148
  echo "nothing is implemented until then. Comment here; adjustments land on \`$branch\`."
@@ -140,7 +140,7 @@ usage() {
140
140
  printf '%-16s %-5s %-30s %8s %10s %10s %s\n' TASK LEVEL MODEL SESSIONS "COST US\$" "AVG US\$" "1ST-ATTEMPT PASS"
141
141
  tail -n +2 "$ledger" | awk -F'\t' '
142
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]++ } }
143
+ if ($12 == 1 && $11 != "fallback") { first[k]++; if ($11 == "pass" || $11 == "fixed" || $11 == "replied") ok[k]++ } }
144
144
  END { for (k in n) { split(k, f, "\t")
145
145
  rate = first[k] ? sprintf("%d%% (%d/%d)", 100 * ok[k] / first[k], ok[k], first[k]) : "-"
146
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
@@ -46,15 +46,35 @@ an OpenSpec workflow or skill says `openspec <args>`, run
46
46
  `openspec-apply-change` skill are disabled in `.claude/settings.json`. Do
47
47
  not edit the approved spec while implementing; a spec change needs a new
48
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.
49
+ 5. **Archive**: after the implementation PR is merged, the change is archived
50
+ on branch `archive/<change-id>` and the PR `archive: <change-id>` merges
51
+ its delta into `openspec/specs/`. The PR watcher does it by itself (ADR
52
+ 0022): `openspec archive <id> --yes`, the formatter on `openspec/`
53
+ (`FORMAT_SPECS` in `.harness/gates.conf`; the archive output is not
54
+ formatted) and `validate --all --strict`. Where there is no watcher, do
55
+ the same by hand, in that order, on that branch. Merging the archive PR is
56
+ the user's action. If `validate --strict` fails after the archive because a
57
+ merged requirement is over 500 characters, the fix is editorial: split the
58
+ requirement or move detail into scenarios, without changing behaviour, and
59
+ list each edit in the PR description.
60
+
61
+ **Dependencies between changes.** A change that builds on requirements another
62
+ change adds or modifies lists it in `proposal.md` under an optional
63
+ `## Depends on` section: one change id in backticks per bullet, e.g.
64
+ ``- `role-deactivation-guard` ``. `.harness/spec-pr.sh` checks that each id is
65
+ a change (open, merged or archived); the watcher starts the change only after
66
+ those are archived and archives in that order. Use `MODIFIED` on an existing
67
+ requirement instead of `ADDED` next to it. Otherwise the archived spec ends up
68
+ with contradicting requirements that `openspec validate` does not detect.
69
+
70
+ **Whoever writes a spec simulates its archive.** The merged specs must still
71
+ pass `.harness/openspec.sh validate --all --strict` after the archive, and a
72
+ requirement over 500 characters, or a `MODIFIED` one that is not in
73
+ `openspec/specs/` yet, only fails then. `.harness/spec-pr.sh` runs that
74
+ simulation in a scratch copy of `openspec/` (archiving first the unarchived
75
+ changes listed under `## Depends on`) and refuses the spec PR if it fails: fix
76
+ the spec before opening the PR, keeping each requirement under 500 characters
77
+ and the detail in scenarios.
58
78
 
59
79
  Write tasks so each one is small, independently verifiable by the gates, and
60
80
  ends in a green state. The loop picks the model from the task's reasoning
@@ -117,8 +137,10 @@ work; the task loop implements it.
117
137
  gate failure, explain the cause in the channel and propose the next step.
118
138
  The work in progress stays uncommitted in the loop worktree (path in the
119
139
  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.
140
+ - After the user merges the implementation PR, the watcher opens the archive
141
+ PR (workflow step 5) and the channel is told. Without a watcher on this
142
+ machine, archive the change by hand in branch `archive/<change-id>`, as step
143
+ 5 says.
122
144
  - Every comment you post on a pull request ends with the line
123
145
  `<!-- harness-agent -->`: GitHub may show it under the user's account, and
124
146
  the watcher ignores marked comments instead of treating them as review.
@@ -18,6 +18,9 @@ GATE_LINT="$bin/eslint . --max-warnings 0"
18
18
  GATE_TYPECHECK="$bin/tsc --noEmit -p tsconfig.json"
19
19
  GATE_BUILD="$bin/nest build"
20
20
 
21
+ # Formats openspec/ after an archive (the archive output is not formatted).
22
+ FORMAT_SPECS="$bin/prettier --write openspec"
23
+
21
24
  # Stop hook runs the fast gates; e2e and integration run in the loop and CI.
22
25
  STOP_GATES=(lint typecheck test)
23
26
 
@@ -21,6 +21,9 @@ GATE_LINT="$bin/eslint . --max-warnings 0"
21
21
  GATE_TYPECHECK="$bin/tsc --noEmit"
22
22
  GATE_BUILD="$bin/next build"
23
23
 
24
+ # Formats openspec/ after an archive (the archive output is not formatted).
25
+ FORMAT_SPECS="$bin/prettier --write openspec"
26
+
24
27
  # Stop hook runs the fast gates (~40s); build (~60s) runs in the loop and CI.
25
28
  STOP_GATES=(lint typecheck test)
26
29
 
@@ -0,0 +1,31 @@
1
+ # Gate commands for a static Vite + TypeScript site (no database, no server).
2
+ # Sourced by .harness/gates.sh from the repo root. Validated against
3
+ # data-matters-io/play-around-world: vite 8.3.5, typescript 5.9.3, vitest 5.0.5,
4
+ # @vitest/coverage-istanbul 5.0.5, jsdom 30.1.3, @data-matters.io/eslint-config 4.0.0.
5
+
6
+ # Gates call node_modules/.bin directly instead of `pnpm exec`: a gate must never
7
+ # install or mutate anything (same rule as the other stack presets). Dependencies
8
+ # are installed beforehand (`pnpm install --frozen-lockfile`).
9
+ bin="node_modules/.bin"
10
+
11
+ # No setup step: nothing to generate before the gates run.
12
+ GATE_SETUP=""
13
+
14
+ # Coverage floor 85% on every metric, statement-based (istanbul), measured over
15
+ # every source file (standards/testing.md). Kept here, not in vite.config.ts, so
16
+ # an agent cannot lower it. Raise it as coverage grows; never lower it.
17
+ GATE_TEST="$bin/vitest run --coverage.enabled --coverage.provider=istanbul --coverage.include='src/**/*.ts' --coverage.thresholds.lines=85 --coverage.thresholds.statements=85 --coverage.thresholds.branches=85 --coverage.thresholds.functions=85"
18
+ # Read-only and zero warnings (@data-matters.io/eslint-config, typescript preset).
19
+ GATE_LINT="$bin/eslint . --max-warnings 0"
20
+ GATE_TYPECHECK="$bin/tsc --noEmit"
21
+ GATE_BUILD="$bin/vite build"
22
+
23
+ # Formats openspec/ after an archive (the archive output is not formatted).
24
+ FORMAT_SPECS="$bin/prettier --write openspec"
25
+
26
+ # Stop hook runs the fast gates; build runs in the loop and CI.
27
+ STOP_GATES=(lint typecheck test)
28
+
29
+ GATE_EXTRA=(
30
+ # "security:pnpm audit --audit-level high"
31
+ )
@@ -0,0 +1,50 @@
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
+ steps:
12
+ # fetch-depth 2: HEAD is the PR merge commit and HEAD^1 its base, enough
13
+ # to list the files the pull request changes.
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 2
17
+
18
+ # Reads the exact pnpm version from package.json#packageManager (ADR 0014).
19
+ # pnpm 12 needs v6.1.0+; the floating @v6 tag is older.
20
+ - uses: pnpm/action-setup@v6.1.0
21
+
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: 24
25
+ cache: pnpm
26
+
27
+ - name: Install dependencies
28
+ run: pnpm install --frozen-lockfile
29
+
30
+ # A harness installed from the package (ADR 0021) is generated by the
31
+ # install's "prepare"; a missing or stale one fails here.
32
+ - name: Check the harness
33
+ run: if [[ -x node_modules/.bin/harness ]]; then node_modules/.bin/harness check; fi
34
+
35
+ # After the install: with the package, .harness/ is generated by it.
36
+ # Spec-only PRs (ADR 0011, 0013) validate the specs instead of running the
37
+ # DoD gates; pr-scope.sh also fails a spec/<id> PR that changes anything
38
+ # outside openspec/changes/<id>/. Pushes to main always run the gates.
39
+ - name: Classify pull request
40
+ id: scope
41
+ run: .harness/pr-scope.sh >>"$GITHUB_OUTPUT"
42
+
43
+ # OpenSpec is a pinned devDependency (.harness/openspec.sh).
44
+ - name: Validate OpenSpec changes and specs
45
+ if: steps.scope.outputs.spec_only == 'true'
46
+ run: .harness/openspec.sh validate --all --strict --no-interactive
47
+
48
+ - name: Run DoD gates
49
+ if: steps.scope.outputs.spec_only != 'true'
50
+ run: .harness/gates.sh