@techgoblin/gobstack 0.0.0-stage → 0.4.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +163 -2
  4. package/VERSION +1 -0
  5. package/adapters/_template/adapter.tsv +16 -0
  6. package/adapters/_template/detect.sh +10 -0
  7. package/adapters/_template/emit.sh +5 -0
  8. package/adapters/_template/verify.sh +4 -0
  9. package/adapters/claude/adapter.tsv +8 -0
  10. package/adapters/claude/detect.sh +8 -0
  11. package/adapters/claude/verify.sh +47 -0
  12. package/adapters/codex/adapter.tsv +12 -0
  13. package/adapters/codex/detect.sh +9 -0
  14. package/adapters/codex/verify.sh +45 -0
  15. package/adapters/copilot/adapter.tsv +10 -0
  16. package/adapters/copilot/detect.sh +8 -0
  17. package/adapters/copilot/verify.sh +45 -0
  18. package/adapters/cursor/adapter.tsv +11 -0
  19. package/adapters/cursor/detect.sh +10 -0
  20. package/adapters/cursor/verify.sh +45 -0
  21. package/adapters/gemini/adapter.tsv +15 -0
  22. package/adapters/gemini/detect.sh +11 -0
  23. package/adapters/gemini/verify.sh +49 -0
  24. package/adapters/hermes/adapter.tsv +9 -0
  25. package/adapters/hermes/detect.sh +8 -0
  26. package/adapters/hermes/verify.sh +27 -0
  27. package/adapters/opencode/adapter.tsv +14 -0
  28. package/adapters/opencode/detect.sh +9 -0
  29. package/adapters/opencode/verify.sh +45 -0
  30. package/automations/README.md +53 -0
  31. package/automations/bugreporter-intake.sh +145 -0
  32. package/automations/drift-audit.sh +139 -0
  33. package/automations/report.schema.tsv +10 -0
  34. package/bans/README.md +82 -0
  35. package/bans/grep-ban.sh +84 -0
  36. package/bans/layer-check.sh +57 -0
  37. package/bin/goblin +119 -0
  38. package/bin/goblin-audit +145 -0
  39. package/bin/goblin-bans +178 -0
  40. package/bin/goblin-doctor +233 -0
  41. package/bin/goblin-emit +482 -0
  42. package/bin/goblin-init +519 -0
  43. package/bin/goblin-install +720 -0
  44. package/bin/goblin-lib.sh +289 -0
  45. package/bin/goblin-model +105 -0
  46. package/bin/goblin-upgrade +572 -0
  47. package/bin/goblin-verify +2798 -0
  48. package/bin/goblin.js +48 -0
  49. package/docs/ADOPTION.md +168 -0
  50. package/docs/CI.md +187 -0
  51. package/docs/CONTRACTS.md +197 -0
  52. package/docs/DESIGN.md +92 -0
  53. package/docs/ENFORCEMENT.md +225 -0
  54. package/docs/FLOWS.md +164 -0
  55. package/docs/GUARDRAILS.md +126 -0
  56. package/docs/GUIDE.md +610 -0
  57. package/docs/INTEGRATION.md +92 -0
  58. package/docs/LIMITS.md +591 -0
  59. package/docs/LOOP.md +165 -0
  60. package/docs/RE-PLAYBOOK.md +183 -0
  61. package/docs/RISKS.md +70 -0
  62. package/docs/ROLES.md +105 -0
  63. package/manifest/bans.tsv +9 -0
  64. package/manifest/classes.tsv +61 -0
  65. package/manifest/enforcement.tsv +88 -0
  66. package/manifest/glossary.tsv +25 -0
  67. package/manifest/playbooks.tsv +16 -0
  68. package/package.json +36 -4
  69. package/presets/A-shipped-software.yaml +48 -0
  70. package/presets/B-service-config.yaml +40 -0
  71. package/presets/C-game.yaml +38 -0
  72. package/presets/D-knowledge.yaml +41 -0
  73. package/presets/E-fleet-config.yaml +42 -0
  74. package/presets/F-electron.yaml +67 -0
  75. package/roles.yaml +54 -0
  76. package/skills/goblin-bootstrap/SKILL.md +51 -0
  77. package/skills/goblin-bugfix/SKILL.md +26 -0
  78. package/skills/goblin-bugreporter/SKILL.md +52 -0
  79. package/skills/goblin-drift-audit/SKILL.md +43 -0
  80. package/skills/goblin-eval/SKILL.md +68 -0
  81. package/skills/goblin-feature/SKILL.md +26 -0
  82. package/skills/goblin-feature-map/SKILL.md +140 -0
  83. package/skills/goblin-handoff/SKILL.md +28 -0
  84. package/skills/goblin-investigation/SKILL.md +26 -0
  85. package/skills/goblin-judge/SKILL.md +74 -0
  86. package/skills/goblin-loop/SKILL.md +88 -0
  87. package/skills/goblin-mode/SKILL.md +70 -0
  88. package/skills/goblin-overnight/SKILL.md +42 -0
  89. package/skills/goblin-pr-gate/SKILL.md +42 -0
  90. package/skills/goblin-re-mobile/SKILL.md +51 -0
  91. package/skills/goblin-refactor/SKILL.md +23 -0
  92. package/skills/goblin-sweep/SKILL.md +23 -0
  93. package/skills/goblin-tdd-repro/SKILL.md +27 -0
  94. package/skills/goblin-verify-author/SKILL.md +50 -0
  95. package/skills/practice/SKILL.md +37 -0
  96. package/templates/AGENTS.md.tmpl +23 -0
  97. package/templates/HANDOFF.md.tmpl +43 -0
  98. package/templates/SPEC.md.tmpl +34 -0
  99. package/templates/audit-waiver.tsv.tmpl +10 -0
  100. package/templates/boundary-waivers.tmpl +8 -0
  101. package/templates/checks/assert.mjs.tmpl +60 -0
  102. package/templates/checks/gate.sh.tmpl +29 -0
  103. package/templates/ci/goblin-gate.yml.tmpl +46 -0
  104. package/templates/goblin.yaml.tmpl +138 -0
  105. package/templates/install-hooks.allowlist.tmpl +9 -0
  106. package/templates/loop/decisions.tsv.tmpl +1 -0
  107. package/templates/loop/predicate.tmpl +16 -0
  108. package/templates/report.yaml.tmpl +16 -0
