@spexcode/spec-cli 0.7.0-next.13 → 0.7.0-next.15
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/dist/hook-prompts.js +1 -1
- package/package.json +7 -7
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +26 -25
- package/templates/spec/project/.plugins/core/session-listen/spec.md +11 -0
- package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +20 -7
- package/templates/spec/project/.plugins/core/spec-first/spec.md +11 -3
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +16 -0
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +52 -29
package/dist/hook-prompts.js
CHANGED
|
@@ -25,7 +25,7 @@ const STOP_GATE_VARIANTS = Object.freeze({
|
|
|
25
25
|
'(This full explanation shows once per session; later undeclared stops get a one-line reminder. `{cli} help session` re-explains the choices any time.)',
|
|
26
26
|
].join('\n'),
|
|
27
27
|
terse: 'undeclared stop — declare the ONE true state as your LAST call: `{cli} session <done --propose merge (review; ONLY clickable merge)|close (close-pending; settled, no human decision/follow-up or posted artifact waiting)|park (parked; real wake-up + next action) / ask (asking; human reply/direction/decision, including reported finding/recommendation or handoff)>`. `done --propose nothing` is a trap: it writes no state and names these choices. Conditions: `{cli} help session`.',
|
|
28
|
-
artifact: 'a posted file/web artifact still needs human inspection; declare `
|
|
28
|
+
artifact: 'a posted file/web artifact still needs human inspection; declare `{cli} session ask --note ...`, and declare it last.',
|
|
29
29
|
commit: 'Not ready to declare done: {reason}. The dogfood ritual lands every change as a git commit on your node branch BEFORE you propose. Commit your spec.md + code on this node branch (spec: <id> — <reason>, with a Session: trailer), then re-run `{cli} session done --propose {proposal}`.',
|
|
30
30
|
eval: 'eval — the loss signal the optimizer reads — flags {count} gap(s) in nodes you changed: {ids}. A node whose score went stale/unmeasured: re-measure it — PRODUCE the measurement YOURSELF with a real run of the scenario\'s actual surface (its tag on the `spex eval lint --changed` line tells you WHICH surface to run), compare to expected, and file it with `spex eval add <node>`; don\'t desk-check it, and don\'t defer to reviewing a recording after the fact. A FRONTEND node with no eval.md: give it one (a scenario — description + expected), since an obvious UI change should carry a loss signal. `spex eval lint --changed` lists them. (Advisory — fires once, not a gate.)',
|
|
31
31
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-cli",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
|
|
6
6
|
"bin": {
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@spexcode/session-application": "0.7.0-next.
|
|
39
|
-
"@spexcode/session-selflaunch": "0.7.0-next.
|
|
40
|
-
"@spexcode/spec-core": "0.7.0-next.
|
|
41
|
-
"@spexcode/spec-eval": "0.7.0-next.
|
|
42
|
-
"@spexcode/spec-forge": "0.7.0-next.
|
|
43
|
-
"@spexcode/transcript": "0.7.0-next.
|
|
38
|
+
"@spexcode/session-application": "0.7.0-next.15",
|
|
39
|
+
"@spexcode/session-selflaunch": "0.7.0-next.15",
|
|
40
|
+
"@spexcode/spec-core": "0.7.0-next.15",
|
|
41
|
+
"@spexcode/spec-eval": "0.7.0-next.15",
|
|
42
|
+
"@spexcode/spec-forge": "0.7.0-next.15",
|
|
43
|
+
"@spexcode/transcript": "0.7.0-next.15",
|
|
44
44
|
"smol-toml": "^1.8.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -62,6 +62,19 @@ if printf ' 0a ' | grep -Eq "$control_pattern"; then capability_error 'grep exte
|
|
|
62
62
|
[ "$(printf 'A\nB\n' | awk "$escape_awk")" = 'A\nB' ] || capability_error 'awk JSON text escaping'
|
|
63
63
|
[ "$(printf A | cat)" = A ] || capability_error 'cat byte emission'
|
|
64
64
|
|
|
65
|
+
# @@@ a failed delivery must not eat the person's own prompt - this hook runs ON UserPromptSubmit, so exiting 2
|
|
66
|
+
# BLOCKS what the person just typed. Two different harms came out of that. A transient dequeue failure consumed
|
|
67
|
+
# nothing, yet the person lost their own unrelated prompt for it. And a failure AFTER a successful dequeue lost
|
|
68
|
+
# the peer message for good (the queue is at-most-once) and then took the person's prompt as a second casualty
|
|
69
|
+
# — blocking cannot bring the message back, it only doubles the loss. So a delivery failure reports itself
|
|
70
|
+
# through the SAME channel the message would have used and lets the prompt through. The notice is ASCII only
|
|
71
|
+
# (an id and base64), so it needs no escaping pass of its own.
|
|
72
|
+
notify_failure() {
|
|
73
|
+
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$1"
|
|
74
|
+
printf '%s\n' "$1" >&2
|
|
75
|
+
exit 0
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
66
79
|
payload=$(cat 2>/dev/null || true)
|
|
67
80
|
sid=$(hp_field "$payload" session_id)
|
|
@@ -73,47 +86,37 @@ case "$event" in
|
|
|
73
86
|
"$cli" initialize --session-id "$sid" >/dev/null || exit 2
|
|
74
87
|
;;
|
|
75
88
|
UserPromptSubmit)
|
|
76
|
-
decoded=$(mktemp "${TMPDIR:-/tmp}/spex-session-listen.XXXXXX")
|
|
77
|
-
|
|
78
|
-
exit 2
|
|
79
|
-
}
|
|
89
|
+
decoded=$(mktemp "${TMPDIR:-/tmp}/spex-session-listen.XXXXXX") \
|
|
90
|
+
|| notify_failure 'session-listen could not allocate a temporary body file, so it read nothing this turn. Repair TMPDIR. Any queued message is still queued.'
|
|
80
91
|
escaped_file="$decoded.escaped"
|
|
81
92
|
output_file="$decoded.output"
|
|
82
93
|
if ! : >"$escaped_file" || ! : >"$output_file"; then
|
|
83
|
-
rm -f "$decoded"
|
|
84
|
-
|
|
85
|
-
printf '%s\n' 'session-listen: could not prepare temporary delivery files; repair TMPDIR and retry' >&2
|
|
86
|
-
exit 2
|
|
94
|
+
rm -f "$decoded" "$escaped_file" "$output_file"
|
|
95
|
+
notify_failure 'session-listen could not prepare its temporary delivery files, so it read nothing this turn. Repair TMPDIR. Any queued message is still queued.'
|
|
87
96
|
fi
|
|
88
97
|
cleanup() { rm -f "$decoded" "$escaped_file" "$output_file"; }
|
|
89
98
|
trap cleanup EXIT
|
|
90
|
-
raw=$("$cli" dequeue --session-id "$sid") ||
|
|
99
|
+
raw=$("$cli" dequeue --session-id "$sid") || notify_failure 'session-listen could not read the message queue this turn. Nothing was consumed, so any queued message is still there and arrives on the next turn.'
|
|
91
100
|
[ "$raw" = 'null' ] && exit 0
|
|
92
101
|
body64=$(printf '%s' "$raw" | sed -n "$body64_sed")
|
|
93
102
|
message_id=$(printf '%s' "$raw" | sed -n 's/.*"messageId":"\([^"]*\)".*/\1/p')
|
|
94
103
|
[ -n "$body64" ] || {
|
|
95
|
-
printf '
|
|
96
|
-
exit 2
|
|
104
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "invalid-json-missing-bodyBase64" "$message_id" "$body64")"
|
|
97
105
|
}
|
|
98
106
|
if ! printf '%s' "$body64" | base64 -d >"$decoded" 2>/dev/null; then
|
|
99
|
-
printf '
|
|
100
|
-
exit 2
|
|
107
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "invalid-bodyBase64" "$message_id" "$body64")"
|
|
101
108
|
fi
|
|
102
109
|
if ! iconv -f UTF-8 -t UTF-8 "$decoded" >/dev/null 2>/dev/null; then
|
|
103
|
-
printf 'session-listen
|
|
104
|
-
exit 2
|
|
110
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "non-utf8-body" "$message_id" "$body64")"
|
|
105
111
|
fi
|
|
106
112
|
if od -An -v -tx1 "$decoded" | grep -Eq "$control_pattern"; then
|
|
107
|
-
printf 'session-listen
|
|
108
|
-
exit 2
|
|
113
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "control-byte-body" "$message_id" "$body64")"
|
|
109
114
|
fi
|
|
110
115
|
if ! awk "$escape_awk" "$decoded" >"$escaped_file"; then
|
|
111
|
-
printf 'session-listen
|
|
112
|
-
exit 2
|
|
116
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "encode-failed" "$message_id" "$body64")"
|
|
113
117
|
fi
|
|
114
118
|
if [ -s "$decoded" ] && [ ! -s "$escaped_file" ]; then
|
|
115
|
-
printf 'session-listen
|
|
116
|
-
exit 2
|
|
119
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "encoded-to-empty" "$message_id" "$body64")"
|
|
117
120
|
fi
|
|
118
121
|
last_byte=$(tail -c 1 "$decoded" | od -An -tx1 | tr -d '[:space:]')
|
|
119
122
|
if ! {
|
|
@@ -122,12 +125,10 @@ case "$event" in
|
|
|
122
125
|
[ "$last_byte" = 0a ] && printf '\\n'
|
|
123
126
|
printf '"}}\n'
|
|
124
127
|
} >"$output_file"; then
|
|
125
|
-
printf 'session-listen
|
|
126
|
-
exit 2
|
|
128
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "assemble-failed" "$message_id" "$body64")"
|
|
127
129
|
fi
|
|
128
130
|
if ! cat "$output_file"; then
|
|
129
|
-
printf 'session-listen
|
|
130
|
-
exit 2
|
|
131
|
+
notify_failure "$(printf 'session-listen dequeued a message it could not deliver, and the queue is at-most-once, so the message is gone from it. Recover it from this line. reason=%s messageId=%s bodyBase64=%s' "emit-failed" "$message_id" "$body64")"
|
|
131
132
|
fi
|
|
132
133
|
;;
|
|
133
134
|
esac
|
|
@@ -27,6 +27,17 @@ is decoded to a temporary file, validated as UTF-8, and rejected loudly (with it
|
|
|
27
27
|
toolchain without command substitution, preserving embedded and trailing newlines; a non-empty body can never become
|
|
28
28
|
an empty additionalContext success.
|
|
29
29
|
|
|
30
|
+
**A delivery failure never costs the person their own prompt.** This hook runs ON prompt submission, so blocking
|
|
31
|
+
is not a way of being loud — it deletes what someone just typed. Two different failures did that. A dequeue that
|
|
32
|
+
errors consumes nothing, and yet the prompt was thrown away for it. And a failure AFTER a successful dequeue had
|
|
33
|
+
already lost the peer message, because the queue is at-most-once; blocking cannot bring it back and only takes a
|
|
34
|
+
second casualty. So every failure inside the delivery path reports itself through the SAME channel the message
|
|
35
|
+
would have used — an `additionalContext` notice, and the same line on stderr — and lets the prompt through. A
|
|
36
|
+
failure before the dequeue says that nothing was consumed and the message is still queued. A failure after it
|
|
37
|
+
carries the `messageId` and the `bodyBase64`, because at that point the notice IS the recovery path. What stays
|
|
38
|
+
blocking is the environment: a configured-but-broken adopter, and the capability probes that run before the
|
|
39
|
+
dequeue precisely so a message is never consumed by a shell that cannot deliver it.
|
|
40
|
+
|
|
30
41
|
The CLI is resolved at runtime through one explicit seam: a non-empty `SPEX_SESSION_CLI` wins, otherwise PATH is
|
|
31
42
|
searched for `spex-session`. If either protocol database environment variable is configured and no CLI can be
|
|
32
43
|
resolved, the hook fails loudly with an installation or `SPEX_SESSION_CLI` repair entrypoint. With neither database
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# @@@ spec-first - a one-shot governed
|
|
3
|
-
# the spec graph resolves a real `code:` governor for that path.
|
|
4
|
-
# uncovered/related-only files leave the sentinel absent, so any
|
|
5
|
-
# first later governed
|
|
6
|
-
#
|
|
2
|
+
# @@@ spec-first - a one-shot governed ACCESS gate. It advances only when the adapter resolves a path the
|
|
3
|
+
# agent is about to READ OR MUTATE AND the spec graph resolves a real `code:` governor for that path.
|
|
4
|
+
# Irrelevant tools, unresolvable paths, and uncovered/related-only files leave the sentinel absent, so any
|
|
5
|
+
# number of ungoverned touches cannot mute the first later governed one. That touch spends the gate and blocks
|
|
6
|
+
# once with its actual governor; retries pass.
|
|
7
|
+
# @@@ why BOTH read and mutate - this gate has been narrowed twice and both narrowings reopened the same hole
|
|
8
|
+
# from opposite sides. Mutation-only let a pure analysis session reason straight from the code without ever
|
|
9
|
+
# opening the contract; read-only lets a session whose first governed touch is an Edit or Write do the same
|
|
10
|
+
# thing while writing. The rule in [[core]] is read the contract FIRST, and a blind write is its strongest
|
|
11
|
+
# case, so the trigger is any governed access. Narrowing it again needs a reason written down here.
|
|
12
|
+
# @@@ event vs matcher - materialized shims bind PreToolUse event-wide on every harness. hp_code_path ... access
|
|
7
13
|
# is the ONE adapter matcher that reduces Claude/Codex payload differences to a path. This handler owns the
|
|
8
14
|
# harness-agnostic state transition and governor lookup; it has no tool-name, harness, or filename branches.
|
|
9
15
|
# @@@ all sessions, global sentinel - file governance is independent of a record's `governed` bit, so the
|
|
@@ -17,7 +23,7 @@ sdir=$(hp_store_dir "$sid") || exit 0
|
|
|
17
23
|
sent="$sdir/spec-checked"
|
|
18
24
|
[ -f "$sent" ] && exit 0
|
|
19
25
|
|
|
20
|
-
paths=$(hp_code_path "$payload"
|
|
26
|
+
paths=$(hp_code_path "$payload" access)
|
|
21
27
|
[ -n "$paths" ] || exit 0
|
|
22
28
|
repo=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
23
29
|
|
|
@@ -36,8 +42,15 @@ $paths
|
|
|
36
42
|
EOF
|
|
37
43
|
[ -n "$owner" ] || exit 0
|
|
38
44
|
|
|
45
|
+
# @@@ spend the gate only on a demand that was actually made - the sentinel used to be written BEFORE the
|
|
46
|
+
# reason was rendered, so a render failure burned the session's one chance and the agent was never told
|
|
47
|
+
# anything: the gate reported nothing and then stayed silent for the rest of the session. Render first; the
|
|
48
|
+
# sentinel records that a demand reached the agent, not that one was attempted.
|
|
49
|
+
reason=$($S internal hook-prompt spec-first --path "$path" --owner "$owner") || {
|
|
50
|
+
printf 'spec-first: could not render its demand for %s (%s); leaving the gate armed for the next governed touch\n' "$path" "$owner" >&2
|
|
51
|
+
exit 0
|
|
52
|
+
}
|
|
39
53
|
mkdir -p "$sdir"; : > "$sent"
|
|
40
|
-
reason=$($S internal hook-prompt spec-first --path "$path" --owner "$owner") || exit 1
|
|
41
54
|
esc=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}')
|
|
42
55
|
printf '{"decision":"block","reason":"%s"}\n' "$esc"
|
|
43
56
|
exit 0
|
|
@@ -8,10 +8,18 @@ events:
|
|
|
8
8
|
order: 20
|
|
9
9
|
block: true
|
|
10
10
|
---
|
|
11
|
-
A one-shot, governed-aware
|
|
11
|
+
A one-shot, governed-aware ACCESS gate. Its state advances only when the agent is about to read OR mutate code that has a real governor (`code:` ownership). The first such touch creates the session sentinel and blocks once, naming the resolved governing spec and directing the agent through the relevant parent, sibling, and child contracts before retrying. Once that contract-read path has been demanded, later code touches pass.
|
|
12
12
|
|
|
13
|
-
The state machine has no transition for an irrelevant tool, an unresolvable path, or an uncovered/related-only file. In particular, any number of ungoverned
|
|
13
|
+
The state machine has no transition for an irrelevant tool, an unresolvable path, or an uncovered/related-only file. In particular, any number of ungoverned touches remain allowed without consuming or muting the gate; a later governed one must still block.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**Both halves of access are in the trigger, and the reason is written down because it has been lost twice.** Gating mutation alone let a pure analysis session reason straight from the code without ever opening the contract — the grounding gap. Gating reads alone lets a session whose first governed touch is an Edit or Write do exactly the same thing while writing, which is the same hole entered from the other side and the worse one, since the rule this enforces is read the contract FIRST and a blind write is its strongest case. Narrowing the trigger again requires a stated reason; the governed-awareness above is what keeps it quiet, not the choice of verb. This is file governance, distinct from a session record's `governed` field: spec-awareness still serves dashboard-launched and user-self-launched agents alike, with the sentinel created on demand in the session's global store directory.
|
|
16
|
+
|
|
17
|
+
**The gate is spent by a demand that reached the agent, not by one that was attempted.** The sentinel used to
|
|
18
|
+
be written before the reason was rendered, so a render failure burned the session's single chance and said
|
|
19
|
+
nothing — the agent was never told, and the gate stayed silent for the rest of the session. The reason is
|
|
20
|
+
rendered first; a failure leaves the gate armed, reports itself on stderr, and lets the touch through, because
|
|
21
|
+
a gate that cannot say what it wants has nothing to demand.
|
|
22
|
+
|
|
23
|
+
Event delivery and semantic matching have separate responsibilities. The hook subscribes to the shared `PreToolUse` lifecycle event because Claude and Codex shims deliver that event broadly. The harness adapter's single `access` matcher decides whether the payload represents a file read or mutation and extracts its path; the hook then asks the spec graph whether that path has a governor. Harness payload differences stay inside the adapter, while the gate and its state transitions stay one mechanism.
|
|
16
24
|
|
|
17
25
|
This enforces the read-the-contract-first rule of [[core]] only where a contract actually exists, at the moment before understanding hardens around governed code.
|
|
@@ -8,6 +8,22 @@ events:
|
|
|
8
8
|
order: 10
|
|
9
9
|
block: true
|
|
10
10
|
---
|
|
11
|
+
**An artifact is a note ON the demand, not a replacement for it.** The artifact reminder used to be its own
|
|
12
|
+
branch taken INSTEAD of the full menu, with the taught sentinel stamped before it, so a session that had
|
|
13
|
+
posted anything never saw the four states at all. It also looked at files only, while `session web add` writes
|
|
14
|
+
its own store beside them, so a web-only artifact was invisible to the check. The teaching level is chosen
|
|
15
|
+
first — full on the first stop, terse afterwards — and the artifact line is appended to whichever level
|
|
16
|
+
applies, from either store.
|
|
17
|
+
|
|
18
|
+
**A gate that cannot render its own text still blocks.** Rendering the reason goes through the CLI, and a
|
|
19
|
+
failure there used to exit non-zero — which, for a Stop hook, means ALLOW. The one gate whose entire job is to
|
|
20
|
+
stop an undeclared stop was disarmed by its own text failing to load. It now blocks with a self-contained
|
|
21
|
+
fallback that names the four states without needing the CLI to produce them. Because a genuinely broken CLI
|
|
22
|
+
would also stop the agent from declaring, that fallback blocks at most once per session and then steps aside
|
|
23
|
+
loudly, so failing closed cannot trap anyone. And one escaper serves every emission: the escaping was written
|
|
24
|
+
three times and only one of them folded newlines, so a multi-line reason — a node stack, a git message — put
|
|
25
|
+
raw newlines inside a JSON string and the harness dropped the block entirely.
|
|
26
|
+
|
|
11
27
|
The blocking stop gate, with two jobs, each holding a hard loop-break so it never blocks twice on the same cause and never lets a dishonest stop through.
|
|
12
28
|
|
|
13
29
|
The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected while the branch still carries uncommitted work or is zero commits ahead of main, because the ritual commits the spec and code BEFORE proposing. Clean work is allowed to stop; a dirty proposal blocks once with the reason, and if the agent ignores it the gate escapes by downgrading to `asking` so a false "ready to merge" can never stand. `done --propose nothing` is a porcelain trap, not a declaration: it writes no state and names merge, close, ask, and park as the real destinations; legacy `nothing` records remain readable.
|
|
@@ -17,6 +17,36 @@
|
|
|
17
17
|
# CLI read; this shell never treats runtime.json as a second lifecycle database.
|
|
18
18
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
19
19
|
S="${SPEX:-spex}"
|
|
20
|
+
# @@@ the block must survive its own text - three ways a decision was lost. (1) A `hook-prompt` failure exited
|
|
21
|
+
# non-zero, which for a Stop hook means ALLOW: the one gate whose job is to stop an undeclared stop was
|
|
22
|
+
# disarmed by its own text failing to load. (2) The escaping was written three times and only one folded
|
|
23
|
+
# newlines, so a multi-line reason put raw newlines inside a JSON string and the harness dropped the block.
|
|
24
|
+
# (3) `render` must only PRINT — a helper that emits the decision itself cannot be called inside `$( )`,
|
|
25
|
+
# because `exit` there ends the substitution and its output is captured as the caller's text. So rendering
|
|
26
|
+
# returns a status, and every decision is taken in the main shell.
|
|
27
|
+
esc_json() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}'; }
|
|
28
|
+
block_with() { printf '{"decision":"block","reason":"%s"}\n' "$(esc_json "$1")"; exit 0; }
|
|
29
|
+
render() {
|
|
30
|
+
local text
|
|
31
|
+
text=$("$@" 2>/dev/null) || return 1
|
|
32
|
+
[ -n "$text" ] || return 1
|
|
33
|
+
printf '%s' "$text"
|
|
34
|
+
}
|
|
35
|
+
# A genuinely broken CLI would also stop the agent from declaring, so the fail-closed fallback blocks at most
|
|
36
|
+
# once per session and then steps aside loudly: failing closed must not trap anyone.
|
|
37
|
+
fallback_or_allow() {
|
|
38
|
+
local once="$sdir/stop-gate-render-failed"
|
|
39
|
+
if [ -f "$once" ]; then
|
|
40
|
+
printf 'stop-gate: cannot render its own reason and has already blocked once for that; allowing this stop. Repair the spex CLI.\n' >&2
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
# the bound only works if the mark can be written: without the mkdir a store dir that does not exist yet
|
|
44
|
+
# left the sentinel uncreated, and "block at most once" quietly became "block every time"
|
|
45
|
+
mkdir -p "$sdir" 2>/dev/null || true
|
|
46
|
+
touch "$once" 2>/dev/null || true
|
|
47
|
+
block_with "undeclared stop, and stop-gate could not render its own text (the spex CLI failed). Declare the ONE true state as your LAST call: session done --propose merge | done --propose close | ask --note <what you await> | park --note <what you await>. Run it through the same CLI this project launched you with; if that CLI is broken, say so and stop."
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
input=$(cat 2>/dev/null || true)
|
|
21
51
|
sid=$(hp_session_id "$input"); [ -n "$sid" ] || exit 0
|
|
22
52
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
@@ -89,10 +119,8 @@ if [ "${status:-active}" = awaiting ] && { [ "$proposal" = merge ] || [ "$propos
|
|
|
89
119
|
$S internal session-state asking --session "$sid" --note "stopped with uncommitted work — commit your spec+code on the node branch, then re-declare done" >/dev/null 2>&1 || true
|
|
90
120
|
exit 0
|
|
91
121
|
fi
|
|
92
|
-
reason=$($S internal hook-prompt stop-gate --variant commit --reason "$gatemsg" --cli "$S" --propose "$proposal") ||
|
|
93
|
-
|
|
94
|
-
printf '{"decision":"block","reason":"%s"}\n' "$esc"
|
|
95
|
-
exit 0
|
|
122
|
+
reason=$(render $S internal hook-prompt stop-gate --variant commit --reason "$gatemsg" --cli "$S" --propose "$proposal") || fallback_or_allow
|
|
123
|
+
block_with "$reason"
|
|
96
124
|
fi
|
|
97
125
|
|
|
98
126
|
# Any other declared state (parked / error / asking / awaiting+close, plus legacy awaiting+nothing) stops.
|
|
@@ -117,30 +145,25 @@ fi
|
|
|
117
145
|
# declare-LAST discipline, and the `help session` entry that re-explains each choice's condition — every bit
|
|
118
146
|
# of the full-to-terse information gap is recoverable from the entry, none of it from memory.
|
|
119
147
|
taught="$sdir/stop-gate-taught"
|
|
148
|
+
# @@@ an artifact is a NOTE ON the demand, not a replacement for it - the artifact line used to be its own
|
|
149
|
+
# branch taken instead of the full menu, and the taught sentinel was stamped before it, so a session that had
|
|
150
|
+
# posted anything never saw the four states at all. It is also not only files: `session web add` writes
|
|
151
|
+
# web.json beside files.json, and checking one of them made a web-only artifact invisible here. So the
|
|
152
|
+
# teaching level is chosen first, and the artifact line is appended to whichever level applies.
|
|
153
|
+
artifact_note=""
|
|
154
|
+
for kind in files web; do
|
|
155
|
+
store="$sdir/$kind.json"
|
|
156
|
+
if [ -s "$store" ] && grep -qE '"[^"]+"' "$store"; then
|
|
157
|
+
artifact_note=$(render $S internal hook-prompt stop-gate --variant artifact --cli "$S") || artifact_note=""
|
|
158
|
+
break
|
|
159
|
+
fi
|
|
160
|
+
done
|
|
120
161
|
if [ -f "$taught" ]; then
|
|
121
|
-
reason=$($S internal hook-prompt stop-gate --variant terse --cli "$S") ||
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
touch "$taught" 2>/dev/null || true
|
|
127
|
-
# The full reason names the PATH-independent CLI ($S) ONCE as a shared `<CLI> session <choice>` prefix, then
|
|
128
|
-
# lists the four real states plus the `nothing` trap as a compact newline menu of bare subcommands — so the
|
|
129
|
-
# terminal output stays legible instead of repeating the long abs path per option. It EMPHASIZES that each
|
|
130
|
-
# state is a CLAIM others act on (not a box to tick to end the turn) and gives the precise APPLICATION CONDITION
|
|
131
|
-
# for each — so the agent picks the TRUE one. park is policed hardest because a false park (no real background task) reads on the
|
|
132
|
-
# board as "fine, self-resuming" when the agent actually needs the human, which is the most damaging mislabel.
|
|
133
|
-
# It ends with the ORDERING discipline — declare LAST, then stop — because a declaration followed by more
|
|
134
|
-
# tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
|
|
135
|
-
# this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
|
|
136
|
-
# kills the park->block->re-park loop at its source lives here.
|
|
137
|
-
if [ -s "$sdir/files.json" ] && grep -qE '"[^"]+"' "$sdir/files.json"; then
|
|
138
|
-
reason=$($S internal hook-prompt stop-gate --variant artifact) || exit 1
|
|
139
|
-
esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
|
|
140
|
-
printf '{"decision":"block","reason":"%s"}\n' "$esc"
|
|
141
|
-
exit 0
|
|
162
|
+
reason=$(render $S internal hook-prompt stop-gate --variant terse --cli "$S") || fallback_or_allow
|
|
163
|
+
else
|
|
164
|
+
reason=$(render $S internal hook-prompt stop-gate --variant full --cli "$S") || fallback_or_allow
|
|
165
|
+
mkdir -p "$sdir" 2>/dev/null || true
|
|
166
|
+
touch "$taught" 2>/dev/null || true
|
|
142
167
|
fi
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
printf '{"decision":"block","reason":"%s"}\n' "$esc"
|
|
146
|
-
exit 0
|
|
168
|
+
[ -n "$artifact_note" ] && reason=$(printf '%s\n\n%s' "$reason" "$artifact_note")
|
|
169
|
+
block_with "$reason"
|