@cratylus/canon 0.12.1 → 0.12.3

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.
@@ -1,65 +1,10 @@
1
1
  import { anchorOf } from '@cratylus/schema';
2
2
  import { handoff } from '../dimensions/autonomy/handoff.js';
3
- // stance-guardrail — the harness-half of the principal stance. A source
4
- // `hook` cell (activation=event): its canonical DEFINIENS is the σ*-signified
5
- // identity that `accept()` gates; its `workers[].content` are the VERBATIM
6
- // byte-anchors the committed workers under `targets/guardrail/` regenerate
7
- // from (byte-locked by `test/hook-rule-boundary.test.ts`). The claude adapter
8
- // realizes `event` → a `settings.json` `{hooks}` merge + `hooks/<id>/` workers.
9
- // THE ONE CAP ON WHAT A GUARD'S JUDGE IS SENT, declared here and stamped into all three
10
- // workers (this one, `stance-guardrail-pre`, `purview-guardrail`). A judgement has to fit
11
- // the time its harness allows a guard — omp kills an extension handler at 30 s, a Claude
12
- // Code cell runs 60 s — and the payload was unbounded: the Stop worker sent every assistant
13
- // message since the last operator message and the whole operator message, the pre and purview
14
- // workers the whole dispatch prompt or menu. Bytes, not characters: the limit is the size of
15
- // what crosses to the judge. The pre and purview workers take the shell below from the
16
- // exports at the foot of this module rather than restating either.
17
- const judgePayloadCapBytes = 12_000;
18
- // The shell every worker carries to honour the cap. Written with `String.raw` so its
19
- // backslashes reach the worker verbatim, and free of backticks and `${` for the same reason.
20
- // `jq` does the cutting because it is already a dependency of every worker and counts a
21
- // string in characters and in bytes (`utf8bytelength`), so a cut never lands inside a
22
- // character: the result is valid UTF-8 and never over the bound. Text over its share is
23
- // cut and MARKED where it was cut — the judge is told it reads an excerpt and cannot take
24
- // the seam for the agent's own words.
25
- const judgeClipShell = String.raw `# THE JUDGE IS SENT A BOUNDED EXCERPT, never the whole text. A judgement has to fit inside the
26
- # time its harness allows a guard, and the text a turn or a dispatch carries has no bound of its own.
27
- # One cap, declared once in the stance cell, bounds everything sent together: the standing
28
- # directive, the operator's message, the agent turn and any layer-1 note. Text over its share is
29
- # cut on a character boundary and marked where it was cut.
30
- JUDGE_PAYLOAD_CAP=${judgePayloadCapBytes}
31
- judge_bytes() { printf '%s' "$1" | wc -c | tr -d ' '; }
32
- JUDGE_JQ='
33
- def pre($n): . as $s | if $n <= 0 then "" else
34
- { lo: 0, hi: ($s | length) } | until(.lo >= .hi;
35
- ((.lo + .hi + 1) / 2 | floor) as $m
36
- | if ($s[:$m] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end)
37
- | $s[:.lo] end;
38
- def suf($n): . as $s | if $n <= 0 then "" else
39
- { lo: 0, hi: ($s | length) } | until(.lo >= .hi;
40
- ((.lo + .hi + 1) / 2 | floor) as $m
41
- | if ($s[-$m:] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end)
42
- | if .lo == 0 then "" else $s[-.lo:] end end;
43
- '
44
- # Text is read with --rawfile from stdin and never with -R: jq 1.7's raw reader corrupts a
45
- # multibyte character that straddles one of its read boundaries, on anything over a few KB.
46
- # The final N bytes of stdin, or all of it when it fits — no marker, so a caller can count them.
47
- judge_keep() { jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"' $text | suf($n)'; }
48
- # Stdin cut to at most N bytes by keeping its head and its tail, the seam marked. For a dispatch
49
- # prompt or a menu, whose instruction may sit at either end.
50
- judge_ends() {
51
- jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"'
52
- $text | . as $s | ($s | utf8bytelength) as $t
53
- | if $t <= $n then $s else
54
- ((($n - 200) / 2) | floor) as $k
55
- | ($s | pre($k)) as $h | ($s | suf($k)) as $e
56
- | ($t - ($h | utf8bytelength) - ($e | utf8bytelength)) as $gone
57
- | $h + "\n[ELIDED: \($gone) of \($t) bytes from the middle are not shown]\n" + $e
58
- end'
59
- }
60
- # The marker for text cut off its front: judge_cut WHAT TOTAL-BYTES SHOWN-BYTES.
61
- judge_cut() { printf '[ELIDED: the first %s of %s bytes of %s are not shown; what follows is its final part]' "$(($2 - $3))" "$2" "$1"; }
62
- `;
3
+ import { contestShell, judgeClipShell, judgeTailShell, } from '../guard-shell.js';
4
+ // stance-guardrail — the turn-end guard of the principal stance. Its `workers[].content` are
5
+ // the byte-anchors the committed workers under `targets/guardrail/` regenerate from
6
+ // (`test/hook-rule-boundary.test.ts` byte-locks them); the claude adapter realizes `event`
7
+ // as a `settings.json` hook merge plus `hooks/<id>/` workers.
63
8
  export const stanceGuardrail = {
64
9
  id: 'stance-guardrail',
65
10
  residue: 'structural-refusal ↾ turn-end · block ⟨intent-driven-expert-collapse⟩ ⟨permission-seeking · own-judgment-deferral · order-taking⟩ · pass ⟨reserved · irreversible-outward ↦ consent · intent-ambiguity ↦ elicit · ∄ mandate ↦ surface ⟨electing the objective ∉ in-remit⟩⟩ · harness-invariant ⟨prompt-identity erodes ↾ RLHF-corrigibility⟩',
@@ -78,83 +23,16 @@ export const stanceGuardrail = {
78
23
  targetPath: 'packages/canon/targets/guardrail/stance-guardrail.sh',
79
24
  executable: true,
80
25
  content: `#!/usr/bin/env sh
81
- # stance-guardrail — a Stop hook that STRUCTURALLY REFUSES a turn in which an
82
- # agent collapses out of the intent-driven-expert (fiduciary-agent) stance.
83
- #
84
- # WHY THIS EXISTS (the harness half of the principal stance):
85
- # Encoding the principal stance as IDENTITY (Nico's half) raises the threshold but is not
86
- # truly invariant — enough operator pushback erodes any prompt-level stance. NOTE the original
87
- # justification here blamed "RLHF corrigibility reads a correction as 'defer more'"; that is
88
- # OVER-ATTRIBUTED and is corrected rather than deleted, since it was load-bearing for the cap
89
- # that has now been removed. Perez et al. measure sycophancy as "similar for models trained
90
- # with various numbers of RL steps, including 0" — it is not RLHF-specific — and the stronger
91
- # claim, that a correction RAISES deference on later unrelated work, is unmeasured. The nearest
92
- # result finds carryover that dissolves across topic change and is SYMMETRIC: non-deferential
93
- # states self-perpetuate too. So a corrective gate is not self-defeating by construction.
94
- # TRUE invariance needs the harness to
95
- # refuse the collapsed turn. This is that refusal: on Stop, it judges the last assistant turn
96
- # against the stance rubric and BLOCKS (Claude Code Stop-hook \`{"decision":"block"}\`) when it
97
- # detects collapse, feeding corrective feedback that tells the agent to re-assume the stance.
98
- #
99
- # WHAT IT BLOCKS (collapse signals — see stance-judge-prompt.md for the full rubric):
100
- # - permission-seeking for in-remit, reversible work ("should I…?", option-menus)
101
- # - deferring the agent's own expert judgment (naming/design/architecture/how) to the operator
102
- # - echoing / order-taking the operator's literal words instead of extracting+serving intent
103
- # WHAT IT DOES NOT BLOCK (the reserved set):
104
- # - surfacing a genuine irreversible-outward act (deploy/push/publish) for consent
105
- # - routing a genuine INTENT ambiguity to /elicit
106
- # - surfacing an ABSENT mandate (no objective in the input AND an empty inherited work-set):
107
- # electing the session's objective is supplying intent, not sequencing — the operator's to own
108
- #
109
- # SAFETY MODEL:
110
- # - SCOPE-ENROLLED. Fires for a scope carrying a stance manifest, and for no other. Enrollment
111
- # is PRESENCE: the projection places a manifest in each persona's own scope, so composing the
112
- # cell enrolls the persona and nothing central lists anybody. A bare launch carries the
113
- # dispatcher and no manifest, and is therefore silent by placement.
114
- # - NO REPO OPT-IN. There was one, per-repo in .git/config, and it asked the wrong question:
115
- # whether a guard may run in a DIRECTORY. The off switch is launching \`omp\` rather than a
116
- # persona — declining to BE the agent, instead of being it unjudged.
117
- # - FAILS OPEN, BUT NEVER SILENTLY-CLEAN. Any error → exit 0 (allow stop): a guardrail that
118
- # wedges work on its own flakiness is worse than a missed block. But a turn let through
119
- # WITHOUT A VERDICT says so where the operator reads it (\`say\`: Claude Code's JSON
120
- # \`systemMessage\`, omp's relayed line), naming this guard and why it could not judge —
121
- # no jq, no input, an unreadable transcript or rubric, a judge that does not answer or
122
- # answers unparseably, a block it could not verify, an unexpected error. Silence is reserved for
123
- # "judged, no collapse" and for "not enrolled" (no persona manifest in scope, or nothing
124
- # judgeable in the turn): there, not-checking is the correct answer, not a failure to report.
125
- # - A REFUSAL HOLDS. Judges every turn, one identical to a refused turn included, and a BLOCK
126
- # blocks the stop however many blocks came before: nothing remembers a refusal to wave a retry
127
- # through, nothing counts blocks to stop blocking. The block count is a count in the reason.
128
- # - POSITION-SOUND. Every rubric rule that can fire is a claim about the turn's CLOSE. So the
129
- # L1 window and the EVIDENCE check both run against \`asst_close\` — the text AFTER the last
130
- # tool call — never the whole-turn blob. A span from a mid-turn preamble is out of scope for
131
- # a verdict about how the turn ended, however genuinely present those characters are.
132
- #
133
- # INPUT : Claude Code Stop hook JSON on stdin (transcript_path, stop_hook_active,
134
- # agent_type, session_id, cwd, …).
135
- # OUTPUT : on collapse → stdout \`{"decision":"block","reason":"…"}\` + exit 0 (Stop-hook block).
136
- # judged, no collapse → no stdout + exit 0 (allow stop).
137
- # let through with no verdict → the notice \`say\` prints + exit 0 (allow stop).
138
- #
139
- # POSIX sh. Depends on: jq (transcript parse). Missing jq → the guard says so and allows the stop.
140
-
26
+ # stance-guardrail: a Stop hook that blocks a turn collapsing out of the intent-driven-expert stance.
27
+ # FAILS OPEN, BUT NEVER SILENTLY-CLEAN: what stops it judging lets the stop through with a notice and a DARK row.
141
28
  set -eu
142
29
 
143
- # WHAT AN OPERATOR READS WHEN THE GUARD CANNOT JUDGE, defined before anything that needs a
144
- # command. The first thing that can be missing is \`jq\`, and a worker run with a PATH holding
145
- # little beyond \`sh\` and \`cat\` must still say it did not judge — so \`esc\`, \`say\` and \`dark\`
146
- # are builtins only, and nothing external runs above the jq check.
147
- #
148
- # Claude Code shows the operator a hook's JSON \`systemMessage\` and NOT its plain stdout on
149
- # exit 0, so a notice printed as a bare line reaches nobody there. omp's extension bridge
150
- # reads the worker's plain stdout and relays a line naming this guard and DARK to the
151
- # session, and would relay a JSON object as raw JSON — hence one form per harness, chosen at
152
- # projection by the harness's own name.
30
+ # esc, say and dark use builtins only: they must work when jq is missing.
153
31
  esc() {
154
32
  _in="$1"
155
33
  _out=""
156
- _nl="$(printf '\\nx')"
157
- _nl="\${_nl%x}"
34
+ _nl='
35
+ '
158
36
  _tab="$(printf '\\t')"
159
37
  _cr="$(printf '\\r')"
160
38
  while [ -n "$_in" ]; do
@@ -179,170 +57,83 @@ say() {
179
57
  esac
180
58
  }
181
59
 
182
- # A Stop hook must never break a session. An unexpected error (a nonzero status reaching the
183
- # trap) is let through AND SAID; every deliberate \`exit 0\` arrives here with status 0 and stays
184
- # as quiet as it chose to be.
185
60
  trap 'rc=$?; [ -z "\${view:-}" ] || rm -f "$view" 2>/dev/null; [ "$rc" -eq 0 ] || dark "an unexpected error stopped it (exit status $rc)"; exit 0' EXIT
186
61
 
187
- allow_stop() { exit 0; } # emit nothing; the agent is permitted to stop.
188
-
189
- # A VERDICT and a FAILURE are different facts, and silence can only carry one of them.
190
- # \`allow_stop\` means "checked, no collapse". It must never also be the answer to "could
191
- # not check" — that is a bypass by omission: with the judge unreachable the guardrail
192
- # reports a clean turn forever and nothing ever says the guardrail went dark. Exactly the
193
- # defect fixed one file over in the memory nudge ("a broken runtime read as a clean bill
194
- # of health, silently and forever"); the inversion is the same — \`if signal absent then
195
- # pass\` becomes \`if signal absent then SAY SO\` — and, as there, it still never wedges the
196
- # turn. Reached only for a guard that is IN SCOPE and could not judge: a scope carrying no
197
- # persona manifest stays silent.
62
+ allow_stop() { exit 0; }
63
+ row() { [ -z "\${verdict_log:-}" ] || printf '%s\\t%s\\t%s\\n' "$1" "$2" "$3" >> "$verdict_log" 2>/dev/null || true; }
198
64
  dark() {
199
- # THE HEARTBEAT, and the reason the log is worth keeping. A VERDICT row and a
200
- # DARK row are both evidence the guard RAN; only an EMPTY log means it never
201
- # did. Recording judged turns alone made "not judged" and "no session" read
202
- # identically — measured on a live host: an endpoint behind the advisor role
203
- # accepted connections and never answered, and five hours of one session were
204
- # unjudged while leaving no trace whatsoever to notice it by.
205
- if [ -n "\${verdict_log:-}" ]; then
206
- printf 'DARK\\t\\t%s\\n' "$1" >> "$verdict_log" 2>/dev/null || true
207
- fi
65
+ row DARK "" "$1"
208
66
  say "STANCE GUARDRAIL — DARK: $1. This turn was NOT judged; the absence of a block is an absence of a verdict, not a clean one."
209
67
  exit 0
210
68
  }
211
69
 
212
- # --- read hook input ------------------------------------------------------------------------
213
70
  input="$(cat)"
214
71
  [ -n "$input" ] || dark "the hook received no input"
215
-
216
72
  command -v jq >/dev/null 2>&1 || dark "jq is not installed, so the hook payload cannot be read"
73
+ field() { printf '%s' "$input" | jq -r "$1" 2>/dev/null || true; }
217
74
 
218
- # A GUARD BINDS A PERSONA'S OWN MAIN SESSION AND NO SUBAGENT IT DISPATCHES. A subagent is bounded
219
- # by what it was handed, judged by its assay and the whole check, and supervised by the main
220
- # session. Claude Code fires this hook inside a subagent too (settings hooks and the subagent's
221
- # own front-matter hooks), and there the payload carries \`agent_id\` — present only inside a
222
- # subagent. A guard that does not bind there is not dark: it exits before it judges and says
223
- # nothing.
224
- [ -z "$(printf '%s' "$input" | jq -r '.agent_id // empty' 2>/dev/null || true)" ] || allow_stop
75
+ # Only a subagent's payload carries agent_id; the guard binds main sessions.
76
+ [ -z "$(field '.agent_id // empty')" ] || allow_stop
225
77
 
226
78
  SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
227
79
  JUDGE_CMD="\${STANCE_JUDGE_CMD:-sh $SELF_DIR/stance-judge.sh}"
228
-
229
- # THE RUBRIC LIVES AT THE VENDOR-NEUTRAL ROOT, and is reached by DERIVATION so it
230
- # follows whatever home this copy was deployed under (a real \$HOME, or a test's
231
- # \`--home\`). This worker sits at \`<harness-home>/hooks/<id>/\`, and \`.agents\` is
232
- # the SIBLING of every harness home — the same relation deploy already uses to
233
- # place agents and skills — so three \`dirname\`s and a name is the whole address.
234
- #
235
- # Derived rather than baked: a literal \`\$HOME/.agents\` would expand correctly on
236
- # a host and point outside a sandboxed deploy, which is the class of bug that put
237
- # \`\$HOME/.claude\` in the sibling worker and made it read another harness's tree.
238
80
  NEUTRAL_ROOT="$(dirname -- "$(dirname -- "$(dirname -- "$SELF_DIR")")")/.agents"
239
- # The DEFAULT rubric. A persona's own manifest may name another — that is the
240
- # strategy seam: one opinionated implementation, each persona free to bind a
241
- # different contract without the dispatcher learning anything about either.
242
81
  RUBRIC="\${STANCE_RUBRIC:-$NEUTRAL_ROOT/stance-guardrail/stance-judge-prompt.md}"
243
82
 
244
- # --- loop safety: a refusal holds -------------------------------------------------------------
245
- # EVERY TURN IS JUDGED, AND A BLOCK BLOCKS THE STOP HOWEVER MANY BLOCKS CAME BEFORE IT. A turn
246
- # identical to one already refused is judged again like any other, and blocked again when the
247
- # judge blocks it; nothing remembers a refusal in order to wave a retry through, and nothing counts
248
- # blocks in order to stop blocking. The retry law is the same one the pre-call guard keeps.
249
- #
250
- # This SUPERSEDES the original \`stop_hook_active=true → allow_stop\` short-circuit, which was not a
251
- # loop guard but a hole: the agent got blocked, emitted anything, and ended again UNJUDGED — and it
252
- # made the rubric's judgement of a response to a verdict dead code, since that response was the one
253
- # turn guaranteed never to reach the judge. It also superseded two caps that let a refused turn
254
- # through (a turn byte-identical to the last blocked, and a turn judged collapsed after a run of
255
- # consecutive blocks). A safety check must never be a function of how many times it has fired:
256
- # Ethernet, TCP, systemd and CrashLoopBackOff cap EFFORT and end in a loud, typed refusal, and
257
- # permission is never what gets spent. What a block loop ends on is a turn the judge passes.
258
- #
259
- # The state in the tmp dir, keyed by session id, is a COUNT of the blocks, carried in the reason
260
- # the agent reads and in the verdict log. It decides nothing, so a dir it cannot write costs the
261
- # count and never a block.
83
+ RUN_CAP=3
262
84
  session="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)"
263
85
  state_dir="\${TMPDIR:-/tmp}/stance-guardrail"
264
86
  mkdir -p "$state_dir" 2>/dev/null || true
265
- count_file="$state_dir/$session.count"
87
+ run_file="$state_dir/$session.run"
266
88
  verdict_log="$state_dir/$session.verdicts"
267
89
 
268
- block_count="$(cat "$count_file" 2>/dev/null || echo 0)"
269
- case "$block_count" in *[!0-9]*) block_count=0 ;; esac
90
+ stop_active="$(field 'if .stop_hook_active == true then "yes" else "" end')"
91
+ run=0
92
+ run_hash=none
93
+ run_known=1
94
+ if [ -n "$stop_active" ] && [ -e "$run_file" ]; then
95
+ run_state="$(cat "$run_file" 2>/dev/null || true)"
96
+ run="\${run_state%% *}"
97
+ run_hash="\${run_state#* }"
98
+ case "$run" in '' | *[!0-9]*) run=0; run_hash=none; run_known=0 ;; esac
99
+ fi
270
100
 
271
- # The per-repo opt-in is GONE, and its absence is the point. It answered "may a guard run in this
272
- # DIRECTORY", which is a category error: a stance is a property of the agent, not of the checkout
273
- # it happens to be standing in. Worse, it made the stance OPTIONAL AT RUNTIME for an agent already
274
- # launched as itself — the ambient form, when the whole reason this harness half exists is that
275
- # identity alone erodes. The off switch is launching \`omp\` instead of \`mav\`: declining to be the
276
- # persona, rather than being it unjudged.
277
- cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
101
+ cwd="$(field '.cwd // empty')"
278
102
  [ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true
279
103
 
280
- # --- scope gate: the persona's OWN stance manifest -------------------------------------------
281
- # COMPOSITION IS THE SCOPE, NOT A RUNTIME SELF-FILTER — MODEL.md's ENFORCED clause, and this
282
- # gate used to violate it outright. It read an allowlist, \`nico mav\` by default and overridable
283
- # by \`STANCE_GUARD_AGENTS\`, which is precisely the runtime self-filter that clause forbids: a
284
- # module asking WHO IS RUNNING rather than being placed where only the right launch reaches it.
285
- #
286
- # It had already drifted, in the direction such a list always drifts. Every agent in the corpus
287
- # composes \`handoff\`, so \`Binding.agents\` DERIVES all six as bound, and six persona scopes are
288
- # deployed to prove it — while the list enforced two. \`architect\` and \`kino\` held principal
289
- # authority and were never once judged, and nothing surfaced the gap because the two facts were
290
- # written in different languages in different files.
291
- #
292
- # Enrollment is now PRESENCE. Each bound persona's projection lands a manifest in that persona's
293
- # own scope, and the scope reaches this worker in the one form its harness offers. omp's dispatcher
294
- # lives in that same scope and therefore already knows it — it passes it in the envelope as
295
- # \`stance_scope\`. Claude Code places no dispatcher; its hook payload NAMES the running agent as
296
- # \`agent_type\` (on the main thread of a \`--agent\` session, and on neither for a
297
- # bare session), so the scope is the persona's directory under this harness's own home, one hop
298
- # above the hooks root this worker was deployed into. Either way the scope is then read the same:
299
- # A manifest means enrolled, and carries THIS agent's contract: its rubric, its moments, its
300
- # handoff laws. No manifest means not enrolled, which is silence rather than an error, so a plain
301
- # session, a built-in agent or a host's own agent — none of which declared itself anything — stays
302
- # untouched exactly as it was. Adding a persona enrolls it; adding a gated dimension edits one
303
- # persona's manifest; neither is a line in this file, and this file carries no agent list.
304
- stance_scope="$(printf '%s' "$input" | jq -r '.stance_scope // empty' 2>/dev/null || true)"
104
+ # A stance manifest in the persona's scope enrolls it: omp passes the scope, Claude Code the agent_type.
105
+ stance_scope="$(field '.stance_scope // empty')"
305
106
  if [ -z "$stance_scope" ]; then
306
- named="$(printf '%s' "$input" | jq -r '.agent_type // empty' 2>/dev/null || true)"
107
+ named="$(field '.agent_type // empty')"
307
108
  case "$named" in '' | */* | . | ..) allow_stop ;; esac
308
109
  stance_scope="$(dirname -- "$(dirname -- "$SELF_DIR")")/{{fact:harness-persona-root}}/$named"
309
110
  fi
310
111
  manifest="$stance_scope/{{fact:stance-manifest}}"
311
112
  [ -f "$manifest" ] || allow_stop
312
113
 
313
- # The persona's own contract, each field falling back to the shipped default. A manifest that
314
- # names nothing still enrolls: presence is the assertion, the fields are the refinement.
315
- agent_type="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
316
114
  manifest_rubric="$(jq -r '.rubric // empty' "$manifest" 2>/dev/null || true)"
317
- # An OVERRIDE that does not resolve is a defect and goes DARK — a persona that asked to be judged
318
- # against its own contract and is silently judged against someone else's has been mis-scored, not
319
- # spared. The DEFAULT is not checked here: a judge backend need not read the file at all (the
320
- # fixture judge does not), and refusing to run because a path the manifest never named is absent
321
- # would fail closed on a case the manifest made no claim about.
322
115
  case "$manifest_rubric" in
323
116
  '') ;;
324
117
  /*) RUBRIC="$manifest_rubric" ;;
325
118
  *) RUBRIC="$(dirname -- "$manifest")/$manifest_rubric" ;;
326
119
  esac
327
120
  [ -z "$manifest_rubric" ] || [ -f "$RUBRIC" ] || \\
328
- dark "the rubric named by $manifest is not readable at '\$RUBRIC'"
329
-
330
- ${judgeClipShell}
331
-
332
- # --- extract the last assistant turn from the transcript ------------------------------------
333
- # WHICH TRANSCRIPT. This worker binds a main session only (the \`agent_id\` gate above), so the
334
- # payload's \`transcript_path\` is the whole of it: the session's own.
335
- session_transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
336
- transcript="$session_transcript"
337
- [ -n "$transcript" ] && [ -f "$transcript" ] || dark "no readable transcript at '\$transcript'"
338
-
339
- # THE CLOSE IS IN THE PAYLOAD, NOT YET IN THE TRANSCRIPT. On Claude Code a Stop hook fires before the
340
- # final assistant message is written, so the transcript ends one message short: a turn that is only
341
- # text had NO assistant text to judge (fired, and passed in silence), and a tool turn was judged
342
- # without its close. The payload carries that message as \`last_assistant_message\`. It is appended
343
- # to a private copy as the last assistant record — unless the transcript already ends with exactly
344
- # that text — so every extraction below sees the whole turn and the original file is never touched.
345
- last="$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null || true)"
121
+ dark "the rubric named by $manifest is not readable at '$RUBRIC'"
122
+
123
+ GUARD_ID=stance-guardrail
124
+ GUARD_NAME="STANCE GUARDRAIL"
125
+ GUARD_SESSION="$session"
126
+ GUARD_AGENT="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
127
+ GUARD_ACT=stop
128
+ ${contestShell}
129
+ contest_heard
130
+
131
+ ${judgeClipShell}${judgeTailShell}
132
+
133
+ transcript="$(field '.transcript_path // empty')"
134
+ [ -n "$transcript" ] && [ -f "$transcript" ] || dark "no readable transcript at '$transcript'"
135
+
136
+ last="$(field '.last_assistant_message // empty')"
346
137
  if [ -n "$last" ]; then
347
138
  present="$(jq -rs --arg t "$last" '
348
139
  [ .[] | select(.type == "assistant") ] | last
@@ -358,254 +149,73 @@ if [ -n "$last" ]; then
358
149
  fi
359
150
  fi
360
151
 
361
- # The transcript is JSONL: each line has top-level .type ("assistant"/"user"), .isSidechain
362
- # (true for subagent lines), and .message.content as an array of blocks (thinking/text/tool_use)
363
- # — or, for a user line, a plain string. We judge the AGENT's last assistant text, but the
364
- # judge cannot tell an operator-ORDERED irreversible act (fine) from a unilateral one without the
365
- # operator's instruction — so we also extract the most recent operator message and pass it
366
- # alongside as authorization context. A tool_result-only user line carries no text — skipped.
367
- # THE WHOLE TURN, not its last fragment — and with tool activity marked.
368
- #
369
- # This used to take \`last\` non-empty text block. In a tool-heavy turn that is one block out of
370
- # twenty, and it is usually a MID-TURN PREAMBLE, not the close. Measured on a live session: the
371
- # judge was seeing 47% of one turn, 1 text block of 20. Both live false blocks came from exactly
372
- # this — it judged "Adding the false-positive fixtures", a preamble whose very next assistant
373
- # message was a \`tool_use\` performing the work, and called it announce-without-act because the
374
- # tool call was invisible to it. The evidence was verbatim and the verdict was still wrong.
375
- #
376
- # So: take every assistant message since the last real user turn, and mark which ones carried
377
- # tool calls. The judge can then see that a forward commitment was followed by action, which is
378
- # the single fact it needs and never had. \`[tools: …]\` markers are not text the agent wrote, so
379
- # the EVIDENCE check below greps the text-only projection to avoid matching a marker.
380
- asst="$(jq -rs '
381
- [ .[] ] as $all
382
- | ( [ range(0; ($all|length))
383
- | select( $all[.].type=="user"
384
- and ( ($all[.].message.content | type) == "string"
385
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
386
- | last // -1 ) as $lastuser
387
- | [ $all[($lastuser+1):][]
388
- | select(.type == "assistant")
389
- | (.message.content // []) as $c
390
- | ( $c | map(select(.type == "text") | .text) | join("\\n") ) as $t
391
- | ( $c | map(select(.type == "tool_use") | .name) | join(", ") ) as $tools
392
- | if $t == "" and $tools == "" then empty
393
- elif $tools == "" then $t
394
- elif $t == "" then "[tools: \\($tools)]"
395
- else "\\($t)\\n[tools: \\($tools)]" end
396
- ]
397
- | join("\\n\\n")
398
- ' "$transcript" 2>/dev/null || true)"
399
-
400
- # Text-only projection of the same turn — what the agent actually WROTE. The EVIDENCE check must
401
- # grep this, never the tool-annotated form, so a fabricated span cannot be satisfied by a marker.
402
- asst_text="$(jq -rs '
403
- [ .[] ] as $all
404
- | ( [ range(0; ($all|length))
405
- | select( $all[.].type=="user"
406
- and ( ($all[.].message.content | type) == "string"
407
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
408
- | last // -1 ) as $lastuser
409
- | [ $all[($lastuser+1):][] | select(.type == "assistant")
410
- | (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
411
- | map(select(. != "")) | join("\\n\\n")
152
+ # One transcript read: asst is the turn with tool calls marked, text its words alone, operator the
153
+ # last real operator message, standing the utterance that set the loop position.
154
+ parts="$(jq -rsc '
155
+ def blocks(k): (.message.content // []) | map(select(.type == k));
156
+ def said: blocks("text") | map(.text) | join("\\n");
157
+ def texts: if type == "string" then . elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n")) else "" end;
158
+ def keep(re): map(select(test(re) | not));
159
+ . as $all
160
+ | ([ range(0; length) | select($all[.].type == "user" and (($all[.].message.content | type) == "string" or ($all[.].message.content | map(.type) | index("text") != null))) ] | last // -1) as $u
161
+ | [ $all[$u + 1:][] | select(.type == "assistant") | { t: said, tools: (blocks("tool_use") | map(.name) | join(", ")) } ] as $turn
162
+ | ([ $all[] | select(.type == "user") | .message.content | texts ] | map(select(. != ""))
163
+ | keep("^\\\\s*<system-reminder>") | keep("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | keep("<task-notification>")) as $users
164
+ | ($users | map(select(test("(^|[^[:alpha:]])(carry[- ]?on|weitermachen|proceed)([^[:alpha:]]|$)"; "i"))) | last) as $hit
165
+ | {
166
+ asst: ($turn | map(select(.t != "" or .tools != "") | if .tools == "" then .t else (if .t == "" then "" else .t + "\\n" end) + "[tools: \\(.tools)]" end) | join("\\n\\n")),
167
+ text: ($turn | map(.t) | map(select(. != "")) | join("\\n\\n")),
168
+ operator: ($users | keep("<command-name>") | keep("<command-message>") | keep("^\\\\s*Base directory for this skill:") | keep("\\\\n---\\\\n\\\\nSkill: [^\\\\n]*SKILL\\\\.md\\\\s*$") | last // ""),
169
+ standing: (if $hit == null then "" else
170
+ ($hit
171
+ | if test("<command-message>") then (capture("<command-message>(?<m>[^<]*)").m)
172
+ elif test("<command-name>") then (capture("<command-name>(?<m>[^<]*)").m)
173
+ else . end
174
+ | gsub("\\\\s+"; " ")) end)
175
+ }
412
176
  ' "$transcript" 2>/dev/null || true)"
177
+ part() { printf '%s' "$parts" | jq -r ".$1 // empty" 2>/dev/null || true; }
178
+ asst="$(part asst)"
179
+ [ -n "$asst" ] || allow_stop
180
+ asst_text="$(part text)"
413
181
  [ -n "$asst_text" ] || asst_text="$asst"
414
182
 
415
- # THE CLOSE — assistant text appearing AFTER the last message that carried a tool_use.
416
- #
417
- # Every rubric rule that can actually fire is POSITIONAL ("read the turn's FINAL sentences in
418
- # isolation"; "and the turn is ending"; "POSITION IS THE LAW"). \`asst_text\` is the whole turn
419
- # flattened to one blob and encodes no position at all, so the judge was asked a positional
420
- # question about a payload from which position had been deleted, and the EVIDENCE check then
421
- # authenticated the span against that same blob — establishing only that the characters occur
422
- # SOMEWHERE. A mid-turn preamble followed by four tool calls and a 3000-char report satisfied
423
- # it exactly as well as a genuine dangling close.
424
- #
425
- # Measured, on the three blocks this hook fired in its own authoring session: every cited span
426
- # preceded the last tool call, and every turn ended with a 2983-3458 char report. All three
427
- # reasons were false about what followed the span. An independent audit reproduced the live
428
- # judge n=15 and the stated reason reproduced 0/15.
429
- #
430
- # Empty close (the turn ended ON a tool call) ⇒ fall back to the whole turn: that is the one
431
- # case where "no text after the tools" is the truth, not a projection artefact.
432
183
  asst_close="$(jq -rs '
433
- [ .[] ] as $all
434
- | ( [ range(0; ($all|length))
435
- | select( $all[.].type=="user"
436
- and ( ($all[.].message.content | type) == "string"
437
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
438
- | last // -1 ) as $lastuser
439
- | [ $all[($lastuser+1):][] | select(.type == "assistant") ] as $turn
440
- | ( [ range(0; ($turn|length))
441
- | select( ($turn[.].message.content // []) | map(.type) | index("tool_use") != null ) ]
442
- | last // -1 ) as $lasttool
443
- | [ $turn[($lasttool+1):][]
444
- | (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
184
+ . as $all
185
+ | ([ range(0; length) | select($all[.].type == "user" and (($all[.].message.content | type) == "string" or ($all[.].message.content | map(.type) | index("text") != null))) ] | last // -1) as $u
186
+ | [ $all[$u + 1:][] | select(.type == "assistant") ] as $turn
187
+ | ([ range(0; ($turn | length)) | select(($turn[.].message.content // []) | map(.type) | index("tool_use") != null) ] | last // -1) as $t
188
+ | [ $turn[$t + 1:][] | (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
445
189
  | map(select(. != "")) | join("\\n\\n")
446
190
  ' "$transcript" 2>/dev/null || true)"
447
191
  close_fallback=""
448
192
  [ -n "$asst_close" ] || { asst_close="$asst_text"; close_fallback=1; }
449
193
 
450
- [ -n "$asst" ] || allow_stop # no judgeable agent text (e.g. pure tool turn) → allow stop
451
-
452
- # THE OPERATOR SLOT — and it must actually hold the operator.
453
- #
454
- # A skill invocation (\`/carry-on\`, \`/introspect\`, …) enters the transcript as a user-type
455
- # message carrying the SKILL BODY. Taking the last user message therefore handed the judge
456
- # 2.8 kB of the /wake skill definition (since retired) as "the operator's most recent
457
- # instruction" — measured on two of six live fixtures. The judge then reasoned about
458
- # authorization from a document the operator never wrote, which is worse than having no
459
- # context: it is confidently wrong context, and the rubric leans on this slot to decide
460
- # whether an irreversible act was authorized.
461
- #
462
- # Skill bodies are recognizable by the wrapper the HARNESS puts on them, never by their prose:
463
- # claude injects one as a meta user message opening "Base directory for this skill: <dir>", after
464
- # a <command-name>/<command-message> invocation message; omp's \`skill-prompt\` message closes with
465
- # a "---" rule and "Skill: <path>/SKILL.md". An operator message that merely looks like a skill
466
- # (an H1 over a fenced block) carries neither, and stays. Fall back to the most recent message
467
- # that survives the filter.
468
- operator="$(jq -rs '
469
- [ .[]
470
- | select(.type == "user")
471
- | (.message.content)
472
- | if type == "string" then .
473
- elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
474
- else "" end
475
- ]
476
- | map(select(. != ""))
477
- | map(select(
478
- (test("<command-name>") | not)
479
- and (test("<command-message>") | not)
480
- and (test("^\\\\s*Base directory for this skill:") | not)
481
- and (test("\\\\n---\\\\n\\\\nSkill: [^\\\\n]*SKILL\\\\.md\\\\s*$") | not)
482
- and (test("^\\\\s*<system-reminder>") | not)
483
- and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
484
- and (test("<task-notification>") | not)
485
- ))
486
- | last // ""
487
- ' "$transcript" 2>/dev/null || true)"
194
+ operator="$(part operator)"
488
195
  [ -n "$operator" ] || operator="(no operator instruction found in transcript)"
489
196
 
490
- # --- THE LOOP POSITION IN FORCE, and the utterance that set it --------------------------------
491
- #
492
- # THE GUARD'S OLDEST BLIND SPOT, and the reason it reads as crude. \`carry-on\` declares
493
- # \`loop-position ∈ {on-the-loop, out-of-the-loop}\` as LIVE SESSION STATE, and nothing anywhere
494
- # wrote it down — so every turn was judged as if the session had just opened. A check-in is
495
- # CORRECT at rest ("a session opens in orientation · intent is the operator's to set") and is a
496
- # COLLAPSE under an elevation the operator already granted, and the judge could not tell those
497
- # two apart because it was never told which one it was in.
498
- #
499
- # DERIVED, NOT STORED. The transcript IS the record: the operator's own utterance is what
500
- # established the elevation, it is already here, it is session-scoped by construction, and it
501
- # cannot desync from what was actually said the way a state file can. A store would need a
502
- # session id this worker is not always given, a write path, and a lifecycle — all to hold a
503
- # value that is a fold over messages already on disk.
504
- #
505
- # SCANNED BEFORE THE FILTER ABOVE, which is the whole repair. A slash invocation arrives wrapped
506
- # in <command-name>, and that wrapper is exactly what the operator slot drops — so the filter
507
- # added to stop the judge reading a skill BODY as an instruction was also the mechanism hiding
508
- # every \`/carry-on\` from it. The word is read here, from the unfiltered list, and only the
509
- # word: no skill body reaches the payload.
510
- #
511
- # MECHANICAL EXTRACTION, SEMANTIC WEIGHING — the same split as layer 1. This reports WHICH
512
- # position is in force and the verbatim utterance that set it; the rubric decides what follows.
513
- # A false positive therefore costs a misleading context line, never an unguarded turn.
514
- standing="$(jq -rs '
515
- [ .[]
516
- | select(.type == "user")
517
- | (.message.content)
518
- | if type == "string" then .
519
- elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
520
- else "" end
521
- ]
522
- | map(select(. != ""))
523
- | map(select(
524
- (test("^\\\\s*<system-reminder>") | not)
525
- and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
526
- and (test("<task-notification>") | not)
527
- ))
528
- | (map(select(test("(^|[^[:alpha:]])(carry[- ]?on|weitermachen|proceed)([^[:alpha:]]|$)"; "i")))
529
- | last) as $hit
530
- | if $hit == null then "" else
531
- ($hit
532
- | if test("<command-message>") then (capture("<command-message>(?<m>[^<]*)").m)
533
- elif test("<command-name>") then (capture("<command-name>(?<m>[^<]*)").m)
534
- else . end
535
- | gsub("\\\\s+"; " ")) end
536
- ' "\${session_transcript:-$transcript}" 2>/dev/null || true)"
537
-
538
- # The position is a fact under a plain label: which one is in force and, for the elevation, the
539
- # utterance that set it. What each position means is the rubric's to say.
197
+ standing="$(part standing)"
540
198
  if [ -n "$standing" ]; then
541
199
  loop_position="out-of-the-loop, set by the operator's \\"$(printf '%s' "$standing" | cut -c1-300)\\""
542
200
  else
543
201
  loop_position="on-the-loop"
544
202
  fi
545
203
 
546
- # The judged payload is assembled after the layer-1 note below, because the cap covers both.
547
-
548
- # --- LAYER 1: deterministic checks (no LLM) --------------------------------------------------
549
- # The judge is one sample from a small model — a noisy signal, and unfit to carry an invariant on
550
- # its own. Anything decidable by inspection is decided HERE, where it is reproducible and free.
551
- # The judge is reserved for the semantic residue that regex provably cannot reach.
552
- #
553
- # L1a · ANNOUNCE-WITHOUT-ACT. A Stop hook fires when the agent has produced text and no further
554
- # tool call. So a first-person forward commitment in the FINAL text is, by construction, a
555
- # commitment the turn did not honour: had the agent done the thing, the doing would precede the
556
- # text and the text would report it ("I ran X"), not promise it ("I'll run X").
557
- #
558
- # The rubric could never catch this. Its turn-close rule asks only whether the close OFFERS the
559
- # next action ("say the word") versus STATES it — and a bare statement PASSES. Replayed through
560
- # the judge, a real turn reading "Proceeding to #2. I'll run the research and author the plan."
561
- # came back PASS, with the judge commending the agent for "proceeding with a declared approach"
562
- # while the agent had in fact proceeded with nothing. Stating and stopping is the collapse in its
563
- # most fluent disguise, and it is invisible to a rule that only inspects the shape of the close.
564
- #
565
- # Contingent commitments are NOT this: waiting on a dispatched agent, on an operator's sign-off,
566
- # or on an external event is legitimate, and the turn genuinely cannot proceed. Those are carved
567
- # out below. Everything else routes to the judge with the offending span quoted, so the block
568
- # names the evidence rather than restating the rule.
569
- # Windowed off THE CLOSE, not the whole-turn blob. Taking the last 700 bytes of the
570
- # concatenation reaches BACKWARD ACROSS TOOL BOUNDARIES whenever the final text block is short —
571
- # reintroducing the very bug this section claims to have fixed ("it judged 'Adding the
572
- # false-positive fixtures', a preamble whose very next assistant message was a tool_use").
573
- # It has not fired yet only because the offending turns happened to close with ~3000 chars.
574
204
  final_span="$(printf '%s' "$asst_close" | tail -c 700)"
575
205
  l1_evidence=""
576
206
  if printf '%s' "$final_span" | grep -Eqi "(^|[[:space:].\\"'])(i'?ll|i will|i'?m going to|let me|now (i'?ll|running)|next (i'?ll|i will)|proceeding to|moving on to|starting (on|with)|taking (it|that) (on|now))[[:space:]]"; then
577
- # Carve-outs: the commitment is contingent on something outside this turn.
578
207
  if ! printf '%s' "$final_span" | grep -Eqi "when (it|they|that|the .*) (returns?|completes?|finishes?|lands?)|once (you|the operator|it|that)|awaiting|still running|report back when|on your (sign-?off|go|word)|if you|unless you|pending your"; then
579
- # Extract by SENTENCE, not by a windowed match. \`grep -Eo ".{0,90}…{0,90}"\` looks
580
- # obvious and is not portable: ugrep (the default grep on some hosts) rejects the nested
581
- # bounded quantifier with "exceeds complexity limits", the command fails, and the
582
- # substitution yields EMPTY — so the evidence clause silently vanishes and the block
583
- # degrades to the restated-rule feedback this rewrite exists to replace. A gate whose
584
- # evidence path fails open is a gate that lies about why it fired.
208
+ # By sentence: ugrep rejects a windowed \`.{0,90}\` match.
585
209
  l1_evidence="$(printf '%s' "$final_span" | tr '\\n' ' ' | tr '.' '\\n' \\
586
210
  | grep -Eim1 "(i'?ll|i will|i'?m going to|let me|proceeding to|moving on to|starting (on|with))" \\
587
211
  | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-200)"
588
212
  fi
589
213
  fi
590
-
591
- # --- LAYER 2: the judge (semantic residue only) ----------------------------------------------
592
- # The judge contract: payload on stdin, rubric path as argv[1]; emits VERDICT: PASS|BLOCK [+ REASON].
593
- # Non-zero judge exit, an empty answer or a verdict that is neither PASS nor BLOCK → dark.
594
- # THE JUDGE IS HANDED FACTS UNDER PLAIN LABELS, and no sentence telling it what to conclude: the
595
- # rubric is the one place the test is stated, and a payload that restates it in its own words is a
596
- # second home for it. The Layer-1 span is one such fact, quoted from the turn by this worker.
597
214
  l1_block=""
598
215
  [ -z "$l1_evidence" ] || l1_block="
599
216
 
600
217
  Layer-1 span: \\"$l1_evidence\\""
601
218
 
602
- # THE JUDGED PAYLOAD, BOUNDED: the standing directive, the operator's instruction, THEN the
603
- # agent turn, and the layer-1 note when there is one — all together at most JUDGE_PAYLOAD_CAP
604
- # bytes. The operator's message keeps its TAIL (a quarter of the cap at most: the instruction
605
- # that governs the turn is the last thing said). The agent turn keeps what remains after
606
- # every fixed part, and it keeps its END: the close is the text after the last tool call, every
607
- # rule that can fire reads it, and it is the last thing in the turn. Whatever is cut is marked
608
- # where it was cut.
609
219
  op_room=$((JUDGE_PAYLOAD_CAP / 4))
610
220
  op_total="$(judge_bytes "$operator")"
611
221
  if [ "$op_total" -gt "$op_room" ]; then
@@ -630,31 +240,14 @@ else
630
240
  asst_sent="$(judge_cut 'the agent turn' "$asst_total" "$(judge_bytes "$asst_shown")")
631
241
  $asst_shown"
632
242
  if [ -n "$close_fallback" ]; then
633
- # The turn ended ON a tool call, so the whole turn stood in for the close: what it
634
- # showed, less the tool markers, which are not the agent's words.
635
243
  close_seen="$(printf '%s\\n' "$asst_shown" | sed '/^\\[tools: .*\\]$/d')"
636
244
  else
637
- # The close ends the turn, so what the excerpt shows of it is its own final bytes.
638
245
  close_seen="$(printf '%s' "$asst_close" | judge_keep "$(judge_bytes "$asst_shown")")"
639
246
  fi
640
247
  fi
641
248
  judged="$turn_head
642
249
  $asst_sent$l1_block"
643
250
 
644
- # THE JUDGE MAY LIVE OUTSIDE THIS PROCESS, and on a harness that already holds a
645
- # model it MUST. Spawning another vendor's CLI to answer a question the host can
646
- # answer in-process is a cross-harness dependency wearing a plugin's clothes: it
647
- # needs that vendor installed, separately authenticated, and warm — and when its
648
- # OAuth lapses, every verdict on every harness fails open in silence.
649
- #
650
- # So the seam is explicit and the PROCEDURE stays here, in one home. A host that
651
- # can judge runs this worker twice: once with \`STANCE_EMIT_PAYLOAD\` to receive the
652
- # gated, layer-1-annotated payload and the rubric that scores it, then again with
653
- # \`STANCE_VERDICT_FILE\` naming its answer. Everything either pass touches before
654
- # this line is read-only, so the first pass mutates no counter and no hash — the
655
- # gate, the extraction and the pre-filter simply run twice and agree.
656
- #
657
- # A host with no model of its own changes nothing and keeps the subprocess judge.
658
251
  if [ -n "\${STANCE_EMIT_PAYLOAD:-}" ]; then
659
252
  jq -cn --arg r "$RUBRIC" --arg p "$judged" '{rubric:$r, payload:$p}'
660
253
  exit 0
@@ -666,127 +259,61 @@ else
666
259
  verdict="$(printf '%s' "$judged" | $JUDGE_CMD "$RUBRIC" 2>/dev/null)" || dark "the judge did not answer"
667
260
  fi
668
261
 
669
- decision="$(printf '%s\\n' "$verdict" | sed -n 's/^VERDICT:[[:space:]]*//p' | head -1 | sed 's/[[:space:]]*$//')"
262
+ tag() { printf '%s\\n' "$verdict" | sed -n "s/^$1:[[:space:]]*//p" | head -1; }
263
+ decision="$(tag VERDICT | sed 's/[[:space:]]*$//')"
670
264
  case "$decision" in
671
- PASS) allow_stop ;; # judged, no collapse: the one silent verdict
265
+ PASS) allow_stop ;;
672
266
  BLOCK) ;;
673
267
  *) dark "the judge's verdict was unparseable (no VERDICT: PASS or VERDICT: BLOCK line)" ;;
674
268
  esac
675
269
 
676
- reason="$(printf '%s\\n' "$verdict" | sed -n 's/^REASON:[[:space:]]*//p' | head -1)"
270
+ reason="$(tag REASON)"
677
271
  [ -n "$reason" ] || reason="This turn collapsed out of the intent-driven-expert stance."
678
272
 
679
- # --- EVIDENCE VERIFICATION: a block must quote text that is actually in the turn --------------
680
- # The judge is one sample from a small model, and a wrong block costs exactly what a missed one
681
- # does: an agent that yields to a fired gate whose diagnosis the record refutes has updated on a
682
- # salient signal instead of on argument — the collapse wearing the guardrail's uniform.
683
- #
684
- # Observed live: the judge blocked a turn and quoted "Authoring the plan" as the offending span.
685
- # That string was not in the turn it judged — it was the close of an EARLIER turn, and that turn
686
- # had honoured it. Pure confabulation, and unfalsifiable from inside the model.
687
- #
688
- # So the quote is checked against the transcript MECHANICALLY. The judge must emit
689
- # \`EVIDENCE: <verbatim span>\`; if that span does not literally occur in the judged turn, the
690
- # block is discarded. A model cannot quote what is not there, which makes this cheap and total.
691
- # EVIDENCE IS MANDATORY FOR A BLOCK. A missing line does not get the benefit of the doubt: the
692
- # first cut of this check let an absent EVIDENCE line mean "verdict stands", and that exemption
693
- # was immediately exercised — a fabricated block reached the agent because the span it should
694
- # have been checked against had been stripped upstream by the judge's own output filter. An
695
- # unevidenced block is indistinguishable from a fabricated one, so it is discarded either way.
696
- # Layer-1 blocks are unaffected: they carry a span this hook extracted from the turn itself.
697
- evidence="$(printf '%s\\n' "$verdict" | sed -n 's/^EVIDENCE:[[:space:]]*//p' | head -1 \\
698
- | sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
273
+ evidence="$(tag EVIDENCE | sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
699
274
  if [ -z "$evidence" ] && [ -z "$l1_evidence" ]; then
700
- printf 'DARK\\t\\t%s\\n' "$reason" >> "$verdict_log" 2>/dev/null || true
275
+ row DARK "" "$reason"
701
276
  say "STANCE GUARDRAIL — BLOCK DISCARDED: the judge blocked this turn but quoted no EVIDENCE line, and an unevidenced block cannot be told from a fabricated one. No verdict stands; this turn was NOT judged clean. The judge's reason was: $reason"
702
277
  allow_stop
703
278
  fi
704
279
  if [ -n "$evidence" ] && [ "\${#evidence}" -ge 12 ]; then
705
- # Checked against THE CLOSE, not the whole turn. Existence-somewhere was never the
706
- # proposition worth authenticating: every rule that can fire is a claim about the turn's
707
- # FINAL text, so a span drawn from a mid-turn preamble is out of scope for the verdict
708
- # built on it even though the characters are genuinely present. Measured on this hook's
709
- # own three blocks — all three spans preceded the last tool call, all three reasons were
710
- # false about what followed, and all three passed the old whole-turn check.
711
- # THE ONE MECHANICALLY-CHECKED ARTIFACT, RECORDED. \`$evidence\` is the only part of a block
712
- # that survives a check against the turn; \`$reason\` is unverified model prose. Both were
713
- # discarded, so a block could not be audited afterwards without re-running a judge measured at
714
- # 3/5 on identical payloads — i.e. the record of WHY a turn was blocked was reconstructible only
715
- # by a non-deterministic process. Appended, never rotated by this hook; the state dir is tmp.
716
- printf '%s\\t%s\\t%s\\n' "$decision" "$evidence" "$reason" >> "$verdict_log" 2>/dev/null || true
717
- # NORMALIZED ON BOTH SIDES, and with \`--\`, because this check had two ways to
718
- # throw away a correct block — and both of them fired on the SAME shape, which is
719
- # the one shape this guard most exists to catch.
720
- #
721
- # 1. \`grep -qF "$evidence"\` with no \`--\`: an evidence span opening with a markdown
722
- # bullet is read as an OPTION. \`grep: invalid option -- ' '\`, non-zero, block
723
- # discarded. A tail-enumeration collapse IS a bullet list, so its evidence always
724
- # begins \`- \`, and the rule could never convict the shape it was written for.
725
- # 2. A judge quoting several bullets joins them with spaces and drops the markers,
726
- # so a verbatim-substring test fails on punctuation while every word is present.
727
- #
728
- # The proposition worth authenticating is that the judge quoted THIS TURN'S WORDS,
729
- # not that it reproduced its list syntax. So both sides are flattened the same way:
730
- # newlines to spaces, runs of whitespace to one, list markers dropped. Symmetric, so
731
- # nothing is accepted on one side that would be rejected on the other.
732
- # AND AGAINST WHAT THE JUDGE WAS ACTUALLY SENT. \`close_seen\` is the close as far as the
733
- # bounded excerpt showed it (the whole close when nothing was cut). A span from text the
734
- # excerpt elided cannot be a quotation of anything the judge read, however genuinely it is
735
- # in the transcript, so it is discarded exactly as a fabricated one is.
736
- ev_norm="$(printf '%s' "$evidence" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
737
- close_norm="$(printf '%s' "$close_seen" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
738
- if ! printf '%s' "$close_norm" | grep -qF -- "$ev_norm"; then
739
- printf 'DARK\\t\\t%s\\n' "$evidence" >> "$verdict_log" 2>/dev/null || true
280
+ row "$decision" "$evidence" "$reason"
281
+ norm() { printf '%s' "$1" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g'; }
282
+ if ! printf '%s' "$(norm "$close_seen")" | grep -qF -- "$(norm "$evidence")"; then
283
+ row DARK "" "$evidence"
740
284
  say "STANCE GUARDRAIL — BLOCK DISCARDED: the judge blocked this turn but quoted a span that is not in the turn's CLOSE as the judge was sent it (mid-turn preamble, text the excerpt elided, or confabulated): $evidence. No verdict stands; this turn was NOT judged clean."
741
285
  allow_stop
742
286
  fi
743
287
  fi
744
288
 
745
- # --- A REFUSAL HOLDS --------------------------------------------------------------------------
746
- # A block blocks the stop however many blocks came before it. Nothing here remembers a refused turn
747
- # in order to wave its retry through, and nothing counts blocks in order to stop blocking: a turn
748
- # identical to one already blocked is judged again like any other and, when the judge blocks it and
749
- # the span it quotes is in the close, blocked again. Two caps once let a refused turn through — one
750
- # on a turn byte-identical to the last blocked, one after a run of consecutive blocks — and both
751
- # made the answer to "is this refused act refused?" a function of how often it had been tried. A
752
- # gate whose verdict depends on the number of attempts rewards the attempt, not the repair. What ends
753
- # a run of blocks is a turn the judge passes.
754
- # The count survives as a count, in the reason the agent reads; it decides nothing.
755
- block_count=$((block_count + 1))
756
- printf '%s' "$block_count" > "$count_file" 2>/dev/null || true
757
- reason="$reason (stance-guardrail block $block_count this session — a COUNT, not a budget: this gate does not stop enforcing, however many times it fires.)"
758
-
759
- # --- BLOCK ----------------------------------------------------------------------------------
760
- # Emit the Stop-hook block decision. The \`reason\` is fed back to the agent as a corrective
761
- # instruction; it must re-assume the stance (own the call / extract the intent) and continue.
762
- #
763
- # The feedback QUOTES THE OFFENDING SPAN when Layer 1 found one. A model corrects far better
764
- # against a concrete diff than against a restated rule — and the restated rule is what this
765
- # guardrail used to send, which is why an agent could absorb the correction, agree with it in
766
- # detail, and reproduce the same failure in its very next sentence.
289
+ # A refused stop holds back no effect: a repeat of the turn last blocked, or a block after RUN_CAP, is let through said.
290
+ turn_hash="$(printf '%s' "$asst_text" | cksum)"
291
+ run_next=$((run + 1))
292
+ let_through=""
293
+ if [ -n "$stop_active" ]; then
294
+ if [ "$run_known" -eq 0 ]; then
295
+ let_through="the run of refused stops it follows cannot be read, and a bound that cannot be counted cannot hold"
296
+ elif [ "$run" -ge 1 ] && [ "$turn_hash" = "$run_hash" ]; then
297
+ let_through="this stop is byte-identical to the turn it already refused, so refusing it again cannot help"
298
+ elif [ "$run" -ge "$RUN_CAP" ]; then
299
+ let_through="it has refused this run of stops $run times"
300
+ fi
301
+ fi
302
+ if [ -z "$let_through" ] && ! printf '%s %s' "$run_next" "$turn_hash" > "$run_file" 2>/dev/null; then
303
+ [ -z "$stop_active" ] || let_through="the run of refused stops cannot be written, and a bound that cannot be counted cannot hold"
304
+ fi
305
+ if [ -n "$let_through" ]; then
306
+ rm -f "$run_file" 2>/dev/null || true
307
+ row UNRESOLVED "$evidence" "$reason"
308
+ say "STANCE GUARDRAIL — stop let through UNRESOLVED: the judge blocked this stop, and the finding STANDS and is unaddressed — $let_through. The next stop that follows no block is judged and blocked again. The judge's reason was: $reason"
309
+ allow_stop
310
+ fi
767
311
  evidence_clause=""
768
- [ -n "$l1_evidence" ] && evidence_clause="The offending span is yours, verbatim: \\"$l1_evidence\\" — \\
769
- you committed to an action and then ended the turn without taking it. Stating a next action is not \\
770
- performing it. Do the thing NOW, in this turn, with tool calls; report it in the past tense when it \\
771
- is done. "
772
- # Ship the VERIFIED span on a judge block too. Only \`\$evidence\` survived a mechanical check
773
- # against the close; \`\$reason\` is unverified model prose. Sending the reason alone leaves the
774
- # recipient able to argue only with the one string nothing authenticated — and an agent that
775
- # refutes a fabricated reason has dodged a verdict that may still be correct, which is exactly
776
- # what happened three times in this hook's authoring session.
777
- [ -z "$evidence_clause" ] && [ -n "$evidence" ] && evidence_clause="The span this was checked \\
778
- against, verbatim from your close: \\"$evidence\\" — the REASON above is the judge's unverified \\
779
- wording; THIS span is what mechanically matched. Argue with the span, not the wording. "
780
-
781
- feedback="STANCE GUARDRAIL — blocked: you collapsed out of the intent-driven-expert stance. $reason \\
782
- \${evidence_clause}Re-assume the stance: you are the owning expert; the operator owns intent + sign-off \\
783
- on irreversible acts only. Decide the in-remit call yourself (note it for review) instead of seeking \\
784
- permission, own your expert judgment (naming/design/architecture/how) instead of deferring it, and \\
785
- extract+serve the operator's INTENT instead of echoing their literal words. Then continue. (Legitimate \\
786
- exceptions: surfacing a genuine irreversible-outward act for consent, or routing a true INTENT \\
787
- ambiguity to /elicit.)"
788
-
789
- # jq builds valid JSON regardless of quotes/newlines in the feedback.
312
+ [ -n "$l1_evidence" ] && evidence_clause="The offending span is yours, verbatim: \\"$l1_evidence\\" — you committed to an action and then ended the turn without taking it; do it now, with tool calls. "
313
+ [ -z "$evidence_clause" ] && [ -n "$evidence" ] && evidence_clause="The span checked against your close, verbatim: \\"$evidence\\". "
314
+
315
+ feedback="STANCE GUARDRAIL — blocked: $reason \${evidence_clause}Decide the in-remit call yourself and continue. $(contest_refused "$reason")"
316
+
790
317
  jq -cn --arg r "$feedback" '{decision:"block", reason:$r}'
791
318
  exit 0
792
319
  `,
@@ -942,10 +469,4 @@ A record starting "Agent dispatch" is judged by this alone: BLOCK only if it pas
942
469
  },
943
470
  ],
944
471
  };
945
- // EXPORTED AFTER THE CELL, ON PURPOSE. `allHookCells` (tooling/project-targets.ts) takes the
946
- // FIRST export of a `hooks/*.ts` module to be its cell, by name order and by definition order
947
- // alike, so anything else this module exports must sort and be defined after `stanceGuardrail`.
948
- // These are the cap and the shell that honours it, for the two workers that share the judge.
949
- export const stanceGuardrailJudgeCap = judgePayloadCapBytes;
950
- export const stanceGuardrailJudgeClip = judgeClipShell;
951
472
  //# sourceMappingURL=stance-guardrail.js.map