@data-matters.io/agent-harness 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-matters.io/agent-harness",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {
@@ -28,7 +28,57 @@ harness_load_env() {
28
28
  # the session's environment: only notify.sh needs them, and it reads the env file
29
29
  # itself. TELEGRAM_STATE_DIR from the env file is kept.
30
30
  # shellcheck disable=SC2034 # used by the scripts that source this file
31
- HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID claude)
31
+ HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID -u HARNESS_ANTHROPIC_API_KEY claude)
32
+
33
+ # ------------------------------------------- optional API key (ADR 0020) --
34
+ # harness_api_refused <session.json>: the session ended on an error that says
35
+ # the Anthropic API key cannot serve it: no credit, a spend or usage limit, a
36
+ # rate limit, billing, or an invalid key. Only an errored session counts, so a
37
+ # task about authentication or billing never matches by its own words.
38
+ harness_api_refused() {
39
+ node -e "$harness_session_js"'
40
+ const raw = (() => { try { return require("fs").readFileSync(process.argv[1], "utf8"); } catch { return ""; } })();
41
+ const d = session(process.argv[1]);
42
+ const text = Object.keys(d).length
43
+ ? (d.is_error ? [d.result, d.api_error].filter((x) => typeof x === "string").join(" ") : "")
44
+ : raw;
45
+ process.exitCode = /credit balance|spend(ing)? limit|billing|usage limit|rate limit|out of usage|invalid api key|authentication/i.test(text) ? 0 : 1;' "$1" 2>/dev/null
46
+ }
47
+
48
+ # harness_claude_session <timeout-seconds> <session.json> <claude args...>: runs
49
+ # one headless Claude Code session (the prompt on stdin) and writes its output
50
+ # (stdout and stderr) to <session.json>; returns the exit status of the run
51
+ # that produced it (124: timed out). Without HARNESS_ANTHROPIC_API_KEY this is
52
+ # the account login, exactly as before. With it, the session runs on the API
53
+ # first (the key goes to that process only, through its environment, never its
54
+ # arguments or a log); when the API refuses it (harness_api_refused), the SAME
55
+ # attempt repeats on the plan login. Which one produced the output is written to
56
+ # <session.json>.source (api|plan), which the usage ledger reads.
57
+ harness_claude_session() {
58
+ local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" rc=0 prompt
59
+ shift 2
60
+ rm -f "$out.source"
61
+ if [[ -z "$key" ]]; then
62
+ timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
63
+ return "$rc"
64
+ fi
65
+ prompt="$(mktemp)"
66
+ cat >"$prompt"
67
+ ANTHROPIC_API_KEY="$key" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
68
+ # Whatever the session printed, the key itself never stays in the log.
69
+ K="$key" node -e 'const fs = require("fs"); const f = process.argv[1];
70
+ try { const s = fs.readFileSync(f, "utf8"); if (s.includes(process.env.K)) fs.writeFileSync(f, s.split(process.env.K).join("[redacted]")); } catch {}' "$out" 2>/dev/null || true
71
+ if (( rc != 124 )) && harness_api_refused "$out"; then
72
+ echo "harness: the API key was refused; repeating the session on the plan login" >&2
73
+ rc=0
74
+ timeout "$secs" env -u ANTHROPIC_API_KEY "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
75
+ echo plan >"$out.source"
76
+ else
77
+ echo api >"$out.source"
78
+ fi
79
+ rm -f "$prompt"
80
+ return "$rc"
81
+ }
32
82
 
33
83
  # Worktree where the task loop runs a change (ADR 0012), outside the checkout so
34
84
  # lint, tests and type-checks of the main checkout never see it:
