@spexcode/spec-cli 0.6.6 → 0.6.8
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/bin/spex.mjs +56 -19
- package/dist/cli.js +102 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/execution-trace.d.ts +1 -0
- package/dist/execution-trace.js +2 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +20 -7
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +15 -3
- package/dist/harness.js +331 -50
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +93 -0
- package/dist/index.js +324 -22
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.js +1 -1
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +108 -15
- package/dist/sessions.js +1465 -744
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/dist/transcript-reader.d.ts +36 -0
- package/dist/transcript-reader.js +251 -0
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +6 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Mark the session errored when a turn ends on an API failure (StopFailure).
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# so it writes the right record without depending on the worktree.
|
|
2
|
+
# Mark the session errored when a turn ends on an API failure (StopFailure). The canonical lifecycle writer owns
|
|
3
|
+
# the governed-session boundary and active-only compare-and-set; this hook only decodes the native event and
|
|
4
|
+
# passes its acting session id.
|
|
6
5
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
7
6
|
payload=$(cat 2>/dev/null)
|
|
7
|
+
# an IN-PROCESS SUBAGENT's failed turn (Claude's Task tool) fires the parent's hooks with the PARENT's
|
|
8
|
+
# session_id, so without this the parent's own record would be flipped to `error` by a helper it spawned —
|
|
9
|
+
# the same defect already fixed for mark-active, and the same discriminator fixes it: the payload's own
|
|
10
|
+
# top-level agent_id stamp. A subagent's turn dying is not THIS session's turn dying.
|
|
11
|
+
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
8
12
|
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
9
|
-
sdir=$(hp_store_dir "$sid") || exit 0
|
|
10
|
-
rec="$sdir/session.json"
|
|
11
|
-
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
12
13
|
exec ${SPEX:-spex} internal session-fail --session "$sid"
|
|
@@ -12,4 +12,6 @@ code:
|
|
|
12
12
|
---
|
|
13
13
|
When a turn ends not because the agent declared but because the API itself failed, this hook structurally marks the session `error`. A failed turn is a real outcome the board must show, and without this signal the session would freeze under whatever state it last held — reading as "active" or "awaiting" long after it actually died.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**A SUBAGENT'S FAILED TURN IS NOT THIS SESSION'S FAILED TURN.** An in-process subagent (Claude's Task tool) fires the parent's hooks carrying the PARENT's `session_id`, so a helper the session spawned could flip the session that spawned it to `error` — a supervising parent marked dead by a delegate it is still supervising. The discriminator is the payload's own top-level `agent_id` stamp, the deterministic one [[mark-active]] already uses for the same reason; it is not a timing window. This is one defect class, so it gets one answer at both hooks rather than a second idea of what "this session acted" means.
|
|
16
|
+
|
|
17
|
+
It is non-blocking on the failure event: the failure already happened, so the only job is to report it truthfully. As a board-lifecycle hook it passes the payload's acting `session_id` to `spex internal session-fail --session <id>`; the canonical writer, not shell parsing of `runtime.json`, resolves the governed record and owns the live-active compare-and-set. Only an undeclared, non-stopped `active` record becomes `error`. A declaration, explicit stop, or archive that landed first remains authoritative; a late native failure never rewrites it. This one writer keeps the [[stop-gate]] family's invariant intact for every harness while each adapter retains only its native failure signal.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -u
|
|
3
|
+
|
|
4
|
+
# A self-launch project opts in only by configuring the adopter database. No configured database means no listener.
|
|
5
|
+
if [ -z "${SPEX_SESSION_DATABASE_PATH+x}" ] && [ -z "${SPEX_SESSION_CONFIG+x}" ]; then
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
# The explicit command is one executable path; an invalid explicit value must not silently fall through to PATH.
|
|
10
|
+
cli="${SPEX_SESSION_CLI:-}"
|
|
11
|
+
if [ -z "$cli" ]; then
|
|
12
|
+
cli=$(command -v spex-session 2>/dev/null || true)
|
|
13
|
+
fi
|
|
14
|
+
if [ -z "$cli" ] || [ ! -x "$cli" ]; then
|
|
15
|
+
printf '%s\n' 'session-listen: spex-session CLI not found; install @spexcode/session-selflaunch (npm install) or set SPEX_SESSION_CLI to its executable path' >&2
|
|
16
|
+
exit 2
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
for tool in awk base64 iconv od grep tail sed mktemp cat tr rm; do
|
|
20
|
+
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
21
|
+
printf "session-listen: required delivery tool '%s' is missing; install it or repair PATH before retrying\n" "$tool" >&2
|
|
22
|
+
exit 2
|
|
23
|
+
fi
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
capability_error() {
|
|
27
|
+
printf "session-listen: required delivery capability '%s' is unavailable; install compatible tooling or repair PATH before retrying\n" "$1" >&2
|
|
28
|
+
exit 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
escape_awk='
|
|
32
|
+
BEGIN { ORS = "" }
|
|
33
|
+
NR > 1 { printf "\\n" }
|
|
34
|
+
{
|
|
35
|
+
for (i = 1; i <= length($0); i++) {
|
|
36
|
+
c = substr($0, i, 1)
|
|
37
|
+
if (c == "\\") printf "\\\\"
|
|
38
|
+
else if (c == "\"") printf "\\\""
|
|
39
|
+
else if (c == "\t") printf "\\t"
|
|
40
|
+
else if (c == "\r") printf "\\r"
|
|
41
|
+
else if (c == "\b") printf "\\b"
|
|
42
|
+
else if (c == "\f") printf "\\f"
|
|
43
|
+
else printf "%s", c
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
'
|
|
47
|
+
control_pattern='(^|[[:space:]])(00|01|02|03|04|05|06|07|08|0b|0c|0e|0f|1[0-9a-f])([[:space:]]|$)'
|
|
48
|
+
body64_sed='s/.*"bodyBase64":"\([^"]*\)".*/\1/p'
|
|
49
|
+
|
|
50
|
+
# Existence is not capability. These fixed vectors prove every non-default operation before at-most-once dequeue.
|
|
51
|
+
[ "$(printf ' A ' | tr -d '[:space:]')" = A ] || capability_error 'tr -d character classes'
|
|
52
|
+
[ "$(printf A | od -An -v -tx1 | tr -d '[:space:]')" = 41 ] || capability_error 'od hexadecimal bytes'
|
|
53
|
+
[ "$(printf QQ== | base64 -d 2>/dev/null | od -An -v -tx1 | tr -d '[:space:]')" = 41 ] \
|
|
54
|
+
|| capability_error 'base64 -d exact decoding'
|
|
55
|
+
[ "$(printf '\303\251' | iconv -f UTF-8 -t UTF-8 2>/dev/null | od -An -v -tx1 | tr -d '[:space:]')" = c3a9 ] \
|
|
56
|
+
|| capability_error 'iconv UTF-8 validation'
|
|
57
|
+
printf ' 00 ' | grep -Eq "$control_pattern" || capability_error 'grep extended quiet match'
|
|
58
|
+
if printf ' 0a ' | grep -Eq "$control_pattern"; then capability_error 'grep extended quiet exclusion'; fi
|
|
59
|
+
[ "$(printf AB | tail -c 1)" = B ] || capability_error 'tail byte selection'
|
|
60
|
+
[ "$(printf '%s' '{"bodyBase64":"QQ=="}' | sed -n "$body64_sed")" = QQ== ] \
|
|
61
|
+
|| capability_error 'sed JSON field extraction'
|
|
62
|
+
[ "$(printf 'A\nB\n' | awk "$escape_awk")" = 'A\nB' ] || capability_error 'awk JSON text escaping'
|
|
63
|
+
[ "$(printf A | cat)" = A ] || capability_error 'cat byte emission'
|
|
64
|
+
|
|
65
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
66
|
+
payload=$(cat 2>/dev/null || true)
|
|
67
|
+
sid=$(hp_field "$payload" session_id)
|
|
68
|
+
[ -n "$sid" ] || exit 0
|
|
69
|
+
event=$(hp_field "$payload" hook_event_name)
|
|
70
|
+
|
|
71
|
+
case "$event" in
|
|
72
|
+
SessionStart)
|
|
73
|
+
"$cli" initialize --session-id "$sid" >/dev/null || exit 2
|
|
74
|
+
;;
|
|
75
|
+
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
|
+
}
|
|
80
|
+
escaped_file="$decoded.escaped"
|
|
81
|
+
output_file="$decoded.output"
|
|
82
|
+
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
|
|
87
|
+
fi
|
|
88
|
+
cleanup() { rm -f "$decoded" "$escaped_file" "$output_file"; }
|
|
89
|
+
trap cleanup EXIT
|
|
90
|
+
raw=$("$cli" dequeue --session-id "$sid") || exit 2
|
|
91
|
+
[ "$raw" = 'null' ] && exit 0
|
|
92
|
+
body64=$(printf '%s' "$raw" | sed -n "$body64_sed")
|
|
93
|
+
message_id=$(printf '%s' "$raw" | sed -n 's/.*"messageId":"\([^"]*\)".*/\1/p')
|
|
94
|
+
[ -n "$body64" ] || {
|
|
95
|
+
printf '%s\n' 'session-listen: spex-session dequeue returned invalid JSON (missing bodyBase64)' >&2
|
|
96
|
+
exit 2
|
|
97
|
+
}
|
|
98
|
+
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
|
|
101
|
+
fi
|
|
102
|
+
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
|
|
105
|
+
fi
|
|
106
|
+
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
|
|
109
|
+
fi
|
|
110
|
+
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
|
|
113
|
+
fi
|
|
114
|
+
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
|
|
117
|
+
fi
|
|
118
|
+
last_byte=$(tail -c 1 "$decoded" | od -An -tx1 | tr -d '[:space:]')
|
|
119
|
+
if ! {
|
|
120
|
+
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"'
|
|
121
|
+
cat "$escaped_file"
|
|
122
|
+
[ "$last_byte" = 0a ] && printf '\\n'
|
|
123
|
+
printf '"}}\n'
|
|
124
|
+
} >"$output_file"; then
|
|
125
|
+
printf 'session-listen: could not assemble harness input; messageId=%s bodyBase64=%s\n' "$message_id" "$body64" >&2
|
|
126
|
+
exit 2
|
|
127
|
+
fi
|
|
128
|
+
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
|
+
fi
|
|
132
|
+
;;
|
|
133
|
+
esac
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: session-listen
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 280
|
|
6
|
+
events:
|
|
7
|
+
- SessionStart
|
|
8
|
+
- UserPromptSubmit
|
|
9
|
+
order: 20
|
|
10
|
+
block: true
|
|
11
|
+
---
|
|
12
|
+
The backend-free self-launch message loop. On `SessionStart`, it initializes the harness's native session id in the
|
|
13
|
+
adopter protocol database; initialization is idempotent and creates no governed lifecycle record. On each
|
|
14
|
+
`UserPromptSubmit`, it performs exactly one at-most-once `spex-session dequeue` for that same native id. A message
|
|
15
|
+
body is decoded and emitted as the harness's `hookSpecificOutput.additionalContext` JSON, so the harness input seam
|
|
16
|
+
receives it without a resident process or wake-hint dependency. An empty queue is a successful no-op and emits no
|
|
17
|
+
stdout. The hook never derives a database path: the adopter CLI owns path resolution and locality checks.
|
|
18
|
+
|
|
19
|
+
Every external delivery tool and each non-default operation it supplies is proven with a fixed result vector before
|
|
20
|
+
`dequeue`, so a binary that exists but lacks or misimplements the required flag cannot consume a message. In
|
|
21
|
+
particular, `base64 -d` must decode `QQ==` to exactly the one byte `0x41`; checking only its exit status would admit a
|
|
22
|
+
shim that merely copies input. `-d` is used because GNU base64 on Linux and base64 on both fleet Macs running macOS
|
|
23
|
+
15.6.1 were measured to decode that vector correctly; this is an observed common capability, not a platform legend.
|
|
24
|
+
The opaque body
|
|
25
|
+
is decoded to a temporary file, validated as UTF-8, and rejected loudly (with its `messageId` and original
|
|
26
|
+
`bodyBase64`) when it contains NUL or other JSON-hostile control bytes. Clean text is escaped with the existing awk
|
|
27
|
+
toolchain without command substitution, preserving embedded and trailing newlines; a non-empty body can never become
|
|
28
|
+
an empty additionalContext success.
|
|
29
|
+
|
|
30
|
+
The CLI is resolved at runtime through one explicit seam: a non-empty `SPEX_SESSION_CLI` wins, otherwise PATH is
|
|
31
|
+
searched for `spex-session`. If either protocol database environment variable is configured and no CLI can be
|
|
32
|
+
resolved, the hook fails loudly with an installation or `SPEX_SESSION_CLI` repair entrypoint. With neither database
|
|
33
|
+
variable configured, the project has not adopted this capability, so the hook exits silently without trying another
|
|
34
|
+
delivery path. Only these two events are bound: startup establishes the address, and prompt submission is the natural
|
|
35
|
+
harness input seam; a configured-but-broken adopter is a blocking hook failure so dispatch cannot hide it. There is no
|
|
36
|
+
daemon, polling, retry loop, observer, governed record, or compatibility path.
|
|
@@ -10,6 +10,8 @@ Use noun-first CLI commands; `spex help` is the authoritative command map.
|
|
|
10
10
|
|
|
11
11
|
Anything a human needs to inspect, whether a file or a local webpage, goes out through `spex session files add` or
|
|
12
12
|
`spex session web add`: never paste an absolute path or `host:port`, and never start a static server yourself.
|
|
13
|
+
Put raw file evidence in a persistent directory outside the product repository by default, then run
|
|
14
|
+
`spex session files ls` before review and repair or retract every `INVALID` handoff.
|
|
13
15
|
|
|
14
16
|
When this session has a clearly running child session (`active` or `parked`), the parent is supervising rather
|
|
15
17
|
than finished: declare `park`, not `done`/`awaiting`, until the child reports a settled state.
|
|
@@ -18,6 +18,6 @@ The block text is where the declaration ritual is taught, so it is written to be
|
|
|
18
18
|
|
|
19
19
|
The clean-done eval nudge is advisory only and must never corrupt the Stop hook protocol. Claude-family hooks can receive it as `hookSpecificOutput.additionalContext`; Codex Stop allows are silent because Codex treats unsupported non-block stdout as invalid hook JSON. Blocking decisions stay shared across harnesses through `{"decision":"block"}` plus the dispatcher’s Codex stderr bridge.
|
|
20
20
|
|
|
21
|
-
Both gates act ONLY on a GOVERNED (dashboard-launched) session: the gate resolves the session's record in the global store from the payload's `session_id`, and on a non-governed (user-self-launched) record — or none — it exits 0 SILENTLY. A self-launched agent has no board to feed, so the declare-demand must never misfire on it.
|
|
21
|
+
Both gates act ONLY on a GOVERNED (dashboard-launched) session: the gate resolves the session's record in the global store from the payload's `session_id`, and on a non-governed (user-self-launched) record — or none — it exits 0 SILENTLY. A self-launched agent has no board to feed, so the declare-demand must never misfire on it. The forced-continuation fallback writes `asking` through `spex internal session-state --session <id>` — the canonical writer, with no delivery or build side effects — passing the id explicitly since there is no worktree file to read it from. Porcelain declarations remain agent-facing; the hook never invokes them to repair its own stop.
|
|
22
22
|
|
|
23
23
|
It is the enforcement edge of [[core]]: nothing leaves a session except as committed work under a truthful declaration. The freshness it reads is set by [[mark-active]].
|
|
@@ -11,28 +11,20 @@
|
|
|
11
11
|
# without inventing a completion state.
|
|
12
12
|
# $SPEX is the PATH-independent CLI invocation (abs tsx + cli) injected by settingsArg, so the gate's own
|
|
13
13
|
# auto-default AND the command it shows the agent both work even when `spex` is absent from PATH.
|
|
14
|
-
# @@@
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# misfiring on a self-launched codex/claude). cwd = the session worktree (resolves the project key + the
|
|
19
|
-
# commit-gate's git); state writes go through `$SPEX session … --session <id>` (TS owns the JSON).
|
|
14
|
+
# @@@ governed gate - the session id comes from the payload. The gate acts ONLY on a GOVERNED
|
|
15
|
+
# (dashboard-launched) session: a user-self-launched agent has no board to feed, so an undeclared stop is
|
|
16
|
+
# none of our business. Lifecycle status/proposal come from the canonical session application through one
|
|
17
|
+
# CLI read; this shell never treats runtime.json as a second lifecycle database.
|
|
20
18
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
21
19
|
S="${SPEX:-spex}"
|
|
22
20
|
input=$(cat 2>/dev/null || true)
|
|
23
21
|
sid=$(hp_session_id "$input"); [ -n "$sid" ] || exit 0
|
|
24
22
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# the single writer ([[sessions-core]]), so anchoring to the line start is what keeps a neighbouring note's
|
|
31
|
-
# escaped quote from being read as this field's value — the read half of the same rule that stops shell from
|
|
32
|
-
# ever composing the record.
|
|
33
|
-
jget() { sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\",\?$/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
34
|
-
status=$(jget status)
|
|
35
|
-
proposal=$(jget proposal)
|
|
23
|
+
# non-governed (or no record) → silently let the stop through. THIS is the self-launch fix. The CLI response
|
|
24
|
+
# is governed<TAB>status<TAB>proposal; status/proposal are canonical, while governed is identity metadata.
|
|
25
|
+
hook_state=$($S internal session-hook-state --session "$sid" 2>/dev/null) || exit 0
|
|
26
|
+
IFS=$'\t' read -r governed status proposal <<< "$hook_state"
|
|
27
|
+
[ "$governed" = 1 ] || exit 0
|
|
36
28
|
|
|
37
29
|
# the value of the payload's structured `stop_hook_active` field (true on the hook-forced continuation),
|
|
38
30
|
# read by field name rather than substring-sniffing the JSON blob. ([a-z]* captures true/false portably —
|
|
@@ -91,7 +83,10 @@ if [ "${status:-active}" = awaiting ] && { [ "$proposal" = merge ] || [ "$propos
|
|
|
91
83
|
exit 0 # work is committed and ahead of main -> the proposal is honest, let it stop.
|
|
92
84
|
fi
|
|
93
85
|
if [ "$cont" = true ]; then
|
|
94
|
-
|
|
86
|
+
# The hook is a thin boundary. Do not call the porcelain declaration here: it may trigger
|
|
87
|
+
# delivery/build work and can remain running after the harness has already accepted the stop.
|
|
88
|
+
# The internal writer is the same canonical lifecycle path, with no dispatch side effects.
|
|
89
|
+
$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
|
|
95
90
|
exit 0
|
|
96
91
|
fi
|
|
97
92
|
reason=$($S internal hook-prompt stop-gate --variant commit --reason "$gatemsg" --cli "$S" --propose "$proposal") || exit 1
|
|
@@ -106,13 +101,15 @@ fi
|
|
|
106
101
|
if [ "$cont" = true ]; then
|
|
107
102
|
# The forced continuation also stopped without declaring. Escape into asking: no default may invent a
|
|
108
103
|
# completed lane, and the stopped agent now needs a human prompt to choose merge, close, ask, or park.
|
|
109
|
-
|
|
104
|
+
# Keep this fallback inside the hook/CLI boundary. The porcelain `session ask` command can wait on
|
|
105
|
+
# delivery and workspace builds; a stop hook must settle the canonical state independently of those paths.
|
|
106
|
+
$S internal session-state asking --session "$sid" --note "auto: stopped without declaring — choose merge, close, ask, or park; done --propose nothing records no state" >/dev/null 2>&1 || true
|
|
110
107
|
exit 0
|
|
111
108
|
fi
|
|
112
109
|
|
|
113
110
|
# first stop in an undeclared state -> block. The FULL teaching text prints ONCE per session; every later
|
|
114
111
|
# undeclared stop gets a ONE-LINE version (a heavy session hits this gate 15-20x a night — re-printing the
|
|
115
|
-
# full menu each time is pure token noise). The once-sentinel is a plain file beside
|
|
112
|
+
# full menu each time is pure token noise). The once-sentinel is a plain file beside runtime.json in the
|
|
116
113
|
# session's global store dir — the same per-session-sentinel mechanism as the CLI's note-echo-taught; $sdir
|
|
117
114
|
# is already alias-resolved here, so a codex thread id lands on the same file, and an unwritable dir just
|
|
118
115
|
# teaches again (never blocks the block). The terse line must stay SELF-EXPLANATORY: an agent whose context
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: merge
|
|
3
|
+
surface: skill, command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 130
|
|
6
|
+
desc: Land this session's completed branch into the repository's source-of-truth branch as one verified no-ff merge, preserve unrelated dirty work, push it, and settle the session honestly. Use when the user says /merge, asks to merge or land this session, or when a supervisor dispatches a merge.
|
|
7
|
+
kind: mutating
|
|
8
|
+
---
|
|
9
|
+
# merge
|
|
10
|
+
|
|
11
|
+
Land the current SpexCode session's branch; do not dispatch another merge request back to yourself.
|
|
12
|
+
|
|
13
|
+
1. Inspect the current session, branch, worktree, source-of-truth checkout, and live Git status. Treat every
|
|
14
|
+
pre-existing dirty or untracked path as user-owned. Record its exact status and diff fingerprint; never
|
|
15
|
+
discard it, fold it into your commit, or hide it behind an unverified stash.
|
|
16
|
+
2. Commit this session's intended spec and code first. In this worktree, merge the latest source-of-truth
|
|
17
|
+
head into the session branch. Resolve conflicts here, then rerun the focused proof, build, `spex spec lint`,
|
|
18
|
+
and `spex eval lint --changed` required by the changed behavior.
|
|
19
|
+
3. Immediately before landing, verify
|
|
20
|
+
`git merge-base --is-ancestor <source-head> <session-head>`. If it fails, sync again. A clean textual merge
|
|
21
|
+
is not product proof.
|
|
22
|
+
4. In the source-of-truth checkout, make one `--no-ff` merge of the already-synced session tip. Do not resolve
|
|
23
|
+
conflicts there. If unrelated dirty work prevents the merge, preserve it byte-for-byte and report the exact
|
|
24
|
+
overlap rather than forcing, resetting, or committing it.
|
|
25
|
+
5. Verify the source checkout has no `MERGE_HEAD`, the session tip is its ancestor, unrelated dirty
|
|
26
|
+
fingerprints are unchanged, and the post-merge gates pass. Push the source-of-truth branch only after
|
|
27
|
+
those checks.
|
|
28
|
+
6. Stop test-owned processes and publish any evidence a human must inspect. If the work is fully landed and
|
|
29
|
+
no decision remains, run `spex session done --propose close` as the final action. Never close your own
|
|
30
|
+
session directly. If a real decision or external wake-up remains, declare the truthful alternative instead.
|
|
31
|
+
|
|
32
|
+
`spex session merge <SEL>` is the supervisor-facing dispatcher that sends this workflow to another session.
|
|
33
|
+
Inside the target session, execute the workflow above; do not call `spex session merge .` recursively.
|
|
@@ -11,9 +11,5 @@ where an agent invokes them on demand, each carrying `surface: skill`. Grouping
|
|
|
11
11
|
legible at a glance — the skill plugins on this shelf, the command presets on [[commands]], the auxiliary
|
|
12
12
|
system contracts on [[prompts]], with [[core]] a flat child beside them.
|
|
13
13
|
|
|
14
|
-
This node is a **shelf, not a surface
|
|
15
|
-
|
|
16
|
-
exactly as it would at the root. A plugin that serves BOTH surfaces — [[distill]] is skill and command —
|
|
17
|
-
shelves here by its primary (skill) identity and still gathers as a command through its field. The init
|
|
18
|
-
templates mirror this layout. The shelf stays pure presentation: moving a resident beneath it changes
|
|
19
|
-
neither that plugin's identity nor the surfaces gathered from its frontmatter.
|
|
14
|
+
This node is a **shelf, not a surface**. Its routing and relocation invariant is owned once by
|
|
15
|
+
[[.plugins]]'s shelf invariant; this node only describes skill-specific materialization and invocation.
|
|
@@ -21,3 +21,10 @@ materialized into the agent's contract.
|
|
|
21
21
|
Which plugins `spex init` ships is the init-preset rule. `seed: false` excludes a plugin subtree; shared
|
|
22
22
|
plugins have one body and one helper set — there is no separately authored adopter variant. Dogfood eval
|
|
23
23
|
scenarios/readings remain with the implementation and git history they measure.
|
|
24
|
+
|
|
25
|
+
## shelf invariant
|
|
26
|
+
|
|
27
|
+
Grouping shelves (`[[commands]]`, `[[skills]]`, `[[review]]`, and `[[prompts]]`) are presentation parents, not
|
|
28
|
+
surfaces. Discovery is recursive and field-driven, so moving a resident under a shelf changes neither its
|
|
29
|
+
identity nor the surfaces gathered from its frontmatter. Each shelf therefore states only its surface-specific
|
|
30
|
+
purpose; it must not copy this routing invariant.
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
-
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
-
# text-sniffed from the TUI:
|
|
5
|
-
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
-
# (the deterministic capture of a question).
|
|
7
|
-
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
-
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
-
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
-
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
-
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
-
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
-
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
-
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
-
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
-
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
-
payload=$(cat 2>/dev/null)
|
|
18
|
-
# an IN-PROCESS SUBAGENT's tool call (Claude's Task tool) fires the parent's hooks with the PARENT's
|
|
19
|
-
# session_id — flipping here let a supervising parent's own subagents erase its declared park/ask within
|
|
20
|
-
# seconds and race the stop-gate into "undeclared stop" (issue #60). A subagent working is not the parent
|
|
21
|
-
# agent ACTING, so its calls never touch the record; the parent's own next tool call still flips. The
|
|
22
|
-
# discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
|
|
23
|
-
# timing window.
|
|
24
|
-
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
25
|
-
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
26
|
-
sdir=$(hp_store_dir "$sid") || exit 0
|
|
27
|
-
rec="$sdir/session.json"
|
|
28
|
-
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
29
|
-
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
30
|
-
|
|
31
|
-
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
32
|
-
|
|
33
|
-
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
34
|
-
status=asking
|
|
35
|
-
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
36
|
-
else
|
|
37
|
-
status=active
|
|
38
|
-
note=
|
|
39
|
-
fi
|
|
40
|
-
|
|
41
|
-
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
42
|
-
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
43
|
-
|
|
44
|
-
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
45
|
-
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
46
|
-
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
47
|
-
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
48
|
-
tmp=$(mktemp) || exit 0
|
|
49
|
-
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
50
|
-
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
51
|
-
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
52
|
-
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
53
|
-
exit 0
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
-
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
-
# text-sniffed from the TUI:
|
|
5
|
-
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
-
# (the deterministic capture of a question).
|
|
7
|
-
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
-
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
-
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
-
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
-
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
-
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
-
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
-
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
-
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
-
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
-
payload=$(cat 2>/dev/null)
|
|
18
|
-
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
19
|
-
sdir=$(hp_store_dir "$sid") || exit 0
|
|
20
|
-
rec="$sdir/session.json"
|
|
21
|
-
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
22
|
-
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
23
|
-
|
|
24
|
-
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
25
|
-
|
|
26
|
-
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
27
|
-
status=asking
|
|
28
|
-
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
29
|
-
else
|
|
30
|
-
status=active
|
|
31
|
-
note=
|
|
32
|
-
fi
|
|
33
|
-
|
|
34
|
-
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
35
|
-
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
36
|
-
|
|
37
|
-
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
38
|
-
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
39
|
-
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
40
|
-
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
41
|
-
tmp=$(mktemp) || exit 0
|
|
42
|
-
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
43
|
-
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
44
|
-
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
45
|
-
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
46
|
-
exit 0
|