@techgoblin/gobstack 0.5.0-beta.8 → 0.6.0-alpha.2

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 (59) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +161 -124
  3. package/VERSION +1 -1
  4. package/automations/drift-audit.sh +4 -4
  5. package/bans/layer-check.sh +10 -8
  6. package/bin/goblin +69 -57
  7. package/bin/goblin-audit +11 -13
  8. package/bin/goblin-bans +11 -11
  9. package/bin/goblin-extras +342 -0
  10. package/bin/goblin-init +382 -711
  11. package/bin/goblin-install +160 -114
  12. package/bin/goblin-lib.sh +234 -1
  13. package/bin/goblin-map +226 -21
  14. package/bin/goblin-mcp.js +492 -0
  15. package/bin/goblin-model +4 -4
  16. package/bin/goblin-upgrade +1 -1
  17. package/bin/goblin-verify +159 -145
  18. package/bin/goblin.js +35 -51
  19. package/docs/ADOPTION.md +15 -15
  20. package/docs/CONTRACTS.md +16 -15
  21. package/docs/DESIGN.md +1 -1
  22. package/docs/ENFORCEMENT.md +89 -90
  23. package/docs/FLOWS.md +1 -1
  24. package/docs/GLOSSARY.md +3 -3
  25. package/docs/GUARDRAILS.md +5 -5
  26. package/docs/GUIDE.md +194 -177
  27. package/docs/INTEGRATION.md +1 -1
  28. package/docs/LIMITS.md +25 -0
  29. package/docs/LOOP.md +12 -12
  30. package/docs/RE-PLAYBOOK.md +3 -3
  31. package/docs/ROLES.md +5 -5
  32. package/extras-catalogue/catalogue.tsv +42 -0
  33. package/extras-catalogue/payload/README.md +14 -0
  34. package/extras-catalogue/payload/taste-skill/taste/REFERENCE.md +3 -0
  35. package/extras-catalogue/payload/taste-skill/taste/SKILL.md +9 -0
  36. package/manifest/bans.tsv +8 -8
  37. package/manifest/classes.tsv +3 -3
  38. package/manifest/enforcement.tsv +40 -40
  39. package/manifest/glossary.tsv +3 -3
  40. package/manifest/playbooks.tsv +1 -1
  41. package/package.json +3 -1
  42. package/presets/electron-overlay.yaml +2 -2
  43. package/presets/fleet.yaml +8 -7
  44. package/presets/game.yaml +1 -1
  45. package/presets/research.yaml +1 -1
  46. package/presets/service.yaml +1 -1
  47. package/presets/software.yaml +1 -1
  48. package/skills/goblin-bootstrap/SKILL.md +2 -2
  49. package/templates/AGENTS.md.tmpl +8 -18
  50. package/templates/HANDOFF.md.tmpl +5 -5
  51. package/templates/agents-block.tmpl +45 -0
  52. package/templates/audit-waiver.tsv.tmpl +2 -2
  53. package/templates/boundary-waivers.tmpl +1 -1
  54. package/templates/checks/gate.sh.tmpl +6 -6
  55. package/templates/install-hooks.allowlist.tmpl +1 -1
  56. package/templates/ci/goblin-gate.yml.tmpl +0 -46
  57. package/templates/goblin.yaml.tmpl +0 -146
  58. package/templates/loop/decisions.tsv.tmpl +0 -1
  59. package/templates/loop/predicate.tmpl +0 -16
@@ -19,8 +19,7 @@
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
22
+ # (the CI lane was REMOVED from the product in v2: no .github/workflows payload, the
24
23
  # explicit no opts the part out (recorded in disabled: so CL-01
25
24
  # reports the opt-out, never a silent absence); an explicit yes is
26
25
  # refused for a class that forbids the part.
@@ -32,7 +31,7 @@
32
31
 
33
32
  set -uo pipefail
34
33
 
35
- GOBLIN_INSTALL_VERSION="0.5.0"
34
+ GOBLIN_INSTALL_VERSION="0.6.0-alpha.1"
36
35
  SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
37
36
  SRC=$(cd "$SELF_DIR/.." && pwd)
38
37
  # shellcheck source=goblin-lib.sh
@@ -66,11 +65,8 @@ UNINSTALL=0
66
65
  RE_PIN=0
67
66
  FORCE=0
68
67
  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=""
68
+ GATE_NAME_ARG="" # parsed by --gate <cmd> [name] below; reset before the loop
69
+ # CI_GATE_OPT is gone with the CI lane (v2): no flag, no workflow file, no ci_gate field.
74
70
  # The electron opt-in (the merged desktop/F class): renders presets/electron-overlay.yaml over
75
71
  # the software preset. Auto-set when --class resolves via the desktop/F/f alias, so the alias
76
72
  # behaves as the old class F did rather than silently installing weaker software.
@@ -98,7 +94,7 @@ while [ $# -gt 0 ]; do
98
94
  --uninstall) UNINSTALL=1; shift ;;
99
95
  --re-pin) RE_PIN=1; shift ;;
100
96
  --opt-out) OPT_OUT="${2:-}"; shift 2 ;;
101
- --ci-gate) CI_GATE_OPT="${2:-}"; shift 2 ;;
97
+ --gate) GOB_GATE="${2:-}"; GATE_NAME_ARG="${3:-}"; shift 2; [ -n "${GATE_NAME_ARG:-}" ] && shift; shift ;;
102
98
  --force) FORCE=1; shift ;;
103
99
  --yes) shift ;;
104
100
  -h|--help) usage; exit 0 ;;
