@techgoblin/gobstack 0.5.0-beta.2 → 0.5.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ After installing, in this order:
56
56
 
57
57
  cd <target> && git add -A && git commit # the install is a change like any other
58
58
  gob verify # or .goblin/bin/goblin-verify, inside the target
59
- gob emit --platform <p> # optional, per platform: the agent skills are an opt-in
59
+ gob sync --platform <p> # optional, per platform: the agent skills are an opt-in (gob emit is the same verb)
60
60
  gob audit # once, deliberately: the ONLY network step (SC-07)
61
61
 
62
62
  **A default software-class install (no agent skills — those are `gob emit`'s job) verifies green —
@@ -92,12 +92,13 @@ The measurement and the vacuous-pass reading are in `docs/CONTRACTS.md`.
92
92
  | `gob verify` | run the rule matrix against the current repo — `PASS`/`FAIL`/`SKIP` per row, exit 0 pass · 1 a check failed · 2 could not run · 3 the manifest is broken |
93
93
  | `gob bans` | run the ban list (per-pattern red lines over the source tree) |
94
94
  | `gob audit` | check recorded dependency claims against live advisory feeds — the only command that touches the network |
95
- | `gob install` | install the harness into a target repo: manifest, verifier, gates, HANDOFF — no agent skills (those are an opt-in: `gob emit --platform <p>`, or `--skills yes`) |
95
+ | `gob install` | install the harness into a target repo: manifest, verifier, gates, HANDOFF — no agent skills (those are an opt-in: `gob sync --platform <p>`, or `--skills yes`); `--ci-gate yes|no` opts the CI lane in or out (default: the class decides) |
96
96
  | `gob uninstall` | remove everything an install wrote, byte-exactly (`gob install --target <dir> --uninstall` is the same job) |
97
97
  | `gob upgrade` | migrate a repo to the shared global engine at `~/.goblin/engine` — 8 steps, two commits, one report |
98
98
  | `gob doctor` | one run across the platforms below: DETECTED / NOT-DETECTED / DRIFT per platform |
99
- | `gob emit` | write the skills + context block for one platform (`--scope project` or `global`); `--unshadow` removes a hermes project skill whose hash equals the source |
100
- | `gob init` | the first-run wizard: detect → class → branch/email → first gate → emit → verify, one screen per question; every question has a flag (`--class software --branch main --email a@b.c --gate 'cmd' --emit hermes`), so CI runs it with zero prompts; `--dry-run` prints the plan and writes nothing |
99
+ | `gob emit` | write the skills + context block for one platform (`--scope project` or `global`); `--unshadow` removes a hermes project skill whose hash equals the source; `gob sync` is the same command under its friendlier name — both spellings work |
100
+ | `gob sync` | the emit verb, renamed (wizard v2): same engine, same flags, same exit contract; `gob emit --help` and `gob sync --help` are byte-identical apart from the verb name |
101
+ | `gob init` | the first-run wizard: detect → class → identity → health check → ci → sync → done, one screen per question; every question has a flag (`--class software --branch main --email a@b.c --gate 'cmd' --emit hermes`), so CI runs it with zero prompts; the ci step defaults to no — nothing under .github/ unless you opt in (`--ci-gate yes|no` overrides); `--dry-run` prints the plan and writes nothing |
101
102
 
102
103
  `goblin` remains as a legacy alias for every command above — existing scripts keep working, but
103
104
  new commands and docs use `gob`.
@@ -122,10 +123,11 @@ prints the full write plan first.
122
123
 
123
124
  **Agent skills are opt-in.** A `gob install` writes the neutral harness only — `.goblin/`,
124
125
  `HANDOFF.md`, `AGENTS.md`, the checks and the `.gitignore` block; no skills directory, and no
125
- files belonging to any coding agent. The guided path is `gob init`'s emit screen; the one-shot
126
- path is `gob emit --platform <p>` after installing. Repos whose install predates the opt-in
127
- default keep their skills through `gob upgrade` (the install record names them; only an explicit
128
- `--skills no`, or `--uninstall`, removes them).
126
+ files belonging to any coding agent. The guided path is `gob init`'s sync screen; the one-shot
127
+ path is `gob sync --platform <p>` after installing (`gob emit --platform <p>` is the same
128
+ command under its original name — both work byte-identically). Repos whose install predates the
129
+ opt-in default keep their skills through `gob upgrade` (the install record names them; only an
130
+ explicit `--skills no`, or `--uninstall`, removes them).
129
131
 
130
132
  ## What it is not
131
133
 
package/bin/goblin CHANGED
@@ -5,7 +5,7 @@
5
5
  # gob bans [--only <id[,id...]>] [--list]
6
6
  # gob audit [--target <dir>] [--print]
7
7
  # gob doctor [--platform <p>] # W4a
8
- # gob emit --platform <p> [...] # W4a/W4b
8
+ # gob emit --platform <p> [...] # W4a/W4b; `gob sync` is the same verb, renamed
9
9
  # gob init [...] # W6: the first-run wizard
10
10
  # gob upgrade [--target .] [...] # W3
11
11
  # gob --version
@@ -46,7 +46,8 @@ gob — the gobstack command line.
46
46
  gob bans [--only <id[,id...]>] [--list]
47
47
  gob audit [--target <dir>] [--print]
48
48
  gob doctor [--platform <p>] [--target <dir>]
49
- gob emit --platform <p> --scope project|global [...]
49
+ gob emit --platform <p> --scope project|global [...] # alias: gob sync
50
+ gob sync --platform <p> --scope project|global [...] # the same verb, friendlier name
50
51
  gob init [--target <dir>] [--class app|A-F] [--dry-run]
51
52
  gob upgrade [--target .] [--dry-run] [--yes] [--engine-dir <path>]
52
53
  gob --version
@@ -94,6 +95,11 @@ case "$CMD" in
94
95
  # same three values verify's wrapper states, propagated verbatim.
95
96
  exec bash "$SRC/bin/goblin-emit" "$@"
96
97
  ;;
98
+ sync)
99
+ # Wizard v2: the friendlier name for the same emission engine. `emit` stays a
100
+ # first-class verb; this case only adds the alias and propagates identically.
101
+ exec bash "$SRC/bin/goblin-emit" "$@"
102
+ ;;
97
103
  init)
98
104
  # W6: the first-run wizard. It drives install/emit/verify and propagates their
99
105
  # exit contract verbatim, like every other subcommand here.
package/bin/goblin-emit CHANGED
@@ -68,9 +68,9 @@ PREIMG="${GOBLIN_PREIMAGES:-$HOME/.goblin-stack/preimages}"
68
68
 
69
69
  usage() {
70
70
  cat <<USAGE
71
- gob emit — per-platform emission (W4a, the seven platforms since W4b).
71
+ gob sync — per-platform skill sync (W4a, the seven platforms since W4b).
72
72
 
73
- gob emit --platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>
73
+ gob sync --platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>
74
74
  --scope project|global
75
75
  [--skills core|all|none] [--target <dir>] [--source <path>]
76
76
  [--uninstall] [--unshadow] [--dry-run] [--strict]
@@ -86,6 +86,8 @@ gob emit — per-platform emission (W4a, the seven platforms since W4b).
86
86
  --dry-run print the full write plan (every path + the priced index size), write nothing
87
87
  --strict make a NOT-DETECTED platform a hard error instead of a project-scope allowance
88
88
 
89
+ `gob emit` is the same command under its original name — both names work, byte-identically.
90
+
89
91
  Exit codes: 0 ok or no-op | 1 refusal (with the path and the fix) | 2 bad input.
90
92
  USAGE
91
93
  }
@@ -143,7 +145,7 @@ case "$PLATFORM" in
143
145
  "") printf 'error: emit: --platform is required\n' >&2; usage >&2; exit 2 ;;
144
146
  $_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2) ;;
145
147
  *)
146
- die "unknown platform '$PLATFORM' - gob emit ships $_PLA, hermes, copilot, cursor, opencode, codex, $_G1$_G2" 2
148
+ die "unknown platform '$PLATFORM' - gob sync ships $_PLA, hermes, copilot, cursor, opencode, codex, $_G1$_G2" 2
147
149
  ;;
