@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,22 @@
1
+ ---
2
+ title: core
3
+ surface: system
4
+ status: active
5
+ hue: 200
6
+ desc: A config plugin — the minimal spec-discipline contract folded into every launched agent.
7
+ code:
8
+ ---
9
+ Use noun-first CLI commands; `spex help` is the authoritative command map.
10
+
11
+ Anything a human needs to inspect, whether a file or a local webpage, goes out through `spex session files add` or
12
+ `spex session web add`: never paste an absolute path or `host:port`, and never start a static server yourself.
13
+
14
+ When this session has a clearly running child session (`active` or `parked`), the parent is supervising rather
15
+ than finished: declare `park`, not `done`/`awaiting`, until the child reports a settled state.
16
+
17
+ 1. **Spec first:** before governed code, read its spec body with `spex spec owner <path>` or `spex spec search`.
18
+ Update that current-state body with any changed intent.
19
+ 2. **Commit before declare:** commit the code and spec it justifies before done or merge; independent intent gets
20
+ its own node.
21
+ 3. **Keep the loss signal honest:** run `spex spec lint` (the blocking correctness gate) and `spex eval lint --changed`. Measure changed scenarios
22
+ through the real product, commit the verified tree, then file with `spex eval add`; the reading's `codeSha` must name that commit.
@@ -0,0 +1,23 @@
1
+ ---
2
+ title: stop-gate
3
+ surface: hook
4
+ status: active
5
+ hue: 200
6
+ events:
7
+ - Stop
8
+ order: 10
9
+ block: true
10
+ ---
11
+ The blocking stop gate, with two jobs, each holding a hard loop-break so it never blocks twice on the same cause and never lets a dishonest stop through.
12
+
13
+ The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected while the branch still carries uncommitted work or is zero commits ahead of main, because the ritual commits the spec and code BEFORE proposing. Clean work is allowed to stop; a dirty proposal blocks once with the reason, and if the agent ignores it the gate escapes by downgrading to `asking` so a false "ready to merge" can never stand. `done --propose nothing` is a porcelain trap, not a declaration: it writes no state and names merge, close, ask, and park as the real destinations; legacy `nothing` records remain readable.
14
+
15
+ The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop emits `{"decision":"block"}` and the dispatcher exits 2 so the harness actually interrupts the stop and shows the reason; on the forced continuation it declares `asking`, so the loop is guaranteed to end without inventing a completion state. state owns the choice: a posted file or web artifact, a reported finding/recommendation awaiting a human decision, or any handoff awaiting human direction selects `asking`; `close-pending` requires genuinely settled work with no such outstanding decision or follow-up.
16
+
17
+ The block text is where the declaration ritual is taught, so it is written to be read at two depths. The FULL teaching text prints once per session: it names the PATH-independent CLI once as a shared prefix, lists the five choices as a compact menu each with its application condition (park policed hardest — a false park is the most damaging mislabel). A real wake source is either a background job or a managed session-follow subscription whose normal send delivery will re-enter the parent; a caller without that address must still arm background `session wait`. It ends with the ordering discipline: declare LAST, then stop — a declaration followed by more tool calls honestly re-flips the record to active ([[mark-active]], by design), so making the declaration the turn's final call is what eliminates the park→block→re-park loop at its source. Every later undeclared stop in the same session gets a ONE-LINE version instead (a heavy session hits the gate 15-20 times a night; re-printing the full menu is token noise). The once-sentinel is a plain file beside the session record in the global store — the same per-session-sentinel mechanism as the CLI's note-truncation notice, never a second scheme. The terse line stays self-explanatory: it carries the command menu, the declare-LAST reminder, and the `spex help session` recovery entry, so an agent that never saw the full text (a compacted context) recovers every choice's condition from the entry rather than from memory — the whole full-to-terse information gap is closable from the line itself.
18
+
19
+ The clean-done eval nudge is advisory only and must never corrupt the Stop hook protocol. Claude-family hooks can receive it as `hookSpecificOutput.additionalContext`; Codex Stop allows are silent because Codex treats unsupported non-block stdout as invalid hook JSON. Blocking decisions stay shared across harnesses through `{"decision":"block"}` plus the dispatcher’s Codex stderr bridge.
20
+
21
+ Both gates act ONLY on a GOVERNED (dashboard-launched) session: the gate resolves the session's record in the global store from the payload's `session_id`, and on a non-governed (user-self-launched) record — or none — it exits 0 SILENTLY. A self-launched agent has no board to feed, so the declare-demand must never misfire on it. Its own state writes go through `spex session … --session <id>`, passing the id explicitly since there is no worktree file to read it from.
22
+
23
+ It is the enforcement edge of [[core]]: nothing leaves a session except as committed work under a truthful declaration. The freshness it reads is set by [[mark-active]].
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ stop-gate - a blocking Stop hook with TWO jobs, each with a HARD loop-break (never blocks more than
3
+ # once on the same cause, never leaks a dishonest stop):
4
+ # (A) COMMIT GATE — a done/merge proposal (awaiting + merge; legacy nothing remains readable) is rejected while the node branch has
5
+ # uncommitted work or 0 commits ahead of main; the dogfood ritual commits BEFORE proposing. Clean ->
6
+ # allow; dirty -> block once with the reason, escape on the continuation to `asking` (needs the human).
7
+ # (B) DECLARE GATE — a session may not stop in an undeclared (`active`) state:
8
+ # declared (awaiting/parked/error/asking) . allow (the agent reported; nothing to do)
9
+ # active, first stop (stop_hook_active false) .. block ONCE — instruct the agent to declare
10
+ # active, the continuation (stop_hook_active true) auto-declare `asking` and allow. Guaranteed to end
11
+ # without inventing a completion state.
12
+ # $SPEX is the PATH-independent CLI invocation (abs tsx + cli) injected by settingsArg, so the gate's own
13
+ # auto-default AND the command it shows the agent both work even when `spex` is absent from PATH.
14
+ # @@@ global store + governed gate - state lives in the per-session GLOBAL record session.json (keyed by the
15
+ # harness session_id from the payload, grouped per-project — mirrors spec-cli/src/layout.ts). The gate acts
16
+ # ONLY on a GOVERNED (dashboard-launched) session: a user-self-launched agent has no board to feed, so an
17
+ # undeclared stop is none of our business — we exit 0 SILENTLY (the bug this fixes: the declare-demand
18
+ # misfiring on a self-launched codex/claude). cwd = the session worktree (resolves the project key + the
19
+ # commit-gate's git); state writes go through `$SPEX session … --session <id>` (TS owns the JSON).
20
+ . "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
21
+ S="${SPEX:-spex}"
22
+ input=$(cat 2>/dev/null || true)
23
+ sid=$(hp_session_id "$input"); [ -n "$sid" ] || exit 0
24
+ sdir=$(hp_store_dir "$sid") || exit 0
25
+ rec="$sdir/session.json"
26
+ # non-governed (or no record) → silently let the stop through. THIS is the self-launch fix.
27
+ grep -q '^[[:space:]]*"governed"[[:space:]]*:[[:space:]]*true,\?$' "$rec" 2>/dev/null || exit 0
28
+
29
+ # read a CLOSED-VOCABULARY field (status / proposal) off its OWN line. The record is one-field-per-line from
30
+ # the single writer ([[sessions-core]]), so anchoring to the line start is what keeps a neighbouring note's
31
+ # escaped quote from being read as this field's value — the read half of the same rule that stops shell from
32
+ # ever composing the record.
33
+ jget() { sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\",\?$/\1/p" "$rec" 2>/dev/null | head -1; }
34
+ status=$(jget status)
35
+ proposal=$(jget proposal)
36
+
37
+ # the value of the payload's structured `stop_hook_active` field (true on the hook-forced continuation),
38
+ # read by field name rather than substring-sniffing the JSON blob. ([a-z]* captures true/false portably —
39
+ # BSD sed has no \| alternation.)
40
+ cont=$(printf '%s' "$input" | sed -n 's/.*"stop_hook_active"[[:space:]]*:[[:space:]]*\([a-z]*\).*/\1/p')
41
+
42
+ # @@@ eval advisory - a nudge (never a gate) emitted when a session stops CLEAN-DONE (committed work + a
43
+ # done/awaiting declaration): the agent IS the measuring hand, so an eval gap in what it just changed is a
44
+ # blind spot to flag the moment work lands. SCOPED via `spex eval lint --changed` to the nodes THIS branch
45
+ # touched — so an agent is never nagged about a score that went stale in a node it never opened (the bug
46
+ # that made three workers ask "is this mine?"). Three gap classes it surfaces: eval-drift / eval-missing
47
+ # (a node with an eval.md whose score is stale / unmeasured) and eval-coverage (a FRONTEND node with no
48
+ # eval.md — an obvious UI change carrying no loss signal). Delivered via the Stop hook's additionalContext
49
+ # (NEVER a block decision: a gap is a heads-up, not a wall). FIRES ONCE: the additionalContext itself forces
50
+ # one continuation, so the CALLER guards it on stop_hook_active — re-emitting on the forced re-stop is what
51
+ # looped 31 turns and tripped the Stop-hook block cap. Called only on ALLOW paths, never alongside a block.
52
+ #
53
+ # SURFACE-NEUTRAL: a stale/unmeasured score is refreshed only by PRODUCING the measurement on the scenario's
54
+ # OWN surface — a real run, never a desk check and never deferring to review a recording after the fact. The
55
+ # nudge privileges NO surface: `eval lint --changed` carries each drift/missing scenario's tag on its finding line
56
+ # ([[eval-core]]'s lint.scenarioTags — frontend-e2e / backend-api / cli / desktop / mobile), so the agent
57
+ # reads there WHICH surface to run. One line covers all five surfaces; there is no per-surface branch.
58
+ eval_advisory() {
59
+ local out ids n msg esc
60
+ # Codex Stop hooks reject the Claude-family `hookSpecificOutput.additionalContext` shape on allow paths.
61
+ # Keep Codex Stop stdout empty unless it is a real block decision; the dispatcher still bridges block
62
+ # reasons to Codex stderr.
63
+ [ "${SPEXCODE_HARNESS:-claude}" = codex ] && return 0
64
+ out=$($S eval lint --changed 2>&1)
65
+ n=$(printf '%s\n' "$out" | grep -cE 'eval-(drift|missing|coverage):')
66
+ [ "${n:-0}" -gt 0 ] || return 0 # no gap in what you changed (or eval lint unavailable) -> nothing to nudge
67
+ ids=$(printf '%s\n' "$out" | sed -n "s/.*eval-[a-z]*: '\([^']*\)'.*/\1/p" | awk '!seen[$0]++' | head -6 | paste -sd' ' -)
68
+ msg=$($S internal hook-prompt stop-gate --variant eval --count "$n" --ids "$ids") || return 0
69
+ esc=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g')
70
+ printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$esc"
71
+ }
72
+
73
+ # @@@ commit gate - a declaration of done/merge (awaiting + proposal merge; legacy nothing is accepted only
74
+ # for backward-readable records) is only honest once the
75
+ # node branch carries the work as COMMITS: the dogfood ritual commits spec+code BEFORE any proposal, yet a
76
+ # dashboard-launched agent kept proposing merge with 0 commits / a dirty tree. So before allowing such a
77
+ # declaration we run the deterministic check (`spex internal commit-gate`, which goes through git.ts's git()
78
+ # so the hook's GIT_DIR/GIT_INDEX_FILE can't misdirect repo discovery). Clean -> allow. Dirty/0-ahead ->
79
+ # block ONCE with the specific reason + commit instructions; on the forced continuation (the agent ignored
80
+ # it) escape the loop by downgrading to `asking` (needs the human) with a clear note, so a FALSE "ready to
81
+ # merge" never stands. (A propose-close declaration is exempt — it discards the worktree, so commits are moot.)
82
+ # The PROPOSAL rides into the check: `merge` claims there is committed work to land (so 0-ahead blocks too),
83
+ # New public `done --propose nothing` calls trap before this hook; keep its legacy branch so historic records
84
+ # stop normally instead of becoming corrupt.
85
+ if [ "${status:-active}" = awaiting ] && { [ "$proposal" = merge ] || [ "$proposal" = nothing ]; }; then
86
+ if gatemsg=$($S internal commit-gate "$proposal" 2>&1); then
87
+ # nudge ONCE: emit on the natural stop, but STAY SILENT on the forced re-stop the additionalContext
88
+ # itself causes (stop_hook_active=true). Without this guard the advisory re-fired every clean-done stop
89
+ # and looped — the bug a prior change DESCRIBED in a comment but never actually implemented at the call.
90
+ [ "$cont" != true ] && eval_advisory
91
+ exit 0 # work is committed and ahead of main -> the proposal is honest, let it stop.
92
+ fi
93
+ if [ "$cont" = true ]; then
94
+ $S session ask --session "$sid" --note "stopped with uncommitted work — commit your spec+code on the node branch, then re-declare done" >/dev/null 2>&1 || true
95
+ exit 0
96
+ fi
97
+ reason=$($S internal hook-prompt stop-gate --variant commit --reason "$gatemsg" --cli "$S" --propose "$proposal") || exit 1
98
+ esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
99
+ printf '{"decision":"block","reason":"%s"}\n' "$esc"
100
+ exit 0
101
+ fi
102
+
103
+ # Any other declared state (parked / error / asking / awaiting+close, plus legacy awaiting+nothing) stops.
104
+ [ "${status:-active}" != "active" ] && exit 0
105
+
106
+ if [ "$cont" = true ]; then
107
+ # The forced continuation also stopped without declaring. Escape into asking: no default may invent a
108
+ # completed lane, and the stopped agent now needs a human prompt to choose merge, close, ask, or park.
109
+ $S session ask --session "$sid" --note "auto: stopped without declaring — choose merge, close, ask, or park; done --propose nothing records no state" >/dev/null 2>&1 || true
110
+ exit 0
111
+ fi
112
+
113
+ # first stop in an undeclared state -> block. The FULL teaching text prints ONCE per session; every later
114
+ # undeclared stop gets a ONE-LINE version (a heavy session hits this gate 15-20x a night — re-printing the
115
+ # full menu each time is pure token noise). The once-sentinel is a plain file beside session.json in the
116
+ # session's global store dir — the same per-session-sentinel mechanism as the CLI's note-echo-taught; $sdir
117
+ # is already alias-resolved here, so a codex thread id lands on the same file, and an unwritable dir just
118
+ # teaches again (never blocks the block). The terse line must stay SELF-EXPLANATORY: an agent whose context
119
+ # was compacted may never have seen the full text, so the line carries the whole command menu, the
120
+ # declare-LAST discipline, and the `help session` entry that re-explains each choice's condition — every bit
121
+ # of the full-to-terse information gap is recoverable from the entry, none of it from memory.
122
+ taught="$sdir/stop-gate-taught"
123
+ if [ -f "$taught" ]; then
124
+ reason=$($S internal hook-prompt stop-gate --variant terse --cli "$S") || exit 1
125
+ esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
126
+ printf '{"decision":"block","reason":"%s"}\n' "$esc"
127
+ exit 0
128
+ fi
129
+ touch "$taught" 2>/dev/null || true
130
+ # The full reason names the PATH-independent CLI ($S) ONCE as a shared `<CLI> session <choice>` prefix, then
131
+ # lists the four real states plus the `nothing` trap as a compact newline menu of bare subcommands — so the
132
+ # terminal output stays legible instead of repeating the long abs path per option. It EMPHASIZES that each
133
+ # state is a CLAIM others act on (not a box to tick to end the turn) and gives the precise APPLICATION CONDITION
134
+ # for each — so the agent picks the TRUE one. park is policed hardest because a false park (no real background task) reads on the
135
+ # board as "fine, self-resuming" when the agent actually needs the human, which is the most damaging mislabel.
136
+ # It ends with the ORDERING discipline — declare LAST, then stop — because a declaration followed by more
137
+ # tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
138
+ # this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
139
+ # kills the park->block->re-park loop at its source lives here.
140
+ if [ -s "$sdir/files.json" ] && grep -qE '"[^"]+"' "$sdir/files.json"; then
141
+ reason=$($S internal hook-prompt stop-gate --variant artifact) || exit 1
142
+ esc=$(printf '%s' "$reason" | sed 's/[\\"]/\\&/g')
143
+ printf '{"decision":"block","reason":"%s"}\n' "$esc"
144
+ exit 0
145
+ fi
146
+ reason=$($S internal hook-prompt stop-gate --variant full --cli "$S") || exit 1
147
+ esc=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}')
148
+ printf '{"decision":"block","reason":"%s"}\n' "$esc"
149
+ exit 0
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: atomic-landing
3
+ surface: system
4
+ status: active
5
+ hue: 30
6
+ desc: A config plugin — the trunk checkout is the fleet's one landing door, so a merge must be trivial by the time it reaches it: sync and resolve in YOUR OWN worktree, land only when your branch already contains the trunk, and wait rather than race for a busy door.
7
+ code:
8
+ ---
9
+ ## Landing is atomic
10
+
11
+ 1. In your worktree, merge `<base>` into the branch, resolve conflicts there, and rerun the proof.
12
+ 2. Immediately before landing, require `git merge-base --is-ancestor <base> <branch>`; otherwise sync again.
13
+ 3. A clean textual merge is not product proof; the synced branch's verification is required.
14
+
15
+ `spex guide spec` has the shared-checkout mid-merge rule.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: forge-link
3
+ surface: system
4
+ status: active
5
+ hue: 280
6
+ desc: A config plugin — agents link an issue or change request opened through the resolved forge to the spec node it serves via one `Spec: <id>` body line.
7
+ code:
8
+ ---
9
+ Every forge issue or change request body includes `Spec: <node-id>` (comma-separate several). Use each node's
10
+ leaf directory name, not its slash path; `spex graph --json` lists valid ids.
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: memory-hygiene
3
+ surface: system
4
+ status: active
5
+ hue: 200
6
+ desc: A config plugin — keep the project-keyed agent memory free of session- and role-specific facts, so N agents in one folder never inherit a confused identity.
7
+ code:
8
+ ---
9
+ ## Memory hygiene
10
+
11
+ Project memory is shared by the main checkout and all worktrees. Store only durable cross-session project/user
12
+ facts. Never store this task, transient worktree state, a one-off decision, role, or identity. On a non-main
13
+ `node/<id>` worktree, record no memory at all; land a durable lesson first, then record it from main.
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: reproduce-before-fix
3
+ surface: system
4
+ status: active
5
+ hue: 140
6
+ desc: A config plugin — a bug fix must first REPRODUCE the failure as a failing eval, then fix, verify, commit, and file the passing eval. The fail→pass pair on one scenario is the fix's proof (the A/B).
7
+ code:
8
+ ---
9
+ ## Reproduce before you fix
10
+
11
+ For a bug fix, the fail→pass pair on one scenario is the repair proof. New intent has no prior failure to
12
+ reproduce; `spex guide eval` has the A/B filing sequence.
@@ -0,0 +1,24 @@
1
+ ---
2
+ title: prompts
3
+ status: active
4
+ hue: 110
5
+ desc: Grouping shelf for the auxiliary `surface: system` prompt contracts — single-body always-on prose. Core is NOT a resident — the core contract subsystem is a direct `.plugins` child. A shelf, not a surface — routing stays field-driven per surface.
6
+ ---
7
+ # prompts
8
+
9
+ The **auxiliary** `surface: system` prompt contracts live here: leaf plugins whose whole substance is one
10
+ prose body an agent must always carry — materialized (in name order, together with every other system
11
+ body) into the `<!-- spexcode -->` contract block — rather than a verb it invokes. Grouping them keeps
12
+ `.plugins/` legible at a glance: peripheral prompt contracts on this shelf; the command presets on
13
+ [[commands]], the skill plugins on [[skills]], and [[core]] as a flat child beside it.
14
+
15
+ The shelf boundary is **leafness, not surface**: a single-body prose contract shelves here, while a
16
+ contract that anchors its own subtree outranks the shelf — [[core]], the dev-flow contract subsystem
17
+ whose children are the `surface: hook` gates, is a *peer* of this shelf, never a resident.
18
+
19
+ This node is a **shelf, not a surface**: it declares no `surface` field and gathers nothing itself.
20
+ Discovery is recursive and field-driven (surface), so residents plug in exactly as they would at the
21
+ root — nothing about materialization order (name order, unchanged names) or gathering changes with the
22
+ path. The init templates mirror this layout — a fresh `spex init` (per init-preset) seeds `core`
23
+ flat and its auxiliary system plugins under the same `prompts/` shelf. The shelf stays pure presentation:
24
+ changing this path changes neither a plugin's identity nor its gathered surface.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // digest.mjs — locate a dead session's transcript on disk and print a compact markdown digest.
3
+ // Usage: node digest.mjs <session-id | path/to/transcript.jsonl>
4
+ // Read-only: never resumes, prompts, or mutates the session. Exit 1 (loud) when no transcript is found.
5
+ //
6
+ // Harness coverage: claude (projects/<enc-cwd>/<id>.jsonl) and codex (sessions/YYYY/MM/DD/rollout-*<id>.jsonl).
7
+ // The digest keeps the high-signal stream — user prompts in full, assistant text, tool calls as one-liners,
8
+ // error results — and drops the bulk (tool outputs, attachments, sidechains, reasoning).
9
+
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+
14
+ const arg = process.argv[2]
15
+ if (!arg) { console.error('usage: digest.mjs <session-id | transcript.jsonl>'); process.exit(1) }
16
+
17
+ const TRUNC = (s, n) => { s = String(s ?? '').trim(); return s.length > n ? s.slice(0, n) + ` …[+${s.length - n} chars]` : s }
18
+
19
+ // ---- locate -------------------------------------------------------------
20
+ const claudeRoots = () => {
21
+ const roots = []
22
+ if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
23
+ for (const e of readdirSync(homedir(), { withFileTypes: true }))
24
+ if (e.isDirectory() && e.name.startsWith('.claude')) roots.push(join(homedir(), e.name))
25
+ return roots
26
+ }
27
+ const findClaude = (id) => {
28
+ for (const root of claudeRoots()) {
29
+ const proj = join(root, 'projects')
30
+ if (!existsSync(proj)) continue
31
+ for (const d of readdirSync(proj)) {
32
+ const f = join(proj, d, `${id}.jsonl`)
33
+ if (existsSync(f)) return f
34
+ }
35
+ }
36
+ return null
37
+ }
38
+ const findCodex = (id) => {
39
+ const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
40
+ if (!existsSync(root)) return null
41
+ const stack = [root]
42
+ while (stack.length) {
43
+ const dir = stack.pop()
44
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
45
+ if (e.isDirectory()) stack.push(join(dir, e.name))
46
+ else if (e.name.startsWith('rollout-') && e.name.includes(id) && e.name.endsWith('.jsonl')) return join(dir, e.name)
47
+ }
48
+ }
49
+ return null
50
+ }
51
+ const file = arg.endsWith('.jsonl') ? arg : (findClaude(arg) || findCodex(arg))
52
+ if (!file || !existsSync(file)) { console.error(`no transcript found for "${arg}" (searched claude projects/ and codex sessions/)`); process.exit(1) }
53
+
54
+ // ---- parse --------------------------------------------------------------
55
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean)
56
+ const out = []
57
+ const meta = { cwd: null, branch: null, first: null, last: null }
58
+ const filesTouched = new Set()
59
+ let errors = 0
60
+ const seen = new Set()
61
+
62
+ // harness-injected preamble arrives typed as "user" (system-reminders, AGENTS.md folds, codex permission/skill
63
+ // blocks) — it is noise to a digest, and real human prompts essentially never open with these markers.
64
+ const isInjected = (t) => /^(<|# AGENTS\.md instructions|Caveat: The messages below)/.test(t.trimStart())
65
+ const userBlock = (t) => { t = String(t ?? '').trim(); return t && !isInjected(t) ? `\n## user\n${t}` : null }
66
+
67
+ const toolLine = (name, input = {}) => {
68
+ if ((name === 'Edit' || name === 'Write' || name === 'NotebookEdit') && input.file_path) filesTouched.add(input.file_path)
69
+ const hint = input.description || input.file_path || input.command || input.prompt || input.query || input.pattern || ''
70
+ return `→ ${name}${hint ? ` · ${TRUNC(hint, 160)}` : ''}`
71
+ }
72
+
73
+ for (const raw of lines) {
74
+ let l; try { l = JSON.parse(raw) } catch { continue }
75
+ const ts = l.timestamp
76
+ if (ts) { meta.first ||= ts; meta.last = ts }
77
+ if (l.cwd) meta.cwd ||= l.cwd
78
+ if (l.gitBranch && l.gitBranch !== 'HEAD') meta.branch ||= l.gitBranch
79
+
80
+ // claude shape: {type: user|assistant, message:{content}, isSidechain}
81
+ if (l.type === 'user' || l.type === 'assistant') {
82
+ if (l.isSidechain) continue // subagent noise
83
+ const c = l.message?.content
84
+ if (typeof c === 'string') { const u = userBlock(c); if (u) out.push(u); continue }
85
+ for (const item of c || []) {
86
+ if (item.type === 'text' && item.text?.trim()) {
87
+ if (l.type === 'user') { const u = userBlock(item.text); if (u) out.push(u) }
88
+ else out.push(TRUNC(item.text, 2000))
89
+ }
90
+ else if (item.type === 'tool_use') out.push(toolLine(item.name, item.input))
91
+ else if (item.type === 'tool_result' && item.is_error) {
92
+ errors++
93
+ const t = Array.isArray(item.content) ? item.content.map((x) => x.text || '').join(' ') : item.content
94
+ out.push(`⚠ tool error: ${TRUNC(t, 400)}`)
95
+ }
96
+ }
97
+ continue
98
+ }
99
+
100
+ // codex shape: {type: session_meta|response_item|event_msg, payload:{...}}. The same message is logged
101
+ // BOTH as a response_item and an event_msg (version-dependent which exists) — dedupe on the text.
102
+ const p = l.payload
103
+ if (!p) continue
104
+ if (l.type === 'session_meta') { meta.cwd ||= p.cwd; continue }
105
+ const pushMsg = (role, text) => {
106
+ text = String(text ?? '').trim()
107
+ if (!text || seen.has(text)) return
108
+ seen.add(text)
109
+ if (role === 'user') { const u = userBlock(text); if (u) out.push(u) }
110
+ else if (role === 'assistant') out.push(TRUNC(text, 2000)) // developer/system roles are harness plumbing
111
+ }
112
+ if (l.type === 'event_msg' && (p.type === 'user_message' || p.type === 'agent_message'))
113
+ pushMsg(p.type === 'user_message' ? 'user' : 'assistant', p.message)
114
+ else if (l.type === 'response_item') {
115
+ if (p.type === 'message') {
116
+ pushMsg(p.role, (p.content || []).map((x) => x.text || '').join('\n'))
117
+ } else if (p.type === 'function_call') {
118
+ let input = {}; try { input = JSON.parse(p.arguments || '{}') } catch {}
119
+ out.push(toolLine(p.name, input))
120
+ } else if (p.type === 'function_call_output' && /error/i.test(String(p.output).slice(0, 200))) {
121
+ errors++
122
+ out.push(`⚠ tool error: ${TRUNC(p.output, 400)}`)
123
+ }
124
+ }
125
+ }
126
+
127
+ // ---- emit ---------------------------------------------------------------
128
+ const kb = Math.round(statSync(file).size / 1024)
129
+ console.log(`# transcript digest — ${arg}`)
130
+ console.log(`- file: ${file} (${kb} KB, ${lines.length} lines)`)
131
+ if (meta.first) console.log(`- span: ${meta.first} → ${meta.last}`)
132
+ if (meta.cwd) console.log(`- cwd: ${meta.cwd}`)
133
+ if (meta.branch) console.log(`- branch: ${meta.branch}`)
134
+ console.log(out.join('\n'))
135
+ console.log(`\n---\n- transcript: ${file}\n- tool errors seen: ${errors}`)
136
+ if (filesTouched.size) console.log(`- files edited:\n${[...filesTouched].map((f) => ` - ${f}`).join('\n')}`)
@@ -0,0 +1,67 @@
1
+ ---
2
+ title: distill
3
+ surface: skill, command
4
+ status: active
5
+ hue: 210
6
+ desc: Use when the user wants to inherit a finished, dead, or abandoned session — "distill session X / 继承那个 session 的经验 / 接手它的工作 / harvest or salvage a session". Given a SpexCode session id, harness thread id, or transcript path, read it without resuming, extract decisions, traps, and next actions, salvage unmerged work, retire only resources proven safe to remove, then rename the inheriting session to mark the handoff.
7
+ ---
8
+
9
+ # distill
10
+
11
+ Inherit a finished session's **mind and desk** without waking it: mind is its transcript; desk is its
12
+ worktree and branch. **Never resume, reopen, send to, or otherwise re-prompt the old session.** Read files
13
+ and git state only until its work has landed.
14
+
15
+ ## Resolve and digest
16
+
17
+ Accept a SpexCode session id, a Claude/Codex thread id, or a transcript `.jsonl` path.
18
+
19
+ - For a SpexCode id, find `~/.spexcode/projects/*/sessions/<id>/session.json` (a unique prefix is enough).
20
+ Keep its prompt, `worktree_path`, `branch`, `harness`, and `harness_session_id`; Claude uses the session
21
+ id as its transcript id, while Codex uses `harness_session_id`.
22
+ - Otherwise pass the harness id or transcript path directly. The digest header supplies its `cwd` and branch
23
+ when recorded; those locate the desk even when it belongs to another repository.
24
+
25
+ From the repository root run the plugin's co-located digest:
26
+
27
+ `node .spec/*/.plugins/skills/distill/digest.mjs <id-or-path>`
28
+
29
+ It searches the harness's normal local transcript stores and prints human prompts, agent text, concise tool
30
+ calls, errors, metadata, edited files, and the raw path. A miss fails loudly; never recover by waking the old
31
+ session. For output above about 100 KB, have a subagent return only the distillation below.
32
+
33
+ ## Distill forward
34
+
35
+ Do not paste the transcript or retell changes git already records. Preserve what git does not:
36
+
37
+ - goal, actual stopping point, and whether anything landed;
38
+ - decisions and rejected alternatives, with reasons;
39
+ - failures, dead ends, and user corrections;
40
+ - unfinished work and next actions;
41
+ - edited files, relevant spec nodes, and the raw transcript path.
42
+
43
+ ## Salvage, then retire
44
+
45
+ Use the record or digest to inspect the old repository's worktree, branch, merge base, commits, dirty files,
46
+ and untracked files. Cross-check the digest's edited-file list because some work may live outside that
47
+ worktree. Distinguish an already-merged branch from a branch equal to its merge base, which never committed.
48
+
49
+ Carry unmerged commits onto the current branch with `git cherry-pick <base>..<branch>`; if replay is
50
+ unsuitable, apply the branch diff. Apply dirty changes and copy needed untracked files, then commit them
51
+ with the source session named. Keep authorship and `Session:` trailers where possible.
52
+
53
+ Only after verifying the salvage is present (or the branch truly merged), retire a SpexCode session with
54
+ `spex session close <id>`, or remove a bare worktree and then its branch. If proof is incomplete, keep the
55
+ resources and report why.
56
+
57
+ `<id>` is the OLD session's id — the one you are inheriting, spelled out. It is never `.` and never your own
58
+ id: `.` means THIS session (the rename step below relies on that), and closing yourself deletes the worktree
59
+ you are running in, mid-turn, along with your branch and record. Retiring the source is the only close this
60
+ skill asks for; your own ending stays a declaration.
61
+
62
+ ## Rename yourself
63
+
64
+ Last step: mark the inheritance on the board. If you run as a SpexCode session (inside a session
65
+ worktree), `spex session rename . "<name>"` — `.` selects this worktree's own session. Name what this
66
+ session now carries — the inherited goal going forward, not the old session's id and no `distill:`
67
+ prefix. Outside a SpexCode session there is nothing to rename; skip this step.
@@ -0,0 +1,19 @@
1
+ ---
2
+ title: skills
3
+ status: active
4
+ hue: 280
5
+ desc: Grouping shelf for the `surface: skill` plugins — the harness skills an agent invokes on demand. A shelf, not a surface — routing stays field-driven per surface.
6
+ ---
7
+ # skills
8
+
9
+ The invocable **skill** plugins live here: leaf plugins that materialize into the harness's skill dir,
10
+ where an agent invokes them on demand, each carrying `surface: skill`. Grouping them keeps `.plugins/`
11
+ legible at a glance — the skill plugins on this shelf, the command presets on [[commands]], the auxiliary
12
+ system contracts on [[prompts]], with [[core]] a flat child beside them.
13
+
14
+ This node is a **shelf, not a surface** (the [[prompts]] shape): it declares no `surface` field and
15
+ gathers nothing itself. Discovery is recursive and field-driven (surface), so a resident plugs in
16
+ exactly as it would at the root. A plugin that serves BOTH surfaces — [[distill]] is skill and command —
17
+ shelves here by its primary (skill) identity and still gathers as a command through its field. The init
18
+ templates mirror this layout. The shelf stays pure presentation: moving a resident beneath it changes
19
+ neither that plugin's identity nor the surfaces gathered from its frontmatter.
@@ -0,0 +1,23 @@
1
+ ---
2
+ title: .plugins
3
+ status: active
4
+ hue: 110
5
+ desc: The instance home — SpexCode's DIY dev-flow plugins live here as skill-shaped plugin nodes.
6
+ ---
7
+ `.plugins/` is the **instance** of the plugin system: the concrete dev-flow plugins SpexCode ships for
8
+ working in this repo. Each plugin is a skill-shaped node — its folder *is* the unit (a `spec.md` plus any
9
+ co-located scripts) — carrying a `surface: command|system|…` field that names where it plugs in, per
10
+ plugin-system's surface field-driven routing. Discovery is recursive, so a plugin may sit under a
11
+ grouping shelf: the auxiliary `surface: system` prompt contracts live under [[prompts]], the `surface:
12
+ command` presets under [[commands]], the `surface: skill` plugins under [[skills]], the `surface: review`
13
+ remark presets under review, while [[core]] — the dev-flow contract subsystem whose children are the
14
+ `surface: hook` gates — sits as a flat child beside them.
15
+
16
+ `/api/plugins` and the launcher's system gather read from here, not from plugin-system (which holds the
17
+ *spec of the plugin system* itself). Only **built/active** plugins gather — a `pending` node is declared
18
+ intent, not yet an active plugin, so it renders on the board but is neither offered as a command preset nor
19
+ materialized into the agent's contract.
20
+
21
+ Which plugins `spex init` ships is the init-preset rule. `seed: false` excludes a plugin subtree; shared
22
+ plugins have one body and one helper set — there is no separately authored adopter variant. Dogfood eval
23
+ scenarios/readings remain with the implementation and git history they measure.
@@ -0,0 +1,20 @@
1
+ ---
2
+ title: project
3
+ status: active
4
+ hue: 45
5
+ desc: The root spec node — the founding intent this repo's spec tree hangs from. Rewrite it to your own.
6
+ ---
7
+ # project
8
+
9
+ The root of your spec tree. In SpexCode the spec tree IS ground truth and git is its database: every
10
+ node is a `spec.md` stating present intent, and each version change is attributed to the session that
11
+ made it. This node is the founding spec everything else hangs from — **rewrite this body to describe
12
+ your own project**, then grow child package/feature nodes beneath it (each its own directory with a
13
+ `spec.md`).
14
+
15
+ `.plugins/` holds the dev-flow plugins this instance ships — spec-shaped child nodes whose `surface`
16
+ field says how they enter the product. The initialized set includes always-on `system` contracts,
17
+ lifecycle `hook` handlers, new-session `command` presets, and an on-demand `skill`; one plugin may serve
18
+ more than one surface. Grouping shelves carry no surface themselves, and discovery is recursive, so the
19
+ folder layout stays readable without deciding behavior. Add, edit, or remove plugins by editing those
20
+ spec nodes.