spexcode 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.
Files changed (205) hide show
  1. package/README.md +12 -7
  2. package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
  3. package/node_modules/@spexcode/session-application/dist/index.js +35 -0
  4. package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
  5. package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
  6. package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
  7. package/node_modules/@spexcode/session-application/dist/production.js +438 -0
  8. package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
  9. package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
  10. package/node_modules/@spexcode/session-application/package.json +29 -0
  11. package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
  12. package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
  13. package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
  14. package/node_modules/@spexcode/session-events/dist/index.js +245 -0
  15. package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
  16. package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
  17. package/node_modules/@spexcode/session-events/package.json +24 -0
  18. package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
  19. package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
  20. package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
  21. package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
  22. package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
  23. package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
  24. package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
  25. package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
  26. package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
  27. package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
  28. package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
  29. package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
  30. package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
  31. package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
  32. package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
  33. package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
  34. package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
  35. package/node_modules/@spexcode/session-runtime/package.json +24 -0
  36. package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
  37. package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
  38. package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
  39. package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
  40. package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
  41. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +20 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +83 -0
  43. package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
  44. package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
  45. package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
  46. package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
  47. package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
  48. package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
  49. package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
  50. package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
  51. package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
  52. package/node_modules/@spexcode/session-topology/package.json +24 -0
  53. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +56 -19
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +102 -59
  55. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
  56. package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
  57. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
  58. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
  59. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
  60. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
  61. package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
  62. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +1 -0
  63. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +2 -2
  64. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  65. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  66. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  69. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  70. package/node_modules/@spexcode/spec-cli/dist/guide.js +20 -7
  71. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  72. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -3
  73. package/node_modules/@spexcode/spec-cli/dist/harness.js +331 -50
  74. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  75. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  76. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  77. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  78. package/node_modules/@spexcode/spec-cli/dist/host.js +93 -0
  79. package/node_modules/@spexcode/spec-cli/dist/index.js +324 -22
  80. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  81. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  82. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  83. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +1 -1
  85. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
  86. package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
  87. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
  88. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  89. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  90. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  91. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  92. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  93. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  94. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  95. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  96. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  97. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  98. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  100. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  101. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  102. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  103. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  104. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +108 -15
  105. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1465 -744
  106. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  107. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  108. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  109. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  110. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  111. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  112. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  113. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  114. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  115. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.d.ts +36 -0
  116. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +251 -0
  117. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  118. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  119. package/node_modules/@spexcode/spec-cli/package.json +6 -6
  120. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  121. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  122. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  123. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  124. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  125. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  126. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  127. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  128. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  139. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  140. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  141. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  142. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  143. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -0
  144. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  145. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  146. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  147. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  148. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  149. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  150. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  151. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  152. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  153. package/node_modules/@spexcode/spec-core/package.json +4 -1
  154. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  155. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  156. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  157. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  158. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  159. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  160. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  161. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  162. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  163. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  164. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  165. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  166. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  167. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  168. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  169. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  170. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  171. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  172. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  173. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  174. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  175. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  176. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  177. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  178. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  179. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  180. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  181. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  182. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  188. package/package.json +9 -3
  189. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -23
  190. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -195
  191. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  192. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  193. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  194. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  195. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  196. package/node_modules/@spexcode/session-core/dist/message.js +0 -52
  197. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  198. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -50
  199. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -286
  200. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  201. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  202. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -46
  203. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  204. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  205. package/node_modules/@spexcode/spec-cli/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
+ }
@@ -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>`. A harness id as $1 (claude|codex|opencode|pi|zcode|plugin) is consumed; otherwise we keep
20
- # $1 as the event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>`
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 ;; esac
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. Before the v1 marker, an absent allowlist is the legacy
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
- if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
60
- manifest="$SPEX_HOOK_MANIFEST"
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"
@@ -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")/session.json" ] \
88
- && [ -e "$(hp_store_dir "$SPEXCODE_SESSION_ID")/session.json" ]; then
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 session.json files — no jq on the hot path; the trailing quote anchors the value so a
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/session.json" ]; then printf '%s' "$direct"; return 0; fi
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/*/session.json 2>/dev/null | head -1)
154
- [ -n "$hit" ] && { printf '%s' "${hit%/session.json}"; return 0; }
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.6.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-core": "0.6.6",
40
- "@spexcode/spec-core": "0.6.6",
41
- "@spexcode/spec-eval": "0.6.6",
42
- "@spexcode/spec-forge": "0.6.6"
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 command -v spex >/dev/null 2>&1; then
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 command -v spex >/dev/null 2>&1; then spex "$@"
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 command -v spex >/dev/null 2>&1; then
16
- spex_kind=path
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 command -v spex >/dev/null 2>&1; then
69
- spex_kind=path
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** (the [[prompts]] shape): it declares no `surface` field and
25
- gathers nothing itself. Discovery is recursive and field-driven (surface), so a resident plugs in
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, mark the session idle (the active-only guard in `internal session-idle` keeps a
3
- # deliberate awaiting/asking/parked/error declaration from being clobbered). GATED on `governed`: only a
4
- # dashboard-launched session has board state to mark a self-launched agent's idle is none of our business.
5
- # State lives in the per-session GLOBAL record session.json (keyed by the harness session_id, grouped per-
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 also acts only on a GOVERNED session it resolves the record in the global store from the payload's `session_id` and no-ops unless `governed: true` then marks idle via `spex internal session-idle --session <id>`. 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
+ 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
- # @@@ read cheap, write through the ONE writer - this hook is on the hot path (every tool call), so it does
17
- # its own READ in pure shell: three exact-line greps answer "already active, nothing stale to clear?", which
18
- # is the overwhelmingly common case, and that path exits without spawning anything. When there IS a change to
19
- # make it hands the write to `spex internal session-state`, the same structured writer the CLI declarations
20
- # use. It never edits session.json itself: an asking note is arbitrary human/agent prose, and a writer that
21
- # substitutes prose into existing JSON meets a quote, a backslash, or a newline and leaves a record nothing
22
- # can parse which is how a live session came to report "no session record" ([[sessions-core]]).
23
- # @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
24
- # by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
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
- sdir=$(hp_store_dir "$sid") || exit 0
37
- rec="$sdir/session.json"
38
- # board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
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 record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), but it never edits that file itself: it READS it in pure shell (whole-line matches, the hot path stays jq-free) and hands every 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.
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, so an inter-agent message is indistinguishable from a human one at the point of arrival. 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.
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.