148
150
  esac
149
151
  TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
package/bin/goblin-init CHANGED
@@ -3,9 +3,13 @@
3
3
  #
4
4
  # gob init [--target <dir>] [--class <software|service|game|research|fleet|A..E|app|agent|desktop>]
5
5
  # [--electron] [--branch <name>] [--email <addr>] [--gate <cmd>]
6
- # [--emit <p[,p..]>] [--scope project|global] [--yes] [--dry-run]
6
+ # [--ci-gate yes|no] [--emit <p[,p..]>] [--scope project|global] [--yes] [--dry-run]
7
7
  #
8
- # One screen per question, answered steps collapsing into the ✔/◆/○ rail above. On a tty
8
+ # Seven screens, one per question, answered steps collapsing into the ✔/◆/○ rail above.
9
+ # On a tty the class and ci steps are a KEYBOARD RADIO (arrows move the ▸ pointer, Enter
10
+ # commits; a typed number still works), the emit step is CHECKBOXES (space toggles ☑/☐,
11
+ # Enter commits), and the health check and identity prompts show their accepted default in
12
+ # the [default] form. On a tty
9
13
  # the whole wizard frame (logotype, header, answered rows, rail) is redrawn in place from
10
14
  # a saved buffer of the wizard's own lines — never by counting printed output, which the
11
15
  # installer/emit engines would break. Every question has a flag: with the flags set (or
@@ -16,7 +20,7 @@
16
20
  # The wizard is a FRONT END over the existing engines, never a second installer:
17
21
  # .goblin/ + the payload -> bin/goblin-install (its refusal contract — an existing
18
22
  # HANDOFF.md is never overwritten — applies verbatim)
19
- # skills + context blocks -> bin/goblin-emit --scope project (the same code path)
23
+ # skills + context blocks -> gob sync (= gob emit; the same code path, friendlier name)
20
24
  # the verdict -> bin/goblin-verify
21
25
  #
22
26
  # Exit codes: 0 done | 1 a refusal (named path + fix) | 2 bad input, or verify could
@@ -69,13 +73,14 @@ if [ "$TTY_OUT" -eq 1 ] && [ -z "${NO_COLOR:-}" ]; then
69
73
  fi
70
74
 
71
75
  # ---------------------------------------------------------------- the rail ----
72
- # 6 steps; the ✔/◆/○ vocabulary. The rail is redrawn in place on a tty from a SAVED
76
+ # 7 steps; the ✔/◆/○ vocabulary. The rail is redrawn in place on a tty from a SAVED
73
77
  # BUFFER of the wizard's own lines (RAIL_BUF) — never by counting the output between
74
78
  # draws, because the installer/emit engines print freely and break naive accounting.
75
79
  # Over a pipe nothing is saved and nothing rewinds: each screen is one numbered line.
76
- STEP_LABELS=("platforms" "class" "branch/email" "first gate" "emit" "done")
77
- STEP_VAL=("" "" "" "" "" "")
80
+ STEP_LABELS=("detect" "class" "identity" "health check" "ci" "sync" "done")
81
+ STEP_VAL=("" "" "" "" "" "" "")
78
82
  CURRENT=1
83
+ N_STEPS=7
79
84
 
80
85
  # rail_say: one line of wizard output on a tty — stored in the redraw buffer AND
81
86
  # printed with a VT100 clear-line prefix (the redraw may overwrite a longer old line).
@@ -104,12 +109,13 @@ rail_note() {
104
109
  }
105
110
  rail_rewind() {
106
111
  if [ "$TTY_OUT" -eq 1 ]; then
107
- # The drawn frame is the buffer PLUS the 6 rail rows rail_draw prints under it.
108
- # Rewinding by the buffer count alone left the 6 rail rows painted on screen, so the
109
- # next draw stacked a second frame under them — the duplicate-banner the client caught
110
- # on a Mac (2026-10-06): every screen ≥2 grew the stack by one frame. Rewind the full
111
- # drawn height. (Clean-room catch, second fixture round.)
112
- printf '\033[%dA\033[J' "$(( ${#RAIL_BUF[@]} + 6 ))"
112
+ # The drawn frame is the buffer PLUS the N_STEPS rail rows rail_draw prints under
113
+ # it. Rewinding by the buffer count alone left the rail rows painted on screen, so
114
+ # the next draw stacked a second frame under them — the duplicate-banner the client
115
+ # caught on a Mac (2026-10-06): every screen ≥2 grew the stack by one frame. Rewind
116
+ # the full drawn height; the count is N_STEPS, so the rail growing again (wizard v2:
117
+ # 6 steps to 7) cannot reintroduce the stack.
118
+ printf '\033[%dA\033[J' "$(( ${#RAIL_BUF[@]} + N_STEPS ))"
113
119
  fi
114
120
  }
