@data-matters.io/agent-harness 0.1.8 → 0.1.10

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.8",
3
+ "version": "0.1.10",
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": {
@@ -35,6 +35,27 @@ harness_load_env() {
35
35
  HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID -u HARNESS_ANTHROPIC_API_KEY -u HARNESS_ANTHROPIC_BASE_URL claude)
36
36
 
37
37
  # ------------------------------------------- optional API key (ADR 0020) --
38
+ # harness_session_error_text <session.json>: the text of an errored session (its
39
+ # result and api_error), or the raw output when it is not JSON. Empty when the
40
+ # session did not fail.
41
+ harness_session_error_text() {
42
+ node -e "$harness_session_js"'
43
+ const raw = (() => { try { return require("fs").readFileSync(process.argv[1], "utf8"); } catch { return ""; } })();
44
+ const d = session(process.argv[1]);
45
+ process.stdout.write(Object.keys(d).length
46
+ ? (d.is_error ? [d.result, d.api_error].filter((x) => typeof x === "string").join(" ") : "")
47
+ : raw);' "$1" 2>/dev/null
48
+ }
49
+
50
+ # harness_session_api_error <session.json>: the session's api_error field, the
51
+ # structured value the runtime sets when the API refused (usage_limit_reached).
52
+ # Empty when the session carries none.
53
+ harness_session_api_error() {
54
+ node -e "$harness_session_js"'
55
+ const d = session(process.argv[1]);
56
+ process.stdout.write(typeof d.api_error === "string" ? d.api_error : "");' "$1" 2>/dev/null
57
+ }
58
+
38
59
  # harness_api_refused <session.json>: the session ended on an error that says
39
60
  # the API cannot serve it: no credit, a quota, a spend or usage limit, billing,
40
61
  # an invalid key, or the gateway itself failing (503 or another 5xx, a
@@ -43,12 +64,7 @@ HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID -u
43
64
  # a task about authentication or billing never matches by its own words.