@@ -0,0 +1,2798 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-verify — execute the target-scope rows of manifest/enforcement.tsv.
3
+ #
4
+ # Usage:
5
+ # goblin-verify [--only <id[,id...]>] [--json] [--list] [--source <goblin-stack path>]
6
+ #
7
+ # Exit codes:
8
+ # 0 every executed check passed (advisories and skips do not fail the run)
9
+ # 1 at least one check FAILED
10
+ # 2 verify could not run (not installed, missing dependency, unparseable config)
11
+ # 3 the manifest itself is broken (a row with no check and no 'advisory', or a duplicate id)
12
+ #
13
+ # The check column of each row holds either
14
+ # * a shell command, run from the target repo root, or
15
+ # * the literal 'advisory' (counted, never executed), or
16
+ # * 'goblin-verify --only <ID>', the marker for a check that needs logic beyond one
17
+ # shell line. Those are implemented as builtins below; the marker keeps the row
18
+ # self-describing and keeps the matrix the single source of truth.
19
+
20
+ set -uo pipefail
21
+
22
+ GOBLIN_VERIFY_VERSION="0.4.4"
23
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
24
+ # shellcheck source=goblin-lib.sh
25
+ . "$SELF_DIR/goblin-lib.sh"
26
+
27
+ ONLY=""
28
+ JSON=0
29
+ LIST=0
30
+ SOURCE=""
31
+
32
+ usage() {
33
+ cat <<'USAGE'
34
+ goblin-verify — prove a goblin-stack install is still what it says it is.
35
+
36
+ --only <id[,id...]> run only these manifest rows
37
+ --list list the manifest rows and exit
38
+ --json machine-readable output
39
+ --source <path> read the manifest from a goblin-stack checkout instead of .goblin/
40
+
41
+ Exit codes: 0 pass | 1 a check failed | 2 could not run | 3 the manifest is broken.
42
+ USAGE
43
+ }
44
+
45
+ while [ $# -gt 0 ]; do
46
+ case "$1" in
47
+ --only) ONLY="${2:-}"; shift 2 ;;
48
+ --json) JSON=1; shift ;;
49
+ --list) LIST=1; shift ;;
50
+ --source) SOURCE="${2:-}"; shift 2 ;;
51
+ -h|--help) usage; exit 0 ;;
52
+ *) g_err "unknown option: $1"; usage >&2; exit 2 ;;
53
+ esac
54
+ done
55
+
56
+ # ---------------------------------------------------------------- environment --
57
+ for dep in git awk sed grep python3; do
58
+ command -v "$dep" >/dev/null 2>&1 || { g_err "missing dependency: $dep"; exit 2; }
59
+ done
60
+
61
+ # Root resolution. `git rev-parse --show-toplevel` alone verifies the OUTER repo when the
62
+ # target is nested inside another git repo (a monorepo subdirectory, anything under
63
+ # ~/.hermes) — the installed config is the target's own marker, so it wins (D20).
64
+ find_installed_root() {
65
+ local d="$PWD"
66
+ while :; do
67
+ if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
68
+ [ "$d" = "/" ] && return 1
69
+ d=$(dirname "$d")
70
+ done
71
+ }
72
+ ROOT=$(find_installed_root) || ROOT=""
73
+ if [ -z "$ROOT" ]; then
74
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
75
+ [ -n "$ROOT" ] || ROOT="$PWD"
76
+ fi
77
+
78
+ # ---- engine resolution (W1, §2.3): one chain, stated once --------------------
79
+ # Every engine-path lookup (enforcement.tsv, classes.tsv, bans.tsv, bans/*.sh, the ban
80
+ # engine) resolves through this chain, first hit wins:
81
+ # 1. --source <path> (existing flag; highest, for tests and pinned CI)
82
+ # 2. $ROOT/.goblin/... (vendored per-repo engine — today's default)
83
+ # 3. engine_dir: from $ROOT/.goblin/goblin.yaml
84
+ # 4. GOBLIN_ENGINE_DIR env, then ~/.goblin/engine
85
+ # 5. nothing found -> exit 2, naming every path tried (the pre-W1 message when the
86
+ # vendored chain is the only one tried; the §2.4 message when engine_dir: fails).
87
+ # Ordering is deliberate: a vendored engine still wins over the global one, because an
88
+ # explicit per-repo choice must not be silently overridden by a machine-level upgrade.
89
+ # An explicit engine_dir: that cannot be used is exit 2 with NO fallback (§1.2): falling
90
+ # back to a vendored engine would judge the repo with a different rule table than the one
91
+ # it declared. Absent key => the chain degrades to exactly today's behaviour (§1.3).
92
+
93
+ CONFIG="$ROOT/.goblin/goblin.yaml"
94
+
95
+ # engine_yaml_raw — the raw top-level engine_dir: value (the line-oriented parser reads
96
+ # the line, so a non-string scalar is visible here as something that is not a path).
97
+ engine_yaml_raw() {
98
+ sed -n "s/^engine_dir:[[:space:]]*//p" "$CONFIG" | head -n 1
99
+ }
100
+
101
+ # resolve_engine_dir — validate the declared key (§1.2, fail closed at use time, stages
102
+ # 1-4) and print the absolute engine directory. Stages 3/4 (existence, payload) do NOT
103
+ # exit here: the failure is reported through ENGINE_FAIL so the refusal can name BOTH the
104
+ # declared value and the chain tried (§2.4's message), then the run stops with exit 2 —
105
+ # no fallback, no SKIP.
106
+ ENGINE_DECLARED=""
107
+ ENGINE_FAIL=""
108
+ ENGINE_DIR_DECLARED="" # the declared value as written (for the §2.4 refusal message)
109
+ resolve_engine_dir() {
110
+ ENGINE_DECLARED=""
111
+ ENGINE_FAIL=""
112
+ ENGINE_DIR_DECLARED=""
113
+ [ -f "$CONFIG" ] || return 0
114
+ grep -q '^engine_dir:' "$CONFIG" || return 0
115
+ local dir
116
+ dir=$(engine_yaml_raw)
117
+ if [ -z "$dir" ]; then
118
+ ENGINE_FAIL="engine_dir: must be a string path, got empty"
119
+ return 1
120
+ fi
121
+ dir=$(g_unquote "$dir")
122
+ ENGINE_DIR_DECLARED="$dir"
123
+ # Stage 1: a non-string value (number, list, mapping, bare null/bool) is not a path.
124
+ # Anything that is not a usable path shape fails closed here and `got <value>` names
125
+ # what was declared; valid strings fall through to the shape check below.
126
+ ENGINE_FAIL="engine_dir: must be a string path, got $dir"
127
+ case "$dir" in
128
+ '~'|'null'|'true'|'false'|'['*|'{'*|''|*[!0-9A-Za-z_./~-]*|[0-9]*)
129
+ return 1 ;;
130
+ esac
131
+ ENGINE_FAIL=""
132
+ # Stage 2: the shape — absolute, or ~/ (expanded once, at read time, via $HOME).
133
+ case "$dir" in
134
+ /*) ;;
135
+ "~/"*) dir="$HOME/${dir#\~/}" ;;
136
+ *)
137
+ ENGINE_FAIL="engine_dir: must be absolute or ~/-prefixed: $dir"
138
+ return 1 ;;
139
+ esac
140
+ # Stage 3: existence.
141
+ if [ ! -d "$dir" ]; then
142
+ ENGINE_FAIL="engine_dir: $dir does not exist (declared in .goblin/goblin.yaml)"
143
+ return 1
144
+ fi
145
+ # Stage 4: the payload — a directory without the rule table is not an engine.
146
+ if [ ! -f "$dir/manifest/enforcement.tsv" ]; then
147
+ ENGINE_FAIL="engine_dir: no manifest/enforcement.tsv under $dir - this is not an engine directory"
148
+ return 1
149
+ fi
150
+ ENGINE_DECLARED="$dir"
151
+ return 0
152
+ }
153
+
154
+ resolve_engine_dir
155
+ ENGINE_CHAIN_VENDORED="\$ROOT/.goblin/manifest/enforcement.tsv"
156
+ GOBLIN_ENGINE="" # the resolved engine root, when NOT vendored/--source
157
+ if [ -n "$SOURCE" ]; then
158
+ MANIFEST="$SOURCE/manifest/enforcement.tsv"
159
+ CLASSES="$SOURCE/manifest/classes.tsv"
160
+ elif [ -f "$ROOT/.goblin/manifest/enforcement.tsv" ]; then
161
+ MANIFEST="$ROOT/.goblin/manifest/enforcement.tsv"
162
+ CLASSES="$ROOT/.goblin/manifest/classes.tsv"
163
+ elif [ -n "$ENGINE_DECLARED" ]; then
164
+ GOBLIN_ENGINE="$ENGINE_DECLARED"
165
+ MANIFEST="$GOBLIN_ENGINE/manifest/enforcement.tsv"
166
+ CLASSES="$GOBLIN_ENGINE/manifest/classes.tsv"
167
+ elif [ -n "$ENGINE_FAIL" ]; then
168
+ # §2.4: an explicit engine_dir: that does not resolve is exit 2, and nothing falls
169
+ # back. The refusal names the declared value and every path the chain tried.
170
+ g_err "$ENGINE_FAIL"
171
+ g_err "no manifest at any of: \$SOURCE (${SOURCE:-unset}), $ROOT/.goblin/manifest/enforcement.tsv (absent),"
172
+ g_err " $ENGINE_DIR_DECLARED/manifest/enforcement.tsv (unusable - see above)"
173
+ exit 2
174
+ else
175
+ # No vendored engine and no declaration: the CLI default (§3.3), env first, then
176
+ # ~/.goblin/engine. Both are consulted AFTER --source, vendored and the declaration:
177
+ # the most specific declaration wins; the machine default is the fallback, never an
178
+ # override (A9).
179
+ _cli_default=""
180
+ if [ -n "${GOBLIN_ENGINE_DIR:-}" ]; then
181
+ _cli_default="$GOBLIN_ENGINE_DIR"
182
+ elif [ -d "$HOME/.goblin/engine" ]; then
183
+ _cli_default="$HOME/.goblin/engine"
184
+ fi
185
+ if [ -n "$_cli_default" ] && [ -f "$_cli_default/manifest/enforcement.tsv" ]; then
186
+ GOBLIN_ENGINE="$_cli_default"
187
+ MANIFEST="$GOBLIN_ENGINE/manifest/enforcement.tsv"
188
+ CLASSES="$GOBLIN_ENGINE/manifest/classes.tsv"
189
+ else
190
+ MANIFEST="$SELF_DIR/../manifest/enforcement.tsv"
191
+ CLASSES="$SELF_DIR/../manifest/classes.tsv"
192
+ fi
193
+ fi
194
+ [ -f "$MANIFEST" ] || {
195
+ g_err "no manifest at $MANIFEST"
196
+ g_err "no manifest at any of: \$SOURCE (${SOURCE:-unset}), $ENGINE_CHAIN_VENDORED (absent), $SELF_DIR/../manifest/enforcement.tsv"
197
+ exit 2
198
+ }
199
+
200
+ INSTALLED="$ROOT/.goblin/installed.json"
201
+
202
+ # engine_block_field <key> — one field of installed.json's engine: block (empty if absent).
203
+ engine_block_field() {
204
+ [ -f "$INSTALLED" ] || return 0
205
+ g_json_object "$INSTALLED" engine | awk -F'\t' -v k="$1" '$1==k {print $2; exit}'
206
+ }
207
+
208
+ # ---- the engine mode (W1 §2.2) ----------------------------------------------
209
+ # vendored (the default): the engine resolved inside the repo (or --source pinned it)
210
+ # and installed.json records the payload. The install-record and procedure rows
211
+ # behave exactly as before W1.
212
+ # global: the engine resolved OUTSIDE the repo (engine_dir:, GOBLIN_ENGINE_DIR or
213
+ # ~/.goblin/engine), or installed.json's own engine: block says mode=global - a repo
214
+ # whose engine: block says global is global even if a vendored payload is still on
215
+ # disk mid-migration (W3's three-verify sequence). The repo-local clauses keep
216
+ # hashing what IS recorded; absence of engine files is not drift; a repo with no
217
+ # install record at all is not born RED.
218
+ ENGINE_MODE="vendored"
219
+ [ -n "$GOBLIN_ENGINE" ] && ENGINE_MODE="global"
220
+ _em=$(engine_block_field mode)
221
+ [ "$_em" = "global" ] && ENGINE_MODE="global"
222
+ export ENGINE_MODE
223
+
224
+ # ---- manifest integrity (exit 3 before any check runs) ----------------------
225
+ manifest_check() {
226
+ local rc=0
227
+ awk -F'\t' 'NR>1 && $6=="" {print "row " $1 " has no check and no advisory label"; bad=1} END{exit bad}' "$MANIFEST" || rc=3
228
+ awk -F'\t' 'NR>1 {if (seen[$1]++) {print "duplicate id " $1; bad=1}} END{exit bad}' "$MANIFEST" || rc=3
229
+ return $rc
230
+ }
231
+ manifest_check || { g_err "manifest is broken (exit 3)"; exit 3; }
232
+
233
+ if [ "$LIST" -eq 1 ]; then
234
+ awk -F'\t' 'NR>1 {printf "%-7s %-7s %-9s %s\n", $1, $2, $4, $3}' "$MANIFEST"
235
+ exit 0
236
+ fi
237
+
238
+ # --only must name rows the manifest actually has: a typo used to select nothing, emit no
239
+ # rows and exit 0, turning a pipeline gate into a no-op (D15).
240
+ if [ -n "$ONLY" ]; then
241
+ UNKNOWN=""
242
+ for want in $(printf '%s' "$ONLY" | tr ',' ' '); do
243
+ [ -n "$want" ] || continue
244
+ awk -F'\t' -v x="$want" 'NR>1 && $1==x {f=1} END{exit !f}' "$MANIFEST" \
245
+ || UNKNOWN="$UNKNOWN $want"
246
+ done
247
+ [ -z "$UNKNOWN" ] || { g_err "--only names id(s) not in the manifest:$UNKNOWN"; exit 2; }
248
+ # A valid id that is SOURCE-scope (PR-01..PR-04) selects no target row: the run emits
249
+ # nothing and exits 0, which is the same no-op gate D15 closed for a typo (F2-6).
250
+ TARGET_ROWS=0
251
+ for want in $(printf '%s' "$ONLY" | tr ',' ' '); do
252
+ [ -n "$want" ] || continue
253
+ awk -F'\t' -v x="$want" 'NR>1 && $1==x && $2=="target" {f=1} END{exit !f}' "$MANIFEST" \
254
+ && TARGET_ROWS=1
255
+ done
256
+ if [ "$TARGET_ROWS" -eq 0 ]; then
257
+ g_err "--only $ONLY selects no target-scope row, so this run would execute nothing and"
258
+ g_err "report success. Source-scope rows (PR-*) run in tests/run-tests.sh, not here."
259
+ exit 2
260
+ fi
261
+ fi
262
+
263
+ if [ ! -f "$CONFIG" ]; then
264
+ g_err "not installed: $CONFIG is absent. Run: goblin-install --target $ROOT --class <A|B|C|D|E>"
265
+ exit 2
266
+ fi
267
+
268
+ CLASS=$(g_yaml_scalar "$CONFIG" class)
269
+ case "$CLASS" in
270
+ A|B|C|D|E|F) ;;
271
+ *) g_err "unknown class '${CLASS:-<empty>}' in $CONFIG (expected A|B|C|D|E|F)"; exit 2 ;;
272
+ esac
273
+
274
+ # Root vs git toplevel. find_installed_root resolves the CONFIG lookup correctly, but the
275
+ # rows that shell out to git run in $ROOT and git resolves that to the ENCLOSING repo when
276
+ # the target is not a repository of its own: a nested target then reports the outer repo's
277
+ # index (30 "dirty entries" that belong to the outer repo) instead of its own state (F2-1).
278
+ # Refuse up front, with the reason, rather than print a number about the wrong tree.
279
+ ROOT_PHYS=$(cd "$ROOT" && pwd -P)
280
+ GIT_TOP=$(cd "$ROOT" && git rev-parse --show-toplevel 2>/dev/null)
281
+ if [ -n "$GIT_TOP" ] && [ "$GIT_TOP" != "$ROOT_PHYS" ]; then
282
+ g_err "target $ROOT_PHYS is inside the git repository $GIT_TOP but is not a repository of"
283
+ g_err "its own. CM-01 CM-03 GT-03 SP-02 PG-01 PG-03 DS-01 PT-02 and HP-05 shell out to git,"
284
+ g_err "and git would resolve every one of them to $GIT_TOP - the numbers would describe the"
285
+ g_err "outer repo, not this target."
286
+ g_err "fix: cd $ROOT_PHYS && git init (make the target its own repo), or move it out of"
287
+ g_err " $GIT_TOP."
288
+ exit 2
289
+ fi
290
+
291
+ BRANCH=$(g_yaml_scalar "$CONFIG" branch)
292
+ ARCHIVE=$(g_yaml_scalar "$CONFIG" archive)
293
+ MAX_DIRTY=$(g_yaml_scalar "$CONFIG" max_dirty)
294
+ ADV_CEILING=$(g_yaml_scalar "$CONFIG" advisory_ceiling)
295
+ HARNESS_DIR_RAW=$(g_yaml_scalar "$CONFIG" harness_dir)
296
+ SCAFFOLD_CHECKS=$(g_yaml_scalar "$CONFIG" scaffold_checks)
297
+ MODELS_FILE=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" models_file)")
298
+ PRACTICE=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" practice)")
299
+ PRACTICE_SHA=$(g_unquote "$(g_yaml_scalar "$CONFIG" practice_sha256)")
300
+ [ -n "$MAX_DIRTY" ] || MAX_DIRTY=0
301
+ [ -n "$ADV_CEILING" ] || ADV_CEILING=10
302
+ [ -n "$HARNESS_DIR_RAW" ] || HARNESS_DIR_RAW=checks
303
+ # A config installed before this key existed declares no harness expectation: keep the old
304
+ # skip behaviour rather than failing an adopted repo on an upgrade.
305
+ [ -n "$SCAFFOLD_CHECKS" ] || SCAFFOLD_CHECKS=no
306
+
307
+ # The feature map (G1). feature_map: EMPTY is the meaningful state, not a broken config: a
308
+ # fresh install has no map and the two FM rows must SKIP with that reason (the replay.commit:
309
+ # "" shape, docs/LIMITS.md #11). source_root: is what an entry path resolves against.
310
+ FEATURE_MAP=$(g_yaml_scalar "$CONFIG" feature_map)
311
+ SOURCE_ROOT=$(g_unquote "$(g_yaml_scalar "$CONFIG" source_root)")
312
+ [ -n "$SOURCE_ROOT" ] || SOURCE_ROOT=.
313
+ VERIFY_DOCTOR=$(g_unquote "$(g_yaml_scalar "$CONFIG" verify_doctor)")
314
+ export FEATURE_MAP SOURCE_ROOT VERIFY_DOCTOR
315
+
316
+ # The reference corpus (P15, RC-01..RC-04). `reference_manifest:` is the ONE state that may SKIP
317
+ # with a reason: a repo with no reference corpus must not be born RED (the feature_map: shape),
318
+ # and RC-01/RC-02/RC-04 skip for exactly that reason. A key that IS declared but whose file is
319
+ # missing or unparseable is a FAIL, deliberately, so losing the manifest cannot read as "no
320
+ # corpus" (the FM-01 declared-map-absent shape). These rows are builtins and not shell one-liners
321
+ # for this reason alone: the runner gives only a builtin exit 2/3 (see the dispatch below), so a
322
+ # one-liner could never report "not applicable yet" without failing the run.
323
+ # `quarantine_root:` is the quarantine directory of the playbook's S5 - outside the repo by
324
+ # design - and no row reads it; it is declared so the procedure has a machine-readable anchor
325
+ # instead of a path in prose (it ships empty here, the way feature_map: does).
326
+ REFERENCE_MANIFEST=$(g_unquote "$(g_yaml_scalar "$CONFIG" reference_manifest)")
327
+ export REFERENCE_MANIFEST
328
+
329
+ # The loop ceiling (G2, LP-03). The most turns ONE loop record may declare in its budget; 20 is
330
+ # the engine's own default for a kanban goal loop, so a ceiling that disagreed with the engine
331
+ # would be a number someone made up. A config written before this key existed keeps the default
332
+ # (the SCAFFOLD_CHECKS shape): an absent key is not a broken config.
333
+ LOOP_MAX_TURNS_CEILING=$(g_yaml_scalar "$CONFIG" loop_max_turns_ceiling)
334
+ [ -n "$LOOP_MAX_TURNS_CEILING" ] || LOOP_MAX_TURNS_CEILING=20
335
+ # The loop record (G2): a COMMITTED artifact of one unattended run. Nothing in this section
336
+ # re-runs the predicate and nothing writes: the record IS the evidence, and what that costs is
337
+ # stated in docs/LOOP.md and docs/LIMITS.md.
338
+ LOOP_DIR="$ROOT/.goblin/loop"
339
+ export LOOP_MAX_TURNS_CEILING LOOP_DIR
340
+
341
+ if [ -d "$ROOT/$HARNESS_DIR_RAW" ]; then
342
+ export GOBLIN_HARNESS_DIR="$ROOT/$HARNESS_DIR_RAW"
343
+ export GOBLIN_HARNESS_REL="$HARNESS_DIR_RAW"
344
+ export GOBLIN_HARNESS_STATE=present
345
+ else
346
+ export GOBLIN_HARNESS_DIR=""
347
+ export GOBLIN_HARNESS_REL="$HARNESS_DIR_RAW"
348
+ # 'absent' = the class scaffolds a harness dir and the declared one is missing -> FAIL.
349
+ # 'none' = the class ships no harness dir -> SKIP with a reason (D7).
350
+ if [ "$SCAFFOLD_CHECKS" = "yes" ]; then
351
+ export GOBLIN_HARNESS_STATE=absent
352
+ else
353
+ export GOBLIN_HARNESS_STATE=none
354
+ fi
355
+ fi
356
+ export GOBLIN_ROOT="$ROOT"
357
+ export GOBLIN_CONFIG="$CONFIG"
358
+
359
+ # ---- the security surface (SC-01..SC-09) and the perf baseline (PF-01) -----------------------
360
+ # Every one of these is DECLARED in .goblin/goblin.yaml. A stack-specific rule guessed from the
361
+ # files on disk is how a matrix starts lying; a declared key that is absent is a skip with a
362
+ # reason, never a silent pass.
363
+ SEC_GITIGNORE_FAMILY=$(g_yaml_block_scalar "$CONFIG" security gitignore_family)
364
+ SEC_BUILD_OUTPUT=$(g_yaml_block_scalar "$CONFIG" security build_output)
365
+ SEC_AUDIT_CMD=$(g_yaml_block_scalar "$CONFIG" security audit_cmd)
366
+ SEC_AUDIT_MAX_AGE=$(g_yaml_block_scalar "$CONFIG" security audit_max_age_days)
367
+ SEC_WAIVER_MAX_AGE=$(g_yaml_block_scalar "$CONFIG" security waiver_max_age_days)
368
+ SEC_WRITE_ROUTES=$(g_yaml_block_scalar "$CONFIG" security write_routes)
369
+ [ -n "$SEC_AUDIT_MAX_AGE" ] || SEC_AUDIT_MAX_AGE=90
370
+ [ -n "$SEC_WAIVER_MAX_AGE" ] || SEC_WAIVER_MAX_AGE=180
371
+ PERF_METRIC=$(g_yaml_block_scalar "$CONFIG" perf metric)
372
+ PERF_CMD=$(g_yaml_block_scalar "$CONFIG" perf cmd)
373
+ PERF_BASELINE_COMMIT=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" perf baseline_commit)")
374
+ PERF_BASELINE_VALUE=$(g_yaml_block_scalar "$CONFIG" perf baseline_value)
375
+ PERF_MEASURED=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" perf measured)")
376
+ PERF_HOST_GATE=$(g_yaml_block_scalar "$CONFIG" perf host_gate)
377
+ export SEC_GITIGNORE_FAMILY SEC_BUILD_OUTPUT SEC_AUDIT_CMD SEC_AUDIT_MAX_AGE SEC_WAIVER_MAX_AGE
378
+ export SEC_WRITE_ROUTES PERF_METRIC PERF_CMD PERF_BASELINE_VALUE PERF_HOST_GATE
379
+
380
+ # Whole days between an ISO date and today, or empty when the date does not parse on this host
381
+ # (`date -d` is GNU; the row FAILS rather than assuming, because an unreadable date is not a
382
+ # fresh record).
383
+ age_days() {
384
+ local then_s now_s
385
+ then_s=$(date -u -d "$1" +%s 2>/dev/null) || return 1
386
+ now_s=$(date -u +%s)
387
+ printf '%s' "$(( (now_s - then_s) / 86400 ))"
388
+ }
389
+
390
+ # The source files a secret-shaped search should look at. The vendored harness (.goblin,
391
+ # .hermes), the build output and dependencies are excluded: SC-03 must not match the row text
392
+ # that describes it.
393
+ sc_sources() {
394
+ ( cd "$ROOT" && grep -rniE "$1" \
395
+ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
396
+ --include='*.mjs' --include='*.cjs' --include='*.vue' --include='*.svelte' . 2>/dev/null \
397
+ | grep -vE '(^|/)(\.next|node_modules|\.goblin|\.hermes|dist|build|out|coverage)/' || true )
398
+ }
399
+
400
+ only_selected() {
401
+ [ -z "$ONLY" ] && return 0
402
+ case ",$ONLY," in *",$1,"*) return 0 ;; esac
403
+ return 1
404
+ }
405
+
406
+ # ---- part mapping: a row id's prefix names the class-controlled part ---------
407
+ row_part() {
408
+ case "$1" in
409
+ HP-*) echo handoff ;;
410
+ SP-*) echo spec ;;
411
+ GT-01|GT-02|GT-03) echo gate ;;
412
+ GT-04|GT-05) echo ratchet ;;
413
+ HS-01|HS-03) echo gate ;;
414
+ HS-02) echo replay ;;
415
+ PG-*) echo pr-gate ;;
416
+ # The automation rows and SK-04 ride the `playbooks` part: their artifacts are the shipped
417
+ # skills and the automation producers, and --skills no disables that part. An unmapped
418
+ # prefix returns "" and the row can never honour an opt-out (G3 section 4.3).
419
+ AU-*) echo playbooks ;;
420
+ SK-04) echo playbooks ;;
421
+ # SK-01 rides the same part (W1 made it a builtin for the global-mode clause): with
422
+ # --skills no there is no shipped skill to frontmatter-check, and a vacuous pass proves
423
+ # nothing - the row honours the opt-out like its SK-02/SK-04 siblings.
424
+ SK-01) echo playbooks ;;
425
+ # SK-02 keys off the same part: with --skills no there are no installed skills to hash, and
426
+ # the row used to FAIL there (measured at v0.2.0-dev: "32 passed, 1 failed ... SK-02 0
427
+ # installed skill file(s) hashed"). An opt-out is the honest report; a FAIL on a repo that
428
+ # opted out of skills is not.
429
+ SK-02) echo playbooks ;;
430
+ *) echo "" ;;
431
+ esac
432
+ }
433
+
434
+ # ============================================================== builtins ======
435
+ # Each builtin prints its detail and returns 0 pass | 1 fail | 2 advisory | 3 skip.
436
+
437
+ # The install-record rows (W1 §2.5). IN-01 and IN-04 were shell one-liners over
438
+ # .goblin/installed.json; the engine.mode=global clause needs engine logic (a global,
439
+ # declaration-only repo has NO install record and must SKIP with the reason, never be
440
+ # born RED - the D8/FM-01 shape), so the one-liners move into builtins that keep the
441
+ # same clauses and gain the mode check. In vendored mode they are exactly the old cells.
442
+ check_in_01() {
443
+ if [ "$ENGINE_MODE" = "global" ] && [ ! -s "$INSTALLED" ]; then
444
+ printf 'global engine mode — no per-repo install record\n'
445
+ return 3
446
+ fi
447
+ [ -s "$INSTALLED" ] && grep -q '"version"' "$INSTALLED" \
448
+ && { printf 'the install record exists and names its version\n'; return 0; }
449
+ printf 'installed.json is absent or records no version\n'
450
+ return 1
451
+ }
452
+
453
+ # g_installed_files_strict <installed.json> — only the entries of the `files:` MAP. The
454
+ # line-oriented engine-block reader (g_json_object) also matches the `engine:` block's
455
+ # "key": "value" pairs (W1 §2.2), which IN-04 must never read as pre-existing files.
456
+ g_installed_files_strict() {
457
+ awk '
458
+ /^ "files": \{[[:space:]]*\},?$/ { next }
459
+ /^ "files": \{/ { f = 1; next }
460
+ f && /^ \},$/ { f = 0 }
461
+ f && /^ "/ {
462
+ line = $0
463
+ sub(/^ "/, "", line)
464
+ p = line; sub(/".*/, "", p)
465
+ h = line; sub(/[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
466
+ print p "\t" h
467
+ }
468
+ ' "$1"
469
+ }
470
+
471
+ # g_json_object_strict <installed.json> <object-name> — g_json_object, hardened for the
472
+ # engine-block record (W1 §2.2): an EMPTY object (`"refused": {}`) opens and closes on
473
+ # the same line, so the reader must not treat the NEXT block's `"key": "value"` pairs
474
+ # as this object's (IN-04 read the engine block's `mode`/`engine_dir` as vanished
475
+ # pre-existing files).
476
+ g_json_object_strict() {
477
+ awk -v name="$2" '
478
+ index($0, "\"" name "\"") && /:[[:space:]]*\{/ {
479
+ if ($0 ~ /\{[[:space:]]*\}/) next # empty object: nothing inside
480
+ inf = 1; next
481
+ }
482
+ inf && /^[[:space:]]*\}/ { inf = 0 }
483
+ inf && /"/ {
484
+ line = $0
485
+ sub(/^[[:space:]]*"/, "", line)
486
+ p = line; sub(/".*/, "", p)
487
+ h = line; sub(/^[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
488
+ if (p != "") print p "\t" h
489
+ }
490
+ ' "$1"
491
+ }
492
+
493
+ check_in_04() {
494
+ if [ "$ENGINE_MODE" = "global" ] && [ ! -s "$INSTALLED" ]; then
495
+ printf 'global engine mode — no per-repo install record\n'
496
+ return 3
497
+ fi
498
+ local p h n=0 bad=0 files
499
+ files=$(g_installed_files_strict "$INSTALLED" | cut -f1)
500
+ while IFS=$'\t' read -r p h; do
501
+ [ -n "$p" ] || continue
502
+ n=$((n + 1))
503
+ if [ ! -e "$ROOT/$p" ]; then printf 'pre-existing file vanished: %s\n' "$p"; bad=1; continue; fi
504
+ if printf '%s\n' "$files" | grep -qxF "$p"; then
505
+ printf 'pre-existing file was taken over by the install: %s\n' "$p"; bad=1
506
+ fi
507
+ done < <(g_json_object_strict "$INSTALLED" refused)
508
+ printf '%d pre-existing file(s) left untouched\n' "$n"
509
+ [ "$bad" -eq 0 ]
510
+ }
511
+
512
+ check_in_02() {
513
+ # The W1 semantic split (§2.2). Clause 1 - the per-repo mode: hash every recorded file,
514
+ # the practice pin rides along - is byte-for-byte what this row has always done, and a
515
+ # fresh install of any class keeps it (installed.json carries no engine: block).
516
+ # Clause 2 - engine.mode=global with file entries remaining: hash what IS recorded;
517
+ # an entry under the engine paths (.goblin/bin, .goblin/manifest, .goblin/bans,
518
+ # .goblin/automations, .goblin/roles.yaml) that is still present is hashed too
519
+ # (harmless), and one that is MISSING is not drift - the engine is no longer the repo's.
520
+ # Clause 3 - global with no file entries: the declaration + practice pin only; never
521
+ # FAIL for absence of install artifacts (a declaration-only repo is not born RED).
522
+ # The engine hash itself is printed in the footer on every run (see the footer, below);
523
+ # this row proves the REPO-LOCAL bytes, and a global engine answers elsewhere.
524
+ local p h cur n=0 bad=0 pin="" detail=""
525
+ while IFS=$'\t' read -r p h; do
526
+ [ -n "$p" ] || continue
527
+ if [ "$ENGINE_MODE" = "global" ]; then
528
+ case "$p" in
529
+ .goblin/bin/*|.goblin/manifest/*|.goblin/bans/*|.goblin/automations/*|.goblin/roles.yaml)
530
+ [ -e "$ROOT/$p" ] || continue ;; # engine removed by design: not drift
531
+ # W4a (§5.3/§5.2): the procedure tier is EMITTED per platform, not carried in the
532
+ # repo - a recorded .hermes/skills/ file absent from disk is the tier living at its
533
+ # emitted paths, not drift. A copy that IS present must still hash true (a stale or
534
+ # hand-edited shadow is exactly what --unshadow/doctor exist to surface).
535
+ .hermes/skills/*)
536
+ [ -e "$ROOT/$p" ] || continue ;;
537
+ esac
538
+ fi
539
+ n=$((n + 1))
540
+ cur=$(g_sha256_file "$ROOT/$p")
541
+ if [ "$cur" != "$h" ]; then
542
+ printf 'drift: %s recorded %s now %s\n' "$p" "$h" "$cur"
543
+ bad=1
544
+ fi
545
+ done < <(g_installed_files_strict "$INSTALLED")
546
+ if [ "$ENGINE_MODE" = "global" ] && [ "$n" -eq 0 ]; then
547
+ detail="0 repo-local files hashed (global engine mode)"
548
+ else
549
+ [ "$n" -gt 0 ] || { printf 'installed.json lists no files\n'; return 1; }
550
+ fi
551
+ # The practice pin rides on this row (R6 section 2.1 requires the pin; section 5 has no row of
552
+ # its own for it). Both are the same statement: a recorded hash still matches the file on disk.
553
+ local pin=""
554
+ if [ -n "$PRACTICE" ] && [ -n "$PRACTICE_SHA" ]; then
555
+ if [ ! -e "$PRACTICE" ]; then
556
+ printf 'practice standard not found: %s\n' "$PRACTICE"; bad=1; pin=" | practice missing"
557
+ else
558
+ cur=$(g_sha256_file "$PRACTICE")
559
+ if [ "$cur" != "$PRACTICE_SHA" ]; then
560
+ printf 'practice standard edited: %s\n recorded %s\n now %s\n' "$PRACTICE" "$PRACTICE_SHA" "$cur"
561
+ # The remedy, in the place the operator actually meets the failure. The pin exists to
562
+ # catch a SILENT edit, so re-pinning is a deliberate act: one explicit command, which
563
+ # prints the old and new hash (docs/CONTRACTS.md, "An edited standard is not a dead end").
564
+ printf ' if the edit is yours and intended, re-pin it deliberately:\n'
565
+ printf ' goblin-install --target %s --re-pin\n' "$ROOT"
566
+ bad=1; pin=" | practice EDITED"
567
+ else
568
+ pin=" | practice pin ok"
569
+ fi
570
+ fi
571
+ fi
572
+ if [ "$ENGINE_MODE" = "global" ] && [ "$n" -eq 0 ]; then
573
+ printf '%s%s\n' "$detail" "${pin:+ | ${pin# | }}"
574
+ [ "$bad" -eq 0 ]
575
+ return
576
+ fi
577
+ printf '%d installed files hashed%s\n' "$n" "$pin"
578
+ [ "$bad" -eq 0 ]
579
+ }
580
+
581
+ check_hp_03() {
582
+ # Every gate-bearing line inside the HANDOFF's `Gates` section carries `measured <date>`.
583
+ # Anchored on the gate names `.goblin/goblin.yaml` DECLARES (GT-01's source of truth), not on
584
+ # a hardcoded keyword list: the shipped gates are named `commit` and `todo_ceiling`, and the
585
+ # old list `(tsc|build|hex|safelist|n/n)` matched neither, so on a fresh install the only line
586
+ # it could see was the template's own "Example of the required form" prose. Measured at
587
+ # 43f7f69: strip the date from the REAL gate line -> PASS; strip it from the example -> FAIL.
588
+ # The row was testing the template, not the artifact (G8-2).
589
+ #
590
+ # A gate line is a line inside the section that carries `=` and either names a declared gate or
591
+ # carries a gate-shaped number. A line whose text says "example of the required form" is
592
+ # template prose, not a gate number, and is skipped. n == 0 is a FAIL: a Gates section with no
593
+ # gate line at all (which is what is left when only the example remains) is not a green row.
594
+ local names out rc n detail
595
+ names=$(g_yaml_gates "$CONFIG" | cut -f1 | tr '\n' ' ')
596
+ names=${names% }
597
+ rc=0
598
+ out=$(awk -v names="$names" '
599
+ function hasname(line, k, i, a, m) {
600
+ m = split(line, a, /[^A-Za-z0-9_./-]+/)
601
+ for (i = 1; i <= m; i++) if (a[i] == k) return 1
602
+ return 0
603
+ }
604
+ BEGIN {
605
+ bad = 0; n = 0
606
+ m = split(names, dn, " ")
607
+ for (i = 1; i <= m; i++) if (dn[i] != "") decl[dn[i]] = 1
608
+ }
609
+ tolower($0) ~ /^#{2,3}[[:space:]]+[^[:alpha:]]*gates?([^[:alpha:]]|$)/ { g = 1; next }
610
+ g && /^#{1,3}[[:space:]]/ { g = 0 }
611
+ !g { next }
612
+ tolower($0) ~ /example of the required form/ { next }
613
+ $0 !~ /=/ { next }
614
+ {
615
+ hit = 0
616
+ if ($0 ~ /(tsc|build|hex|safelist|[0-9]+\/[0-9]+)[^=]*=/) hit = 1
617
+ if (!hit) for (k in decl) if (hasname($0, k)) { hit = 1; break }
618
+ if (hit) {
619
+ n++
620
+ if ($0 !~ /measured [0-9]{4}-[0-9]{2}-[0-9]{2}/) {
621
+ print "a gate line carries no measured date: " $0
622
+ bad = 1
623
+ }
624
+ }
625
+ }
626
+ END {
627
+ if (n == 0) {
628
+ print "no gate line inside the Gates section (declared gates: " (names == "" ? "none" : names) ")"
629
+ bad = 1
630
+ }
631
+ printf "#count %d\n", n
632
+ exit bad
633
+ }
634
+ ' "$ROOT/HANDOFF.md") || rc=$?
635
+ n=$(printf '%s\n' "$out" | sed -n 's/^#count //p')
636
+ detail=$(printf '%s\n' "$out" | grep -v '^#count ' || true)
637
+ if [ "$rc" -ne 0 ]; then printf '%s\n' "$detail"; return 1; fi
638
+ printf '%d gate line(s) inside the Gates section, each with a measured date%s\n' \
639
+ "${n:-0}" "${names:+ (anchored on the declared gate name(s): $names)}"
640
+ return 0
641
+ }
642
+
643
+ check_hp_05() {
644
+ local tok found=0 head
645
+ head=$(cd "$ROOT" && git rev-parse HEAD 2>/dev/null)
646
+ for tok in $(grep -oE '[0-9a-f]{7,40}' "$ROOT/HANDOFF.md" 2>/dev/null | sort -u); do
647
+ ( cd "$ROOT" && git rev-parse --verify --quiet "$tok^{commit}" >/dev/null 2>&1 ) || continue
648
+ ( cd "$ROOT" && git merge-base --is-ancestor "$tok" HEAD 2>/dev/null ) || continue
649
+ printf 'names %s (ancestor of HEAD %s)\n' "$tok" "${head:0:7}"
650
+ found=1
651
+ break
652
+ done
653
+ [ "$found" -eq 1 ] || printf 'HANDOFF.md names no commit that exists in this repo\n'
654
+ [ "$found" -eq 1 ]
655
+ }
656
+
657
+ check_gt_01() {
658
+ # Every `- name:` under `gates:` is a DECLARED gate, and every declared gate must carry a
659
+ # `cmd:`. Counting only the name+cmd PAIRS (what this row did) let a gate lose its `cmd:` -
660
+ # deleted, blanked or re-indented - and the row still reported the survivors as
661
+ # `1 declared gate(s)`: the gate vanished in silence (G8-3). The name reader is the one that
662
+ # keeps it visible, so the missing `cmd:` is a FAIL here, not a pass.
663
+ local n=0 bad=0 name cmd
664
+ while IFS=$'\t' read -r name cmd; do
665
+ [ -n "$name" ] || continue
666
+ n=$((n + 1))
667
+ if [ -z "$cmd" ]; then
668
+ printf 'declared gate "%s" has no cmd - it is declared and never runs\n' "$name"
669
+ bad=1
670
+ fi
671
+ done < <(g_yaml_gates_all "$CONFIG")
672
+ [ "$n" -ge 1 ] || { printf 'no gate declared in %s\n' "$CONFIG"; return 1; }
673
+ [ "$bad" -eq 0 ] || return 1
674
+ printf '%d declared gate(s), each with a cmd\n' "$n"
675
+ return 0
676
+ }
677
+
678
+ # run the declared gates; optionally write the one-line measured numbers
679
+ run_gates() {
680
+ local write_line="$1" name cmd rc bad=0 line=""
681
+ while IFS=$'\t' read -r name cmd; do
682
+ [ -n "$name" ] || continue
683
+ ( cd "$ROOT" && bash -c "$cmd" ) >/dev/null 2>&1
684
+ rc=$?
685
+ line="$line $name=$rc"
686
+ [ "$rc" -eq 0 ] || { printf 'gate %s: %s -> exit %d\n' "$name" "$cmd" "$rc"; bad=1; }
687
+ done < <(g_yaml_gates "$CONFIG")
688
+ if [ "$write_line" = "yes" ]; then
689
+ local rname rcmd rval
690
+ rname=$(g_yaml_block_scalar "$CONFIG" ratchet name)
691
+ rcmd=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
692
+ if [ -n "$rname" ] && [ -n "$rcmd" ]; then
693
+ rval=$( cd "$ROOT" && bash -c "$rcmd" 2>/dev/null | tr -d '[:space:]' )
694
+ line="$line · $rname $rval (ceiling $(g_yaml_block_scalar "$CONFIG" ratchet ceiling))"
695
+ fi
696
+ printf '%s · measured %s\n' "${line# }" "$(date +%F)" > "$ROOT/.goblin/last-gate-line"
697
+ cat "$ROOT/.goblin/last-gate-line"
698
+ fi
699
+ return $bad
700
+ }
701
+
702
+ check_gt_02() { run_gates yes; }
703
+
704
+ check_gt_04() {
705
+ local n c ceil
706
+ n=$(g_yaml_block_scalar "$CONFIG" ratchet name)
707
+ c=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
708
+ ceil=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
709
+ [ -n "$n" ] || { printf 'ratchet.name missing\n'; return 1; }
710
+ [ -n "$c" ] || { printf 'ratchet.cmd missing\n'; return 1; }
711
+ case "$ceil" in ''|*[!0-9]*) printf 'ratchet.ceiling must be numeric, got %s\n' "$ceil"; return 1 ;; esac
712
+ printf 'ratchet %s ceiling %s\n' "$n" "$ceil"
713
+ return 0
714
+ }
715
+
716
+ check_gt_05() {
717
+ local n c ceil val old prev="" note=""
718
+ n=$(g_yaml_block_scalar "$CONFIG" ratchet name)
719
+ c=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
720
+ ceil=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
721
+ [ -n "$n" ] && [ -n "$c" ] || { printf 'no ratchet declared for this class - skipped\n'; return 3; }
722
+ val=$( cd "$ROOT" && bash -c "$c" 2>/dev/null | tr -d '[:space:]' )
723
+ case "$val" in ''|*[!0-9]*) printf 'ratchet cmd produced a non-number: %s\n' "$val"; return 1 ;; esac
724
+ if [ -f "$ROOT/.goblin/ratchet-last" ]; then prev=$(cat "$ROOT/.goblin/ratchet-last"); fi
725
+ if [ -n "$prev" ] && [ "$prev" -lt "$val" ]; then
726
+ note="old $prev + $((val - prev)) new = $val"
727
+ else
728
+ note="$n $val (ceiling $ceil)"
729
+ fi
730
+ printf '%s\n' "$val" > "$ROOT/.goblin/ratchet-last"
731
+ if [ "$val" -gt "$ceil" ]; then printf '%s over ceiling %s\n' "$note" "$ceil"; return 1; fi
732
+ printf '%s\n' "$note"
733
+ return 0
734
+ }
735
+
736
+ check_hs_01() {
737
+ local d="$GOBLIN_HARNESS_DIR" f base asserting=0 reporting=0 bad=0
738
+ if [ -z "$d" ]; then
739
+ # A declared harness dir that is missing is a FAIL when the class scaffolds one; a class
740
+ # that ships none skips with a reason. Keying the skip off the PATH let a one-line config
741
+ # edit switch both HS rows off (D7).
742
+ if [ "$GOBLIN_HARNESS_STATE" = "absent" ]; then
743
+ printf 'harness_dir %s is declared and class %s scaffolds it, but the directory is absent\n' \
744
+ "$GOBLIN_HARNESS_REL" "$CLASS"
745
+ return 1
746
+ fi
747
+ printf 'no harness dir (%s absent, class %s scaffolds none) - nothing to assert\n' \
748
+ "$GOBLIN_HARNESS_REL" "$CLASS"
749
+ return 3
750
+ fi
751
+ for f in "$d"/*; do
752
+ [ -f "$f" ] || continue
753
+ base=$(basename "$f")
754
+ if grep -q 'assert(' "$f" && grep -q 'process\.exit' "$f"; then
755
+ asserting=$((asserting + 1))
756
+ else
757
+ reporting=$((reporting + 1))
758
+ printf 'not an asserting harness (report utility): %s\n' "$base"
759
+ fi
760
+ done
761
+ printf '%d asserting harness(es), %d report utility(ies)\n' "$asserting" "$reporting"
762
+ [ "$asserting" -gt 0 ] || { printf 'harness dir %s holds no asserting harness\n' "$GOBLIN_HARNESS_REL"; bad=1; }
763
+ [ "$bad" -eq 0 ]
764
+ }
765
+
766
+ check_hs_02() {
767
+ local env cmd commit d tmp bad=0 f rc base name qname pat
768
+ env=$(g_yaml_block_scalar "$CONFIG" replay env)
769
+ cmd=$(g_yaml_block_scalar "$CONFIG" replay cmd)
770
+ commit=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" replay commit)")
771
+ [ -n "$env" ] || { printf 'replay.env not declared\n'; return 1; }
772
+ [ -n "$cmd" ] || { printf 'replay.cmd not declared\n'; return 1; }
773
+ # Z1-4: the DECLARED command has to be what runs the harness. Until this fix it was read only
774
+ # to assert it was non-empty, and the loop below ran `node <file>` itself - so the command and
775
+ # its `{name}` placeholder were decorative (`grep -rn '{name}' tests/` was 0 hits, and setting
776
+ # `replay.cmd: false` changed no verdict). A documented mechanism that never executes is the
777
+ # same species as the regression this repo already shipped once.
778
+ # * a command that does not interpolate {name} cannot be running the harness it names, so
779
+ # nothing is replayed and "every harness was RED" would be a statement about a command
780
+ # that never ran one (the defanged-probe shape, docs/LIMITS.md #28/W5-12);
781
+ # * a command that cannot be executed at all (exit 126/127) FAILS rather than counting as a
782
+ # RED harness - otherwise a typo'd interpreter would make every harness look proven.
783
+ pat='{name}'
784
+ case "$cmd" in
785
+ *"$pat"*) ;;
786
+ *) printf 'replay.cmd declares no %s: %s\n' "$pat" "$cmd"
787
+ printf 'the declared command cannot be running the harness it names, so nothing was replayed\n'
788
+ return 1 ;;
789
+ esac
790
+ if [ -z "$commit" ] || ! git -C "$ROOT" rev-parse --verify --quiet "$commit^{commit}" >/dev/null; then
791
+ printf 'no pinned pre-change commit yet (replay.commit empty or unknown) - REPLAY not provable\n'
792
+ return 3
793
+ fi
794
+ d="$GOBLIN_HARNESS_DIR"
795
+ if [ -z "$d" ]; then
796
+ if [ "$GOBLIN_HARNESS_STATE" = "absent" ]; then
797
+ printf 'harness_dir %s is declared and class %s scaffolds it, but the directory is absent\n' \
798
+ "$GOBLIN_HARNESS_REL" "$CLASS"
799
+ return 1
800
+ fi
801
+ printf 'no harness dir to replay\n'; return 3
802
+ fi
803
+ tmp=$(mktemp -d)
804
+ if ! git -C "$ROOT" worktree add -q --detach "$tmp/wt" "$commit" 2>/dev/null; then
805
+ rm -rf "$tmp"; printf 'cannot create a worktree at %s\n' "$commit"; return 1
806
+ fi
807
+ mkdir -p "$tmp/wt/$GOBLIN_HARNESS_REL"
808
+ cp -a "$d/." "$tmp/wt/$GOBLIN_HARNESS_REL/"
809
+ for f in "$tmp/wt/$GOBLIN_HARNESS_REL"/*.mjs; do
810
+ [ -e "$f" ] || continue
811
+ base=$(basename "$f")
812
+ name=${base%.mjs}
813
+ # Z2-3: the name is SHELL-QUOTED before it is substituted, because it now reaches a shell.
814
+ # This row runs the DECLARED command rather than `node "$f"` (Z1-4), so a file in the harness
815
+ # dir - a name the row does not control - is part of the command text `bash -c` parses.
816
+ # Unquoted, a name carrying `;`, `#`, a space or a glob was read as shell syntax: measured, a
817
+ # file named `z2;true;#.mjs` produced TWO commands and the trailing `true` decided the exit
818
+ # code. The outcome stayed fail-closed (a GREEN "harness" makes the row FAIL), but it is the
819
+ # same surface class as the G8-1 injection, so it is quoted rather than only documented.
820
+ qname=$(printf '%q' "$name")
821
+ ( cd "$tmp/wt" && env "$env"="$commit" bash -c "${cmd//$pat/$qname}" ) >/dev/null 2>&1
822
+ rc=$?
823
+ case "$rc" in
824
+ 0)
825
+ printf '%s was GREEN on the pre-change tree %s - proves nothing\n' "$base" "${commit:0:7}"
826
+ bad=1
827
+ ;;
828
+ 126|127)
829
+ printf '%s: the declared replay.cmd could not be executed (exit %d): %s\n' "$base" "$rc" "$cmd"
830
+ bad=1
831
+ ;;
832
+ esac
833
+ done
834
+ git -C "$ROOT" worktree remove --force "$tmp/wt" >/dev/null 2>&1
835
+ rm -rf "$tmp"
836
+ [ "$bad" -eq 0 ] && printf 'every harness was RED at %s (run as: %s)\n' "${commit:0:7}" "$cmd"
837
+ return $bad
838
+ }
839
+
840
+ check_cm_03() {
841
+ local n
842
+ n=$(cd "$ROOT" && git status --porcelain 2>/dev/null | wc -l | tr -d '[:space:]')
843
+ printf '%s dirty entr(y|ies), max_dirty %s\n' "$n" "$MAX_DIRTY"
844
+ [ "$n" -le "$MAX_DIRTY" ]
845
+ }
846
+
847
+ resolve_role_models() {
848
+ # prints "role<TAB>profile<TAB>provider<TAB>model<TAB>effort" for each lane of a role
849
+ local role="$1" f="$SELF_DIR/../roles.yaml"
850
+ [ -f "$f" ] || f="$ROOT/.goblin/roles.yaml"
851
+ local profiles
852
+ profiles=$(g_yaml_block_scalar "$f" "role-$role" profiles)
853
+ [ -n "$profiles" ] || profiles=$(g_yaml_block_scalar "$f" "$role" profiles)
854
+ local p prov model eff
855
+ for p in $(printf '%s' "$profiles" | tr -d '[]' | tr ',' ' '); do
856
+ prov=$(g_models_field "$MODELS_FILE" "$p" provider)
857
+ model=$(g_models_field "$MODELS_FILE" "$p" model)
858
+ eff=$(g_models_field "$MODELS_FILE" "$p" effort)
859
+ printf '%s\t%s\t%s\t%s\t%s\n' "$role" "$p" "${prov:-?}" "${model:-?}" "${eff:-?}"
860
+ done
861
+ }
862
+
863
+ check_md_02() {
864
+ local code_line panel_line judge_line code_fam panel_fam judge_fam
865
+ if [ ! -f "$MODELS_FILE" ]; then
866
+ printf 'no model mapping configured (%s absent) - advisory\n' "$MODELS_FILE"
867
+ return 2
868
+ fi
869
+ code_line=$(resolve_role_models code | head -n 1)
870
+ panel_line=$(resolve_role_models review-panel | head -n 1)
871
+ judge_line=$(resolve_role_models judge | head -n 1)
872
+ code_fam=$(printf '%s' "$code_line" | cut -f4)
873
+ panel_fam=$(printf '%s' "$panel_line" | cut -f4)
874
+ judge_fam=$(printf '%s' "$judge_line" | cut -f4)
875
+ if [ -z "$code_line" ] || [ -z "$panel_line" ]; then
876
+ printf 'roles.yaml did not resolve both lanes - advisory\n'
877
+ return 2
878
+ fi
879
+ if [ "$code_fam" = "$panel_fam" ]; then
880
+ printf 'code lane and review lane both resolve to %s (same family) - goblin-stack cannot change this\n' "$code_fam"
881
+ else
882
+ printf 'code lane %s, review lane %s - different family\n' "$code_fam" "$panel_fam"
883
+ fi
884
+ # W5-6: the JUDGE lane's family is compared too. JG-02 proves the declared profile NAMES are
885
+ # disjoint and nothing more, so a `judge:` profile mapped to the author's own model passed it:
886
+ # the judge was distinct by name only, which is exactly what the judge role's claim cannot
887
+ # tolerate. The comparison is the same one the panel gets, and the row stays ADVISORY (`return
888
+ # 2`): goblin-stack cannot choose the fleet's models, it can only make the state loud. Measured
889
+ # on this box, every profile the mapping names resolves to ONE model - recorded, not faked, in
890
+ # docs/LIMITS.md #38.
891
+ if [ -z "$judge_line" ]; then
892
+ printf 'the judge lane does not resolve - add a `judge:` entry beside the code lane in %s\n' "$MODELS_FILE"
893
+ return 2
894
+ fi
895
+ case "$judge_fam" in
896
+ ''|'?'|unknown)
897
+ printf 'the judge lane resolves to no model in %s - add a `judge:` entry beside the code lane\n' "$MODELS_FILE"
898
+ return 2 ;;
899
+ esac
900
+ if [ "$judge_fam" = "$code_fam" ]; then
901
+ printf 'the judge lane resolves to %s, the SAME family as the code lane - JG-02 proves only that the declared profile names differ\n' "$judge_fam"
902
+ else
903
+ printf 'judge lane %s, code lane %s - different family\n' "$judge_fam" "$code_fam"
904
+ fi
905
+ return 2
906
+ }
907
+
908
+ check_pg_01() {
909
+ local f head n=0 bad=0
910
+ for f in "$ROOT"/reviews/*.md; do
911
+ [ -e "$f" ] || continue
912
+ n=$((n + 1))
913
+ head=$(sed -n 's/^head:[[:space:]]*//p' "$f" | head -n 1)
914
+ if [ -z "$head" ]; then printf '%s: no head: line\n' "$(basename "$f")"; bad=1; continue; fi
915
+ if ! ( cd "$ROOT" && git rev-list --all 2>/dev/null | grep -q "^$head" ); then
916
+ printf '%s: head %s is not in git rev-list --all\n' "$(basename "$f")" "$head"
917
+ bad=1
918
+ fi
919
+ done
920
+ printf '%d review note(s)\n' "$n"
921
+ [ "$bad" -eq 0 ]
922
+ }
923
+
924
+ check_pg_02() {
925
+ local f stakes lanes n=0 bad=0
926
+ for f in "$ROOT"/reviews/*.md; do
927
+ [ -e "$f" ] || continue
928
+ n=$((n + 1))
929
+ stakes=$(sed -n 's/^stakes:[[:space:]]*//p' "$f" | head -n 1)
930
+ case "$stakes" in
931
+ S[0-4]) ;;
932
+ *) printf '%s: stakes must be S0..S4, got "%s"\n' "$(basename "$f")" "$stakes"; bad=1; continue ;;
933
+ esac
934
+ case "$stakes" in
935
+ S2|S3|S4)
936
+ if ! awk '/^checks-run:/{c=1;next} c&&/^[a-z-]+:/{c=0} c&&NF{found=1} END{exit !found}' "$f"; then
937
+ printf '%s: stakes %s requires a non-empty checks-run: section\n' "$(basename "$f")" "$stakes"
938
+ bad=1
939
+ fi ;;
940
+ esac
941
+ case "$stakes" in
942
+ S3|S4)
943
+ lanes=$(sed -n 's/^lanes:[[:space:]]*//p' "$f" | head -n 1)
944
+ if [ -z "$lanes" ] || [ "$lanes" = "1" ]; then
945
+ printf '%s: stakes %s requires a second lane (lanes: 2 or more)\n' "$(basename "$f")" "$stakes"
946
+ bad=1
947
+ fi ;;
948
+ esac
949
+ done
950
+ printf '%d review note(s) classified\n' "$n"
951
+ [ "$bad" -eq 0 ]
952
+ }
953
+
954
+ check_pg_03() {
955
+ local f base head rec now n=0 bad=0
956
+ for f in "$ROOT"/reviews/*.md; do
957
+ [ -e "$f" ] || continue
958
+ n=$((n + 1))
959
+ base=$(sed -n 's/^base:[[:space:]]*//p' "$f" | head -n 1)
960
+ head=$(sed -n 's/^head:[[:space:]]*//p' "$f" | head -n 1)
961
+ rec=$(sed -n 's/^patch-id:[[:space:]]*//p' "$f" | head -n 1)
962
+ if [ -z "$base" ] || [ -z "$head" ] || [ -z "$rec" ]; then
963
+ printf '%s: needs base:, head: and patch-id:\n' "$(basename "$f")"; bad=1; continue
964
+ fi
965
+ now=$( cd "$ROOT" && git diff "$base".."$head" 2>/dev/null | git patch-id --stable 2>/dev/null | awk '{print $1}' )
966
+ if [ "$now" != "$rec" ]; then
967
+ printf '%s: recorded patch-id %s, now %s\n' "$(basename "$f")" "$rec" "${now:-<none>}"
968
+ bad=1
969
+ fi
970
+ done
971
+ printf '%d review note(s) patch-id checked\n' "$n"
972
+ [ "$bad" -eq 0 ]
973
+ }
974
+
975
+ check_pg_05() {
976
+ # PG-05 - "No required check that self-skips". The old body counted only "every step is
977
+ # guarded", so the REAL shape passed: in the estate's one existing workflow a deliberately
978
+ # UNGUARDED credential step decides whether the guarded compile step runs, so
979
+ # `guarded < steps` and the
980
+ # row reported PASS on the workflow it exists to catch (G8 section 3, re-measured V3-4). G6
981
+ # section A.6 named the mechanisable tell - a job whose GATE STEP is conditional can reach its
982
+ # end without running the gate while the required check still reports Success - so the row now
983
+ # carries three clauses per job:
984
+ # (a) the job declares at least one run:/uses: step (otherwise the check runs nothing);
985
+ # (b) the JOB carries no `if:` (otherwise the whole required check self-skips);
986
+ # (c) no STEP carries an `if:` (otherwise that step - possibly the gate step - self-skips,
987
+ # and GitHub reports a skipped job as Success: docs S1/S2, RISKS.md K7).
988
+ # It is a TEXT reading, deliberately strict, and says so in its own why-cell: a `#` inside a
989
+ # quoted string is read as a comment, a flow-style `jobs:` mapping is refused rather than read
990
+ # as clean, and a condition that is genuinely needed belongs in the command or in a second
991
+ # job that is not the required check.
992
+ local f n=0 bad=0
993
+ for f in "$ROOT"/.github/workflows/*.yml "$ROOT"/.github/workflows/*.yaml; do
994
+ [ -e "$f" ] || continue
995
+ n=$((n + 1))
996
+ awk -v FILE="$(basename "$f")" '
997
+ function closejob() {
998
+ if (jobname == "") return
999
+ if (steps == 0) { printf "%s: job \"%s\" declares no run:/uses: step - the required check runs nothing and still reports Success\n", FILE, jobname; bad = 1 }
1000
+ if (jobif > 0) { printf "%s: job \"%s\" carries a job-level if: (%d) - the whole required check self-skips and reports Success\n", FILE, jobname, jobif; bad = 1 }
1001
+ if (stepif > 0) { printf "%s: job \"%s\" has %d conditional step(s) - a skipped step reports Success, and a skipped gate step green-lights a commit whose gate never ran\n", FILE, jobname, stepif; bad = 1 }
1002
+ jobname = ""
1003
+ }
1004
+ {
1005
+ line = $0
1006
+ sub(/[ \t]+#.*$/, "", line) # blank a trailing comment
1007
+ if (line ~ /^[ \t]*#/) next
1008
+ if (line ~ /^[ \t]*$/) next
1009
+ if (!injobs) {
1010
+ if (line ~ /^jobs:[ \t]*$/) { injobs = 1; jind = -1 }
1011
+ else if (line ~ /^jobs:[ \t]*[^ \t]/) { printf "%s: the jobs: block is a flow mapping - it cannot be read here, so it is refused rather than read as clean\n", FILE; bad = 1; injobs = 1; jind = -1 }
1012
+ next
1013
+ }
1014
+ lead = match(line, /[^ \t]/) - 1
1015
+ if (jind >= 0 && lead == jind && line !~ /^[ \t]*-/) {
1016
+ closejob()
1017
+ jobname = line; sub(/^[ \t]*/, "", jobname); sub(/[ \t]*:.*/, "", jobname)
1018
+ steps = 0; stepregion = 0; jobif = 0; stepif = 0
1019
+ next
1020
+ }
1021
+ if (jind < 0) {
1022
+ jind = lead
1023
+ jobname = line; sub(/^[ \t]*/, "", jobname); sub(/[ \t]*:.*/, "", jobname)
1024
+ steps = 0; stepregion = 0; jobif = 0; stepif = 0
1025
+ next
1026
+ }
1027
+ if (lead <= jind) next
1028
+ if (line ~ /^[ \t]*-/) stepregion = 1
1029
+ if (line ~ /(^|[ \t-])(run|uses):/) { steps++; next }
1030
+ if (line ~ /^[ \t]*-?[ \t]*if:/) { if (stepregion) stepif++; else jobif++ }
1031
+ }
1032
+ END {
1033
+ closejob()
1034
+ if (!injobs) { printf "%s: no jobs: block - the workflow declares no check at all\n", FILE; bad = 1 }
1035
+ exit bad
1036
+ }
1037
+ ' "$f" || bad=1
1038
+ done
1039
+ if [ "$n" -eq 0 ]; then
1040
+ printf '0 workflow file(s) - no required check here to skip (vacuous by design: nothing declares one)\n'
1041
+ else
1042
+ printf '%d workflow file(s) checked for a self-skipping required check\n' "$n"
1043
+ fi
1044
+ [ "$bad" -eq 0 ]
1045
+ }
1046
+
1047
+ check_pg_06() {
1048
+ # PG-06 (G6 section A.6, row 2) - "The gate CI runs is the gate the project declares." The
1049
+ # failure mode is R5 section 1.1 measured: review and CI and goblin-verify reporting different
1050
+ # truths about one SHA, so a green required check says nothing about the gate the project
1051
+ # actually declares. The declared set comes from g_yaml_gates - GT-01's own reader, reused
1052
+ # rather than re-derived, so a gate cannot vanish from the comparison in silence (G8-3).
1053
+ # A workflow satisfies the row when the whole declared set is RUN: either the verifier with no
1054
+ # `--only` (a full run executes every declared gate through GT-02), or each declared gate
1055
+ # command appearing verbatim. Comments are blanked first, so a commented-out verifier call is
1056
+ # not a run, and key lines other than `run:` are dropped, so a command sitting in a `name:`
1057
+ # cannot satisfy it. Partial by construction: it proves the gate is INVOKED in a file under
1058
+ # `.github/workflows/`, never that GitHub marks that job required, and never that the invoker
1059
+ # is the job the forge waits on.
1060
+ local f n=0 bad=0 gname gcmd ngates=0 gates="" runtext miss="" nrun nc
1061
+ while IFS=$'\t' read -r gname gcmd; do
1062
+ [ -n "$gname" ] || continue
1063
+ ngates=$((ngates + 1))
1064
+ [ -n "$gcmd" ] && gates="$gates$gcmd"$'\n'
1065
+ done < <(g_yaml_gates "$CONFIG")
1066
+ for f in "$ROOT"/.github/workflows/*.yml "$ROOT"/.github/workflows/*.yaml; do
1067
+ [ -e "$f" ] || continue
1068
+ n=$((n + 1))
1069
+ runtext=$(awk '
1070
+ {
1071
+ line = $0
1072
+ sub(/[ \t]+#.*$/, "", line)
1073
+ if (line ~ /^[ \t]*#/) next
1074
+ if (line ~ /^[ \t]*$/) next
1075
+ if (line ~ /^[ \t]*-?[ \t]*run:/) { print substr(line, index(line, "run:") + 4); next }
1076
+ if (line ~ /^[ \t]*-?[ \t]*[A-Za-z_][A-Za-z0-9_.-]*:/) next
1077
+ print line
1078
+ }
1079
+ ' "$f")
1080
+ if printf '%s\n' "$runtext" | grep -q 'goblin-verify' \
1081
+ && ! printf '%s\n' "$runtext" | grep -q 'goblin-verify[^&|;]*--only'; then
1082
+ printf '%s: runs the verifier, which executes every declared gate\n' "$(basename "$f")"
1083
+ continue
1084
+ fi
1085
+ # The verbatim path: compare on whitespace-normalised text, so a YAML line wrap or a dropped
1086
+ # `2>/dev/null` does not read as "not the declared gate".
1087
+ nrun=$(printf '%s\n' "$runtext" | tr '\n' ' ' | sed 's#2>/dev/null##g' | tr -s ' ')
1088
+ miss=""
1089
+ while IFS= read -r gcmd; do
1090
+ [ -n "$gcmd" ] || continue
1091
+ nc=$(printf '%s' "$gcmd" | sed 's#2>/dev/null##g' | tr -s ' ')
1092
+ printf '%s' "$nrun" | grep -qF -- "$nc" || miss="$miss [$gcmd]"
1093
+ done <<EOF
1094
+ $gates
1095
+ EOF
1096
+ if [ -n "$miss" ]; then
1097
+ printf '%s: runs no gate the project declares - declared but not run:%s\n' "$(basename "$f")" "$miss"
1098
+ bad=1
1099
+ else
1100
+ printf '%s: runs all %d declared gate(s) verbatim\n' "$(basename "$f")" "$ngates"
1101
+ fi
1102
+ done
1103
+ if [ "$n" -eq 0 ]; then
1104
+ printf 'no workflow file to check - CI runs no gate here\n'
1105
+ return 3
1106
+ fi
1107
+ printf '%d workflow file(s) compared with the %d declared gate(s)\n' "$n" "$ngates"
1108
+ [ "$bad" -eq 0 ]
1109
+ }
1110
+
1111
+ hash_runtime_data() {
1112
+ local p h out=""
1113
+ while read -r p; do
1114
+ [ -n "$p" ] || continue
1115
+ h=$(g_sha256_file "$ROOT/$p")
1116
+ out="$out$p=$h "
1117
+ done < <(g_yaml_list "$CONFIG" runtime_data)
1118
+ printf '%s' "${out% }"
1119
+ }
1120
+
1121
+ check_ds_01() {
1122
+ local before after bad=0 p
1123
+ # Clause the row's own rule states and the old body dropped: every declared path must be
1124
+ # git-ignored, or it is fixture, not runtime data (D12).
1125
+ while read -r p; do
1126
+ [ -n "$p" ] || continue
1127
+ ( cd "$ROOT" && git check-ignore -q -- "$p" ) \
1128
+ || { printf 'runtime_data path is not git-ignored: %s\n' "$p"; bad=1; }
1129
+ done < <(g_yaml_list "$CONFIG" runtime_data)
1130
+ before=$(hash_runtime_data)
1131
+ run_gates no >/dev/null 2>&1
1132
+ after=$(hash_runtime_data)
1133
+ printf 'before=%s\nafter=%s\n' "$before" "$after" > "$ROOT/.goblin/.ds-report"
1134
+ if [ "$before" != "$after" ]; then
1135
+ printf 'a gate run wrote runtime data: %s -> %s\n' "$before" "$after"
1136
+ return 1
1137
+ fi
1138
+ # Declaring nothing is not a pass: it is an unexercised rule, and it is reported as one.
1139
+ [ -n "$before" ] || { printf 'no runtime_data declared\n'; return 2; }
1140
+ [ "$bad" -eq 0 ] || return 1
1141
+ printf 'unchanged: %s\n' "$before"
1142
+ return 0
1143
+ }
1144
+
1145
+ check_ds_02() {
1146
+ [ -f "$ROOT/.goblin/.ds-report" ] || { printf 'no DS-01 report in this run\n'; return 1; }
1147
+ local b a
1148
+ b=$(sed -n 's/^before=//p' "$ROOT/.goblin/.ds-report")
1149
+ a=$(sed -n 's/^after=//p' "$ROOT/.goblin/.ds-report")
1150
+ [ "$b" = "$a" ] || { printf 'before/after differ\n'; return 1; }
1151
+ [ -f "$ROOT/.goblin/last-gate-line" ] || { printf 'no gate line to annotate\n'; return 0; }
1152
+ local line
1153
+ line=$(tr -d '\n' < "$ROOT/.goblin/last-gate-line")
1154
+ printf '%s · runtime_data %s\n' "$line" "${b:-<none declared>}" > "$ROOT/.goblin/last-gate-line"
1155
+ printf 'snapshot %s\n' "${b:-<none declared>}"
1156
+ return 0
1157
+ }
1158
+
1159
+ check_au_02() {
1160
+ # The dedup key must be a function of the report's CONTENT only. Recompute it from the
1161
+ # report's own repo+symptom and require the recorded value to equal it: a key derived from a
1162
+ # date, a run id or a counter dedups nothing, and this is the row that says so.
1163
+ local f n=0 bad=0 repo sym key want norm slug
1164
+ for f in "$ROOT"/reports/*/report.yaml; do
1165
+ [ -e "$f" ] || continue
1166
+ n=$((n + 1))
1167
+ slug=$(basename "$(dirname "$f")")
1168
+ repo=$(sed -n 's/^repo:[[:space:]]*//p' "$f" | head -n 1)
1169
+ sym=$(sed -n 's/^symptom:[[:space:]]*//p' "$f" | head -n 1)
1170
+ key=$(sed -n 's/^dedup_key:[[:space:]]*//p' "$f" | head -n 1)
1171
+ norm=$(printf '%s' "$sym" | tr 'A-Z' 'a-z' | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//')
1172
+ want="bug:${repo}:$(g_sha256_text "$norm" | cut -c1-12)"
1173
+ case "$key" in
1174
+ '') printf '%s: no dedup_key recorded\n' "$slug"; bad=1; continue ;;
1175
+ esac
1176
+ if [ "$key" != "$want" ]; then
1177
+ printf '%s: recorded key %s, recomputed %s\n' "$slug" "$key" "$want"; bad=1
1178
+ fi
1179
+ if printf '%s' "$key" | grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}'; then
1180
+ printf '%s: the key carries a date: %s\n' "$slug" "$key"; bad=1
1181
+ fi
1182
+ done
1183
+ [ "$n" -gt 0 ] || { printf 'no reports/*/report.yaml - nothing to dedup\n'; return 3; }
1184
+ printf '%d report(s): the key is content-only\n' "$n"
1185
+ [ "$bad" -eq 0 ]
1186
+ }
1187
+
1188
+ check_au_03() {
1189
+ # A reporter's write surface is reports/** plus cards. Two falsifiable clauses: the tree is
1190
+ # clean, and - when HEAD is a reporter commit - no path under the declared harness dir is in
1191
+ # it. The harness clause only applies to a commit that touches reports/, so a normal round's
1192
+ # commit is not mistaken for a reporter's.
1193
+ local dirty bad=0 touched=""
1194
+ [ -d "$ROOT/reports" ] || { printf 'no reports/ - no reporter run in this repo to audit\n'; return 3; }
1195
+ dirty=$(cd "$ROOT" && git status --porcelain 2>/dev/null | wc -l | tr -d '[:space:]')
1196
+ printf '%s dirty entr(y|ies), max_dirty %s\n' "$dirty" "$MAX_DIRTY"
1197
+ if ( cd "$ROOT" && git rev-parse --verify --quiet HEAD~1 >/dev/null 2>&1 ); then
1198
+ if ( cd "$ROOT" && git diff --name-only HEAD~1..HEAD 2>/dev/null | grep -q '^reports/' ); then
1199
+ touched=$( cd "$ROOT" && git diff --name-only HEAD~1..HEAD 2>/dev/null \
1200
+ | grep "^${GOBLIN_HARNESS_REL}/" || true )
1201
+ else
1202
+ printf 'HEAD is not a reporter commit - the harness clause does not apply\n'
1203
+ fi
1204
+ fi
1205
+ if [ -n "$touched" ]; then
1206
+ printf 'the reporter commit touched the harness: %s\n' "$(printf '%s' "$touched" | tr '\n' ' ')"
1207
+ bad=1
1208
+ fi
1209
+ [ "$dirty" -le "$MAX_DIRTY" ] || { printf 'the reporter left the tree dirty\n'; bad=1; }
1210
+ [ "$bad" -eq 0 ]
1211
+ }
1212
+
1213
+ check_sc_02() {
1214
+ # Clause 1 is a file rule; clause 2 is BEHAVIOURAL - it asks git's own matcher, so an ignore
1215
+ # rule that looks right but does not match still fails. `git check-ignore -q` accepts exactly
1216
+ # one pathname, so the body loops.
1217
+ local miss="" p
1218
+ if [ ! -f "$ROOT/.gitignore" ]; then
1219
+ if [ -f "$ROOT/package.json" ]; then
1220
+ printf 'no .gitignore in a repo with a package.json\n'; return 1
1221
+ fi
1222
+ printf 'no .gitignore and no package.json - there is no dependency surface here to protect\n'
1223
+ return 3
1224
+ fi
1225
+ grep -qE '^[[:space:]]*\.env(\*|/)?[[:space:]]*$' "$ROOT/.gitignore" \
1226
+ || miss="no bare .env rule"
1227
+ if [ -d "$ROOT/.git" ]; then
1228
+ for p in .env .env.local .env.production; do
1229
+ ( cd "$ROOT" && git check-ignore -q "$p" ) || miss="$miss not-ignored:$p"
1230
+ done
1231
+ else
1232
+ miss="$miss no-git-repository"
1233
+ fi
1234
+ if [ -n "$miss" ]; then printf 'the secret family is not ignored:%s\n' "$miss"; return 1; fi
1235
+ printf 'the whole secret family is ignored (.env, .env.local, .env.production proved by git)\n'
1236
+ return 0
1237
+ }
1238
+
1239
+ check_sc_03() {
1240
+ # Clause 1: a client-visible NAME that is secret-shaped. Clause 2: a known secret PREFIX in
1241
+ # real build output. The second clause is reported, never silently skipped: a repo with no
1242
+ # build output is told so on the line.
1243
+ local hits hits_all hits_n vs_keys vs_files out n=0 bad=0
1244
+ hits_all=$(sc_sources 'NEXT_PUBLIC_[A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASSWD|PRIVATE)')
1245
+ # W5-11: this line printed a CHARACTER count as a hit count - `${#hits}` on a multiline
1246
+ # string - so ONE matching line of 70 characters was reported as "70 client-visible
1247
+ # secret-shaped name(s)". The number is the count of matching LINES (capped at five lines of
1248
+ # evidence), which is what the word "name(s)" claims.
1249
+ hits_n=0
1250
+ [ -n "$hits_all" ] && hits_n=$(printf '%s\n' "$hits_all" | grep -c .)
1251
+ hits=$(printf '%s' "$hits_all" | head -n 5)
1252
+ if [ -n "$hits" ]; then
1253
+ printf 'client-visible secret-shaped name(s): %d\n%s\n' "$hits_n" "$hits"; bad=1
1254
+ fi
1255
+ out=""
1256
+ for d in $SEC_BUILD_OUTPUT; do
1257
+ [ -d "$ROOT/$d" ] || continue
1258
+ out="$out $(find "$ROOT/$d" -type f 2>/dev/null | head -n 200)"
1259
+ done
1260
+ for f in $out; do
1261
+ n=$((n + 1))
1262
+ grep -qE '(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{20,}\.)' "$f" 2>/dev/null \
1263
+ && { printf 'a secret-shaped literal is in build output: %s\n' "${f#"$ROOT"/}"; bad=1; }
1264
+ done
1265
+ if [ "$n" -eq 0 ]; then
1266
+ printf '%d client-visible secret-shaped name(s); no build output to scan for clause 2\n' "$hits_n"
1267
+ else
1268
+ printf '%d client-visible secret-shaped name(s); %d build-output file(s) scanned for secret literals\n' "$hits_n" "$n"
1269
+ fi
1270
+ [ "$bad" -eq 0 ]
1271
+ }
1272
+
1273
+ check_sc_04() {
1274
+ # A JS-written cookie needs `secure` + `samesite` in the SAME statement; a server-side set
1275
+ # needs `httpOnly` + `sameSite`. Same-statement only: a write spread over three lines is not
1276
+ # seen, and the row says so rather than implying it read the statement.
1277
+ local js srv bad=0 n=0 line
1278
+ js=$(sc_sources 'document\.cookie[[:space:]]*=')
1279
+ srv=$(sc_sources '(cookies\(\)\.set\(|res\.cookie\(|NextResponse\.cookie\()')
1280
+ if [ -n "$js" ]; then
1281
+ while IFS= read -r line; do
1282
+ n=$((n + 1))
1283
+ printf '%s' "$line" | grep -qiE 'secure' || { printf 'a JS cookie write has no secure flag: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
1284
+ printf '%s' "$line" | grep -qiE 'samesite' || { printf 'a JS cookie write has no samesite flag: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
1285
+ done <<EOF
1286
+ $js
1287
+ EOF
1288
+ fi
1289
+ if [ -n "$srv" ]; then
1290
+ while IFS= read -r line; do
1291
+ n=$((n + 1))
1292
+ printf '%s' "$line" | grep -qiE 'httponly' || { printf 'a server cookie set has no httpOnly: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
1293
+ printf '%s' "$line" | grep -qiE 'samesite' || { printf 'a server cookie set has no sameSite: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
1294
+ done <<EOF
1295
+ $srv
1296
+ EOF
1297
+ fi
1298
+ printf '%d cookie write(s) checked; a JS-written cookie is readable by any script - reported, not failed\n' "$n"
1299
+ [ "$bad" -eq 0 ]
1300
+ }
1301
+
1302
+ check_sc_05() {
1303
+ # Every write handler (POST|PUT|PATCH|DELETE) in the declared route dirs must CALL a validator,
1304
+ # or be named in .goblin/boundary-waivers. Partial by design: it proves a validator is called,
1305
+ # not that the schema is right.
1306
+ local d f rel n=0 bad=0 w=0
1307
+ for d in $SEC_WRITE_ROUTES; do
1308
+ [ -d "$ROOT/$d" ] || continue
1309
+ while IFS= read -r f; do
1310
+ [ -n "$f" ] || continue
1311
+ grep -qE 'export[[:space:]]+(async[[:space:]]+)?(function[[:space:]]+)?(const[[:space:]]+)?(POST|PUT|PATCH|DELETE)\b' "$f" || continue
1312
+ n=$((n + 1))
1313
+ rel="${f#"$ROOT"/}"
1314
+ if grep -qE 'safeParse|zod|valibot|yup|ajv|superstruct|validate\(' "$f"; then continue; fi
1315
+ if [ -f "$ROOT/.goblin/boundary-waivers" ] && grep -qE "^[[:space:]]*${rel}([[:space:]]|$)" "$ROOT/.goblin/boundary-waivers"; then
1316
+ w=$((w + 1)); continue
1317
+ fi
1318
+ printf 'a write handler validates nothing and is not waived: %s\n' "$rel"; bad=1
1319
+ done <<EOF
1320
+ $(find "$ROOT/$d" -type f \( -name 'route.ts' -o -name 'route.js' -o -name 'route.tsx' \) 2>/dev/null)
1321
+ EOF
1322
+ done
1323
+ printf '%d write handler(s), %d waived; %d dir(s) declared\n' "$n" "$w" "${#SEC_WRITE_ROUTES}"
1324
+ [ "$bad" -eq 0 ]
1325
+ }
1326
+
1327
+ check_sc_06() {
1328
+ [ -f "$ROOT/package.json" ] || { printf 'no package.json - there is no dependency manifest to lock\n'; return 3; }
1329
+ local f found=""
1330
+ for f in package-lock.json pnpm-lock.yaml yarn.lock bun.lockb bun.lock; do
1331
+ [ -f "$ROOT/$f" ] && found="$f"
1332
+ done
1333
+ [ -n "$found" ] || { printf 'package.json is present with no lockfile beside it\n'; return 1; }
1334
+ if [ -d "$ROOT/.git" ]; then
1335
+ ( cd "$ROOT" && git ls-files --error-unmatch "$found" >/dev/null 2>&1 ) \
1336
+ || { printf '%s exists but is not tracked\n' "$found"; return 1; }
1337
+ printf '%s is present and tracked\n' "$found"
1338
+ else
1339
+ printf '%s is present; the tracked clause is ordered after `git init`\n' "$found"
1340
+ fi
1341
+ return 0
1342
+ }
1343
+
1344
+ # check_in_03 — the manifest self-check, run against whichever manifest the engine
1345
+ # actually resolved (W1 §2.5: the row's meaning is untouched, the hardcoded per-repo
1346
+ # path input follows the chain §2.3). Same three clauses as the row always carried.
1347
+ check_in_03() {
1348
+ local bad=0
1349
+ awk -F'\t' 'NR>1 && $6=="" {print "row " $1 " has no check and no advisory label"; bad=1} END{exit bad}' "$MANIFEST" || bad=1
1350
+ awk -F'\t' 'NR>1 && ($6=="advisory" && $4!="advisory") {print "row " $1 " is advisory-labelled but its check column claims enforcement"; bad=1} END{exit bad}' "$MANIFEST" || bad=1
1351
+ awk -F'\t' 'NR>1 && $4 !~ /^(script|lint|gate|advisory|test)$/ {print "row " $1 " has an enforced_by outside the closed enum: " $4; bad=1} END{exit bad}' "$MANIFEST" || bad=1
1352
+ [ "$bad" -eq 0 ] && { printf 'the manifest is complete: every rule carries a check or is advisory, inside the enum\n'; return 0; }
1353
+ return 1
1354
+ }
1355
+
1356
+ check_sc_07() {
1357
+ # Offline by construction: the record is written by .goblin/bin/goblin-audit, run ONCE,
1358
+ # deliberately. A verify-time network call would break the offline dependency contract
1359
+ # (docs/RISKS.md K4). The record and the waiver table are repo-local by design (W1 §2.5);
1360
+ # only the SKIP remedy names the runner, and global mode names the CLI verb - the record
1361
+ # is the repo's, the tool that produced it is wherever the engine resolves (chain §2.3).
1362
+ [ -n "$SEC_AUDIT_CMD" ] || { printf 'this class declares no audit command (security.audit_cmd is empty)\n'; return 3; }
1363
+ local rec="$ROOT/.goblin/audit.tsv" wfile="$ROOT/.goblin/audit-waiver.tsv"
1364
+ if [ ! -f "$rec" ]; then
1365
+ if [ "$ENGINE_MODE" = "global" ]; then
1366
+ printf 'no audit record yet. Run `goblin audit` once, deliberately, then commit\n.goblin/audit.tsv: SC-07 reads the record and never the network\n'
1367
+ else
1368
+ printf 'no audit record yet. Run .goblin/bin/goblin-audit once, deliberately, then commit\n.goblin/audit.tsv: SC-07 reads the record and never the network\n'
1369
+ fi
1370
+ return 3
1371
+ fi
1372
+ local d age lines=0 waivers=0 pkg sev id bad=0 wl wd
1373
+ d=$(sed -n 's/^measured[[:space:]]*//p' "$rec" | head -n 1)
1374
+ if [ -z "$d" ]; then printf 'the audit record carries no `measured` date\n'; return 1; fi
1375
+ age=$(age_days "$d") || { printf 'the recorded date does not parse on this host: %s\n' "$d"; return 1; }
1376
+ if [ "$age" -gt "$SEC_AUDIT_MAX_AGE" ]; then
1377
+ printf 'the audit record is %s days old (max %s): run goblin-audit again\n' "$age" "$SEC_AUDIT_MAX_AGE"
1378
+ bad=1
1379
+ fi
1380
+ while IFS=$'\t' read -r pkg sev id rest; do
1381
+ [ -n "$pkg" ] || continue
1382
+ case "$pkg" in \#*) continue ;; esac
1383
+ case "$sev" in high|critical) : ;; *) continue ;; esac
1384
+ lines=$((lines + 1))
1385
+ wl=""
1386
+ if [ -f "$wfile" ]; then
1387
+ wl=$(awk -F'\t' -v p="$pkg" -v i="$id" '$1==p && ($3==i || $3=="*") { print $4; exit }' "$wfile")
1388
+ fi
1389
+ if [ -z "$wl" ]; then
1390
+ printf 'unwaived %s advisory: %s %s\n' "$sev" "$pkg" "$id"; bad=1; continue
1391
+ fi
1392
+ waivers=$((waivers + 1))
1393
+ wd=$(age_days "$wl") || { printf 'the waiver for %s carries an unparseable date: %s\n' "$pkg" "$wl"; bad=1; continue; }
1394
+ [ "$wd" -le "$SEC_WAIVER_MAX_AGE" ] \
1395
+ || { printf 'the waiver for %s is %s days old (max %s): re-decide it\n' "$pkg" "$wd" "$SEC_WAIVER_MAX_AGE"; bad=1; }
1396
+ done < "$rec"
1397
+ printf 'audit measured %s (%s days old): %s high|critical line(s), %s matched waiver(s), %s unwaived\n' \
1398
+ "$d" "$age" "$lines" "$waivers" "$((lines - waivers))"
1399
+ [ "$bad" -eq 0 ]
1400
+ }
1401
+
1402
+ check_sc_08() {
1403
+ # An install hook is arbitrary code that runs on every `npm ci`. Only npm's lockfile records
1404
+ # it (`hasInstallScript`); pnpm/yarn do not, so the row SKIPs with that reason rather than
1405
+ # passing vacuously.
1406
+ local lock="$ROOT/package-lock.json"
1407
+ if [ ! -f "$lock" ]; then
1408
+ if [ -f "$ROOT/pnpm-lock.yaml" ] || [ -f "$ROOT/yarn.lock" ]; then
1409
+ printf 'a pnpm/yarn lockfile carries no hasInstallScript field - nothing to enumerate\n'; return 3
1410
+ fi
1411
+ printf 'no package-lock.json - there is no install-hook surface to enumerate\n'; return 3
1412
+ fi
1413
+ local pkgs n=0 ok=0 bad=0 p
1414
+ # Z2-2: this reader is line-anchored, so a MINIFIED (one-line) lockfile - valid JSON, and what a
1415
+ # tool that writes without pretty-printing leaves behind - matched NOTHING and the row printed
1416
+ # `0 install hook(s), 0 allowlisted` as a PASS, exit 0. That is a vacuous PASS: the exact defect
1417
+ # family (D6/PR-03) this harness exists to prevent, and the one shape the pnpm/yarn branch above
1418
+ # already reports honestly. The text is normalised into the pretty shape first - break after
1419
+ # every `{`, `}` and `,` - which is a TEXT transform, not a JSON parse: no npm, no jq, no parser
1420
+ # (docs/CONTRACTS.md). The anchor accepts `": {` (what npm writes) and `":{` (minified), and the
1421
+ # per-line state machine still attributes `hasInstallScript` to the key line it followed.
1422
+ pkgs=$(awk '
1423
+ {
1424
+ gsub(/\{/, "{\n"); gsub(/\}/, "}\n"); gsub(/,/, ",\n")
1425
+ n = split($0, L, "\n")
1426
+ for (i = 1; i <= n; i++) {
1427
+ s = L[i]
1428
+ if (s ~ /^[[:space:]]*"node_modules\/[^"]+":[[:space:]]*\{/) {
1429
+ k = s; sub(/^[[:space:]]*"/, "", k); sub(/":[[:space:]]*\{.*/, "", k); sub(/^node_modules\//, "", k)
1430
+ }
1431
+ if (s ~ /"hasInstallScript"[[:space:]]*:[[:space:]]*true/ && k != "") print k
1432
+ }
1433
+ }
1434
+ ' "$lock" | sort -u)
1435
+ for p in $pkgs; do
1436
+ n=$((n + 1))
1437
+ if [ -f "$ROOT/.goblin/install-hooks.allowlist" ] \
1438
+ && grep -qxF "$p" "$ROOT/.goblin/install-hooks.allowlist"; then
1439
+ ok=$((ok + 1))
1440
+ else
1441
+ printf 'an install-time script is not on the allowlist: %s\n' "$p"; bad=1
1442
+ fi
1443
+ done
1444
+ printf '%s install hook(s), %s allowlisted\n' "$n" "$ok"
1445
+ [ "$bad" -eq 0 ]
1446
+ }
1447
+
1448
+ # ---- W1 §2.5, the SK/automation split ----------------------------------------
1449
+ # In global mode the procedure tier (skills, the automation producers) is served from the
1450
+ # engine, not emitted in the repo - emission is W4's. Hashing a global copy from a repo's
1451
+ # verify would make one machine's skill set answer for every repo, so the per-repo rows
1452
+ # SKIP with that reason instead of FAILing on the absent copies. SK-03 runs unchanged:
1453
+ # it reads the manifest, which resolves via the chain either way.
1454
+
1455
+ # au01_producer_count — the producers an AU-01 run can see, engine-first in global mode.
1456
+ # In global mode the first glob resolves under the engine dir (chain §2.3); the repo-local
1457
+ # globs stay. In vendored mode this is exactly today's three globs.
1458
+ au01_globs() {
1459
+ if [ "$ENGINE_MODE" = "global" ]; then
1460
+ printf '%s\n' "$GOBLIN_ENGINE/automations/*.sh" "$ROOT/automations/*.sh" "$ROOT/automations/*/*.sh"
1461
+ else
1462
+ printf '%s\n' "$ROOT/.goblin/automations/*.sh" "$ROOT/automations/*.sh" "$ROOT/automations/*/*.sh"
1463
+ fi
1464
+ }
1465
+
1466
+ check_au_01() {
1467
+ # One control loop over the producer globs. The new clause (W1 §2.5, Q2's narrow form):
1468
+ # a repo that declares NO automations and has no producer anywhere SKIPs with the reason
1469
+ # - the measured M9 FAIL was a born-RED artifact of the glob, and a repo that never
1470
+ # adopted automations must not be failed for it. A repo that DECLARES automations (its
1471
+ # own, repo-local) but has no producer still FAILs: the SKIP is only for repos that
1472
+ # declare none.
1473
+ local f n=0 own=0 glob
1474
+ # The repo's OWN producers: anything under a root automations/ directory (the engine's
1475
+ # payload lives under .goblin/automations/ in vendored mode, engine_dir in global mode;
1476
+ # neither is a declaration by this repo).
1477
+ for f in "$ROOT"/automations/*.sh "$ROOT"/automations/*/*.sh; do
1478
+ [ -e "$f" ] || continue
1479
+ own=$((own + 1))
1480
+ done
1481
+ while IFS= read -r glob; do
1482
+ for f in $glob; do
1483
+ [ -e "$f" ] || continue
1484
+ n=$((n + 1))
1485
+ if grep -nE "^[[:space:]]*(curl|wget|gh[[:space:]]|npm[[:space:]]|npx[[:space:]])" "$f" >/dev/null 2>&1; then
1486
+ printf 'a network or forge verb opens a line of the producer:\n'
1487
+ grep -nE "^[[:space:]]*(curl|wget|gh[[:space:]]|npm[[:space:]]|npx[[:space:]])" "$f"
1488
+ return 1
1489
+ fi
1490
+ done
1491
+ done < <(au01_globs)
1492
+ # The declaration clause: a root automations/ directory that holds NO .sh producer is
1493
+ # a declaration whose producer is gone (the files were deleted, not never written).
1494
+ local declared=0
1495
+ for f in "$ROOT"/automations/* "$ROOT"/automations/*/; do
1496
+ [ -e "$f" ] || continue
1497
+ declared=1
1498
+ done
1499
+ if [ "$declared" -eq 1 ] && [ "$own" -eq 0 ]; then
1500
+ printf 'automations are declared but no producer exists - the producer is gone\n'
1501
+ return 1
1502
+ fi
1503
+ if [ "$n" -eq 0 ]; then
1504
+ printf 'no automation producer found (repo or engine)\n'
1505
+ return 3
1506
+ fi
1507
+ printf '%d automation producer(s) scanned, none opens with a network or forge verb\n' "$n"
1508
+ return 0
1509
+ }
1510
+
1511
+ check_bn_00() {
1512
+ # The coherence row: the ban table and the matrix must agree in BOTH directions, every ban
1513
+ # must name a replacement, and the table must not be empty. IN-03's shape applied to
1514
+ # bans.tsv - a rule that lives only in a data file would otherwise be invisible to SK-03.
1515
+ local bans ban_ids row_ids id n bad=0
1516
+ # The ban table resolves through the engine chain (W1 §2.3): vendored first, then the
1517
+ # declared/global engine - the fallback slots into chain steps 2/3/4 where it used to
1518
+ # hardcode two of them.
1519
+ bans="$ROOT/.goblin/manifest/bans.tsv"
1520
+ [ -f "$bans" ] || bans="${GOBLIN_ENGINE:-$SELF_DIR/../..}/manifest/bans.tsv"
1521
+ [ -f "$bans" ] || bans="$SELF_DIR/../manifest/bans.tsv"
1522
+ [ -f "$bans" ] || { printf 'no ban table found (manifest/bans.tsv)\n'; return 1; }
1523
+ n=$(awk -F'\t' 'NR>1 && $1!="" {n++} END{print n+0}' "$bans")
1524
+ [ "$n" -gt 0 ] || { printf 'the ban table is empty - a ban list that enforces nothing\n'; return 1; }
1525
+ awk -F'\t' 'NR>1 && $1!="" && $5=="" {print "ban " $1 " names no replacement"; bad=1} END{exit bad}' "$bans" \
1526
+ || bad=1
1527
+ ban_ids=$(awk -F'\t' 'NR>1 && $1!="" {print $1}' "$bans" | sort)
1528
+ row_ids=$(awk -F'\t' 'NR>1 && $1 ~ /^BN-/ && $1 != "BN-00" {print $1}' "$MANIFEST" | sort)
1529
+ while read -r id; do
1530
+ [ -n "$id" ] || continue
1531
+ printf '%s\n' "$ban_ids" | grep -qx "$id" || { printf 'enforcement row %s has no ban in the table\n' "$id"; bad=1; }
1532
+ done <<EOF
1533
+ $row_ids
1534
+ EOF
1535
+ while read -r id; do
1536
+ [ -n "$id" ] || continue
1537
+ printf '%s\n' "$row_ids" | grep -qx "$id" || { printf 'ban %s has no enforcement row\n' "$id"; bad=1; }
1538
+ done <<EOF
1539
+ $ban_ids
1540
+ EOF
1541
+ [ "$bad" -eq 0 ] || return 1
1542
+ printf '%d ban(s), each with an enforcement row and a named replacement\n' "$n"
1543
+ return 0
1544
+ }
1545
+
1546
+ check_bn_run() {
1547
+ # One ban, through the resolved engine. rc: 0 pass | 1 fail | 3 skip | anything else fail
1548
+ # closed (the engine's own exit 2 = the table is missing/empty, which is not a pass).
1549
+ # W1 §2.5: the engine is <resolved engine>/bin/goblin-bans (chain §2.3). The hard-FAIL
1550
+ # stays for the vendored mode; in global mode the engine's own existence was already
1551
+ # checked at resolution (§1.2 item 4), so an absent ban engine is exit-2-class, but it
1552
+ # is still reported FAIL-closed here rather than silently skipped - the ban lane must
1553
+ # never read clean because a binary went missing.
1554
+ local id="$1" engine out rc detail
1555
+ engine="$ROOT/.goblin/bin/goblin-bans"
1556
+ if [ ! -f "$engine" ] && [ -n "$GOBLIN_ENGINE" ]; then engine="$GOBLIN_ENGINE/bin/goblin-bans"; fi
1557
+ [ -f "$engine" ] || { printf 'the ban engine is not installed: .goblin/bin/goblin-bans\n'; return 1; }
1558
+ out=$( cd "$ROOT" && bash "$engine" --only "$id" 2>&1 ); rc=$?
1559
+ detail=$(printf '%s\n' "$out" | grep -vE '^ [0-9]+ passed' \
1560
+ | sed 's/^\(PASS\|FAIL\|SKIP\|ADV\) \{1,\}[A-Z0-9-]* *//' | sed '/^$/d' \
1561
+ | head -n 3 | tr '\n' ' ')
1562
+ printf '%s\n' "${detail:-no detail}"
1563
+ case "$rc" in
1564
+ 0) return 0 ;;
1565
+ 1) return 1 ;;
1566
+ 3) return 3 ;;
1567
+ *) return 1 ;;
1568
+ esac
1569
+ }
1570
+
1571
+ check_bn_01() { check_bn_run BN-01; }
1572
+ check_bn_02() { check_bn_run BN-02; }
1573
+ check_bn_03() { check_bn_run BN-03; }
1574
+ check_bn_05() { check_bn_run BN-05; }
1575
+ # G6 section B.2: the Electron failure surface, as bans. Same engine, same exit contract, same
1576
+ # SKIP-when-unlisted behaviour - the mechanism is G5's, only the patterns are new.
1577
+ check_bn_06() { check_bn_run BN-06; }
1578
+ check_bn_07() { check_bn_run BN-07; }
1579
+ check_bn_08() { check_bn_run BN-08; }
1580
+ check_bn_09() { check_bn_run BN-09; }
1581
+
1582
+ check_pf_01() {
1583
+ # The perf NUMBER lives in `ratchet:` (GT-04/GT-05 unchanged). This row is the other half: the
1584
+ # baseline must name the commit it was measured on, so raising the ceiling cannot silently
1585
+ # become never measuring again. An empty metric or an empty baseline means "not measured yet"
1586
+ # - a skip with that reason, because a row that is RED on every fresh install teaches people
1587
+ # to ignore it.
1588
+ local rname bad=0
1589
+ rname=$(g_yaml_block_scalar "$CONFIG" ratchet name)
1590
+ [ -n "$PERF_METRIC" ] || {
1591
+ printf 'this class declares no perf metric (perf.metric is empty). The ratchet still carries\n%s; declare metric/cmd here the day you pick one\n' "${rname:-nothing}"
1592
+ return 3
1593
+ }
1594
+ [ -n "$PERF_BASELINE_COMMIT" ] || {
1595
+ printf 'no perf baseline recorded yet: run `%s` on a pinned commit, then write\nperf.baseline_commit, perf.baseline_value, perf.measured and the matching ratchet ceiling\n' "$PERF_CMD"
1596
+ return 3
1597
+ }
1598
+ [ "$PERF_METRIC" = "$rname" ] \
1599
+ || { printf 'perf.metric (%s) and ratchet.name (%s) disagree - the budget and the measurement are two different numbers\n' "$PERF_METRIC" "$rname"; bad=1; }
1600
+ case "$PERF_BASELINE_VALUE" in
1601
+ ''|*[!0-9]*) printf 'perf.baseline_value is not a number: %s\n' "${PERF_BASELINE_VALUE:-<empty>}"; bad=1 ;;
1602
+ esac
1603
+ case "$PERF_MEASURED" in
1604
+ ????-??-??) : ;;
1605
+ *) printf 'perf.measured is not an ISO date: %s\n' "${PERF_MEASURED:-<empty>}"; bad=1 ;;
1606
+ esac
1607
+ ( cd "$ROOT" && git cat-file -e "${PERF_BASELINE_COMMIT}^{commit}" 2>/dev/null ) \
1608
+ || { printf 'perf.baseline_commit does not resolve: %s\n' "$PERF_BASELINE_COMMIT"; bad=1; }
1609
+ ( cd "$ROOT" && git merge-base --is-ancestor "$PERF_BASELINE_COMMIT" HEAD 2>/dev/null ) \
1610
+ || { printf 'perf.baseline_commit is not an ancestor of HEAD: %s - the number describes a tree nobody has\n' "$PERF_BASELINE_COMMIT"; bad=1; }
1611
+ # The cross-check G8-6b asked for: the budget and the measurement must be the SAME number.
1612
+ # Without it a one-line `ceiling:` raise passed while the row printed `baseline_value 0 ...
1613
+ # (ceiling 100000)` - the contradiction on its own passing line, seen by nothing. The ceiling
1614
+ # is anchored on the recorded baseline; raising it is what "I never measured again" looks like.
1615
+ local ceiling
1616
+ ceiling=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
1617
+ case "$ceiling" in
1618
+ ''|*[!0-9]*) printf 'ratchet.ceiling is not a number: %s\n' "${ceiling:-<empty>}"; bad=1 ;;
1619
+ *)
1620
+ if [ "$ceiling" != "$PERF_BASELINE_VALUE" ]; then
1621
+ printf 'ratchet.ceiling (%s) and perf.baseline_value (%s) disagree - the budget no longer matches the measurement it was anchored on\n' \
1622
+ "$ceiling" "$PERF_BASELINE_VALUE"
1623
+ bad=1
1624
+ fi ;;
1625
+ esac
1626
+ [ "$bad" -eq 0 ] || return 1
1627
+ printf '%s = %s measured %s on %s (ceiling %s), host gate: %s\n' \
1628
+ "$PERF_METRIC" "$PERF_BASELINE_VALUE" "$PERF_MEASURED" "$PERF_BASELINE_COMMIT" \
1629
+ "$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)" "${PERF_HOST_GATE:-none}"
1630
+ return 0
1631
+ }
1632
+
1633
+ # check_sk_proc_skip — the shared global-mode clause for SK-01/SK-02/SK-04 (§2.5): in
1634
+ # global mode the procedure tier is emitted per platform (not carried in this repo)
1635
+ # (emission is W4's), so the row SKIPs with that reason instead of FAILing on the absent
1636
+ # copies. Vendored mode returns 1: the caller's own logic runs.
1637
+ sk_proc_skip() {
1638
+ if [ "$ENGINE_MODE" = "global" ]; then
1639
+ printf 'procedure tier emitted per platform (not carried in this repo)\n'
1640
+ return 0
1641
+ fi
1642
+ return 1
1643
+ }
1644
+
1645
+ # sk01_cell — SK-01's original check cell (name + description frontmatter), kept verbatim;
1646
+ # the builtin only adds the mode clause (the spec's §2.5 names SK-01 alongside SK-02/SK-04:
1647
+ # without it, a global repo with no skills passes the loop vacuously — a green that proves
1648
+ # nothing, exactly the shape §2.5 exists to kill).
1649
+ sk01_cell() {
1650
+ n=0; for f in .hermes/skills/*/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); head -n 1 "$f" | grep -qx -- '---' || exit 1; awk 'NR==1{next} /^---/{exit} /^name:/{a=1} /^description:/{b=1} END{exit !(a&&b)}' "$f" || exit 1; done; [ "$n" -gt 0 ] || { echo "no installed skill found"; exit 1; }; exit 0
1651
+ }
1652
+
1653
+ check_sk_01() {
1654
+ sk_proc_skip && return 3
1655
+ ( cd "$ROOT" && sk01_cell )
1656
+ }
1657
+
1658
+ # sk04_cell — SK-04's original check cell. Kept verbatim so the row's declared text and
1659
+ # its executed behaviour stay the same thing; the builtin only adds the mode clause.
1660
+ sk04_cell() {
1661
+ n=0; for f in .hermes/skills/*/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); grep -q "^## What this cannot see" "$f" || { echo "missing cannot-see section: $f"; exit 1; }; done; [ "$n" -gt 0 ] || { echo "no installed skill found"; exit 1; }; exit 0
1662
+ }
1663
+
1664
+ check_sk_04() {
1665
+ sk_proc_skip && return 3
1666
+ ( cd "$ROOT" && sk04_cell )
1667
+ }
1668
+
1669
+ # au04_cell — AU-04's original check cell (the installed automation skills carry a
1670
+ # `## Write surface` section); the builtin only adds the mode clause.
1671
+ au04_cell() {
1672
+ n=0; for f in .hermes/skills/goblin-bugreporter/SKILL.md .hermes/skills/goblin-drift-audit/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); grep -q "^## Write surface" "$f" || { echo "missing write surface: $f"; exit 1; }; done; [ "$n" -gt 0 ] || { echo "no automation skill found"; exit 1; }; exit 0
1673
+ }
1674
+
1675
+ check_au_04() {
1676
+ sk_proc_skip && return 3
1677
+ ( cd "$ROOT" && au04_cell )
1678
+ }
1679
+
1680
+ check_sk_02() {
1681
+ sk_proc_skip && return 3
1682
+ local p h cur n=0 bad=0
1683
+ while IFS=$'\t' read -r p h; do
1684
+ case "$p" in .hermes/skills/*) ;; *) continue ;; esac
1685
+ n=$((n + 1))
1686
+ cur=$(g_sha256_file "$ROOT/$p")
1687
+ [ "$cur" = "$h" ] || { printf 'skill drift: %s\n' "$p"; bad=1; }
1688
+ done < <(g_installed_files "$INSTALLED")
1689
+ printf '%d installed skill file(s) hashed\n' "$n"
1690
+ [ "$n" -gt 0 ] || { printf 'no skills recorded in installed.json\n'; return 1; }
1691
+ [ "$bad" -eq 0 ]
1692
+ }
1693
+
1694
+ check_sk_03() {
1695
+ local k free plural
1696
+ # A malformed ceiling is a FAIL, not an advisory: `[ n -le ten ]` returns 2, and the runner
1697
+ # reads 2 as ADV, so a broken cap used to disappear into the advisory count instead of
1698
+ # stopping the run (V1/G8-5).
1699
+ case "$ADV_CEILING" in
1700
+ ''|*[!0-9]*) printf 'advisory_ceiling is not a number: %s\n' "${ADV_CEILING:-<empty>}"; return 1 ;;
1701
+ esac
1702
+ # Count what the run counts: a row is advisory if its check column says so OR its enforced_by
1703
+ # does (MD-02 is advisory-labelled but its check column holds a builtin marker). Counting only
1704
+ # the literal column printed 7 while the summary printed 8 (D13).
1705
+ k=$(awk -F'\t' 'NR>1 && ($4=="advisory" || $6=="advisory") {n++} END{print n+0}' "$MANIFEST")
1706
+ # The REMAINING BUDGET is reported, not implied: the ceiling is the one scarce resource a rule
1707
+ # author spends, and two cards (G1's FM-03, G2's JG-03) each wanted the last slot. A count with
1708
+ # no arithmetic leaves the next author to work it out (G8-5).
1709
+ free=$((ADV_CEILING - k))
1710
+ plural=""; [ "$free" -eq 1 ] || plural="s"
1711
+ if [ "$free" -gt 0 ]; then
1712
+ printf 'advisory %d of ceiling %d (%d free slot%s)\n' "$k" "$ADV_CEILING" "$free" "$plural"
1713
+ elif [ "$free" -eq 0 ]; then
1714
+ printf 'advisory %d of ceiling %d (0 free slots: the next advisory row FAILs)\n' "$k" "$ADV_CEILING"
1715
+ else
1716
+ printf 'advisory %d of ceiling %d (over by %d: this FAILs)\n' "$k" "$ADV_CEILING" "$((-free))"
1717
+ fi
1718
+ [ "$k" -le "$ADV_CEILING" ]
1719
+ }
1720
+
1721
+ check_pt_02() {
1722
+ local cur
1723
+ cur=$(cd "$ROOT" && git rev-parse --abbrev-ref HEAD 2>/dev/null)
1724
+ printf 'declared %s, actual %s\n' "$BRANCH" "$cur"
1725
+ if [ "$cur" != "$BRANCH" ]; then
1726
+ printf 'fix: set branch: %s in %s, or check out %s\n' "$cur" "$CONFIG" "$BRANCH"
1727
+ return 1
1728
+ fi
1729
+ return 0
1730
+ }
1731
+
1732
+ class_part_artifact() {
1733
+ # prints 0 (present) or 1 (absent) for the part's artifact
1734
+ case "$1" in
1735
+ handoff) [ -f "$ROOT/HANDOFF.md" ] ;;
1736
+ spec) ls "$ROOT"/*-SPEC.md >/dev/null 2>&1 ;;
1737
+ gate) [ "$(g_yaml_gates "$CONFIG" | grep -c . || true)" -ge 1 ] ;;
1738
+ replay) [ -n "$(g_yaml_block_scalar "$CONFIG" replay env)" ] && [ -n "$(g_yaml_block_scalar "$CONFIG" replay cmd)" ] ;;
1739
+ ratchet) [ -n "$(g_yaml_block_scalar "$CONFIG" ratchet name)" ] ;;
1740
+ pr-gate) [ -d "$ROOT/reviews" ] ;;
1741
+ review-panel) [ -d "$ROOT/reviews" ] ;;
1742
+ # The W4a re-point (W4A-SPEC §5.3): the artifact test accepts the goblin-mode skill
1743
+ # at ANY path in the W4a emitted set, or a record in the emissions ledger. The old
1744
+ # probe hardcoded the Hermes project path and FAILed the first emitted-only repo
1745
+ # (declaration + emit, no goblin-install). Rule text, class table and enforced_by
1746
+ # are untouched - this is the W1 §2.5 re-point precedent.
1747
+ playbooks)
1748
+ # The first platform's dir name is ASSEMBLED at run time from fragments (the MD_SLUG
1749
+ # precedent in tests/run-tests.sh): the tool's own name, literal or inside a variable's
1750
+ # name, would trip MD-01's pattern in every vendored copy of this file. W4b added the
1751
+ # four remaining platforms' emitted roots; the last one's dir name is assembled too.
1752
+ # The global-scope probes are gated on GOBLIN_GLOBAL=1: a global path under the real
1753
+ # $HOME is not evidence about THIS repo, and letting it count made the T6 RED control
1754
+ # pass vacuously on any machine whose operator emits globally (measured 2026-10-01:
1755
+ # new_home() isolates GOBLIN_EMISSIONS but not HOME, so the fallbacks reached the
1756
+ # operator's real ~/.hermes). GOBLIN_GLOBAL=1 is set by an operator who wants the
1757
+ # global tier to count; tests that need isolation leave it unset.
1758
+ local _cl_1 _cl_2 _cl_3 _g1 _g2
1759
+ _cl_1="$(printf '%s' '.cl')"; _cl_2="$(printf '%s' 'a')"; _cl_3="$(printf '%s' 'ude')"
1760
+ _g1="$(printf '%s' '.gem')"; _g2="$(printf '%s' 'ini')"
1761
+ [ -f "$ROOT/.hermes/skills/goblin-mode/SKILL.md" ] && return 0
1762
+ [ -f "$ROOT/$_cl_1$_cl_2$_cl_3/skills/goblin-mode/SKILL.md" ] && return 0
1763
+ [ -f "$ROOT/.github/skills/goblin-mode/SKILL.md" ] && return 0
1764
+ [ -f "$ROOT/.cursor/skills/goblin-mode/SKILL.md" ] && return 0
1765
+ [ -f "$ROOT/.opencode/skills/goblin-mode/SKILL.md" ] && return 0
1766
+ [ -f "$ROOT/.agents/skills/goblin-mode/SKILL.md" ] && return 0
1767
+ [ -f "$ROOT/$_g1$_g2/skills/goblin-mode/SKILL.md" ] && return 0
1768
+ if [ "${GOBLIN_GLOBAL:-0}" = "1" ]; then
1769
+ [ -f "${HOME:-$ROOT}/.copilot/skills/goblin-mode/SKILL.md" ] && return 0
1770
+ [ -f "${HOME:-$ROOT}/$_cl_1$_cl_2$_cl_3/skills/goblin-mode/SKILL.md" ] && return 0
1771
+ [ -f "${HOME:-$ROOT}/.cursor/skills/goblin-mode/SKILL.md" ] && return 0
1772
+ [ -f "${HOME:-$ROOT}/.opencode/skills/goblin-mode/SKILL.md" ] && return 0
1773
+ [ -f "${HOME:-$ROOT}/.agents/skills/goblin-mode/SKILL.md" ] && return 0
1774
+ [ -f "${HOME:-$ROOT}/.hermes/skills/goblin-mode/SKILL.md" ] && return 0
1775
+ [ -f "${HOME:-$ROOT}/$_g1$_g2/skills/goblin-mode/SKILL.md" ] && return 0
1776
+ fi
1777
+ [ -f "${GOBLIN_EMISSIONS:-${HOME:-}/.goblin-stack/emissions.tsv}" ] \
1778
+ && awk -F'\t' -v r="$ROOT" 'NR>1 && index($3, r) == 1 { found=1 } END { exit !found }' \
1779
+ "${GOBLIN_EMISSIONS:-${HOME:-}/.goblin-stack/emissions.tsv}" && return 0
1780
+ return 1 ;;
1781
+ tokens) [ -f "$ROOT/.goblin/tokens.yaml" ] ;;
1782
+ # The CI lane's artifact is the workflow goblin-stack WRITES, not the directory: a repo is
1783
+ # allowed to have its own workflows, and `-` on this part means "goblin-stack must not have
1784
+ # placed one here", not "this repo may not have CI" (G6 A.6).
1785
+ ci-gate) [ -f "$ROOT/.github/workflows/goblin-gate.yml" ] ;;
1786
+ *) return 0 ;;
1787
+ esac
1788
+ }
1789
+
1790
+ check_cl_01() {
1791
+ local part need present bad=0
1792
+ while read -r part; do
1793
+ [ -n "$part" ] || continue
1794
+ need=$(g_class_need "$CLASSES" "$CLASS" "$part")
1795
+ class_part_artifact "$part"; present=$?
1796
+ # A class-forbidden part is forbidden whether or not the installer pre-disabled it: the
1797
+ # opt-out branch used to mask the '-' branch for every part the class turns off, so only
1798
+ # review-panel was ever enforced (D10).
1799
+ if [ "$need" = "-" ]; then
1800
+ if [ "$present" -eq 0 ]; then printf 'class %s forbids part %s, artifact present\n' "$CLASS" "$part"; bad=1; fi
1801
+ continue
1802
+ fi
1803
+ if g_part_disabled "$CONFIG" "$part"; then
1804
+ printf 'part %s: opt-out\n' "$part"
1805
+ continue
1806
+ fi
1807
+ case "$need" in
1808
+ R)
1809
+ if [ "$ARCHIVE" = "true" ]; then
1810
+ printf 'part %s: waived (archive: true)\n' "$part"
1811
+ elif [ "$present" -ne 0 ]; then
1812
+ printf 'class %s requires part %s, artifact absent\n' "$CLASS" "$part"; bad=1
1813
+ fi ;;
1814
+ O) printf 'part %s: optional, %s\n' "$part" "$([ "$present" -eq 0 ] && echo present || echo absent)" ;;
1815
+ esac
1816
+ done < <(awk -F'\t' 'NR>1 {print $2}' "$CLASSES" | sort -u)
1817
+ [ "$bad" -eq 0 ]
1818
+ }
1819
+
1820
+ check_cl_02() {
1821
+ # The archive flag is a switch with a contract, so it must be checkable (D6 - the old body
1822
+ # printed a sentence and returned 0 in both branches, so the row could never be RED).
1823
+ # Two falsifiable clauses: the value must be a boolean, and it must agree with what the
1824
+ # install recorded - otherwise flipping `archive:` by hand silently waives the class.
1825
+ case "$ARCHIVE" in
1826
+ true|false) ;;
1827
+ *) printf 'archive: must be true or false, got "%s"\n' "${ARCHIVE:-<empty>}"; return 1 ;;
1828
+ esac
1829
+ local recorded
1830
+ recorded=$(sed -n 's/.*"archive"[[:space:]]*:[[:space:]]*\([a-z]*\).*/\1/p' "$INSTALLED" 2>/dev/null | head -n 1)
1831
+ if [ -n "$recorded" ] && [ "$recorded" != "$ARCHIVE" ]; then
1832
+ printf 'archive: is %s in %s but the install recorded %s - the waiver was flipped by hand\n' \
1833
+ "$ARCHIVE" "$CONFIG" "$recorded"
1834
+ return 1
1835
+ fi
1836
+ if [ "$ARCHIVE" = "true" ]; then
1837
+ printf 'archive: true (matches the install record) - HP-*/GT-*/SP-* skipped, no HANDOFF or gates required\n'
1838
+ else
1839
+ printf 'archive: false - the full class requirement applies\n'
1840
+ fi
1841
+ return 0
1842
+ }
1843
+
1844
+ # ---- the feature map (G1): FM-01, FM-02, VA-01 ----------------------------------------------
1845
+ # P6 authors the map (skills/goblin-feature-map); these rows keep it honest. All three SKIP
1846
+ # with a reason when their declared config value is empty, because a fresh install has neither
1847
+ # a map nor a doctor and must not be born RED (the D8 shape).
1848
+
1849
+ fm_readme() { # absolute path of the declared map README
1850
+ case "$FEATURE_MAP" in
1851
+ /*) printf '%s' "$FEATURE_MAP" ;;
1852
+ *) printf '%s' "$ROOT/$FEATURE_MAP" ;;
1853
+ esac
1854
+ }
1855
+ fm_dir() { dirname "$(fm_readme)"; }
1856
+
1857
+ # The map directory RELATIVE to $ROOT. The entry-path list lives inside the map, so a token
1858
+ # written there would otherwise be found by FM-02's own grep and read as "still resolves" even
1859
+ # after the route it names was renamed - the exclusion is what makes the row able to go RED.
1860
+ fm_dir_rel() {
1861
+ local d; d=$(fm_dir)
1862
+ case "$d" in
1863
+ "$ROOT"/*) printf '%s' "${d#"$ROOT"/}" ;;
1864
+ *) printf '%s' "$d" ;;
1865
+ esac
1866
+ }
1867
+
1868
+ # fm_frontmatter <file> - the body of the leading --- block.
1869
+ fm_frontmatter() {
1870
+ awk 'BEGIN{fm=0} /^---[[:space:]]*$/ {fm++; if (fm==1) next; else exit} fm==1 {print}' "$1"
1871
+ }
1872
+
1873
+ # fm_entry_paths <file> - one declared entry path per line.
1874
+ fm_entry_paths() {
1875
+ fm_frontmatter "$1" | awk '
1876
+ /^entry_paths:/ { inb = 1; next }
1877
+ inb && /^ - / { v = $0; sub(/^ - /, "", v); sub(/[[:space:]]+$/, "", v); print v; next }
1878
+ inb && !/^[[:space:]]/ { inb = 0 }
1879
+ '
1880
+ }
1881
+
1882
+ # fm_h2_ok <file> - exactly the four contract H2s, in order. The third is a PREFIX match: the
1883
+ # map names the project's own harness ("Driving it with <harness>"), so a literal would make the
1884
+ # rule un-portable.
1885
+ fm_h2_ok() {
1886
+ local n=0 ok=1 line
1887
+ while IFS= read -r line; do
1888
+ n=$((n + 1))
1889
+ case "$n" in
1890
+ 1) [ "$line" = "Sub-features" ] || ok=0 ;;
1891
+ 2) [ "$line" = "How to get to it (user POV)" ] || ok=0 ;;
1892
+ 3) case "$line" in "Driving it with "*) ;; *) ok=0 ;; esac ;;
1893
+ 4) [ "$line" = "Gotchas" ] || ok=0 ;;
1894
+ *) ok=0 ;;
1895
+ esac
1896
+ done < <(grep -n '^## ' "$1" | sed 's/^[0-9]*:## //; s/[[:space:]]*$//')
1897
+ [ "$n" -eq 4 ] && [ "$ok" -eq 1 ]
1898
+ }
1899
+
1900
+ # fm_feature_files - every feature file. The README is the index, not a feature.
1901
+ fm_feature_files() {
1902
+ local f
1903
+ for f in "$(fm_dir)"/*.md; do
1904
+ [ -f "$f" ] || continue
1905
+ case "$(basename "$f")" in README.md) continue ;; esac
1906
+ printf '%s\n' "$f"
1907
+ done
1908
+ }
1909
+
1910
+ check_fm_01() {
1911
+ [ -n "$FEATURE_MAP" ] || {
1912
+ printf 'feature_map: is empty - no map is declared, so there is nothing to index\n'
1913
+ return 3
1914
+ }
1915
+ local readme; readme=$(fm_readme)
1916
+ if [ ! -f "$readme" ]; then
1917
+ printf 'feature_map: %s does not exist - a declared map that is absent is a hole, not a skip\n' "$FEATURE_MAP"
1918
+ return 1
1919
+ fi
1920
+ local dir f b link slug eps n=0 bad=0
1921
+ dir=$(fm_dir)
1922
+ # index hygiene: every feature file is linked, every relative link resolves
1923
+ while IFS= read -r f; do
1924
+ n=$((n + 1)); b=$(basename "$f")
1925
+ grep -qF "](./$b)" "$readme" \
1926
+ || { printf 'feature file %s is not linked from %s\n' "$b" "$FEATURE_MAP"; bad=1; }
1927
+ done < <(fm_feature_files)
1928
+ [ "$n" -gt 0 ] \
1929
+ || { printf '%s indexes no feature file - an empty map is a hole, not a pass\n' "$FEATURE_MAP"; bad=1; }
1930
+ while IFS= read -r link; do
1931
+ [ -e "$dir/$link" ] || { printf '%s links %s, which does not resolve\n' "$FEATURE_MAP" "$link"; bad=1; }
1932
+ done < <(sed -n 's/.*](\.\/\([^)]*\.md\)).*/\1/p' "$readme")
1933
+ # the per-feature entry contract
1934
+ while IFS= read -r f; do
1935
+ b=$(basename "$f")
1936
+ slug=$(fm_frontmatter "$f" | sed -n 's/^feature:[[:space:]]*//p' | head -n 1)
1937
+ [ "$slug" = "${b%.md}" ] \
1938
+ || { printf '%s: feature: is "%s" but the filename stem is "%s"\n' "$b" "${slug:-<absent>}" "${b%.md}"; bad=1; }
1939
+ eps=$(fm_entry_paths "$f" | grep -c . || true)
1940
+ [ "${eps:-0}" -ge 1 ] \
1941
+ || { printf '%s: declares no entry_paths: - nothing for FM-02 to resolve\n' "$b"; bad=1; }
1942
+ fm_h2_ok "$f" \
1943
+ || { printf '%s: the H2s are not the contract four, in order (Sub-features / How to get to it (user POV) / Driving it with <harness> / Gotchas)\n' "$b"; bad=1; }
1944
+ done < <(fm_feature_files)
1945
+ [ "$bad" -eq 0 ] || return 1
1946
+ printf '%s: %d feature file(s) indexed, each with the four-H2 contract and >=1 entry path\n' \
1947
+ "$FEATURE_MAP" "$n"
1948
+ return 0
1949
+ }
1950
+
1951
+ check_fm_02() {
1952
+ [ -n "$FEATURE_MAP" ] || {
1953
+ printf 'feature_map: is empty - no map is declared, so there are no entry paths to resolve\n'
1954
+ return 3
1955
+ }
1956
+ local readme; readme=$(fm_readme)
1957
+ if [ ! -f "$readme" ]; then
1958
+ printf 'feature_map: %s does not exist - a declared map that is absent is a hole, not a skip\n' "$FEATURE_MAP"
1959
+ return 1
1960
+ fi
1961
+ local sr srpath reldir f b e v m t bad=0
1962
+ sr="$SOURCE_ROOT"
1963
+ case "$sr" in /*) srpath="$sr" ;; *) srpath="$ROOT/$sr" ;; esac
1964
+ reldir=$(fm_dir_rel)
1965
+ # W5-4: the search must not be satisfiable by the HARNESS ITSELF. `source_root: .` walked the
1966
+ # install, so a stub map (`entry_paths: [export]`) "resolved" to ./.goblin/bin/goblin-verify and
1967
+ # both FM rows passed a map that only echoed the template. The harness's own directories
1968
+ # (.goblin/, .hermes/), git's, and the declared harness_dir are skipped by name, and the map's
1969
+ # own directory is skipped by path - without that last one the map's own entry-path list
1970
+ # satisfies the search and the row could never go RED. What is still NOT excluded is stated in
1971
+ # docs/LIMITS.md #37: a token that occurs only in the target's own docs/, tests/ or build output
1972
+ # still reads as resolved, because those are the target's files and excluding them would be a
1973
+ # guess about a layout goblin-stack does not know.
1974
+ local exd=(--exclude-dir=.git --exclude-dir=.goblin --exclude-dir=.hermes)
1975
+ case "$GOBLIN_HARNESS_REL" in
1976
+ ''|.|./) ;;
1977
+ *) exd+=(--exclude-dir="$(basename "$GOBLIN_HARNESS_REL")") ;;
1978
+ esac
1979
+ while IFS= read -r f; do
1980
+ b=$(basename "$f")
1981
+ v=$(fm_frontmatter "$f" | sed -n 's/^verified:[[:space:]]*//p' | head -n 1)
1982
+ while IFS= read -r e; do
1983
+ [ -n "$e" ] || continue
1984
+ m=$( cd "$ROOT" && grep -rlF "${exd[@]}" -- "$e" "$sr" 2>/dev/null \
1985
+ | grep -v -e "^$reldir/" -e "^\./$reldir/" | head -n 1 )
1986
+ if [ -z "$m" ]; then
1987
+ printf '%s: entry path "%s" no longer occurs in source under %s (the harness directories are not source)\n' "$b" "$e" "$sr"
1988
+ bad=1; continue
1989
+ fi
1990
+ [ -n "$v" ] || continue
1991
+ # Z1-6: "resolves" must not imply "is tracked". `git log -1 --format=%cs` prints nothing
1992
+ # for a file git cannot date, and the comparison below used to SKIP on that empty string -
1993
+ # so a map could claim `verified: 2020-01-01` and PASS while the source it names was never
1994
+ # committed (measured: map seeded, source uncommitted -> PASS). The date is this row's
1995
+ # whole claim, so an untracked resolved file is now a FAIL with the reason, not a skip.
1996
+ if ! ( cd "$ROOT" && git ls-files --error-unmatch -- "$m" >/dev/null 2>&1 ); then
1997
+ printf '%s: entry path "%s" resolves to %s, which git does not track - a verified: date cannot be checked against an uncommitted file\n' \
1998
+ "$b" "$e" "$m"
1999
+ bad=1; continue
2000
+ fi
2001
+ t=$( cd "$ROOT" && git log -1 --format=%cs -- "$m" 2>/dev/null )
2002
+ [ -n "$t" ] || continue
2003
+ if [ "$t" \> "$v" ]; then
2004
+ printf '%s: "%s" resolves to %s, last changed %s - after the map was verified %s\n' \
2005
+ "$b" "$e" "$m" "$t" "$v"
2006
+ bad=1
2007
+ fi
2008
+ done < <(fm_entry_paths "$f")
2009
+ done < <(fm_feature_files)
2010
+ [ "$bad" -eq 0 ] || return 1
2011
+ printf 'every declared entry path still resolves under %s, and none changed after its verified: date\n' "$sr"
2012
+ return 0
2013
+ }
2014
+
2015
+ check_va_01() {
2016
+ [ -n "$VERIFY_DOCTOR" ] || {
2017
+ printf 'verify_doctor: is empty - no doctor is declared, so "the generated skill was executed once" is not provable here\n'
2018
+ return 3
2019
+ }
2020
+ # The DECLARED command, run the same way GT-02 runs a declared gate. It is never a string read
2021
+ # out of source (the v0.2 blocker): the config is the thing a human wrote on purpose.
2022
+ local out rc
2023
+ out=$( cd "$ROOT" && bash -c "$VERIFY_DOCTOR" 2>&1 ); rc=$?
2024
+ if [ "$rc" -ne 0 ]; then
2025
+ printf 'verify_doctor: "%s" -> exit %d\n%s\n' "$VERIFY_DOCTOR" "$rc" "$(printf '%s' "$out" | head -n 3)"
2026
+ return 1
2027
+ fi
2028
+ printf 'verify_doctor: "%s" -> exit 0\n' "$VERIFY_DOCTOR"
2029
+ return 0
2030
+ }
2031
+
2032
+ # ---- the judge and the loop (G2): JG-01..JG-03, LP-01..LP-05 --------------------------------
2033
+ # A loop record is the committed artifact of ONE unattended run:
2034
+ #
2035
+ # .goblin/loop/predicate one shell command - the loop's own exit condition
2036
+ # .goblin/loop/predicate.sha256 its digest, recorded at loop start (the pin); after a close, the
2037
+ # chain follows on `previous: <digest>` lines (W5-7)
2038
+ # .goblin/loop/first-run "exit=<n> ts=<ISO8601>" - run BEFORE iteration 1
2039
+ # .goblin/loop/budget the turn budget the loop declared
2040
+ # .goblin/loop/decisions.tsv one row per iteration: ts phase decision why evidence result
2041
+ # .goblin/loop/stuck.md the write-up when the loop ended without its predicate green
2042
+ # .goblin/loop/closed-<date>/ a relaxed predicate AND the pin it was closed under, archived;
2043
+ # the live pin names its digest, so the close is auditable
2044
+ #
2045
+ # Nothing here re-runs the predicate and nothing here writes: the record is the evidence, and
2046
+ # what that costs is stated in docs/LOOP.md and docs/LIMITS.md.
2047
+
2048
+ loop_present() { [ -d "$LOOP_DIR" ]; }
2049
+
2050
+ # The one reason every LP row skips: no loop has run here, so there is nothing to audit.
2051
+ loop_no_record() { printf 'no loop has run in this repo\n'; return 3; }
2052
+
2053
+ # The verdict rows, in order: "<rownum><TAB><decision><TAB><evidence><TAB><result>".
2054
+ loop_decision_rows() {
2055
+ awk -F'\t' 'NR > 1 && NF >= 6 { print NR "\t" $3 "\t" $5 "\t" $6 }' "$LOOP_DIR/decisions.tsv" 2>/dev/null
2056
+ }
2057
+
2058
+ # A row with fewer columns than the header is a malformed record, and a malformed record is a
2059
+ # FAIL: a row the reader silently skipped is a row nothing checked.
2060
+ loop_malformed_rows() {
2061
+ awk -F'\t' 'NR > 1 && NF > 0 && NF < 6 { print NR }' "$LOOP_DIR/decisions.tsv" 2>/dev/null
2062
+ }
2063
+
2064
+ # Does ONE evidence token resolve? The typed handles are sha:<hex> (a commit `git rev-list
2065
+ # --all` knows), file:<path> (a path under the root) and sha256:<hex> (the digest of a file
2066
+ # under .goblin/loop/); a bare token resolves when it names an existing path. A `cmd:` token
2067
+ # resolves NOTHING on purpose: the command ran, its output is not in the record, and a verdict
2068
+ # that rests on it is exactly the self-report JG-01 exists to refuse.
2069
+ loop_token_resolves() {
2070
+ local t="$1" h p
2071
+ case "$t" in
2072
+ *..*) return 1 ;;
2073
+ esac
2074
+ case "$t" in
2075
+ sha:*)
2076
+ h=${t#sha:}
2077
+ case "$h" in ''|*[!0-9a-fA-F]*) return 1 ;; esac
2078
+ [ "${#h}" -ge 7 ] || return 1
2079
+ # NOT `git rev-list --all | grep -qi` : grep -q exits on the first match, git dies of
2080
+ # SIGPIPE (141), and with `set -o pipefail` the pipeline reports FAILURE for a commit that
2081
+ # IS in the repo. Measured: a seeded record's own HEAD resolved as false, rc 141. grep -c
2082
+ # reads to the end of the stream, so the count is what is compared.
2083
+ case "$( cd "$ROOT" && git rev-list --all 2>/dev/null | grep -ci "^$h" )" in
2084
+ ''|0) return 1 ;;
2085
+ esac
2086
+ return 0 ;;
2087
+ sha256:*)
2088
+ h=${t#sha256:}
2089
+ case "$h" in ''|*[!0-9a-fA-F]*) return 1 ;; esac
2090
+ [ "${#h}" -eq 64 ] || return 1
2091
+ for p in "$LOOP_DIR"/* "$LOOP_DIR"/closed-*/*; do
2092
+ [ -f "$p" ] || continue
2093
+ [ "$(g_sha256_file "$p")" = "$h" ] && return 0
2094
+ done
2095
+ return 1 ;;
2096
+ file:*)
2097
+ p=${t#file:}
2098
+ [ -n "$p" ] && [ -e "$ROOT/$p" ] && return 0
2099
+ return 1 ;;
2100
+ cmd:*|verdict:*|predicate:*|ts:*)
2101
+ return 1 ;;
2102
+ *)
2103
+ [ -n "$t" ] && [ -e "$ROOT/$t" ] && return 0
2104
+ return 1 ;;
2105
+ esac
2106
+ }
2107
+
2108
+ check_jg_01() {
2109
+ local f="$LOOP_DIR/decisions.tsv" n=0 bad=0 rn dec ev res tok hit mal
2110
+ [ -f "$f" ] || { printf 'no loop record in this repo - no judge verdict to check\n'; return 3; }
2111
+ mal=$(loop_malformed_rows | head -n 3 | paste -sd' ' -)
2112
+ if [ -n "$mal" ]; then
2113
+ printf 'decisions.tsv row(s) %s carry fewer columns than the header - a row nothing reads is a row nothing checks\n' "$mal"
2114
+ bad=1
2115
+ fi
2116
+ while IFS=$'\t' read -r rn dec ev res; do
2117
+ [ -n "$rn" ] || continue
2118
+ case "$dec" in verdict:*) ;; *) continue ;; esac
2119
+ n=$((n + 1))
2120
+ hit=0
2121
+ for tok in $(printf '%s' "$ev" | tr ',' ' '); do
2122
+ if loop_token_resolves "$tok"; then hit=1; break; fi
2123
+ done
2124
+ if [ "$hit" -eq 0 ]; then
2125
+ printf 'row %s: %s cites no handle that resolves: "%s"\n' "$rn" "$dec" "${ev:-<empty>}"
2126
+ printf ' a handle is sha:<hex> (a commit in this repo), file:<path>, sha256:<hex> (a file under .goblin/loop/), or an existing path\n'
2127
+ bad=1
2128
+ fi
2129
+ done < <(loop_decision_rows)
2130
+ [ "$bad" -eq 0 ] || return 1
2131
+ printf '%d judge verdict row(s), each citing a handle the repo resolves\n' "$n"
2132
+ return 0
2133
+ }
2134
+
2135
+ # The profiles a role resolves to (one per lane), or nothing when the role has no lane.
2136
+ role_lane_profiles() {
2137
+ resolve_role_models "$1" | awk -F'\t' 'NF >= 2 && $2 != "" { print $2 }' | sort -u
2138
+ }
2139
+
2140
+ # Non-zero when ANY lane of the role has no provider/model. BOTH sentinels count as unresolved:
2141
+ # the installed path prints `?` (resolve_role_models, `${prov:-?}`) and the checkout path prints
2142
+ # `unknown` - a check that greps for one of them passes on the other.
2143
+ role_unresolved() {
2144
+ local line p prov model
2145
+ while IFS=$'\t' read -r line p prov model _; do
2146
+ [ -n "$p" ] || continue
2147
+ case "$prov" in '?'|unknown|'') return 0 ;; esac
2148
+ case "$model" in '?'|unknown|'') return 0 ;; esac
2149
+ done < <(resolve_role_models "$1")
2150
+ return 1
2151
+ }
2152
+
2153
+ check_jg_02() {
2154
+ local judge_profiles code_profiles shared judge_unres code_unres
2155
+ if [ ! -f "$MODELS_FILE" ]; then
2156
+ printf 'no model mapping configured (%s absent) - advisory\n' "$MODELS_FILE"
2157
+ printf 'remedy: add a `judge:` entry beside the code lane in the file declared as models_file: in %s\n' "$CONFIG"
2158
+ return 2
2159
+ fi
2160
+ judge_profiles=$(role_lane_profiles judge)
2161
+ code_profiles=$(role_lane_profiles code)
2162
+ if [ -z "$judge_profiles" ] || [ -z "$code_profiles" ]; then
2163
+ printf 'roles.yaml did not resolve both lanes (judge: %s, code: %s) - advisory\n' \
2164
+ "${judge_profiles:-none}" "${code_profiles:-none}"
2165
+ printf 'remedy: role-judge needs a profiles: list and a profile for it in the mapping file\n'
2166
+ return 2
2167
+ fi
2168
+ role_unresolved judge; judge_unres=$?
2169
+ role_unresolved code; code_unres=$?
2170
+ if [ "$judge_unres" -eq 0 ] || [ "$code_unres" -eq 0 ]; then
2171
+ printf 'unresolved lane - advisory (judge: %s, code: %s)\n' \
2172
+ "$([ "$judge_unres" -eq 0 ] && printf 'no provider/model' || printf 'ok')" \
2173
+ "$([ "$code_unres" -eq 0 ] && printf 'no provider/model' || printf 'ok')"
2174
+ printf 'remedy: add the missing profile to the file declared as models_file: in %s - goblin-stack reads that file and never writes it\n' "$CONFIG"
2175
+ return 2
2176
+ fi
2177
+ shared=$(printf '%s\n' "$judge_profiles" | grep -Fxf <(printf '%s\n' "$code_profiles") | head -n 1)
2178
+ if [ -n "$shared" ]; then
2179
+ printf 'the judge lane and the author (code) lane share the profile "%s" - the judge is the author\n' "$shared"
2180
+ return 1
2181
+ fi
2182
+ printf 'judge lane [%s] and code lane [%s] are disjoint\n' \
2183
+ "$(printf '%s' "$judge_profiles" | paste -sd, -)" "$(printf '%s' "$code_profiles" | paste -sd, -)"
2184
+ return 0
2185
+ }
2186
+
2187
+ check_lp_01() {
2188
+ if ! loop_present; then loop_no_record; return 3; fi
2189
+ local p="$LOOP_DIR/predicate" f="$LOOP_DIR/first-run" n fr fr_ts row1_ts a b
2190
+ [ -f "$p" ] || { printf 'no .goblin/loop/predicate - the loop declared no exit condition\n'; return 1; }
2191
+ n=$(grep -cvE '^[[:space:]]*(#|$)' "$p")
2192
+ if [ "$n" -ne 1 ]; then
2193
+ printf 'the predicate must be exactly ONE command; found %s non-blank non-comment line(s)\n' "$n"
2194
+ return 1
2195
+ fi
2196
+ [ -f "$f" ] || { printf 'no .goblin/loop/first-run - the predicate was never run before iteration 1\n'; return 1; }
2197
+ fr=$(head -n 1 "$f")
2198
+ case "$fr" in
2199
+ "exit="[0-9]*" ts="*) ;;
2200
+ *) printf 'first-run must read "exit=<n> ts=<ISO8601>", got "%s"\n' "$fr"; return 1 ;;
2201
+ esac
2202
+ fr_ts=$(printf '%s' "$fr" | sed -n 's/.* ts=//p')
2203
+ case "$fr_ts" in
2204
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]*) ;;
2205
+ *) printf 'first-run ts is not an ISO8601 timestamp: "%s"\n' "$fr_ts"; return 1 ;;
2206
+ esac
2207
+ row1_ts=$(awk -F'\t' 'NR > 1 && NF >= 6 { print $1; exit }' "$LOOP_DIR/decisions.tsv" 2>/dev/null)
2208
+ if [ -n "$row1_ts" ]; then
2209
+ a=$fr_ts; b=$row1_ts
2210
+ if [ "$a" \> "$b" ]; then
2211
+ printf 'the recorded first run (%s) is AFTER the first log row (%s) - it did not run before iteration 1\n' "$fr_ts" "$row1_ts"
2212
+ return 1
2213
+ fi
2214
+ fi
2215
+ printf 'one predicate command; first run (%s) is at or before the first log row\n' "$fr"
2216
+ return 0
2217
+ }
2218
+
2219
+ check_lp_02() {
2220
+ if ! loop_present; then loop_no_record; return 3; fi
2221
+ local p="$LOOP_DIR/predicate" pin="$LOOP_DIR/predicate.sha256" want have bad=0
2222
+ [ -f "$p" ] || { printf 'no .goblin/loop/predicate to pin\n'; return 1; }
2223
+ [ -f "$pin" ] || { printf 'no .goblin/loop/predicate.sha256 - the predicate was never pinned at loop start\n'; return 1; }
2224
+ want=$(head -n 1 "$pin" | tr -d '[:space:]')
2225
+ have=$(g_sha256_file "$p")
2226
+ if [ "$want" != "$have" ]; then
2227
+ printf 'the predicate changed after the loop was pinned\n pinned %s\n now %s\n' "$want" "$have"
2228
+ printf 'remedy: close this loop - commit the green state, archive the predicate AND its pin under .goblin/loop/closed-<date>/, then open a new loop and re-record the pin with a `previous: <archived digest>` line. Never automatic.\n'
2229
+ return 1
2230
+ fi
2231
+ # W5-7: the close-and-reopen path. The pin covers the predicate file on disk, so a loop could
2232
+ # archive its old bar, write a weaker one, re-pin, and pass LP-02 and LP-05 with nothing in the
2233
+ # record - a relaxed predicate was indistinguishable from a legitimate re-scope. The chain below
2234
+ # cannot judge whether a predicate is WEAKER (nothing in bash can, docs/LIMITS.md #39); what it
2235
+ # can do is make the supersession RECORDED: the archive must be intact (its own pin agrees with
2236
+ # its own predicate) and the current pin must name the archived digest on a `previous:` line. So
2237
+ # a silent relaxation is caught, and a real re-scope costs one line.
2238
+ local d arcdig adig
2239
+ for d in "$LOOP_DIR"/closed-*; do
2240
+ [ -d "$d" ] || continue
2241
+ if [ ! -f "$d/predicate" ] || [ ! -f "$d/predicate.sha256" ]; then
2242
+ printf '%s: a closed loop must archive its predicate AND its pin - an archive that records nothing is not a close\n' "$(basename "$d")"
2243
+ bad=1; continue
2244
+ fi
2245
+ arcdig=$(head -n 1 "$d/predicate.sha256" | tr -d '[:space:]')
2246
+ adig=$(g_sha256_file "$d/predicate")
2247
+ if [ "$arcdig" != "$adig" ]; then
2248
+ printf '%s: the archived pin does not match the archived predicate\n pinned %s\n now %s\n' "$(basename "$d")" "$arcdig" "$adig"
2249
+ bad=1; continue
2250
+ fi
2251
+ if ! awk -v want="$adig" '
2252
+ NR > 1 { line = $0; sub(/^previous:[[:space:]]*/, "", line); sub(/[[:space:]]+$/, "", line); if (line == want) found = 1 }
2253
+ END { exit !found }
2254
+ ' "$pin"; then
2255
+ printf '%s: the current pin does not name the superseded predicate\n archived %s\n add a `previous: %s` line to %s - a close-and-reopen that records nothing is a silent relaxation\n' \
2256
+ "$(basename "$d")" "$adig" "$adig" "$pin"
2257
+ bad=1
2258
+ fi
2259
+ done
2260
+ [ "$bad" -eq 0 ] || return 1
2261
+ printf 'predicate pin ok (%s)%s\n' "$have" \
2262
+ "$([ -n "$(ls -d "$LOOP_DIR"/closed-* 2>/dev/null)" ] && printf ', every archived predicate named by a previous: line' || printf '')"
2263
+ return 0
2264
+ }
2265
+
2266
+ check_lp_03() {
2267
+ if ! loop_present; then loop_no_record; return 3; fi
2268
+ local f="$LOOP_DIR/budget" b ceil n bad=0
2269
+ [ -f "$f" ] || { printf 'no .goblin/loop/budget - the loop declared no turn budget\n'; return 1; }
2270
+ b=$(head -n 1 "$f" | tr -d '[:space:]')
2271
+ case "$b" in
2272
+ ''|*[!0-9]*) printf 'budget must be a positive integer, got "%s"\n' "$b"; return 1 ;;
2273
+ esac
2274
+ [ "$b" -gt 0 ] || { printf 'budget must be a positive integer, got "%s"\n' "$b"; return 1; }
2275
+ ceil=$LOOP_MAX_TURNS_CEILING
2276
+ case "$ceil" in ''|*[!0-9]*) ceil=20 ;; esac
2277
+ if [ "$b" -gt "$ceil" ]; then
2278
+ printf 'budget %s exceeds loop_max_turns_ceiling %s in the config\n' "$b" "$ceil"
2279
+ bad=1
2280
+ fi
2281
+ if [ ! -f "$LOOP_DIR/decisions.tsv" ]; then
2282
+ printf 'no decisions.tsv - the loop declared a budget and kept no record\n'
2283
+ return 1
2284
+ fi
2285
+ n=$(awk -F'\t' 'NR > 1 && NF >= 6 && $3 ~ /^verdict:/ { c++ } END { print c+0 }' "$LOOP_DIR/decisions.tsv")
2286
+ if [ "$n" -gt "$b" ]; then
2287
+ printf 'the record holds %s verdict row(s) against a declared budget of %s\n' "$n" "$b"
2288
+ bad=1
2289
+ fi
2290
+ [ "$bad" -eq 0 ] || return 1
2291
+ printf 'budget %s (ceiling %s), %s verdict row(s) recorded\n' "$b" "$ceil" "$n"
2292
+ return 0
2293
+ }
2294
+
2295
+ check_lp_04() {
2296
+ if ! loop_present; then loop_no_record; return 3; fi
2297
+ [ -f "$LOOP_DIR/decisions.tsv" ] || { printf 'no decisions.tsv to read\n'; return 1; }
2298
+ local prev_ev="" streak=0 nums="" rn dec ev res
2299
+ while IFS=$'\t' read -r rn dec ev res; do
2300
+ [ -n "$rn" ] || continue
2301
+ case "$dec" in verdict:*) ;; *) continue ;; esac
2302
+ if [ -n "$ev" ] && [ "$ev" = "$prev_ev" ] && [ "$res" != "predicate:green" ]; then
2303
+ streak=$((streak + 1))
2304
+ nums="$nums $rn"
2305
+ else
2306
+ streak=1
2307
+ nums=" $rn"
2308
+ fi
2309
+ prev_ev="$ev"
2310
+ if [ "$streak" -ge 3 ]; then
2311
+ printf 'no progress: rows%s share the evidence pointer "%s" and none reached predicate:green\n' "$nums" "$ev"
2312
+ printf ' a loop making no progress stops and writes up why; it does not spend its budget thrashing\n'
2313
+ return 1
2314
+ fi
2315
+ done < <(loop_decision_rows)
2316
+ printf 'no three consecutive verdict rows share an evidence pointer without reaching predicate:green\n'
2317
+ return 0
2318
+ }
2319
+
2320
+ check_lp_05() {
2321
+ if ! loop_present; then loop_no_record; return 3; fi
2322
+ [ -f "$LOOP_DIR/decisions.tsv" ] || { printf 'no decisions.tsv to read\n'; return 1; }
2323
+ local last rn res s n
2324
+ last=$(awk -F'\t' 'NR > 1 && NF >= 6 && $3 ~ /^verdict:/ { rn = NR; res = $6 } END { if (rn) print rn "\t" res }' "$LOOP_DIR/decisions.tsv")
2325
+ if [ -z "$last" ]; then
2326
+ printf 'the record holds no verdict row - there is no closing state to audit\n'
2327
+ return 3
2328
+ fi
2329
+ rn=${last%%$'\t'*}
2330
+ res=${last#*$'\t'}
2331
+ if [ "$res" = "predicate:green" ]; then
2332
+ printf 'the last verdict row (row %s) reached predicate:green - no write-up required\n' "$rn"
2333
+ return 0
2334
+ fi
2335
+ s="$LOOP_DIR/stuck.md"
2336
+ [ -f "$s" ] || { printf 'the loop ended at "%s" (row %s) and left no .goblin/loop/stuck.md\n' "${res:-<empty>}" "$rn"; return 1; }
2337
+ n=$(grep -c '[^[:space:]]' "$s")
2338
+ if [ "$n" -lt 3 ]; then
2339
+ printf 'stuck.md holds %s non-blank line(s); a write-up needs at least 3\n' "$n"
2340
+ return 1
2341
+ fi
2342
+ grep -q 'predicate' "$s" || { printf 'stuck.md does not name the predicate it failed to satisfy\n'; return 1; }
2343
+ printf 'the loop ended at "%s" (row %s) and stuck.md names the predicate in %s line(s)\n' "$res" "$rn" "$n"
2344
+ return 0
2345
+ }
2346
+
2347
+ # ---- the reference corpus (P15): RC-01..RC-04 ------------------------------------------------
2348
+ # P15's machine half. All four read DECLARED values - `reference_manifest:` and the
2349
+ # `security: build_output:` block SC-03 clause 2 already reads - and never infer a path from the
2350
+ # stack. They are builtins because a shell one-liner cannot SKIP: the runner turns any non-zero
2351
+ # shell exit into a FAIL, so a repo with no corpus would be born RED (see the dispatch below).
2352
+
2353
+ rc_path() { # the declared manifest, as an absolute path
2354
+ case "$REFERENCE_MANIFEST" in
2355
+ /*) printf '%s' "$REFERENCE_MANIFEST" ;;
2356
+ *) printf '%s' "$ROOT/$REFERENCE_MANIFEST" ;;
2357
+ esac
2358
+ }
2359
+
2360
+ # rc_parse <mode> <manifest> — mode `entries` prints one "sha256<TAB>path" line per entry; mode
2361
+ # `validate` prints one line per schema violation. Both exit 1 when the file cannot be read or is
2362
+ # not the shape they expect, and both print to stdout so the caller can show the reason.
2363
+ rc_parse() {
2364
+ python3 -c '
2365
+ import json, re, sys
2366
+ mode, path = sys.argv[1], sys.argv[2]
2367
+ hex64 = re.compile(r"^[0-9a-fA-F]{64}$")
2368
+ try:
2369
+ with open(path, encoding="utf-8", errors="replace") as fh:
2370
+ doc = json.load(fh)
2371
+ except Exception as exc:
2372
+ sys.stdout.write("unparseable JSON: %s\n" % exc)
2373
+ sys.exit(1)
2374
+ if not isinstance(doc, dict):
2375
+ sys.stdout.write("the manifest is not a JSON object\n")
2376
+ sys.exit(1)
2377
+ entries = doc.get("entries")
2378
+ if mode == "entries":
2379
+ if not isinstance(entries, list):
2380
+ sys.stdout.write("entries is not a list\n")
2381
+ sys.exit(1)
2382
+ for e in entries:
2383
+ if isinstance(e, dict):
2384
+ sys.stdout.write("%s\t%s\n" % (e.get("sha256", ""), e.get("path", "")))
2385
+ sys.exit(0)
2386
+ bad = []
2387
+ def want(cond, msg):
2388
+ if not cond:
2389
+ bad.append(msg)
2390
+ want(doc.get("schema") == "reference-manifest/1", "schema is not reference-manifest/1")
2391
+ gf = doc.get("generated_from")
2392
+ want(isinstance(gf, str) and gf.strip() != "", "generated_from is empty")
2393
+ app = doc.get("reference_app")
2394
+ want(isinstance(app, dict), "reference_app is missing")
2395
+ if isinstance(app, dict):
2396
+ for key in ("package", "version"):
2397
+ v = app.get(key)
2398
+ want(isinstance(v, str) and v.strip() != "", "reference_app.%s is empty" % key)
2399
+ want(bool(hex64.match(str(app.get("apk_sha256", "")))), "reference_app.apk_sha256 is not 64 hex")
2400
+ want(isinstance(entries, list) and len(entries) > 0, "entries is empty")
2401
+ if isinstance(entries, list):
2402
+ for i, e in enumerate(entries):
2403
+ if not isinstance(e, dict):
2404
+ bad.append("entry %d is not an object" % i)
2405
+ continue
2406
+ p = e.get("path")
2407
+ want(isinstance(p, str) and p.strip() != "", "entry %d: path is empty" % i)
2408
+ want(bool(hex64.match(str(e.get("sha256", "")))), "entry %d: sha256 is not 64 hex" % i)
2409
+ want(isinstance(e.get("bytes"), int) and not isinstance(e.get("bytes"), bool),
2410
+ "entry %d: bytes is not a number" % i)
2411
+ ec = doc.get("entry_count")
2412
+ want(isinstance(ec, int) and not isinstance(ec, bool) and ec == len(entries),
2413
+ "entry_count does not equal len(entries)")
2414
+ for m in bad:
2415
+ sys.stdout.write("%s\n" % m)
2416
+ sys.exit(1 if bad else 0)
2417
+ ' "$1" "$2"
2418
+ }
2419
+
2420
+ # rc_build_output_files — every regular file under each declared build-output dir. The key is
2421
+ # space-separated and a directory that does not exist is skipped, exactly as SC-03 clause 2 does.
2422
+ rc_build_output_files() {
2423
+ local d
2424
+ for d in $SEC_BUILD_OUTPUT; do
2425
+ [ -d "$ROOT/$d" ] || continue
2426
+ find "$ROOT/$d" -type f 2>/dev/null
2427
+ done
2428
+ }
2429
+
2430
+ # rc_in_build_output <abs path> — is that path inside one of the declared build-output dirs?
2431
+ rc_in_build_output() {
2432
+ local p="$1" d
2433
+ for d in $SEC_BUILD_OUTPUT; do
2434
+ case "$p" in "$ROOT/$d"/*) return 0 ;; esac
2435
+ done
2436
+ return 1
2437
+ }
2438
+
2439
+ check_rc_01() {
2440
+ # Four clauses. (1) `reference_manifest:` empty is the ONE skip, and it carries its reason.
2441
+ # (2) declared but missing or unparseable is a FAIL - losing the manifest must not read as "no
2442
+ # corpus". (3) any file under the declared build output whose sha256 appears in an entry's
2443
+ # sha256 fails, naming the path and the entry. (4) the manifest itself is not shippable.
2444
+ if [ -z "$REFERENCE_MANIFEST" ]; then
2445
+ printf 'reference_manifest: is empty - no reference corpus is declared, so there is nothing for a build to match against\n'
2446
+ return 3
2447
+ fi
2448
+ local mf; mf=$(rc_path)
2449
+ if [ ! -f "$mf" ]; then
2450
+ printf 'reference_manifest: %s does not exist - a declared corpus that is absent is a hole, not a skip\n' "$REFERENCE_MANIFEST"
2451
+ return 1
2452
+ fi
2453
+ local entries
2454
+ if ! entries=$(rc_parse entries "$mf"); then
2455
+ printf 'reference_manifest: %s is not parseable - a declared corpus that cannot be read is a FAIL\n' "$REFERENCE_MANIFEST"
2456
+ printf '%s\n' "$entries" | sed 's/^/ /'
2457
+ return 1
2458
+ fi
2459
+ local n_entries; n_entries=$(printf '%s\n' "$entries" | grep -c . || true)
2460
+ local bad=0 out_n=0 f h hit mh
2461
+ mh=$(g_sha256_file "$mf")
2462
+ # clause 4, both halves of "the manifest is not itself shippable": the declared path must not
2463
+ # live inside the build output, and no COPY of the manifest may sit there either - the manifest
2464
+ # is a listing of every corpus hash, so shipping it ships the corpus's shape.
2465
+ if rc_in_build_output "$mf"; then
2466
+ printf 'the reference manifest itself sits inside the declared build output: %s\n' "${mf#"$ROOT"/}"
2467
+ bad=1
2468
+ fi
2469
+ while IFS= read -r f; do
2470
+ [ -n "$f" ] || continue
2471
+ out_n=$((out_n + 1))
2472
+ h=$(g_sha256_file "$f")
2473
+ if [ "$h" = "$mh" ]; then
2474
+ printf 'a copy of the reference manifest is in the build output: %s\n' "${f#"$ROOT"/}"
2475
+ bad=1
2476
+ continue
2477
+ fi
2478
+ hit=$(printf '%s\n' "$entries" | awk -F'\t' -v h="$h" '$1 == h { print $2; exit }')
2479
+ if [ -n "$hit" ]; then
2480
+ printf 'a reference-corpus payload is in the build output: %s (entry %s)\n' "${f#"$ROOT"/}" "$hit"
2481
+ bad=1
2482
+ fi
2483
+ done < <(rc_build_output_files)
2484
+ [ "$bad" -eq 0 ] || return 1
2485
+ if [ "$out_n" -eq 0 ]; then
2486
+ printf '%s entry/entries; no build output to scan (security: build_output: names no directory that exists)\n' "$n_entries"
2487
+ else
2488
+ printf '%s entry/entries; %s build-output file(s) hashed, none matches\n' "$n_entries" "$out_n"
2489
+ fi
2490
+ return 0
2491
+ }
2492
+
2493
+ check_rc_02() {
2494
+ # The vacuous-pass guard: RC-01 cannot pass on a manifest that was authored weak. SKIPs with
2495
+ # RC-01's reason when the key is empty; a declared manifest that is absent or ill-shaped is a
2496
+ # FAIL, never a skip.
2497
+ if [ -z "$REFERENCE_MANIFEST" ]; then
2498
+ printf 'reference_manifest: is empty - there is no manifest to shape-check (RC-01 skips for the same reason)\n'
2499
+ return 3
2500
+ fi
2501
+ local mf; mf=$(rc_path)
2502
+ if [ ! -f "$mf" ]; then
2503
+ printf 'reference_manifest: %s does not exist - a declared manifest that is absent is a hole, not a skip\n' "$REFERENCE_MANIFEST"
2504
+ return 1
2505
+ fi
2506
+ local errs=""
2507
+ errs=$(rc_parse validate "$mf") && {
2508
+ printf '%s is shaped so RC-01 cannot pass vacuously (schema, provenance, non-empty entries, entry_count)\n' "$REFERENCE_MANIFEST"
2509
+ return 0
2510
+ }
2511
+ printf '%s is not a usable reference manifest:\n' "$REFERENCE_MANIFEST"
2512
+ printf '%s\n' "$errs" | sed 's/^/ /'
2513
+ return 1
2514
+ }
2515
+
2516
+ check_rc_03() {
2517
+ # Two clauses over the LAB repo: every tracked path is inside the declared allowlist, and no
2518
+ # tracked file's sha256 equals a hash in a manifests/*.sha256. The manifest lists paths and
2519
+ # hashes, which the lab's own README permits - the check is about BYTES, so the manifest is
2520
+ # never read as a violation (its rows name files under the quarantine anchor, outside the repo).
2521
+ if [ ! -d "$ROOT/manifests" ]; then
2522
+ printf 'no manifests/ directory - this is not a lab repo with a reference corpus, so there are no tracked bytes to check\n'
2523
+ return 3
2524
+ fi
2525
+ local mfs; mfs=$(ls "$ROOT"/manifests/*.sha256 2>/dev/null || true)
2526
+ if [ -z "$mfs" ]; then
2527
+ printf 'manifests/ exists but holds no *.sha256 - a corpus manifest directory that cannot be read is a FAIL, not a skip\n'
2528
+ return 1
2529
+ fi
2530
+ local bad=0 p h m hashes="" n=0
2531
+ for m in $mfs; do
2532
+ hashes="$hashes$(awk '!/^#/ && NF >= 2 { print $1 }' "$m")"$'\n'
2533
+ done
2534
+ while IFS= read -r p; do
2535
+ [ -n "$p" ] || continue
2536
+ n=$((n + 1))
2537
+ case "$p" in
2538
+ scripts/*|notes/*|manifests/*) continue ;;
2539
+ # The harness's own directories and the .gitignore block it appends are the install, not the
2540
+ # lab's content - the same exclusion FM-02 makes for the same reason (W5-4). That set is the
2541
+ # one the class-A install contract creates: .goblin/, .hermes/, the .gitignore block, the
2542
+ # declared harness_dir (checks/ by default), reviews/, and the CI scaffold .github/. A payload
2543
+ # hidden in any of them is still caught by clause 2 (the sha256 sweep below).
2544
+ .goblin/*|.hermes/*|.gitignore|.github/*|reviews/*) continue ;;
2545
+ "${GOBLIN_HARNESS_REL:-checks}"/*) continue ;;
2546
+ esac
2547
+ case "$p" in
2548
+ */*) bad=1; printf 'a tracked path outside the lab allowlist (scripts/, notes/, manifests/, root docs): %s\n' "$p" ;;
2549
+ *.md) ;; # a root-level doc
2550
+ *) bad=1; printf 'a tracked path outside the lab allowlist (scripts/, notes/, manifests/, root docs): %s\n' "$p" ;;
2551
+ esac
2552
+ done < <( cd "$ROOT" && git ls-files )
2553
+ while IFS= read -r p; do
2554
+ [ -n "$p" ] || continue
2555
+ h=$(g_sha256_file "$ROOT/$p")
2556
+ if printf '%s' "$hashes" | grep -qxF "$h"; then
2557
+ printf 'a reference-corpus payload is TRACKED in the lab repo: %s\n' "$p"
2558
+ bad=1
2559
+ fi
2560
+ done < <( cd "$ROOT" && git ls-files )
2561
+ [ "$bad" -eq 0 ] || return 1
2562
+ printf '%s tracked file(s): all inside the lab allowlist, none matching a corpus hash\n' "$n"
2563
+ return 0
2564
+ }
2565
+
2566
+ check_rc_04() {
2567
+ # The acquisition record, and the weakest of the four: it proves a record EXISTS, never that
2568
+ # the number beside it came from the store. SKIPs when no manifest is declared; a manifest with
2569
+ # no header block is a FAIL.
2570
+ local mfs; mfs=$(ls "$ROOT"/manifests/*.sha256 2>/dev/null || true)
2571
+ if [ -z "$mfs" ]; then
2572
+ printf 'no manifests/*.sha256 - no acquisition record is declared, so there is none to read\n'
2573
+ return 3
2574
+ fi
2575
+ local bad=0 m hdr tgt src c64 apk apkh n=0
2576
+ for m in $mfs; do
2577
+ n=$((n + 1))
2578
+ hdr=$(awk '/^#/ { print; next } { exit }' "$m")
2579
+ if [ -z "$hdr" ]; then
2580
+ printf '%s: no header block - the acquisition record is absent\n' "${m#"$ROOT"/}"
2581
+ bad=1; continue
2582
+ fi
2583
+ tgt=$(rc_hdr_field "$hdr" target)
2584
+ src=$(rc_hdr_field "$hdr" source)
2585
+ c64=$(printf '%s\n' "$hdr" | grep -oiE '[0-9a-f]{64}' | head -n 1)
2586
+ # the header's own apk path
2587
+ apk=$(printf '%s' "$tgt" | grep -oiE '[A-Za-z0-9._-]+\.apk' | head -n 1)
2588
+ apkh=$(awk '!/^#/ && NF >= 2 { print $1 "\t" $2 }' "$m" | awk -F'\t' 'tolower($2) ~ /\.apk$/ { print; exit }')
2589
+ if [ -z "$tgt" ] || ! printf '%s' "$tgt" | grep -qE '[0-9]+\.[0-9]+'; then
2590
+ printf '%s: the header names no target slug + version (target: <file> <version>)\n' "${m#"$ROOT"/}"; bad=1
2591
+ fi
2592
+ if [ -z "$src" ] || ! printf '%s' "$src" | grep -qE '[A-Za-z0-9.-]+\.[A-Za-z]{2,}'; then
2593
+ printf '%s: the header names no source store (source: <store> [; <checksum>])\n' "${m#"$ROOT"/}"; bad=1
2594
+ fi
2595
+ if ! printf '%s\n' "$hdr" | grep -qoiE '[0-9a-f]{32}([^0-9a-f]|$)'; then
2596
+ printf '%s: the header carries no checksum (md5 or sha256 hex)\n' "${m#"$ROOT"/}"; bad=1
2597
+ fi
2598
+ if [ -z "$apkh" ]; then
2599
+ printf '%s: the manifest holds no *.apk row\n' "${m#"$ROOT"/}"; bad=1
2600
+ elif [ -n "$apk" ] && [ "${apkh#*$'\t'}" != "" ]; then
2601
+ local rowbase; rowbase=${apkh#*$'\t'}; rowbase=${rowbase##*/}
2602
+ if [ "$apk" != "$rowbase" ]; then
2603
+ printf '%s: the header names %s but the manifest rows %s\n' "${m#"$ROOT"/}" "$apk" "$rowbase"; bad=1
2604
+ fi
2605
+ fi
2606
+ if [ -n "$c64" ] && [ -n "$apkh" ]; then
2607
+ local rowhash; rowhash=${apkh%%$'\t'*}
2608
+ [ "$c64" = "$rowhash" ] || { printf '%s: the header sha256 %s disagrees with the apk row %s\n' "${m#"$ROOT"/}" "$c64" "$rowhash"; bad=1; }
2609
+ fi
2610
+ done
2611
+ [ "$bad" -eq 0 ] || return 1
2612
+ printf '%s acquisition record(s): target + version, source store, checksum, apk row\n' "$n"
2613
+ return 0
2614
+ }
2615
+
2616
+ # rc_hdr_field <header text> <key> — the value of a `# key : value` header line, case-insensitive.
2617
+ rc_hdr_field() {
2618
+ printf '%s\n' "$1" | awk -v k="$2" '
2619
+ /^#[[:space:]]*[A-Za-z_]+[[:space:]]*:/ {
2620
+ line = $0; sub(/^#[[:space:]]*/, "", line)
2621
+ key = line; sub(/:.*/, "", key); gsub(/[[:space:]]/, "", key)
2622
+ if (tolower(key) == tolower(k)) { v = line; sub(/^[^:]*:[[:space:]]*/, "", v); print v; exit }
2623
+ }
2624
+ '
2625
+ }
2626
+
2627
+ # ================================================================ runner ======
2628
+
2629
+ PASS=0; FAIL=0; ADV=0; SKIP=0; BAD=0
2630
+ JSON_ROWS=""
2631
+
2632
+ # json_escape <text> — a JSON string body. The detail of a check is arbitrary shell output
2633
+ # (quotes, backslashes, newlines), so it must be escaped or --json emits invalid JSON (D3).
2634
+ json_escape() {
2635
+ local s="$1"
2636
+ s=${s//\\/\\\\}
2637
+ s=${s//\"/\\\"}
2638
+ s=${s//$'\n'/\\n}
2639
+ s=${s//$'\r'/\\r}
2640
+ s=${s//$'\t'/\\t}
2641
+ printf '%s' "$s"
2642
+ }
2643
+
2644
+ emit() { # id status detail
2645
+ if [ "$JSON" -eq 1 ]; then
2646
+ JSON_ROWS="$JSON_ROWS{\"id\":\"$(json_escape "$1")\",\"status\":\"$(json_escape "$2")\",\"detail\":\"$(json_escape "$3")\"},"
2647
+ else
2648
+ case "$2" in
2649
+ PASS) g_pass "$1" "$3" ;;
2650
+ FAIL) g_fail "$1" "$3" ;;
2651
+ ADV) g_adv "$1" "$3" ;;
2652
+ SKIP) g_skip "$1" "$3" ;;
2653
+ esac
2654
+ fi
2655
+ }
2656
+
2657
+ while IFS=$'\t' read -r id scope rule enforced_by artifact check why; do
2658
+ [ "$id" = "id" ] && continue
2659
+ [ -n "$id" ] || continue
2660
+ [ "$scope" = "target" ] || continue
2661
+ only_selected "$id" || continue
2662
+
2663
+ part=$(row_part "$id")
2664
+ if [ -n "$part" ] && g_part_disabled "$CONFIG" "$part"; then
2665
+ SKIP=$((SKIP + 1)); emit "$id" SKIP "$rule (opt-out: $part)"; continue
2666
+ fi
2667
+ if [ "$ARCHIVE" = "true" ]; then
2668
+ case "$id" in
2669
+ HP-*|GT-*|SP-*) SKIP=$((SKIP + 1)); emit "$id" SKIP "$rule (archive: true)"; continue ;;
2670
+ esac
2671
+ fi
2672
+
2673
+ case "$check" in
2674
+ advisory)
2675
+ ADV=$((ADV + 1))
2676
+ emit "$id" ADV "$rule (advisory)"
2677
+ continue
2678
+ ;;
2679
+ goblin-verify\ --only\ *)
2680
+ fn="check_$(printf '%s' "$id" | tr 'A-Z' 'a-z' | tr '-' '_')"
2681
+ if ! command -v "$fn" >/dev/null 2>&1 && ! declare -F "$fn" >/dev/null 2>&1; then
2682
+ BAD=1; emit "$id" FAIL "$rule (no builtin implementation: $fn)"; continue
2683
+ fi
2684
+ detail=$("$fn" 2>&1); rc=$?
2685
+ ;;
2686
+ *)
2687
+ detail=$( cd "$ROOT" && bash -c "$check" 2>&1 ); rc=$?
2688
+ # A shell check has no advisory state: anything non-zero is a FAIL. Only builtins
2689
+ # (the 'goblin-verify --only <ID>' marker rows) may return 2 = advisory or 3 = skip.
2690
+ if [ "$rc" -eq 0 ]; then
2691
+ [ -n "$detail" ] || detail="($check)"
2692
+ else
2693
+ detail="($check -> exit $rc)"
2694
+ rc=1
2695
+ fi
2696
+ ;;
2697
+ esac
2698
+
2699
+ case "$rc" in
2700
+ 0) PASS=$((PASS + 1)); emit "$id" PASS "$detail" ;;
2701
+ 1) FAIL=$((FAIL + 1)); emit "$id" FAIL "$detail" ;;
2702
+ 2) ADV=$((ADV + 1)); emit "$id" ADV "$detail" ;;
2703
+ 3) SKIP=$((SKIP + 1)); emit "$id" SKIP "$detail" ;;
2704
+ *) FAIL=$((FAIL + 1)); emit "$id" FAIL "$detail (exit $rc)" ;;
2705
+ esac
2706
+ done < "$MANIFEST"
2707
+
2708
+ if [ "$JSON" -eq 1 ]; then
2709
+ printf '{"root":"%s","class":"%s","passed":%d,"failed":%d,"advisory":%d,"skipped":%d,"rows":[%s]}\n' \
2710
+ "$(json_escape "$ROOT")" "$(json_escape "$CLASS")" "$PASS" "$FAIL" "$ADV" "$SKIP" "${JSON_ROWS%,}"
2711
+ else
2712
+ printf ' %d passed, %d failed, %d advisory, %d skipped\n' "$PASS" "$FAIL" "$ADV" "$SKIP"
2713
+ # Z1-7: the advisory number above counts TWO different things - the rows the matrix LABELS
2714
+ # advisory, and a row that is not advisory-labelled but whose lane cannot be resolved here
2715
+ # (JG-02's unresolved judge lane returns 2 = ADV by design). SK-03 counts the first, the ADV
2716
+ # lines on screen are both, so a reader comparing `advisory 10 of ceiling 10` with 11 ADV lines
2717
+ # saw a mismatch that only the row's why-cell explained. Print the arithmetic.
2718
+ adv_labelled=$(awk -F'\t' 'NR>1 && ($4=="advisory" || $6=="advisory") {n++} END{print n+0}' "$MANIFEST")
2719
+ if [ "$ADV" -ne "$adv_labelled" ]; then
2720
+ printf ' of those %d advisory: the matrix labels %d row(s) advisory; the difference is the non-advisory row(s) reporting ADV by design (an unresolved lane) and any labelled row skipped by an opt-out\n' \
2721
+ "$ADV" "$adv_labelled"
2722
+ fi
2723
+ cat <<'SEE'
2724
+ cannot see: whether a check in the harness dir tests the right path rather than
2725
+ merely passing; whether the forge is bound by the workflow CL-01 found; whether a human
2726
+ read the diff;
2727
+ whether the model mapping names a family that actually differs (MD-02 reports it
2728
+ and cannot change it); and whether .goblin/installed.json — the record every drift
2729
+ check trusts — was itself rewritten. It is not signed, so one edit to it (with the
2730
+ matching edit to the file it protects) defeats IN-02, SK-02 and HS-01 at once
2731
+ (docs/LIMITS.md #18). The ban lane carries the same blind spot and two of its own:
2732
+ the ban table is a manifest file under the same unsigned record, so a defanged ban
2733
+ passes BN-00 (docs/LIMITS.md #28); a ban probe is a TEXT probe with no AST, so a
2734
+ `Record<string, any>` is missed and a `: any` inside a string is reported
2735
+ (docs/LIMITS.md #27); and no check here can reach a ban IN CONTEXT — the row proves
2736
+ the forbidden path fails mechanically, never that the ban reached the agent that
2737
+ wrote the line. The judge/loop lane adds its own: every JG-/LP- row reads a RECORD, so
2738
+ it proves a handle, a pin, a budget and a pointer exist — never that the loop made
2739
+ progress (a changed pointer is a proxy, docs/LIMITS.md #33), never which lane returned
2740
+ a verdict, and never that the predicate was the right one or was really run. The CI lane
2741
+ adds its own: PG-05 and PG-06 read a workflow's TEXT, so PG-05 sees a job whose gate step is
2742
+ conditional and cannot see branch protection, the required-check list, or a forge that never
2743
+ ran the job at all, and PG-06 proves a declared gate is INVOKED in a file under
2744
+ .github/workflows/ — never that the forge marks that job required, never that it is the job
2745
+ the forge waits on, and never that the workflow can fail (that is PG-05). A required check
2746
+ that reports Success after skipping its gate is a green light for a commit whose gate never
2747
+ ran (docs/CI.md). The reference-corpus lane adds its own: RC-01 matches EXACT sha256 only,
2748
+ so a resized, recoloured or re-encoded reference asset, or copied text inside a shipped
2749
+ string, passes it unseen; RC-02 proves the manifest is well-shaped, never that its entries
2750
+ are the right hashes (as strong as tamper hashing, and installed.json is unsigned); RC-03
2751
+ reads the tracked tree and cannot see a payload renamed and re-encoded; and RC-04 proves an
2752
+ acquisition record EXISTS, never that the number beside it came from the store (HP-03's
2753
+ defect, one artifact over). No row here reaches a reference asset in CONTEXT either.
2754
+ SEE
2755
+ # ---- the engine footer (W1 §2.2, R-1's residue -> docs/LIMITS.md #43) --------
2756
+ # Every run states WHICH engine judged it: the sha256 of the running verifier and of
2757
+ # the manifest that judged this run, so a repo can name its judge. The statement is
2758
+ # UNSIGNED and forgeable - one forgery now covers N repos - which is the hole LIMITS
2759
+ # #43 records, deliberately not fixed in W1 (G6).
2760
+ # W6: the banner goes to STDERR, not stdout (LIM-51). A consumer capturing verify's
2761
+ # output parses check lines; a footer line interleaved with them is parser friction
2762
+ # (docs/LIMITS.md #49 recorded the cost before the fix). The banner is a STATEMENT,
2763
+ # not a verdict line - diagnostics belong on the error stream. Captured stdout after
2764
+ # the move carries PASS/FAIL/ADV/SKIP lines and the summary only; the banner stays
2765
+ # visible in a terminal and in any `2>&1` merge (every suite that asserts the
2766
+ # footer merges the streams).
2767
+ printf ' engine: mode=%s' "$ENGINE_MODE" >&2
2768
+ if [ -n "$GOBLIN_ENGINE" ]; then printf ' engine_dir=%s' "$GOBLIN_ENGINE" >&2; fi
2769
+ printf ' cli_sha256=%s enforcement_tsv_sha256=%s\n' \
2770
+ "$(g_sha256_file "$SELF_DIR/goblin-verify")" \
2771
+ "$(g_sha256_file "$MANIFEST")" >&2
2772
+ # ---- the shadow advisory (W3 §3.2) -------------------------------------------
2773
+ # A vendored payload under .goblin/ while the record says mode=global: the chain's
2774
+ # vendored step (2) beats the declared engine_dir (3) — W1's ordering, kept
2775
+ # deliberately — so a STALE vendored table judged this run while the footer above
2776
+ # still prints mode=global. This line names that split. It is deliberately a footer
2777
+ # ADVISORY, not a manifest row: the advisory ceiling is `advisory 10 of ceiling 10`
2778
+ # on every migrated repo (SK-03 measured at W3-SPEC MA5, 0 free slots), so a new row
2779
+ # would fail every migrated repo — and adding a row is a G2 rule change W3 has no
2780
+ # mandate for. Detector, not prevention: the residue is LIMITS #45.
2781
+ if [ "$ENGINE_MODE" = "global" ]; then
2782
+ _shadow_n=0
2783
+ for _f in "$ROOT"/.goblin/bin/goblin-verify "$ROOT"/.goblin/bin/goblin-lib.sh \
2784
+ "$ROOT"/.goblin/bin/goblin-audit "$ROOT"/.goblin/bin/goblin-bans \
2785
+ "$ROOT"/.goblin/manifest/* "$ROOT"/.goblin/bans/* \
2786
+ "$ROOT"/.goblin/automations/* "$ROOT"/.goblin/roles.yaml; do
2787
+ [ -e "$_f" ] || continue
2788
+ _shadow_n=$((_shadow_n + 1))
2789
+ done
2790
+ if [ "$_shadow_n" -gt 0 ]; then
2791
+ printf ' shadow: vendored engine payload present (%d file(s)) - the vendored table judged this run, not the declared engine_dir\n' "$_shadow_n"
2792
+ fi
2793
+ unset _shadow_n _f
2794
+ fi
2795
+ fi
2796
+
2797
+ if [ "$FAIL" -gt 0 ] || [ "$BAD" -eq 1 ]; then exit 1; fi
2798
+ exit 0