@techgoblin/gobstack 0.0.0-stage → 0.4.4-beta.1

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 (108) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +163 -2
  4. package/VERSION +1 -0
  5. package/adapters/_template/adapter.tsv +16 -0
  6. package/adapters/_template/detect.sh +10 -0
  7. package/adapters/_template/emit.sh +5 -0
  8. package/adapters/_template/verify.sh +4 -0
  9. package/adapters/claude/adapter.tsv +8 -0
  10. package/adapters/claude/detect.sh +8 -0
  11. package/adapters/claude/verify.sh +47 -0
  12. package/adapters/codex/adapter.tsv +12 -0
  13. package/adapters/codex/detect.sh +9 -0
  14. package/adapters/codex/verify.sh +45 -0
  15. package/adapters/copilot/adapter.tsv +10 -0
  16. package/adapters/copilot/detect.sh +8 -0
  17. package/adapters/copilot/verify.sh +45 -0
  18. package/adapters/cursor/adapter.tsv +11 -0
  19. package/adapters/cursor/detect.sh +10 -0
  20. package/adapters/cursor/verify.sh +45 -0
  21. package/adapters/gemini/adapter.tsv +15 -0
  22. package/adapters/gemini/detect.sh +11 -0
  23. package/adapters/gemini/verify.sh +49 -0
  24. package/adapters/hermes/adapter.tsv +9 -0
  25. package/adapters/hermes/detect.sh +8 -0
  26. package/adapters/hermes/verify.sh +27 -0
  27. package/adapters/opencode/adapter.tsv +14 -0
  28. package/adapters/opencode/detect.sh +9 -0
  29. package/adapters/opencode/verify.sh +45 -0
  30. package/automations/README.md +53 -0
  31. package/automations/bugreporter-intake.sh +145 -0
  32. package/automations/drift-audit.sh +139 -0
  33. package/automations/report.schema.tsv +10 -0
  34. package/bans/README.md +82 -0
  35. package/bans/grep-ban.sh +84 -0
  36. package/bans/layer-check.sh +57 -0
  37. package/bin/goblin +119 -0
  38. package/bin/goblin-audit +145 -0
  39. package/bin/goblin-bans +178 -0
  40. package/bin/goblin-doctor +233 -0
  41. package/bin/goblin-emit +482 -0
  42. package/bin/goblin-init +519 -0
  43. package/bin/goblin-install +720 -0
  44. package/bin/goblin-lib.sh +289 -0
  45. package/bin/goblin-model +105 -0
  46. package/bin/goblin-upgrade +572 -0
  47. package/bin/goblin-verify +2798 -0
  48. package/bin/goblin.js +48 -0
  49. package/docs/ADOPTION.md +168 -0
  50. package/docs/CI.md +187 -0
  51. package/docs/CONTRACTS.md +197 -0
  52. package/docs/DESIGN.md +92 -0
  53. package/docs/ENFORCEMENT.md +225 -0
  54. package/docs/FLOWS.md +164 -0
  55. package/docs/GUARDRAILS.md +126 -0
  56. package/docs/GUIDE.md +610 -0
  57. package/docs/INTEGRATION.md +92 -0
  58. package/docs/LIMITS.md +591 -0
  59. package/docs/LOOP.md +165 -0
  60. package/docs/RE-PLAYBOOK.md +183 -0
  61. package/docs/RISKS.md +70 -0
  62. package/docs/ROLES.md +105 -0
  63. package/manifest/bans.tsv +9 -0
  64. package/manifest/classes.tsv +61 -0
  65. package/manifest/enforcement.tsv +88 -0
  66. package/manifest/glossary.tsv +25 -0
  67. package/manifest/playbooks.tsv +16 -0
  68. package/package.json +36 -4
  69. package/presets/A-shipped-software.yaml +48 -0
  70. package/presets/B-service-config.yaml +40 -0
  71. package/presets/C-game.yaml +38 -0
  72. package/presets/D-knowledge.yaml +41 -0
  73. package/presets/E-fleet-config.yaml +42 -0
  74. package/presets/F-electron.yaml +67 -0
  75. package/roles.yaml +54 -0
  76. package/skills/goblin-bootstrap/SKILL.md +51 -0
  77. package/skills/goblin-bugfix/SKILL.md +26 -0
  78. package/skills/goblin-bugreporter/SKILL.md +52 -0
  79. package/skills/goblin-drift-audit/SKILL.md +43 -0
  80. package/skills/goblin-eval/SKILL.md +68 -0
  81. package/skills/goblin-feature/SKILL.md +26 -0
  82. package/skills/goblin-feature-map/SKILL.md +140 -0
  83. package/skills/goblin-handoff/SKILL.md +28 -0
  84. package/skills/goblin-investigation/SKILL.md +26 -0
  85. package/skills/goblin-judge/SKILL.md +74 -0
  86. package/skills/goblin-loop/SKILL.md +88 -0
  87. package/skills/goblin-mode/SKILL.md +70 -0
  88. package/skills/goblin-overnight/SKILL.md +42 -0
  89. package/skills/goblin-pr-gate/SKILL.md +42 -0
  90. package/skills/goblin-re-mobile/SKILL.md +51 -0
  91. package/skills/goblin-refactor/SKILL.md +23 -0
  92. package/skills/goblin-sweep/SKILL.md +23 -0
  93. package/skills/goblin-tdd-repro/SKILL.md +27 -0
  94. package/skills/goblin-verify-author/SKILL.md +50 -0
  95. package/skills/practice/SKILL.md +37 -0
  96. package/templates/AGENTS.md.tmpl +23 -0
  97. package/templates/HANDOFF.md.tmpl +43 -0
  98. package/templates/SPEC.md.tmpl +34 -0
  99. package/templates/audit-waiver.tsv.tmpl +10 -0
  100. package/templates/boundary-waivers.tmpl +8 -0
  101. package/templates/checks/assert.mjs.tmpl +60 -0
  102. package/templates/checks/gate.sh.tmpl +29 -0
  103. package/templates/ci/goblin-gate.yml.tmpl +46 -0
  104. package/templates/goblin.yaml.tmpl +138 -0
  105. package/templates/install-hooks.allowlist.tmpl +9 -0
  106. package/templates/loop/decisions.tsv.tmpl +1 -0
  107. package/templates/loop/predicate.tmpl +16 -0
  108. package/templates/report.yaml.tmpl +16 -0