115
121
  rail_draw() { # reprint the saved buffer, then the live rail row for $CURRENT
@@ -118,7 +124,7 @@ rail_draw() { # reprint the saved buffer, then the live rail row for $CURRENT
118
124
  for line in "${RAIL_BUF[@]}"; do
119
125
  printf '\033[2K%s\n' "$line"
120
126
  done
121
- for i in 1 2 3 4 5 6; do
127
+ for i in 1 2 3 4 5 6 7; do
122
128
  label="${STEP_LABELS[$((i - 1))]}"
123
129
  if [ "$i" -lt "$CURRENT" ]; then
124
130
  glyph="✔"; col="$C_GREEN"
@@ -132,20 +138,135 @@ rail_draw() { # reprint the saved buffer, then the live rail row for $CURRENT
132
138
  }
133
139
  rail_reset() { RAIL_BUF=(); }
134
140
  cascade() { # the pipe-degraded shape: one numbered line per screen (zero cursor motion)
135
- printf 'gob init [%d/6] %s\n' "$CURRENT" "$1"
141
+ printf 'gob init [%d/%d] %s\n' "$CURRENT" "$N_STEPS" "$1"
136
142
  }
137
143
 
138
- # ask_value <label> <default> -> REPLY (Enter = default; non-tty stdin = default)
139
144
  ask_value() {
140
145
  local label="$1" dflt="$2"
141
146
  if [ "$TTY_IN" -eq 0 ]; then REPLY="$dflt"; return 0; fi
142
- printf ' %s%s%s %s─ %s' "$C_TEXT" "$label" "$C_RESET" "$C_DIM" "$dflt"
147
+ # The [default] form: what Enter accepts sits on the line, in brackets, before the cursor.
148
+ printf ' %s%s%s %s[%s]%s ' "$C_TEXT" "$label" "$C_RESET" "$C_DIM" "$dflt" "$C_RESET"
143
149
  read -r REPLY
144
150
  rail_say ""
145
151
  [ -n "$REPLY" ] || REPLY="$dflt"
146
152
  return 0
147
153
  }
148
154
 
155
+ # ---- the interactive selection primitives (tty only) --------------------------
156
+ # The class and ci steps are a RADIO: one ▸ pointer, arrows move it, Enter commits
157
+ # (the number-then-Enter path keeps working). The emit step is CHECKBOXES: space toggles
158
+ # the pointed row between ☑ and ☐, Enter commits. Raw mode is taken for one key and
159
+ # ALWAYS restored on the same line — a wizard that dies in raw mode bricks the shell.
160
+ KEY_UP=$'\033[A'; KEY_DOWN=$'\033[B'
161
+ tty_key() { # -> REPLY_KEY: UP | DOWN | SPACE | ENTER | <char>
162
+ local k rest
163
+ REPLY_KEY="ENTER"
164
+ [ "$TTY_IN" -eq 1 ] || return 0
165
+ stty raw -echo 2>/dev/null
166
+ k=$(dd bs=1 count=1 2>/dev/null)
167
+ rest=""
168
+ if [ "$k" = "$(printf '\033')" ]; then
169
+ rest=$(dd bs=1 count=2 2>/dev/null)
170
+ fi
171
+ stty sane 2>/dev/null
172
+ case "$k" in
173
+ "$(printf '\033')")
174
+ case "$rest" in
175
+ "[A") REPLY_KEY="UP" ;;
176
+ "[B") REPLY_KEY="DOWN" ;;
177
+ *) REPLY_KEY="OTHER" ;;
178
+ esac ;;
179
+ ""|$(printf '\r')|$(printf '\n')) # Enter: CR raw, LF cooked/ICRNL, LF from script(1)
180
+ REPLY_KEY="ENTER" ;;
181
+ " ") # a literal space byte — dd bs=1 count=1 hands it through unreduced
182
+ REPLY_KEY="SPACE" ;;
183
+ *) REPLY_KEY="$k" ;;
184
+ esac
185
+ return 0
186
+ }
187
+
188
+ # ask_radio <prompt> <var-out> <option-label>... -> sets <var-out> to the CHOSEN LABEL.
189
+ # Space/Enter on the pointed row commits; arrows move; a digit pre-positions then commits.
190
+ ask_radio() {
191
+ local prompt="$1" out="$2"; shift 2
192
+ local opts=("$@") n=$# sel=0 i key redraw
193
+ if [ "$TTY_IN" -ne 1 ]; then sel=0; printf -v "$out" '%s' "${opts[$sel]}"; return 0; fi
194
+ redraw=1
195
+ while :; do
196
+ if [ "$redraw" -eq 1 ]; then
197
+ printf '\033[2K\r'
198
+ printf ' %s\n' "$prompt"
199
+ for i in "${!opts[@]}"; do
200
+ if [ "$i" -eq "$sel" ]; then
201
+ printf ' %s▸ %s%s%s\n' "$C_ACCENT" "$C_TEXT" "${opts[$i]}" "$C_RESET"
202
+ else
203
+ printf ' %s%s%s\n' "$C_DIM" "${opts[$i]}" "$C_RESET"
204
+ fi
205
+ done
206
+ redraw=0
207
+ fi
208
+ tty_key; key="$REPLY_KEY"
209
+ case "$key" in
210
+ UP) [ "$sel" -gt 0 ] && sel=$((sel - 1)) && redraw=1 ;;
211
+ DOWN) [ "$sel" -lt $((n - 1)) ] && sel=$((sel + 1)) && redraw=1 ;;
212
+ ENTER) break ;;
213
+ SPACE) break ;;
214
+ [1-9]) [ "$key" -le "$n" ] 2>/dev/null && { sel=$((key - 1)); break; } ;;
215
+ esac
216
+ if [ "$redraw" -eq 1 ]; then
217
+ # rewind the option rows + the prompt line, then redraw
218
+ printf '\033[%dA' "$(( n + 1 ))"
219
+ fi
220
+ done
221
+ printf -v "$out" '%s' "${opts[$sel]}"
222
+ return 0
223
+ }
224
+
225
+ # ask_check_pre <prompt> <pre-ticked-space-list> <label>... -> CHOOSE_FLAGS parallel to
226
+ # the labels, 1 = on (a label named in the pre-tick list starts ☑). Space toggles the
227
+ # pointed row (☑/☐), arrows move, Enter commits. Non-tty stdin leaves the flags exactly
228
+ # as pre-ticked (the caller already decided them).
229
+ CHOOSE_FLAGS=()
230
+ ask_check_pre() {
231
+ local prompt="$1" pretick="$2"; shift 2
232
+ local opts=("$@") n=$# sel=0 i key redraw w
233
+ CHOOSE_FLAGS=()
234
+ for i in "${!opts[@]}"; do
235
+ CHOOSE_FLAGS[i]=0
236
+ for w in $pretick; do [ "${opts[$i]}" = "$w" ] && CHOOSE_FLAGS[i]=1; done
237
+ done
238
+ if [ "$TTY_IN" -ne 1 ]; then return 0; fi
239
+ redraw=1
240
+ while :; do
241
+ if [ "$redraw" -eq 1 ]; then
242
+ printf '\033[2K\r'
243
+ printf ' %s\n' "$prompt"
244
+ for i in "${!opts[@]}"; do
245
+ local mark pointer
246
+ if [ "${CHOOSE_FLAGS[$i]}" -eq 1 ]; then mark="☑"; else mark="☐"; fi
247
+ if [ "$i" -eq "$sel" ]; then
248
+ printf ' %s▸ %s%s %s%s\n' "$C_ACCENT" "$C_TEXT" "$mark" "${opts[$i]}" "$C_RESET"
249
+ else
250
+ printf ' %s%s %s%s\n' "$C_DIM" "$mark" "${opts[$i]}" "$C_RESET"
251
+ fi
252
+ done
253
+ redraw=0
254
+ fi
255
+ tty_key; key="$REPLY_KEY"
256
+ case "$key" in
257
+ UP) [ "$sel" -gt 0 ] && sel=$((sel - 1)) && redraw=1 ;;
258
+ DOWN) [ "$sel" -lt $((n - 1)) ] && sel=$((sel + 1)) && redraw=1 ;;
259
+ SPACE) CHOOSE_FLAGS[$sel]=$(( 1 - CHOOSE_FLAGS[$sel] )); redraw=1 ;;
260
+ ENTER) break ;;
261
+ [1-9]) [ "$key" -le "$n" ] 2>/dev/null && { sel=$((key - 1)); break; } ;;
262
+ esac
263
+ if [ "$redraw" -eq 1 ]; then
264
+ printf '\033[%dA' "$(( n + 1 ))"
265
+ fi
266
+ done
267
+ return 0
268
+ }
269
+
149
270
  # ------------------------------------------------------------------ flags -----
150
271
  TARGET="" CLASS="" BRANCH="" EMAIL="" GATE="" EMIT="" SCOPE=""
151
272
  ELECTRON=0
@@ -160,6 +281,7 @@ while [ $# -gt 0 ]; do
160
281
  --branch) BRANCH="${2:-}"; shift 2 ;;
161
282
  --email) EMAIL="${2:-}"; shift 2 ;;
162
283
  --gate) GATE="${2:-}"; shift 2 ;;
284
+ --ci-gate) CI_GATE="${2:-}"; shift 2 ;;
163
285
  --emit) EMIT="${2:-}"; shift 2 ;;
164
286
  --scope) SCOPE="${2:-}"; shift 2 ;;
165
287
  --yes) YES=1; shift ;;
@@ -193,7 +315,7 @@ rail_seed "${C_ACCENT}▀██▀▀ ▀██▀ ██▀ ▀▀▀▀ ▀
193
315
  rail_seed " ${C_MUTED}discipline around whatever executes${C_RESET}"
194
316
  rail_seed ""
195
317
  rail_seed "${C_TEXT} gob init${C_RESET} ${C_MUTED}— first-run setup (gobstack v$VERSION)${C_RESET}"
196
- rail_seed " ${C_MUTED}6 steps · every step has a flag · nothing is written until the plan is confirmed${C_RESET}"
318
+ rail_seed " ${C_MUTED}7 steps · every step has a flag · nothing is written until the plan is confirmed${C_RESET}"
197
319
  rail_seed ""
198
320
  if [ "$TTY_OUT" -eq 1 ]; then
199
321
  rail_draw
@@ -239,9 +361,9 @@ done
239
361
  STEP_VAL[0]="$C_DIM$DET_N detected$C_RESET"
240
362
  if [ "$TTY_OUT" -eq 1 ]; then
241
363
  rail_say ""
242
- rail_say " ✔ 1. platforms $C_TEXT$DET_N detected$C_RESET"
364
+ rail_say " ✔ 1. detect $C_TEXT$DET_N platform(s) clean$C_RESET"
243
365
  if [ "$DET_N" -eq 0 ]; then
244
- rail_say " ${C_YELLOW}no platform detected here — the wizard will still install the harness; emit later with gob emit$C_RESET"
366
+ rail_say " ${C_YELLOW}no platform detected here — the wizard will still install the harness; sync later with gob sync$C_RESET"
245
367
  else
246
368
  while IFS=$'\t' read -r p proj glob hit; do
247
369
  [ -n "$p" ] || continue
