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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/VERSION +1 -0
  5. package/adapters/_template/adapter.tsv +16 -0
  6. package/adapters/_template/detect.sh +10 -0
  7. package/adapters/_template/emit.sh +5 -0
  8. package/adapters/_template/verify.sh +4 -0
  9. package/adapters/claude/adapter.tsv +8 -0
  10. package/adapters/claude/detect.sh +8 -0
  11. package/adapters/claude/verify.sh +47 -0
  12. package/adapters/codex/adapter.tsv +12 -0
  13. package/adapters/codex/detect.sh +9 -0
  14. package/adapters/codex/verify.sh +45 -0
  15. package/adapters/copilot/adapter.tsv +10 -0
  16. package/adapters/copilot/detect.sh +8 -0
  17. package/adapters/copilot/verify.sh +45 -0
  18. package/adapters/cursor/adapter.tsv +11 -0
  19. package/adapters/cursor/detect.sh +10 -0
  20. package/adapters/cursor/verify.sh +45 -0
  21. package/adapters/gemini/adapter.tsv +15 -0
  22. package/adapters/gemini/detect.sh +11 -0
  23. package/adapters/gemini/verify.sh +49 -0
  24. package/adapters/hermes/adapter.tsv +9 -0
  25. package/adapters/hermes/detect.sh +8 -0
  26. package/adapters/hermes/verify.sh +27 -0
  27. package/adapters/opencode/adapter.tsv +14 -0
  28. package/adapters/opencode/detect.sh +9 -0
  29. package/adapters/opencode/verify.sh +45 -0
  30. package/automations/README.md +53 -0
  31. package/automations/bugreporter-intake.sh +145 -0
  32. package/automations/drift-audit.sh +139 -0
  33. package/automations/report.schema.tsv +10 -0
  34. package/bans/README.md +82 -0
  35. package/bans/grep-ban.sh +84 -0
  36. package/bans/layer-check.sh +57 -0
  37. package/bin/goblin +119 -0
  38. package/bin/goblin-audit +145 -0
  39. package/bin/goblin-bans +178 -0
  40. package/bin/goblin-doctor +233 -0
  41. package/bin/goblin-emit +484 -0
  42. package/bin/goblin-init +519 -0
  43. package/bin/goblin-install +720 -0
  44. package/bin/goblin-lib.sh +289 -0
  45. package/bin/goblin-model +105 -0
  46. package/bin/goblin-upgrade +572 -0
  47. package/bin/goblin-verify +2798 -0
  48. package/bin/goblin.js +103 -0
  49. package/docs/ADOPTION.md +168 -0
  50. package/docs/CI.md +187 -0
  51. package/docs/CONTRACTS.md +197 -0
  52. package/docs/DESIGN.md +92 -0
  53. package/docs/ENFORCEMENT.md +225 -0
  54. package/docs/FLOWS.md +164 -0
  55. package/docs/GUARDRAILS.md +126 -0
  56. package/docs/GUIDE.md +610 -0
  57. package/docs/INTEGRATION.md +92 -0
  58. package/docs/LIMITS.md +591 -0
  59. package/docs/LOOP.md +165 -0
  60. package/docs/RE-PLAYBOOK.md +183 -0
  61. package/docs/RISKS.md +70 -0
  62. package/docs/ROLES.md +105 -0
  63. package/manifest/bans.tsv +9 -0
  64. package/manifest/classes.tsv +61 -0
  65. package/manifest/enforcement.tsv +88 -0
  66. package/manifest/glossary.tsv +25 -0
  67. package/manifest/playbooks.tsv +16 -0
  68. package/package.json +37 -4
  69. package/presets/A-shipped-software.yaml +48 -0
  70. package/presets/B-service-config.yaml +40 -0
  71. package/presets/C-game.yaml +38 -0
  72. package/presets/D-knowledge.yaml +41 -0
  73. package/presets/E-fleet-config.yaml +42 -0
  74. package/presets/F-electron.yaml +67 -0
  75. package/roles.yaml +54 -0
  76. package/skills/goblin-bootstrap/SKILL.md +51 -0
  77. package/skills/goblin-bugfix/SKILL.md +26 -0
  78. package/skills/goblin-bugreporter/SKILL.md +52 -0
  79. package/skills/goblin-drift-audit/SKILL.md +43 -0
  80. package/skills/goblin-eval/SKILL.md +68 -0
  81. package/skills/goblin-feature/SKILL.md +26 -0
  82. package/skills/goblin-feature-map/SKILL.md +140 -0
  83. package/skills/goblin-handoff/SKILL.md +28 -0
  84. package/skills/goblin-investigation/SKILL.md +26 -0
  85. package/skills/goblin-judge/SKILL.md +74 -0
  86. package/skills/goblin-loop/SKILL.md +88 -0
  87. package/skills/goblin-mode/SKILL.md +70 -0
  88. package/skills/goblin-overnight/SKILL.md +42 -0
  89. package/skills/goblin-pr-gate/SKILL.md +42 -0
  90. package/skills/goblin-re-mobile/SKILL.md +51 -0
  91. package/skills/goblin-refactor/SKILL.md +23 -0
  92. package/skills/goblin-sweep/SKILL.md +23 -0
  93. package/skills/goblin-tdd-repro/SKILL.md +27 -0
  94. package/skills/goblin-verify-author/SKILL.md +50 -0
  95. package/skills/practice/SKILL.md +37 -0
  96. package/templates/AGENTS.md.tmpl +23 -0
  97. package/templates/HANDOFF.md.tmpl +43 -0
  98. package/templates/SPEC.md.tmpl +34 -0
  99. package/templates/audit-waiver.tsv.tmpl +10 -0
  100. package/templates/boundary-waivers.tmpl +8 -0
  101. package/templates/checks/assert.mjs.tmpl +60 -0
  102. package/templates/checks/gate.sh.tmpl +29 -0
  103. package/templates/ci/goblin-gate.yml.tmpl +46 -0
  104. package/templates/goblin.yaml.tmpl +138 -0
  105. package/templates/install-hooks.allowlist.tmpl +9 -0
  106. package/templates/loop/decisions.tsv.tmpl +1 -0
  107. package/templates/loop/predicate.tmpl +16 -0
  108. package/templates/report.yaml.tmpl +16 -0
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-audit — run the DECLARED dependency audit ONCE, deliberately, and write the record that
3
+ # SC-07 reads.
4
+ #
5
+ # goblin-audit [--target <dir>] [--print] [--help]
6
+ #
7
+ # This is the ONE command in the toolchain that touches the network, and it is not a check: SC-07
8
+ # never runs it, never calls out, and reads only the record it leaves behind. That split is the
9
+ # whole design (docs/RISKS.md K4: a network call at verify time breaks the offline dependency
10
+ # contract). Run it when you mean to, then commit `.goblin/audit.tsv`.
11
+ #
12
+ # What it writes: `.goblin/audit.tsv`, with a `measured <date>` line and one TAB-separated line
13
+ # per high|critical advisory:
14
+ #
15
+ # package severity id url
16
+ #
17
+ # Exit codes:
18
+ # 0 the record was written (whether or not it is clean)
19
+ # 2 usage, or no .goblin/goblin.yaml to read `security.audit_cmd` from
20
+ # 3 the class declares no audit command (security.audit_cmd is empty) - nothing to run
21
+ # 4 the declared command could not run
22
+ # 5 the output could not be parsed as an audit report - REFUSING to write an empty record,
23
+ # because an empty record reads to SC-07 as "clean" and that would be a fabricated pass
24
+ set -uo pipefail
25
+
26
+ GOBLIN_AUDIT_VERSION="0.4.4"
27
+
28
+ usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
29
+
30
+ TARGET="$(pwd)"
31
+ PRINT=0
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --target) TARGET="${2:-}"; shift 2 ;;
35
+ --print) PRINT=1; shift ;;
36
+ --help|-h) usage; exit 0 ;;
37
+ *) printf 'goblin-audit: unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
38
+ esac
39
+ done
40
+ [ -n "$TARGET" ] || { printf 'goblin-audit: --target needs a directory\n' >&2; exit 2; }
41
+ cd "$TARGET" || { printf 'goblin-audit: no such directory: %s\n' "$TARGET" >&2; exit 2; }
42
+
43
+ CONFIG=".goblin/goblin.yaml"
44
+ [ -f "$CONFIG" ] || { printf 'goblin-audit: %s has no %s - run goblin-install first\n' "$TARGET" "$CONFIG" >&2; exit 2; }
45
+
46
+ # Self-contained on purpose: the audit runner must work even if goblin-lib.sh was edited or is
47
+ # missing, because it is the one tool a human runs by hand.
48
+ g_yaml_block_scalar() { # <file> <block> <key>
49
+ awk -v b="$2" -v k="$3" '
50
+ $0 ~ ("^" b ":[[:space:]]*$") { inb = 1; next }
51
+ inb && /^[^ ]/ { inb = 0 }
52
+ inb && $0 ~ ("^ " k ":") {
53
+ v = $0; sub("^ " k ":[[:space:]]*", "", v); print v; exit
54
+ }
55
+ ' "$1"
56
+ }
57
+
58
+ AUDIT_CMD=$(g_yaml_block_scalar "$CONFIG" security audit_cmd)
59
+ if [ -z "$AUDIT_CMD" ]; then
60
+ printf 'goblin-audit: this class declares no audit command (security.audit_cmd is empty in\n'
61
+ printf ' %s). Nothing to run, and no record to write.\n' "$CONFIG"
62
+ exit 3
63
+ fi
64
+
65
+ TODAY=$(date -u +%F)
66
+ RAW=$(mktemp) || exit 2
67
+ trap 'rm -f "$RAW"' EXIT
68
+
69
+ printf 'goblin-audit: running the declared audit ONCE (this is the deliberate network step):\n %s\n' "$AUDIT_CMD"
70
+ # shellcheck disable=SC2086
71
+ bash -c "$AUDIT_CMD" > "$RAW" 2>&1
72
+ RC=$?
73
+ if [ "$RC" -ne 0 ] && [ ! -s "$RAW" ]; then
74
+ printf 'goblin-audit: the declared command exited %s and printed nothing\n' "$RC" >&2
75
+ exit 4
76
+ fi
77
+
78
+ # npm audit exits non-zero when it FINDS advisories, which is the normal case we are recording.
79
+ # So the exit code is not the signal here - the parse is.
80
+ if ! grep -qE '"(vulnerabilities|advisories)"|auditReportVersion' "$RAW"; then
81
+ printf 'goblin-audit: the output is not an audit report (no "vulnerabilities", "advisories" or\n' >&2
82
+ printf ' "auditReportVersion" key). REFUSING to write a record: an empty record reads to SC-07\n' >&2
83
+ printf ' as "clean", which would be a fabricated pass. First 20 lines:\n' >&2
84
+ head -n 20 "$RAW" >&2
85
+ exit 5
86
+ fi
87
+
88
+ RECORD=".goblin/audit.tsv"
89
+ LINES=$(awk '
90
+ # npm v2/v3 JSON. Two shapes matter:
91
+ # "vulnerabilities": { "<pkg>": { "name": ..., "severity": "high", "via": [ ... ] } }
92
+ # "via": [ { "source": 1, "name": "<pkg>", "id": 1091, "url": "https://..." , ... "range": "..." } ]
93
+ # The vulnerability object names its severity BEFORE the via block that carries the advisory id
94
+ # and url, so the entries are buffered and printed at END rather than on the severity line.
95
+ # Field order (source, name, dependency, title, url, severity, cwe, cvss, range) is what makes
96
+ # "range" a safe end-of-advisory marker; anything the parse cannot pair is left empty on the
97
+ # line, visibly, instead of guessed.
98
+ {
99
+ line = $0
100
+ if (match(line, /"source"[[:space:]]*:[[:space:]]*[0-9]+/)) {
101
+ inadv = 1; aname = ""; aurl = ""
102
+ s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*/, "", s); aid = s
103
+ }
104
+ if (inadv) {
105
+ if (match(line, /"name"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
106
+ s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); aname = s
107
+ }
108
+ if (match(line, /"id"[[:space:]]*:[[:space:]]*[0-9]+/)) {
109
+ s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*/, "", s); aid = s
110
+ }
111
+ if (match(line, /"url"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
112
+ s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); aurl = s
113
+ }
114
+ if (match(line, /"range"[[:space:]]*:/)) {
115
+ if (aname != "" && !(aname in adid)) { adid[aname] = aid; aurl2[aname] = aurl }
116
+ inadv = 0
117
+ }
118
+ }
119
+ if (match(line, /"name"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
120
+ s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); vname = s
121
+ }
122
+ if (match(line, /"severity"[[:space:]]*:[[:space:]]*"(high|critical)"/)) {
123
+ s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*"/, "", s); sub(/"$/, "", s)
124
+ if (vname != "") vsev[vname] = s
125
+ }
126
+ }
127
+ END {
128
+ for (p in vsev) printf "%s\t%s\t%s\t%s\n", p, vsev[p], (p in adid ? adid[p] : ""), (p in aurl2 ? aurl2[p] : "")
129
+ }
130
+ ' "$RAW" | sort)
131
+
132
+ {
133
+ printf '# .goblin/audit.tsv - written by goblin-audit %s on %s\n' "$GOBLIN_AUDIT_VERSION" "$TODAY"
134
+ printf '# command: %s\n' "$AUDIT_CMD"
135
+ printf '# one line per high|critical advisory: package<TAB>severity<TAB>id<TAB>url\n'
136
+ printf '# SC-07 reads this file and never the network. Each line must be matched in\n'
137
+ printf '# .goblin/audit-waiver.tsv (package, severity, id, date, reason) or it is a RED.\n'
138
+ printf 'measured %s\n' "$TODAY"
139
+ if [ -n "$LINES" ]; then printf '%s\n' "$LINES"; else printf '# no high|critical advisories\n'; fi
140
+ } > "$RECORD"
141
+
142
+ N=$(printf '%s\n' "$LINES" | grep -c '[^[:space:]]' || true)
143
+ printf 'goblin-audit: wrote %s - %s high|critical advisory line(s), measured %s\n' "$RECORD" "$N" "$TODAY"
144
+ if [ "$PRINT" -eq 1 ]; then printf '\n'; cat "$RECORD"; fi
145
+ exit 0
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-bans — run the ban table. Dune rule 2: forbidden must fail mechanically.
3
+ #
4
+ # Usage:
5
+ # goblin-bans [--only <id[,id...]>] [--list]
6
+ #
7
+ # The table is .goblin/manifest/bans.tsv (installed) or manifest/bans.tsv (a checkout).
8
+ # Each row's `detect` column is a real command, run from the repo root, that
9
+ # 0 the tree is clean
10
+ # 1 the ban is violated - the exit code alone decides; stdout, when any, names the lines
11
+ # 2 the check could not run (FAIL CLOSED - a missing tool is never a silent pass)
12
+ # A ban the config's `bans:` list does not name is SKIPPED with that reason; a ban whose
13
+ # globs match no file is SKIPPED with that reason. A missing or empty ban table is exit 2.
14
+ #
15
+ # A narrow, explicit exception (Dune rule 5) reaches the probe through its environment, so it can
16
+ # change the VERDICT and not only the printed detail: GOBLIN_BANS_ID (the ban) and
17
+ # GOBLIN_BANS_EXEMPT (its `bans_exempt:` path prefixes) let the probe drop exempted hits before it
18
+ # chooses its exit code, and `// BAN-OK(<id>): <reason>` clears one offending line. See
19
+ # bans/README.md, "Narrow exceptions".
20
+ #
21
+ # Exit codes:
22
+ # 0 every selected ban passed or skipped
23
+ # 1 at least one selected ban FAILED
24
+ # 2 the ban table is missing, empty or unreadable (fail closed)
25
+ # 3 every selected ban was SKIPPED (only when exactly one ban is selected)
26
+ #
27
+ # No npm, no jq, no yq, no network. bash/git/awk/sed/grep only.
28
+
29
+ set -uo pipefail
30
+
31
+ GOBLIN_BANS_VERSION="0.4.4"
32
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
33
+ # shellcheck source=goblin-lib.sh
34
+ . "$SELF_DIR/goblin-lib.sh"
35
+
36
+ ONLY=""
37
+ LIST=0
38
+ while [ $# -gt 0 ]; do
39
+ case "$1" in
40
+ --only) ONLY="${2:-}"; shift 2 ;;
41
+ --list) LIST=1; shift ;;
42
+ -h|--help)
43
+ sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
44
+ *) g_err "unknown option: $1"; exit 2 ;;
45
+ esac
46
+ done
47
+
48
+ # ---- root resolution (the installed config is the target's own marker) --------
49
+ find_installed_root() {
50
+ local d="$PWD"
51
+ while :; do
52
+ if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
53
+ [ "$d" = "/" ] && return 1
54
+ d=$(dirname "$d")
55
+ done
56
+ }
57
+ ROOT=$(find_installed_root) || ROOT=""
58
+ if [ -z "$ROOT" ]; then
59
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
60
+ [ -n "$ROOT" ] || ROOT="$PWD"
61
+ fi
62
+
63
+ CONFIG="$ROOT/.goblin/goblin.yaml"
64
+ if [ -f "$ROOT/.goblin/manifest/bans.tsv" ]; then
65
+ BANS="$ROOT/.goblin/manifest/bans.tsv"
66
+ else
67
+ BANS="$SELF_DIR/../manifest/bans.tsv"
68
+ fi
69
+
70
+ # Fail closed: no table, or a table with no data rows, is exit 2 - never a silent pass.
71
+ if [ ! -f "$BANS" ]; then
72
+ g_err "no ban table at $BANS - the ban list cannot be empty and still enforce anything"
73
+ exit 2
74
+ fi
75
+ if [ "$(awk -F'\t' 'NR>1 && $1!="" {n++} END{print n+0}' "$BANS")" -eq 0 ]; then
76
+ g_err "the ban table $BANS holds no bans - an empty list enforces nothing (fail closed)"
77
+ exit 2
78
+ fi
79
+
80
+ if [ "$LIST" -eq 1 ]; then
81
+ awk -F'\t' 'NR>1 && $1!="" {printf "%-7s %s\n", $1, $2}' "$BANS"
82
+ exit 0
83
+ fi
84
+
85
+ # ---- the config: which bans this project turns on, and its narrow exceptions --
86
+ ENABLED=""
87
+ if [ -f "$CONFIG" ]; then
88
+ ENABLED=$(g_yaml_scalar "$CONFIG" bans)
89
+ ENABLED=${ENABLED#[}; ENABLED=${ENABLED%]}
90
+ ENABLED=$(printf '%s' "$ENABLED" | tr ',' ' ' | tr -s ' ' ' ')
91
+ fi
92
+ enabled() {
93
+ [ -z "$ENABLED" ] && return 1
94
+ for e in $ENABLED; do [ "$e" = "$1" ] && return 0; done
95
+ return 1
96
+ }
97
+
98
+ # bans_exempt: a list of "- <ID> <path>" items (Dune rule 5: narrow, explicit, reviewed).
99
+ # W5-1: the exemption must reach the PROBE. Filtering the probe's stdout after it has already
100
+ # chosen its exit code cannot change the verdict, and the exit code is the signal (V3-2) - so the
101
+ # old stdout filter made every exemption DECORATIVE: a real violation inside an exempted path was
102
+ # a permanent FAIL with no remedy (measured rc 0 at 72490f0 -> rc 1 at 7fec08f). The engine now
103
+ # exports GOBLIN_BANS_ID and GOBLIN_BANS_EXEMPT and each shipped probe filters the FILE LIST
104
+ # before it judges (bans/README.md, "Narrow exceptions"). A probe that ignores the variables
105
+ # keeps the old behaviour, which fails CLOSED, never open.
106
+ exempt_paths() { # exempt_paths <id> -> path prefixes, one per line
107
+ [ -f "$CONFIG" ] || return 0
108
+ g_yaml_list "$CONFIG" bans_exempt | awk -v id="$1" '$1==id {print $2}'
109
+ }
110
+
111
+ only_selected() {
112
+ [ -z "$ONLY" ] && return 0
113
+ case ",$ONLY," in *",$1,"*) return 0 ;; esac
114
+ return 1
115
+ }
116
+
117
+ # ---- run ---------------------------------------------------------------------
118
+ PASS=0; FAIL=0; SKIP=0; SEL=0
119
+ while IFS=$'\t' read -r id ban globs detect replacement escape reviewer source; do
120
+ [ "$id" = "id" ] && continue
121
+ [ -n "$id" ] || continue
122
+ only_selected "$id" || continue
123
+ SEL=$((SEL + 1))
124
+
125
+ if ! enabled "$id"; then
126
+ SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to .goblin/goblin.yaml to turn it on)"
127
+ continue
128
+ fi
129
+ # A ban whose globs match no file is a SKIP with a reason, not a pass and not a fail.
130
+ have_glob=0
131
+ for g in $globs; do
132
+ [ -e "$ROOT/$g" ] && have_glob=1
133
+ done
134
+ if [ "$have_glob" -eq 0 ]; then
135
+ SKIP=$((SKIP + 1)); g_skip "$id" "$ban (no matching files: $globs)"
136
+ continue
137
+ fi
138
+
139
+ # The exemption is pushed into the probe (see exempt_paths above): the probe filters the file
140
+ # list and exits 0 when nothing outside the exempted paths is hit. The exit code stays the only
141
+ # signal - stdout, when any, still names the offending lines the probe did NOT exempt.
142
+ ex=$(exempt_paths "$id")
143
+ out=$( cd "$ROOT" && GOBLIN_BANS_ID="$id" GOBLIN_BANS_EXEMPT="$ex" bash -c "$detect" 2>&1 ); rc=$?
144
+ case "$rc" in
145
+ 0)
146
+ if [ -n "$out" ]; then
147
+ FAIL=$((FAIL + 1))
148
+ g_fail "$id" "$ban -> $replacement"
149
+ printf '%s\n' "$out" | sed 's/^/ /'
150
+ else
151
+ PASS=$((PASS + 1)); g_pass "$id" "$ban"
152
+ fi ;;
153
+ 1)
154
+ # Exit 1 IS the documented signal that the ban is violated (this file's header and
155
+ # bans/README.md). Judging by whether stdout happened to be empty made the engine
156
+ # FAIL-OPEN: a detect that reports a violation through its exit code alone was read as
157
+ # clean. The exit code decides; stdout only adds detail (V3-2).
158
+ FAIL=$((FAIL + 1))
159
+ g_fail "$id" "$ban -> $replacement"
160
+ if [ -n "$out" ]; then
161
+ printf '%s\n' "$out" | sed 's/^/ /'
162
+ else
163
+ printf ' (reported violated by exit 1 with no detail on stdout)\n'
164
+ fi ;;
165
+ 3)
166
+ SKIP=$((SKIP + 1))
167
+ g_skip "$id" "$ban (${out:-skipped})" ;;
168
+ *)
169
+ FAIL=$((FAIL + 1))
170
+ g_fail "$id" "$ban (the check could not run - exit $rc, fail closed)"
171
+ printf '%s\n' "$out" | sed 's/^/ /' ;;
172
+ esac
173
+ done < "$BANS"
174
+
175
+ printf ' %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP"
176
+ [ "$FAIL" -gt 0 ] && exit 1
177
+ if [ "$SEL" -eq 1 ] && [ "$SKIP" -eq 1 ]; then exit 3; fi
178
+ exit 0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-doctor — one run, every W4a platform (W4A-SPEC §7).
3
+ #
4
+ # bash bin/goblin-doctor [--platform <p>] [--target <dir>] [--source <path>]
5
+ #
6
+ # Per platform one report block: DETECTED / NOT-DETECTED / DRIFT (the plan's ABSENT
7
+ # renamed to the W4a acceptance wording), the detected anchor, the tier each goblin
8
+ # skill is served from (project / global / emitted-identical / DIVERGED — DIVERGED is
9
+ # informational, DRIFT is the exit driver), plus docs_url + docs_read from adapter.tsv.
10
+ #
11
+ # DRIFT means any of: a malformed adapter.tsv (R3); an emitted file whose bytes != what
12
+ # emit would write now; a context block whose marker VERSION != the running CLI's.
13
+ #
14
+ # Exit codes (W1 §3.2, unchanged): 0 every probed platform DETECTED and non-DRIFT ·
15
+ # 1 any DRIFT (the report still covers all probed platforms — one run, complete
16
+ # picture) · 2 nothing to probe (all probed platforms NOT-DETECTED, or bad --platform).
17
+ #
18
+ # verify.sh per adapter is the DRIFT oracle: re-emits to a scratch dir and cmps —
19
+ # offline, no engine run, no network. bash/awk/sed/grep/sha256sum only.
20
+
21
+ set -uo pipefail
22
+
23
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
24
+ SRC=$(cd "$SELF_DIR/.." && pwd)
25
+ if [ -f "$SRC/bin/goblin-lib.sh" ]; then
26
+ # shellcheck disable=SC1091
27
+ . "$SRC/bin/goblin-lib.sh"
28
+ else
29
+ g_sha256_file() { [ -f "$1" ] && { sha256sum "$1" 2>/dev/null | awk '{print $1}'; return; } || { shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; }; }
30
+ g_err() { printf 'error: %s\n' "$*" >&2; }
31
+ g_info() { printf '%s\n' "$*"; }
32
+ fi
33
+ VERSION=$(cat "$SRC/VERSION" 2>/dev/null || printf 'unknown')
34
+ # The platform enum is assembled from fragments (the MD_SLUG precedent): a literal
35
+ # tool name would trip MD-01 in the source tree, and the enum is data the refusals quote.
36
+ _PL1="$(printf '%s' 'cl')"; _PL2="$(printf '%s' 'aude')"
37
+ _PLA="$_PL1$_PL2"
38
+ _G1="$(printf '%s' 'gem')"; _G2="$(printf '%s' 'ini')"
39
+ ADAPTERS_DIR="$SRC/adapters"
40
+
41
+ usage() {
42
+ cat <<USAGE
43
+ goblin doctor — one run, seven platforms (W4a, the seven since W4b).
44
+
45
+ goblin doctor [--platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>]
46
+ [--target <dir>] [--source <path>]
47
+
48
+ Prints, per platform: the verdict (DETECTED / NOT-DETECTED / DRIFT), the detected
49
+ anchor, the tier each goblin skill is served from, and the adapter's docs pin.
50
+
51
+ Exit codes: 0 all DETECTED, zero DRIFT | 1 any DRIFT | 2 nothing to probe.
52
+ USAGE
53
+ }
54
+
55
+ die() { printf 'error: doctor: %s\n' "$1" >&2; exit "${2:-2}"; }
56
+
57
+ ONLY="" TARGET="$PWD" SRCSKILLS=""
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --platform) ONLY="${2:-}"; shift 2 ;;
61
+ --target) TARGET="${2:-}"; shift 2 ;;
62
+ --source) SRCSKILLS="${2:-}"; shift 2 ;;
63
+ -h|--help) usage; exit 0 ;;
64
+ *) printf 'error: doctor: unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
65
+ esac
66
+ done
67
+ TARGET=$(cd "$TARGET" 2>/dev/null && pwd || printf '%s' "$TARGET")
68
+ [ -n "$SRCSKILLS" ] || SRCSKILLS="$SRC/skills"
69
+
70
+ PLATFORMS="$_PLA hermes copilot cursor opencode codex $_G1$_G2"
71
+ if [ -n "$ONLY" ]; then
72
+ case "$ONLY" in
73
+ $_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2) PLATFORMS="$ONLY" ;;
74
+ *) die "unknown platform '$ONLY' - goblin doctor ships $_PLA, hermes, copilot, cursor, opencode, codex, $_G1$_G2" 2 ;;
75
+ esac
76
+ fi
77
+
78
+ g_index_bytes() {
79
+ local files=() n
80
+ for n in $(selected_skills "$1"); do [ -f "$SRCSKILLS/$n/SKILL.md" ] && files+=("$SRCSKILLS/$n/SKILL.md"); done
81
+ [ "${#files[@]}" -eq 0 ] && { printf '0'; return 0; }
82
+ awk 'FNR==1{c=0;n=0} /^---$/{c++; next}
83
+ c==1 && /^name:/{n+=length($0)+1} c==1 && /^description:/{n+=length($0)+1}
84
+ ENDFILE{tot+=n} END{print tot+0}' "${files[@]}" 2>/dev/null
85
+ }
86
+ selected_skills() { # <core|all>
87
+ local d
88
+ for d in "$SRCSKILLS"/*/; do
89
+ [ -d "$d" ] || continue
90
+ if [ "$1" = core ]; then
91
+ case "$(basename "$d")" in
92
+ goblin-mode|goblin-verify-author|goblin-judge|goblin-bootstrap|goblin-handoff|practice) ;;
93
+ *) continue ;;
94
+ esac
95
+ fi
96
+ basename "$d"
97
+ done
98
+ }
99
+
100
+ VERDICT_MIN=2 # the exit driver: 0 all detected+clean, 1 any DRIFT, 2 nothing to probe
101
+
102
+ for PLATFORM in $PLATFORMS; do
103
+ TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
104
+ printf '== %s ==\n' "$PLATFORM"
105
+ # ---- R3: the capability table must parse; a table that cannot parse is drift ----
106
+ if [ ! -f "$TSV" ]; then
107
+ printf ' DRIFT adapter table missing: %s\n' "$TSV"
108
+ VERDICT_MIN=1
109
+ continue
110
+ fi
111
+ ROW=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { print }' "$TSV")
112
+ ROW_N=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { n++ } END { print n+0 }' "$TSV")
113
+ PARSE_ERR=""
114
+ [ "$ROW_N" -eq 1 ] || PARSE_ERR="expected exactly one data row, found $ROW_N"
115
+ if [ -z "$PARSE_ERR" ] && [ "$(printf '%s\n' "$ROW" | awk -F'\t' '{print NF}')" -ne 13 ]; then
116
+ PARSE_ERR="expected 13 columns, found $(printf '%s\n' "$ROW" | awk -F'\t' '{print NF}')"
117
+ fi
118
+ if [ -z "$PARSE_ERR" ]; then
119
+ IFS=$'\t' read -r A_ID A_DISPLAY A_DETECT A_PROJ A_GLOB A_CTX A_DOCS A_READ A_C1 A_C2 A_C3 A_C4 A_C5 <<< "$ROW"
120
+ [ "$A_ID" = "$PLATFORM" ] || PARSE_ERR="id cell '$A_ID' does not match the directory name '$PLATFORM'"
121
+ # The capability columns carry the plan §3.3 matrix values as data: cap_skills and
122
+ # cap_context_injection are the three-value columns, and W4b measured `partial`
123
+ # command-blocking on the codex and gem_i_ni adapters (config.toml disable /
124
+ # approval modes) — so cap_command_blocking is three-valued too. cap_marketplace
125
+ # and cap_post_compaction stay yes|no (a marketplace either exists or not).
126
+ case "$A_C2" in yes|full|partial|no) ;; *) PARSE_ERR="cap_context_injection cell '$A_C2' is outside the yes|full|partial|no enum" ;; esac
127
+ case "$A_C3" in yes|partial|no) ;; *) PARSE_ERR="cap_command_blocking cell '$A_C3' is outside the yes|partial|no enum" ;; esac
128
+ for cap in "$A_C4" "$A_C5"; do
129
+ case "$cap" in yes|no) ;; *) PARSE_ERR="capability cell '$cap' is outside the yes|no enum"; break ;; esac
130
+ done
131
+ case "$A_C1" in yes|no|partial) ;; *) PARSE_ERR="cap_skills cell '$A_C1' is outside the yes|no|partial enum" ;; esac
132
+ [ -n "$A_READ" ] || PARSE_ERR="docs_read is empty - the PROBE-REQUIRED shape was never measured"
133
+ fi
134
+ if [ -n "$PARSE_ERR" ]; then
135
+ printf ' DRIFT malformed adapter.tsv (%s)\n' "$PARSE_ERR"
136
+ VERDICT_MIN=1
137
+ continue
138
+ fi
139
+ printf ' docs %s (read %s)\n' "$A_DOCS" "$A_READ"
140
+ [ "$PLATFORM" = copilot ] && printf ' caveat prompt hooks fire at sessionStart only - not on resume, not headless; skills arrive, the bootstrap does not\n'
141
+
142
+ # ---- detection (§3) ----
143
+ DRIFT=0
144
+ ANCHOR=""
145
+ DET_HIT=""
146
+ IFS=':' read -ra ANCHORS <<< "$A_DETECT"
147
+ for a in "${ANCHORS[@]}"; do
148
+ case "$a" in
149
+ "~"*) [ -d "${HOME}${a#\~}" ] && { DET_HIT="${HOME}${a#\~}"; break; } ;;
150
+ /*) [ -e "$a" ] && { DET_HIT="$a"; break; } ;;
151
+ *) command -v "$a" >/dev/null 2>&1 && { DET_HIT="$(command -v "$a")"; break; } ;;
152
+ esac
153
+ done
154
+ if [ -n "$DET_HIT" ]; then
155
+ printf ' DETECTED anchor %s\n' "$DET_HIT"
156
+ else
157
+ printf ' NOT-DETECTED anchors tried: %s\n' "$(printf '%s' "$A_DETECT" | tr ':' ', ')"
158
+ fi
159
+
160
+ # ---- the tier each goblin skill is served from (§5.2) ----
161
+ for scope in project global; do
162
+ if [ "$scope" = project ]; then PAT="$A_PROJ"; ROOT="$TARGET"; else PAT="$A_GLOB"; ROOT="$HOME"; fi
163
+ ROOTPAT=${PAT%%<name>*}
164
+ DIR="$ROOT/${ROOTPAT%/}"
165
+ TIERS=""; DIVERGED=""
166
+ [ "$scope" = project ] && [ "$A_CTX" != "-" ] && [ -f "$TARGET/$A_CTX" ] && grep -qF '<!-- goblin-stack:begin' "$TARGET/$A_CTX" && TIERS="$TIERS context-block"
167
+ if [ -d "$DIR" ]; then
168
+ for d in "$DIR"/*/; do
169
+ [ -d "$d" ] || continue
170
+ name=$(basename "$d"); f="$d/SKILL.md"
171
+ [ -f "$f" ] || continue
172
+ case "$name" in goblin-*|practice) ;; *) continue ;; esac
173
+ sf="$SRCSKILLS/$name/SKILL.md"
174
+ if [ ! -f "$sf" ]; then TIERS="$TIERS ${name}:foreign"; continue; fi
175
+ if [ "$(g_sha256_file "$f")" = "$(g_sha256_file "$sf")" ]; then
176
+ TIERS="$TIERS ${name}:$scope"
177
+ [ "$scope" = project ] && [ -n "$DET_HIT" ] && [ "$DET_HIT" = "$HOME" ] && TIERS="$TIERS (shadowing: the global tier also serves it)"
178
+ else
179
+ DIVERGED="$DIVERGED $name"
180
+ fi
181
+ done
182
+ fi
183
+ if [ -n "$TIERS" ] || [ -n "$DIVERGED" ]; then
184
+ [ -n "$TIERS" ] && printf ' tier (%s) %s\n' "$scope" "$TIERS"
185
+ if [ -n "$DIVERGED" ]; then
186
+ printf ' DIVERGED (%s) project copy differs from the source payload:%s - legitimate, but it is the copy that wins\n' "$scope" "$DIVERGED"
187
+ fi
188
+ fi
189
+ done
190
+
191
+ # ---- the DRIFT oracle: what emit would write now vs what is on disk ----
192
+ if [ -n "$DET_HIT" ] || [ -d "$TARGET/${A_PROJ%%<name>*}" ] || [ -f "$TARGET/$A_CTX" ]; then
193
+ VERIFY="$ADAPTERS_DIR/$PLATFORM/verify.sh"
194
+ if [ -x "$VERIFY" ] || [ -f "$VERIFY" ]; then
195
+ if OUT=$(GOBLIN_DOCTOR_TARGET="$TARGET" GOBLIN_DOCTOR_HOME="$HOME" bash "$VERIFY" 2>&1); then
196
+ printf '%s\n' "$OUT" | sed 's/^/ /'
197
+ else
198
+ DRIFT=1
199
+ printf ' DRIFT emitted bytes differ from what emit would write now:\n'
200
+ printf '%s\n' "$OUT" | sed 's/^/ /'
201
+ fi
202
+ fi
203
+ fi
204
+
205
+ if [ "$DRIFT" -eq 1 ]; then
206
+ VERDICT_MIN=1
207
+ elif [ -z "$DET_HIT" ] && [ "$VERDICT_MIN" -ne 1 ]; then
208
+ # a NOT-DETECTED platform is exit 2 only if nothing DRIFTed anywhere (§7)
209
+ :
210
+ fi
211
+ done
212
+
213
+ # ---- the exit driver (§7): 0 detected+clean · 1 any DRIFT · 2 nothing to probe ----
214
+ if [ "$VERDICT_MIN" -eq 1 ]; then
215
+ exit 1
216
+ fi
217
+ # exit 2 iff NO probed platform was DETECTED (and none DRIFTed)
218
+ ANY_DETECTED=0
219
+ for PLATFORM in $PLATFORMS; do
220
+ TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
221
+ [ -f "$TSV" ] || continue
222
+ A_DETECT=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { print $3 }' "$TSV")
223
+ IFS=':' read -ra ANCHORS <<< "$A_DETECT"
224
+ for a in "${ANCHORS[@]}"; do
225
+ case "$a" in
226
+ "~"*) [ -d "${HOME}${a#\~}" ] && ANY_DETECTED=1 ;;
227
+ /*) [ -e "$a" ] && ANY_DETECTED=1 ;;
228
+ *) command -v "$a" >/dev/null 2>&1 && ANY_DETECTED=1 ;;
229
+ esac
230
+ done
231
+ done
232
+ [ "$ANY_DETECTED" -eq 1 ] && exit 0
233
+ exit 2