@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 +12 -3
- package/bin/harness +42 -6
- package/package.json +3 -2
- package/standards/stack.md +2 -0
- package/standards/stacks/vite/architecture.md +10 -0
- package/standards/stacks/vite/conventions.md +13 -0
- package/standards/stacks/vite/stack.md +27 -0
- package/standards/stacks/vite/testing.md +25 -0
- package/templates/.github/dependabot.yml +19 -0
- package/templates/.harness/gates.conf.example +5 -0
- package/templates/.harness/lib.sh +42 -2
- package/templates/.harness/loop.sh +73 -5
- package/templates/.harness/models.conf +1 -0
- package/templates/.harness/run-tasks.sh +55 -16
- package/templates/.harness/spec-pr.sh +58 -4
- package/templates/.harness/status.sh +1 -1
- package/templates/CLAUDE.md.tmpl +33 -11
- package/templates/stacks/nestjs/gates.conf +3 -0
- package/templates/stacks/nextjs/gates.conf +3 -0
- package/templates/stacks/vite/gates.conf +31 -0
- package/templates/stacks/vite/gates.yml +50 -0
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
320
|
-
.gitignore; run harness check, then
|
|
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.
|
|
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
|
+
}
|
package/standards/stack.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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)
|
|
8
|
-
# declared
|
|
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,
|
|
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
|
package/templates/CLAUDE.md.tmpl
CHANGED
|
@@ -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**:
|
|
50
|
-
|
|
51
|
-
`openspec/specs
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
121
|
-
|
|
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
|