@@ -250,7 +372,7 @@ if [ "$TTY_OUT" -eq 1 ]; then
250
372
  done <<< "$DET_ROWS"
251
373
  fi
252
374
  else
253
- cascade "platforms: $DET_N detected ($(printf '%s' "$DET_ROWS" | awk -F'\t' '{printf "%s ", $1}'))"
375
+ cascade "detect: $DET_N platform(s) detected ($(printf '%s' "$DET_ROWS" | awk -F'\t' '{printf "%s ", $1}'))"
254
376
  fi
255
377
 
256
378
  # ------------------------------------------------------------ screen 3: class -
@@ -261,27 +383,21 @@ CLASS_DEFAULT=1
261
383
  if [ -z "$CLASS" ]; then
262
384
  if [ "$TTY_IN" -eq 1 ]; then
263
385
  rail_say ""
264
- rail_say " ${C_MUTED}what kind of work does this repo do?$C_RESET"
265
- rail_say " $C_TEXT 1 ▸ software shipped features, PRs, review gates ${C_DIM}(class A)$C_RESET"
266
- rail_say " $C_TEXT 2 service backend jobs, config, unattended runs ${C_DIM}(class B)$C_RESET"
267
- rail_say " $C_TEXT 3 game playable builds, perf budgets ${C_DIM}(class C)$C_RESET"
268
- rail_say " $C_TEXT 4 research specs, replays, reference corpora ${C_DIM}(class D)$C_RESET"
269
- rail_say " $C_TEXT 5 fleet fleets, loops, unattended automation ${C_DIM}(class E)$C_RESET"
270
- rail_say " ${C_MUTED}↑ number + Enter · Enter = software · a class letter also works (A-E)$C_RESET"
271
- rail_say " ${C_DIM}an Electron desktop shell: pick software, then --electron$C_RESET"
272
- printf ' '
273
- read -r REPLY_CLASS
274
386
  ASKED_ANY=1
387
+ # The keyboard radio: arrows move the ▸ pointer, Enter commits, a typed number
388
+ # pre-positions and commits. Non-tty stdin never reaches this branch.
389
+ ask_radio "what kind of work does this repo do?" REPLY_CLASS \
390
+ "software — shipped features, PRs, review gates" \
391
+ "service — backend jobs, config, unattended runs" \
392
+ "game — playable builds, perf budgets" \
393
+ "research — specs, replays, reference corpora" \
394
+ "fleet — fleets, loops, unattended automation"
395
+ CLASS="${REPLY_CLASS%% —*}"
275
396
  rail_say ""
276
397
  else
277
- REPLY_CLASS="1"
398
+ CLASS="software"
278
399
  cascade "class: software (default; --class software|service|game|research|fleet or A-E)"
279
400
  fi
280
- case "$REPLY_CLASS" in
281
- 1) CLASS="software" ;; 2) CLASS="service" ;; 3) CLASS="game" ;;
282
- 4) CLASS="research" ;; 5) CLASS="fleet" ;;
283
- *) CLASS="${REPLY_CLASS:-software}" ;;
284
- esac
285
401
  fi
286
402
  # Resolve every accepted spelling to the canonical class NAME. The letters A-E and the old
287
403
  # taught names (app/agent/desktop) are read-time aliases; anything else is refused here, with
@@ -340,12 +456,12 @@ GATE_DEFAULT="bash tests/run-tests.sh"
340
456
  if [ -z "$GATE" ]; then
341
457
  if [ "$TTY_IN" -eq 1 ]; then
342
458
  rail_say ""
343
- rail_say " ${C_MUTED}one real command that must stay green — suggested for a bash suite:$C_RESET"
459
+ rail_say " ${C_MUTED}what command proves this repo is healthy? it must stay green:$C_RESET"
344
460
  if [ -f "$TARGET/tests/run-tests.sh" ]; then
345
461
  rail_say " $C_DIM$GATE_DEFAULT$C_RESET"
346
- ask_value "gate command" "$GATE_DEFAULT"; GATE="$REPLY"
462
+ ask_value "health check" "$GATE_DEFAULT"; GATE="$REPLY"
347
463
  else
348
- ask_value "gate command" "true"; GATE="$REPLY"
464
+ ask_value "health check" "true"; GATE="$REPLY"
349
465
  if [ "$GATE" = "true" ]; then
350
466
  rail_say " ${C_YELLOW}no tests/run-tests.sh found — a placeholder gate is allowed but it proves nothing; replace it in .goblin/goblin.yaml$C_RESET"
351
467
  fi
@@ -357,13 +473,49 @@ if [ -z "$GATE" ]; then
357
473
  fi
358
474
  STEP_VAL[3]="$C_TEXT$GATE$C_RESET"
359
475
  if [ "$TTY_OUT" -eq 1 ]; then
360
- rail_say " ✔ $((CURRENT - 1)). first gate $C_TEXT$GATE$C_RESET"
476
+ rail_say " ✔ $((CURRENT - 1)). health check $C_TEXT$GATE$C_RESET"
361
477
  fi
362
478
 
363
- # --------------------------------------------------------- screen 6: the plan --
479
+ # -------------------------------------------------------- screen 6: ci gate ----
480
+ # The CI opt-in. The class decides by default (ci-gate R/O places the workflow, '-'
481
+ # never does); an explicit no opts the part out through --ci-gate no, which the installer
482
+ # records in disabled: so CL-01 reports the opt-out rather than a silent absence.
364
483
  CURRENT=6
365
484
  rail_rewind
366
485
  if [ "$TTY_OUT" -eq 1 ]; then rail_draw; fi
486
+ CI_DEFAULT="no — nothing under .github/ (the default)"
487
+ if [ "${CI_GATE:-}" = "" ] && [ "$TTY_IN" -eq 1 ] && [ "$YES" -eq 0 ]; then
488
+ rail_say ""
489
+ ASKED_ANY=1
490
+ ask_radio "install a CI workflow that runs the health check on every pull request?" REPLY_CI \
491
+ "no — nothing under .github/ (the default)" \
492
+ "yes — write the CI workflow" \
493
+ "class decides — follow the class contract"
494
+ case "$REPLY_CI" in
495
+ "yes"*) CI_GATE="yes" ;;
496
+ "no"*) CI_GATE="no" ;;
497
+ *) CI_GATE="" ;;
498
+ esac
499
+ rail_say ""
500
+ fi
501
+ CI_GATE="${CI_GATE:-}" # empty = the class decides; yes/no ride the installer's --ci-gate
502
+ case "$CI_GATE" in
503
+ "") CI_CHOICE="no (the default)"; CI_FLAG="--ci-gate no" ;;
504
+ yes) CI_CHOICE="yes"; CI_FLAG="--ci-gate yes" ;;
505
+ no) CI_CHOICE="no (explicit opt-out)"; CI_FLAG="--ci-gate no" ;;
506
+ *) g_err "--ci-gate must be yes or no, got '$CI_GATE'"; exit 2 ;;
507
+ esac
508
+ STEP_VAL[4]="$C_DIM$CI_CHOICE$C_RESET"
509
+ if [ "$TTY_OUT" -eq 1 ]; then
510
+ rail_say " ✔ $((CURRENT - 1)). ci $C_DIM$CI_CHOICE$C_RESET"
511
+ else
512
+ cascade "ci: $CI_CHOICE"
513
+ fi
514
+
515
+ # -------------------------------------------------------- screen 7: the plan ---
516
+ CURRENT=7
517
+ rail_rewind
518
+ if [ "$TTY_OUT" -eq 1 ]; then rail_draw; fi
367
519
 
368
520
  # The emit selection: pre-tick the DETECTED platforms (in doctor's enum order).
369
521
  EMIT_SELECTED=""
@@ -397,33 +549,33 @@ EMIT_N=$(printf '%s' "$EMIT_SELECTED" | awk 'NF' | wc -l | tr -d '[:space:]')
397
549
 
398
550
  if [ "$TTY_IN" -eq 1 ] && [ -z "$EMIT" ] && [ "$YES" -eq 0 ]; then
399
551
  rail_say ""
