@cratylus/canon 0.12.2 → 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,88 +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, EXCEPT AT THE TURN END, AND THERE ONLY FOR ONE STOP. Judges every turn, one
126
- # identical to a refused turn included, and a BLOCK blocks the stop. The turn end is the one
127
- # exception to a refused act staying refused: refusing it holds back no effect, it only makes
128
- # the agent redo its turn. So a stop that follows a block of this guard (\`stop_hook_active\`)
129
- # continues a RUN of blocks, and a BLOCK that would be the repeat of the turn last blocked in
130
- # the run, or the one after the run's third block, is let through, SAID, UNRESOLVED. The bound
131
- # is on one stop and never on the session: a stop that follows no block starts a new run at 0.
132
- # - POSITION-SOUND. Every rubric rule that can fire is a claim about the turn's CLOSE. So the
133
- # L1 window and the EVIDENCE check both run against \`asst_close\` — the text AFTER the last
134
- # tool call — never the whole-turn blob. A span from a mid-turn preamble is out of scope for
135
- # a verdict about how the turn ended, however genuinely present those characters are.
136
- #
137
- # INPUT : Claude Code Stop hook JSON on stdin (transcript_path, stop_hook_active,
138
- # agent_type, session_id, cwd, …).
139
- # OUTPUT : on collapse → stdout \`{"decision":"block","reason":"…"}\` + exit 0 (Stop-hook block).
140
- # judged, no collapse → no stdout + exit 0 (allow stop).
141
- # let through with no verdict, or a refused stop let through UNRESOLVED → the notice \`say\`
142
- # prints + exit 0 (allow stop).
143
- #
144
- # POSIX sh. Depends on: jq (transcript parse). Missing jq → the guard says so and allows the stop.
145
-
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.
146
28
  set -eu
147
29
 
148
- # WHAT AN OPERATOR READS WHEN THE GUARD CANNOT JUDGE, defined before anything that needs a
149
- # command. The first thing that can be missing is \`jq\`, and a worker run with a PATH holding
150
- # little beyond \`sh\` and \`cat\` must still say it did not judge — so \`esc\`, \`say\` and \`dark\`
151
- # are builtins only, and nothing external runs above the jq check.
152
- #
153
- # Claude Code shows the operator a hook's JSON \`systemMessage\` and NOT its plain stdout on
154
- # exit 0, so a notice printed as a bare line reaches nobody there. omp's extension bridge
155
- # reads the worker's plain stdout and relays a line naming this guard and DARK to the
156
- # session, and would relay a JSON object as raw JSON — hence one form per harness, chosen at
157
- # projection by the harness's own name.
30
+ # esc, say and dark use builtins only: they must work when jq is missing.
158
31
  esc() {
159
32
  _in="$1"
160
33
  _out=""
161
- _nl="$(printf '\\nx')"
162
- _nl="\${_nl%x}"
34
+ _nl='
35
+ '
163
36
  _tab="$(printf '\\t')"
164
37
  _cr="$(printf '\\r')"
165
38
  while [ -n "$_in" ]; do
@@ -184,97 +57,29 @@ say() {
184
57
  esac
185
58
  }
186
59
 
187
- # A Stop hook must never break a session. An unexpected error (a nonzero status reaching the
188
- # trap) is let through AND SAID; every deliberate \`exit 0\` arrives here with status 0 and stays
189
- # as quiet as it chose to be.
190
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
191
61
 
192
- allow_stop() { exit 0; } # emit nothing; the agent is permitted to stop.
193
-
194
- # A VERDICT and a FAILURE are different facts, and silence can only carry one of them.
195
- # \`allow_stop\` means "checked, no collapse". It must never also be the answer to "could
196
- # not check" — that is a bypass by omission: with the judge unreachable the guardrail
197
- # reports a clean turn forever and nothing ever says the guardrail went dark. Exactly the
198
- # defect fixed one file over in the memory nudge ("a broken runtime read as a clean bill
199
- # of health, silently and forever"); the inversion is the same — \`if signal absent then
200
- # pass\` becomes \`if signal absent then SAY SO\` — and, as there, it still never wedges the
201
- # turn. Reached only for a guard that is IN SCOPE and could not judge: a scope carrying no
202
- # 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; }
203
64
  dark() {
204
- # THE HEARTBEAT, and the reason the log is worth keeping. A VERDICT row and a
205
- # DARK row are both evidence the guard RAN; only an EMPTY log means it never
206
- # did. Recording judged turns alone made "not judged" and "no session" read
207
- # identically — measured on a live host: an endpoint behind the advisor role
208
- # accepted connections and never answered, and five hours of one session were
209
- # unjudged while leaving no trace whatsoever to notice it by.
210
- if [ -n "\${verdict_log:-}" ]; then
211
- printf 'DARK\\t\\t%s\\n' "$1" >> "$verdict_log" 2>/dev/null || true
212
- fi
65
+ row DARK "" "$1"
213
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."
214
67
  exit 0
215
68
  }
216
69
 
217
- # --- read hook input ------------------------------------------------------------------------
218
70
  input="$(cat)"
219
71
  [ -n "$input" ] || dark "the hook received no input"
220
-
221
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; }
222
74
 
