@cratylus/canon 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/guard-shell.d.ts +43 -0
- package/dist/guard-shell.js +85 -0
- package/dist/guard-shell.js.map +1 -0
- package/dist/hooks/purview-guardrail.js +63 -175
- package/dist/hooks/purview-guardrail.js.map +1 -1
- package/dist/hooks/stance-guardrail-pre.js +46 -146
- package/dist/hooks/stance-guardrail-pre.js.map +1 -1
- package/dist/hooks/stance-guardrail.d.ts +0 -2
- package/dist/hooks/stance-guardrail.js +90 -612
- package/dist/hooks/stance-guardrail.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,65 +1,10 @@
|
|
|
1
1
|
import { anchorOf } from '@cratylus/schema';
|
|
2
2
|
import { handoff } from '../dimensions/autonomy/handoff.js';
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// byte-
|
|
7
|
-
//
|
|
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
|
|
82
|
-
#
|
|
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
|
-
#
|
|
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=
|
|
162
|
-
|
|
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; }
|
|
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
|
-
|
|
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
|
-
#
|
|
224
|
-
|
|
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="$(
|
|
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
|
-
|
|
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
|
-
#
|
|
306
|
-
|
|
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="$(
|
|
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 '
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
#
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
459
|
-
| (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 ;;
|
|
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="$(
|
|
270
|
+
reason="$(tag REASON)"
|
|
702
271
|
[ -n "$reason" ] || reason="This turn collapsed out of the intent-driven-expert stance."
|
|
703
272
|
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|