400
- rail_say " ${C_MUTED}agent skills are opt-in, per platform — emit them into:$C_RESET"
552
+ ASKED_ANY=1
553
+ # The checkboxes: the detected platforms pre-tick, space toggles the pointed row between
554
+ # ☑ and ☐, arrows move, Enter commits. The install itself writes none either way — the
555
+ # harness stays neutral; these are the per-platform skills opt-in.
556
+ EMIT_OPTS=()
401
557
  for p in $PLATFORMS; do
402
558
  hit=""
403
- while IFS=$' ' read -r dp _a _b _c; do [ "$dp" = "$p" ] && hit=1; done <<< "$DET_ROWS"
559
+ while IFS=$'\t' read -r dp _a _b _c; do [ "$dp" = "$p" ] && hit=1; done <<< "$DET_ROWS"
404
560
  [ -n "$hit" ] || continue
405
- rail_say " ${C_GREEN}✔$C_RESET $C_TEXT$p$C_RESET"
561
+ EMIT_OPTS+=("$p")
406
562
  done
407
- rail_say " ${C_MUTED}the install itself writes none — the harness stays neutral$C_RESET"
408
- rail_say " ${C_MUTED}Enter = these; or type platform names to override (none: type x)$C_RESET"
409
- printf ' '
410
- read -r REPLY_EMIT
411
- ASKED_ANY=1
412
- rail_say ""
413
- if [ "$REPLY_EMIT" = "x" ] || [ "$REPLY_EMIT" = "none" ]; then
414
- EMIT_SELECTED=""
415
- elif [ -n "$REPLY_EMIT" ]; then
563
+ if [ "${#EMIT_OPTS[@]}" -gt 0 ]; then
564
+ ask_check_pre "agent skills are opt-in, per platform — sync them into:" "$EMIT_SELECTED" "${EMIT_OPTS[@]}"
416
565
  EMIT_SELECTED=""
417
- for w in $REPLY_EMIT; do
418
- ok=""
419
- for p in $PLATFORMS; do [ "$p" = "$w" ] && ok=1; done
420
- [ -n "$ok" ] && EMIT_SELECTED="$EMIT_SELECTED $w" || { g_err "unknown platform '$w' — I ship $PLA, hermes, copilot, cursor, opencode, codex, $GEM"; exit 2; }
566
+ i=0
567
+ for p in "${EMIT_OPTS[@]}"; do
568
+ [ "${CHOOSE_FLAGS[$i]}" -eq 1 ] && EMIT_SELECTED="$EMIT_SELECTED $p"
569
+ i=$((i + 1))
421
570
  done
571
+ else
572
+ rail_say " ${C_MUTED}no platform detected — nothing to pre-tick; sync later with gob sync --platform <p>$C_RESET"
422
573
  fi
574
+ rail_say ""
423
575
  fi
424
- STEP_VAL[4]="$C_TEXT$EMIT_N platform(s) · scope $SCOPE$C_RESET"
576
+ STEP_VAL[5]="$C_TEXT$EMIT_N platform(s) · scope $SCOPE$C_RESET"
425
577
  if [ "$TTY_OUT" -eq 1 ]; then
426
- rail_say " ✔ $((CURRENT - 1)). emit $C_TEXT$EMIT_N platform(s) · scope $SCOPE$C_RESET"
578
+ rail_say " ✔ $((CURRENT - 1)). sync $C_TEXT$EMIT_N platform(s) · scope $SCOPE$C_RESET"
427
579
  fi
428
580
 
429
581
  # ---- the plan box: the wizard never writes until this has been on the screen ----
@@ -437,7 +589,8 @@ add " ${C_DIM}╭────────────────────
437
589
  add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}install${C_RESET} $C_DIM.goblin/ + the payload into$C_RESET $C_TEXT$TARGET$C_RESET"
438
590
  add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}class${C_RESET} $C_TEXT$CLASS$C_RESET ${C_DIM}· branch $C_TEXT$BRANCH$C_RESET ${C_DIM}· email$C_RESET $C_TEXT$EMAIL$C_RESET"
439
591
  add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}gate${C_RESET} $C_TEXT$GATE$C_RESET"
440
- add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}emit${C_RESET} $C_TEXT$EMIT_N platform(s), scope $SCOPE$C_RESET$([ "$EMIT_N" -gt 0 ] && printf '%s' " $C_DIM($(printf '%s' "$EMIT_SELECTED" | awk '{$1=$1};1'))$C_RESET")"
592
+ add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}ci${C_RESET} $C_DIM$CI_CHOICE$C_RESET"
593
+ add " ${C_DIM}│${C_RESET} ${C_GREEN}✔${C_RESET} ${C_TEXT}sync${C_RESET} $C_TEXT$EMIT_N platform(s), scope $SCOPE$C_RESET$([ "$EMIT_N" -gt 0 ] && printf '%s' " $C_DIM($(printf '%s' "$EMIT_SELECTED" | awk '{$1=$1};1'))$C_RESET")"
441
594
  add " ${C_DIM}╰──────────────────────────────────────────────────────╯${C_RESET}"
442
595
  if [ "$DRYRUN" -eq 1 ]; then
443
596
  printf '%s\n' "$BOX"
@@ -467,15 +620,14 @@ fi
467
620
  # never rewinds past its own buffer by counting lines it did not print.
468
621
  if [ "$TTY_OUT" -eq 1 ]; then
469
622
  rail_note ""
470
- rail_note " ${C_MUTED}running: goblin-install --target $TARGET --class $CLASS$([ "$ELECTRON" -eq 1 ] && printf ' --electron')${C_RESET}"
471
- else
472
- printf 'gob init [run] goblin-install --target %s --class %s%s\n' "$TARGET" "$CLASS" "$([ "$ELECTRON" -eq 1 ] && printf ' --electron')"
473
- fi
474
- if [ "$ELECTRON" -eq 1 ]; then
475
- bash "$SRC/bin/goblin-install" --target "$TARGET" --class "$CLASS" --electron --skills no --yes
623
+ rail_note " ${C_MUTED}running: goblin-install --target $TARGET --class $CLASS$([ "$ELECTRON" -eq 1 ] && printf ' --electron')$([ -n "$CI_FLAG" ] && printf ' %s' "$CI_FLAG")${C_RESET}"
476
624
  else
477
- bash "$SRC/bin/goblin-install" --target "$TARGET" --class "$CLASS" --skills no --yes
625
+ printf 'gob init [run] goblin-install --target %s --class %s%s%s\n' "$TARGET" "$CLASS" "$([ "$ELECTRON" -eq 1 ] && printf ' --electron')" "$([ -n "$CI_FLAG" ] && printf ' %s' "$CI_FLAG")"
478
626
  fi
627
+ INSTALL_ARGS=(--target "$TARGET" --class "$CLASS" --skills no --yes)
628
+ [ "$ELECTRON" -eq 1 ] && INSTALL_ARGS+=(--electron)
629
+ [ -n "$CI_FLAG" ] && INSTALL_ARGS+=($CI_FLAG)
630
+ bash "$SRC/bin/goblin-install" "${INSTALL_ARGS[@]}"
479
631
  EXIT_INSTALL=$?
480
632
  if [ "$EXIT_INSTALL" -ne 0 ]; then
481
633
  g_err "install failed (exit $EXIT_INSTALL) — the refusals above name the path and the fix"
@@ -484,8 +636,8 @@ fi
484
636
  CONFIG="$TARGET/.goblin/goblin.yaml"
485
637
  [ -f "$CONFIG" ] || { g_err "install wrote no .goblin/goblin.yaml at $TARGET"; exit 1; }
486
638
  if [ "$DRYRUN" -eq 0 ]; then
487
- sed -i "s|^branch:.*|branch: $BRANCH|" "$CONFIG" || { g_err "cannot write the declared branch into $CONFIG"; exit 1; }
488
- sed -i "s|^owner_email:.*|owner_email: $EMAIL|" "$CONFIG" || { g_err "cannot write the owner email into $CONFIG"; exit 1; }
639
+ g_sed_i "s|^branch:.*|branch: $BRANCH|" "$CONFIG" || { g_err "cannot write the declared branch into $CONFIG"; exit 1; }
640
+ g_sed_i "s|^owner_email:.*|owner_email: $EMAIL|" "$CONFIG" || { g_err "cannot write the owner email into $CONFIG"; exit 1; }
489
641
  TMP_CFG=$(mktemp) || { g_err "mktemp failed"; exit 2; }
