@data-matters.io/agent-harness 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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,69 @@ 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 -u HARNESS_ANTHROPIC_BASE_URL 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_ANTHROPIC_BASE_URL is the optional base URL of that key (a gateway):
58
+ # it reaches the API run only, as ANTHROPIC_BASE_URL, and the plan run never
59
+ # sees it. A key from a gateway without its base URL would be sent to the
60
+ # Anthropic API and refused.
61
+ harness_claude_session() {
62
+ local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" base="${HARNESS_ANTHROPIC_BASE_URL:-}" rc=0 prompt
63
+ shift 2
64
+ rm -f "$out.source"
65
+ if [[ -z "$key" ]]; then
66
+ timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
67
+ return "$rc"
68
+ fi
69
+ prompt="$(mktemp)"
70
+ cat >"$prompt"
71
+ if [[ -n "$base" ]]; then
72
+ ANTHROPIC_API_KEY="$key" ANTHROPIC_BASE_URL="$base" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
73
+ else
74
+ ANTHROPIC_API_KEY="$key" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
75
+ fi
76
+ # Whatever the session printed, the key itself never stays in the log.
77
+ K="$key" node -e 'const fs = require("fs"); const f = process.argv[1];
78
+ 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
79
+ if (( rc != 124 )) && harness_api_refused "$out"; then
80
+ echo "harness: the API key was refused; repeating the session on the plan login" >&2
81
+ rc=0
82
+ if [[ -n "$base" ]]; then
83
+ timeout "$secs" env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
84
+ else
85
+ timeout "$secs" env -u ANTHROPIC_API_KEY "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
86
+ fi
87
+ echo plan >"$out.source"
88
+ else
89
+ echo api >"$out.source"
90
+ fi
91
+ rm -f "$prompt"
92
+ return "$rc"
93
+ }
32
94
 
33
95
  # Worktree where the task loop runs a change (ADR 0012), outside the checkout so
34
96
  # lint, tests and type-checks of the main checkout never see it:
@@ -158,14 +220,32 @@ harness_session_usage() {
158
220
 
159
221
  # harness_record_usage <project> <ref> <task-type> <level> <effort> <attempt> <result> <session.json>:
160
222
  # one line in the machine-wide ledger (HARNESS_USAGE_LEDGER, default
161
- # ~/.local/state/harness/usage.tsv); prints the session's usage columns.
223
+ # ~/.local/state/harness/usage.tsv); prints the session's usage columns. The last
224
+ # column, source, is api or plan (harness_claude_session); a ledger written
225
+ # before it existed gets the column added to its header, its old rows stay empty.
162
226
  harness_record_usage() {
163
- local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage
227
+ local ledger="${HARNESS_USAGE_LEDGER:-$HOME/.local/state/harness/usage.tsv}" usage source
164
228
  usage="$(harness_session_usage "$8")"
229
+ source="$(harness_session_source "$8")"
165
230
  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"
231
+ [[ -s "$ledger" ]] || printf 'timestamp\tproject\tref\ttask_type\tlevel\tmodel\teffort\tcost_usd\toutput_tokens\tcache_read_tokens\tresult\tattempt\tsource\n' >"$ledger"
232
+ harness_tsv_add_source "$ledger"
167
233
  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"
234
+ 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" \
235
+ "$model" "${5:-default}" "$cost" "$out" "$cache" "$7" "$6" "$source" >>"$ledger"
170
236
  printf '%s' "$usage"
171
237
  }
238
+
239
+ # harness_session_source <session.json>: api or plan, as harness_claude_session
240
+ # recorded it (plan when it did not: no API key was configured).
241
+ harness_session_source() {
242
+ local s=plan
243
+ [[ -f "$1.source" ]] && s="$(<"$1.source")"
244
+ echo "$s"
245
+ }
246
+
247
+ # harness_tsv_add_source <file>: adds the source column to the header of a TSV
248
+ # written before it existed.
249
+ harness_tsv_add_source() {
250
+ [[ "$(head -n1 "$1")" == *$'\tsource' ]] || sed -i '1s/$/\tsource/' "$1"
251
+ }
@@ -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