@@ -158,14 +208,32 @@ harness_session_usage() {
158
208
 
159
209
  # harness_record_usage <project> <ref> <task-type> <level> <effort> <attempt> <result> <session.json>:
160
210
  # one line in the machine-wide ledger (HARNESS_USAGE_LEDGER, default
161
- # ~/.local/state/harness/usage.tsv); prints the session's usage columns.
211
+ # ~/.local/state/harness/usage.tsv); prints the session's usage columns. The last
212
+ # column, source, is api or plan (harness_claude_session); a ledger written
213
+ # before it existed gets the column added to its header, its old rows stay empty.
162
214
  harness_record_usage() {
163
- local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage
215
+ local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage source
164
216
  usage="$(harness_session_usage "$8")"
217
+ source="$(harness_session_source "$8")"
165
218
  mkdir -p "$(dirname "$ledger")"
166
- [[ -s "$ledger" ]] || printf 'timestamp\tproject\tref\ttask_type\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tresult\tattempt\n' >"$ledger"
219
+ [[ -s "$ledger" ]] || printf 'timestamp\tproject\tref\ttask_type\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tresult\tattempt\tsource\n' >"$ledger"
220
+ harness_tsv_add_source "$ledger"
167
221
  IFS=$'\t' read -r model cost out cache <<<"$usage"
168
- printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds)" "$1" "$2" "$3" "$4" \
169
- "$model" "${5:-default}" "$cost" "$out" "$cache" "$7" "$6" >>"$ledger"
222
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds)" "$1" "$2" "$3" "$4" \
223
+ "$model" "${5:-default}" "$cost" "$out" "$cache" "$7" "$6" "$source" >>"$ledger"
170
224
  printf '%s' "$usage"
171
225
  }
226
+
227
+ # harness_session_source <session.json>: api or plan, as harness_claude_session
228
+ # recorded it (plan when it did not: no API key was configured).
229
+ harness_session_source() {
230
+ local s=plan
231
+ [[ -f "$1.source" ]] && s="$(<"$1.source")"
232
+ echo "$s"
233
+ }
234
+
235
+ # harness_tsv_add_source <file>: adds the source column to the header of a TSV
236
+ # written before it existed.
237
+ harness_tsv_add_source() {
238
+ [[ "$(head -n1 "$1")" == *$'\tsource' ]] || sed -i '1s/$/\tsource/' "$1"
239
+ }
@@ -6,20 +6,21 @@
6
6
  #
7
7
  # Overrides, applied in this order after this file:
8
8
  # .harness/models.local.conf per project (project-owned, never synced)
9
- # $HARNESS_MODELS_FILE per machine (e.g. no Fable: R4="--model opus --effort xhigh")
9
+ # $HARNESS_MODELS_FILE per machine (e.g. a cheaper R3: R3="--model opus --effort low")
10
10
  #
11
11
  # Rollout (decision 7): with HARNESS_MODEL_ROUTING=measure (the default) the
12
12
  # levels are computed and recorded but no flags are passed, so sessions keep
13
13
  # the account's default model; set HARNESS_MODEL_ROUTING=on (env file) to switch.
14
14
  # shellcheck shell=bash disable=SC2034
15
15
 
16
- R4="--model fable --effort high" # design: specs, decisions no gate checks
17
- R3="--model opus --effort high" # hard: ambiguous work, unclear diagnosis
16
+ # No level uses Fable (amendment 2026-10-31 to ADR 0020).
17
+ R4="--model opus --effort high" # reserved: large product documents, last rung of the ladder
18
+ R3="--model opus --effort medium" # design/hard: specs, ambiguous work, unclear diagnosis
18
19
  R2="--model sonnet --effort medium" # build: a well-specified, gate-verified task
19
20
  R1="--model haiku --effort medium" # routine: a mechanical, local edit
20
21
 
21
- TASK_spec_author=R4 # /opsx:propose in an interactive or channel session
22
- TASK_spec_adjust=R4 # spec PR review comments (watcher, ADR 0017)
22
+ TASK_spec_author=R3 # /opsx:propose in an interactive or channel session; R4 for big product documents
23
+ TASK_spec_adjust=R2 # spec PR review comments (watcher, ADR 0017): bounded edits to an existing spec
23
24
  TASK_impl=R2 # task loop (run-tasks.sh); tasks.md may override
24
25
  TASK_conflict=R3 # merge conflicts on a PR (watcher, ADR 0019)
25
26
  TASK_ci_fix=R2 # failing checks, clear cause (watcher, ADR 0019)
@@ -27,5 +28,5 @@ TASK_ci_fix_retry=R3 # failing checks after a failed fix (watcher, ADR 0019)
27
28
  TASK_review_comments=R2 # owner comments on implementation/sync PRs (ADR 0019, 1b)
28
29
  TASK_diagnosis=R3 # explaining an escalation
29
30
  TASK_review=R3 # PR review
30
- TASK_archive_fix=R2 # editorial split of a merged requirement over 500 characters (ADR 0022)
31
+ TASK_archive_fix=R1 # editorial split of a merged requirement over 500 characters (ADR 0022), gate-verified
31
32
  TASK_routine=R1 # hand edits around sync and archive
@@ -207,11 +207,13 @@ trap '[[ "$run_status" == running ]] && run_status=interrupted; write_state' EXI
207
207
  write_state
