@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.
- package/CHANGELOG.md +351 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/VERSION +1 -0
- package/adapters/_template/adapter.tsv +16 -0
- package/adapters/_template/detect.sh +10 -0
- package/adapters/_template/emit.sh +5 -0
- package/adapters/_template/verify.sh +4 -0
- package/adapters/claude/adapter.tsv +8 -0
- package/adapters/claude/detect.sh +8 -0
- package/adapters/claude/verify.sh +47 -0
- package/adapters/codex/adapter.tsv +12 -0
- package/adapters/codex/detect.sh +9 -0
- package/adapters/codex/verify.sh +45 -0
- package/adapters/copilot/adapter.tsv +10 -0
- package/adapters/copilot/detect.sh +8 -0
- package/adapters/copilot/verify.sh +45 -0
- package/adapters/cursor/adapter.tsv +11 -0
- package/adapters/cursor/detect.sh +10 -0
- package/adapters/cursor/verify.sh +45 -0
- package/adapters/gemini/adapter.tsv +15 -0
- package/adapters/gemini/detect.sh +11 -0
- package/adapters/gemini/verify.sh +49 -0
- package/adapters/hermes/adapter.tsv +9 -0
- package/adapters/hermes/detect.sh +8 -0
- package/adapters/hermes/verify.sh +27 -0
- package/adapters/opencode/adapter.tsv +14 -0
- package/adapters/opencode/detect.sh +9 -0
- package/adapters/opencode/verify.sh +45 -0
- package/automations/README.md +53 -0
- package/automations/bugreporter-intake.sh +145 -0
- package/automations/drift-audit.sh +139 -0
- package/automations/report.schema.tsv +10 -0
- package/bans/README.md +82 -0
- package/bans/grep-ban.sh +84 -0
- package/bans/layer-check.sh +57 -0
- package/bin/goblin +119 -0
- package/bin/goblin-audit +145 -0
- package/bin/goblin-bans +178 -0
- package/bin/goblin-doctor +233 -0
- package/bin/goblin-emit +484 -0
- package/bin/goblin-init +519 -0
- package/bin/goblin-install +720 -0
- package/bin/goblin-lib.sh +289 -0
- package/bin/goblin-model +105 -0
- package/bin/goblin-upgrade +572 -0
- package/bin/goblin-verify +2798 -0
- package/bin/goblin.js +103 -0
- package/docs/ADOPTION.md +168 -0
- package/docs/CI.md +187 -0
- package/docs/CONTRACTS.md +197 -0
- package/docs/DESIGN.md +92 -0
- package/docs/ENFORCEMENT.md +225 -0
- package/docs/FLOWS.md +164 -0
- package/docs/GUARDRAILS.md +126 -0
- package/docs/GUIDE.md +610 -0
- package/docs/INTEGRATION.md +92 -0
- package/docs/LIMITS.md +591 -0
- package/docs/LOOP.md +165 -0
- package/docs/RE-PLAYBOOK.md +183 -0
- package/docs/RISKS.md +70 -0
- package/docs/ROLES.md +105 -0
- package/manifest/bans.tsv +9 -0
- package/manifest/classes.tsv +61 -0
- package/manifest/enforcement.tsv +88 -0
- package/manifest/glossary.tsv +25 -0
- package/manifest/playbooks.tsv +16 -0
- package/package.json +37 -4
- package/presets/A-shipped-software.yaml +48 -0
- package/presets/B-service-config.yaml +40 -0
- package/presets/C-game.yaml +38 -0
- package/presets/D-knowledge.yaml +41 -0
- package/presets/E-fleet-config.yaml +42 -0
- package/presets/F-electron.yaml +67 -0
- package/roles.yaml +54 -0
- package/skills/goblin-bootstrap/SKILL.md +51 -0
- package/skills/goblin-bugfix/SKILL.md +26 -0
- package/skills/goblin-bugreporter/SKILL.md +52 -0
- package/skills/goblin-drift-audit/SKILL.md +43 -0
- package/skills/goblin-eval/SKILL.md +68 -0
- package/skills/goblin-feature/SKILL.md +26 -0
- package/skills/goblin-feature-map/SKILL.md +140 -0
- package/skills/goblin-handoff/SKILL.md +28 -0
- package/skills/goblin-investigation/SKILL.md +26 -0
- package/skills/goblin-judge/SKILL.md +74 -0
- package/skills/goblin-loop/SKILL.md +88 -0
- package/skills/goblin-mode/SKILL.md +70 -0
- package/skills/goblin-overnight/SKILL.md +42 -0
- package/skills/goblin-pr-gate/SKILL.md +42 -0
- package/skills/goblin-re-mobile/SKILL.md +51 -0
- package/skills/goblin-refactor/SKILL.md +23 -0
- package/skills/goblin-sweep/SKILL.md +23 -0
- package/skills/goblin-tdd-repro/SKILL.md +27 -0
- package/skills/goblin-verify-author/SKILL.md +50 -0
- package/skills/practice/SKILL.md +37 -0
- package/templates/AGENTS.md.tmpl +23 -0
- package/templates/HANDOFF.md.tmpl +43 -0
- package/templates/SPEC.md.tmpl +34 -0
- package/templates/audit-waiver.tsv.tmpl +10 -0
- package/templates/boundary-waivers.tmpl +8 -0
- package/templates/checks/assert.mjs.tmpl +60 -0
- package/templates/checks/gate.sh.tmpl +29 -0
- package/templates/ci/goblin-gate.yml.tmpl +46 -0
- package/templates/goblin.yaml.tmpl +138 -0
- package/templates/install-hooks.allowlist.tmpl +9 -0
- package/templates/loop/decisions.tsv.tmpl +1 -0
- package/templates/loop/predicate.tmpl +16 -0
- package/templates/report.yaml.tmpl +16 -0
package/bin/goblin-emit
ADDED
|
@@ -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
|