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