@spexcode/spec-cli 0.6.5

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 (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. package/templates/spec/project/spec.md +20 -0
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ harness.sh - the SHELL face of the [[harness-adapter]] (spec-cli/src/harness.ts). The hook scripts run
3
+ # as pure shell, so they cannot import the TS adapter; this sourced library is its mirror. dispatch.sh sources
4
+ # it and exports SPEXCODE_HARNESS (claude|codex|opencode|zcode) — baked into the shim by each adapter, so a hook learns its
5
+ # harness deterministically, never by sniffing the payload shape. EVERY harness-divergent payload-parse lives
6
+ # HERE; the hook scripts stay harness-agnostic and just call hp_* (the one place Claude's tool names appear in
7
+ # shell, plus codex's Bash-command mapping). The session-id + global-store resolution is harness-agnostic and
8
+ # lives here too, so the six hooks no longer each repeat the git-common-dir → project-key dance.
9
+ # SPEXCODE_HARNESS is claude|codex|opencode|pi|zcode|plugin: `plugin` is the bundle form ([[plugin-harness]]) and its
10
+ # host (adopter-a/Claude) shares Claude's payload shape; `opencode`'s generated plugin ([[opencode-harness]]),
11
+ # `pi`'s generated extension ([[pi-harness]]), and z-code's native hook payload all share that Claude shape — so every
12
+ # `case "$SPEXCODE_HARNESS"` below routes all four through the claude branch via the default case; only codex
13
+ # keeps parse arms of its own.
14
+
15
+
16
+ # the string value of a top-level JSON string field (first match). Harness-agnostic — both harnesses' payloads
17
+ # carry session_id / tool_name as plain string fields. $1 = payload, $2 = field name. The value is scanned as a
18
+ # real JSON string: the closing quote is the first UNESCAPED `"`, so a value containing `\"` (a quoted literal
19
+ # inside a codex Bash/apply_patch command — extremely common) is captured WHOLE, not truncated at the inner
20
+ # quote. The standard escapes are decoded (`\"` `\\` `\/` `\n` `\t` `\r` `\b` `\f`), so a patch envelope's `\n`
21
+ # arrives as a real newline here (the downstream codex decode then no-ops). Pure awk, no jq on the hot path.
22
+ hp_field() {
23
+ printf '%s' "$1" | awk -v field="$2" '
24
+ BEGIN { s = ""; while ((getline line) > 0) s = s (s == "" ? "" : "\n") line }
25
+ END {
26
+ key = "\"" field "\""
27
+ n = length(s); i = 1
28
+ while (i <= n) {
29
+ p = index(substr(s, i), key)
30
+ if (p == 0) exit
31
+ i += p + length(key) - 1
32
+ # skip whitespace, require a colon, skip whitespace, require an opening quote
33
+ while (i <= n && substr(s, i, 1) ~ /[ \t\n]/) i++
34
+ if (substr(s, i, 1) != ":") continue
35
+ i++
36
+ while (i <= n && substr(s, i, 1) ~ /[ \t\n]/) i++
37
+ if (substr(s, i, 1) != "\"") continue
38
+ i++
39
+ out = ""
40
+ while (i <= n) {
41
+ c = substr(s, i, 1)
42
+ if (c == "\\") {
43
+ e = substr(s, i + 1, 1)
44
+ if (e == "n") out = out "\n"
45
+ else if (e == "t") out = out "\t"
46
+ else if (e == "r") out = out "\r"
47
+ else if (e == "b") out = out "\b"
48
+ else if (e == "f") out = out "\f"
49
+ else out = out e # \" \\ \/ and any other → the literal char
50
+ i += 2
51
+ } else if (c == "\"") {
52
+ print out; exit
53
+ } else {
54
+ out = out c; i++
55
+ }
56
+ }
57
+ exit
58
+ }
59
+ }'
60
+ }
61
+
62
+ # the session id from a payload (both harnesses use session_id).
63
+ # Codex hooks run inside ONE shared per-project app-server. That process can inherit the FIRST launched
64
+ # session's SPEXCODE_SESSION_ID, so on codex the payload session_id (the acting thread id) must win and then
65
+ # hp_store_dir aliases it to the governed record. Claude's payload id equals its governed record id, so the
66
+ # PAYLOAD is the acting identity; the inherited env is only a fallback for payload-less events. Env-first was
67
+ # a live bug: a nested subagent (Task tool) inherits the parent's SPEXCODE_SESSION_ID, so with env winning,
68
+ # every child tool call fired mark-active against the PARENT's record — the parent read `working` forever and
69
+ # every park/done declaration was clobbered within seconds (measured). Same staleness class the codex branch
70
+ # already guards against (payload-first below); claude now follows the same rule.
71
+ # The claude preference is RESOLUTION-AWARE, not blind. "Claude's payload id equals its governed record id"
72
+ # holds only while the launched conversation lives: a compaction/continuation MINTS A NEW conversation id
73
+ # while the record keeps the launched one, and a blindly-preferred payload id then resolves to no record at
74
+ # all — hp_store_dir echoes a path nothing sits at, so every record-dependent hook (stop-gate, mark-active,
75
+ # idle, session-fail) silently no-ops and the session reads `working` forever (measured: a live session froze
76
+ # at `active` while its terminal still answered). So the payload wins only when a record actually answers to
77
+ # it, and an unresolvable payload falls back to the launched SPEXCODE_SESSION_ID. This cannot reopen the
78
+ # subagent bug above: a Task subagent's payload carries the PARENT's session_id, which DOES resolve, so the
79
+ # preference is unchanged there — the divergent branch is reached only when the two ids differ AND the
80
+ # payload names nothing. The common path costs one string compare (the ids are equal), never a store read.
81
+ hp_session_id() {
82
+ local pid
83
+ case "$SPEXCODE_HARNESS" in
84
+ codex) hp_field "$1" session_id ;;
85
+ *) pid=$(hp_field "$1" session_id)
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
89
+ printf '%s' "$SPEXCODE_SESSION_ID"
90
+ else
91
+ printf '%s' "${pid:-$SPEXCODE_SESSION_ID}"
92
+ fi ;;
93
+ esac
94
+ }
95
+
96
+ # is THIS payload a tool call executed by an IN-PROCESS SUBAGENT (Claude's Task tool — a sub-conversation
97
+ # inside the SAME harness process) rather than the session's own top-level agent? The subagent's payload
98
+ # carries the PARENT's session_id/transcript_path (measured live, claude 2.1.207), so hp_session_id cannot
99
+ # tell them apart — but the harness stamps every subagent-executed call with a top-level `agent_id`
100
+ # (+ `agent_type`) field that a top-level call NEVER carries. The check is structural, never a heuristic:
101
+ # scan ONLY the payload prefix before the first `"tool_input"` (every harness-stamped top-level field
102
+ # precedes tool_input), and require the key shape `"agent_id":`. Inside a JSON string value every quote is
103
+ # escaped (\"), so a bare-quoted `"agent_id"` followed by a colon in that prefix can only be the harness's
104
+ # own stamp — a tool parameter literally NAMED agent_id (an MCP tool) sits inside tool_input, past the
105
+ # truncation, and file/prompt content can never fake an unescaped quote. Codex payloads carry no such field
106
+ # (its verified field set) so this never matches there. Echoes "1" when yes, else nothing.
107
+ hp_is_subagent() {
108
+ printf '%s' "${1%%\"tool_input\"*}" | grep -q '"agent_id"[[:space:]]*:' && printf 1
109
+ }
110
+
111
+ # the per-PROJECT GLOBAL runtime dir (mirrors packages/spec-core/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
112
+ # keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
113
+ # worktree). The per-session dirs and the per-tree materialize slots (hp_tree_dir) live under it.
114
+ # Echoes the dir; returns non-zero (echoing nothing) when git can't resolve, so a caller can `|| exit 0`.
115
+ hp_runtime_dir() {
116
+ local gcd
117
+ gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
118
+ [ -n "$gcd" ] || return 1
119
+ printf '%s/projects/%s' "${SPEXCODE_HOME:-$HOME/.spexcode}" "$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
120
+ }
121
+
122
+ # the per-WORKTREE materialize slot (mirrors layout.ts `treeSlotDir`): <runtime>/trees/<enc(worktree-toplevel)> —
123
+ # where THIS tree's materialized hook manifest + content-hash marker live. Keyed by the cwd's own
124
+ # `rev-parse --show-toplevel` through the same enc transform, so a dispatch can only ever read the manifest
125
+ # of the tree it fires in ([[hook-dispatch]] — the old single global file let the last-materialized tree's
126
+ # hook set reach every other tree's sessions). Echoes the dir; returns non-zero when git can't resolve.
127
+ hp_tree_dir() {
128
+ local rd top
129
+ rd=$(hp_runtime_dir) || return 1
130
+ top=$(git rev-parse --show-toplevel 2>/dev/null)
131
+ [ -n "$top" ] || return 1
132
+ printf '%s/trees/%s' "$rd" "$(printf '%s' "$top" | sed 's#[/.]#-#g')"
133
+ }
134
+
135
+ # the per-session GLOBAL store dir for a session id — <runtime>/sessions/<id> (sibling of the per-project
136
+ # runtime above). Echoes the dir; returns non-zero (echoing nothing) when git can't resolve.
137
+ # ALIAS resolution: a codex hook fires from the shared per-PROJECT app-server process, whose env may carry a
138
+ # stale SPEXCODE_SESSION_ID from the first launched codex session. hp_session_id therefore returns the acting
139
+ # payload session_id on codex: the codex THREAD id, NOT the SpexCode record id the dir is keyed by. So when no
140
+ # record sits at <id> directly, find the one record that captured this id as `harness_session_id` (the backend
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
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
+ # unchanged, so the caller's `[ -e "$rec" ]` still no-ops gracefully. Mirrors layout.ts `readAliasedRawRecord`.
145
+ hp_store_dir() {
146
+ local rd; rd=$(hp_runtime_dir) || return 1
147
+ local direct="$rd/sessions/$1"
148
+ if [ -e "$direct/session.json" ]; then printf '%s' "$direct"; return 0; fi
149
+ # same two-halves-of-absence rule as layout.ts's readAliasedRecordEntry: an id owning a store dir is already
150
+ # one of ours, so its emptiness is settled and the alias grep is the wrong question (and a whole-store scan).
151
+ if [ -d "$direct" ]; then printf '%s' "$direct"; return 0; fi
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; }
155
+ printf '%s' "$direct"
156
+ }
157
+
158
+ # the TOOLCHAIN's own version fingerprint — the toolchain side of the content key. The materialized artifacts
159
+ # are a function of (config content, toolchain), so a TOOLCHAIN update must move the key too, or an updated
160
+ # deploy never self-heals its stale contract/shims/manifest until someone happens to edit .plugins (the field
161
+ # lesson: a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the
162
+ # package dir (moves exactly when the toolchain's content moves, not on every repo commit); an npm install
163
+ # (no .git) answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's
164
+ # exported GIT_DIR must not misdirect repo discovery (same rule as git.ts's git()).
165
+ SPEXCODE_HP_PKG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
166
+ hp_toolchain_version() {
167
+ ( cd "$SPEXCODE_HP_PKG" 2>/dev/null && env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse 'HEAD:./' 2>/dev/null ) \
168
+ || sha256sum "$SPEXCODE_HP_PKG/package.json" 2>/dev/null | cut -d' ' -f1 \
169
+ || echo unversioned
170
+ }
171
+
172
+ # the deterministic content fingerprint of EVERYTHING the materialize is a function of: the EDITABLE config
173
+ # roots (.plugins + plugin-system md/sh), the PERSISTED POLICY files (the MAIN checkout's spexcode.json +
174
+ # spexcode.local.json — the `harnesses` set materialize reads via readConfig(mainCheckout)), and the
175
+ # toolchain version above. Since the dispatch-gate retired ([[commit-surgery]] — materialize anchors on
176
+ # git-native events only), this is a FRESHNESS STAMP materialize records after each pass, a diagnostic
177
+ # (is the last materialize current?) rather than a trigger. Run with cwd = the project. ONE definition:
178
+ # materialize.ts shells to it. env-stripped git, same rule as hp_toolchain_version.
179
+ hp_config_hash() {
180
+ local gcd
181
+ gcd=$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
182
+ || gcd=$(realpath "$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
183
+ { hp_toolchain_version
184
+ find .spec/*/.plugins .spec/*/plugin-system \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
185
+ | sort -z | xargs -0 cat 2>/dev/null
186
+ [ -n "$gcd" ] && cat "$(dirname "$gcd")/spexcode.json" "$(dirname "$gcd")/spexcode.local.json" 2>/dev/null
187
+ } | sha256sum | cut -d' ' -f1
188
+ }
189
+
190
+ # the tool a payload is about to run / just ran (harness-agnostic field name).
191
+ hp_tool() { hp_field "$1" tool_name; }
192
+
193
+ # is THIS payload the agent pausing to ask the HUMAN a question? Claude: the AskUserQuestion tool. Codex: the
194
+ # experimental request_user_input tool (its only structured ask path). Echoes "1" when yes, else nothing.
195
+ hp_is_ask() {
196
+ case "$SPEXCODE_HARNESS" in
197
+ codex) [ "$(hp_tool "$1")" = request_user_input ] && printf 1 ;;
198
+ *) [ "$(hp_tool "$1")" = AskUserQuestion ] && printf 1 ;;
199
+ esac
200
+ }
201
+
202
+ # the question text of an ask payload (best-effort; for the board note). Both harnesses carry it under a
203
+ # "question" field of the tool input — so it is just the first such JSON string value: collapse onto hp_field,
204
+ # which (unlike the old grep `[^"]*`) handles an embedded `\"` and decodes escapes instead of truncating.
205
+ hp_ask_note() { hp_field "$1" question; }
206
+
207
+ # the CODE file(s) a payload touches, mapped to the trigger the spec hooks key on. $2 = mode:
208
+ # read → the file being READ ([[spec-first]] gates the first GOVERNED read)
209
+ # mutate → the file being EDITED ([[spec-of-file]] fires only on a mutation)
210
+ # access → either operation
211
+ # Echoes the path(s), ONE PER LINE — a codex multi-file apply_patch (several `*** Update File:` markers)
212
+ # touches several files in one tool call, so every consuming hook iterates the lines. Echoes nothing when the
213
+ # payload is not a code touch of that mode. The harness divergence:
214
+ # Claude — Read/Edit/Write/NotebookEdit + tool_input.file_path|notebook_path.
215
+ # Codex — NO file_path. An EDIT is its own first-class tool `tool_name:"apply_patch"` whose tool_input.command
216
+ # is the bare patch envelope (`*** Update File: <path>`); a READ/shell is `tool_name:"Bash"` +
217
+ # tool_input.command. Both carry the touched file inside `command`, so we parse that one field.
218
+ hp_code_path() {
219
+ local payload="$1" mode="$2" tool
220
+ tool=$(hp_tool "$payload")
221
+ case "$SPEXCODE_HARNESS" in
222
+ codex)
223
+ case "$mode:$tool" in
224
+ read:Bash|mutate:apply_patch|mutate:Bash|access:apply_patch|access:Bash) ;;
225
+ *) return 0 ;;
226
+ esac
227
+ _hp_codex_cmd_path "$(hp_field "$payload" command)" "$mode"
228
+ ;;
229
+ *)
230
+ case "$mode" in
231
+ read) case "$tool" in Read) ;; *) return 0 ;; esac ;;
232
+ mutate) case "$tool" in Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
233
+ access) case "$tool" in Read|Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
234
+ *) return 0 ;;
235
+ esac
236
+ local p; p=$(hp_field "$payload" file_path); [ -n "$p" ] || p=$(hp_field "$payload" notebook_path)
237
+ printf '%s' "$p"
238
+ ;;
239
+ esac
240
+ }
241
+
242
+ # @@@ codex command → path - the apply_patch / sed / cat path-extractor. Codex never sends a file_path; the
243
+ # touched file lives inside `command`. Two shapes: a PATCH envelope carries `*** (Add|Update|Delete) File:
244
+ # <path>` lines (always a MUTATION) — this is what the apply_patch tool sends, the bare envelope with NO literal
245
+ # `apply_patch` token, so we detect it by the File: markers themselves (the legacy `apply_patch` token is kept
246
+ # too, for a shell-wrapped invocation); a plain command (sed/cat/head/rg/…) carries the path as a token.
247
+ # A MUTATION is a patch envelope or a write shape (a redirect, tee, `sed -i`, dd of=); in `mutate` mode a pure
248
+ # read yields nothing. A patch envelope can carry SEVERAL `*** Update File:` markers (a multi-file edit) — ALL
249
+ # of them are emitted, one per line, so the consuming hook acts on every touched file (a single-file patch
250
+ # emits one line; a plain command emits its one token). The plain-command path is the last path-like token
251
+ # (has a / or a .ext), ignoring flags — matches the verified-facts example `sed -n 1p f.ts` → `f.ts`.
252
+ # Best-effort: an exotic command may not resolve, which only means a missed nudge, never a wrong block.
253
+ _hp_codex_cmd_path() {
254
+ local mode="$2" cmd
255
+ # the command arrives as a JSON STRING value; hp_field already decodes JSON escapes (so a patch File: line
256
+ # already ends at a real newline). This gsub is a no-op safety net for any caller that bypassed hp_field.
257
+ cmd=$(printf '%s' "$1" | awk '{gsub(/\\n/,"\n"); gsub(/\\t/,"\t")}1')
258
+ case "$cmd" in
259
+ *apply_patch*|*applypatch*|*'*** Add File:'*|*'*** Update File:'*|*'*** Delete File:'*)
260
+ [ "$mode" = read ] && return 0
261
+ printf '%s\n' "$cmd" | sed -n 's/^\*\*\* \(Add\|Update\|Delete\) File: \(.*\)$/\2/p' | sed 's/[[:space:]]*$//'
262
+ return 0 ;;
263
+ esac
264
+ local is_mutate=0
265
+ case "$cmd" in *' >> '*|*' > '*|*' >>'*|*' >'*|*' tee '*|*'sed -i'*|*' dd '*) is_mutate=1 ;; esac
266
+ [ "$mode" = mutate ] && [ "$is_mutate" = 0 ] && return 0
267
+ [ "$mode" = read ] && [ "$is_mutate" = 1 ] && return 0
268
+ printf '%s\n' "$cmd" | tr ' \t' '\n\n' | grep -E '^[^-].*[/.][A-Za-z0-9_]+' | grep -vE '^(apply_patch|applypatch)$' | tail -1
269
+ }
270
+
271
+ # the notification type of a Notification payload (Claude only — Codex fires no Notification event).
272
+ hp_notification_type() { hp_field "$1" notification_type; }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@spexcode/spec-cli",
3
+ "version": "0.6.5",
4
+ "type": "module",
5
+ "description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
6
+ "bin": {
7
+ "spex": "bin/spex.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/index.js",
11
+ "./bin/spex.mjs": "./bin/spex.mjs",
12
+ "./ranker": "./dist/ranker.js",
13
+ "./eval-host": "./dist/eval-host.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "dist",
19
+ "templates",
20
+ "hooks"
21
+ ],
22
+ "bundledDependencies": [
23
+ "@spexcode/spec-core",
24
+ "@spexcode/spec-eval",
25
+ "@spexcode/spec-forge"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "node ../scripts/build-dist.mjs",
32
+ "prepublishOnly": "node ../scripts/release-publish.mjs --from-package-publish",
33
+ "serve": "npm run build && node bin/spex.mjs serve",
34
+ "lint": "npm run build && node bin/spex.mjs spec lint",
35
+ "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
36
+ },
37
+ "dependencies": {
38
+ "@spexcode/spec-core": "0.6.5",
39
+ "@spexcode/spec-eval": "0.6.5",
40
+ "@spexcode/spec-forge": "0.6.5"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.16.0",
44
+ "tsx": "^4.19.2",
45
+ "typescript": "^5.6.3"
46
+ }
47
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # spexcode-managed-hook-v1
3
+ # Candidate lint is driven solely by reference-transaction. This hook intentionally carries no marker,
4
+ # message hash, tree binding, or TTL; Git's immutable commit object is the only candidate identity.
5
+ # Git does not apply scissors cleanup to every no-editor/-F path, so normalize the final message here,
6
+ # after every editor, using scissors only when the repository explicitly selected that cleanup mode.
7
+ if [ "$(git config --get commit.cleanup 2>/dev/null || true)" = scissors ]; then
8
+ msg_file="$1"
9
+ tmp="${msg_file}.spexcode-scissors.$$"
10
+ awk '/^# ------------------------ >8 ------------------------$/ { exit } { print }' "$msg_file" \
11
+ | git stripspace --strip-comments > "$tmp"
12
+ mv "$tmp" "$msg_file"
13
+ fi
14
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # spexcode-managed-hook-v1
3
+ # @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
4
+ # transitions that can move the materialize's inputs: .spec/.plugins content changes across branches, and a
5
+ # contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
6
+ # index prose — the re-materialize here writes the block back into the working file, binds the clean/smudge
7
+ # filter, and withdraws the exclude entry; the kind-transition heals itself). Git-native anchors only —
8
+ # never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor (pre-commit's
9
+ # unconditional materialize is the correctness backstop).
10
+ #
11
+ # args: <prev-HEAD> <new-HEAD> <flag>; flag=1 is a branch checkout, flag=0 a file checkout (git checkout --
12
+ # <path> restores files and moves nothing the materialize depends on — skip those).
13
+ [ "${3:-0}" = "1" ] || exit 0
14
+ # Session creation owns the one post-seed materialize in its transaction.
15
+ [ "${SPEXCODE_DEFER_FOOTPRINT_REFRESH:-}" = "session-create" ] && exit 0
16
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
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
20
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
21
+ "$repo_root/node_modules/.bin/spex" internal refresh-footprint >/dev/null 2>&1 || true
22
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
23
+ "$main_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
24
+ fi
25
+ exit 0
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # spexcode-managed-hook-v1
3
+ # @@@ issue nudge (post-merge) - the taste store ([[local-issues]]) fires AFTER the work lands, not while the
4
+ # agent is finishing it: the agent's own task comes first. Merge is dispatched to the session's OWN agent
5
+ # (see [[dispatch]]), so this hook runs in that live merge turn and its stdout reaches the agent as the merge
6
+ # command's own output — a git-native nudge that costs no harness block-cap and reaches a self-launched agent
7
+ # too. GUARDED to the `merge node/<id>:` commit, so an ordinary `git pull`/unrelated merge never nags.
8
+ #
9
+ # The nudge TEXT and the on/off toggle both live in the CLI (`spex internal nudge <node>` prints nothing when
10
+ # the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
11
+ # three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
12
+ # nudge (safe degradation, same as the lint shim's advisory mode).
13
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
14
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
15
+ spex_run() {
16
+ if command -v spex >/dev/null 2>&1; then spex "$@"
17
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then "$repo_root/node_modules/.bin/spex" "$@"
18
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then "$main_root/spec-cli/bin/spex.mjs" "$@"
19
+ else return 127; fi
20
+ }
21
+
22
+ # @@@ footprint refresh ([[commit-surgery]]) - a merge is one of the three git transitions that can move the
23
+ # materialize's inputs (.spec/.plugins content, contract-file trackedness), so re-materialize here — git-native
24
+ # anchors only, never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor.
25
+ spex_run internal refresh-footprint >/dev/null 2>&1 || true
26
+
27
+ subj=$(git log -1 --format=%s 2>/dev/null)
28
+ case "$subj" in
29
+ "merge node/"*) ;;
30
+ *) exit 0 ;;
31
+ esac
32
+ node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
33
+ spex_run internal nudge "$node"
34
+ exit 0
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # spexcode-managed-hook-v1
3
+ # @@@ shared spex runner - resolve the `spex` CLI ONCE, into spex_cli(), so BOTH the main-guard trunk
4
+ # lookup and the spec-lint shim shim through the same mechanism (no duplicated, drift-prone resolution).
5
+ # This must work for BOTH ways this hook ships: a real project that installed @spexcode/spec-cli, and the
6
+ # SpexCode monorepo dogfooding itself from source. cwd stays the committing worktree either way. main_root
7
+ # is the parent of the shared git *common* dir (= layout.ts mainCheckout()); a fresh session worktree has
8
+ # no node_modules of its own, so the dogfood tsx path reaches the main checkout's. First that exists wins:
9
+ # 1. `spex` on PATH - installed globally / on the shell PATH.
10
+ # 2. <repo>/node_modules/.bin/spex - a project that did `npm i @spexcode/spec-cli` (the dep's bin).
11
+ # 3. monorepo launcher by path - dogfood: bin/spex.mjs (owns tsx resolution + the mid-merge guard).
12
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
13
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
14
+ spex_kind=
15
+ if command -v spex >/dev/null 2>&1; then
16
+ spex_kind=path
17
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
18
+ spex_kind=local
19
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
20
+ spex_kind=pkg
21
+ fi
22
+ spex_cli() {
23
+ case "$spex_kind" in
24
+ path) spex "$@" ;;
25
+ local) "$repo_root/node_modules/.bin/spex" "$@" ;;
26
+ pkg) "$main_root/spec-cli/bin/spex.mjs" "$@" ;;
27
+ *) return 127 ;;
28
+ esac
29
+ }
30
+
31
+ # A commit containing only issue storage cannot change governed code or an anchor range. Keep this as a
32
+ # path-property check at the hook boundary so the common dashboard write does not pay materialize/eval
33
+ # backstops; any mixed commit continues through the full hook chain and the reference transaction gate.
34
+ staged_issue_only=1
35
+ staged_any=0
36
+ while IFS= read -r -d '' staged_path; do
37
+ staged_any=1
38
+ case "$staged_path" in .spec/.issues/*) ;; *) staged_issue_only=0 ;; esac
39
+ done < <(git diff --cached --name-only -z 2>/dev/null)
40
+ if [ "$staged_any" -eq 1 ] && [ "$staged_issue_only" -eq 1 ]; then
41
+ exit 0
42
+ fi
43
+
44
+ # @@@ tree-unchanged stamp - a commit whose tree EQUALS HEAD's tree (`git write-tree` over the commit's own
45
+ # index — the hook's GIT_INDEX_FILE, a temp HEAD-tree index for `commit --only --allow-empty`) adds NO
46
+ # content: it cannot smuggle code past the main-guard below and cannot introduce spec↔code drift, so the
47
+ # whole hook passes it. This is `spex spec ack`'s empty Spec-OK stamp — the reason ack works on a trunk merge
48
+ # commit (amending it would re-author the merge after MERGE_HEAD is gone and read as direct authoring) and
49
+ # with a DIRTY real index (the lint shim below reads the real index via git.ts's env-strip, which would
50
+ # otherwise block the stamp on the very drift it acknowledges). Deliberately a one-line tree compare, not an
51
+ # "is this an amend?" heuristic (undecidable at pre-commit time).
52
+ if [ "$(git write-tree 2>/dev/null)" = "$(git rev-parse 'HEAD^{tree}' 2>/dev/null || echo none)" ]; then
53
+ exit 0
54
+ fi
55
+
56
+ # @@@ main-guard - the worktree model: never author directly on the trunk; the trunk only RECEIVES merges.
57
+ # Reject a direct commit while HEAD is the trunk. Merges pass (MERGE_HEAD present), and a tree-unchanged
58
+ # stamp already passed above. Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
59
+ #
60
+ # The trunk is resolved the SAME way the rest of SpexCode resolves it — `spex internal trunk` = layout.ts
61
+ # mainBranch() (the stable mainBranch project fact stamped at adoption; conventional default 'main') — so
62
+ # the guard protects whatever the repo's trunk is actually named. When the CLI isn't resolvable (advisory
63
+ # mode), only the convention is available: shell does not guess identity from a mutable checkout.
64
+ branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo)
65
+ git_dir=$(git rev-parse --git-dir 2>/dev/null)
66
+ trunk=$(spex_cli internal trunk 2>/dev/null | head -1)
67
+ [ -n "$trunk" ] || trunk=main
68
+ if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
69
+ echo "✗ SpexCode: direct commits on $trunk (the trunk) are blocked." >&2
70
+ echo " Work in a worktree (.worktrees/<node>, branch node/<id>) and merge back." >&2
71
+ echo " Merges and tree-unchanged stamps (spex spec ack) pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
72
+ exit 1
73
+ fi
74
+
75
+ # @@@ footprint surgery ([[commit-surgery]]) - the history anchor: an UNCONDITIONAL materialize (masks fresh
76
+ # at the one moment history is written) + staged-index repair — strip the spexcode sentinel block from any
77
+ # staged blob (source = the staged blob, so `git add -p` partial staging survives), unstage HEAD-untracked
78
+ # generated artifacts. REPAIRS AND PROCEEDS, never rejects; one note per repair on stderr. Runs INSIDE the
79
+ # hook env on purpose: the surgery honors GIT_INDEX_FILE so a pathspec/`-a` commit's TEMPORARY index is the
80
+ # one repaired. Advisory when spex is unresolvable or errors (CI lint enforces); never blocks the commit.
81
+ if [ -n "$spex_kind" ]; then
82
+ spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
83
+ fi
84
+
85
+ # @@@ spec-lint - reference-transaction owns the candidate gate. When the installed consumer is the
86
+ # SpexCode unmarked predicate, running full HEAD lint here would restore the history-scaled cost this gate
87
+ # removes. A missing/custom consumer keeps the old full lint fallback, so a hook collision never reduces
88
+ # local coverage. Static identity matters: never execute a user-owned hook with made-up probe arguments.
89
+ # Bypass with SPEXCODE_SKIP_LINT=1. It shims through shared spex_cli() resolved above.
90
+ if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
91
+ if [ -n "$spex_kind" ]; then
92
+ consume_hook=$(git rev-parse --git-path hooks/reference-transaction 2>/dev/null)
93
+ if [ -x "$consume_hook" ] && grep -q 'one unmarked predicate at Git' "$consume_hook" 2>/dev/null; then
94
+ lint_rc=0
95
+ else
96
+ spex_cli spec lint >&2
97
+ lint_rc=$?
98
+ fi
99
+ # exit 75 = the launcher's mid-merge guard (spex's own source holds conflict markers — transient).
100
+ # Degrade to advisory rather than walling every commit behind a merge someone else is resolving.
101
+ if [ "$lint_rc" -eq 75 ]; then
102
+ echo "• SpexCode: spec-lint skipped — spex is paused mid-merge (exit 75); CI still enforces." >&2
103
+ elif [ "$lint_rc" -ne 0 ]; then
104
+ echo "✗ SpexCode: spec-lint failed — fix the spec↔code links or bypass with SPEXCODE_SKIP_LINT=1." >&2
105
+ exit 1
106
+ else
107
+ # @@@ eval backstop - reject a staged eval offender: a stray evidence blob copied into the tree (blobs
108
+ # belong in the shared git common dir, never committed) OR a malformed eval.md (a scenario schema
109
+ # violation). Logic lives in spec-eval; this is the lint-shim's twin. (this node's contract)
110
+ if ! spex_cli internal check-staged >&2; then
111
+ echo "✗ SpexCode: internal check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
112
+ exit 1
113
+ fi
114
+ fi
115
+ else
116
+ # Advisory hook: no CLI found (project hasn't installed @spexcode/spec-cli; monorepo not `npm install`ed).
117
+ echo "• SpexCode: spec-lint skipped — no \`spex\` CLI found (install @spexcode/spec-cli); CI still enforces." >&2
118
+ fi
119
+ fi
120
+ exit 0
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # spexcode-managed-hook-v1
3
+ # @@@ session-stamp - auto-attribute a commit to the SESSION that authored it, ACROSS HARNESSES. SpexCode
4
+ # reads the `Session:` trailer as a version's attribution (git is the database), keyed by the SpexCode record
5
+ # id so the dashboard's session window links to it.
6
+ #
7
+ # The id is simply READ here, because it was established where it is KNOWN: whoever creates an agent's
8
+ # execution context injects that context's own record id, and nothing else may carry one.
9
+ # - a session launch bakes `SPEXCODE_SESSION_ID=<record id>` into the agent's process — and STRIPS every
10
+ # session-identity variable it inherited first, so the pane's ancestry (a tmux server started long ago by
11
+ # someone else) cannot smuggle one in;
12
+ # - a codex thread gets the same variable injected into every command it spawns, at `thread/start`, via
13
+ # codex's own `shell_environment_policy.set` — the tool shell is a child of the SHARED app-server and must
14
+ # therefore inherit nothing, so the backend hands it its identity per thread instead;
15
+ # - that shared app-server, and any other process we own that belongs to no single session, is spawned with
16
+ # those variables stripped. A process that outlived its session handing its id to strangers is exactly how
17
+ # this trailer once named a session that no longer existed (github#76).
18
+ # So there is nothing to resolve, alias, or verify at commit time: the variable is present only in processes
19
+ # that genuinely belong to that session, and it already IS the record id. No store lookup, no per-harness
20
+ # tiers, and nothing derived from the current directory — where a process stands says nothing about who it is.
21
+ # No id → no trailer. An existing Session: trailer is left alone, so the ritual / a human can set one by hand.
22
+ set -euo pipefail
23
+ msg_file="$1"
24
+ grep -qiE '^Session:[[:space:]]' "$msg_file" && exit 0
25
+ [ -n "${SPEXCODE_SESSION_ID:-}" ] || exit 0
26
+
27
+ # Stamp via interpret-trailers (git's own sample-hook mechanism), NOT a raw printf append: appending after a
28
+ # message that already carries a trailer block (e.g. `spex spec ack`'s Spec-OK) opens a new paragraph, and git then
29
+ # parses ONLY the last paragraph as trailers — the earlier trailers silently become body prose. interpret-trailers
30
+ # appends into the existing block, keeping ONE parseable block. It needs the file newline-terminated first:
31
+ # on an unterminated message (git merge -m writes MERGE_MSG without one) it folds the trailer into the subject
32
+ # paragraph, where git's trailer parser never looks — the reason merge commits' Session: never parsed.
33
+ [ -n "$(tail -c1 "$msg_file")" ] && echo >> "$msg_file"
34
+ git interpret-trailers --in-place --trailer "Session: $SPEXCODE_SESSION_ID" "$msg_file"
35
+ exit 0