@techgoblin/gobstack 0.0.0-stage → 0.4.4-beta.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 (108) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -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 +484 -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 +103 -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 +37 -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,720 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-install — drop the harness into a target repo, idempotently.
3
+ #
4
+ # Usage: goblin-install --target <dir> [options]
5
+ # --target <dir> required; the repo root to install into
6
+ # --class A|B|C|D|E|F required unless --uninstall or --re-pin; anything else needs an explicit class
7
+ # --models <path> model mapping file (default: $GOBLIN_MODELS -> ~/projects/fleet-model.yaml)
8
+ # --practice <path> the referenced standard (default: $GOBLIN_PRACTICE -> ~/projects/PROJECT-PRACTICE.md); a named path that is absent is reported, never silently dropped
9
+ # --parts <list> comma list to install; default = every part the class requires
10
+ # --archive mark the project archive: verify requires no HANDOFF and no gates
11
+ # --skills yes|no install .hermes/skills (default yes; needs the one-time skills trust step)
12
+ # --dry-run print the plan; write nothing
13
+ # --upgrade re-install at the current version; report created/updated/unchanged/skipped
14
+ # --opt-out <part> record the part in disabled: so its required checks are skipped
15
+ # --uninstall remove exactly the files in installed.json
16
+ # --re-pin re-record practice_sha256: for an edited standard; nothing else changes
17
+ # --force allow overwriting a file goblin-stack did not create
18
+ # --yes non-interactive; take the defaults above
19
+ #
20
+ # Exit codes: 0 success / no-op | 1 a refusal (with the path and the fix) | 2 bad input or a
21
+ # missing dependency.
22
+
23
+ set -uo pipefail
24
+
25
+ GOBLIN_INSTALL_VERSION="0.4.4"
26
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
27
+ SRC=$(cd "$SELF_DIR/.." && pwd)
28
+ # shellcheck source=goblin-lib.sh
29
+ . "$SELF_DIR/goblin-lib.sh"
30
+
31
+ TARGET=""
32
+ CLASS=""
33
+ MODELS="${GOBLIN_MODELS:-$HOME/projects/fleet-model.yaml}"
34
+ PRACTICE="${GOBLIN_PRACTICE:-$HOME/projects/PROJECT-PRACTICE.md}"
35
+ # W5-3: `--practice <path>` naming a file that is not there used to be IGNORED IN SILENCE -
36
+ # exit 0, `practice: ~`, `practice_sha256: ~`, and not one line about it in the install log.
37
+ # The empty pin is a legitimate state (a repo may reference no standard, and the default path is
38
+ # allowed to be absent), so this is a refusal to be silent, not a refusal to install: the value
39
+ # stays empty and the log names the path that was not found.
40
+ PRACTICE_ARG=""
41
+ PARTS=""
42
+ ARCHIVE="false"
43
+ SKILLS="yes"
44
+ DRY_RUN=0
45
+ UPGRADE=0
46
+ UNINSTALL=0
47
+ RE_PIN=0
48
+ FORCE=0
49
+ OPT_OUT=""
50
+
51
+ usage() {
52
+ # The header comment runs from line 2 to the first blank line, so adding an option cannot
53
+ # truncate the help or leak code into it. The old fixed range ('2,22p') ended on the
54
+ # `set -uo pipefail` line and printed it as if it were an option.
55
+ sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
56
+ }
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --target) TARGET="${2:-}"; shift 2 ;;
61
+ --class) CLASS="${2:-}"; shift 2 ;;
62
+ --models) MODELS="${2:-}"; shift 2 ;;
63
+ --practice) PRACTICE="${2:-}"; PRACTICE_ARG="$PRACTICE"; shift 2 ;;
64
+ --parts) PARTS="${2:-}"; shift 2 ;;
65
+ --archive) ARCHIVE="true"; shift ;;
66
+ --skills) SKILLS="${2:-}"; shift 2 ;;
67
+ --dry-run) DRY_RUN=1; shift ;;
68
+ --upgrade) UPGRADE=1; shift ;;
69
+ --uninstall) UNINSTALL=1; shift ;;
70
+ --re-pin) RE_PIN=1; shift ;;
71
+ --opt-out) OPT_OUT="${2:-}"; shift 2 ;;
72
+ --force) FORCE=1; shift ;;
73
+ --yes) shift ;;
74
+ -h|--help) usage; exit 0 ;;
75
+ *) g_err "unknown option: $1"; usage >&2; exit 2 ;;
76
+ esac
77
+ done
78
+
79
+ for dep in git awk sed grep; do
80
+ command -v "$dep" >/dev/null 2>&1 || { g_err "missing dependency: $dep"; exit 2; }
81
+ done
82
+ [ -n "$TARGET" ] || { g_err "--target is required"; exit 2; }
83
+ TARGET=$(g_expand_tilde "$TARGET")
84
+ [ -d "$TARGET" ] || { g_err "--target is not a directory: $TARGET"; exit 2; }
85
+ TARGET=$(cd "$TARGET" && pwd)
86
+ [ -f "$SRC/VERSION" ] || { g_err "no VERSION at $SRC — is this a goblin-stack checkout?"; exit 2; }
87
+
88
+ VERSION=$(head -n 1 "$SRC/VERSION")
89
+ CLASSES_TSV="$SRC/manifest/classes.tsv"
90
+ INSTALLED="$TARGET/.goblin/installed.json"
91
+ CONFIG="$TARGET/.goblin/goblin.yaml"
92
+ STAMP=$(date +%F)
93
+
94
+ # --uninstall and --re-pin are different jobs, and this guard has to sit ABOVE the uninstall
95
+ # branch: that branch ends `exit 0`, so the same check placed below it was unreachable and
96
+ # `--uninstall --re-pin` ran the uninstall without ever seeing the refusal (measured at bf7e9c9:
97
+ # exit 0, target uninstalled, "different jobs" never printed).
98
+ if [ "$UNINSTALL" -eq 1 ] && [ "$RE_PIN" -eq 1 ]; then
99
+ g_err "--uninstall and --re-pin are different jobs; run them one at a time"
100
+ exit 2
101
+ fi
102
+
103
+ # ------------------------------------------------------------- uninstall ------
104
+ if [ "$UNINSTALL" -eq 1 ]; then
105
+ [ -f "$INSTALLED" ] || { g_err "nothing to uninstall: $INSTALLED is absent"; exit 2; }
106
+ n=0
107
+ PARENTS=""
108
+ while IFS=$'\t' read -r p h; do
109
+ [ -n "$p" ] || continue
110
+ [ -e "$TARGET/$p" ] || continue
111
+ if [ "$DRY_RUN" -eq 1 ]; then g_info "would remove $p"; else rm -f "$TARGET/$p"; fi
112
+ n=$((n + 1))
113
+ # EVERY ancestor directory, not just the immediate parent: a file at
114
+ # .github/workflows/goblin-gate.yml empties two levels, and collecting only `${p%/*}` left
115
+ # .github/ behind with nothing named (the shape t-uninstall.sh asserts against).
116
+ d="$p"; while [ "${d%/*}" != "$d" ]; do d="${d%/*}"; PARENTS="$PARENTS$d"$'\n'; done
117
+ done < <(g_installed_files "$INSTALLED")
118
+ # The config is goblin-stack's own file, so it goes too. HANDOFF/AGENTS/SPEC/reviews do not:
119
+ # they are the project's record, not the harness's.
120
+ if [ -f "$CONFIG" ]; then
121
+ [ "$DRY_RUN" -eq 1 ] || rm -f "$CONFIG"
122
+ g_info "removed .goblin/goblin.yaml (goblin-stack's own config)"
123
+ fi
124
+ # Runtime state goblin-stack's own checks wrote.
125
+ for f in .goblin/last-gate-line .goblin/ratchet-last .goblin/.ds-report; do
126
+ [ -f "$TARGET/$f" ] || continue
127
+ [ "$DRY_RUN" -eq 1 ] || rm -f "$TARGET/$f"
128
+ g_info "removed $f (runtime state)"
129
+ done
130
+ # The three decision records (audit waivers, boundary waivers, the install-hook allowlist) are
131
+ # put_once files. An UNEDITED one is still goblin-stack's template and goes; an EDITED one is
132
+ # the project's decision and stays, named in the summary. Deleting a decision silently would be
133
+ # data loss; leaving an untouched template behind would be residue (both were measured).
134
+ KEPT=""
135
+ while IFS=$'\t' read -r p h; do
136
+ [ -n "$p" ] || continue
137
+ case "$p" in
138
+ .goblin/audit-waiver.tsv|.goblin/boundary-waivers|.goblin/install-hooks.allowlist) ;;
139
+ *) continue ;;
140
+ esac
141
+ [ -e "$TARGET/$p" ] || continue
142
+ if [ "$(g_sha256_file "$TARGET/$p")" = "$h" ]; then
143
+ [ "$DRY_RUN" -eq 1 ] || rm -f "$TARGET/$p"
144
+ g_info "removed $p (unedited template)"
145
+ n=$((n + 1))
146
+ case "$p" in */*) PARENTS="$PARENTS${p%/*}"$'\n' ;; esac
147
+ else
148
+ KEPT="$KEPT $p"
149
+ g_info "kept $p (you edited it: a decision record is the project's, not the harness's)"
150
+ fi
151
+ done < <(g_json_object "$INSTALLED" owned)
152
+ # installed.json goes BEFORE the directory pass. While it was still on disk, .goblin/ was
153
+ # never empty when it was tested, so the directory survived unnamed (D17 / F2-7).
154
+ if [ "$DRY_RUN" -eq 0 ]; then
155
+ rm -f "$INSTALLED"
156
+ if [ -f "$TARGET/.gitignore" ]; then
157
+ printf '# goblin-stack uninstalled %s\n' "$STAMP" >> "$TARGET/.gitignore"
158
+ fi
159
+ fi
160
+ # Empty directories goblin-stack created: the parents of the files just removed, plus the
161
+ # harness roots. Deepest first, so a parent is only tested once its child has gone. The
162
+ # old fixed list never tried .hermes/skills/goblin-* and tested .goblin/ too early.
163
+ nd=0
164
+ while read -r d; do
165
+ [ -n "$d" ] || continue
166
+ [ -d "$TARGET/$d" ] || continue
167
+ if [ "$DRY_RUN" -eq 1 ]; then
168
+ g_info "would remove $d/ (if the files above leave it empty)"
169
+ nd=$((nd + 1))
170
+ continue
171
+ fi
172
+ if [ -z "$(ls -A "$TARGET/$d" 2>/dev/null)" ]; then
173
+ rmdir "$TARGET/$d" 2>/dev/null && { g_info "removed $d/ (empty)"; nd=$((nd + 1)); }
174
+ fi
175
+ done < <({
176
+ printf '%s' "$PARENTS"
177
+ printf '.goblin\n.goblin/bin\n.goblin/manifest\n.goblin/bans\n.hermes\n.hermes/skills\n'
178
+ } | awk 'NF' | awk -F/ '{print NF " " $0}' | sort -rn | cut -d' ' -f2- | awk '!seen[$0]++')
179
+ 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}."
180
+ exit 0
181
+ fi
182
+
183
+ # ---------------------------------------------------------------- re-pin ------
184
+ # The practice pin (practice_sha256: in .goblin/goblin.yaml, re-checked by IN-02) exists to catch
185
+ # a SILENTLY edited standard. The standard itself is a living document edited in place, and
186
+ # goblin.yaml is an `owned` file the installer never rewrites - so the remedy for an edit that IS
187
+ # intended has to be explicit, printed and deliberate. This mode rewrites exactly one line and
188
+ # reports the old and new hash. It is unreachable from --upgrade and from goblin-verify, by
189
+ # design: an automatic re-pin would be the silent edit the pin exists to catch.
190
+ # See docs/CONTRACTS.md, "An edited standard is not a dead end".
191
+ if [ "$RE_PIN" -eq 1 ]; then
192
+ [ -f "$CONFIG" ] || { g_err "nothing to re-pin: $CONFIG is absent (install first)"; exit 2; }
193
+ PIN_PATH=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" practice)")
194
+ [ -n "$PIN_PATH" ] || { g_err "no practice: recorded in $CONFIG — nothing to re-pin"; exit 2; }
195
+ [ -f "$PIN_PATH" ] || { g_err "the recorded standard is absent: $PIN_PATH"; exit 2; }
196
+ grep -q '^practice_sha256:' "$CONFIG" || { g_err "no practice_sha256: line in $CONFIG to rewrite"; exit 2; }
197
+ # g_unquote, exactly as goblin-verify reads it: a hand-quoted value is accepted by the verifier,
198
+ # so it must not read as "different" here and trigger a rewrite of a line that already matches.
199
+ OLD_SHA=$(g_unquote "$(g_yaml_scalar "$CONFIG" practice_sha256)")
200
+ NEW_SHA=$(g_sha256_file "$PIN_PATH")
201
+ if [ "$OLD_SHA" = "$NEW_SHA" ]; then
202
+ g_info "practice pin already current: $PIN_PATH"
203
+ g_info " recorded $OLD_SHA"
204
+ exit 0
205
+ fi
206
+ if [ "$DRY_RUN" -eq 1 ]; then
207
+ g_info "would re-pin $PIN_PATH"
208
+ g_info " recorded $OLD_SHA"
209
+ g_info " now $NEW_SHA"
210
+ exit 0
211
+ fi
212
+ # One line, in place. Every other line of the config - comments included - is untouched, so
213
+ # the `owned` contract holds for everything except the single value just asked for.
214
+ sed -i "s|^practice_sha256:.*|practice_sha256: $NEW_SHA|" "$CONFIG" \
215
+ || { g_err "could not rewrite the pin in $CONFIG"; exit 1; }
216
+ # Fail closed: never report a re-pin that did not land. The failure this command exists to
217
+ # remove is a hash nobody verified (measured at bf7e9c9: sed -i could not write to a read-only
218
+ # .goblin/, yet the branch printed "practice re-pinned" with both hashes and exited 0, leaving
219
+ # IN-02 RED and the config byte-identical).
220
+ grep -qxF "practice_sha256: $NEW_SHA" "$CONFIG" \
221
+ || { g_err "the new pin did not land in $CONFIG"; exit 1; }
222
+ g_info "practice re-pinned: $PIN_PATH"
223
+ g_info " recorded $OLD_SHA"
224
+ g_info " now $NEW_SHA"
225
+ g_info ""
226
+ g_info "next:"
227
+ g_info " 1. cd $TARGET && git add .goblin/goblin.yaml && git commit # the re-pin is a change like any other"
228
+ g_info " 2. .goblin/bin/goblin-verify --only IN-02 # practice pin ok"
229
+ exit 0
230
+ fi
231
+
232
+ [ -n "$CLASS" ] || { g_err "--class is required (A|B|C|D|E|F)"; exit 2; }
233
+ case "$CLASS" in A|B|C|D|E|F) ;; *) g_err "--class must be one of A B C D E F, got '$CLASS'"; exit 2 ;; esac
234
+ case "$SKILLS" in yes|no) ;; *) g_err "--skills must be yes or no"; exit 2 ;; esac
235
+
236
+ PRESET=$(ls "$SRC"/presets/"$CLASS"-*.yaml 2>/dev/null | head -n 1)
237
+ [ -n "$PRESET" ] || { g_err "no preset for class $CLASS"; exit 2; }
238
+
239
+ preset() { local v; v=$(g_yaml_scalar "$PRESET" "$1"); printf '%s' "${v%\"}" | sed 's/^"//'; }
240
+
241
+ HARNESS_DIR=$(preset harness_dir); [ -n "$HARNESS_DIR" ] || HARNESS_DIR=checks
242
+ SCAFFOLD=$(preset scaffold_checks); [ -n "$SCAFFOLD" ] || SCAFFOLD=no
243
+ GATE_NAME=$(preset gate_name)
244
+ GATE_CMD=$(preset gate_cmd)
245
+ RAT_NAME=$(preset ratchet_name)
246
+ RAT_CMD=$(preset ratchet_cmd)
247
+ RAT_CEIL=$(preset ratchet_ceiling)
248
+ REPLAY_ENV=$(preset replay_env)
249
+ REPLAY_CMD=$(preset replay_cmd)
250
+ RUNTIME_DATA=$(preset runtime_data)
251
+ ADV_CEILING=$(preset advisory_ceiling); [ -n "$ADV_CEILING" ] || ADV_CEILING=10
252
+ # The loop ceiling (G2, LP-03) is a CEILING, not a per-class policy: every class gets the same
253
+ # one, defaulted to the engine's own DEFAULT_MAX_TURNS when a preset predates the key.
254
+ LOOP_MAX_TURNS=$(preset loop_max_turns_ceiling); [ -n "$LOOP_MAX_TURNS" ] || LOOP_MAX_TURNS=20
255
+ # The optional second gate (class A: the TODO ceiling). Empty for every other class.
256
+ GATE2_NAME=$(preset gate2_name)
257
+ GATE2_CMD=$(preset gate2_cmd)
258
+ # The security surface (SC-01..SC-09) and the perf baseline (PF-01).
259
+ SEC_GITIGNORE_FAMILY=$(preset sec_gitignore_family); [ -n "$SEC_GITIGNORE_FAMILY" ] || SEC_GITIGNORE_FAMILY=no
260
+ SEC_BUILD_OUTPUT=$(preset sec_build_output)
261
+ SEC_AUDIT_CMD=$(preset sec_audit_cmd)
262
+ SEC_AUDIT_MAX_AGE=$(preset sec_audit_max_age_days); [ -n "$SEC_AUDIT_MAX_AGE" ] || SEC_AUDIT_MAX_AGE=90
263
+ SEC_WAIVER_MAX_AGE=$(preset sec_waiver_max_age_days); [ -n "$SEC_WAIVER_MAX_AGE" ] || SEC_WAIVER_MAX_AGE=180
264
+ SEC_WRITE_ROUTES=$(preset sec_write_routes)
265
+ PERF_METRIC=$(preset perf_metric)
266
+ PERF_CMD=$(preset perf_cmd)
267
+ PERF_BASELINE_COMMIT=$(preset perf_baseline_commit)
268
+ PERF_BASELINE_VALUE=$(preset perf_baseline_value); [ -n "$PERF_BASELINE_VALUE" ] || PERF_BASELINE_VALUE=0
269
+ PERF_MEASURED=$(preset perf_measured)
270
+ PERF_HOST_GATE=$(preset perf_host_gate)
271
+ # The ban list (G5): which bans this class turns on. An unlisted ban SKIPs with a reason.
272
+ BANS=$(preset bans)
273
+ # `layers:` is a block list the project declares; no preset ships one, so it renders empty and
274
+ # BN-05 SKIPs with that reason until the project declares a from/to pair.
275
+ LAYERS_BLOCK=""
276
+
277
+ need() { g_class_need "$CLASSES_TSV" "$CLASS" "$1"; }
278
+
279
+ # parts that are OFF for this class become real opt-outs, not silent absences
280
+ DISABLED=""
281
+ for p in handoff spec gate replay ratchet pr-gate review-panel playbooks tokens ci-gate; do
282
+ [ "$(need "$p")" = "-" ] && DISABLED="$DISABLED$p,"
283
+ done
284
+ [ "$SKILLS" = "no" ] && DISABLED="$DISABLED""playbooks,"
285
+ if [ -n "$OPT_OUT" ]; then DISABLED="$DISABLED$OPT_OUT,"; fi
286
+ DISABLED=$(printf '%s' "$DISABLED" | tr ',' '\n' | grep -v '^$' | sort -u | paste -sd, -)
287
+ # The automation producers ride the `playbooks` part (they are the shipped automation files, and
288
+ # --skills no disables that part), so they are written under exactly the condition that makes
289
+ # AU-01..AU-04 and SK-04 honour an opt-out.
290
+ PLAYBOOKS_ON=1
291
+ case ",$DISABLED," in *,playbooks,*) PLAYBOOKS_ON=0 ;; esac
292
+ # parts the class forbids are never installed
293
+ for p in spec replay ratchet pr-gate; do
294
+ [ "$(need "$p")" = "-" ] && SCAFFOLD=no
295
+ done
296
+
297
+ WANT_SPEC=1; [ "$(need spec)" = "-" ] && WANT_SPEC=0
298
+ WANT_REVIEWS=0
299
+ case "$(need pr-gate)$(need review-panel)" in *R*|*O*) WANT_REVIEWS=1 ;; esac
300
+ WANT_RATCHET=1; [ "$(need ratchet)" = "-" ] && WANT_RATCHET=0
301
+ # The CI lane (G6 A.6). Installed for a class that requires or permits it (A, C, E, F) and never
302
+ # for one that forbids it (B, D) - where CL-01 then requires the file to be ABSENT, which is why
303
+ # class_part_artifact keys off this exact path rather than off .github/workflows/.
304
+ WANT_CI_GATE=1; [ "$(need ci-gate)" = "-" ] && WANT_CI_GATE=0
305
+ [ "$ARCHIVE" = "true" ] && { WANT_SPEC=0; WANT_REVIEWS=0; SCAFFOLD=no; }
306
+
307
+ OWNER_EMAIL=$( cd "$TARGET" && git config user.email 2>/dev/null )
308
+ [ -n "$OWNER_EMAIL" ] || OWNER_EMAIL="you@example.com"
309
+ BRANCH=$( cd "$TARGET" && git rev-parse --abbrev-ref HEAD 2>/dev/null )
310
+ case "$BRANCH" in ""|HEAD) BRANCH=main ;; esac
311
+ HEAD_SHORT=$( cd "$TARGET" && git rev-parse --short HEAD 2>/dev/null )
312
+ [ -n "$HEAD_SHORT" ] || HEAD_SHORT="0000000"
313
+
314
+ # practice pin
315
+ if [ -f "$(g_expand_tilde "$PRACTICE")" ]; then
316
+ PRACTICE_SHA=$(g_sha256_file "$(g_expand_tilde "$PRACTICE")")
317
+ else
318
+ # W5-3: an explicitly named --practice that does not resolve is REPORTED, not swallowed. The
319
+ # pin stays empty (a repo with no referenced standard is legitimate), but a flag nobody honours
320
+ # has to say so - `practice: ~` with no line in the log is the "silent no-op" species.
321
+ if [ -n "$PRACTICE_ARG" ]; then
322
+ g_info "warning: --practice $PRACTICE_ARG does not exist - no standard is referenced"
323
+ g_info " (practice: and practice_sha256: stay empty in .goblin/goblin.yaml)"
324
+ fi
325
+ PRACTICE=""
326
+ PRACTICE_SHA=""
327
+ fi
328
+
329
+ # ratchet ceiling: measured at install time, so the ratchet starts anchored to reality
330
+ if [ "$RAT_CEIL" = "measure" ]; then
331
+ RAT_CEIL=$( cd "$TARGET" && bash -c "$RAT_CMD" 2>/dev/null | tr -d '[:space:]' )
332
+ case "$RAT_CEIL" in ''|*[!0-9]*) RAT_CEIL=0 ;; esac
333
+ fi
334
+ [ "$WANT_RATCHET" -eq 0 ] && { RAT_NAME=""; RAT_CMD=""; RAT_CEIL=0; }
335
+ # A part the class turns off must not be declared either: leaving replay.env/replay.cmd in the
336
+ # config made CL-01's "class forbids this part" branch fire on the installer's own output (D10).
337
+ [ "$(need replay)" = "-" ] && { REPLAY_ENV=""; REPLAY_CMD=""; }
338
+
339
+ # parts selected with --parts
340
+ part_wanted() {
341
+ [ -z "$PARTS" ] && return 0
342
+ case ",$PARTS," in *",$1,"*) return 0 ;; esac
343
+ return 1
344
+ }
345
+
346
+ # ------------------------------------------------------------------ plan -----
347
+ TMP=$(mktemp -d)
348
+ trap 'rm -rf "$TMP"' EXIT
349
+ : > "$TMP/files.tsv"
350
+ : > "$TMP/owned.tsv"
351
+ : > "$TMP/refused.tsv"
352
+ CREATED=0; UPDATED=0; UNCHANGED=0; SKIPPED=0
353
+ REFUSED_PATHS=""
354
+ # Files a PREVIOUS run created and left to the user (never overwritten, never hash-checked).
355
+ PREV_OWNED=""
356
+ # Files a PREVIOUS run installed (the hash-checked 'files' map). A path that exists on disk
357
+ # but is NOT in this map was not created by goblin-stack, so it is refused rather than
358
+ # clobbered - the contract in docs/CONTRACTS.md. --force is the only way past it.
359
+ PREV_FILES=""
360
+ if [ -f "$INSTALLED" ]; then
361
+ PREV_OWNED=$(g_json_object "$INSTALLED" owned | cut -f1)
362
+ PREV_FILES=$(g_installed_files "$INSTALLED" | cut -f1)
363
+ fi
364
+
365
+ # prev_installed <relpath> - did a previous run install this exact path?
366
+ prev_installed() {
367
+ [ -n "$PREV_FILES" ] || return 1
368
+ printf '%s\n' "$PREV_FILES" | grep -qxF "$1"
369
+ }
370
+
371
+ render() { # render <template> key value [key value ...]
372
+ local tpl="$1"; shift
373
+ local out; out=$(cat "$tpl")
374
+ while [ $# -ge 2 ]; do out=${out//"{{$1}}"/$2}; shift 2; done
375
+ printf '%s\n' "$out"
376
+ }
377
+
378
+ # put <relpath> <source-file> [refuse-overwrite]
379
+ # Refuses when the destination exists with different content and goblin-stack did not install
380
+ # it: either the caller asked for it explicitly ('refuse') or the path is not in the previous
381
+ # run's 'files' map. --force is the documented override.
382
+ put() {
383
+ local rel="$1" srcf="$2" refuse="${3:-}"
384
+ local dst="$TARGET/$rel" srch cur
385
+ srch=$(g_sha256_file "$srcf")
386
+ if [ -f "$dst" ]; then
387
+ cur=$(g_sha256_file "$dst")
388
+ if [ "$cur" = "$srch" ]; then
389
+ UNCHANGED=$((UNCHANGED + 1))
390
+ printf '%s\t%s\n' "$rel" "$srch" >> "$TMP/files.tsv"
391
+ return 0
392
+ fi
393
+ if [ "$FORCE" -eq 0 ] && { [ "$refuse" = "refuse" ] || ! prev_installed "$rel"; }; then
394
+ SKIPPED=$((SKIPPED + 1))
395
+ printf '%s\t%s\n' "$rel" "$cur" >> "$TMP/refused.tsv"
396
+ REFUSED_PATHS="$REFUSED_PATHS $rel"
397
+ g_info "kept (exists, not installed by goblin-stack): $rel"
398
+ return 0
399
+ fi
400
+ UPDATED=$((UPDATED + 1))
401
+ else
402
+ CREATED=$((CREATED + 1))
403
+ fi
404
+ if [ "$DRY_RUN" -eq 1 ]; then g_info "plan: $rel"; else
405
+ mkdir -p "$(dirname "$dst")"
406
+ cp "$srcf" "$dst"
407
+ [ -x "$srcf" ] && chmod +x "$dst"
408
+ fi
409
+ printf '%s\t%s\n' "$rel" "$srch" >> "$TMP/files.tsv"
410
+ }
411
+
412
+ # put_once <relpath> <source-file> — created once, then YOURS. Never overwritten, never
413
+ # hash-checked (you are meant to edit it), and left in place by --uninstall.
414
+ put_once() {
415
+ local rel="$1" srcf="$2"
416
+ local dst="$TARGET/$rel" srch cur
417
+ srch=$(g_sha256_file "$srcf")
418
+ if [ -f "$dst" ] && [ "$FORCE" -eq 1 ] && ! printf '%s\n' "$PREV_OWNED" | grep -qxF "$rel"; then
419
+ # --force is the documented override for "a file goblin-stack did not create" (D11). A file
420
+ # a previous install created is 'yours' by contract, and --force does not take it back.
421
+ UPDATED=$((UPDATED + 1))
422
+ g_info "taken over (--force): $rel"
423
+ if [ "$DRY_RUN" -eq 0 ]; then cp "$srcf" "$dst"; fi
424
+ printf '%s\t%s\n' "$rel" "$srch" >> "$TMP/owned.tsv"
425
+ return 0
426
+ fi
427
+ if [ -f "$dst" ]; then
428
+ SKIPPED=$((SKIPPED + 1))
429
+ if printf '%s\n' "$PREV_OWNED" | grep -qxF "$rel"; then
430
+ g_info "kept (yours, never overwritten): $rel"
431
+ # Still recorded as yours, so the next install keeps treating it that way.
432
+ printf '%s\t%s\n' "$rel" "$srch" >> "$TMP/owned.tsv"
433
+ else
434
+ cur=$(g_sha256_file "$dst")
435
+ printf '%s\t%s\n' "$rel" "$cur" >> "$TMP/refused.tsv"
436
+ REFUSED_PATHS="$REFUSED_PATHS $rel"
437
+ g_info "kept (existed before the install, never overwritten): $rel"
438
+ fi
439
+ # A refused file is NOT recorded as owned: it was never goblin-stack's, so a later run
440
+ # must refuse it again rather than inherit ownership of it (D11).
441
+ return 0
442
+ fi
443
+ CREATED=$((CREATED + 1))
444
+ if [ "$DRY_RUN" -eq 1 ]; then
445
+ g_info "plan: $rel"
446
+ else
447
+ mkdir -p "$(dirname "$dst")"
448
+ cp "$srcf" "$dst"
449
+ fi
450
+ printf '%s\t%s\n' "$rel" "$srch" >> "$TMP/owned.tsv"
451
+ }
452
+
453
+ # ------------------------------------------------------- generated content ---
454
+ render_config() {
455
+ local gates runtime
456
+ gates=" - name: $GATE_NAME
457
+ cmd: $GATE_CMD"
458
+ # A class may declare a second gate (A: the TODO ceiling that used to be the ratchet, G4 D2).
459
+ if [ -n "$GATE2_NAME" ] && [ -n "$GATE2_CMD" ]; then
460
+ gates="$gates
461
+ - name: $GATE2_NAME
462
+ cmd: $GATE2_CMD"
463
+ fi
464
+ runtime=" - $RUNTIME_DATA"
465
+ render "$SRC/templates/goblin.yaml.tmpl" \
466
+ CLASS "$CLASS" \
467
+ BRANCH "$BRANCH" \
468
+ MODELS_FILE "$MODELS" \
469
+ PRACTICE "$PRACTICE" \
470
+ PRACTICE_SHA "$PRACTICE_SHA" \
471
+ OWNER_EMAIL "$OWNER_EMAIL" \
472
+ ARCHIVE "$ARCHIVE" \
473
+ MAX_DIRTY "0" \
474
+ ADVISORY_CEILING "$ADV_CEILING" \
475
+ SEC_GITIGNORE_FAMILY "$SEC_GITIGNORE_FAMILY" \
476
+ SEC_BUILD_OUTPUT "$SEC_BUILD_OUTPUT" \
477
+ SEC_AUDIT_CMD "$SEC_AUDIT_CMD" \
478
+ SEC_AUDIT_MAX_AGE "$SEC_AUDIT_MAX_AGE" \
479
+ SEC_WAIVER_MAX_AGE "$SEC_WAIVER_MAX_AGE" \
480
+ SEC_WRITE_ROUTES "$SEC_WRITE_ROUTES" \
481
+ PERF_METRIC "$PERF_METRIC" \
482
+ PERF_CMD "$PERF_CMD" \
483
+ PERF_BASELINE_COMMIT "$PERF_BASELINE_COMMIT" \
484
+ PERF_BASELINE_VALUE "$PERF_BASELINE_VALUE" \
485
+ PERF_MEASURED "$PERF_MEASURED" \
486
+ PERF_HOST_GATE "$PERF_HOST_GATE" \
487
+ HARNESS_DIR "$HARNESS_DIR" \
488
+ SCAFFOLD_CHECKS "$SCAFFOLD" \
489
+ FEATURE_MAP "" \
490
+ SOURCE_ROOT "." \
491
+ VERIFY_DOCTOR "" \
492
+ REFERENCE_MANIFEST "" \
493
+ QUARANTINE_ROOT "" \
494
+ LOOP_MAX_TURNS "$LOOP_MAX_TURNS" \
495
+ GATES "$gates" \
496
+ RATCHET_NAME "$RAT_NAME" \
497
+ RATCHET_CMD "$RAT_CMD" \
498
+ RATCHET_CEILING "$RAT_CEIL" \
499
+ RUNTIME_DATA "$runtime" \
500
+ BANS "$BANS" \
501
+ BANS_EXEMPT "" \
502
+ LAYERS "$LAYERS_BLOCK" \
503
+ REPLAY_ENV "$REPLAY_ENV" \
504
+ REPLAY_CMD "$REPLAY_CMD" \
505
+ DISABLED "$DISABLED" > "$TMP/goblin.yaml"
506
+ }
507
+
508
+ render_config
509
+ # The re-shadow refusal (W3 §5.3): a record with engine.mode=global is a MIGRATED repo.
510
+ # Re-installing would recreate the 18-file payload (the vendored step of the resolution
511
+ # chain then beats the declared engine_dir — the shadowing hazard, W3-SPEC §3.1) AND
512
+ # rewrite the record from scratch, silently de-migrating it. The writer below has no
513
+ # engine:-block support, so the rewrite is not a migration-aware update — it is a
514
+ # regression. The exits out of a migrated repo are `git revert` of the migration's two
515
+ # commits, or a hand edit of the engine_dir line (docs/GUIDE.md "Rolling back a
516
+ # migration"). DRY_RUN reports the refusal too: the plan it prints is not shippable.
517
+ # The guard sits BEFORE the put-loop: a refusal that fires after the payload phase has
518
+ # already re-created the 18 files is a refusal that itself shadows (measured: U11's
519
+ # 'the refused install wrote nothing' failed on exactly that).
520
+ g_install_engine_mode() {
521
+ awk -v q='"' -v k="mode" '
522
+ index($0, q "engine" q) && /:[[:space:]]*\{/ { inf = 1; next }
523
+ inf && /^[[:space:]]*\}/ { inf = 0 }
524
+ inf && index($0, q k q) {
525
+ v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/,?[[:space:]]*$/, "", v)
526
+ gsub(q, "", v); print v; exit
527
+ }
528
+ ' "$1" 2>/dev/null
529
+ }
530
+ if [ -f "$INSTALLED" ] && [ "$(g_install_engine_mode "$INSTALLED")" = "global" ]; then
531
+ g_rec_engine_dir=$(awk -v q='"' '
532
+ index($0, q "engine" q) && /:[[:space:]]*\{/ { inf = 1; next }
533
+ inf && /^[[:space:]]*\}/ { inf = 0 }
534
+ inf && index($0, q "engine_dir" q) {
535
+ v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/,?[[:space:]]*$/, "", v)
536
+ gsub(q, "", v); print v; exit
537
+ }
538
+ ' "$INSTALLED")
539
+ 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."
540
+ exit 1
541
+ fi
542
+
543
+ put ".goblin/bin/goblin-verify" "$SRC/bin/goblin-verify"
544
+ put ".goblin/bin/goblin-lib.sh" "$SRC/bin/goblin-lib.sh"
545
+ # The audit record is produced by a DELIBERATE, separate command - SC-07 reads the record and
546
+ # never the network (RISKS.md K4: no network at verify time).
547
+ put ".goblin/bin/goblin-audit" "$SRC/bin/goblin-audit"
548
+ put ".goblin/roles.yaml" "$SRC/roles.yaml"
549
+ put ".goblin/manifest/enforcement.tsv" "$SRC/manifest/enforcement.tsv"
550
+ put ".goblin/manifest/playbooks.tsv" "$SRC/manifest/playbooks.tsv"
551
+ put ".goblin/manifest/classes.tsv" "$SRC/manifest/classes.tsv"
552
+ put ".goblin/manifest/glossary.tsv" "$SRC/manifest/glossary.tsv"
553
+ # The ban list (G5): the table, the engine, and the two probes. A ban with no mechanism is a
554
+ # wish, so the table and the engine install together or not at all.
555
+ put ".goblin/manifest/bans.tsv" "$SRC/manifest/bans.tsv"
556
+ put ".goblin/bin/goblin-bans" "$SRC/bin/goblin-bans"
557
+ put ".goblin/bans/README.md" "$SRC/bans/README.md"
558
+ put ".goblin/bans/grep-ban.sh" "$SRC/bans/grep-ban.sh"
559
+ put ".goblin/bans/layer-check.sh" "$SRC/bans/layer-check.sh"
560
+ put_once ".goblin/goblin.yaml" "$TMP/goblin.yaml"
561
+ # The security lane's two decision records and the audit allowlist are created once, then YOURS:
562
+ # they are answers to questions only the project can answer (G4 B3).
563
+ [ -f "$SRC/templates/audit-waiver.tsv.tmpl" ] && put_once ".goblin/audit-waiver.tsv" "$SRC/templates/audit-waiver.tsv.tmpl"
564
+ [ -f "$SRC/templates/boundary-waivers.tmpl" ] && put_once ".goblin/boundary-waivers" "$SRC/templates/boundary-waivers.tmpl"
565
+ [ -f "$SRC/templates/install-hooks.allowlist.tmpl" ] && put_once ".goblin/install-hooks.allowlist" "$SRC/templates/install-hooks.allowlist.tmpl"
566
+
567
+ if [ "$SKILLS" = "yes" ] && part_wanted playbooks; then
568
+ for d in "$SRC"/skills/*/; do
569
+ [ -d "$d" ] || continue
570
+ name=$(basename "$d")
571
+ put ".hermes/skills/$name/SKILL.md" "$d/SKILL.md"
572
+ done
573
+ fi
574
+
575
+ # The automation producers. They land in the target so the target is self-contained and
576
+ # IN-02/SK-02 hash them like every other installed file; the cron copy step then reads from
577
+ # here (see the "next:" block and automations/README.md).
578
+ if [ "$PLAYBOOKS_ON" -eq 1 ] && part_wanted playbooks; then
579
+ for f in "$SRC"/automations/drift-audit.sh "$SRC"/automations/bugreporter-intake.sh \
580
+ "$SRC"/automations/README.md "$SRC"/automations/report.schema.tsv; do
581
+ [ -f "$f" ] || continue
582
+ put ".goblin/automations/$(basename "$f")" "$f"
583
+ done
584
+ if [ -f "$SRC/templates/report.yaml.tmpl" ]; then
585
+ put ".goblin/automations/report.yaml.tmpl" "$SRC/templates/report.yaml.tmpl"
586
+ fi
587
+ fi
588
+
589
+ if [ "$WANT_SPEC" -eq 1 ] && part_wanted spec; then
590
+ render "$SRC/templates/SPEC.md.tmpl" CLASS "$CLASS" DATE "$STAMP" > "$TMP/spec"
591
+ put_once "ROUND-000-SPEC.md" "$TMP/spec"
592
+ fi
593
+ if [ "$ARCHIVE" != "true" ] && part_wanted handoff; then
594
+ 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"
595
+ put_once "HANDOFF.md" "$TMP/handoff"
596
+ fi
597
+ render "$SRC/templates/AGENTS.md.tmpl" CLASS "$CLASS" DATE "$STAMP" > "$TMP/agents"
598
+ put_once "AGENTS.md" "$TMP/agents"
599
+
600
+ if [ "$WANT_REVIEWS" -eq 1 ]; then
601
+ printf '# Review verdicts live here: reviews/<slug>-<head7>.md, each naming the SHA it reviewed.\n' > "$TMP/gitkeep"
602
+ put_once "reviews/.gitkeep" "$TMP/gitkeep"
603
+ fi
604
+
605
+ # The CI lane (G6): one workflow, rendered and placed. The header of the template states what
606
+ # makes a workflow a GATE (a required check, no admin bypass, a push identity that is not the
607
+ # repo's sole admin) - because a workflow file nobody is bound by is decoration. The job carries
608
+ # no `if:` at any level, so PG-05 passes on goblin-stack's own output.
609
+ if [ "$WANT_CI_GATE" -eq 1 ] && part_wanted ci-gate && [ -f "$SRC/templates/ci/goblin-gate.yml.tmpl" ]; then
610
+ render "$SRC/templates/ci/goblin-gate.yml.tmpl" CLASS "$CLASS" BRANCH "$BRANCH" DATE "$STAMP" > "$TMP/gate.yml"
611
+ put ".github/workflows/goblin-gate.yml" "$TMP/gate.yml"
612
+ fi
613
+
614
+ if [ "$SCAFFOLD" = "yes" ]; then
615
+ put "$HARNESS_DIR/gate.sh" "$SRC/templates/checks/gate.sh.tmpl"
616
+ put "$HARNESS_DIR/assert.mjs" "$SRC/templates/checks/assert.mjs.tmpl"
617
+ fi
618
+
619
+ # .gitignore: one appended block, delimited by markers; never rewritten. The declared
620
+ # runtime_data paths are listed too, because DS-01 asserts each of them is git-ignored.
621
+ GI_MARK="# >>> goblin-stack >>>"
622
+ if [ -f "$TARGET/.gitignore" ] && grep -qF "$GI_MARK" "$TARGET/.gitignore"; then
623
+ SKIPPED=$((SKIPPED + 1))
624
+ g_info "kept (.gitignore block already present)"
625
+ else
626
+ {
627
+ [ -f "$TARGET/.gitignore" ] && cat "$TARGET/.gitignore"
628
+ printf '\n%s\n' "$GI_MARK"
629
+ printf '# goblin-stack runtime state (gates write these; they are not fixture)\n'
630
+ printf '.goblin/last-gate-line\n.goblin/.ds-report\n.goblin/ratchet-last\n'
631
+ if [ "$SEC_GITIGNORE_FAMILY" = "yes" ]; then
632
+ # SC-02: the whole secret family, not one member of it. The row is behavioural - it asks
633
+ # git's own matcher (`git check-ignore`) rather than reading this file - so a rule that
634
+ # looks right but does not match still fails.
635
+ printf '# the secret family (SC-02): each is one `git add` away from a live credential\n'
636
+ printf '.env\n.env.*\n*.pem\n*.key\n'
637
+ fi
638
+ if [ -n "$RUNTIME_DATA" ]; then
639
+ printf '# declared runtime_data (DS-01 hashes them around every gate run)\n'
640
+ printf '%s\n' "$RUNTIME_DATA"
641
+ fi
642
+ printf '# <<< goblin-stack <<<\n'
643
+ } > "$TMP/gitignore.new"
644
+ if [ -f "$TARGET/.gitignore" ]; then UPDATED=$((UPDATED + 1)); else CREATED=$((CREATED + 1)); fi
645
+ # Counted in --dry-run too: the plan must name every write it would make (D14).
646
+ if [ "$DRY_RUN" -eq 0 ]; then cp "$TMP/gitignore.new" "$TARGET/.gitignore"; fi
647
+ fi
648
+
649
+ # installed.json is the record; it is written LAST and never lists itself
650
+ {
651
+ printf '{\n'
652
+ printf ' "version": "%s",\n' "$VERSION"
653
+ printf ' "source_sha": "%s",\n' "$( cd "$SRC" && git rev-parse HEAD 2>/dev/null || echo unknown )"
654
+ printf ' "class": "%s",\n' "$CLASS"
655
+ printf ' "installed_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
656
+ printf ' "options": {"skills": "%s", "archive": %s, "parts": "%s"},\n' "$SKILLS" "$ARCHIVE" "${PARTS:-all}"
657
+ printf ' "files": {\n'
658
+ awk -F'\t' 'NF==2 {printf " \"%s\": \"%s\",\n", $1, $2}' "$TMP/files.tsv" | sed '$ s/,$//'
659
+ printf ' },\n'
660
+ printf ' "owned": {\n'
661
+ awk -F'\t' 'NF==2 {printf " \"%s\": \"%s\",\n", $1, $2}' "$TMP/owned.tsv" | sed '$ s/,$//'
662
+ printf ' },\n'
663
+ printf ' "refused": {\n'
664
+ awk -F'\t' 'NF==2 {printf " \"%s\": \"%s\",\n", $1, $2}' "$TMP/refused.tsv" | sed '$ s/,$//'
665
+ printf ' }\n'
666
+ printf '}\n'
667
+ } > "$TMP/installed.json"
668
+
669
+ PREV_VERSION=""
670
+ if [ -f "$INSTALLED" ]; then PREV_VERSION=$(g_installed_scalar "$INSTALLED" version); fi
671
+
672
+
673
+ if [ "$DRY_RUN" -eq 0 ]; then
674
+ mkdir -p "$TARGET/.goblin"
675
+ if [ "$CREATED" -eq 0 ] && [ "$UPDATED" -eq 0 ] && [ "$PREV_VERSION" = "$VERSION" ]; then
676
+ g_info "no-op: $UNCHANGED files unchanged (v$VERSION already installed)"
677
+ else
678
+ cp "$TMP/installed.json" "$INSTALLED"
679
+ if [ -n "$PREV_VERSION" ] && [ "$PREV_VERSION" != "$VERSION" ]; then
680
+ g_info "upgraded $PREV_VERSION -> $VERSION"
681
+ fi
682
+ g_info "created $CREATED · updated $UPDATED · unchanged $UNCHANGED · skipped $SKIPPED"
683
+ fi
684
+ if [ -n "$OPT_OUT" ]; then
685
+ for p in $(printf '%s' "$OPT_OUT" | tr ',' ' '); do
686
+ if grep -q "^disabled:" "$CONFIG"; then
687
+ cur=$(g_yaml_scalar "$CONFIG" disabled)
688
+ cur=${cur#[}; cur=${cur%]}
689
+ new=$(printf '%s,%s' "$cur" "$p" | tr ',' '\n' | grep -v '^$' | sort -u | paste -sd, -)
690
+ sed -i "s|^disabled:.*|disabled: [$new]|" "$CONFIG"
691
+ fi
692
+ done
693
+ g_info "recorded opt-out: $OPT_OUT"
694
+ fi
695
+ g_info ""
696
+ g_info "next:"
697
+ g_info " 1. cd $TARGET && git add -A && git commit # the install is a change like any other"
698
+ g_info " 2. .goblin/bin/goblin-verify # or add .goblin/bin to PATH"
699
+ g_info " 3. edit .goblin/goblin.yaml: replace the default gate with your real commands (P8 step 3)"
700
+ if [ "$SKILLS" = "yes" ]; then
701
+ g_info " 4. hermes skills trust $TARGET # one-time, so the project-tier skills load"
702
+ fi
703
+ g_info ""
704
+ g_info "automations (optional; neither writes outside this repo, and A-02 has no agent in it):"
705
+ g_info " bash $TARGET/.goblin/automations/drift-audit.sh --dry-run # read the record first"
706
+ g_info " cp $TARGET/.goblin/automations/drift-audit.sh ~/.hermes/scripts/"
707
+ g_info " hermes cron create \"0 9 * * 1\" --name \"Drift audit - weekly\" --no-agent \\"
708
+ g_info " --script drift-audit.sh --deliver <channel>"
709
+ g_info " P13/P14 in docs/FLOWS.md; the whole model in automations/README.md"
710
+ else
711
+ g_info "dry-run: created $CREATED · updated $UPDATED · unchanged $UNCHANGED · skipped $SKIPPED"
712
+ fi
713
+
714
+ if [ -n "$REFUSED_PATHS" ]; then
715
+ g_info "refused to overwrite:$REFUSED_PATHS (kept - reconcile it: docs/ADOPTION.md; --force would destroy it)"
716
+ # Documented contract (docs/CONTRACTS.md, this file's header): exit 1 is "a refusal, with
717
+ # the path and the fix". A wrapper has to be able to detect it (D2).
718
+ exit 1
719
+ fi
720
+ exit 0