490
642
  GATE_SUB=$(mktemp) || { g_err "mktemp failed"; exit 2; }
491
643
  # print the gate body once, read it as data: an & or / inside the command must not
@@ -512,20 +664,20 @@ fi
512
664
  EMITTED=0
513
665
  for p in $EMIT_SELECTED; do
514
666
  if [ "$TTY_OUT" -eq 0 ]; then
515
- printf 'gob init [run] goblin-emit --platform %s --scope %s\n' "$p" "$SCOPE"
667
+ printf 'gob init [run] gob sync --platform %s --scope %s\n' "$p" "$SCOPE"
516
668
  else
517
- rail_note " ${C_MUTED}running: goblin-emit --platform $p --scope $SCOPE${C_RESET}"
669
+ rail_note " ${C_MUTED}running: gob sync --platform $p --scope $SCOPE${C_RESET}"
518
670
  fi
519
671
  bash "$SRC/bin/goblin-emit" --platform "$p" --scope "$SCOPE" --target "$TARGET"
520
672
  EXIT_EMIT=$?
521
673
  if [ "$EXIT_EMIT" -ne 0 ]; then
522
- g_err "emit $p failed (exit $EXIT_EMIT) — run 'gob emit --platform $p --scope $SCOPE' to see the refusal again"
674
+ g_err "sync $p failed (exit $EXIT_EMIT) — run 'gob sync --platform $p --scope $SCOPE' to see the refusal again"
523
675
  exit "$EXIT_EMIT"
524
676
  fi
525
677
  EMITTED=$((EMITTED + 1))
526
678
  done
527
679
 
528
- # --------------------------------------------------- screen 6b: done + verify --
680
+ # --------------------------------------------------- screen 7b: done + verify --
529
681
  # Spacing pass: exactly one blank line before each done-screen section; the engine's
530
682
  # verify output and this screen's own summary stay visually separated.
531
683
  if [ "$TTY_OUT" -eq 0 ]; then
@@ -564,11 +716,11 @@ if [ "$DRYRUN" -eq 0 ]; then
564
716
  printf ' %snext:%s\n' "$C_MUTED" "$C_RESET"
565
717
  printf ' 1. %sthe %s skipped checks ARE your checklist — gob verify names each one%s\n' "$C_TEXT" "$SKIPPED" "$C_RESET"
566
718
  printf ' 2. %sreplace the placeholder gate in .goblin/goblin.yaml with your real commands (P8 step 3)%s\n' "$C_TEXT" "$C_RESET"
567
- printf ' 3. %snot emitted here: gob emit --platform <p> --scope global%s\n' "$C_TEXT" "$C_RESET"
719
+ printf ' 3. %snot synced here: gob sync --platform <p> --scope global%s\n' "$C_TEXT" "$C_RESET"
568
720
  if [ "$EMIT_N" -gt 0 ]; then
569
721
  printf ' 4. %shermes skills trust %s # one-time, so the project-tier skills load%s\n' "$C_TEXT" "$TARGET" "$C_RESET"
570
722
  else
571
- printf ' 4. %sagent skills are opt-in per platform: gob emit --platform <p>%s\n' "$C_TEXT" "$C_RESET"
723
+ printf ' 4. %sagent skills are opt-in per platform: gob sync --platform <p>%s\n' "$C_TEXT" "$C_RESET"
572
724
  fi
573
725
  printf '\n'
574
726
  if [ "$VERIFY_RC" -eq 0 ]; then
@@ -19,6 +19,11 @@
19
19
  # --opt-out <part> record the part in disabled: so its required checks are skipped
20
20
  # --uninstall remove exactly the files in installed.json
21
21
  # --re-pin re-record practice_sha256: for an edited standard; nothing else changes
22
+ # --ci-gate yes|no the CI lane opt-in (default: the class decides - ci-gate R/O places
23
+ # .github/workflows/goblin-gate.yml, ci-gate '-' never does). An
24
+ # explicit no opts the part out (recorded in disabled: so CL-01
25
+ # reports the opt-out, never a silent absence); an explicit yes is
26
+ # refused for a class that forbids the part.
22
27
  # --force allow overwriting a file goblin-stack did not create
23
28
  # --yes non-interactive; take the defaults above
24
29
  #
@@ -61,6 +66,11 @@ UNINSTALL=0
61
66
  RE_PIN=0
62
67
  FORCE=0
63
68
  OPT_OUT=""
69
+ # The CI lane opt-in (wizard v2). Empty = the class decides, exactly as before this flag
70
+ # existed: every pre-existing invocation (and every pipe) keeps its byte-identical shape.
71
+ # "no" is an explicit opt-out of the ci-gate part; "yes" is an explicit opt-in, refused for
72
+ # a class that forbids the part (CL-01 would fail it on the very first verify).
73
+ CI_GATE_OPT=""
64
74
  # The electron opt-in (the merged desktop/F class): renders presets/electron-overlay.yaml over
65
75
  # the software preset. Auto-set when --class resolves via the desktop/F/f alias, so the alias
66
76
  # behaves as the old class F did rather than silently installing weaker software.
@@ -88,6 +98,7 @@ while [ $# -gt 0 ]; do
88
98
  --uninstall) UNINSTALL=1; shift ;;
89
99
  --re-pin) RE_PIN=1; shift ;;
90
100
  --opt-out) OPT_OUT="${2:-}"; shift 2 ;;
101
+ --ci-gate) CI_GATE_OPT="${2:-}"; shift 2 ;;
91
102
  --force) FORCE=1; shift ;;
92
103
  --yes) shift ;;
93
104
  -h|--help) usage; exit 0 ;;
@@ -230,7 +241,7 @@ if [ "$RE_PIN" -eq 1 ]; then
230
241
  fi
231
242
  # One line, in place. Every other line of the config - comments included - is untouched, so
232
243
  # the `owned` contract holds for everything except the single value just asked for.
233
- sed -i "s|^practice_sha256:.*|practice_sha256: $NEW_SHA|" "$CONFIG" \
244
+ g_sed_i "s|^practice_sha256:.*|practice_sha256: $NEW_SHA|" "$CONFIG" \
234
245
  || { g_err "could not rewrite the pin in $CONFIG"; exit 1; }
235
246
  # Fail closed: never report a re-pin that did not land. The failure this command exists to
236
247
  # remove is a hash nobody verified (measured at bf7e9c9: sed -i could not write to a read-only
@@ -257,6 +268,7 @@ CLASS_RAW="$CLASS"
257
268
  CLASS=$(g_class_canon "$CLASS") \
258
269
  || { g_err "--class must be software|service|game|research|fleet (or A-E / app / agent / desktop), got '$CLASS_RAW'"; exit 2; }
259
270
  case "$SKILLS" in yes|no) ;; *) g_err "--skills must be yes or no"; exit 2 ;; esac
271
+ case "$CI_GATE_OPT" in ""|yes|no) ;; *) g_err "--ci-gate must be yes or no, got '$CI_GATE_OPT'"; exit 2 ;; esac
260
272
 
261
273
  # W6 migration safety: on a --upgrade (or any re-install) of a repo whose record shows skills
262
274
  # were installed, an OMITTED --skills flag must READ that state, not silently strip it. The
@@ -354,7 +366,15 @@ WANT_RATCHET=1; [ "$(need ratchet)" = "-" ] && WANT_RATCHET=0
354
366
  # The CI lane (G6 A.6). Installed for a class that requires or permits it (A, C, E, F) and never
355
367
  # for one that forbids it (B, D) - where CL-01 then requires the file to be ABSENT, which is why
356
368
  # class_part_artifact keys off this exact path rather than off .github/workflows/.