44
65
  harness_api_refused() {
45
66
  local text
46
- text="$(node -e "$harness_session_js"'
47
- const raw = (() => { try { return require("fs").readFileSync(process.argv[1], "utf8"); } catch { return ""; } })();
48
- const d = session(process.argv[1]);
49
- process.stdout.write(Object.keys(d).length
50
- ? (d.is_error ? [d.result, d.api_error].filter((x) => typeof x === "string").join(" ") : "")
51
- : raw);' "$1" 2>/dev/null)" || return 1
67
+ text="$(harness_session_error_text "$1")" || return 1
52
68
  [[ -n "$text" ]] || return 1
53
69
  harness_api_unreachable "$text" && return 0
54
70
  harness_quota_transient "$text" && return 1
@@ -56,86 +72,30 @@ harness_api_refused() {
56
72
  grep -qiE "credit balance|spend(ing)? limit|billing|usage limit|rate limit|out of usage|invalid api key|authentication" <<<"$text"
57
73
  }
58
74
 
59
- # ------------------------------------------------ machine mode (ADR 0025) --
60
- # harness_mode: api, plan or blocked, from the machine's mode file
61
- # (HARNESS_MODE_FILE, default ~/.local/state/harness/mode; its only writer is
62
- # the watchdog). A missing, unreadable or unrecognised file is api, the
63
- # preferred mode.
64
- harness_mode() {
65
- local f="${HARNESS_MODE_FILE:-$HOME/.local/state/harness/mode}" line mode=api
66
- if [[ -r "$f" ]]; then
67
- while IFS= read -r line; do
68
- [[ "$line" == mode=* ]] && mode="${line#mode=}"
69
- done <"$f"
70
- fi
71
- case "$mode" in api|plan|blocked) echo "$mode" ;; *) echo api ;; esac
72
- }
73
-
74
- # harness_claude_session <timeout-seconds> <session.json> <claude args...>: runs
75
- # one headless Claude Code session (the prompt on stdin) and writes its output
76
- # (stdout and stderr) to <session.json>; returns the exit status of the run
77
- # that produced it (124: timed out). Without HARNESS_ANTHROPIC_API_KEY this is
78
- # the account login, exactly as before. With it, the session runs on the API
79
- # first (the key goes to that process only, through its environment, never its
80
- # arguments or a log); when the API refuses it (harness_api_refused), the SAME
81
- # attempt repeats on the plan login. Which one produced the output is written to
82
- # <session.json>.source (api|plan), which the usage ledger reads.
83
- # HARNESS_ANTHROPIC_BASE_URL is the optional base URL of that key (a gateway):
84
- # it reaches the API run only, as ANTHROPIC_BASE_URL, and the plan run never
85
- # sees it. A key from a gateway without its base URL would be sent to the
86
- # Anthropic API and refused.
87
- harness_claude_session() {
88
- local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" base="${HARNESS_ANTHROPIC_BASE_URL:-}" rc=0 prompt mode unset=()
89
- shift 2
90
- rm -f "$out.source"
91
- # The machine mode (ADR 0025) names the authentication to start on: api → the
92
- # key and its base URL, plan → the account login with both removed, blocked →
93
- # nothing runs. The fallback below stays as the net for a refusal that
94
- # happens after the mode was read.
95
- mode="$(harness_mode)"
96
- if [[ "$mode" == blocked ]]; then
97
- echo '{"type":"result","is_error":true,"api_error":"usage_limit_reached","result":"harness: the machine is blocked (the API does not respond and the account is out of usage); usage limit reached"}' >"$out"
98
- return 1
99
- fi
100
- if [[ -z "$key" || "$mode" == plan ]]; then
101
- # No key configured, or the API is down: the session is the account login,
102
- # on the Anthropic API. A base URL left in the environment would point that
103
- # login at a gateway, and a key must not reach it.
104
- [[ -z "$key" ]] || unset=(-u ANTHROPIC_API_KEY)
105
- [[ -z "$base" ]] || unset+=(-u ANTHROPIC_BASE_URL)
106
- if (( ${#unset[@]} )); then
107
- timeout "$secs" env "${unset[@]}" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
108
- else
109
- timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
110
- fi
111
- [[ -z "$key" ]] || echo plan >"$out.source"
112
- return "$rc"
113
- fi
114
- prompt="$(mktemp)"
115
- cat >"$prompt"
116
- if [[ -n "$base" ]]; then
117
- ANTHROPIC_API_KEY="$key" ANTHROPIC_BASE_URL="$base" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
118
- else
119
- ANTHROPIC_API_KEY="$key" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
120
- fi
121
- # Whatever the session printed, the key itself never stays in the log.
122
- K="$key" node -e 'const fs = require("fs"); const f = process.argv[1];
123
- 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
124
- if (( rc != 124 )) && harness_api_refused "$out"; then
125
- echo "harness: the API did not serve the session (refused, or the gateway failed); repeating it on the plan login" >&2
126
- rc=0
127
- if [[ -n "$base" ]]; then
128
- timeout "$secs" env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
129
- else
130
- timeout "$secs" env -u ANTHROPIC_API_KEY "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
131
- fi
132
- echo plan >"$out.source"
133
- else
75
+ # ----------------------------------- machine layer shim (ADR 0026, section 9) --
76
+ # The session wrapper and the machine mode live in the machine layer,
77
+ # machine/session.sh, in the VPS runtime (HARNESS_WATCH_RUNTIME, default
78
+ # ~/.local/share/agent-harness/watch). HARNESS_MACHINE_API=1 is the contract.
79
+ # Without a runtime (CI, a laptop, a clone elsewhere) a session is a plain claude
80
+ # with the caller's own environment and the mode is api: the degraded fallback of
81
+ # ADR 0025, section 8. A key from HARNESS_ANTHROPIC_API_KEY is not used then.
82
+ # shellcheck disable=SC2034 # the contract marker, read by the runtime
83
+ HARNESS_MACHINE_API=1
84
+ harness_machine_session_file() { printf '%s' "${HARNESS_WATCH_RUNTIME:-$HOME/.local/share/agent-harness/watch}/machine/session.sh"; }
85
+ if [[ -r "$(harness_machine_session_file)" ]]; then
86
+ # shellcheck source=/dev/null
87
+ . "$(harness_machine_session_file)"
88
+ else
89
+ harness_mode() { echo api; }
90
+ harness_claude_session() { # <timeout-seconds> <session.json> <claude args...>
91
+ local secs="$1" out="$2" rc=0
92
+ shift 2
93
+ rm -f "$out.source"
94
+ timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
134
95
  echo api >"$out.source"
135
- fi
136
- rm -f "$prompt"
137
- return "$rc"
138
- }
96
+ return "$rc"
97
+ }
98
+ fi
139
99
 
140
100
  # Worktree where the task loop runs a change (ADR 0012), outside the checkout so
141
101
  # lint, tests and type-checks of the main checkout never see it:
@@ -205,8 +165,30 @@ harness_claude_flags() {
205
165
  # session limit") and sets api_error to usage_limit_reached, so both are matched;
206
166
  # the gateway's Portuguese quota message is matched too, and its transient 429
207
167
  # is not (quota.sh, ADR 0025 section 8).
168
+ #
169
+ # Only the session's own FAILURE is read: the api_error field, and the error
170
+ # text of a session that ended with is_error (harness_session_error_text).
171
+ # The answer of a session that ended well is never a usage error by what it
172
+ # says: a task that explains an old quota event ("cota semanal") is a task
173
+ # about a quota, not a quota. The one exception is the gateway's quota notice,
174
+ # which arrives unmarked, in the body of a 200: there the whole answer IS the
175
+ # notice, so a short answer that carries a quota marker still is a usage error
176
+ # (harness_quota_notice_unmarked).
208
177
  harness_usage_error() {
209
- harness_quota_exhausted "$(cat -- "$1" 2>/dev/null)"
178
+ local text reply
179
+ text="$(harness_session_error_text "$1")"
180
+ if [[ -n "$text" ]]; then
181
+ # The session failed: its error text (its result and its api_error) is the
182
+ # message the rule is about.
183
+ if harness_quota_exhausted "$text"; then return 0; fi
184
+ return 1
185
+ fi
186
+ # The session ended well: its answer is not a usage error by what it says.
187
+ # The api_error field still counts (usage_limit_reached), and the gateway's
188
+ # unmarked notice is the one answer that IS the notice.
189
+ if harness_quota_exhausted "$(harness_session_api_error "$1")"; then return 0; fi
190
+ reply="$(harness_session_reply "$1")" || return 1
191
+ harness_quota_notice_unmarked "$reply"
210
192
  }
211
193
 
212
194
  # harness_level_descend <level>: the level one below, for the fallback after a
@@ -28,5 +28,6 @@ TASK_ci_fix_retry=R3 # failing checks after a failed fix (watcher, ADR 0019)
28
28
  TASK_review_comments=R2 # owner comments on implementation/sync PRs (ADR 0019, 1b)
29
29
  TASK_diagnosis=R3 # explaining an escalation
30
30
  TASK_review=R3 # PR review
31
+ TASK_issue_fix=R3 # a fix for an open harness-fault issue (ADR 0027, decision 5); unused until fix mode is enabled
31
32
  TASK_archive_fix=R1 # editorial split of a merged requirement over 500 characters (ADR 0022), gate-verified
32
33
  TASK_routine=R1 # hand edits around sync and archive
@@ -11,6 +11,10 @@
11
11
  # a quota marker and no transient marker. A 429 alone is not enough in
12
12
  # either direction: both gateway messages carry the same structured
13
13
  # fields, so only the text tells them apart.
14
+ # (c) is a session that ENDED WELL the gateway's quota notice?
15
+ # harness_quota_notice_unmarked <text>
16
+ # the gateway sometimes answers 200 with the quota notice in the body,
17
+ # without marking it as an error: there the whole answer is the notice.
14
18
  # A transient 429 (harness_quota_transient) is neither: it may be retried and
15
19
  # changes nothing.
16
20
  #
@@ -34,8 +38,11 @@ harness_quota_markers='limite de uso do seu plano|cota semanal|sera resetado em|
34
38
  # Transient markers, on normalized text: the request may be retried.
35
39
  harness_quota_transient_markers='o consumo da requisicao anterior esta sendo atualizado|tente novamente em alguns segundos|temporarily at capacity|repeated [0-9]+ overloaded errors'
36
40
 
37
- # Gateway failures, on normalized text: the API does not answer.
38
- harness_gateway_markers='api error:? \(?5[0-9]{2}|(status|error|http)[ :=]+5[0-9]{2}\b|"api_error_status": ?5[0-9]{2}|\b50[0-9] (service unavailable|bad gateway|gateway time-?out)|service unavailable|bad gateway|gateway time-?out|econnrefused|econnreset|etimedout|enotfound|eai_again|socket hang up|fetch failed|unable to connect|connection error|connection refused|request timed out'
41
+ # Gateway failures, on normalized text: the API does not answer. This is the
42
+ # one list for the whole system (ADR 0026, section 6): the machine library
43
+ # (vps-bootstrap) loads this file and keeps a copy only as a degraded fallback,
44
+ # and a test there fails if the two disagree.
45
+ harness_gateway_markers='api error:? *\(?5[0-9]{2}|(status|error|http)[ :=]+5[0-9]{2}\b|"api_error_status": ?5[0-9]{2}|\b50[0-9] (service unavailable|bad gateway|gateway time-?out)|service unavailable|bad gateway|gateway time-?out|overloaded|upstream|econnrefused|econnreset|etimedout|enotfound|eai_again|socket hang up|fetch failed|unable to connect|connection (error|refused|reset|closed)|timed? ?out|could not resolve|network error|context validation'
39
46
 
40
47
  # harness_quota_transient <text>: a retryable condition, not a quota.
41
48
  harness_quota_transient() {
@@ -49,8 +56,38 @@ harness_quota_exhausted() {
49
56
  grep -qE -- "$harness_quota_markers" <<<"$t" && ! grep -qE -- "$harness_quota_transient_markers" <<<"$t"
50
57
  }
51
58
 
59
+ # The longest text that can be the gateway's quota notice. The notice arrives
60
+ # in the body of a 200 response, without being marked as an error (the rule of
61
+ # the watchdog, vps-bootstrap, 2026-11-11): there the whole answer IS the
62
+ # notice. A long answer that talks about a quota is a task about a quota, not a
63
+ # quota.
64
+ harness_quota_notice_max_len=400
65
+
66
+ # harness_quota_notice_unmarked <text>: the text is the gateway's quota notice
67
+ # that arrived unmarked: a SHORT message (at most harness_quota_notice_max_len
68
+ # characters) that carries a quota marker. The markers are the ones above (one
69
+ # list, ADR 0025 section 8): this rule only says how much of the answer may
70
+ # be the notice.
71
+ harness_quota_notice_unmarked() {
72
+ local t="$1" len
73
+ len=${#t}
74
+ (( len <= harness_quota_notice_max_len )) || return 1
75
+ harness_quota_exhausted "$t"
76
+ }
77
+
52
78
  # harness_api_unreachable <text>: the gateway failed (503 or another 5xx, a
53
79
  # connection failure, a timeout): the API does not answer, whatever the quota.
54
80
  harness_api_unreachable() {
55
81
  grep -qE -- "$harness_gateway_markers" <<<"$(harness_quota_normalize "$1")"
56
82
  }
83
+
84
+ # harness_api_fault_class <text>: how a session error counts for the machine
85
+ # mode (ADR 0025 section 8, ADR 0026 sections 3 and 6). Prints one word:
86
+ # transient a passing failure: counts only after several in a row
87
+ # down the API does not serve: a quota, or a gateway failure
88
+ # none not a failure of the API
89
+ harness_api_fault_class() {
90
+ if harness_quota_transient "$1"; then printf 'transient'; return 0; fi
91
+ if harness_quota_exhausted "$1" || harness_api_unreachable "$1"; then printf 'down'; return 0; fi
92
+ printf 'none'
93
+ }