@@ -0,0 +1,572 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-upgrade — the 0.4.4 per-repo → global migration (W3, W3-SPEC §2).
3
+ #
4
+ # goblin upgrade [--target <dir>] [--dry-run] [--yes] [--engine-dir <path>]
5
+ #
6
+ # What it does (the corrected 7-step sequence, W3-SPEC §2 — the record is rewritten
7
+ # BEFORE the deletion, and every verify runs against a committed tree, CM-03):
8
+ # 0. preflight: resolve the input state (I1/I2/I3/I4), refuse R1–R10
9
+ # 1. hash-verify every recorded file (IN-02 clause-1 logic) — drift exits 1
10
+ # 2. verify #1 — the untouched repo must already be green
11
+ # 3. land the global engine at --engine-dir (default ~/.goblin/engine), 0700,
12
+ # by copying this repo's own 18 verified bytes (same-hash reuse; different
13
+ # hash → R6 refusal — never overwrite a global engine other repos declare)
14
+ # 4. commit A: engine_dir: active in goblin.yaml + engine: block in
15
+ # installed.json (18 engine entries dropped, skills retained) + gate.sh and
16
+ # the CI run line re-pointed
17
+ # 5. verify #2 — green with BOTH engines present (the vendored one judges; the
18
+ # point is that the rewritten record and gate.sh parse mid-sequence)
19
+ # 6. commit B: git rm exactly the 18 engine paths (the only irreversible step)
20
+ # 7. verify #3 — expected, measured: 39 passed, 0 failed, 11 advisory,
21
+ # 32 skipped, rc 0 (W3-SPEC MA5)
22
+ # 8. report: counts, engine identity, both commit SHAs, the rollback command
23
+ #
24
+ # Exit contract (PLAN-V1 §2.3): 0 ok or no-op · 1 refusal, with the path and the
25
+ # fix · 2 bad input. Refusals R1–R10 are W3-SPEC §4 verbatim in intent.
26
+ #
27
+ # Non-negotiables inherited from the rest of bin/: no npm, no jq, no yq, no
28
+ # network (bash/awk/sed/git only); the engine stays bash; nothing outside the
29
+ # repo is written except the engine lift, which is additive; rollback is pure
30
+ # `git revert` of this command's own two commits (W3-SPEC §5).
31
+ set -uo pipefail
32
+
33
+ GOBLIN_UPGRADE_VERSION="0.4.4"
34
+
35
+ usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
36
+
37
+ g_err() { printf 'error: %s\n' "$*" >&2; }
38
+
39
+ # g_expand_tilde <path> — ~ and ~/... to $HOME (the goblin-install:81 idiom).
40
+ g_expand_tilde() {
41
+ case "$1" in
42
+ "~") printf '%s\n' "$HOME" ;;
43
+ "~"/*) printf '%s\n' "$HOME/${1#\~/}" ;;
44
+ *) printf '%s\n' "$1" ;;
45
+ esac
46
+ }
47
+
48
+ # sha256 <file> — the one hash spelling, used everywhere.
49
+ sha256() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
50
+
51
+ # ---- options ------------------------------------------------------------------
52
+ TARGET="."
53
+ DRY_RUN=0 # --dry-run is the DEFAULT: the CLI never changes a repo
54
+ # without an explicit --yes (PLAN-V1 §8.1 item 8).
55
+ YES=0
56
+ ENGINE_DIR_OPT=""
57
+ while [ $# -gt 0 ]; do
58
+ case "$1" in
59
+ --target) TARGET="${2:-}"; shift 2 ;;
60
+ --dry-run) DRY_RUN=1; shift ;;
61
+ --yes) YES=1; shift ;;
62
+ --engine-dir) ENGINE_DIR_OPT="${2:-}"; shift 2 ;;
63
+ --help|-h) usage; exit 0 ;;
64
+ *) g_err "unknown option: $1"; usage >&2; exit 2 ;;
65
+ esac
66
+ done
67
+ if [ "$DRY_RUN" -eq 1 ] && [ "$YES" -eq 1 ]; then
68
+ g_err "--dry-run and --yes are mutually exclusive"
69
+ exit 2
70
+ fi
71
+ [ -n "$TARGET" ] || { g_err "--target needs a directory"; exit 2; }
72
+ [ -z "$ENGINE_DIR_OPT" ] || [ -n "${ENGINE_DIR_OPT#-}" ] || { g_err "--engine-dir needs a path"; exit 2; }
73
+
74
+ for dep in git awk sed grep sha256sum; do
75
+ command -v "$dep" >/dev/null 2>&1 || { g_err "missing dependency: $dep"; exit 2; }
76
+ done
77
+
78
+ [ -d "$TARGET" ] || { g_err "--target is not a directory: $TARGET"; exit 2; }
79
+ REPO=$(cd "$TARGET" && pwd)
80
+ [ -n "$ENGINE_DIR_OPT" ] && ENGINE_DIR_OPT=$(g_expand_tilde "$ENGINE_DIR_OPT")
81
+ ED="${ENGINE_DIR_OPT:-$HOME/.goblin/engine}"
82
+
83
+ CONFIG="$REPO/.goblin/goblin.yaml"
84
+ INSTALLED="$REPO/.goblin/installed.json"
85
+
86
+ # The 18 engine paths (W3-SPEC MA1): .goblin/bin/* (the 4 installable) +
87
+ # .goblin/manifest/* (5) + .goblin/bans/* (3) + .goblin/automations/* (5) +
88
+ # .goblin/roles.yaml. The checkout's bin/ holds 8 files; only 4 are installable
89
+ # (goblin-install:509-522), so the vendored payload is exactly these four.
90
+ ENGINE_BIN_FILES="goblin-verify goblin-lib.sh goblin-audit goblin-bans"
91
+
92
+ # vendored_list — the repo's engine payload, one path per line, .goblin-prefixed.
93
+ # Empty when there is no payload (the engine is already gone).
94
+ vendored_list() {
95
+ local f
96
+ [ -d "$REPO/.goblin/bin" ] && for f in $ENGINE_BIN_FILES; do
97
+ [ -f "$REPO/.goblin/bin/$f" ] && printf '%s\n' ".goblin/bin/$f"
98
+ done
99
+ for d in manifest bans automations; do
100
+ [ -d "$REPO/.goblin/$d" ] || continue
101
+ for f in "$REPO/.goblin/$d/"*; do
102
+ [ -f "$f" ] && printf '%s\n' ".goblin/$d/$(basename "$f")"
103
+ done
104
+ done
105
+ [ -f "$REPO/.goblin/roles.yaml" ] && printf '%s\n' ".goblin/roles.yaml"
106
+ return 0
107
+ }
108
+
109
+ # g_installed_files_strict <record> — only the `files:` map's entries (the same
110
+ # reader goblin-verify's IN-04 uses: the `engine:` block's key/value pairs must
111
+ # never read as files).
112
+ g_installed_files_strict() {
113
+ awk '
114
+ /^ "files": \{[[:space:]]*\},?$/ { next }
115
+ /^ "files": \{/ { f = 1; next }
116
+ f && /^ \},$/ { f = 0 }
117
+ f && /^ "/ {
118
+ line = $0
119
+ sub(/^ "/, "", line)
120
+ p = line; sub(/".*/, "", p)
121
+ h = line; sub(/[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
122
+ if (p != "") print p "\t" h
123
+ }
124
+ ' "$1"
125
+ }
126
+
127
+ # engine_block_field <key> — one field of the record's engine: block (empty if absent).
128
+ engine_block_field() {
129
+ [ -f "$INSTALLED" ] || return 0
130
+ awk -v k="$1" '
131
+ index($0, "\"engine\"") && /:[[:space:]]*\{/ { inf = 1; next }
132
+ inf && /^[[:space:]]*\}/ { inf = 0 }
133
+ inf && $0 ~ ("\"" k "\"") {
134
+ v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/,?[[:space:]]*$/, "", v)
135
+ gsub(/"/, "", v); print v; exit
136
+ }
137
+ ' "$INSTALLED"
138
+ }
139
+
140
+ record_scalar() { # <key>
141
+ sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}$/\1/p" "$INSTALLED" | head -n 1
142
+ }
143
+
144
+ # record_files_count — entries in the record's files map.
145
+ record_files_count() { g_installed_files_strict "$INSTALLED" | grep -c .; }
146
+
147
+ record_engine_entries_count() { # engine-path entries still in the record
148
+ g_installed_files_strict "$INSTALLED" | cut -f1 | grep -cE '^\.goblin/(bin/|manifest/|bans/|automations/|roles\.yaml$)' || true
149
+ }
150
+
151
+ # ---- verify #N runner ----------------------------------------------------------
152
+ # The verify INSIDE the sequence must see the target repo's own state: HOME and
153
+ # GOBLIN_ENGINE_DIR are re-pointed for the child only, so the operator's shell
154
+ # environment can never override the migration's own declaration (the engine-dir
155
+ # escape hatch, --engine-dir, must win over an inherited GOBLIN_ENGINE_DIR).
156
+ VERIFY_SCRIPT="" # resolved per step: vendored while it exists, else the engine's
157
+ run_verify() {
158
+ local ed="$ED"
159
+ ( cd "$REPO" && env -u GOBLIN_ENGINE_DIR \
160
+ ${ed:+GOBLIN_ENGINE_DIR="$ed"} HOME="${GOBLIN_UPGRADE_HOME:-$HOME}" \
161
+ bash "$VERIFY_SCRIPT" "$@" )
162
+ }
163
+
164
+ step_line() { printf 'step %s: %s\n' "$1" "$2"; }
165
+
166
+ # ---- preflight: resolve the input state (W3-SPEC §1.2) -------------------------
167
+ # I1 fresh 0.4.x per-repo install → migrate
168
+ # I2 already migrated → idempotent no-op, exit 0
169
+ # I3 absent/foreign/unparseable → refusal (2 / 1)
170
+ # I4 partial crash shapes → refusal naming resume/rollback (R8/R9)
171
+
172
+ MODE=""
173
+
174
+ # ---- R3: not a git repo --------------------------------------------------------
175
+ if ! git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
176
+ MODE="refuse-R3"
177
+ R3_MSG="upgrade: $REPO is not a git repository - upgrade's safety (three verifies, two commits, revert) requires git. fix: git init && git add -A && git commit"
178
+ else
179
+ # ---- R2: detached HEAD -------------------------------------------------------
180
+ if [ -z "$(git -C "$REPO" symbolic-ref -q HEAD)" ]; then
181
+ MODE="refuse-R2"
182
+ R2_SHA=$(git -C "$REPO" rev-parse --short HEAD)
183
+ R2_MSG="upgrade: $REPO is in detached-HEAD state at $R2_SHA - upgrade's rollback is \`git revert\` of its commits, which needs a branch. fix: git switch -c <name>"
184
+ else
185
+ # ---- R1: dirty tree --------------------------------------------------------
186
+ R1_COUNT=$(git -C "$REPO" status --porcelain | grep -c . || true)
187
+ if [ "${R1_COUNT:-0}" -gt 0 ]; then
188
+ MODE="refuse-R1"
189
+ R1_MSG="upgrade: $R1_COUNT uncommitted entries in $REPO - commit or stash first (upgrade writes its own two commits and cannot mix them with yours)"
190
+ fi
191
+ fi
192
+ fi
193
+
194
+ # ---- the record (I3) -----------------------------------------------------------
195
+ RECORD_PRESENT=0
196
+ [ -f "$INSTALLED" ] && RECORD_PRESENT=1
197
+ RECORD_OK=0
198
+ if [ "$RECORD_PRESENT" -eq 1 ]; then
199
+ # It must parse, carry a version, and carry a files map (the strict reader must
200
+ # see at least the shape a 0.4.x install writes).
201
+ REC_VERSION=$(record_scalar version)
202
+ REC_FILES_N=$(record_files_count)
203
+ if grep -q '"version"' "$INSTALLED" 2>/dev/null && [ -n "$REC_VERSION" ] && grep -q '"files"' "$INSTALLED" 2>/dev/null; then
204
+ RECORD_OK=1
205
+ fi
206
+ fi
207
+
208
+ case "$RECORD_PRESENT:$RECORD_OK" in
209
+ 0:*) MODE="${MODE:-refuse-I3-absent}" ;;
210
+ *:0) MODE="${MODE:-refuse-I3-parse}" ;;
211
+ esac
212
+
213
+ if [ "$RECORD_OK" -eq 1 ]; then
214
+ case "$REC_VERSION" in
215
+ 0.4.*) : ;;
216
+ *) MODE="${MODE:-refuse-I3-version}" ;;
217
+ esac
218
+ fi
219
+
220
+ # ---- the state discriminator (I1/I2/I4), only on a well-formed 0.4.x record ----
221
+ if [ "${RECORD_OK:-0}" -eq 1 ] && [ -z "${MODE:-}" ]; then
222
+ BLOCK_MODE=$(engine_block_field mode)
223
+ BLOCK_DIR=$(engine_block_field engine_dir)
224
+ PAYLOAD_N=$(vendored_list | grep -c . || true)
225
+ RECORD_ENGINE_N=$(record_engine_entries_count)
226
+ if [ "$BLOCK_MODE" = "global" ] && [ "$PAYLOAD_N" -eq 0 ]; then
227
+ MODE="noop-R4" # I2: already migrated → idempotent no-op
228
+ elif [ "$BLOCK_MODE" = "global" ] && [ "$PAYLOAD_N" -gt 0 ]; then
229
+ MODE="refuse-R8" # crash between commits A and B
230
+ R8_MSG="upgrade: partial migration detected - the record says global but $PAYLOAD_N engine files are still here. fix: resume by removing them (the engine lives at ${BLOCK_DIR:-$ED}), or roll back: git revert <commit A>"
231
+ elif [ "$BLOCK_MODE" != "global" ] && [ "$RECORD_ENGINE_N" -lt 18 ]; then
232
+ MODE="refuse-R9" # hand-migration: entries PARTIALLY or fully dropped, no block
233
+ R9_MSG="upgrade: $REPO's record is missing its engine: block and carries only $RECORD_ENGINE_N of the 18 engine entries - dropped $((18 - RECORD_ENGINE_N)) by hand, a state goblin upgrade did not produce. fix: restore the record (git checkout .goblin/installed.json) and re-run"
234
+ elif [ "$PAYLOAD_N" -eq 0 ]; then
235
+ MODE="refuse-R5" # fresh-shape record but no payload to lift
236
+ R5_MSG="upgrade: no engine at $ED and no vendored payload to lift it from - install the engine first (goblin-install a fresh repo, or set --engine-dir)"
237
+ elif [ "$RECORD_ENGINE_N" -eq 18 ]; then
238
+ MODE="migrate" # I1: the migration runs (record intact, payload intact)
239
+ else
240
+ MODE="refuse-R9" # record count off in any unnamed combination: refuse, do not guess
241
+ R9_MSG="upgrade: $REPO's record carries $RECORD_ENGINE_N engine entries where a 0.4.4 install records 18 - an unnamed state. fix: restore the record (git checkout .goblin/installed.json) and re-run"
242
+ fi
243
+ fi
244
+
245
+ # ---- dry-run report -------------------------------------------------------------
246
+ if [ "$DRY_RUN" -eq 1 ]; then
247
+ printf 'goblin upgrade --dry-run — target %s\n' "$REPO"
248
+ printf ' engine-dir: %s\n' "$ED"
249
+ case "$MODE" in
250
+ migrate)
251
+ printf ' state: fresh 0.4.x per-repo install (record %s entries, %s engine payload files on disk)\n' \
252
+ "$(record_files_count)" "$PAYLOAD_N"
253
+ step_line 1 "hash-verify every recorded file (IN-02 clause-1 logic)"
254
+ step_line 2 "verify #1 — the untouched repo must be green"
255
+ step_line 3 "land the global engine at $ED (copy this repo's own $PAYLOAD_N verified bytes; reuse if the hash pair matches)"
256
+ step_line 4 "commit A — engine_dir: active in goblin.yaml + engine: block in installed.json (record $(record_files_count) → $(( $(record_files_count) - RECORD_ENGINE_N )) entries) + gate.sh and the CI run line re-pointed"
257
+ step_line 5 "verify #2 — green with both engines present (vendored judges; proves the rewritten record parses)"
258
+ step_line 6 "commit B — git rm exactly the $PAYLOAD_N engine paths (the only irreversible step)"
259
+ step_line 7 "verify #3 — expected: 39 passed, 0 failed, 11 advisory, 32 skipped, rc 0"
260
+ step_line 8 "report — counts, engine identity, both commit SHAs, the rollback command"
261
+ ;;
262
+ noop-R4)
263
+ printf ' state: already migrated — the real run would print no-op and exit 0\n'
264
+ ;;
265
+ refuse-R1) printf ' would refuse (exit 1): %s\n' "$R1_MSG" ;;
266
+ refuse-R2) printf ' would refuse (exit 1): %s\n' "$R2_MSG" ;;
267
+ refuse-R3) printf ' would refuse (exit 1): %s\n' "$R3_MSG" ;;
268
+ refuse-I3-absent) printf ' would refuse: nothing to upgrade: %s/.goblin/installed.json — this is not a per-repo goblin-stack install\n' "$REPO" ;;
269
+ refuse-I3-parse) printf ' would refuse: %s does not parse — this is not a per-repo goblin-stack install (exit 2)\n' "$INSTALLED" ;;
270
+ refuse-I3-version) printf ' would refuse: record says %s; this upgrade migrates 0.4.x installs — do not hand-edit the record\n' "$REC_VERSION" ;;
271
+ refuse-R8) printf ' would refuse (exit 1): %s\n' "$R8_MSG" ;;
272
+ refuse-R9) printf ' would refuse (exit 1): %s\n' "$R9_MSG" ;;
273
+ refuse-R5) printf ' would refuse (exit 1): %s\n' "$R5_MSG" ;;
274
+ *) printf ' state: unknown\n' ;;
275
+ esac
276
+ printf ' dry-run wrote nothing.\n'
277
+ exit 0
278
+ fi
279
+
280
+ # ---- the refusals, for a real run ----------------------------------------------
281
+ case "$MODE" in
282
+ refuse-R1) g_err "$R1_MSG"; exit 1 ;;
283
+ refuse-R2) g_err "$R2_MSG"; exit 1 ;;
284
+ refuse-R3) g_err "$R3_MSG"; exit 1 ;;
285
+ refuse-I3-absent)
286
+ g_err "nothing to upgrade: $INSTALLED — this is not a per-repo goblin-stack install"
287
+ exit 2 ;;
288
+ refuse-I3-parse)
289
+ g_err "$INSTALLED does not parse — this is not a per-repo goblin-stack install"
290
+ exit 2 ;;
291
+ refuse-I3-version)
292
+ g_err "record says $REC_VERSION; this upgrade migrates 0.4.x installs — do not hand-edit the record"
293
+ exit 1 ;;
294
+ refuse-R8) g_err "$R8_MSG"; exit 1 ;;
295
+ refuse-R9) g_err "$R9_MSG"; exit 1 ;;
296
+ refuse-R5) g_err "$R5_MSG"; exit 1 ;;
297
+ noop-R4)
298
+ printf 'no-op: already migrated to %s (cli %s)\n' \
299
+ "${BLOCK_DIR:-$ED}" "$(engine_block_field cli_version)"
300
+ exit 0 ;;
301
+ esac
302
+
303
+ # ================================================================================ step 1
304
+ # Hash-verify EVERY recorded file before writing anything. A hand-edited harness
305
+ # is not migrated silently: any drift exits 1 naming the file.
306
+ step_line 1 "hash-verify the record ($(record_files_count) files)"
307
+ VERIFY_SCRIPT="$REPO/.goblin/bin/goblin-verify"
308
+ while IFS=$'\t' read -r p h; do
309
+ [ -n "$p" ] || continue
310
+ if [ ! -e "$REPO/$p" ]; then
311
+ g_err "upgrade: recorded file is missing: $REPO/$p - restore it (git checkout -- $p) before upgrading"
312
+ exit 1
313
+ fi
314
+ cur=$(sha256 "$REPO/$p")
315
+ if [ "$cur" != "$h" ]; then
316
+ g_err "upgrade: hash drift: $p (recorded $h, now $cur) - restore it (git checkout -- $p) before upgrading; a hand-edited harness is not migrated silently"
317
+ exit 1
318
+ fi
319
+ done < <(g_installed_files_strict "$INSTALLED")
320
+
321
+ # ================================================================================ step 2
322
+ # Verify #1 — the untouched repo must already be green. Do not migrate a repo
323
+ # that is already red: the migration would be blamed for it.
324
+ step_line 2 "verify #1 — the untouched repo must be green"
325
+ VERIFY_SCRIPT="$REPO/.goblin/bin/goblin-verify"
326
+ V1=$(run_verify 2>&1); V1_RC=$?
327
+ if [ "$V1_RC" -ne 0 ]; then
328
+ printf '%s\n' "$V1"
329
+ g_err "upgrade: verify #1 is red (exit $V1_RC) - the repo is not green before the migration; fix it first (the migration would be blamed for a red it did not cause)"
330
+ exit 1
331
+ fi
332
+ printf 'verify #1: %s\n' "$(printf '%s' "$V1" | grep -E 'passed,' | head -n 1 | sed 's/^[[:space:]]*//')"
333
+
334
+ # ================================================================================ step 3
335
+ # Land the global engine. Copy THIS repo's own just-verified bytes — no trust
336
+ # question arises. Same hash pair → reuse; different → R6 refusal.
337
+ step_line 3 "land the global engine at $ED"
338
+ RH=$(sha256 "$REPO/.goblin/bin/goblin-verify")
339
+ RT=$(sha256 "$REPO/.goblin/manifest/enforcement.tsv")
340
+ CLI_VERSION=$(record_scalar version)
341
+ if [ -d "$ED" ]; then
342
+ if [ ! -f "$ED/manifest/enforcement.tsv" ]; then
343
+ g_err "upgrade: $ED exists but holds no manifest/enforcement.tsv - this is not an engine directory (a partial copy is a crashed step 3). fix: pass --engine-dir <path>, or remove $ED and re-run"
344
+ exit 1
345
+ fi
346
+ EH=$(sha256 "$ED/bin/goblin-verify")
347
+ if [ -z "$EH" ]; then
348
+ g_err "upgrade: $ED holds a manifest but no bin/goblin-verify - a partial engine copy. fix: pass --engine-dir <path>, or remove $ED and re-run"
349
+ exit 1
350
+ fi
351
+ ET=$(sha256 "$ED/manifest/enforcement.tsv")
352
+ if [ "$EH" = "$RH" ] && [ "$ET" = "$RT" ]; then
353
+ printf ' reusing existing engine (identical hash pair)\n'
354
+ else
355
+ g_err "upgrade: $ED holds a different engine (cli_sha256 ${EH:-absent}, enforcement ${ET:-absent}) than this repo's ($RH, $RT). I will not overwrite a global engine other repos may declare. fix: pass --engine-dir <path> for a second engine, or upgrade $ED first"
356
+ exit 1
357
+ fi
358
+ else
359
+ lift_rc=0
360
+ while IFS= read -r rel; do
361
+ [ -n "$rel" ] || continue
362
+ dst="$ED/${rel#.goblin/}"
363
+ mkdir -p "$(dirname "$dst")" || { lift_rc=1; break; }
364
+ cp -p "$REPO/$rel" "$dst" || { lift_rc=1; break; }
365
+ done < <(vendored_list)
366
+ if [ "$lift_rc" -ne 0 ]; then
367
+ g_err "upgrade: copying the engine payload to $ED failed - aborting before anything in the repo changed"
368
+ exit 1
369
+ fi
370
+ chmod 700 "$ED"
371
+ # Validate AFTER the copy: the landed engine must carry this repo's exact bytes.
372
+ EH=$(sha256 "$ED/bin/goblin-verify"); ET=$(sha256 "$ED/manifest/enforcement.tsv")
373
+ if [ "$EH" != "$RH" ] || [ "$ET" != "$RT" ]; then
374
+ g_err "upgrade: the engine landed at $ED does not match this repo's bytes (cli $EH/$RH, enforcement $ET/$RT) - a partial copy; remove $ED and re-run"
375
+ exit 1
376
+ fi
377
+ printf ' landed %s engine files at %s (mode 0700)\n' "$(vendored_list | grep -c .)" "$ED"
378
+ fi
379
+
380
+ # ================================================================================ step 4
381
+ # Commit A — the declaration-and-record commit. Written in ONE commit:
382
+ # goblin.yaml the commented `# engine_dir:` placeholder becomes the active line
383
+ # installed.json gains the engine: block; exactly the 18 engine entries drop
384
+ # (skills stay — MA5 shows SK-01/02/04/AU-04 SKIP by mode)
385
+ # checks/gate.sh the source line re-pointed to the engine-resolution idiom
386
+ # .github/workflows/goblin-gate.yml run line re-pointed to npx @techgoblin/gobstack@<v>
387
+ # The record rewrite happens BEFORE any deletion (the plan's order is measured
388
+ # wrong: deleting with the record untouched is IN-02 FAIL, W3-SPEC MA3).
389
+ step_line 4 "commit A — the declaration-and-record commit"
390
+
391
+ engine_dir_line="$ED"
392
+ active_engine_line() { printf 'engine_dir: %s\n' "$engine_dir_line"; }
393
+
394
+ # ---- goblin.yaml: the commented placeholder becomes the active line ----
395
+ if grep -q '^engine_dir:' "$CONFIG"; then
396
+ cur_line=$(sed -n 's/^engine_dir:[[:space:]]*//p' "$CONFIG" | head -n 1)
397
+ if [ "$cur_line" != "$engine_dir_line" ]; then
398
+ g_err "upgrade: $CONFIG already declares engine_dir: $cur_line (wanted $engine_dir_line) - unparseable or hand-edited config; aborting"
399
+ exit 2
400
+ fi
401
+ else
402
+ if ! grep -q '^# engine_dir:' "$CONFIG"; then
403
+ g_err "upgrade: $CONFIG has neither an active nor a commented engine_dir: line - not a 0.4.4 template; aborting"
404
+ exit 2
405
+ fi
406
+ sed -i "s|^# engine_dir:.*|$(active_engine_line | sed 's/[&|]/\\&/g')|" "$CONFIG"
407
+ fi
408
+
409
+ # ---- installed.json: engine block + drop exactly the 18 engine entries ----
410
+ REC_BEFORE=$(record_files_count)
411
+ PYJ=$(mktemp -d)
412
+ cat > "$PYJ/rewrite_record.py" <<'PYEOF'
413
+ import json, sys
414
+ path, ed, ver, cli_sha, tsv_sha = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
415
+ rec = json.load(open(path))
416
+ rec["engine"] = {"mode": "global", "engine_dir": ed, "cli_version": ver,
417
+ "cli_sha256": cli_sha, "enforcement_tsv_sha256": tsv_sha}
418
+ def is_engine(p):
419
+ return p.startswith(".goblin/bin/") or p.startswith(".goblin/manifest/") \
420
+ or p.startswith(".goblin/bans/") or p.startswith(".goblin/automations/") \
421
+ or p == ".goblin/roles.yaml"
422
+ rec["files"] = {p: h for p, h in rec["files"].items() if not is_engine(p)}
423
+ json.dump(rec, open(path, "w"), indent=2)
424
+ PYEOF
425
+ cat > "$PYJ/rehash_record.py" <<'PYEOF'
426
+ import json, sys
427
+ path, key, new_sha = sys.argv[1], sys.argv[2], sys.argv[3]
428
+ rec = json.load(open(path))
429
+ if key in rec.get("files", {}):
430
+ rec["files"][key] = new_sha
431
+ json.dump(rec, open(path, "w"), indent=2)
432
+ PYEOF
433
+ if ! python3 "$PYJ/rewrite_record.py" "$INSTALLED" "$ED" "$CLI_VERSION" "$RH" "$RT"; then
434
+ g_err "upgrade: rewriting $INSTALLED failed (python3 unavailable or the record unparseable) - the tree is uncommitted; fix: git checkout .goblin/goblin.yaml .goblin/installed.json checks/gate.sh"
435
+ exit 2
436
+ fi
437
+ REC_AFTER=$(record_files_count)
438
+
439
+ # ---- checks/gate.sh: source line re-pointed to the engine-resolution idiom ----
440
+ GATE="$REPO/checks/gate.sh"
441
+ if [ -f "$GATE" ] && grep -q '\.goblin/bin/goblin-lib.sh' "$GATE"; then
442
+ # No machine path is committed: the idiom resolves engine_dir: → the env
443
+ # default → ~/.goblin/engine, then sources <engine>/bin/goblin-lib.sh.
444
+ # The CONFIG line moves UP beside ROOT so the resolver can read it.
445
+ # gate.sh is a files-RECORDED artifact: its record entry is re-hashed below,
446
+ # in the same commit (IN-02 would otherwise read the rewrite as drift).
447
+ awk '
448
+ /^# shellcheck source=\/dev\/null$/ && !done {
449
+ print "# shellcheck source=/dev/null"
450
+ print "# The engine went global (goblin upgrade): resolve it exactly as"
451
+ print "# goblin-verify does - engine_dir:, then the machine default."
452
+ print "CONFIG=\"$ROOT/.goblin/goblin.yaml\""
453
+ print "ENGINE_DIR=$(sed -n \"s/^engine_dir:[[:space:]]*//p\" \"$CONFIG\" | head -n 1)"
454
+ print "case \"$ENGINE_DIR\" in \"~\") ENGINE_DIR=\"$HOME\" ;; \"~\"/*) ENGINE_DIR=\"$HOME/${ENGINE_DIR#\\~/}\" ;; esac"
455
+ print "if [ -z \"$ENGINE_DIR\" ] && [ -n \"${GOBLIN_ENGINE_DIR:-}\" ]; then ENGINE_DIR=$GOBLIN_ENGINE_DIR; fi"
456
+ print "if [ -z \"$ENGINE_DIR\" ] && [ -d \"$HOME/.goblin/engine\" ]; then ENGINE_DIR=$HOME/.goblin/engine; fi"
457
+ print ". \"$ENGINE_DIR/bin/goblin-lib.sh\""
458
+ done=1; next
459
+ }
460
+ /^CONFIG="/ { next }
461
+ /^\. \"\$ROOT\/\.goblin\/bin\/goblin-lib\.sh\"$/ { next }
462
+ { print }
463
+ ' "$GATE" > "$GATE.new" && mv "$GATE.new" "$GATE"
464
+ GATE_NEW=$(sha256 "$GATE")
465
+ python3 "$PYJ/rehash_record.py" "$INSTALLED" "checks/gate.sh" "$GATE_NEW" || {
466
+ g_err "upgrade: re-hashing the rewritten gate.sh in $INSTALLED failed - the tree is uncommitted; fix: git checkout ."
467
+ exit 2
468
+ }
469
+ fi
470
+
471
+ # ---- the CI workflow: run line re-pointed to the pinned global form ----
472
+ WF="$REPO/.github/workflows/goblin-gate.yml"
473
+ if [ -f "$WF" ] && grep -q 'run: bash .goblin/bin/goblin-verify' "$WF"; then
474
+ # Names a package W5 publishes; until then CI on a migrated repo is expected
475
+ # red-by-absence — recorded in the commit message, not hidden. The workflow is
476
+ # a files-RECORDED artifact, so this rewrite re-hashes its record entry here,
477
+ # in the same commit (IN-02 hashes every recorded file; a rewritten artifact
478
+ # 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"
480
+ WF_NEW=$(sha256 "$WF")
481
+ python3 "$PYJ/rehash_record.py" "$INSTALLED" ".github/workflows/goblin-gate.yml" "$WF_NEW" || {
482
+ g_err "upgrade: re-hashing the rewritten workflow in $INSTALLED failed - the tree is uncommitted; fix: git checkout ."
483
+ exit 2
484
+ }
485
+ rm -rf "$PYJ"
486
+ fi
487
+
488
+ git -C "$REPO" add -A
489
+ git -C "$REPO" -c commit.gpgsign=false commit -q -m "feat: migrate goblin-stack engine to global $ED (W3 step A)
490
+
491
+ The record is rewritten BEFORE the deletion (the plan's order is measured
492
+ wrong: deleting with the record untouched is IN-02 FAIL, W3-SPEC MA3).
493
+ goblin.yaml gains the active engine_dir line; installed.json gains the
494
+ engine block and drops exactly the engine entries (skills stay); gate.sh
495
+ and the CI run line are re-pointed. The CI run line names
496
+ npx @techgoblin/gobstack@$CLI_VERSION verify — a package W5 publishes; until then
497
+ CI on a migrated repo is expected red-by-absence."
498
+ COMMIT_A=$(git -C "$REPO" rev-parse --short HEAD)
499
+ printf ' commit A: %s (record %s → %s entries)\n' "$COMMIT_A" "$REC_BEFORE" "$REC_AFTER"
500
+
501
+ # ================================================================================ step 5
502
+ # Verify #2 — green with both engines present. The vendored engine judges this
503
+ # run (W1's A7 ordering); the point is that the rewritten record, declaration
504
+ # and gate.sh parse and the tree is green MID-sequence. Red → no deletion; the
505
+ # user reverts commit A.
506
+ step_line 5 "verify #2 — green with both engines present"
507
+ VERIFY_SCRIPT="$REPO/.goblin/bin/goblin-verify"
508
+ V2=$(run_verify 2>&1); V2_RC=$?
509
+ if [ "$V2_RC" -ne 0 ]; then
510
+ printf '%s\n' "$V2"
511
+ g_err "upgrade: verify #2 is red (exit $V2_RC) - nothing was deleted. fix: git revert $COMMIT_A"
512
+ exit 1
513
+ fi
514
+ printf 'verify #2: %s\n' "$(printf '%s' "$V2" | grep -E 'passed,' | head -n 1 | sed 's/^[[:space:]]*//')"
515
+
516
+ # ================================================================================ step 6
517
+ # Commit B — the deletion (the only irreversible step). Exactly the payload list
518
+ # the record just dropped; never skills/, installed.json, or any owned file.
519
+ step_line 6 "commit B — git rm the engine payload (the only irreversible step)"
520
+ EXPECT_N=$(vendored_list | grep -c .)
521
+ DELETE_N=0
522
+ while IFS= read -r rel; do
523
+ [ -n "$rel" ] || continue
524
+ case "$rel" in
525
+ .goblin/bin/goblin-verify|.goblin/bin/goblin-lib.sh|.goblin/bin/goblin-audit|.goblin/bin/goblin-bans|\
526
+ .goblin/manifest/*|.goblin/bans/*|.goblin/automations/*|.goblin/roles.yaml) : ;;
527
+ *) g_err "upgrade: refusing to delete anything outside the engine-path list: $rel - aborting, nothing deleted"
528
+ exit 1 ;;
529
+ esac
530
+ DELETE_N=$((DELETE_N + 1))
531
+ done < <(vendored_list)
532
+ if [ "$DELETE_N" -ne "$EXPECT_N" ]; then
533
+ g_err "upgrade: payload list changed mid-run ($EXPECT_N → $DELETE_N) - aborting, nothing deleted"
534
+ exit 1
535
+ fi
536
+ while IFS= read -r rel; do
537
+ [ -n "$rel" ] || continue
538
+ git -C "$REPO" rm -q "$rel"
539
+ done < <(vendored_list)
540
+ git -C "$REPO" -c commit.gpgsign=false commit -q -m "feat: remove the vendored engine payload (W3 step B; engine lives at $ED)
541
+
542
+ The one-way door, split from commit A so the rollback for a red verify #3
543
+ never has to resurrect a deleted file: \`git revert <A> <B>\` restores the
544
+ pre-upgrade tree byte-for-byte (W3-SPEC §5.2)."
545
+ COMMIT_B=$(git -C "$REPO" rev-parse --short HEAD)
546
+ printf ' commit B: %s (%s files deleted)\n' "$COMMIT_B" "$DELETE_N"
547
+
548
+ # ================================================================================ step 7
549
+ # Verify #3 — the post-deletion gate, run against a committed tree (CM-03).
550
+ # Expected, measured: 39 passed, 0 failed, 11 advisory, 32 skipped, rc 0 (MA5).
551
+ step_line 7 "verify #3 — the post-deletion gate"
552
+ if [ -f "$REPO/.goblin/bin/goblin-verify" ]; then
553
+ VERIFY_SCRIPT="$REPO/.goblin/bin/goblin-verify"
554
+ else
555
+ VERIFY_SCRIPT="$ED/bin/goblin-verify"
556
+ fi
557
+ V3=$(run_verify 2>&1); V3_RC=$?
558
+ if [ "$V3_RC" -ne 0 ]; then
559
+ printf '%s\n' "$V3"
560
+ g_err "upgrade: verify #3 is red (exit $V3_RC) - rollback: git revert $COMMIT_A $COMMIT_B"
561
+ exit 1
562
+ fi
563
+ printf 'verify #3: %s\n' "$(printf '%s' "$V3" | grep -E 'passed,' | head -n 1 | sed 's/^[[:space:]]*//')"
564
+
565
+ # ================================================================================ step 8
566
+ step_line 8 "report"
567
+ printf ' files moved: %s (now at %s)\n' "$DELETE_N" "$ED"
568
+ printf ' record entries: %s -> %s\n' "$REC_BEFORE" "$REC_AFTER"
569
+ printf ' engine identity: cli_sha256=%s enforcement_tsv_sha256=%s\n' "$RH" "$RT"
570
+ printf ' commits: %s (A, declaration+record), %s (B, deletion)\n' "$COMMIT_A" "$COMMIT_B"
571
+ printf ' rollback: git revert %s %s\n' "$COMMIT_A" "$COMMIT_B"
572
+ exit 0