369
+ # Wizard v2 adds the explicit opt-in/out: the empty default keeps the class-driven shape
370
+ # byte-identical for every existing invocation; `no` opts the part out (recorded in disabled:
371
+ # so CL-01 reports the opt-out rather than a silent absence, the --skills no precedent); `yes`
372
+ # is refused for a class that forbids the part, because the file could never survive verify.
357
373
  WANT_CI_GATE=1; [ "$(need ci-gate)" = "-" ] && WANT_CI_GATE=0
374
+ case "$CI_GATE_OPT" in
375
+ no) WANT_CI_GATE=0; OPT_OUT="${OPT_OUT:+$OPT_OUT,}ci-gate" ;;
376
+ yes) [ "$WANT_CI_GATE" -eq 1 ] || { g_err "--ci-gate yes: class $CLASS forbids the ci-gate part - CI is not in this class's contract (see docs/CI.md)"; exit 2; } ;;
377
+ esac
358
378
  [ "$ARCHIVE" = "true" ] && { WANT_SPEC=0; WANT_REVIEWS=0; SCAFFOLD=no; }
359
379
 
360
380
  OWNER_EMAIL=$( cd "$TARGET" && git config user.email 2>/dev/null )
@@ -731,7 +751,7 @@ fi
731
751
  printf ' "source_sha": "%s",\n' "$( cd "$SRC" && git rev-parse HEAD 2>/dev/null || echo unknown )"
732
752
  printf ' "class": "%s",\n' "$CLASS"
733
753
  printf ' "installed_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
734
- printf ' "options": {"skills": "%s", "archive": %s, "parts": "%s"},\n' "$SKILLS" "$ARCHIVE" "${PARTS:-all}"
754
+ printf ' "options": {"skills": "%s", "archive": %s, "parts": "%s", "ci_gate": "%s"},\n' "$SKILLS" "$ARCHIVE" "${PARTS:-all}" "${CI_GATE_OPT:-class}"
735
755
  printf ' "files": {\n'
736
756
  awk -F'\t' 'NF==2 {printf " \"%s\": \"%s\",\n", $1, $2}' "$TMP/files.tsv" | sed '$ s/,$//'
737
757
  printf ' },\n'
@@ -773,7 +793,7 @@ if [ "$DRY_RUN" -eq 0 ]; then
773
793
  cur=$(g_yaml_scalar "$CONFIG" disabled)
