@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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { claudeTranscriptPath, codexRolloutPath } from './execution-trace.js';
|
|
4
|
+
export class TranscriptReadError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.name = 'TranscriptReadError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_TURNS = 200;
|
|
13
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
14
|
+
const POST_RANGE_LOOKAHEAD_LINES = 256;
|
|
15
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
16
|
+
const items = (value) => Array.isArray(value) ? value : [];
|
|
17
|
+
const string = (value) => typeof value === 'string' && value.trim() ? value : null;
|
|
18
|
+
const idOf = (value) => {
|
|
19
|
+
if (!value)
|
|
20
|
+
return null;
|
|
21
|
+
for (const key of ['id', 'uuid', 'message_id', 'messageId', 'call_id', 'callId', 'client_id', 'clientId']) {
|
|
22
|
+
const found = string(value[key]);
|
|
23
|
+
if (found)
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
const timestamp = (value) => {
|
|
29
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
30
|
+
return value;
|
|
31
|
+
if (typeof value !== 'string' || !value.trim())
|
|
32
|
+
return null;
|
|
33
|
+
const numeric = Number(value);
|
|
34
|
+
if (Number.isFinite(numeric))
|
|
35
|
+
return numeric;
|
|
36
|
+
const parsed = Date.parse(value);
|
|
37
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
38
|
+
};
|
|
39
|
+
const at = (value) => {
|
|
40
|
+
if (!value)
|
|
41
|
+
return null;
|
|
42
|
+
for (const key of ['timestamp', 'created_at', 'createdAt', 'created', 'time']) {
|
|
43
|
+
const candidate = timestamp(value[key]);
|
|
44
|
+
if (candidate !== null)
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
const compact = (value) => {
|
|
50
|
+
if (typeof value === 'string')
|
|
51
|
+
return value;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.stringify(value) ?? String(value);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return String(value);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const lineCount = (value) => value ? value.split(/\r?\n/).length : 0;
|
|
60
|
+
function claudeEvent(value) {
|
|
61
|
+
const entry = object(value);
|
|
62
|
+
const message = object(entry?.message);
|
|
63
|
+
if (!entry || !message)
|
|
64
|
+
return null;
|
|
65
|
+
const eventAt = at(entry) ?? at(message);
|
|
66
|
+
if (eventAt === null)
|
|
67
|
+
return { at: null, turn: null };
|
|
68
|
+
if (entry.type === 'user' && message.role === 'user') {
|
|
69
|
+
const blocks = items(message.content);
|
|
70
|
+
const text = typeof message.content === 'string'
|
|
71
|
+
? string(message.content)
|
|
72
|
+
: blocks.map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
|
|
73
|
+
const outputs = blocks.flatMap((block) => {
|
|
74
|
+
const b = object(block);
|
|
75
|
+
const id = string(b?.tool_use_id);
|
|
76
|
+
return b?.type === 'tool_result' && id ? [{ id, text: compact(b?.content ?? '') }] : [];
|
|
77
|
+
});
|
|
78
|
+
if (outputs.length)
|
|
79
|
+
return { at: eventAt, turn: null, toolOutputs: outputs };
|
|
80
|
+
if (text)
|
|
81
|
+
return { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } };
|
|
82
|
+
}
|
|
83
|
+
if (entry.type === 'assistant' && message.role === 'assistant') {
|
|
84
|
+
const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
85
|
+
for (const blockValue of items(message.content)) {
|
|
86
|
+
const block = object(blockValue);
|
|
87
|
+
if (block?.type === 'text')
|
|
88
|
+
turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
|
|
89
|
+
if (block?.type === 'tool_use') {
|
|
90
|
+
const id = string(block.id) ?? `tool-${turn.tools.length}`;
|
|
91
|
+
turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.input === undefined ? undefined : compact(block.input), outputLines: 0, outputBytes: 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { at: eventAt, turn };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function codexEvent(value) {
|
|
99
|
+
const entry = object(value);
|
|
100
|
+
const payload = object(entry?.payload);
|
|
101
|
+
if (!entry || !payload)
|
|
102
|
+
return null;
|
|
103
|
+
const eventAt = at(payload) ?? at(entry);
|
|
104
|
+
if (eventAt === null)
|
|
105
|
+
return { at: null, turn: null };
|
|
106
|
+
const type = string(payload.type);
|
|
107
|
+
if ((entry.type === 'event_msg' && type === 'user_message')
|
|
108
|
+
|| (entry.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')) {
|
|
109
|
+
const text = typeof payload.message === 'string' ? payload.message : compact(payload.message ?? payload.content ?? '');
|
|
110
|
+
return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
111
|
+
}
|
|
112
|
+
if (entry.type === 'event_msg' && type === 'agent_message' && payload.phase === 'commentary') {
|
|
113
|
+
const text = string(payload.message ?? payload.text);
|
|
114
|
+
return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', text, tools: [] } } : null;
|
|
115
|
+
}
|
|
116
|
+
if (entry.type === 'response_item' && (type === 'custom_tool_call' || type === 'function_call')) {
|
|
117
|
+
const id = string(payload.call_id ?? payload.id) ?? 'tool';
|
|
118
|
+
return { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [{ id, name: string(payload.name ?? payload.tool_name) ?? 'tool', input: payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments), outputLines: 0, outputBytes: 0 }] } };
|
|
119
|
+
}
|
|
120
|
+
if (entry.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
|
|
121
|
+
const id = string(payload.call_id ?? payload.id);
|
|
122
|
+
const output = payload.output ?? payload.result ?? '';
|
|
123
|
+
return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] } : null;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
function sourcePath(harness, threadId) {
|
|
128
|
+
if (harness === 'claude' || harness === 'claude-headless')
|
|
129
|
+
return claudeTranscriptPath(threadId);
|
|
130
|
+
if (harness === 'codex' || harness === 'codex-headless')
|
|
131
|
+
return codexRolloutPath(threadId);
|
|
132
|
+
throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`);
|
|
133
|
+
}
|
|
134
|
+
export async function readTranscript(harness, threadId, range) {
|
|
135
|
+
const path = sourcePath(harness, threadId);
|
|
136
|
+
if (!path)
|
|
137
|
+
throw new TranscriptReadError('missing', `${harness} transcript for ${threadId} is unavailable: file was not found`);
|
|
138
|
+
let size = 0;
|
|
139
|
+
try {
|
|
140
|
+
size = statSync(path).size;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
throw new TranscriptReadError('unreadable', `${harness} transcript is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
}
|
|
145
|
+
if (size <= 0)
|
|
146
|
+
throw new TranscriptReadError('unreadable', `${harness} transcript is unreadable: file is empty`);
|
|
147
|
+
const parse = harness === 'claude' || harness === 'claude-headless' ? claudeEvent : codexEvent;
|
|
148
|
+
const turns = [];
|
|
149
|
+
const byTool = new Map();
|
|
150
|
+
let sawTimestamp = false;
|
|
151
|
+
let omittedTurns = 0;
|
|
152
|
+
let omittedBytes = 0;
|
|
153
|
+
let outOfOrderEvents = 0;
|
|
154
|
+
let lookingPastRange = false;
|
|
155
|
+
let postRangeLines = 0;
|
|
156
|
+
const input = createReadStream(path, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
|
157
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
158
|
+
try {
|
|
159
|
+
for await (const line of lines) {
|
|
160
|
+
if (!line.trim())
|
|
161
|
+
continue;
|
|
162
|
+
let value;
|
|
163
|
+
try {
|
|
164
|
+
value = JSON.parse(line);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new TranscriptReadError('invalid', `${harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
const event = parse(value);
|
|
170
|
+
const stopAfterLine = lookingPastRange && ++postRangeLines >= POST_RANGE_LOOKAHEAD_LINES;
|
|
171
|
+
if (!event) {
|
|
172
|
+
if (stopAfterLine)
|
|
173
|
+
break;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const eventAt = event.at;
|
|
177
|
+
const hasTimestamp = eventAt !== null;
|
|
178
|
+
if (hasTimestamp)
|
|
179
|
+
sawTimestamp = true;
|
|
180
|
+
if (!lookingPastRange && hasTimestamp && eventAt > range.to) {
|
|
181
|
+
lookingPastRange = true;
|
|
182
|
+
}
|
|
183
|
+
else if (lookingPastRange && hasTimestamp && eventAt <= range.to) {
|
|
184
|
+
outOfOrderEvents++;
|
|
185
|
+
}
|
|
186
|
+
if (hasTimestamp && eventAt >= range.from && eventAt <= range.to) {
|
|
187
|
+
if (event.toolOutputs) {
|
|
188
|
+
for (const output of event.toolOutputs) {
|
|
189
|
+
const bytes = Buffer.byteLength(output.text);
|
|
190
|
+
const tool = byTool.get(output.id);
|
|
191
|
+
if (!tool) {
|
|
192
|
+
omittedBytes += bytes;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
tool.outputBytes += bytes;
|
|
196
|
+
tool.outputLines += lineCount(output.text);
|
|
197
|
+
if (tool.output === undefined)
|
|
198
|
+
tool.output = '';
|
|
199
|
+
const remaining = Math.max(0, MAX_OUTPUT_BYTES - Buffer.byteLength(tool.output));
|
|
200
|
+
tool.output += output.text.slice(0, remaining);
|
|
201
|
+
if (bytes > remaining)
|
|
202
|
+
omittedBytes += bytes - remaining;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (event.turn) {
|
|
206
|
+
if (turns.length >= MAX_TURNS)
|
|
207
|
+
omittedTurns++;
|
|
208
|
+
else {
|
|
209
|
+
turns.push(event.turn);
|
|
210
|
+
for (const tool of event.turn.tools)
|
|
211
|
+
byTool.set(tool.id, tool);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (stopAfterLine)
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error instanceof TranscriptReadError)
|
|
221
|
+
throw error;
|
|
222
|
+
throw new TranscriptReadError('unreadable', `${harness} transcript could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
input.destroy();
|
|
226
|
+
}
|
|
227
|
+
if (!sawTimestamp)
|
|
228
|
+
throw new TranscriptReadError('invalid', `${harness} transcript has no reliable timestamps; interval reads are unavailable`);
|
|
229
|
+
const normalized = turns.map((turn) => ({
|
|
230
|
+
...turn,
|
|
231
|
+
tools: turn.tools.length ? turn.tools.map((tool) => ({ ...tool })) : undefined,
|
|
232
|
+
}));
|
|
233
|
+
return {
|
|
234
|
+
from: range.from,
|
|
235
|
+
to: range.to,
|
|
236
|
+
turns: normalized,
|
|
237
|
+
truncated: omittedTurns > 0 || omittedBytes > 0 || outOfOrderEvents > 0,
|
|
238
|
+
omittedTurns,
|
|
239
|
+
omittedBytes,
|
|
240
|
+
outOfOrderEvents,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export async function readClaudeTranscript(threadId, range) {
|
|
244
|
+
return readTranscript('claude', threadId, range);
|
|
245
|
+
}
|
|
246
|
+
export async function readCodexTranscript(threadId, range) {
|
|
247
|
+
return readTranscript('codex', threadId, range);
|
|
248
|
+
}
|
|
249
|
+
export async function unsupportedTranscriptReader(harness, _threadId, _range) {
|
|
250
|
+
throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`);
|
|
251
|
+
}
|
package/hooks/dispatch.sh
CHANGED
|
@@ -16,27 +16,23 @@
|
|
|
16
16
|
# session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .plugins edits are
|
|
17
17
|
# git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
|
|
18
18
|
set -u
|
|
19
|
-
# args: `<harness> <Event>`.
|
|
20
|
-
#
|
|
21
|
-
# keeps working. `plugin` is the bundle form ([[plugin-harness]]), `opencode` the generated event-bus plugin
|
|
19
|
+
# args: `<harness> <Event>`. The harness id is explicit. `plugin` is the bundle form ([[plugin-harness]]),
|
|
20
|
+
# `opencode` the generated event-bus plugin
|
|
22
21
|
# ([[opencode-harness]]), `pi` the generated extension ([[pi-harness]]), and `zcode` the native adapter: all four
|
|
23
22
|
# carry Claude-shaped payloads (Claude tool names + file_path), so they join the claude branch in harness.sh via
|
|
24
23
|
# the default case — no parse arm of their own.
|
|
25
24
|
harness=claude
|
|
26
|
-
case "${1:-}" in claude|codex|opencode|pi|zcode|plugin) harness="$1"; shift ;;
|
|
25
|
+
case "${1:-}" in claude|codex|opencode|pi|zcode|plugin) harness="$1"; shift ;; *)
|
|
26
|
+
printf 'dispatch.sh: missing or unknown harness id\n' >&2
|
|
27
|
+
exit 64
|
|
28
|
+
;; esac
|
|
27
29
|
event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
28
30
|
export SPEXCODE_HARNESS="$harness"
|
|
29
31
|
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
30
32
|
# source it here too for hp_runtime_dir (the per-project store dir).
|
|
31
33
|
hook_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
-
tool_root="$(cd "$hook_root/.." && pwd)"
|
|
33
34
|
export SPEXCODE_HARNESS_LIB="$hook_root/harness.sh"
|
|
34
35
|
. "$SPEXCODE_HARNESS_LIB"
|
|
35
|
-
if [ -n "${SPEX:-}" ]; then
|
|
36
|
-
read -r -a spex_cmd <<< "$SPEX"
|
|
37
|
-
else
|
|
38
|
-
spex_cmd=("$tool_root/bin/spex.mjs")
|
|
39
|
-
fi
|
|
40
36
|
proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
41
37
|
# the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
|
|
42
38
|
# NOT the worktree — and per tree, so a dispatch can only read the manifest of the tree it fires in
|
|
@@ -46,8 +42,7 @@ rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
|
46
42
|
slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
|
|
47
43
|
|
|
48
44
|
# A project transport can outlive the tree that installed it. The current tree's last successful materialize
|
|
49
|
-
# is the authority for whether its events are active.
|
|
50
|
-
# shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
|
|
45
|
+
# is the authority for whether its events are active. A tree without a published selection is inert.
|
|
51
46
|
allowed="$slot/harnesses"
|
|
52
47
|
if [ -f "$allowed" ]; then
|
|
53
48
|
grep -Fxq "$harness" "$allowed" || exit 0
|
|
@@ -56,17 +51,8 @@ elif [ -f "$rt/harness-selection-v1" ]; then
|
|
|
56
51
|
fi
|
|
57
52
|
|
|
58
53
|
# --- dispatch ---------------------------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
else
|
|
62
|
-
# migration window: a tree last materialized by a pre-slot toolchain has no slot until its next git-native
|
|
63
|
-
# anchor — fall back to the legacy global manifest (its exact pre-migration behavior) so no hook (the
|
|
64
|
-
# Stop gate included) silently no-ops. The legacy file is never written again; the next anchor plants the
|
|
65
|
-
# slot and this branch goes dead.
|
|
66
|
-
manifest="$slot/hooks-manifest"
|
|
67
|
-
[ -f "$manifest" ] || manifest="$rt/hooks-manifest"
|
|
68
|
-
fi
|
|
69
|
-
[ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
|
|
54
|
+
manifest="${SPEX_HOOK_MANIFEST:-$slot/hooks-manifest}"
|
|
55
|
+
[ -f "$manifest" ] || { printf 'dispatch.sh: current tree has no hook manifest\n' >&2; exit 78; }
|
|
70
56
|
input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
|
|
71
57
|
err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
|
|
72
58
|
cleanup() { rm -f "$err"; }
|
|
@@ -78,14 +64,6 @@ rc=0
|
|
|
78
64
|
while IFS=$'\t' read -r ev order block script; do
|
|
79
65
|
[ "$ev" = "$event" ] || continue
|
|
80
66
|
handler="$proj/$script"
|
|
81
|
-
# A seeded core hook is tracked project source, so package replacement cannot safely overwrite it. These
|
|
82
|
-
# byte-exact default revisions compose an ask note into JSON with sed; route only them to the package
|
|
83
|
-
# implementation. `cmp` makes a user-modified hook ineligible without a platform-specific hash utility.
|
|
84
|
-
if [ "$script" = '.spec/project/.plugins/core/mark-active/mark-active.sh' ] &&
|
|
85
|
-
{ cmp -s "$handler" "$hook_root/compat/mark-active-sed-v0.fixture" ||
|
|
86
|
-
cmp -s "$handler" "$hook_root/compat/mark-active-0.5.2-eef1.fixture"; }; then
|
|
87
|
-
handler="$tool_root/templates/spec/project/.plugins/core/mark-active/mark-active.sh"
|
|
88
|
-
fi
|
|
89
67
|
out="$(printf '%s' "$input" | bash "$handler" 2>"$err")"; code=$?
|
|
90
68
|
[ -n "$out" ] && printf '%s' "$out"
|
|
91
69
|
if [ "$block" = "true" ] && { [ "$code" = "2" ] || printf '%s' "$out" | grep -q '"decision"[[:space:]]*:[[:space:]]*"block"'; }; then
|
|
@@ -101,5 +79,15 @@ while IFS=$'\t' read -r ev order block script; do
|
|
|
101
79
|
fi
|
|
102
80
|
rc=2
|
|
103
81
|
fi
|
|
82
|
+
# FAIL LOUD. A non-blocking handler's failure used to vanish completely: its exit code was dropped and its
|
|
83
|
+
# stderr was overwritten by the next handler and deleted on exit, so a lifecycle hook that could not write
|
|
84
|
+
# left NO trace anywhere — the board kept whatever state it last held and the reader had to guess whether
|
|
85
|
+
# the hook had run at all. That silence is what let a whole fleet's mark-active and stop-gate die unnoticed.
|
|
86
|
+
# Reporting is all this does: a non-blocking hook must not change the dispatch verdict, so `rc` stays the
|
|
87
|
+
# blocking handlers' to set, and a noisy hook can never turn into a gate.
|
|
88
|
+
if [ "$code" != 0 ] && [ "$block" != "true" ]; then
|
|
89
|
+
printf 'dispatch.sh: %s handler %s exited %s\n' "$event" "$script" "$code" >&2
|
|
90
|
+
[ -s "$err" ] && cat "$err" >&2
|
|
91
|
+
fi
|
|
104
92
|
done < "$manifest"
|
|
105
93
|
exit "$rc"
|
package/hooks/harness.sh
CHANGED
|
@@ -84,8 +84,8 @@ hp_session_id() {
|
|
|
84
84
|
codex) hp_field "$1" session_id ;;
|
|
85
85
|
*) pid=$(hp_field "$1" session_id)
|
|
86
86
|
if [ -n "$pid" ] && [ -n "$SPEXCODE_SESSION_ID" ] && [ "$pid" != "$SPEXCODE_SESSION_ID" ] \
|
|
87
|
-
&& [ ! -e "$(hp_store_dir "$pid")/
|
|
88
|
-
&& [ -e "$(hp_store_dir "$SPEXCODE_SESSION_ID")/
|
|
87
|
+
&& [ ! -e "$(hp_store_dir "$pid")/runtime.json" ] \
|
|
88
|
+
&& [ -e "$(hp_store_dir "$SPEXCODE_SESSION_ID")/runtime.json" ]; then
|
|
89
89
|
printf '%s' "$SPEXCODE_SESSION_ID"
|
|
90
90
|
else
|
|
91
91
|
printf '%s' "${pid:-$SPEXCODE_SESSION_ID}"
|
|
@@ -139,19 +139,19 @@ hp_tree_dir() {
|
|
|
139
139
|
# payload session_id on codex: the codex THREAD id, NOT the SpexCode record id the dir is keyed by. So when no
|
|
140
140
|
# record sits at <id> directly, find the one record that captured this id as `harness_session_id` (the backend
|
|
141
141
|
# stored it at thread/start, before the first tool turn).
|
|
142
|
-
# A grep over the few
|
|
142
|
+
# A grep over the few runtime.json files — no jq on the hot path; the trailing quote anchors the value so a
|
|
143
143
|
# thread id can't match a longer one as a prefix. Direct hit wins; a miss with no alias echoes the direct path
|
|
144
144
|
# unchanged, so the caller's `[ -e "$rec" ]` still no-ops gracefully. Mirrors layout.ts `readAliasedRawRecord`.
|
|
145
145
|
hp_store_dir() {
|
|
146
146
|
local rd; rd=$(hp_runtime_dir) || return 1
|
|
147
147
|
local direct="$rd/sessions/$1"
|
|
148
|
-
if [ -e "$direct/
|
|
148
|
+
if [ -e "$direct/runtime.json" ]; then printf '%s' "$direct"; return 0; fi
|
|
149
149
|
# same two-halves-of-absence rule as layout.ts's readAliasedRecordEntry: an id owning a store dir is already
|
|
150
150
|
# one of ours, so its emptiness is settled and the alias grep is the wrong question (and a whole-store scan).
|
|
151
151
|
if [ -d "$direct" ]; then printf '%s' "$direct"; return 0; fi
|
|
152
152
|
local hit
|
|
153
|
-
hit=$(grep -lF "\"harness_session_id\": \"$1\"" "$rd"/sessions/*/
|
|
154
|
-
[ -n "$hit" ] && { printf '%s' "${hit%/
|
|
153
|
+
hit=$(grep -lF "\"harness_session_id\": \"$1\"" "$rd"/sessions/*/runtime.json 2>/dev/null | head -1)
|
|
154
|
+
[ -n "$hit" ] && { printf '%s' "${hit%/runtime.json}"; return 0; }
|
|
155
155
|
printf '%s' "$direct"
|
|
156
156
|
}
|
|
157
157
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
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": {
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
],
|
|
22
22
|
"bundledDependencies": [
|
|
23
23
|
"@spexcode/spec-core",
|
|
24
|
-
"@spexcode/session-core",
|
|
25
24
|
"@spexcode/spec-eval",
|
|
26
25
|
"@spexcode/spec-forge"
|
|
27
26
|
],
|
|
@@ -36,10 +35,11 @@
|
|
|
36
35
|
"test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
|
|
37
36
|
},
|
|
38
37
|
"dependencies": {
|
|
39
|
-
"@spexcode/session-
|
|
40
|
-
"@spexcode/
|
|
41
|
-
"@spexcode/spec-
|
|
42
|
-
"@spexcode/spec-
|
|
38
|
+
"@spexcode/session-application": "0.6.8",
|
|
39
|
+
"@spexcode/session-selflaunch": "0.6.8",
|
|
40
|
+
"@spexcode/spec-core": "0.6.8",
|
|
41
|
+
"@spexcode/spec-eval": "0.6.8",
|
|
42
|
+
"@spexcode/spec-forge": "0.6.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^20.16.0",
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
[ "${SPEXCODE_DEFER_FOOTPRINT_REFRESH:-}" = "session-create" ] && exit 0
|
|
16
16
|
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
17
17
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
18
|
-
if
|
|
19
|
-
spex internal refresh-footprint >/dev/null 2>&1 || true
|
|
18
|
+
if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then
|
|
19
|
+
"$repo_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
|
|
20
20
|
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
21
21
|
"$repo_root/node_modules/.bin/spex" internal refresh-footprint >/dev/null 2>&1 || true
|
|
22
|
+
elif command -v spex >/dev/null 2>&1; then
|
|
23
|
+
spex internal refresh-footprint >/dev/null 2>&1 || true
|
|
22
24
|
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
23
25
|
"$main_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
|
|
24
26
|
fi
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
14
14
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
15
15
|
spex_run() {
|
|
16
|
-
if
|
|
16
|
+
if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then "$repo_root/spec-cli/bin/spex.mjs" "$@"
|
|
17
17
|
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then "$repo_root/node_modules/.bin/spex" "$@"
|
|
18
|
+
elif command -v spex >/dev/null 2>&1; then spex "$@"
|
|
18
19
|
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then "$main_root/spec-cli/bin/spex.mjs" "$@"
|
|
19
20
|
else return 127; fi
|
|
20
21
|
}
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
13
13
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
14
14
|
spex_kind=
|
|
15
|
-
if
|
|
16
|
-
spex_kind=
|
|
15
|
+
if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then
|
|
16
|
+
spex_kind=pkg
|
|
17
17
|
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
18
18
|
spex_kind=local
|
|
19
|
+
elif command -v spex >/dev/null 2>&1; then
|
|
20
|
+
spex_kind=path
|
|
19
21
|
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
20
22
|
spex_kind=pkg
|
|
21
23
|
fi
|
|
@@ -23,7 +25,7 @@ spex_cli() {
|
|
|
23
25
|
case "$spex_kind" in
|
|
24
26
|
path) spex "$@" ;;
|
|
25
27
|
local) "$repo_root/node_modules/.bin/spex" "$@" ;;
|
|
26
|
-
pkg) "$main_root/spec-cli/bin/spex.mjs" "$@" ;;
|
|
28
|
+
pkg) if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then "$repo_root/spec-cli/bin/spex.mjs" "$@"; else "$main_root/spec-cli/bin/spex.mjs" "$@"; fi ;;
|
|
27
29
|
*) return 127 ;;
|
|
28
30
|
esac
|
|
29
31
|
}
|
|
@@ -65,10 +65,12 @@ fi
|
|
|
65
65
|
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
66
66
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
67
67
|
spex_kind=
|
|
68
|
-
if
|
|
69
|
-
spex_kind=
|
|
68
|
+
if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then
|
|
69
|
+
spex_kind=pkg
|
|
70
70
|
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
71
71
|
spex_kind=local
|
|
72
|
+
elif command -v spex >/dev/null 2>&1; then
|
|
73
|
+
spex_kind=path
|
|
72
74
|
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
73
75
|
spex_kind=pkg
|
|
74
76
|
fi
|
|
@@ -76,7 +78,7 @@ spex_cli() {
|
|
|
76
78
|
case "$spex_kind" in
|
|
77
79
|
path) spex "$@" ;;
|
|
78
80
|
local) "$repo_root/node_modules/.bin/spex" "$@" ;;
|
|
79
|
-
pkg) "$main_root/spec-cli/bin/spex.mjs" "$@" ;;
|
|
81
|
+
pkg) if [ -x "$repo_root/spec-cli/bin/spex.mjs" ]; then "$repo_root/spec-cli/bin/spex.mjs" "$@"; else "$main_root/spec-cli/bin/spex.mjs" "$@"; fi ;;
|
|
80
82
|
*) return 127 ;;
|
|
81
83
|
esac
|
|
82
84
|
}
|
|
@@ -21,10 +21,5 @@ resolution. A preset with `{{targets}}` always receives the resolved target bloc
|
|
|
21
21
|
gets a target block only when the invocation actually names a target, so a targetless utility remains a small
|
|
22
22
|
prompt. An unknown leading `/name` stays ordinary prompt text and is never swallowed or guessed.
|
|
23
23
|
|
|
24
|
-
This node is a **shelf, not a surface
|
|
25
|
-
|
|
26
|
-
exactly as it would at the root — the gather set is path-independent, so shelving a command changes
|
|
27
|
-
nothing about what `/api/plugins` and the launcher offer. A plugin that serves BOTH surfaces (e.g.
|
|
28
|
-
[[distill]], skill and command) shelves once by its primary identity, never duplicated. The init
|
|
29
|
-
templates mirror this layout. The shelf stays pure presentation: moving a resident beneath it changes
|
|
30
|
-
neither that plugin's identity nor the surfaces gathered from its frontmatter.
|
|
24
|
+
This node is a **shelf, not a surface**. Its routing and relocation invariant is owned once by
|
|
25
|
+
[[.plugins]]'s shelf invariant; this node only describes command-specific discovery and invocation.
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# On an idle_prompt notification,
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# project — see hp_store_dir); the id is passed to the cli via `--session` so it writes the right record
|
|
7
|
-
# without depending on the worktree (which no longer holds any session file). NOTE the Notification event is
|
|
8
|
-
# Claude-only ([[harness-adapter]]: Codex fires no Notification), so this never runs under Codex.
|
|
2
|
+
# On an idle_prompt notification, ask the canonical lifecycle writer to infer idle. The writer owns both the
|
|
3
|
+
# governed-session boundary and the active-only compare-and-set; this hook only decodes the native event and
|
|
4
|
+
# passes its acting session id. NOTE the Notification event is Claude-only ([[harness-adapter]]: Codex fires no
|
|
5
|
+
# Notification), so this never runs under Codex.
|
|
9
6
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
10
7
|
payload=$(cat 2>/dev/null)
|
|
11
8
|
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
12
|
-
sdir=$(hp_store_dir "$sid") || exit 0
|
|
13
|
-
rec="$sdir/session.json"
|
|
14
|
-
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
15
9
|
[ "$(hp_notification_type "$payload")" = idle_prompt ] && exec ${SPEX:-spex} internal session-idle --session "$sid"
|
|
@@ -10,4 +10,4 @@ block: false
|
|
|
10
10
|
---
|
|
11
11
|
Catches the undeclared stop the [[stop-gate]] misses. When the harness signals — via an idle-prompt notification — that the agent is simply sitting idle at its prompt rather than working, this hook marks the session `idle`, so a session that quietly ran out of things to do is not left reading as active on the board.
|
|
12
12
|
|
|
13
|
-
It acts only on the idle-prompt notification, ignoring every other notification kind. As a board-lifecycle hook it
|
|
13
|
+
It acts only on the idle-prompt notification, ignoring every other notification kind. As a board-lifecycle hook it passes the payload's acting `session_id` to `spex internal session-idle --session <id>`; the canonical writer owns governed-session resolution and returns a no-op for self-launched or missing records. It is guarded so it never clobbers a deliberate declaration: marking idle applies only to a session still in the undeclared `active` state, leaving any considered `awaiting`, `asking`, `parked`, or `error` claim untouched. Together with [[stop-gate]] and [[session-fail]] it closes the last gap where a session could stop without its true state reaching the board.
|
|
@@ -13,15 +13,15 @@
|
|
|
13
13
|
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
14
14
|
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
15
15
|
# the next real tool flips back to active, forcing a fresh Stop-gate declaration.
|
|
16
|
-
# @@@
|
|
17
|
-
#
|
|
18
|
-
# is the
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
16
|
+
# @@@ one writer - this hook is on the hot path (every tool call), but it must not inspect runtime.json to
|
|
17
|
+
# decide whether a transition is needed. That file is only a runtime/worktree envelope; the canonical
|
|
18
|
+
# session application is the lifecycle authority. The structured writer is idempotent for an unchanged state,
|
|
19
|
+
# so every eligible event goes through the same writer and cannot short-circuit on a second fact. It never
|
|
20
|
+
# edits runtime.json itself: an asking note is arbitrary prose, and shell substitution is not a record writer
|
|
21
|
+
# ([[sessions-core]]).
|
|
22
|
+
# @@@ global store - the lifecycle state lives in the canonical session application, keyed by the harness
|
|
23
|
+
# session_id, grouped per-project (see hp_store_dir). The sibling runtime.json is only the runtime/worktree
|
|
24
|
+
# envelope. GATED on `governed`: a user-self-launched
|
|
25
25
|
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
26
26
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
27
27
|
payload=$(cat 2>/dev/null)
|
|
@@ -32,20 +32,20 @@ payload=$(cat 2>/dev/null)
|
|
|
32
32
|
# discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
|
|
33
33
|
# timing window.
|
|
34
34
|
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
35
|
+
# Managed watch deliveries are supervision messages, not work performed by this session. They arrive through
|
|
36
|
+
# the harness's ordinary UserPromptSubmit seam, so the freshness hook must recognize the protocol's exact
|
|
37
|
+
# prefix before treating that seam as a human re-entry. This is deliberately a prefix check, not a broad
|
|
38
|
+
# text heuristic: only the canonical `[spex watch] ` wire form is exempt; ordinary prompts and all tools still
|
|
39
|
+
# mark active.
|
|
40
|
+
if [ "$(hp_field "$payload" hook_event_name)" = "UserPromptSubmit" ]; then
|
|
41
|
+
case "$(hp_field "$payload" prompt)" in
|
|
42
|
+
"[spex watch] "*) exit 0 ;;
|
|
43
|
+
esac
|
|
44
|
+
fi
|
|
35
45
|
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
39
|
-
grep -q '^[[:space:]]*"governed"[[:space:]]*:[[:space:]]*true,\?$' "$rec" 2>/dev/null || exit 0
|
|
40
|
-
|
|
41
|
-
# does FIELD's line hold exactly VALUE? The record is written one-field-per-line by the single writer
|
|
42
|
-
# (sessions.ts writeRecord), so a whole-line match is exact — and, unlike a value regex, it cannot be fooled
|
|
43
|
-
# by an escaped quote inside a neighbouring note.
|
|
44
|
-
jline_is() { grep -q "^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"$2\",\?$" "$rec" 2>/dev/null; }
|
|
45
|
-
|
|
46
|
-
# The writer's own stdout is a human confirmation, not hook output — swallow it so a PreToolUse handler never
|
|
47
|
-
# emits a decision-shaped line; its stderr (a refusal — a corrupt or retired record) still surfaces. We always
|
|
48
|
-
# exit 0: this hook observes freshness, it is not a gate on the tool that triggered it.
|
|
46
|
+
# The canonical writer owns governed/lifecycle validation. The hook must not inspect runtime.json: that file is
|
|
47
|
+
# a runtime envelope, and using it as a gate is how old/missing envelopes silently disabled mark-active.
|
|
48
|
+
# The writer's stdout is a human confirmation, not hook output; stderr remains visible for real refusals.
|
|
49
49
|
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
50
50
|
# first question's text → the note (best-effort). It is passed as ONE argv word to the writer, so quotes,
|
|
51
51
|
# backslashes, newlines, and non-ASCII reach the record intact — no shell ever composes the JSON.
|
|
@@ -53,7 +53,5 @@ if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
|
53
53
|
exit 0
|
|
54
54
|
fi
|
|
55
55
|
|
|
56
|
-
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case), no spawn.
|
|
57
|
-
jline_is status active && jline_is proposal '' && jline_is note '' && exit 0
|
|
58
56
|
${SPEX:-spex} internal session-state active --session "$sid" >/dev/null
|
|
59
57
|
exit 0
|
|
@@ -15,8 +15,16 @@ The state is read from ONE structured field in the hook payload, never sniffed f
|
|
|
15
15
|
|
|
16
16
|
The one activity that does NOT count as the session acting is an IN-PROCESS SUBAGENT's tool call (the harness's Task tool — a sub-conversation inside the same process). Such a call fires the parent's hooks carrying the parent's `session_id`, so without a discriminator a supervising parent could never hold a declared state: its own subagents erased every park/ask within seconds and raced the stop-gate into "undeclared stop". The harness stamps subagent-executed calls with a top-level `agent_id` field the parent's own calls never carry; `hp_is_subagent` reads that stamp deterministically (scanning only the pre-`tool_input` payload prefix, where a tool parameter or file content can never fake an unescaped key), and this hook skips the flip entirely. A subagent working is its parent supervising, not the parent moving on — the parent's own next tool call still flips as before.
|
|
17
17
|
|
|
18
|
-
It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's
|
|
18
|
+
It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's runtime envelope in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. Lifecycle state is written to the canonical session application; `runtime.json` is not a lifecycle authority. The hook never edits that envelope itself: it hands every lifecycle write to `spex internal session-state`, the one structured writer the CLI declarations use — an asking note is arbitrary prose, and a shell that substitutes prose into existing JSON eventually writes a record nothing can parse.
|
|
19
19
|
|
|
20
|
-
This hook carries no conversation. A message addressed to the session reaches its agent as an ordinary prompt through the harness adapter (delivery-queue), which is the only way anything enters a turn
|
|
20
|
+
This hook carries no conversation. A message addressed to the session reaches its agent as an ordinary prompt through the harness adapter (delivery-queue), which is the only way anything enters a turn. The one protocol exception is a managed watch notice: the canonical `[spex watch] ` prefix identifies a supervision snapshot, not work performed by the receiving agent, so UserPromptSubmit for that exact wire form does not flip the receiver to `active`. Other inter-agent prompts remain ordinary prompt activity. A hook that also injected mail delivered every message a second time and made the agent's context depend on which of two paths won a race; a freshness signal reports a fact about the session and hands nothing over.
|
|
21
21
|
|
|
22
22
|
This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.
|
|
23
|
+
|
|
24
|
+
## legacy handler migration
|
|
25
|
+
|
|
26
|
+
For the two known pre-structured `mark-active` source blobs still tracked by existing projects, dispatch performs
|
|
27
|
+
a bounded migration at the adapter boundary: it executes the current package-owned structured implementation,
|
|
28
|
+
and the next materialize replaces the old tracked handler with that implementation. This is a migration of a
|
|
29
|
+
legacy source identity, not a second lifecycle protocol or a permanent backward-compatibility path. The old
|
|
30
|
+
handler's envelope writes are never allowed to author current lifecycle state.
|