@@ -117,8 +113,8 @@ TARGET=$(cd "$TARGET" && pwd)
117
113
 
118
114
  VERSION=$(head -n 1 "$SRC/VERSION")
119
115
  CLASSES_TSV="$SRC/manifest/classes.tsv"
120
- INSTALLED="$TARGET/.goblin/installed.json"
121
- CONFIG="$TARGET/.goblin/goblin.yaml"
116
+ INSTALLED="$TARGET/.gob/installed.json"
117
+ CONFIG="$TARGET/AGENTS.md"
122
118
  STAMP=$(date +%F)
123
119
 
124
120
  # --uninstall and --re-pin are different jobs, and this guard has to sit ABOVE the uninstall
@@ -145,18 +141,35 @@ if [ "$UNINSTALL" -eq 1 ]; then
145
141
  # .github/ behind with nothing named (the shape t-uninstall.sh asserts against).
146
142
  d="$p"; while [ "${d%/*}" != "$d" ]; do d="${d%/*}"; PARENTS="$PARENTS$d"$'\n'; done
147
143
  done < <(g_installed_files "$INSTALLED")
148
- # The config is goblin-stack's own file, so it goes too. HANDOFF/AGENTS/SPEC/reviews do not:
149
- # they are the project's record, not the harness's.
150
- if [ -f "$CONFIG" ]; then
151
- [ "$DRY_RUN" -eq 1 ] || rm -f "$CONFIG"
152
- g_info "removed .goblin/goblin.yaml (goblin-stack's own config)"
144
+ # The config lives in the AGENTS.md gob block: uninstall strips ONLY the marker block and
145
+ # keeps the file (the body prose is the project's). A file with no gob block is untouched.
146
+ if [ -f "$CONFIG" ] && grep -q "^$GOB_AGENTS_BEGIN" "$CONFIG" 2>/dev/null; then
147
+ if [ "$DRY_RUN" -eq 1 ]; then
148
+ g_info "would remove the gob block from $CONFIG (the body prose stays)"
149
+ else
150
+ awk -v begin="^$GOB_AGENTS_BEGIN" -v end="^$GOB_AGENTS_END" '
151
+ $0 ~ begin { inb = 1; next } $0 ~ end { inb = 0; next } !inb { print }
152
+ ' "$CONFIG" > "$CONFIG.nogob" && mv "$CONFIG.nogob" "$CONFIG"
153
+ g_info "removed the gob block from AGENTS.md (the body prose stays)"
154
+ fi
153
155
  fi
154
156
  # Runtime state goblin-stack's own checks wrote.
155
- for f in .goblin/last-gate-line .goblin/ratchet-last .goblin/.ds-report; do
157
+ for f in .gob/last-gate-line .gob/ratchet-last .gob/.ds-report; do
156
158
  [ -f "$TARGET/$f" ] || continue
157
159
  [ "$DRY_RUN" -eq 1 ] || rm -f "$TARGET/$f"
158
160
  g_info "removed $f (runtime state)"
159
161
  done
162
+ # The repo-distributed MCP registration (init --with-mcp-config): removed ONLY when it
163
+ # still carries the generated bytes — the preimage pattern, the same clause the decision
164
+ # records below get. A hand-tuned or team-customized .mcp.json is the project's and stays.
165
+ if [ -f "$TARGET/.mcp.json" ]; then
166
+ if [ "$(cat "$TARGET/.mcp.json" 2>/dev/null)" = '{"mcpServers":{"gob":{"command":"npx","args":["-y","@techgoblin/gobstack","mcp"]}}}' ]; then
167
+ [ "$DRY_RUN" -eq 1 ] || rm -f "$TARGET/.mcp.json"
168
+ g_info "removed .mcp.json (the generated registration)"
169
+ else
170
+ g_info "kept .mcp.json (not the generated bytes: a registration you customized stays)"
171
+ fi
172
+ fi
160
173
  # The three decision records (audit waivers, boundary waivers, the install-hook allowlist) are
161
174
  # put_once files. An UNEDITED one is still goblin-stack's template and goes; an EDITED one is
162
175
  # the project's decision and stays, named in the summary. Deleting a decision silently would be
@@ -165,7 +178,7 @@ if [ "$UNINSTALL" -eq 1 ]; then
165
178
  while IFS=$'\t' read -r p h; do
166
179
  [ -n "$p" ] || continue
167
180
  case "$p" in
168
- .goblin/audit-waiver.tsv|.goblin/boundary-waivers|.goblin/install-hooks.allowlist) ;;
181
+ .gob/audit-waiver.tsv|.gob/boundary-waivers|.gob/install-hooks.allowlist) ;;
169
182
  *) continue ;;
170
183
  esac
171
184
  [ -e "$TARGET/$p" ] || continue
@@ -179,7 +192,7 @@ if [ "$UNINSTALL" -eq 1 ]; then
179
192
  g_info "kept $p (you edited it: a decision record is the project's, not the harness's)"
180
193
  fi
181
194
  done < <(g_json_object "$INSTALLED" owned)
182
- # installed.json goes BEFORE the directory pass. While it was still on disk, .goblin/ was
195
+ # installed.json goes BEFORE the directory pass. While it was still on disk, .gob/ was
183
196
  # never empty when it was tested, so the directory survived unnamed (D17 / F2-7).
184
197
  if [ "$DRY_RUN" -eq 0 ]; then
185
198
  rm -f "$INSTALLED"
@@ -189,7 +202,7 @@ if [ "$UNINSTALL" -eq 1 ]; then
189
202
  fi
190
203
  # Empty directories goblin-stack created: the parents of the files just removed, plus the