774
794
  cur=${cur#[}; cur=${cur%]}
775
795
  new=$(printf '%s,%s' "$cur" "$p" | tr ',' '\n' | grep -v '^$' | sort -u | paste -sd, -)
776
- sed -i "s|^disabled:.*|disabled: [$new]|" "$CONFIG"
796
+ g_sed_i "s|^disabled:.*|disabled: [$new]|" "$CONFIG"
777
797
  fi
778
798
  done
779
799
  g_info "recorded opt-out: $OPT_OUT"
package/bin/goblin-lib.sh CHANGED
@@ -274,6 +274,21 @@ g_installed_scalar_options() {
274
274
  ' "$1"
275
275
  }
276
276
 
277
+ # g_sed_i <sed-script> <file...> — in-place sed that works on BOTH sed families.
278
+ # GNU sed takes -i with an optional suffix attached; BSD sed (macOS) REQUIRES an argument
279
+ # after -i (the backup suffix), so a bare `sed -i 's|…|…|' file` makes BSD read the script
280
+ # as the suffix and the file as the program — the client's `sed: 1: "...": command a
281
+ # expects \ followed by text` (Mac, 2026-10-06). The empty-suffix form `-i ''` is a syntax
282
+ # error on GNU, hence the branch. Callers: g_sed_i 's|a|b|' file
283
+ g_sed_i() {
284
+ local script="$1"; shift
285
+ if sed --version >/dev/null 2>&1; then
286
+ sed -i "$script" "$@"
287
+ else
288
+ sed -i '' "$script" "$@"
289
+ fi
290
+ }
291
+
277
292
  # ------------------------------------------------------------- self-test -----
278
293
  # Proves the parser actually parses. Every assertion is a real comparison against a
279
294
  # value written to a temp file in this function — blank the awk in g_yaml_scalar and
@@ -36,6 +36,14 @@ usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
36
36
 
37
37
  g_err() { printf 'error: %s\n' "$*" >&2; }
38
38
 
39
+ # g_sed_i <sed-script> <file...> — in-place sed on BOTH sed families (goblin-lib's
40
+ # helper; duplicated here because this command is deliberately self-contained and
41
+ # does not source goblin-lib.sh — the BSD sed bomb, client Mac 2026-10-06).
42
+ g_sed_i() {
43
+ local script="$1"; shift
44
+ if sed --version >/dev/null 2>&1; then sed -i "$script" "$@"; else sed -i '' "$script" "$@"; fi
45
+ }
46
+
39
47
  # g_expand_tilde <path> — ~ and ~/... to $HOME (the goblin-install:81 idiom).
40
48
  g_expand_tilde() {
41
49
  case "$1" in
@@ -403,7 +411,7 @@ else
403
411
  g_err "upgrade: $CONFIG has neither an active nor a commented engine_dir: line - not a 0.4.4 template; aborting"
404
412
  exit 2
405
413
  fi
406
- sed -i "s|^# engine_dir:.*|$(active_engine_line | sed 's/[&|]/\\&/g')|" "$CONFIG"
414
+ g_sed_i "s|^# engine_dir:.*|$(active_engine_line | sed 's/[&|]/\\&/g')|" "$CONFIG"
407
415
  fi
408
416
 
409
417
  # ---- installed.json: engine block + drop exactly the 18 engine entries ----
@@ -476,7 +484,7 @@ if [ -f "$WF" ] && grep -q 'run: bash .goblin/bin/goblin-verify' "$WF"; then
476
484
  # a files-RECORDED artifact, so this rewrite re-hashes its record entry here,
477
485
  # in the same commit (IN-02 hashes every recorded file; a rewritten artifact
478
486
  # with a stale recorded hash would fail verify #2 — measured this session).
479
- sed -i "s|run: bash .goblin/bin/goblin-verify|run: npx @techgoblin/gobstack@$CLI_VERSION run goblin-verify|" "$WF"
487
+ g_sed_i "s|run: bash .goblin/bin/goblin-verify|run: npx @techgoblin/gobstack@$CLI_VERSION run goblin-verify|" "$WF"
480
488
  WF_NEW=$(sha256 "$WF")
481
489
  python3 "$PYJ/rehash_record.py" "$INSTALLED" ".github/workflows/goblin-gate.yml" "$WF_NEW" || {
482
490
  g_err "upgrade: re-hashing the rewritten workflow in $INSTALLED failed - the tree is uncommitted; fix: git checkout ."
package/bin/goblin.js CHANGED
@@ -44,7 +44,9 @@ if (arg0 === "--version" || arg0 === "-V" || arg0 === "-v") {
44
44
  process.exit(0);
45
45
  }
46
46
 
47
- const SCRIPT = { verify: "goblin-verify", bans: "goblin-bans", audit: "goblin-audit", upgrade: "goblin-upgrade", doctor: "goblin-doctor", emit: "goblin-emit", init: "goblin-init" };
47
+ const SCRIPT = { verify: "goblin-verify", bans: "goblin-bans", audit: "goblin-audit", upgrade: "goblin-upgrade", doctor: "goblin-doctor", emit: "goblin-emit", sync: "goblin-emit", init: "goblin-init" };
48
+ // `sync` is the friendlier name for `emit` (wizard v2): same engine, same flags, same exit
49
+ // contract. `emit` stays a first-class verb - nothing is removed, this row only adds an alias.
48
50
  const [cmd, ...rest] = process.argv.slice(2);
49
51
 
50
52
  // No args, a help flag, or an unrecognized first arg: short usage, exit 2. The one survivor of
@@ -57,13 +59,13 @@ function usage() {
57
59
  [
58
60
  "gob <command>",
59
61
  "",
60
- " gob init start here — the guided first step (detect, class, emit, first verify)",
62
+ " gob init start here — the guided first step (health, class, gate, ci, sync, verify)",
61
63
  " gob verify run the rule matrix against the current repo",
62
64
  " gob bans run the ban list (per-pattern red lines over the source tree)",
63
65
  " gob audit check recorded dependency claims against live advisory feeds",
64
66
  " gob upgrade migrate a repo to the shared global engine at ~/.goblin/engine",
65
67
  " gob doctor one detection/drift run across the agent platforms",
66
- " gob emit write the skills + context block for one platform",
68
+ " gob sync write the skills + context block for one platform (alias: gob emit)",
67
69
  " gob uninstall --target . remove exactly what an install wrote (preimages)",
68
70
  "",
69
71
  "start here: gob init",
package/docs/GUIDE.md CHANGED
@@ -88,9 +88,9 @@ one way this guide installs it.
88
88
  **Do not install into a real project yet.** You want to see what it does before it touches
89
89
  something you care about.
90
90
 
91
- The guided path is `gob init` — one screen per question (class, branch/email, first
92
- gate, which platforms to emit), every question also answerable by flag, `--dry-run` to
93
- see the plan first:
91
+ The guided path is `gob init` — one screen per question (health check, class,
92
+ identity (branch/email), the health check, the CI opt-in, which platforms to sync), every question also
93
+ answerable by flag, `--dry-run` to see the plan first:
94
94
 
95
95
  mkdir -p /tmp/gs-try && cd /tmp/gs-try
96
96
  git init -b main
@@ -100,6 +100,13 @@ see the plan first:
100
100
  gob init --target . --class software --branch main --email "you@example.com" \
101
101
  --gate "bash tests/run-tests.sh" --yes
102
102
 
103
+ The wizard's ci step asks whether the gate should also run in CI
104
+ (`.github/workflows/goblin-gate.yml`). The wizard's default is **no** — nothing lands under `.github/` unless you opt in. (Outside the wizard, `gob install`'s own default is the class decides: a class whose
105
+ contract requires or permits the ci-gate part gets the workflow, one that forbids it never does.)
106
+ `--ci-gate yes|no` overrides: an explicit `no` is recorded as an opt-out (so verify reports the
107
+ opt-out, never a silent absence), and an explicit `yes` is refused for a class that forbids the
108
+ part.
109
+
103
110
  or the plain installer this wizard drives, if you prefer the one-shot shape:
104
111
 
105
112
  gob install --target . --class software
@@ -112,13 +119,14 @@ Expected output (this is a real transcript, trimmed):
112
119
  1. cd /tmp/gs-try && git add -A && git commit # the install is a change like any other
113
120
  2. .goblin/bin/goblin-verify # or add .goblin/bin to PATH
114
121
  3. edit .goblin/goblin.yaml: replace the default gate with your real commands (P8 step 3)
115
- 4. agent skills are opt-in: gob emit --platform <p> # hermes, claude, copilot, cursor, opencode, codex, gemini
122
+ 4. agent skills are opt-in: gob sync --platform <p> # hermes, claude, copilot, cursor, opencode, codex, gemini
116
123
 
117
124
  **`created 25`** is the installer's count of the files it **tracks** — the 16 in its `files`
118
125
  map, the 8 it `owns`, and `.gitignore`. It writes **26**: the 26th is `.goblin/installed.json`,
119
126
  the record it keeps for itself, which it writes but does not count. It has written nothing
120
127
  outside this directory. The default install ships **no agent skills** — the harness is neutral,
121
- and `gob emit --platform <p>` is the per-platform opt-in (the old `--skills yes` default is
128
+ and `gob sync --platform <p>` is the per-platform opt-in (`gob emit` is the same command under
129
+ its original name; the old `--skills yes` default is
122
130
  still there for repos that want the Hermes project tier vendored).
123
131
 
124
132
  ### Why `git init -b main` matters
@@ -448,7 +456,7 @@ with no fallback** — a repo is never judged by an engine it did not declare. A
448
456
  says `mode=global` keeps hashing whatever files it still holds; the engine's own identity prints in
449
457
  every run's footer (`engine: mode=… cli_sha256=… enforcement_tsv_sha256=…`). The same commands are
450
458
  available outside any repo through the npm CLI: `gob verify` / `gob bans` / `gob audit` /
451
- `gob doctor` / `gob emit` / `gob upgrade` / `gob --version`.
459
+ `gob doctor` / `gob sync` / `gob upgrade` / `gob --version`.
452
460
 
453
461
  **Migrating a repo to the global engine (W3):**
454
462
 
@@ -476,7 +484,7 @@ engine and silently de-migrate the record).
476
484
  |---|---|
477
485
  | `goblin-install` | `0` ok · `1` a refusal (with the path and the fix) · `2` bad input |
478
486
  | `goblin-verify` | `0` all checks passed · `1` a check failed · `2` could not run · `3` the manifest itself is broken |
479
- | `goblin` (npm CLI) | propagates the subcommand's codes verbatim — `verify`/`bans`/`audit`/`--version`; `install`/`uninstall`/`re-pin`/`upgrade` route into `goblin-install` (`upgrade` migrates to the global engine: `0` ok · `1` refusal · `2` bad input); `doctor`/`emit` carry the same contract: `doctor` exits `0` every probed platform DETECTED and clean · `1` any DRIFT · `2` nothing to probe, and `emit` exits `0` ok or no-op · `1` refusal (with the path and the fix) · `2` bad input or unknown platform |
487
+ | `goblin` (npm CLI) | propagates the subcommand's codes verbatim — `verify`/`bans`/`audit`/`--version`; `install`/`uninstall`/`re-pin`/`upgrade` route into `goblin-install` (`upgrade` migrates to the global engine: `0` ok · `1` refusal · `2` bad input); `doctor`/`emit` carry the same contract: `doctor` exits `0` every probed platform DETECTED and clean · `1` any DRIFT · `2` nothing to probe, and `emit` exits `0` ok or no-op · `1` refusal (with the path and the fix) · `2` bad input or unknown platform; `sync` is the same verb renamed and propagates identically |
480
488
  | platforms (W4b) | `emit`/`doctor` cover seven: `claude`, `hermes`, `copilot`, `cursor`, `opencode`, `codex`, `gemini` — each detected via its own anchor (`~/.claude`, `~/.hermes`, `~/.copilot`, `~/.cursor`, `~/.config/opencode`, `~/.codex`, `~/.gemini`); codex and gemini carry `partial` command-blocking (see LIMITS #47) |
481
489
 
482
490
  `3` is the one to notice: it means gobstack's own rule table is malformed, not your project.
@@ -583,7 +591,7 @@ with *"prove it was broken first"* — it is the one practice that survives cont
583
591
  # 1. try it somewhere disposable
584
592
  mkdir -p /tmp/gs-try && cd /tmp/gs-try
585
593
  git init -b main
586
- gob install --target . --class software # expect: created 25 (no skills — those are gob emit)
594
+ gob install --target . --class software # expect: created 25 (no skills — those are gob sync)
587
595
 
588
596
  # 2. commit and check
589
597
  git add -A && git commit -m "chore: install gobstack"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techgoblin/gobstack",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
4
4
  "description": "Agent-discipline toolkit: one verify command, an enforcement matrix, and LIMITS. bash engine, npm shim.",
5
5
  "bin": {
6
6
  "goblin": "bin/goblin.js",
@@ -22,10 +22,11 @@ not a rule dump — the rules live in one executable place, and facts beat requi
22
22
  harness on purpose, so nothing here assumes which coding agent you use. To install the
23
23
  procedure skills for your tool:
24
24
 
25
- gob emit --platform <p>
25
+ gob sync --platform <p>
26
26
 
27
27
  (`--scope project` writes them inside this repo; `--scope global` writes them for your user.)
28
- Run `gob emit` with no platform to see the list. If skills were installed here, they live
28
+ Run `gob sync` with no platform to see the list (`gob emit` is the same command under its
29
+ original name). If skills were installed here, they live
29
30
  under the platform's own directory, and `gob verify` hashes them.
30
31
 
31
32
  Two things this repo does not do: it does not choose models (a role resolves through the