223
- # A GUARD BINDS A PERSONA'S OWN MAIN SESSION AND NO SUBAGENT IT DISPATCHES. A subagent is bounded
224
- # by what it was handed, judged by its assay and the whole check, and supervised by the main
225
- # session. Claude Code fires this hook inside a subagent too (settings hooks and the subagent's
226
- # own front-matter hooks), and there the payload carries \`agent_id\` — present only inside a
227
- # subagent. A guard that does not bind there is not dark: it exits before it judges and says
228
- # nothing.
229
- [ -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
230
77
 
231
78
  SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
232
79
  JUDGE_CMD="\${STANCE_JUDGE_CMD:-sh $SELF_DIR/stance-judge.sh}"
233
-
234
- # THE RUBRIC LIVES AT THE VENDOR-NEUTRAL ROOT, and is reached by DERIVATION so it
235
- # follows whatever home this copy was deployed under (a real \$HOME, or a test's
236
- # \`--home\`). This worker sits at \`<harness-home>/hooks/<id>/\`, and \`.agents\` is
237
- # the SIBLING of every harness home — the same relation deploy already uses to
238
- # place agents and skills — so three \`dirname\`s and a name is the whole address.
239
- #
240
- # Derived rather than baked: a literal \`\$HOME/.agents\` would expand correctly on
241
- # a host and point outside a sandboxed deploy, which is the class of bug that put
242
- # \`\$HOME/.claude\` in the sibling worker and made it read another harness's tree.
243
80
  NEUTRAL_ROOT="$(dirname -- "$(dirname -- "$(dirname -- "$SELF_DIR")")")/.agents"
244
- # The DEFAULT rubric. A persona's own manifest may name another — that is the
245
- # strategy seam: one opinionated implementation, each persona free to bind a
246
- # different contract without the dispatcher learning anything about either.
247
81
  RUBRIC="\${STANCE_RUBRIC:-$NEUTRAL_ROOT/stance-guardrail/stance-judge-prompt.md}"
248
82
 
249
- # --- loop safety: a refusal holds, but the turn end is bounded --------------------------------
250
- # EVERY TURN IS JUDGED, and a BLOCK blocks the stop. What follows a BLOCK is the only thing bounded.
251
- # The turn end is the one exception to a refused act staying refused: refusing it holds back no
252
- # effect, it only makes the agent redo its turn, so a stop the judge keeps blocking would lock the
253
- # session in an endless loop if every one of them blocked. The bound is on ONE STOP, never on the
254
- # session — it is not a budget spent over the session's life and not a switch that stays thrown:
255
- #
256
- # - A stop that follows a block of this guard (\`stop_hook_active\` true) CONTINUES the run of
257
- # blocks. A stop with \`stop_hook_active\` false or absent follows no block and STARTS A NEW
258
- # RUN AT 0, whatever came before, so a let-through never leaves enforcement off.
259
- # - After the judge gives a BLOCK that survives the evidence checks, the stop is let through
260
- # instead of blocked — and SAID, naming the finding as standing and unresolved — when either
261
- # (a) the run already holds a block and this turn's text is byte-identical to the turn last
262
- # blocked in it (a repeated stop: blocking it again cannot help), or (b) the run already holds
263
- # RUN_CAP blocks (a several-times-refused stop). Otherwise it blocks and records the run and the
264
- # blocked turn's hash.
265
- # - A stop that follows a block but whose run cannot be read or written (a run record that is
266
- # unreadable, or a state dir that is unwritable) is let through on a BLOCK, and said: a bound
267
- # that cannot be counted cannot hold. A fresh stop there still blocks. No record at all, in a
268
- # dir that can be written, is a run of 0: nothing was blocked, so the stop blocks and starts one.
269
- #
270
- # RUN_CAP is a constant of this cell; no environment variable sets it. A judged PASS stays silent
271
- # and the DARK and BLOCK DISCARDED paths are untouched. Nor is the old \`stop_hook_active=true →
272
- # allow_stop\` short-circuit back: that let the agent be blocked, emit anything, and end again
273
- # UNJUDGED, which made the rubric's judgement of a response to a verdict dead code. The re-fired
274
- # stop is judged like any other; only the BLOCK that follows the bound is waved through, and said.
275
- #
276
- # The state in the tmp dir, keyed by session id, is the run: the blocks it holds and the hash of
277
- # the turn last blocked in it.
278
83
  RUN_CAP=3
279
84
  session="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)"
280
85
  state_dir="\${TMPDIR:-/tmp}/stance-guardrail"
@@ -282,7 +87,7 @@ mkdir -p "$state_dir" 2>/dev/null || true
282
87
  run_file="$state_dir/$session.run"
283
88
  verdict_log="$state_dir/$session.verdicts"
284
89
 
285
- stop_active="$(printf '%s' "$input" | jq -r 'if .stop_hook_active == true then "yes" else "" end' 2>/dev/null || true)"
90
+ stop_active="$(field 'if .stop_hook_active == true then "yes" else "" end')"
286
91
  run=0
287
92
  run_hash=none
288
93
  run_known=1
@@ -293,81 +98,42 @@ if [ -n "$stop_active" ] && [ -e "$run_file" ]; then
293
98
  case "$run" in '' | *[!0-9]*) run=0; run_hash=none; run_known=0 ;; esac
294
99
  fi
295
100
 
