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