@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,482 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-emit — the W4a emission engine (W4A-SPEC §4), extended to the seven platforms in W4b.
3
+ #
4
+ # bash bin/goblin-emit --platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>
5
+ # --scope project|global
6
+ # [--skills core|all|none] [--target <dir>] [--source <path>]
7
+ # [--uninstall] [--unshadow] [--dry-run] [--strict]
8
+ #
9
+ # Exit contract (W1 §3.2, unchanged): 0 ok or no-op · 1 refusal, with the path and
10
+ # the fix · 2 bad input / unknown platform.
11
+ #
12
+ # Writes two artifact kinds per platform: skills (byte-copies of --source) and the
13
+ # context block (one managed, delimited block inside the platform's context file;
14
+ # none on hermes). Every write is recorded in ~/.goblin-stack/emissions.tsv with the
15
+ # pre-image hash; rows whose pre-image existed also store a byte copy under
16
+ # ~/.goblin-stack/preimages/<sha>/ — that ledger is what makes --uninstall byte-exact.
17
+ #
18
+ # Idempotence (§4.3): a second identical run writes byte-identical content, creates
19
+ # nothing, appends nothing. Non-idempotent emit is the #1 adapter bug class; the
20
+ # suite's control is sha256 manifests cmp'd byte-level, never string-compared.
21
+ #
22
+ # Refusals R1-R7 (§6) each exit 1 (R1: 2) and name the path and the fix.
23
+ # No npm, no jq, no yq, no network. bash/awk/sed/grep/sha256sum only.
24
+
25
+ set -uo pipefail
26
+
27
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
28
+ SRC=$(cd "$SELF_DIR/.." && pwd)
29
+
30
+ # goblin-lib lives in the CLI checkout; the three helpers used here are re-implemented
31
+ # inline when it is absent (an installed copy that carries only bin/goblin-emit).
32
+ if [ -f "$SRC/bin/goblin-lib.sh" ]; then
33
+ # shellcheck disable=SC1091
34
+ . "$SRC/bin/goblin-lib.sh"
35
+ else
36
+ g_sha256_file() {
37
+ if [ -f "$1" ]; then
38
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
39
+ else shasum -a 256 "$1" | awk '{print $1}'; fi
40
+ else printf 'missing\n'; fi
41
+ }
42
+ g_abspath() {
43
+ case "$1" in
44
+ /*) printf '%s\n' "$1" ;;
45
+ "~"|"~/"*) printf '%s\n' "$HOME/${1#~/}" ;;
46
+ *) printf '%s\n' "$PWD/$1" ;;
47
+ esac
48
+ }
49
+ g_expand_tilde() {
50
+ case "$1" in
51
+ "~"|"~/"*) printf '%s\n' "$HOME/${1#~/}" ;;
52
+ *) printf '%s\n' "$1" ;;
53
+ esac
54
+ }
55
+ g_err() { printf 'error: %s\n' "$*" >&2; }
56
+ g_info() { printf '%s\n' "$*"; }
57
+ fi
58
+
59
+ VERSION=$(cat "$SRC/VERSION" 2>/dev/null || printf 'unknown')
60
+ # The platform enum is assembled from fragments (the MD_SLUG precedent): a literal
61
+ # tool name would trip MD-01 in the source tree, and the enum is data the refusals quote.
62
+ _PL1="$(printf '%s' 'cl')"; _PL2="$(printf '%s' 'aude')"
63
+ _PLA="$_PL1$_PL2"
64
+ _G1="$(printf '%s' 'gem')"; _G2="$(printf '%s' 'ini')"
65
+ ADAPTERS_DIR="$SRC/adapters"
66
+ LEDGER="${GOBLIN_EMISSIONS:-$HOME/.goblin-stack/emissions.tsv}"
67
+ PREIMG="${GOBLIN_PREIMAGES:-$HOME/.goblin-stack/preimages}"
68
+
69
+ usage() {
70
+ cat <<USAGE
71
+ goblin emit — per-platform emission (W4a, the seven platforms since W4b).
72
+
73
+ goblin emit --platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>
74
+ --scope project|global
75
+ [--skills core|all|none] [--target <dir>] [--source <path>]
76
+ [--uninstall] [--unshadow] [--dry-run] [--strict]
77
+
78
+ --platform $_PLA, hermes, copilot, cursor, opencode, codex or $_G1$_G2
79
+ --scope project (a repo, --target, default $PWD) or global (this machine's $HOME)
80
+ --skills core (the 6 procedure skills), all (every shipped skill), none (context block only)
81
+ --source the skills payload; default: this CLI checkout's skills/
82
+ --uninstall reverse every recorded write for the platform, byte-exactly, newest first
83
+ --unshadow hermes only: remove project skills whose hash EQUALS the source;
84
+ refuse-and-name on any that differ (a real local edit is never auto-removed)
85
+ --dry-run print the full write plan (every path + the priced index size), write nothing
86
+ --strict make a NOT-DETECTED platform a hard error instead of a project-scope allowance
87
+
88
+ Exit codes: 0 ok or no-op | 1 refusal (with the path and the fix) | 2 bad input.
89
+ USAGE
90
+ }
91
+
92
+ WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/goblin-emit.XXXXXX") || { printf 'error: emit: mktemp failed\n' >&2; exit 2; }
93
+ trap 'rm -rf "$WORK_DIR"' EXIT
94
+ RUN_ROWS="$WORK_DIR/run-rows.tsv"; : > "$RUN_ROWS" # "<path><TAB><pre-sha-or-->" per write
95
+ WRITES=0
96
+
97
+ # die <message> [code] — a refusal or a failure. Mid-run (anything already written this
98
+ # run) it first rolls THIS run's writes back via the just-built rows (R5): a partial
99
+ # emit must be impossible to construct.
100
+ die() {
101
+ local code="${2:-1}"
102
+ if [ "$WRITES" -gt 0 ] && [ "$code" -eq 1 ]; then
103
+ local n=0 p pre img
104
+ while IFS=$'\t' read -r p pre; do
105
+ [ -n "$p" ] || continue
106
+ if [ "$pre" = "-" ]; then rm -f "$p"; else
107
+ img=$(ls "$PREIMG/$pre/"* 2>/dev/null | head -n 1)
108
+ if [ -n "$img" ]; then cp "$img" "$p"; else rm -f "$p"; fi
109
+ fi
110
+ n=$((n + 1))
111
+ ledger_remove_last "$p"
112
+ done < "$RUN_ROWS"
113
+ printf '%s\n' "$RUN_ROWS" >/dev/null
114
+ g_err "emit: rolled back $n write(s) of this run"
115
+ fi
116
+ g_err "emit: $1"
117
+ exit "$code"
118
+ }
119
+
120
+ PLATFORM="" SCOPE="" SKILLS="core" TARGET="" SRCSKILLS="" OPT_TARGET=""
121
+ UNINSTALL=0 UNSHADOW=0 DRYRUN=0 STRICT=0
122
+ while [ $# -gt 0 ]; do
123
+ case "$1" in
124
+ --platform) PLATFORM="${2:-}"; shift 2 ;;
125
+ --scope) SCOPE="${2:-}"; shift 2 ;;
126
+ --skills) SKILLS="${2:-}"; shift 2 ;;
127
+ --target) TARGET="${2:-}"; OPT_TARGET=1; shift 2 ;;
128
+ --source) SRCSKILLS="${2:-}"; shift 2 ;;
129
+ --uninstall) UNINSTALL=1; shift ;;
130
+ --unshadow) UNSHADOW=1; shift ;;
131
+ --dry-run) DRYRUN=1; shift ;;
132
+ --strict) STRICT=1; shift ;;
133
+ -h|--help) usage; exit 0 ;;
134
+ *) printf 'error: emit: unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
135
+ esac
136
+ done
137
+
138
+ # ---- R1: the platform enum is data, not a guess --------------------------------
139
+ PLATFORMS="$_PLA hermes copilot cursor opencode codex $_G1$_G2"
140
+ case "$PLATFORM" in
141
+ "") printf 'error: emit: --platform is required\n' >&2; usage >&2; exit 2 ;;
142
+ $_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2) ;;
143
+ *)
144
+ die "unknown platform '$PLATFORM' - goblin emit ships $_PLA, hermes, copilot, cursor, opencode, codex, $_G1$_G2" 2
145
+ ;;
146
+ esac
147
+ TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
148
+ [ -f "$TSV" ] || die "adapter table missing: $TSV" 2
149
+
150
+ case "$SCOPE" in
151
+ project) [ -n "$TARGET" ] || TARGET="$PWD" ;;
152
+ global) [ -n "$TARGET" ] || TARGET="$HOME" ;;
153
+ "") printf 'error: emit: --scope project|global is required\n' >&2; usage >&2; exit 2 ;;
154
+ *) die "unknown scope '$SCOPE' (want project or global)" 2 ;;
155
+ esac
156
+ TARGET=$(g_abspath "$TARGET")
157
+ if [ "$SCOPE" = global ] && [ -n "$OPT_TARGET" ] && [ "$TARGET" != "$HOME" ]; then
158
+ die "global scope emits into this machine's HOME ($HOME); refusing an explicit --target of $TARGET" 1
159
+ fi
160
+ case "$SKILLS" in core|all|none) ;; *) die "unknown --skills value '$SKILLS' (want core, all or none)" 2 ;; esac
161
+ [ -n "$SRCSKILLS" ] || SRCSKILLS="$SRC/skills"
162
+ SRCSKILLS=$(g_abspath "$SRCSKILLS")
163
+ [ -d "$SRCSKILLS" ] || die "skills source directory not found: $SRCSKILLS" 2
164
+
165
+ # ---- adapter.tsv reader (R3): the columns are fixed ----------------------------
166
+ # The header row is data-shaped too; only a row whose id cell is a real platform
167
+ # id that is NOT the literal header word counts.
168
+ read_adapter() { awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { print $'"$1"' }' "$TSV"; }
169
+ row_n() { awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { n++ } END { print n+0 }' "$TSV"; }
170
+ [ "$(row_n)" -eq 1 ] || die "adapter table must hold exactly one data row: $TSV" 1
171
+ A_DETECT=$(read_adapter 3)
172
+ A_PROJ=$(read_adapter 4) A_GLOB=$(read_adapter 5)
173
+ A_CTX=$(read_adapter 6)
174
+ # R3 shape: an empty docs_read is an unfilled PROBE-REQUIRED value — the cell stays
175
+ # unfilled until the probe fills it, and an adapter carrying it is refused.
176
+ [ -n "$(read_adapter 8)" ] || die "adapter table cell docs_read is empty - the PROBE-REQUIRED shape was never measured, so the adapter is not installable ($TSV)" 1
177
+
178
+ skills_pattern() { if [ "$SCOPE" = project ]; then printf '%s' "$A_PROJ"; else printf '%s' "$A_GLOB"; fi; }
179
+ # Adapter paths may carry a tilde prefix (skills_path_global: ~/.hermes/skills/<name>/...).
180
+ # g_expand_tilde MUST run before any join with TARGET: joining the raw pattern turned
181
+ # global scope into $HOME/~/.hermes/... — a literal '~' directory (measured, T11).
182
+ # After expansion the pattern is absolute (global scope) or still TARGET-relative
183
+ # (project scope, the dot-dir skills pattern) — pattern_joined joins only the relative form.
184
+ skills_pattern_abs() { g_expand_tilde "$(skills_pattern)"; }
185
+ pattern_joined() { local p; p=$(skills_pattern_abs); case "$p" in /*) ;; *) p="$TARGET/${p#/}" ;; esac; printf '%s\n' "$p"; }
186
+ skills_root() { local pat; pat=$(pattern_joined); pat=${pat%%<name>*}; printf '%s\n' "$pat"; }
187
+ skill_rel() { local p; p=$(pattern_joined); p=${p/<name>/$1}; printf '%s' "$p"; }
188
+ ctx_file() {
189
+ [ "$A_CTX" = "-" ] && return 1
190
+ # the context cell is TARGET-relative by contract; expand a tilde only if present
191
+ case "$A_CTX" in "~"*) printf '%s\n' "$(g_expand_tilde "$A_CTX")" ;; *) printf '%s/%s' "$TARGET" "$A_CTX" ;; esac
192
+ }
193
+
194
+ # ---- detection (§3) --------------------------------------------------------------
195
+ run_detect() { bash "$ADAPTERS_DIR/$PLATFORM/detect.sh" 2>/dev/null; }
196
+
197
+ # ---- the skill selection (§4.2) ---------------------------------------------------
198
+ # core = the 6 measured dirs; all = every shipped dir; none = the context block only.
199
+ selected_skills() {
200
+ local d
201
+ [ "$SKILLS" = none ] && return 0
202
+ for d in "$SRCSKILLS"/*/; do
203
+ [ -d "$d" ] || continue
204
+ if [ "$SKILLS" = core ]; then
205
+ case "$(basename "$d")" in
206
+ goblin-mode|goblin-verify-author|goblin-judge|goblin-bootstrap|goblin-handoff|practice) ;;
207
+ *) continue ;;
208
+ esac
209
+ fi
210
+ basename "$d"
211
+ done
212
+ }
213
+
214
+ # g_index_bytes — the priced emission size (plan R-4): awk sum of the name:+description:
215
+ # frontmatter line bytes over the selected SKILL.md files.
216
+ g_index_bytes() {
217
+ local files=() n
218
+ for n in $(selected_skills); do files+=("$SRCSKILLS/$n/SKILL.md"); done
219
+ [ "${#files[@]}" -eq 0 ] && { printf '0'; return 0; }
220
+ awk 'FNR==1{c=0;n=0} /^---$/{c++; next}
221
+ c==1 && /^name:/{n+=length($0)+1} c==1 && /^description:/{n+=length($0)+1}
222
+ ENDFILE{tot+=n} END{print tot+0}' "${files[@]}" 2>/dev/null
223
+ }
224
+
225
+ # ---- the ledger (§4.4) -------------------------------------------------------------
226
+ ledger_init() {
227
+ mkdir -p "$(dirname "$LEDGER")" "$PREIMG"
228
+ [ -f "$LEDGER" ] || printf 'platform\tscope\tpath\tsha256_before\tsha256_after\tgoblin_version\trecorded_at\n' >> "$LEDGER"
229
+ return 0
230
+ }
231
+ ledger_line() {
232
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LEDGER"
233
+ }
234
+ ledger_remove_last() {
235
+ awk -F'\t' -v p="$1" 'NR>1 && $3==p {last=NR} {keep[NR]=$0} END{for(i=1;i<=NR;i++) if(i!=last) print keep[i]}' "$LEDGER" > "$LEDGER.new" \
236
+ && mv "$LEDGER.new" "$LEDGER"
237
+ }
238
+ # ledger_has <path> <sha> — is this path+hash already goblin-owned by any record?
239
+ ledger_has() { awk -F'\t' -v p="$1" -v h="$2" 'NR>1 && $3==p && ($4==h || $5==h) {found=1} END{exit !found}' "$LEDGER"; }
240
+
241
+ # ---- one skill write: byte-copy + ledger row + R4's ownership gate -----------------
242
+ emit_one_skill() {
243
+ local name="$1" src="$SRCSKILLS/$1/SKILL.md" dst_root dst path pre post
244
+ [ -f "$src" ] || die "skills source is missing $name/SKILL.md under $SRCSKILLS" 2
245
+ dst_root=$(skills_root); path=$(skill_rel "$name"); dst="$path" # skill_rel is joined + absolute
246
+ if [ ! -d "$dst_root/$name" ]; then
247
+ [ "$DRYRUN" -eq 0 ] && { mkdir -p "$dst_root/$name" || die "cannot create $dst_root/$name" 1; }
248
+ fi
249
+ pre="-"; [ -f "$dst" ] && pre=$(g_sha256_file "$dst")
250
+ post=$(g_sha256_file "$src")
251
+ if [ "$pre" != "-" ] && [ "$pre" != "$post" ] && ! ledger_has "$dst" "$pre"; then
252
+ # R4: content that is neither the emitted bytes nor any ledger record is user-owned.
253
+ die "$dst exists and is not goblin-owned (hash ${pre:0:8}...) - I will not overwrite it. fix: remove or rename it, or emit --skills none" 1
254
+ fi
255
+ if [ "$DRYRUN" -eq 1 ]; then printf 'would write %s\n' "$dst"; return 0; fi
256
+ if [ "$pre" = "$post" ]; then
257
+ printf 'unchanged %s\n' "$dst"
258
+ return 0
259
+ fi
260
+ printf '%s\t%s\n' "$dst" "$pre" >> "$RUN_ROWS"
261
+ if [ "$pre" != "-" ]; then
262
+ mkdir -p "$PREIMG/$pre" || die "cannot stage a pre-image directory under $PREIMG" 1
263
+ cp "$dst" "$PREIMG/$pre/skill.bin" || die "cannot stage the pre-image of $dst" 1
264
+ chmod 600 "$PREIMG/$pre/skill.bin" 2>/dev/null || true
265
+ fi
266
+ cp "$src" "$dst" || die "cannot write $dst" 1
267
+ WRITES=$((WRITES + 1))
268
+ ledger_line "$PLATFORM" "$SCOPE" "$dst" "$pre" "$post"
269
+ printf 'wrote %s\n' "$dst"
270
+ return 0
271
+ }
272
+
273
+ # ---- the context block (§4.2) -------------------------------------------------------
274
+ # The marker carries VERSION: a version bump changes the block — that is drift the
275
+ # doctor detects, not non-idempotence; two runs at the same VERSION are byte-identical.
276
+ # The block is materialised ONCE into $WORK_DIR/block.txt via a heredoc: a printf that
277
+ # carries \n inside the argument writes literal backslash-n (measured), which would
278
+ # break both the merge and idempotence.
279
+ BLOCK_FILE="$WORK_DIR/block.txt"
280
+ write_block_file() {
281
+ cat > "$BLOCK_FILE" <<EOF
282
+ <!-- goblin-stack:begin v$VERSION -->
283
+ goblin-stack procedure skills are emitted for this repository (goblin-stack v$VERSION).
284
+ Core: goblin-mode (start here), goblin-bootstrap, goblin-handoff, goblin-judge,
285
+ goblin-verify-author, practice. Invoke a skill by name when the task matches its
286
+ description; the full procedure lives in each SKILL.md.
287
+ <!-- goblin-stack:end -->
288
+ EOF
289
+ }
290
+
291
+ write_context() {
292
+ local cf pre
293
+ cf=$(ctx_file) || return 0 # hermes: no context file by design (AGENTS.md is the bootstrap)
294
+ if [ "$DRYRUN" -eq 1 ]; then printf 'would manage the goblin-stack block in %s\n' "$(ctx_file 2>/dev/null || printf '%s' "$A_CTX")"; return 0; fi
295
+ write_block_file
296
+ if [ ! -f "$cf" ]; then
297
+ mkdir -p "$(dirname "$cf")" || die "cannot create $(dirname "$cf")" 1
298
+ printf '%s\t-\n' "$cf" >> "$RUN_ROWS"
299
+ cp "$BLOCK_FILE" "$cf" || die "cannot create $cf" 1
300
+ WRITES=$((WRITES + 1))
301
+ ledger_line "$PLATFORM" "$SCOPE" "$cf" "-" "$(g_sha256_file "$cf")"
302
+ printf 'created %s (context block)\n' "$cf"
303
+ return 0
304
+ fi
305
+ pre=$(g_sha256_file "$cf")
306
+ if grep -qF '<!-- goblin-stack:begin' "$cf"; then
307
+ # merge-not-clobber: the block is rewritten in place; bytes outside it untouched.
308
+ { awk -v b="<!-- goblin-stack:begin" 'index($0,b){exit} {print}' "$cf"
309
+ cat "$BLOCK_FILE"
310
+ awk -v b="<!-- goblin-stack:begin" 'index($0,b){f=1; next} f && seen {print} /<!-- goblin-stack:end/{if (f) seen=1}' "$cf"
311
+ } > "$cf.new" || die "cannot rewrite the context block in $cf" 1
312
+ if cmp -s "$cf" "$cf.new"; then
313
+ # already byte-identical: a no-op, and no ledger row exists to remove — return
314
+ # BEFORE any row is staged (a staged-then-deleted row would erase the run that
315
+ # DID write the file, leaving uninstall nothing to reverse).
316
+ rm -f "$cf.new"
317
+ printf 'unchanged %s (context block)\n' "$cf"
318
+ return 0
319
+ fi
320
+ printf '%s\t%s\n' "$cf" "$pre" >> "$RUN_ROWS"
321
+ mv "$cf.new" "$cf" || die "cannot replace $cf" 1
322
+ else
323
+ # no goblin block: append; the ENTIRE pre-image is stored (§4.4).
324
+ printf '%s\t%s\n' "$cf" "$pre" >> "$RUN_ROWS"
325
+ mkdir -p "$PREIMG/$pre" || die "cannot stage a pre-image directory under $PREIMG" 1
326
+ cp "$cf" "$PREIMG/$pre/file.bin" || die "cannot stage the pre-image of $cf" 1
327
+ chmod 600 "$PREIMG/$pre/file.bin" 2>/dev/null || true
328
+ { cat "$cf"; printf '\n'; cat "$BLOCK_FILE"; } > "$cf.new" || die "cannot append the context block in $cf" 1
329
+ mv "$cf.new" "$cf" || die "cannot replace $cf" 1
330
+ fi
331
+ WRITES=$((WRITES + 1))
332
+ ledger_line "$PLATFORM" "$SCOPE" "$cf" "$pre" "$(g_sha256_file "$cf")"
333
+ printf 'updated %s (context block)\n' "$cf"
334
+ return 0
335
+ }
336
+
337
+ # ---- uninstall (§4.4): reverse every recorded write byte-exactly, newest first ------
338
+ do_uninstall() {
339
+ ledger_init
340
+ local n=0 bad=0 p pre after img
341
+ [ -f "$LEDGER" ] && [ "$(awk -F'\t' -v P="$PLATFORM" 'NR>1 && $1==P' "$LEDGER" | grep -c . )" -gt 0 ] \
342
+ || { g_info "emit: nothing recorded for $PLATFORM - nothing to uninstall"; exit 0; }
343
+ # R6 first: a recorded file whose CURRENT hash != sha256_after was hand-edited after
344
+ # emit; uninstall refuses to delete an edited file silently.
345
+ while IFS=$'\t' read -r p after; do
346
+ [ -n "$p" ] || continue
347
+ if [ -f "$p" ] && [ "$(g_sha256_file "$p")" != "$after" ]; then
348
+ g_err "emit: $p was modified after emit (its bytes no longer match the ledger's post-image) - uninstall will not delete an edited file silently. fix: restore or remove it, then re-run --uninstall"
349
+ bad=1
350
+ fi
351
+ done < <(awk -F'\t' -v P="$PLATFORM" 'NR>1 && $1==P {print $3"\t"$5}' "$LEDGER")
352
+ [ "$bad" -eq 0 ] || exit 1
353
+ # newest first: the ledger is append-ordered, so walk it by descending line number.
354
+ # The whole selection is one awk (a `sort -t$'\t' -k1,1rn` mangles the tab argument
355
+ # under some locales and returned the rows in file order — measured).
356
+ while IFS=$'\t' read -r p pre; do
357
+ [ -n "$p" ] || continue
358
+ if [ "$DRYRUN" -eq 1 ]; then printf 'would reverse %s\n' "$p"; n=$((n + 1)); continue; fi
359
+ if [ "$pre" = "-" ]; then
360
+ rm -f "$p" && printf 'removed %s (created by emit)\n' "$p"
361
+ else
362
+ img=$(ls "$PREIMG/$pre/"* 2>/dev/null | head -n 1)
363
+ if [ -n "$img" ]; then
364
+ cp "$img" "$p" || die "cannot restore the pre-image of $p" 1
365
+ rm -rf "$PREIMG/$pre"
366
+ printf 'restored %s (byte-exact pre-image)\n' "$p"
367
+ else
368
+ die "the stored pre-image $pre for $p is missing under $PREIMG - cannot restore byte-exactly" 1
369
+ fi
370
+ fi
371
+ n=$((n + 1))
372
+ done < <(awk -F'\t' -v P="$PLATFORM" 'NR>1 && $1==P && !seen[$3]++ {line[NR]=$3 "\t" $4; ord[++m]=NR}
373
+ END { for (i=m; i>=1; i--) print line[ord[i]] }' "$LEDGER")
374
+ if [ "$DRYRUN" -eq 1 ]; then g_info "dry-run: uninstall would reverse $n recorded write(s) for $PLATFORM"; exit 0; fi
375
+ LEDGER_PATHS="$WORK_DIR/ledger-paths.txt"
376
+ # collect every directory the removed files sat in (the per-skill dirs included) BEFORE
377
+ # the ledger rows are dropped, then rmdir the emptied ones deepest-first (the measured
378
+ # installer pattern, MA17): a parent is only tested once its children have gone.
379
+ awk -F'\t' -v P="$PLATFORM" 'NR>1 && $1==P {print $3}' "$LEDGER" > "$LEDGER_PATHS"
380
+ awk -F'\t' -v P="$PLATFORM" 'NR==1 || $1!=P' "$LEDGER" > "$LEDGER.new" && mv "$LEDGER.new" "$LEDGER"
381
+ {
382
+ while IFS= read -r lp; do
383
+ [ -n "$lp" ] || continue
384
+ case "$lp" in "$TARGET"/*) printf '%s\n' "$(dirname "$lp")" ;; esac
385
+ done < "$LEDGER_PATHS"
386
+ skills_root
387
+ ctx_file || true
388
+ } | awk '!seen[$0]++' > "$WORK_DIR/seed-dirs.txt"
389
+ awk -F/ '{print NF" "$0}' "$WORK_DIR/seed-dirs.txt" | sort -rn | cut -d' ' -f2- \
390
+ | while IFS= read -r d; do
391
+ while [ -n "$d" ] && [ "$d" != "$TARGET" ] && [ "$d" != "$HOME" ] && [ "$d" != "/" ]; do
392
+ if [ -d "$d" ] && [ -z "$(ls -A "$d" 2>/dev/null)" ]; then rmdir "$d" 2>/dev/null && printf 'removed %s/ (empty)\n' "${d#"$TARGET"/}"; fi
393
+ d=$(dirname "$d")
394
+ done
395
+ done
396
+ g_info "uninstalled the $PLATFORM emission: $n write(s) reversed; a second run is a no-op"
397
+ exit 0
398
+ }
399
+
400
+ # ---- --unshadow (§5.2, hermes only): the PROCEDURE-tier reversal W3 deferred --------
401
+ do_unshadow() {
402
+ [ "$PLATFORM" = hermes ] || die "--unshadow ships for hermes only (the only platform where the shadowing hazard is measured)" 1
403
+ [ "$SCOPE" = project ] || die "--unshadow removes PROJECT-scope copies; say --scope project" 1
404
+ local root_path root d name src dst h srch n=0
405
+ root_path=$(skills_pattern); root_path=${root_path%%<name>*}
406
+ root=$(pattern_joined); root=${root%%<name>*}; root=${root%/}
407
+ [ -d "$root" ] || { g_info "emit: no $root_path under the target - nothing to unshadow"; exit 0; }
408
+ local found=0
409
+ # Pass 1: verify EVERY copy first. R7 (a hash differing from the source is a real local
410
+ # edit) must fire BEFORE anything is removed - the review's S2-1 measured that a mixed
411
+ # tree (one identical + one edited skill) got the identical copies deleted before the
412
+ # refusal fired, leaving the tree partially mutated on a refused run.
413
+ for d in "$root"/*/; do
414
+ [ -d "$d" ] || continue
415
+ name=$(basename "$d"); src="$SRCSKILLS/$name/SKILL.md"
416
+ [ -f "$src" ] || continue
417
+ found=1
418
+ dst="$d/SKILL.md"; srch=$(g_sha256_file "$src"); h=$(g_sha256_file "$dst")
419
+ if [ "$h" != "$srch" ]; then
420
+ die "$dst differs from the source payload (hash ${h:0:8}...) - it is a real local edit and --unshadow never auto-removes one. fix: reconcile or delete it yourself, then re-run" 1
421
+ fi
422
+ done
423
+ [ "$found" -eq 1 ] || { g_info "emit: no goblin-stack project skills under $root - nothing to unshadow"; exit 0; }
424
+ # Pass 2: every copy verified identical - now remove.
425
+ local n=0
426
+ for d in "$root"/*/; do
427
+ [ -d "$d" ] || continue
428
+ name=$(basename "$d"); src="$SRCSKILLS/$name/SKILL.md"
429
+ [ -f "$src" ] || continue
430
+ dst="$d/SKILL.md"
431
+ if [ "$DRYRUN" -eq 1 ]; then printf 'would remove %s (hash equals the source)\n' "${dst#"$TARGET"/}"; else
432
+ rm -f "$dst" && rmdir "$(dirname "$dst")" 2>/dev/null
433
+ printf 'removed %s (identical to the source payload)\n' "${dst#"$TARGET"/}"
434
+ fi
435
+ n=$((n + 1))
436
+ done
437
+ if [ "$DRYRUN" -eq 0 ] && [ "$n" -gt 0 ]; then
438
+ d="$root"
439
+ while [ "$d" != "$TARGET" ] && [ "$d" != "$HOME" ] && [ "$d" != "/" ]; do
440
+ [ -d "$d" ] && [ -z "$(ls -A "$d" 2>/dev/null)" ] && rmdir "$d" 2>/dev/null
441
+ d=$(dirname "$d")
442
+ done
443
+ fi
444
+ g_info "unshadowed $n identical project skill(s); a differing copy would have been refused (R7)"
445
+ exit 0
446
+ }
447
+
448
+ [ "$UNINSTALL" -eq 1 ] && [ "$UNSHADOW" -eq 1 ] && die "--uninstall and --unshadow are separate modes" 2
449
+ [ "$UNINSTALL" -eq 1 ] && do_uninstall
450
+ [ "$UNSHADOW" -eq 1 ] && do_unshadow
451
+
452
+ # ---- the main emit path (§4.1) -------------------------------------------------------
453
+ ledger_init
454
+
455
+ # Detection gate (§3): an absent platform refuses, UNLESS this is a project-scope write
456
+ # with an explicit --target (that is how CI prepares a repo) and no --strict.
457
+ det_rc=0; run_detect >/dev/null 2>&1 || det_rc=$?
458
+ if [ "$det_rc" -ne 0 ]; then
459
+ if [ "$STRICT" -eq 1 ]; then
460
+ die "$PLATFORM is NOT DETECTED (anchors tried: ${A_DETECT//:/, }) and --strict was given. fix: install the platform, or drop --strict" 1
461
+ fi
462
+ if [ "$SCOPE" != project ] || [ -z "$OPT_TARGET" ]; then
463
+ die "$PLATFORM is NOT DETECTED (anchors tried: ${A_DETECT//:/, }). fix: install the platform; or pass --scope project --target <dir> to prepare a repo on a machine without the platform" 1
464
+ fi
465
+ fi
466
+
467
+ PLAN_N=0
468
+ for name in $(selected_skills); do
469
+ emit_one_skill "$name" || exit $?
470
+ PLAN_N=$((PLAN_N + 1))
471
+ done
472
+ if [ "$SKILLS" != none ]; then
473
+ g_info "procedure index size: $(g_index_bytes) bytes (name+description frontmatter bytes over the emitted SKILL.md s)"
474
+ fi
475
+ write_context || exit $?
476
+
477
+ if [ "$DRYRUN" -eq 1 ]; then
478
+ g_info "dry-run: the plan above writes nothing"
479
+ exit 0
480
+ fi
481
+
482
+ exit 0