208
208
  cd "$wt"
209
209
  summary="$log_dir/summary.tsv"
210
- summary_header=$'timestamp\ttask\tattempt\tduration_s\tresult\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens'
210
+ summary_header=$'timestamp\ttask\tattempt\tduration_s\tresult\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tsource'
211
211
  if [[ ! -f "$summary" ]]; then
212
212
  printf '%s\n' "$summary_header" >"$summary"
213
213
  elif [[ "$(head -n1 "$summary")" == $'timestamp\ttask\tattempt\tduration_s\tresult' ]]; then
214
214
  sed -i "1s/.*/$summary_header/" "$summary" # older runs: same first columns, new ones empty
215
+ else
216
+ harness_tsv_add_source "$summary"
215
217
  fi
216
218
 
217
219
  next_task() { grep -n -m1 -E '^[[:space:]]*- \[ \] ' "$tasks" || true; }
@@ -297,13 +299,14 @@ warn_if_untrusted() {
297
299
  # with its level, model, effort, cost and tokens (ADR 0020); the same session
298
300
  # also goes to the machine-wide ledger.
299
301
  record() {
300
- local usage effort model cost out cache
302
+ local usage effort model cost out cache source
301
303
  effort="$(harness_flag_value "${level_flags:-}" --effort)"
302
304
  usage="$(harness_record_usage "$(basename "$root")" "$change" impl "${level:-}" "${effort:-default}" "$2" "$4" \
303
305
  "$log_dir/task-$1-attempt-$2.json")"
304
306
  IFS=$'\t' read -r model cost out cache <<<"$usage"
305
- printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds)" "$1" "$2" "$3" "$4" \
306
- "${level:-}" "$model" "${effort:-default}" "$cost" "$out" "$cache" >>"$summary"
307
+ source="$(harness_session_source "$log_dir/task-$1-attempt-$2.json")"
308
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -Iseconds)" "$1" "$2" "$3" "$4" \
309
+ "${level:-}" "$model" "${effort:-default}" "$cost" "$out" "$cache" "$source" >>"$summary"
307
310
  }
308
311
 
309
312
  while line="$(next_task)"; [[ -n "$line" ]]; do
@@ -345,13 +348,12 @@ while line="$(next_task)"; [[ -n "$line" ]]; do
345
348
  session_json="$log_dir/task-$task_id-attempt-$attempt.json"
346
349
  while :; do
347
350
  # shellcheck disable=SC2086
348
- build_prompt "$task" "$feedback" | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p \
351
+ build_prompt "$task" "$feedback" | harness_claude_session "$timeout_s" "$session_json" -p \
349
352
  --output-format json \
350
353
  --permission-mode "$permission_mode" \
351
354
  --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
352
355
  "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
353
- ${level_flags} ${HARNESS_CLAUDE_ARGS:-} \
354
- >"$session_json" 2>&1 || true
356
+ ${level_flags} ${HARNESS_CLAUDE_ARGS:-} || true
355
357
 
356
358
  warn_if_untrusted "$session_json"
357
359
 
@@ -462,10 +464,10 @@ if [[ -x .harness/gates.sh ]] && .harness/gates.sh --has-final; then
462
464
  session_json="$log_dir/task-final-attempt-$attempt.json"
463
465
  # shellcheck disable=SC2086
464
466
  build_prompt "make the final gates pass: .harness/gates.sh --final (end-to-end checks run once at the end of the change). Fix the code or tests the change touched; the approved spec is the reference." "$final_gate_out" \
465
- | timeout "$timeout_s" "${HARNESS_CLAUDE[@]}" -p --output-format json --permission-mode "$permission_mode" \
467
+ | harness_claude_session "$timeout_s" "$session_json" -p --output-format json --permission-mode "$permission_mode" \
466
468
  --disallowedTools "Bash(git commit:*)" "Bash(git push:*)" "Bash(.harness/spec-pr.sh:*)" \
467
469
  "Bash(pnpm add:*)" "Bash(pnpm remove:*)" "Bash(pnpm update:*)" "Bash(npm install:*)" \
468
- ${level_flags} ${HARNESS_CLAUDE_ARGS:-} >"$session_json" 2>&1 || true
470
+ ${level_flags} ${HARNESS_CLAUDE_ARGS:-} || true
469
471
  warn_if_untrusted "$session_json"
470
472
  if harness_usage_error "$session_json"; then
471
473
  record final "$attempt" "$(( $(date +%s) - start ))" paused