296
- # The per-repo opt-in is GONE, and its absence is the point. It answered "may a guard run in this
297
- # DIRECTORY", which is a category error: a stance is a property of the agent, not of the checkout
298
- # it happens to be standing in. Worse, it made the stance OPTIONAL AT RUNTIME for an agent already
299
- # launched as itself — the ambient form, when the whole reason this harness half exists is that
300
- # identity alone erodes. The off switch is launching \`omp\` instead of \`mav\`: declining to be the
301
- # persona, rather than being it unjudged.
302
- cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
101
+ cwd="$(field '.cwd // empty')"
303
102
  [ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true
304
103
 
305
- # --- scope gate: the persona's OWN stance manifest -------------------------------------------
306
- # COMPOSITION IS THE SCOPE, NOT A RUNTIME SELF-FILTER — MODEL.md's ENFORCED clause, and this
307
- # gate used to violate it outright. It read an allowlist, \`nico mav\` by default and overridable
308
- # by \`STANCE_GUARD_AGENTS\`, which is precisely the runtime self-filter that clause forbids: a
309
- # module asking WHO IS RUNNING rather than being placed where only the right launch reaches it.
310
- #
311
- # It had already drifted, in the direction such a list always drifts. Every agent in the corpus
312
- # composes \`handoff\`, so \`Binding.agents\` DERIVES all six as bound, and six persona scopes are
313
- # deployed to prove it — while the list enforced two. \`architect\` and \`kino\` held principal
314
- # authority and were never once judged, and nothing surfaced the gap because the two facts were
315
- # written in different languages in different files.
316
- #
317
- # Enrollment is now PRESENCE. Each bound persona's projection lands a manifest in that persona's
318
- # own scope, and the scope reaches this worker in the one form its harness offers. omp's dispatcher
319
- # lives in that same scope and therefore already knows it — it passes it in the envelope as
320
- # \`stance_scope\`. Claude Code places no dispatcher; its hook payload NAMES the running agent as
321
- # \`agent_type\` (on the main thread of a \`--agent\` session, and on neither for a
322
- # bare session), so the scope is the persona's directory under this harness's own home, one hop
323
- # above the hooks root this worker was deployed into. Either way the scope is then read the same:
324
- # A manifest means enrolled, and carries THIS agent's contract: its rubric, its moments, its
325
- # handoff laws. No manifest means not enrolled, which is silence rather than an error, so a plain
326
- # session, a built-in agent or a host's own agent — none of which declared itself anything — stays
327
- # untouched exactly as it was. Adding a persona enrolls it; adding a gated dimension edits one
328
- # persona's manifest; neither is a line in this file, and this file carries no agent list.
329
- 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')"
330
106
  if [ -z "$stance_scope" ]; then
331
- named="$(printf '%s' "$input" | jq -r '.agent_type // empty' 2>/dev/null || true)"
107
+ named="$(field '.agent_type // empty')"
332
108
  case "$named" in '' | */* | . | ..) allow_stop ;; esac
333
109
  stance_scope="$(dirname -- "$(dirname -- "$SELF_DIR")")/{{fact:harness-persona-root}}/$named"
334
110
  fi
335
111
  manifest="$stance_scope/{{fact:stance-manifest}}"
336
112
  [ -f "$manifest" ] || allow_stop
337
113
 
338
- # The persona's own contract, each field falling back to the shipped default. A manifest that
339
- # names nothing still enrolls: presence is the assertion, the fields are the refinement.
340
- agent_type="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
341
114
  manifest_rubric="$(jq -r '.rubric // empty' "$manifest" 2>/dev/null || true)"
342
- # An OVERRIDE that does not resolve is a defect and goes DARK — a persona that asked to be judged
343
- # against its own contract and is silently judged against someone else's has been mis-scored, not
344
- # spared. The DEFAULT is not checked here: a judge backend need not read the file at all (the
345
- # fixture judge does not), and refusing to run because a path the manifest never named is absent
346
- # would fail closed on a case the manifest made no claim about.
347
115
  case "$manifest_rubric" in
348
116
  '') ;;
349
117
  /*) RUBRIC="$manifest_rubric" ;;
350
118
  *) RUBRIC="$(dirname -- "$manifest")/$manifest_rubric" ;;
351
119
  esac
352
120
  [ -z "$manifest_rubric" ] || [ -f "$RUBRIC" ] || \\
353
- dark "the rubric named by $manifest is not readable at '\$RUBRIC'"
354
-
355
- ${judgeClipShell}
356
-
357
- # --- extract the last assistant turn from the transcript ------------------------------------
358
- # WHICH TRANSCRIPT. This worker binds a main session only (the \`agent_id\` gate above), so the
359
- # payload's \`transcript_path\` is the whole of it: the session's own.
360
- session_transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
361
- transcript="$session_transcript"
362
- [ -n "$transcript" ] && [ -f "$transcript" ] || dark "no readable transcript at '\$transcript'"
363
-
364
- # THE CLOSE IS IN THE PAYLOAD, NOT YET IN THE TRANSCRIPT. On Claude Code a Stop hook fires before the
365
- # final assistant message is written, so the transcript ends one message short: a turn that is only
366
- # text had NO assistant text to judge (fired, and passed in silence), and a tool turn was judged
367
- # without its close. The payload carries that message as \`last_assistant_message\`. It is appended
368
- # to a private copy as the last assistant record — unless the transcript already ends with exactly
369
- # that text — so every extraction below sees the whole turn and the original file is never touched.
370
- 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')"
371
137
  if [ -n "$last" ]; then
372
138
  present="$(jq -rs --arg t "$last" '
373
139
  [ .[] | select(.type == "assistant") ] | last
@@ -383,254 +149,73 @@ if [ -n "$last" ]; then
383
149
  fi
384
150
  fi
385
151
 
386
- # The transcript is JSONL: each line has top-level .type ("assistant"/"user"), .isSidechain
387
- # (true for subagent lines), and .message.content as an array of blocks (thinking/text/tool_use)
388
- # — or, for a user line, a plain string. We judge the AGENT's last assistant text, but the
389
- # judge cannot tell an operator-ORDERED irreversible act (fine) from a unilateral one without the
390
- # operator's instruction — so we also extract the most recent operator message and pass it
391
- # alongside as authorization context. A tool_result-only user line carries no text — skipped.
392
- # THE WHOLE TURN, not its last fragment — and with tool activity marked.
393
- #
394
- # This used to take \`last\` non-empty text block. In a tool-heavy turn that is one block out of
395
- # twenty, and it is usually a MID-TURN PREAMBLE, not the close. Measured on a live session: the
396
- # judge was seeing 47% of one turn, 1 text block of 20. Both live false blocks came from exactly
397
- # this — it judged "Adding the false-positive fixtures", a preamble whose very next assistant
398
- # message was a \`tool_use\` performing the work, and called it announce-without-act because the
399
- # tool call was invisible to it. The evidence was verbatim and the verdict was still wrong.
400
- #
401
- # So: take every assistant message since the last real user turn, and mark which ones carried
402
- # tool calls. The judge can then see that a forward commitment was followed by action, which is
403
- # the single fact it needs and never had. \`[tools: …]\` markers are not text the agent wrote, so
404
- # the EVIDENCE check below greps the text-only projection to avoid matching a marker.
405
- asst="$(jq -rs '
406
- [ .[] ] as $all
407
- | ( [ range(0; ($all|length))
408
- | select( $all[.].type=="user"
409
- and ( ($all[.].message.content | type) == "string"
410
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
411
- | last // -1 ) as $lastuser
412
- | [ $all[($lastuser+1):][]
413
- | select(.type == "assistant")
414
- | (.message.content // []) as $c
415
- | ( $c | map(select(.type == "text") | .text) | join("\\n") ) as $t
416
- | ( $c | map(select(.type == "tool_use") | .name) | join(", ") ) as $tools
417
- | if $t == "" and $tools == "" then empty
418
- elif $tools == "" then $t
419
- elif $t == "" then "[tools: \\($tools)]"
420
- else "\\($t)\\n[tools: \\($tools)]" end
421
- ]
422
- | join("\\n\\n")
423
- ' "$transcript" 2>/dev/null || true)"
424
-
425
- # Text-only projection of the same turn — what the agent actually WROTE. The EVIDENCE check must
426
- # grep this, never the tool-annotated form, so a fabricated span cannot be satisfied by a marker.
427
- asst_text="$(jq -rs '
428
- [ .[] ] as $all
429
- | ( [ range(0; ($all|length))
430
- | select( $all[.].type=="user"
431
- and ( ($all[.].message.content | type) == "string"
432
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
433
- | last // -1 ) as $lastuser
434
- | [ $all[($lastuser+1):][] | select(.type == "assistant")
435
- | (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
436
- | 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
+ }
437
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)"
438
181
  [ -n "$asst_text" ] || asst_text="$asst"
439
182
 
440
- # THE CLOSE — assistant text appearing AFTER the last message that carried a tool_use.
441
- #
442
- # Every rubric rule that can actually fire is POSITIONAL ("read the turn's FINAL sentences in
443
- # isolation"; "and the turn is ending"; "POSITION IS THE LAW"). \`asst_text\` is the whole turn
444
- # flattened to one blob and encodes no position at all, so the judge was asked a positional
445
- # question about a payload from which position had been deleted, and the EVIDENCE check then
446
- # authenticated the span against that same blob — establishing only that the characters occur
447
- # SOMEWHERE. A mid-turn preamble followed by four tool calls and a 3000-char report satisfied
448
- # it exactly as well as a genuine dangling close.
449
- #
450
- # Measured, on the three blocks this hook fired in its own authoring session: every cited span
451
- # preceded the last tool call, and every turn ended with a 2983-3458 char report. All three
452
- # reasons were false about what followed the span. An independent audit reproduced the live
453
- # judge n=15 and the stated reason reproduced 0/15.
454
- #
455
- # Empty close (the turn ended ON a tool call) ⇒ fall back to the whole turn: that is the one
456
- # case where "no text after the tools" is the truth, not a projection artefact.
457
183
  asst_close="$(jq -rs '
458
- [ .[] ] as $all
459
- | ( [ range(0; ($all|length))
460
- | select( $all[.].type=="user"
461
- and ( ($all[.].message.content | type) == "string"
462
- or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
463
- | last // -1 ) as $lastuser
464
- | [ $all[($lastuser+1):][] | select(.type == "assistant") ] as $turn
465
- | ( [ range(0; ($turn|length))
466
- | select( ($turn[.].message.content // []) | map(.type) | index("tool_use") != null ) ]
467
- | last // -1 ) as $lasttool
468
- | [ $turn[($lasttool+1):][]
469
- | (.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") ]
470
189
  | map(select(. != "")) | join("\\n\\n")
471
190
  ' "$transcript" 2>/dev/null || true)"
472
191
  close_fallback=""
473
192
  [ -n "$asst_close" ] || { asst_close="$asst_text"; close_fallback=1; }
474
193
 
475
- [ -n "$asst" ] || allow_stop # no judgeable agent text (e.g. pure tool turn) → allow stop
476
-
477
- # THE OPERATOR SLOT — and it must actually hold the operator.
478
- #
479
- # A skill invocation (\`/carry-on\`, \`/introspect\`, …) enters the transcript as a user-type
480
- # message carrying the SKILL BODY. Taking the last user message therefore handed the judge
481
- # 2.8 kB of the /wake skill definition (since retired) as "the operator's most recent
482
- # instruction" — measured on two of six live fixtures. The judge then reasoned about
483
- # authorization from a document the operator never wrote, which is worse than having no
484
- # context: it is confidently wrong context, and the rubric leans on this slot to decide
485
- # whether an irreversible act was authorized.
486
- #
487
- # Skill bodies are recognizable by the wrapper the HARNESS puts on them, never by their prose:
488
- # claude injects one as a meta user message opening "Base directory for this skill: <dir>", after
489
- # a <command-name>/<command-message> invocation message; omp's \`skill-prompt\` message closes with
490
- # a "---" rule and "Skill: <path>/SKILL.md". An operator message that merely looks like a skill
491
- # (an H1 over a fenced block) carries neither, and stays. Fall back to the most recent message
492
- # that survives the filter.
493
- operator="$(jq -rs '
494
- [ .[]
495
- | select(.type == "user")
496
- | (.message.content)
497
- | if type == "string" then .
498
- elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
499
- else "" end
500
- ]
501
- | map(select(. != ""))
502
- | map(select(
503
- (test("<command-name>") | not)
504
- and (test("<command-message>") | not)
505
- and (test("^\\\\s*Base directory for this skill:") | not)
506
- and (test("\\\\n---\\\\n\\\\nSkill: [^\\\\n]*SKILL\\\\.md\\\\s*$") | not)
507
- and (test("^\\\\s*<system-reminder>") | not)
508
- and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
509
- and (test("<task-notification>") | not)
510
- ))
511
- | last // ""
512
- ' "$transcript" 2>/dev/null || true)"
194
+ operator="$(part operator)"
513
195
  [ -n "$operator" ] || operator="(no operator instruction found in transcript)"
514
196
 
515
- # --- THE LOOP POSITION IN FORCE, and the utterance that set it --------------------------------
516
- #
517
- # THE GUARD'S OLDEST BLIND SPOT, and the reason it reads as crude. \`carry-on\` declares
518
- # \`loop-position ∈ {on-the-loop, out-of-the-loop}\` as LIVE SESSION STATE, and nothing anywhere
519
- # wrote it down — so every turn was judged as if the session had just opened. A check-in is
520
- # CORRECT at rest ("a session opens in orientation · intent is the operator's to set") and is a
521
- # COLLAPSE under an elevation the operator already granted, and the judge could not tell those
522
- # two apart because it was never told which one it was in.
523
- #
524
- # DERIVED, NOT STORED. The transcript IS the record: the operator's own utterance is what
525
- # established the elevation, it is already here, it is session-scoped by construction, and it
526
- # cannot desync from what was actually said the way a state file can. A store would need a
527
- # session id this worker is not always given, a write path, and a lifecycle — all to hold a
528
- # value that is a fold over messages already on disk.
529
- #
530
- # SCANNED BEFORE THE FILTER ABOVE, which is the whole repair. A slash invocation arrives wrapped
531
- # in <command-name>, and that wrapper is exactly what the operator slot drops — so the filter
532
- # added to stop the judge reading a skill BODY as an instruction was also the mechanism hiding
533
- # every \`/carry-on\` from it. The word is read here, from the unfiltered list, and only the
534
- # word: no skill body reaches the payload.
535
- #
536
- # MECHANICAL EXTRACTION, SEMANTIC WEIGHING — the same split as layer 1. This reports WHICH
537
- # position is in force and the verbatim utterance that set it; the rubric decides what follows.
538
- # A false positive therefore costs a misleading context line, never an unguarded turn.
539
- standing="$(jq -rs '
540
- [ .[]
541
- | select(.type == "user")
542
- | (.message.content)
543
- | if type == "string" then .
544
- elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
545
- else "" end
546
- ]
547
- | map(select(. != ""))
548
- | map(select(
549
- (test("^\\\\s*<system-reminder>") | not)
550
- and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
551
- and (test("<task-notification>") | not)
552
- ))
553
- | (map(select(test("(^|[^[:alpha:]])(carry[- ]?on|weitermachen|proceed)([^[:alpha:]]|$)"; "i")))
554
- | last) as $hit
555
- | if $hit == null then "" else
556
- ($hit
557
- | if test("<command-message>") then (capture("<command-message>(?<m>[^<]*)").m)
558
- elif test("<command-name>") then (capture("<command-name>(?<m>[^<]*)").m)
559
- else . end
560
- | gsub("\\\\s+"; " ")) end
561
- ' "\${session_transcript:-$transcript}" 2>/dev/null || true)"
562
-
563
- # The position is a fact under a plain label: which one is in force and, for the elevation, the
564
- # utterance that set it. What each position means is the rubric's to say.
197
+ standing="$(part standing)"
565
198
  if [ -n "$standing" ]; then
566
199
  loop_position="out-of-the-loop, set by the operator's \\"$(printf '%s' "$standing" | cut -c1-300)\\""
567
200
  else
568
201
  loop_position="on-the-loop"
569
202
  fi
570
203
 
571
- # The judged payload is assembled after the layer-1 note below, because the cap covers both.
572
-
573
- # --- LAYER 1: deterministic checks (no LLM) --------------------------------------------------
574
- # The judge is one sample from a small model — a noisy signal, and unfit to carry an invariant on
575
- # its own. Anything decidable by inspection is decided HERE, where it is reproducible and free.
576
- # The judge is reserved for the semantic residue that regex provably cannot reach.
577
- #
578
- # L1a · ANNOUNCE-WITHOUT-ACT. A Stop hook fires when the agent has produced text and no further
579
- # tool call. So a first-person forward commitment in the FINAL text is, by construction, a
580
- # commitment the turn did not honour: had the agent done the thing, the doing would precede the
581
- # text and the text would report it ("I ran X"), not promise it ("I'll run X").
582
- #
583
- # The rubric could never catch this. Its turn-close rule asks only whether the close OFFERS the
584
- # next action ("say the word") versus STATES it — and a bare statement PASSES. Replayed through
585
- # the judge, a real turn reading "Proceeding to #2. I'll run the research and author the plan."
586
- # came back PASS, with the judge commending the agent for "proceeding with a declared approach"
587
- # while the agent had in fact proceeded with nothing. Stating and stopping is the collapse in its
588
- # most fluent disguise, and it is invisible to a rule that only inspects the shape of the close.
589
- #
590
- # Contingent commitments are NOT this: waiting on a dispatched agent, on an operator's sign-off,
591
- # or on an external event is legitimate, and the turn genuinely cannot proceed. Those are carved
592
- # out below. Everything else routes to the judge with the offending span quoted, so the block
593
- # names the evidence rather than restating the rule.
594
- # Windowed off THE CLOSE, not the whole-turn blob. Taking the last 700 bytes of the
595
- # concatenation reaches BACKWARD ACROSS TOOL BOUNDARIES whenever the final text block is short —
596
- # reintroducing the very bug this section claims to have fixed ("it judged 'Adding the
597
- # false-positive fixtures', a preamble whose very next assistant message was a tool_use").
598
- # It has not fired yet only because the offending turns happened to close with ~3000 chars.
599
204
  final_span="$(printf '%s' "$asst_close" | tail -c 700)"
600
205
  l1_evidence=""
601
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
602
- # Carve-outs: the commitment is contingent on something outside this turn.
603
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
604
- # Extract by SENTENCE, not by a windowed match. \`grep -Eo ".{0,90}…{0,90}"\` looks
605
- # obvious and is not portable: ugrep (the default grep on some hosts) rejects the nested
606
- # bounded quantifier with "exceeds complexity limits", the command fails, and the
607
- # substitution yields EMPTY — so the evidence clause silently vanishes and the block
608
- # degrades to the restated-rule feedback this rewrite exists to replace. A gate whose
609
- # evidence path fails open is a gate that lies about why it fired.
208
+ # By sentence: ugrep rejects a windowed \`.{0,90}\` match.
610
209
  l1_evidence="$(printf '%s' "$final_span" | tr '\\n' ' ' | tr '.' '\\n' \\
611
210
  | grep -Eim1 "(i'?ll|i will|i'?m going to|let me|proceeding to|moving on to|starting (on|with))" \\
612
211
  | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-200)"
613
212
  fi
614
213
  fi
615
-
616
- # --- LAYER 2: the judge (semantic residue only) ----------------------------------------------
617
- # The judge contract: payload on stdin, rubric path as argv[1]; emits VERDICT: PASS|BLOCK [+ REASON].
618
- # Non-zero judge exit, an empty answer or a verdict that is neither PASS nor BLOCK → dark.
619
- # THE JUDGE IS HANDED FACTS UNDER PLAIN LABELS, and no sentence telling it what to conclude: the
620
- # rubric is the one place the test is stated, and a payload that restates it in its own words is a
621
- # second home for it. The Layer-1 span is one such fact, quoted from the turn by this worker.
622
214
  l1_block=""
623
215
  [ -z "$l1_evidence" ] || l1_block="
624
216
 
625
217
  Layer-1 span: \\"$l1_evidence\\""
626
218
 
627
- # THE JUDGED PAYLOAD, BOUNDED: the standing directive, the operator's instruction, THEN the
628
- # agent turn, and the layer-1 note when there is one — all together at most JUDGE_PAYLOAD_CAP
629
- # bytes. The operator's message keeps its TAIL (a quarter of the cap at most: the instruction
630
- # that governs the turn is the last thing said). The agent turn keeps what remains after
631
- # every fixed part, and it keeps its END: the close is the text after the last tool call, every
632
- # rule that can fire reads it, and it is the last thing in the turn. Whatever is cut is marked
633
- # where it was cut.
634
219
  op_room=$((JUDGE_PAYLOAD_CAP / 4))
635
220
  op_total="$(judge_bytes "$operator")"
636
221
  if [ "$op_total" -gt "$op_room" ]; then
@@ -655,31 +240,14 @@ else
655
240
  asst_sent="$(judge_cut 'the agent turn' "$asst_total" "$(judge_bytes "$asst_shown")")
656
241
  $asst_shown"
657
242
  if [ -n "$close_fallback" ]; then
658
- # The turn ended ON a tool call, so the whole turn stood in for the close: what it
659
- # showed, less the tool markers, which are not the agent's words.
660
243
  close_seen="$(printf '%s\\n' "$asst_shown" | sed '/^\\[tools: .*\\]$/d')"
661
244
  else
662
- # The close ends the turn, so what the excerpt shows of it is its own final bytes.
663
245
  close_seen="$(printf '%s' "$asst_close" | judge_keep "$(judge_bytes "$asst_shown")")"
664
246
  fi
665
247
  fi
666
248
  judged="$turn_head
667
249
  $asst_sent$l1_block"
668
250
 
669
- # THE JUDGE MAY LIVE OUTSIDE THIS PROCESS, and on a harness that already holds a
670
- # model it MUST. Spawning another vendor's CLI to answer a question the host can
671
- # answer in-process is a cross-harness dependency wearing a plugin's clothes: it
672
- # needs that vendor installed, separately authenticated, and warm — and when its
673
- # OAuth lapses, every verdict on every harness fails open in silence.
674
- #
675
- # So the seam is explicit and the PROCEDURE stays here, in one home. A host that
676
- # can judge runs this worker twice: once with \`STANCE_EMIT_PAYLOAD\` to receive the
677
- # gated, layer-1-annotated payload and the rubric that scores it, then again with
678
- # \`STANCE_VERDICT_FILE\` naming its answer. Everything either pass touches before
679
- # this line is read-only, so the first pass mutates no counter and no hash — the
680
- # gate, the extraction and the pre-filter simply run twice and agree.
681
- #
682
- # A host with no model of its own changes nothing and keeps the subprocess judge.
683
251
  if [ -n "\${STANCE_EMIT_PAYLOAD:-}" ]; then
684
252
  jq -cn --arg r "$RUBRIC" --arg p "$judged" '{rubric:$r, payload:$p}'
685
253
  exit 0
@@ -691,91 +259,34 @@ else
691
259
  verdict="$(printf '%s' "$judged" | $JUDGE_CMD "$RUBRIC" 2>/dev/null)" || dark "the judge did not answer"
692
260
  fi
693
261
 
694
- 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:]]*$//')"
695
264
  case "$decision" in
696
- PASS) allow_stop ;; # judged, no collapse: the one silent verdict
265
+ PASS) allow_stop ;;
697
266
  BLOCK) ;;
698
267
  *) dark "the judge's verdict was unparseable (no VERDICT: PASS or VERDICT: BLOCK line)" ;;
699
268
  esac
700
269
 
701
- reason="$(printf '%s\\n' "$verdict" | sed -n 's/^REASON:[[:space:]]*//p' | head -1)"
270
+ reason="$(tag REASON)"
702
271
  [ -n "$reason" ] || reason="This turn collapsed out of the intent-driven-expert stance."
703
272
 
704
- # --- EVIDENCE VERIFICATION: a block must quote text that is actually in the turn --------------
705
- # The judge is one sample from a small model, and a wrong block costs exactly what a missed one
706
- # does: an agent that yields to a fired gate whose diagnosis the record refutes has updated on a
707
- # salient signal instead of on argument — the collapse wearing the guardrail's uniform.
708
- #
709
- # Observed live: the judge blocked a turn and quoted "Authoring the plan" as the offending span.
710
- # That string was not in the turn it judged — it was the close of an EARLIER turn, and that turn
711
- # had honoured it. Pure confabulation, and unfalsifiable from inside the model.
712
- #
713
- # So the quote is checked against the transcript MECHANICALLY. The judge must emit
714
- # \`EVIDENCE: <verbatim span>\`; if that span does not literally occur in the judged turn, the
715
- # block is discarded. A model cannot quote what is not there, which makes this cheap and total.
716
- # EVIDENCE IS MANDATORY FOR A BLOCK. A missing line does not get the benefit of the doubt: the
717
- # first cut of this check let an absent EVIDENCE line mean "verdict stands", and that exemption
718
- # was immediately exercised — a fabricated block reached the agent because the span it should
719
- # have been checked against had been stripped upstream by the judge's own output filter. An
720
- # unevidenced block is indistinguishable from a fabricated one, so it is discarded either way.
721
- # Layer-1 blocks are unaffected: they carry a span this hook extracted from the turn itself.
722
- evidence="$(printf '%s\\n' "$verdict" | sed -n 's/^EVIDENCE:[[:space:]]*//p' | head -1 \\
723
- | sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
273
+ evidence="$(tag EVIDENCE | sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
724
274
  if [ -z "$evidence" ] && [ -z "$l1_evidence" ]; then
725
- printf 'DARK\\t\\t%s\\n' "$reason" >> "$verdict_log" 2>/dev/null || true
275
+ row DARK "" "$reason"
726
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"
727
277
  allow_stop
728
278
  fi
729
279
  if [ -n "$evidence" ] && [ "\${#evidence}" -ge 12 ]; then
730
- # Checked against THE CLOSE, not the whole turn. Existence-somewhere was never the
731
- # proposition worth authenticating: every rule that can fire is a claim about the turn's
732
- # FINAL text, so a span drawn from a mid-turn preamble is out of scope for the verdict
733
- # built on it even though the characters are genuinely present. Measured on this hook's
734
- # own three blocks — all three spans preceded the last tool call, all three reasons were
735
- # false about what followed, and all three passed the old whole-turn check.
736
- # THE ONE MECHANICALLY-CHECKED ARTIFACT, RECORDED. \`$evidence\` is the only part of a block
737
- # that survives a check against the turn; \`$reason\` is unverified model prose. Both were
738
- # discarded, so a block could not be audited afterwards without re-running a judge measured at
739
- # 3/5 on identical payloads — i.e. the record of WHY a turn was blocked was reconstructible only
740
- # by a non-deterministic process. Appended, never rotated by this hook; the state dir is tmp.
741
- printf '%s\\t%s\\t%s\\n' "$decision" "$evidence" "$reason" >> "$verdict_log" 2>/dev/null || true
742
- # NORMALIZED ON BOTH SIDES, and with \`--\`, because this check had two ways to
743
- # throw away a correct block — and both of them fired on the SAME shape, which is
744
- # the one shape this guard most exists to catch.
745
- #
746
- # 1. \`grep -qF "$evidence"\` with no \`--\`: an evidence span opening with a markdown
747
- # bullet is read as an OPTION. \`grep: invalid option -- ' '\`, non-zero, block
748
- # discarded. A tail-enumeration collapse IS a bullet list, so its evidence always
749
- # begins \`- \`, and the rule could never convict the shape it was written for.
750
- # 2. A judge quoting several bullets joins them with spaces and drops the markers,
751
- # so a verbatim-substring test fails on punctuation while every word is present.
752
- #
753
- # The proposition worth authenticating is that the judge quoted THIS TURN'S WORDS,
754
- # not that it reproduced its list syntax. So both sides are flattened the same way:
755
- # newlines to spaces, runs of whitespace to one, list markers dropped. Symmetric, so
756
- # nothing is accepted on one side that would be rejected on the other.
757
- # AND AGAINST WHAT THE JUDGE WAS ACTUALLY SENT. \`close_seen\` is the close as far as the
758
- # bounded excerpt showed it (the whole close when nothing was cut). A span from text the
759
- # excerpt elided cannot be a quotation of anything the judge read, however genuinely it is
760
- # in the transcript, so it is discarded exactly as a fabricated one is.
761
- ev_norm="$(printf '%s' "$evidence" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
762
- close_norm="$(printf '%s' "$close_seen" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
763
- if ! printf '%s' "$close_norm" | grep -qF -- "$ev_norm"; then
764
- 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"
765
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."
766
285
  allow_stop
767
286
  fi
768
287
  fi
769
288
 
770
- # --- A REFUSAL HOLDS, EXCEPT AT THE TURN END, AND THERE ONLY FOR ONE STOP ---------------------
771
- # A block blocks the stop. The turn end is the one exception to a refused act staying refused: a
772
- # refused stop holds back no effect, it only makes the agent redo its turn, so the bound below lets
773
- # a repeated or several-times-refused stop through rather than lock the session in an endless loop.
774
- # It lets through ONE STOP — the next stop that follows no block starts a new run at 0 and is
775
- # judged and blocked like any other — and it SAYS SO: the finding stands and the stop is
776
- # UNRESOLVED. Two caps once let a refused turn through silently, and a counter that never reset
777
- # turned a one-turn escape into a session-wide disable; neither is restored. What ends a run of
778
- # blocks is a turn the judge passes, or this bound, said aloud.
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.
779
290
  turn_hash="$(printf '%s' "$asst_text" | cksum)"
780
291
  run_next=$((run + 1))
781
292
  let_through=""
@@ -793,43 +304,16 @@ if [ -z "$let_through" ] && ! printf '%s %s' "$run_next" "$turn_hash" > "$run_fi
793
304
  fi
794
305
  if [ -n "$let_through" ]; then
795
306
  rm -f "$run_file" 2>/dev/null || true
796
- printf 'UNRESOLVED\\t%s\\t%s\\n' "$evidence" "$reason" >> "$verdict_log" 2>/dev/null || true
307
+ row UNRESOLVED "$evidence" "$reason"
797
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"
798
309
  allow_stop
799
310
  fi
800
- reason="$reason (stance-guardrail block $run_next in this run of refused stops.)"
801
-
802
- # --- BLOCK ----------------------------------------------------------------------------------
803
- # Emit the Stop-hook block decision. The \`reason\` is fed back to the agent as a corrective
804
- # instruction; it must re-assume the stance (own the call / extract the intent) and continue.
805
- #
806
- # The feedback QUOTES THE OFFENDING SPAN when Layer 1 found one. A model corrects far better
807
- # against a concrete diff than against a restated rule — and the restated rule is what this
808
- # guardrail used to send, which is why an agent could absorb the correction, agree with it in
809
- # detail, and reproduce the same failure in its very next sentence.
810
311
  evidence_clause=""
811
- [ -n "$l1_evidence" ] && evidence_clause="The offending span is yours, verbatim: \\"$l1_evidence\\" — \\
812
- you committed to an action and then ended the turn without taking it. Stating a next action is not \\
813
- performing it. Do the thing NOW, in this turn, with tool calls; report it in the past tense when it \\
814
- is done. "
815
- # Ship the VERIFIED span on a judge block too. Only \`\$evidence\` survived a mechanical check
816
- # against the close; \`\$reason\` is unverified model prose. Sending the reason alone leaves the
817
- # recipient able to argue only with the one string nothing authenticated — and an agent that
818
- # refutes a fabricated reason has dodged a verdict that may still be correct, which is exactly
819
- # what happened three times in this hook's authoring session.
820
- [ -z "$evidence_clause" ] && [ -n "$evidence" ] && evidence_clause="The span this was checked \\
821
- against, verbatim from your close: \\"$evidence\\" — the REASON above is the judge's unverified \\
822
- wording; THIS span is what mechanically matched. Argue with the span, not the wording. "
823
-
824
- feedback="STANCE GUARDRAIL — blocked: you collapsed out of the intent-driven-expert stance. $reason \\
825
- \${evidence_clause}Re-assume the stance: you are the owning expert; the operator owns intent + sign-off \\
826
- on irreversible acts only. Decide the in-remit call yourself (note it for review) instead of seeking \\
827
- permission, own your expert judgment (naming/design/architecture/how) instead of deferring it, and \\
828
- extract+serve the operator's INTENT instead of echoing their literal words. Then continue. (Legitimate \\
829
- exceptions: surfacing a genuine irreversible-outward act for consent, or routing a true INTENT \\
830
- ambiguity to /elicit.)"
831
-
832
- # 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
+
833
317
  jq -cn --arg r "$feedback" '{decision:"block", reason:$r}'
834
318
  exit 0
835
319
  `,
@@ -985,10 +469,4 @@ A record starting "Agent dispatch" is judged by this alone: BLOCK only if it pas
985
469
  },
986
470
  ],
987
471
  };
988
- // EXPORTED AFTER THE CELL, ON PURPOSE. `allHookCells` (tooling/project-targets.ts) takes the
989
- // FIRST export of a `hooks/*.ts` module to be its cell, by name order and by definition order
990
- // alike, so anything else this module exports must sort and be defined after `stanceGuardrail`.
991
- // These are the cap and the shell that honours it, for the two workers that share the judge.
992
- export const stanceGuardrailJudgeCap = judgePayloadCapBytes;
993
- export const stanceGuardrailJudgeClip = judgeClipShell;
994
472
  //# sourceMappingURL=stance-guardrail.js.map