@spexcode/spec-cli 0.7.0-next.14 → 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.
@@ -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 `spex session ask --note ...`, and declare it last.',
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.14",
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.14",
39
- "@spexcode/session-selflaunch": "0.7.0-next.14",
40
- "@spexcode/spec-core": "0.7.0-next.14",
41
- "@spexcode/spec-eval": "0.7.0-next.14",
42
- "@spexcode/spec-forge": "0.7.0-next.14",
43
- "@spexcode/transcript": "0.7.0-next.14",
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
- printf '%s\n' 'session-listen: could not allocate a temporary body file; repair TMPDIR and retry' >&2
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
- rm -f "$escaped_file" "$output_file"
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") || exit 2
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 '%s\n' 'session-listen: spex-session dequeue returned invalid JSON (missing bodyBase64)' >&2
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 '%s\n' 'session-listen: spex-session dequeue returned invalid bodyBase64' >&2
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: refusing non-UTF-8 body; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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: refusing control-byte body; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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: could not encode body for harness input; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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: non-empty body encoded to empty additionalContext; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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: could not assemble harness input; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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: could not emit harness input; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
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 READ gate. It advances only when the adapter resolves a read path AND
3
- # the spec graph resolves a real `code:` governor for that path. Irrelevant tools, unresolvable reads, and
4
- # uncovered/related-only files leave the sentinel absent, so any number of ungoverned reads cannot mute the
5
- # first later governed read. That read spends the gate and blocks once with its actual governor; retries pass.
6
- # @@@ event vs matcher - materialized shims bind PreToolUse event-wide on every harness. hp_code_path ... read
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" read)
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 READ gate. Its state advances only when the agent is about to read code that has a real governor (`code:` ownership). The first such read 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 reads pass.
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 reads remain allowed without consuming or muting the gate; a later governed read must still block. 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.
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
- 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 `read` matcher decides whether the payload represents a file read 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.
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") || exit 1
93
- esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
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") || exit 1
122
- esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
123
- printf '{"decision":"block","reason":"%s"}\n' "$esc"
124
- exit 0
125
- fi
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
- reason=$($S internal hook-prompt stop-gate --variant full --cli "$S") || exit 1
144
- esc=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}')
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"