191
204
  # harness roots. Deepest first, so a parent is only tested once its child has gone. The
192
- # old fixed list never tried .hermes/skills/goblin-* and tested .goblin/ too early.
205
+ # old fixed list never tried .hermes/skills/goblin-* and tested .gob/ too early.
193
206
  nd=0
194
207
  while read -r d; do
195
208
  [ -n "$d" ] || continue
@@ -204,29 +217,32 @@ if [ "$UNINSTALL" -eq 1 ]; then
204
217
  fi
205
218
  done < <({
206
219
  printf '%s' "$PARENTS"
207
- printf '.goblin\n.goblin/bin\n.goblin/manifest\n.goblin/bans\n.hermes\n.hermes/skills\n'
220
+ printf '.goblin\n.gob/bin\n.gob/manifest\n.gob/bans\n.hermes\n.hermes/skills\n'
208
221
  } | awk 'NF' | awk -F/ '{print NF " " $0}' | sort -rn | cut -d' ' -f2- | awk '!seen[$0]++')
209
222
  g_info "removed $n file(s) and $nd empty director(y|ies). Left in place: HANDOFF.md, AGENTS.md, ROUND-000-SPEC.md, reviews/, the .gitignore block${KEPT:+, and the decision record(s) you edited:$KEPT}."
210
223
  exit 0
211
224
  fi
212
225
 
213
226
  # ---------------------------------------------------------------- re-pin ------
214
- # The practice pin (practice_sha256: in .goblin/goblin.yaml, re-checked by IN-02) exists to catch
215
- # a SILENTLY edited standard. The standard itself is a living document edited in place, and
216
- # goblin.yaml is an `owned` file the installer never rewrites - so the remedy for an edit that IS
227
+ # The practice pin (practice_sha256: in the AGENTS.md gob block, re-checked by IN-02) exists to
228
+ # catch a SILENTLY edited standard. The standard itself is a living document edited in place, and
229
+ # the block is `owned` content the installer never rewrites - so the remedy for an edit that IS
217
230
  # intended has to be explicit, printed and deliberate. This mode rewrites exactly one line and
218
231
  # reports the old and new hash. It is unreachable from --upgrade and from goblin-verify, by
219
232
  # design: an automatic re-pin would be the silent edit the pin exists to catch.
220
233
  # See docs/CONTRACTS.md, "An edited standard is not a dead end".
234
+ # The write's own failure output is captured (not discarded) so the fail-closed branch below can
235
+ # name it: a g_agents_write that could not land used to vanish into >/dev/null and the pipe still
236
+ # exited 0 (the sed inside writes via mktemp+mv, and only the mv reports).
237
+ RE_PIN_WRITE_LOG=""
221
238
  if [ "$RE_PIN" -eq 1 ]; then
222
239
  [ -f "$CONFIG" ] || { g_err "nothing to re-pin: $CONFIG is absent (install first)"; exit 2; }
223
- PIN_PATH=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" practice)")
240
+ PIN_PATH=$(g_expand_tilde "$(g_agents_read "$CONFIG" practice)")
224
241
  [ -n "$PIN_PATH" ] || { g_err "no practice: recorded in $CONFIG — nothing to re-pin"; exit 2; }
225
242
  [ -f "$PIN_PATH" ] || { g_err "the recorded standard is absent: $PIN_PATH"; exit 2; }
226
- grep -q '^practice_sha256:' "$CONFIG" || { g_err "no practice_sha256: line in $CONFIG to rewrite"; exit 2; }
227
243
  # g_unquote, exactly as goblin-verify reads it: a hand-quoted value is accepted by the verifier,
228
244
  # so it must not read as "different" here and trigger a rewrite of a line that already matches.
229
- OLD_SHA=$(g_unquote "$(g_yaml_scalar "$CONFIG" practice_sha256)")
245
+ OLD_SHA=$(g_unquote "$(g_agents_read "$CONFIG" practice_sha256)")
230
246
  NEW_SHA=$(g_sha256_file "$PIN_PATH")
231
247
  if [ "$OLD_SHA" = "$NEW_SHA" ]; then
232
248
  g_info "practice pin already current: $PIN_PATH"
@@ -239,23 +255,31 @@ if [ "$RE_PIN" -eq 1 ]; then
239
255
  g_info " now $NEW_SHA"
240
256
  exit 0
241
257
  fi
242
- # One line, in place. Every other line of the config - comments included - is untouched, so
243
- # the `owned` contract holds for everything except the single value just asked for.
244
- g_sed_i "s|^practice_sha256:.*|practice_sha256: $NEW_SHA|" "$CONFIG" \
245
- || { g_err "could not rewrite the pin in $CONFIG"; exit 1; }
246
- # Fail closed: never report a re-pin that did not land. The failure this command exists to
247
- # remove is a hash nobody verified (measured at bf7e9c9: sed -i could not write to a read-only
248
- # .goblin/, yet the branch printed "practice re-pinned" with both hashes and exited 0, leaving
249
- # IN-02 RED and the config byte-identical).
250
- grep -qxF "practice_sha256: $NEW_SHA" "$CONFIG" \
258
+ # One key, in place: g_agents_write rewrites only the marker block, and the rewrite keeps
259
+ # every OTHER key by reading the current block first (the `owned` contract holds for
260
+ # everything except the single value just asked for). The write's failure output is captured,
261
+ # not discarded: a g_agents_write that could not land used to vanish into >/dev/null and only
262
+ # the generic fail-closed message remained.
263
+ RE_PIN_WRITE_LOG=$(mktemp "${TMPDIR:-/tmp}/gob-repin.XXXXXX")
264
+ {
265
+ while IFS=$'\t' read -r k v; do
266
+ [ "$k" = "practice_sha256" ] && continue
267
+ printf '%s\t%s\n' "$k" "$v"
268
+ done < <(g_agents_pairs "$CONFIG")
269
+ printf 'practice_sha256\t%s\n' "$NEW_SHA"
270
+ } | g_agents_write "$CONFIG" > "$RE_PIN_WRITE_LOG" 2>&1 \
271
+ || { g_err "could not rewrite the pin in $CONFIG"; sed -n '1,3p' "$RE_PIN_WRITE_LOG" >&2 2>/dev/null; rm -f "$RE_PIN_WRITE_LOG"; exit 1; }
272
+ rm -f "$RE_PIN_WRITE_LOG"
273
+ # Fail closed: never report a re-pin that did not land.
274
+ [ "$(g_agents_read "$CONFIG" practice_sha256)" = "$NEW_SHA" ] \
251
275
  || { g_err "the new pin did not land in $CONFIG"; exit 1; }
252
276
  g_info "practice re-pinned: $PIN_PATH"
253
277
  g_info " recorded $OLD_SHA"
254
278
  g_info " now $NEW_SHA"
255
279
  g_info ""
256
280
  g_info "next:"
257
- g_info " 1. cd $TARGET && git add .goblin/goblin.yaml && git commit # the re-pin is a change like any other"
258
- g_info " 2. .goblin/bin/goblin-verify --only IN-02 # practice pin ok"
281
+ g_info " 1. cd $TARGET && git add AGENTS.md && git commit # the re-pin is a change like any other"
282
+ g_info " 2. .gob/bin/goblin-verify --only IN-02 # practice pin ok"
259
283
  exit 0
260
284
  fi
261
285
 
@@ -268,7 +292,6 @@ CLASS_RAW="$CLASS"
268
292
  CLASS=$(g_class_canon "$CLASS") \
269
293
  || { g_err "--class must be software|service|game|research|fleet (or A-E / app / agent / desktop), got '$CLASS_RAW'"; exit 2; }
270
294
  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
272
295
 
273
296
  # W6 migration safety: on a --upgrade (or any re-install) of a repo whose record shows skills
274
297
  # were installed, an OMITTED --skills flag must READ that state, not silently strip it. The
@@ -306,7 +329,14 @@ preset() {
306
329
  HARNESS_DIR=$(preset harness_dir); [ -n "$HARNESS_DIR" ] || HARNESS_DIR=checks
307
330
  SCAFFOLD=$(preset scaffold_checks); [ -n "$SCAFFOLD" ] || SCAFFOLD=no
308
331
  GATE_NAME=$(preset gate_name)
332
+ # (GATE1/GATE2 render names are set in render_config; the preset keys are unchanged.)
309
333
  GATE_CMD=$(preset gate_cmd)
334
+ # `--gate <cmd>` (gob init --write): the proposal's first gate REPLACES the class default.
335
+ # `--gate <cmd> <name>` (optional 2nd word): the proposal may also name the gate — the
336
+ # HANDOFF template renders THAT name, so HP-03 (anchored on the DECLARED names) sees the
337
+ # gate line a fresh HANDOFF carries even when the name is not the class default.
338
+ case "${GATE_NAME_ARG:-}" in "") ;; *) GATE_NAME="$GATE_NAME_ARG" ;; esac
339
+ [ -n "${GOB_GATE:-}" ] && GATE_CMD="$GOB_GATE"
310
340
  RAT_NAME=$(preset ratchet_name)
311
341
  RAT_CMD=$(preset ratchet_cmd)
312
342
  RAT_CEIL=$(preset ratchet_ceiling)
@@ -343,7 +373,7 @@ need() { g_class_need "$CLASSES_TSV" "$CLASS" "$1"; }
343
373
 
344
374
  # parts that are OFF for this class become real opt-outs, not silent absences
345
375
  DISABLED=""
346
- for p in handoff spec gate replay ratchet pr-gate review-panel playbooks tokens ci-gate; do
376
+ for p in handoff spec gate replay ratchet pr-gate review-panel playbooks tokens; do
347
377
  [ "$(need "$p")" = "-" ] && DISABLED="$DISABLED$p,"
348
378
  done
349
379
  [ "$SKILLS" = "no" ] && DISABLED="$DISABLED""playbooks,"
@@ -363,18 +393,9 @@ WANT_SPEC=1; [ "$(need spec)" = "-" ] && WANT_SPEC=0
363
393
  WANT_REVIEWS=0
364
394
  case "$(need pr-gate)$(need review-panel)" in *R*|*O*) WANT_REVIEWS=1 ;; esac
365
395
  WANT_RATCHET=1; [ "$(need ratchet)" = "-" ] && WANT_RATCHET=0
366
- # The CI lane (G6 A.6). Installed for a class that requires or permits it (A, C, E, F) and never
367
- # for one that forbids it (B, D) - where CL-01 then requires the file to be ABSENT, which is why
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.
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
396
+ # The CI lane is GONE (v2 product decision): no .github/workflows file is written, the ci-gate
397
+ # part is recorded as a class-level opt-out, and no flag configures it.
398
+ WANT_CI_GATE=0
378
399
  [ "$ARCHIVE" = "true" ] && { WANT_SPEC=0; WANT_REVIEWS=0; SCAFFOLD=no; }
379
400
 
380
401
  OWNER_EMAIL=$( cd "$TARGET" && git config user.email 2>/dev/null )
@@ -393,7 +414,7 @@ else
393
414
  # has to say so - `practice: ~` with no line in the log is the "silent no-op" species.
394
415
  if [ -n "$PRACTICE_ARG" ]; then
395
416
  g_info "warning: --practice $PRACTICE_ARG does not exist - no standard is referenced"
396
- g_info " (practice: and practice_sha256: stay empty in .goblin/goblin.yaml)"
417
+ g_info " (practice: and practice_sha256: stay empty in the AGENTS.md gob block)"
397
418
  fi
398
419
  PRACTICE=""
399
420
  PRACTICE_SHA=""
@@ -526,16 +547,13 @@ put_once() {
526
547
  # ------------------------------------------------------- generated content ---
527
548
  render_config() {
528
549
  local gates runtime
529
- gates=" - name: $GATE_NAME
530
- cmd: $GATE_CMD"
531
- # A class may declare a second gate (A: the TODO ceiling that used to be the ratchet, G4 D2).
532
- if [ -n "$GATE2_NAME" ] && [ -n "$GATE2_CMD" ]; then
533
- gates="$gates
534
- - name: $GATE2_NAME
535
- cmd: $GATE2_CMD"
536
- fi
537
- runtime=" - $RUNTIME_DATA"
538
- render "$SRC/templates/goblin.yaml.tmpl" \
550
+ # v2 gate shape: one flat key per gate (gate_<name>_cmd:). A class may declare a second
551
+ # gate (A: the TODO ceiling that used to be the ratchet, G4 D2); a class with none renders
552
+ # an empty gate name and the key line is dropped below.
553
+ GATE1_NAME="$GATE_NAME"; GATE1_CMD="$GATE_CMD"
554
+ GATE2_RENDER_NAME="$GATE2_NAME"; GATE2_RENDER_CMD="$GATE2_CMD"
555
+ runtime="$RUNTIME_DATA"
556
+ render "$SRC/templates/agents-block.tmpl" \
539
557
  CLASS "$CLASS" \
540
558
  ELECTRON "$( [ "$ELECTRON" -eq 1 ] && printf 'true' || printf 'false' )" \
541
559
  BRANCH "$BRANCH" \
@@ -566,7 +584,10 @@ render_config() {
566
584
  REFERENCE_MANIFEST "" \
567
585
  QUARANTINE_ROOT "" \
568
586
  LOOP_MAX_TURNS "$LOOP_MAX_TURNS" \
569
- GATES "$gates" \
587
+ GATE1_NAME "$GATE1_NAME" \
588
+ GATE1_CMD "$GATE1_CMD" \
589
+ GATE2_NAME "$GATE2_RENDER_NAME" \
590
+ GATE2_CMD "$GATE2_RENDER_CMD" \
570
591
  RATCHET_NAME "$RAT_NAME" \
571
592
  RATCHET_CMD "$RAT_CMD" \
572
593
  RATCHET_CEILING "$RAT_CEIL" \
@@ -576,7 +597,9 @@ render_config() {
576
597
  LAYERS "$LAYERS_BLOCK" \
577
598
  REPLAY_ENV "$REPLAY_ENV" \
578
599
  REPLAY_CMD "$REPLAY_CMD" \
579
- DISABLED "$DISABLED" > "$TMP/goblin.yaml"
600
+ DISABLED "$DISABLED" > "$TMP/agents-block.raw"
601
+ # A gate with no name renders a `gate__cmd:` line - dropped, not shipped.
602
+ grep -v '^gate__cmd: $' "$TMP/agents-block.raw" > "$TMP/agents-block"
580
603
  }
581
604
 
582
605
  render_config
@@ -610,33 +633,57 @@ if [ -f "$INSTALLED" ] && [ "$(g_install_engine_mode "$INSTALLED")" = "global" ]
610
633
  gsub(q, "", v); print v; exit
611
634
  }
612
635
  ' "$INSTALLED")
613
- g_err "goblin-install: $TARGET is migrated to the global engine at ${g_rec_engine_dir:-<unknown>}. Re-installing would re-shadow it and rewrite its migration record. To return to a vendored engine: git revert the two W3 commits (see docs/GUIDE.md \"Rolling back a migration\"). To re-point at a different engine: edit .goblin/goblin.yaml's engine_dir line."
636
+ g_err "goblin-install: $TARGET is migrated to the global engine at ${g_rec_engine_dir:-<unknown>}. Re-installing would re-shadow it and rewrite its migration record. To return to a vendored engine: git revert the two W3 commits (see docs/GUIDE.md \"Rolling back a migration\"). To re-point at a different engine: edit AGENTS.md's engine_dir line."
614
637
  exit 1
615
638
  fi
616
639
 
617
- put ".goblin/bin/goblin-verify" "$SRC/bin/goblin-verify"
618
- put ".goblin/bin/goblin-lib.sh" "$SRC/bin/goblin-lib.sh"
640
+ put ".gob/bin/goblin-verify" "$SRC/bin/goblin-verify"
641
+ put ".gob/bin/goblin-lib.sh" "$SRC/bin/goblin-lib.sh"
619
642
  # The audit record is produced by a DELIBERATE, separate command - SC-07 reads the record and
620
643
  # never the network (RISKS.md K4: no network at verify time).
621
- put ".goblin/bin/goblin-audit" "$SRC/bin/goblin-audit"
622
- put ".goblin/roles.yaml" "$SRC/roles.yaml"
623
- put ".goblin/manifest/enforcement.tsv" "$SRC/manifest/enforcement.tsv"
624
- put ".goblin/manifest/playbooks.tsv" "$SRC/manifest/playbooks.tsv"
625
- put ".goblin/manifest/classes.tsv" "$SRC/manifest/classes.tsv"
626
- put ".goblin/manifest/glossary.tsv" "$SRC/manifest/glossary.tsv"
644
+ put ".gob/bin/goblin-audit" "$SRC/bin/goblin-audit"
645
+ put ".gob/roles.yaml" "$SRC/roles.yaml"
646
+ put ".gob/manifest/enforcement.tsv" "$SRC/manifest/enforcement.tsv"
647
+ put ".gob/manifest/playbooks.tsv" "$SRC/manifest/playbooks.tsv"
648
+ put ".gob/manifest/classes.tsv" "$SRC/manifest/classes.tsv"
649
+ put ".gob/manifest/glossary.tsv" "$SRC/manifest/glossary.tsv"
627
650
  # The ban list (G5): the table, the engine, and the two probes. A ban with no mechanism is a
628
651
  # wish, so the table and the engine install together or not at all.
629
- put ".goblin/manifest/bans.tsv" "$SRC/manifest/bans.tsv"
630
- put ".goblin/bin/goblin-bans" "$SRC/bin/goblin-bans"
631
- put ".goblin/bans/README.md" "$SRC/bans/README.md"
632
- put ".goblin/bans/grep-ban.sh" "$SRC/bans/grep-ban.sh"
633
- put ".goblin/bans/layer-check.sh" "$SRC/bans/layer-check.sh"
634
- put_once ".goblin/goblin.yaml" "$TMP/goblin.yaml"
652
+ put ".gob/manifest/bans.tsv" "$SRC/manifest/bans.tsv"
653
+ put ".gob/bin/goblin-bans" "$SRC/bin/goblin-bans"
654
+ put ".gob/bans/README.md" "$SRC/bans/README.md"
655
+ put ".gob/bans/grep-ban.sh" "$SRC/bans/grep-ban.sh"
656
+ put ".gob/bans/layer-check.sh" "$SRC/bans/layer-check.sh"
657
+ # The AGENTS.md frontmatter: the body prose is rendered once (put_once), then the marker
658
+ # block is written into it through g_agents_write — the same path every later rewrite takes,
659
+ # so the block is never a second copy of the file and the body is never machine-owned.
660
+ render "$SRC/templates/AGENTS.md.tmpl" CLASS "$CLASS" DATE "$STAMP" > "$TMP/agents-body"
661
+ put_once "AGENTS.md" "$TMP/agents-body"
662
+ if [ "$DRY_RUN" -eq 0 ] && [ -f "$CONFIG" ]; then
663
+ # The install-once contract for the project's own answers: on a RE-install (the previous
664
+ # record exists), the block rewrite carries forward the existing practice pin instead of the
665
+ # freshly hashed one - an --upgrade must never re-pin the standard by itself, because a pin
666
+ # that silently follows the file defeats the only check that guards it (IN-02). A FIRST
667
+ # install (no previous record) has nothing to carry forward: the rendered value is the pin.
668
+ # The block file here is the RENDERED template ("key: value" lines, no tabs), so the match
669
+ # anchors on the key prefix, not on a tab field ($1 == "practice" never fired under the
670
+ # rendered shape and the freshly rendered NEW hash flowed straight through — an --upgrade
671
+ # re-pinned the standard silently, exactly what IN-02 exists to catch).
672
+ if [ -n "$PREV_FILES" ]; then
673
+ KEEP_PRACTICE=$(g_agents_read "$CONFIG" practice)
674
+ KEEP_PRACTICE_SHA=$(g_agents_read "$CONFIG" practice_sha256)
675
+ if [ -n "$KEEP_PRACTICE" ]; then
676
+ sed -i "s#^practice: .*\$#practice: $KEEP_PRACTICE#; s#^practice_sha256: .*\$#practice_sha256: $KEEP_PRACTICE_SHA#" "$TMP/agents-block"
677
+ fi
678
+ fi
679
+ g_agents_write "$CONFIG" < "$TMP/agents-block" >/dev/null \
680
+ || { g_err "could not write the gob block into $CONFIG"; exit 1; }
681
+ fi
635
682
  # The security lane's two decision records and the audit allowlist are created once, then YOURS:
636
683
  # they are answers to questions only the project can answer (G4 B3).
637
- [ -f "$SRC/templates/audit-waiver.tsv.tmpl" ] && put_once ".goblin/audit-waiver.tsv" "$SRC/templates/audit-waiver.tsv.tmpl"
638
- [ -f "$SRC/templates/boundary-waivers.tmpl" ] && put_once ".goblin/boundary-waivers" "$SRC/templates/boundary-waivers.tmpl"
639
- [ -f "$SRC/templates/install-hooks.allowlist.tmpl" ] && put_once ".goblin/install-hooks.allowlist" "$SRC/templates/install-hooks.allowlist.tmpl"
684
+ [ -f "$SRC/templates/audit-waiver.tsv.tmpl" ] && put_once ".gob/audit-waiver.tsv" "$SRC/templates/audit-waiver.tsv.tmpl"
685
+ [ -f "$SRC/templates/boundary-waivers.tmpl" ] && put_once ".gob/boundary-waivers" "$SRC/templates/boundary-waivers.tmpl"
686
+ [ -f "$SRC/templates/install-hooks.allowlist.tmpl" ] && put_once ".gob/install-hooks.allowlist" "$SRC/templates/install-hooks.allowlist.tmpl"
640
687
 
641
688
  if [ "$SKILLS" = "yes" ] && part_wanted playbooks; then
642
689
  for d in "$SRC"/skills/*/; do
@@ -654,7 +701,7 @@ if [ "$SKILLS_NAMED" -eq 1 ] && [ "$SKILLS" = "no" ] && [ -n "$PREV_FILES" ]; th
654
701
  while IFS= read -r rel; do
655
702
  case "$rel" in .hermes/skills/*) ;; *) continue ;; esac
656
703
  dst="$TARGET/$rel"
657
- [ -f "$dst" ] || { rm -f "$TARGET/.goblin/$(basename "$rel")" 2>/dev/null; continue; }
704
+ [ -f "$dst" ] || { rm -f "$TARGET/.gob/$(basename "$rel")" 2>/dev/null; continue; }
658
705
  if [ "$DRY_RUN" -eq 1 ]; then
659
706
  g_info "plan: remove $rel (explicit --skills no)"
660
707
  else
@@ -677,10 +724,10 @@ if [ "$PLAYBOOKS_ON" -eq 1 ] && part_wanted playbooks; then
677
724
  for f in "$SRC"/automations/drift-audit.sh "$SRC"/automations/bugreporter-intake.sh \
678
725
  "$SRC"/automations/README.md "$SRC"/automations/report.schema.tsv; do
679
726
  [ -f "$f" ] || continue
680
- put ".goblin/automations/$(basename "$f")" "$f"
727
+ put ".gob/automations/$(basename "$f")" "$f"
681
728
  done
682
729
  if [ -f "$SRC/templates/report.yaml.tmpl" ]; then
683
- put ".goblin/automations/report.yaml.tmpl" "$SRC/templates/report.yaml.tmpl"
730
+ put ".gob/automations/report.yaml.tmpl" "$SRC/templates/report.yaml.tmpl"
684
731
  fi
685
732
  fi
686
733
 
@@ -689,26 +736,25 @@ if [ "$WANT_SPEC" -eq 1 ] && part_wanted spec; then
689
736
  put_once "ROUND-000-SPEC.md" "$TMP/spec"
690
737
  fi
691
738
  if [ "$ARCHIVE" != "true" ] && part_wanted handoff; then
692
- render "$SRC/templates/HANDOFF.md.tmpl" CLASS "$CLASS" DATE "$STAMP" HEAD "$HEAD_SHORT" BRANCH "$BRANCH" GATE_NAME "$GATE_NAME" GATE_CMD "$GATE_CMD" > "$TMP/handoff"
739
+ # The HANDOFF names the gate the way GT-02 runs it: by the command's own name key
740
+ # (gate_<name>_cmd), so HP-03 anchors on a declared name even when --gate replaced the
741
+ # class default command. GATE2_LINE renders the class's second gate (if any) the same
742
+ # way — a Gates section that names only gate 1 while AGENTS.md declares two is the
743
+ # HP-03 day-one anchoring red. A class with no second gate renders an empty line.
744
+ if [ -n "$GATE2_NAME" ]; then
745
+ GATE2_LINE="- \`$GATE2_NAME\` = 0 · measured $STAMP"
746
+ else
747
+ GATE2_LINE=""
748
+ fi
749
+ render "$SRC/templates/HANDOFF.md.tmpl" CLASS "$CLASS" DATE "$STAMP" HEAD "$HEAD_SHORT" BRANCH "$BRANCH" GATE_NAME "$GATE_NAME" GATE_CMD "$GATE_CMD" GATE2_LINE "$GATE2_LINE" > "$TMP/handoff"
693
750
  put_once "HANDOFF.md" "$TMP/handoff"
694
751
  fi
695
- render "$SRC/templates/AGENTS.md.tmpl" CLASS "$CLASS" DATE "$STAMP" > "$TMP/agents"
696
- put_once "AGENTS.md" "$TMP/agents"
697
752
 
698
753
  if [ "$WANT_REVIEWS" -eq 1 ]; then
699
754
  printf '# Review verdicts live here: reviews/<slug>-<head7>.md, each naming the SHA it reviewed.\n' > "$TMP/gitkeep"
700
755
  put_once "reviews/.gitkeep" "$TMP/gitkeep"
701
756
  fi
702
757
 
703
- # The CI lane (G6): one workflow, rendered and placed. The header of the template states what
704
- # makes a workflow a GATE (a required check, no admin bypass, a push identity that is not the
705
- # repo's sole admin) - because a workflow file nobody is bound by is decoration. The job carries
706
- # no `if:` at any level, so PG-05 passes on goblin-stack's own output.
707
- if [ "$WANT_CI_GATE" -eq 1 ] && part_wanted ci-gate && [ -f "$SRC/templates/ci/goblin-gate.yml.tmpl" ]; then
708
- render "$SRC/templates/ci/goblin-gate.yml.tmpl" CLASS "$CLASS" BRANCH "$BRANCH" DATE "$STAMP" > "$TMP/gate.yml"
709
- put ".github/workflows/goblin-gate.yml" "$TMP/gate.yml"
710
- fi
711
-
712
758
  if [ "$SCAFFOLD" = "yes" ]; then
713
759
  put "$HARNESS_DIR/gate.sh" "$SRC/templates/checks/gate.sh.tmpl"
714
760
  put "$HARNESS_DIR/assert.mjs" "$SRC/templates/checks/assert.mjs.tmpl"
@@ -725,7 +771,7 @@ else
725
771
  [ -f "$TARGET/.gitignore" ] && cat "$TARGET/.gitignore"
726
772
  printf '\n%s\n' "$GI_MARK"
727
773
  printf '# goblin-stack runtime state (gates write these; they are not fixture)\n'
728
- printf '.goblin/last-gate-line\n.goblin/.ds-report\n.goblin/ratchet-last\n'
774
+ printf '.gob/last-gate-line\n.gob/.ds-report\n.gob/ratchet-last\n'
729
775
  if [ "$SEC_GITIGNORE_FAMILY" = "yes" ]; then
730
776
  # SC-02: the whole secret family, not one member of it. The row is behavioural - it asks
731
777
  # git's own matcher (`git check-ignore`) rather than reading this file - so a rule that
@@ -751,7 +797,7 @@ fi
751
797
  printf ' "source_sha": "%s",\n' "$( cd "$SRC" && git rev-parse HEAD 2>/dev/null || echo unknown )"
752
798
  printf ' "class": "%s",\n' "$CLASS"
753
799
  printf ' "installed_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
754
- printf ' "options": {"skills": "%s", "archive": %s, "parts": "%s", "ci_gate": "%s"},\n' "$SKILLS" "$ARCHIVE" "${PARTS:-all}" "${CI_GATE_OPT:-class}"
800
+ printf ' "options": {"skills": "%s", "archive": %s, "parts": "%s"},\n' "$SKILLS" "$ARCHIVE" "${PARTS:-all}"
755
801
  printf ' "files": {\n'
756
802
  awk -F'\t' 'NF==2 {printf " \"%s\": \"%s\",\n", $1, $2}' "$TMP/files.tsv" | sed '$ s/,$//'
757
803
  printf ' },\n'
@@ -776,7 +822,6 @@ fi
776
822
 
777
823
 
778
824
  if [ "$DRY_RUN" -eq 0 ]; then
779
- mkdir -p "$TARGET/.goblin"
780
825
  if [ "$CREATED" -eq 0 ] && [ "$UPDATED" -eq 0 ] && [ "$PREV_VERSION" = "$VERSION" ] \
781
826
  && [ "$(printf '%s' "$RECORD_SKILLS_EFFECTIVE" )" = "$(printf '%s' "$SKILLS")" ]; then
782
827
  g_info "no-op: $UNCHANGED files unchanged (v$VERSION already installed)"
@@ -787,22 +832,23 @@ if [ "$DRY_RUN" -eq 0 ]; then
787
832
  fi
788
833
  g_info "created $CREATED · updated $UPDATED · unchanged $UNCHANGED · skipped $SKIPPED"
789
834
  fi
790
- if [ -n "$OPT_OUT" ]; then
791
- for p in $(printf '%s' "$OPT_OUT" | tr ',' ' '); do
792
- if grep -q "^disabled:" "$CONFIG"; then
793
- cur=$(g_yaml_scalar "$CONFIG" disabled)
794
- cur=${cur#[}; cur=${cur%]}
795
- new=$(printf '%s,%s' "$cur" "$p" | tr ',' '\n' | grep -v '^$' | sort -u | paste -sd, -)
796
- g_sed_i "s|^disabled:.*|disabled: [$new]|" "$CONFIG"
797
- fi
798
- done
835
+ if [ -n "$OPT_OUT" ] && grep -q "^$GOB_AGENTS_BEGIN" "$CONFIG" 2>/dev/null; then
836
+ cur=$(g_agents_read "$CONFIG" disabled)
837
+ new=$(printf '%s,%s' "${cur#[]}" "${OPT_OUT}" | tr ',' '\n' | grep -v '^$' | sort -u | paste -sd, -)
838
+ {
839
+ while IFS=$'\t' read -r k v; do
840
+ [ "$k" = "disabled" ] && continue
841
+ printf '%s\t%s\n' "$k" "$v"
842
+ done < <(g_agents_pairs "$CONFIG")
843
+ printf 'disabled\t[%s]\n' "$new"
844
+ } | g_agents_write "$CONFIG" >/dev/null
799
845
  g_info "recorded opt-out: $OPT_OUT"
800
846
  fi
801
847
  g_info ""
802
848
  g_info "next:"
803
849
  g_info " 1. cd $TARGET && git add -A && git commit # the install is a change like any other"
804
- g_info " 2. .goblin/bin/goblin-verify # or add .goblin/bin to PATH"
805
- g_info " 3. edit .goblin/goblin.yaml: replace the default gate with your real commands (P8 step 3)"
850
+ g_info " 2. .gob/bin/goblin-verify # or add .gob/bin to PATH"
851
+ g_info " 3. edit AGENTS.md: replace the default gate with your real commands (P8 step 3)"
806
852
  if [ "$SKILLS" = "yes" ]; then
807
853
  g_info " 4. hermes skills trust $TARGET # one-time, so the project-tier skills load"
808
854
  else
@@ -810,8 +856,8 @@ if [ "$DRY_RUN" -eq 0 ]; then
810
856
  fi
811
857
  g_info ""
812
858
  g_info "automations (optional; neither writes outside this repo, and A-02 has no agent in it):"
813
- g_info " bash $TARGET/.goblin/automations/drift-audit.sh --dry-run # read the record first"
814
- g_info " cp $TARGET/.goblin/automations/drift-audit.sh ~/.hermes/scripts/"
859
+ g_info " bash $TARGET/.gob/automations/drift-audit.sh --dry-run # read the record first"
860
+ g_info " cp $TARGET/.gob/automations/drift-audit.sh ~/.hermes/scripts/"
815
861
  g_info " hermes cron create \"0 9 * * 1\" --name \"Drift audit - weekly\" --no-agent \\"
816
862
  g_info " --script drift-audit.sh --deliver <channel>"
817
863
  g_info " P13/P14 in docs/FLOWS.md; the whole model in automations/README.md"