@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
+ # bugreporter-intake.sh — A-01's intake validator (the gate in front of `goblin-bugreporter`, P13).
3
+ #
4
+ # bash bugreporter-intake.sh <slug> [--reports <dir>] [--cards]
5
+ #
6
+ # A report is a FILE WITH A SCHEMA, not prose in a card body: prose invites the agent to fill
7
+ # the gaps by guessing, and a guess at intake poisons everything downstream. This script does
8
+ # the schema check with line-oriented shell, computes the content-only dedup key, and prints
9
+ # the exact card command.
10
+ #
11
+ # Exit codes: 0 the report passed intake | 1 REFUSED (the gap is named) | 2 could not run.
12
+ #
13
+ # The report is UNTRUSTED INPUT, so nothing reads it into a shell: the card is created by
14
+ # exec'ing an argv array (`run_card`), and the form printed for an operator (`card_cmd`) quotes
15
+ # every value that came out of the report. There is no `eval` on any path. A `symptom:` carrying
16
+ # a backtick, `$(...)` or a quote is text, in the card title and on the printed line alike.
17
+ #
18
+ # An under-specified report is filed, not dropped and not guessed: the refusal command carries
19
+ # NO --assignee, so the dispatcher buckets the card `skipped_unassigned` and it is structurally
20
+ # un-spawnable, while remaining visible on the board to a human.
21
+ set -uo pipefail
22
+
23
+ SLUG=""
24
+ REPORTS="reports"
25
+ CARDS=0
26
+
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --reports) REPORTS="${2:-}"; shift 2 ;;
30
+ --cards) CARDS=1; shift ;;
31
+ -h|--help) sed -n '2,/^set -/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
32
+ -*)
33
+ printf 'bugreporter-intake: unknown option: %s\n' "$1" >&2; exit 2 ;;
34
+ *) SLUG="$1"; shift ;;
35
+ esac
36
+ done
37
+
38
+ [ -n "$SLUG" ] || { printf 'bugreporter-intake: usage: bugreporter-intake.sh <slug> [--reports <dir>]\n' >&2; exit 2; }
39
+ REPORT="$REPORTS/$SLUG/report.yaml"
40
+ [ -f "$REPORT" ] || { printf 'bugreporter-intake: no report at %s\n' "$REPORT" >&2; exit 2; }
41
+
42
+ field() { sed -n "s/^$1:[[:space:]]*//p" "$REPORT" | head -n 1; }
43
+ # present <key> — the key exists AND carries something: an inline value, or at least one
44
+ # indented item under it (repro_steps is a block list, so a bare `field` read is empty for a
45
+ # perfectly good report - that false refusal was measured and is why this helper exists).
46
+ present() {
47
+ grep -q "^$1:" "$REPORT" || return 1
48
+ [ -n "$(field "$1")" ] && return 0
49
+ awk -v k="$1" '
50
+ $0 ~ ("^" k ":[[:space:]]*$") { inb = 1; next }
51
+ inb && /^[^[:space:]]/ { inb = 0 }
52
+ inb && /^[[:space:]]+[^[:space:]]/ { f = 1 }
53
+ END { exit !f }
54
+ ' "$REPORT"
55
+ }
56
+ sha12() {
57
+ if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -c1-12
58
+ else printf '%s' "$1" | shasum -a 256 | cut -c1-12; fi
59
+ }
60
+ # normalised = lowercased, whitespace collapsed, trimmed. The dedup key is a function of THIS
61
+ # and nothing else: no date, no run id, no counter - or it dedups nothing.
62
+ normalise() { printf '%s' "$1" | tr 'A-Z' 'a-z' | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//'; }
63
+
64
+ REPO=$(field repo)
65
+ SYMPTOM=$(field symptom)
66
+ REVISION=$(field revision)
67
+ KEY=$(field dedup_key)
68
+
69
+ MISSING=""
70
+ for k in repo symptom expected observed repro_steps revision; do
71
+ present "$k" || MISSING="$MISSING $k"
72
+ done
73
+
74
+ WANT_KEY="bug:${REPO}:$(sha12 "$(normalise "$SYMPTOM")")"
75
+
76
+ TITLE="bug: ${SYMPTOM}"
77
+
78
+ # shq <word> — the word quoted for a shell, so the printed form can be pasted without executing
79
+ # anything the report says. Single quotes make backticks and `$( )` text; an embedded single
80
+ # quote is written as the four-character `'\''` (close, escaped quote, reopen).
81
+ shq() { local s="$1"; s=${s//\'/\'\\\'\'}; printf "'%s'" "$s"; }
82
+
83
+ # card_cmd <title-suffix> <idempotency-key> <max-runtime> <max-retries> [assignee-args...]
84
+ # The card command as it is PRINTED for an operator. Every value that came out of the report is
85
+ # shell-quoted; flags and numbers are printed literally, so the line stays greppable. The printed
86
+ # form is what skills/goblin-bugreporter/SKILL.md step 1 tells the operator to run, so it must be
87
+ # paste-safe: the report is the untrusted input this gate exists to validate (G8-1).
88
+ card_cmd() {
89
+ local title="$TITLE$1" key="$2" rt="$3" rr="$4"; shift 4
90
+ local a
91
+ printf 'hermes kanban create %s' "$(shq "$title")"
92
+ for a in "$@"; do printf ' %s' "$a"; done
93
+ printf ' --idempotency-key %s --body-file %s --max-runtime %s --max-retries %s\n' \
94
+ "$(shq "$key")" "$(shq "$REPORT")" "$rt" "$rr"
95
+ }
96
+
97
+ # run_card <title-suffix> <idempotency-key> <max-runtime> <max-retries> [assignee-args...]
98
+ # The same card as an ARGV ARRAY: no `eval`, no command string, so the title reaches the board as
99
+ # ONE argument whatever it contains. Never build a shell command out of file content (G8-1).
100
+ run_card() {
101
+ local title="$TITLE$1" key="$2" rt="$3" rr="$4"; shift 4
102
+ hermes kanban create "$title" "$@" --idempotency-key "$key" --body-file "$REPORT" \
103
+ --max-runtime "$rt" --max-retries "$rr"
104
+ }
105
+
106
+ if [ -n "$MISSING" ]; then
107
+ printf 'bugreporter-intake: REFUSED - %s carries no value for:%s\n' "$REPORT" "$MISSING"
108
+ printf '# the card is created WITHOUT --assignee, so no agent can be spawned on it\n'
109
+ card_cmd " (incomplete intake)" "${KEY:-incomplete:$SLUG}" 600 0
110
+ exit 1
111
+ fi
112
+
113
+ if [ -n "$KEY" ] && [ "$KEY" != "$WANT_KEY" ]; then
114
+ printf 'bugreporter-intake: REFUSED - the recorded dedup_key is not the content key\n'
115
+ printf ' recorded %s\n recomputed %s\n' "$KEY" "$WANT_KEY"
116
+ printf ' a key derived from a date, a run id or a counter dedups nothing\n'
117
+ card_cmd " (bad key)" "$WANT_KEY" 600 0
118
+ exit 1
119
+ fi
120
+
121
+ # The false-positive guard: a reproduction must name a revision that exists. A report with an
122
+ # unverifiable `revision:` is a refusal, not a card.
123
+ if [ -d "$REPO" ]; then
124
+ if ! ( cd "$REPO" && git rev-parse --verify --quiet "$REVISION^{commit}" >/dev/null 2>&1 ); then
125
+ printf 'bugreporter-intake: REFUSED - revision "%s" does not resolve in %s\n' "$REVISION" "$REPO"
126
+ card_cmd " (unresolvable revision)" "$WANT_KEY" 600 0
127
+ exit 1
128
+ fi
129
+ else
130
+ printf '# note: %s is not a local directory - the revision gate is unexercised here\n' "$REPO" >&2
131
+ fi
132
+
133
+ if [ "$CARDS" -eq 0 ]; then
134
+ card_cmd "" "$WANT_KEY" 1800 1 --assignee researcher --skill goblin-bugreporter
135
+ exit 0
136
+ fi
137
+ if ! command -v hermes >/dev/null 2>&1; then
138
+ printf 'bugreporter-intake: hermes is not on PATH - the command is printed, no card was created\n' >&2
139
+ card_cmd "" "$WANT_KEY" 1800 1 --assignee researcher --skill goblin-bugreporter
140
+ exit 0
141
+ fi
142
+ run_card "" "$WANT_KEY" 1800 1 --assignee researcher --skill goblin-bugreporter >/dev/null 2>&1 \
143
+ || { printf 'bugreporter-intake: the card was NOT created\n' >&2; exit 1; }
144
+ printf 'bugreporter-intake: card created with key %s\n' "$WANT_KEY"
145
+ exit 0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # drift-audit.sh — A-02's producer, the no-agent half of `goblin-drift-audit` (P14).
3
+ #
4
+ # bash drift-audit.sh [--root <glob>] [--state <file>] [--dry-run] [--cards] [--limit <n>]
5
+ #
6
+ # It compares a RECORDED CLAIM with an ARTIFACT, never a memory and never a judgement, so its
7
+ # false-positive rate is structurally zero: the comparison is made by this script, which cannot
8
+ # invent a finding.
9
+ #
10
+ # claim 1: the HANDOFF names a commit that exists here and is an ancestor of HEAD (HP-05)
11
+ # claim 2: every installed file still matches its recorded hash (IN-02)
12
+ # artifact: the repo's git state and the files on disk
13
+ #
14
+ # It is deliberately the FIRST automation to turn on: no agent sits inside the producer, so it
15
+ # costs zero tokens when there is nothing to report.
16
+ #
17
+ # Exit codes: 0 clean, or nothing to do | 1 drift found | 2 could not run.
18
+ #
19
+ # Kill switch <state> carrying `enabled: false` stops it dead, and prints nothing on stdout.
20
+ # Ceiling `run:` lines in <state> from the last 24 h, capped by --limit (default 5). A
21
+ # capped run prints one `# capped` line, so "silent because clean" and "silent
22
+ # because the ceiling was hit" are never confused for each other.
23
+ set -uo pipefail
24
+
25
+ ROOT_GLOB="${HOME}/projects/*"
26
+ STATE="${GOBLIN_DRIFT_STATE:-${HOME}/.goblin/automations/drift-audit.state}"
27
+ DRY_RUN=0
28
+ CARDS=0
29
+ LIMIT=5
30
+
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in
33
+ --root) ROOT_GLOB="${2:-}"; shift 2 ;;
34
+ --state) STATE="${2:-}"; shift 2 ;;
35
+ --limit) LIMIT="${2:-}"; shift 2 ;;
36
+ --dry-run) DRY_RUN=1; shift ;;
37
+ --cards) CARDS=1; shift ;;
38
+ -h|--help) sed -n '2,/^set -/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
39
+ *) printf 'drift-audit: unknown option: %s\n' "$1" >&2; exit 2 ;;
40
+ esac
41
+ done
42
+
43
+ sha12() {
44
+ if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -c1-12
45
+ else printf '%s' "$1" | shasum -a 256 | cut -c1-12; fi
46
+ }
47
+
48
+ # ---- kill switch -------------------------------------------------------------
49
+ if [ -f "$STATE" ] && grep -q '^enabled:[[:space:]]*false' "$STATE"; then
50
+ printf 'drift-audit: disabled (enabled: false in %s)\n' "$STATE" >&2
51
+ exit 0
52
+ fi
53
+
54
+ # ---- the producer's own ceiling ----------------------------------------------
55
+ TODAY=$(date -u +%F)
56
+ RUNS=0
57
+ if [ -f "$STATE" ]; then
58
+ RUNS=$(grep -c "^run: $TODAY" "$STATE" 2>/dev/null || true)
59
+ case "$RUNS" in ''|*[!0-9]*) RUNS=0 ;; esac
60
+ fi
61
+ if [ "$RUNS" -ge "$LIMIT" ]; then
62
+ printf '# capped: %s of %s runs used today - nothing filed\n' "$RUNS" "$LIMIT"
63
+ exit 0
64
+ fi
65
+
66
+ # ---- enumerate targets by glob, never by memory ------------------------------
67
+ TARGETS=0
68
+ SKIPPED=""
69
+ DRIFT=""
70
+ DETAIL=""
71
+
72
+ for d in $ROOT_GLOB; do
73
+ [ -d "$d" ] || continue
74
+ TARGETS=$((TARGETS + 1))
75
+ if [ ! -f "$d/.goblin/goblin.yaml" ]; then
76
+ SKIPPED="$SKIPPED $(basename "$d")"
77
+ continue
78
+ fi
79
+ if [ ! -f "$d/.goblin/bin/goblin-verify" ]; then
80
+ SKIPPED="$SKIPPED $(basename "$d")(no-verifier)"
81
+ continue
82
+ fi
83
+ # --only IN-02,HP-05 rather than a full run: both are builtins that write no runtime state,
84
+ # so the audit never edits the tree it is auditing (design rule 2 of the honesty section).
85
+ OUT=$( cd "$d" && bash .goblin/bin/goblin-verify --json --only IN-02,HP-05 2>/dev/null )
86
+ RC=$?
87
+ case "$RC" in
88
+ 0|1) ;;
89
+ *) SKIPPED="$SKIPPED $(basename "$d")(verify-exit-$RC)"; continue ;;
90
+ esac
91
+ FAILED=$(printf '%s' "$OUT" | grep -o '"id":"[A-Z][A-Z0-9-]*","status":"FAIL"' | sed 's/"id":"//; s/","status":"FAIL"//')
92
+ for id in $FAILED; do
93
+ DRIFT="$DRIFT $d/$id"
94
+ DETAIL="$DETAIL$d $id cd $d && bash .goblin/bin/goblin-verify --only $id
95
+ "
96
+ done
97
+ done
98
+
99
+ # ---- print nothing when there is nothing to file -----------------------------
100
+ # A clean run writes nothing either: the ceiling counts the runs that FILED something (the
101
+ # cards it created), never the runs that found nothing - otherwise a quiet week would exhaust
102
+ # the ceiling and silence a real finding.
103
+ if [ -z "$DRIFT" ]; then
104
+ exit 0
105
+ fi
106
+
107
+ printf '%s' "$DETAIL"
108
+ printf '# coverage: %s target(s) audited by glob, %s drifting, skipped:%s\n' \
109
+ "$TARGETS" "$(printf '%s' "$DRIFT" | wc -w | tr -d ' ')" "${SKIPPED:- none}"
110
+ printf '# run: %s of %s today\n' "$((RUNS + 1))" "$LIMIT"
111
+
112
+ if [ "$DRY_RUN" -eq 0 ]; then
113
+ [ -d "$(dirname "$STATE")" ] || mkdir -p "$(dirname "$STATE")"
114
+ printf 'run: %s\n' "$TODAY" >> "$STATE"
115
+ fi
116
+ if [ "$CARDS" -eq 1 ]; then
117
+ if command -v hermes >/dev/null 2>&1; then
118
+ printf '%s' "$DETAIL" | while IFS=$'\t' read -r repo id cmd; do
119
+ [ -n "$id" ] || continue
120
+ KEY="drift:$(basename "$repo"):$id"
121
+ BODY=$(mktemp)
122
+ {
123
+ printf '# Drift: %s %s\n\n' "$(basename "$repo")" "$id"
124
+ printf 'The producer compared a recorded claim with an artifact and they disagree.\n\n'
125
+ printf ' %s\n\n' "$cmd"
126
+ printf 'Reproduces at %s. Dedup key: %s\n' "$(date -u +%F)" "$KEY"
127
+ printf 'sha256 of the record: %s\n' "$(sha12 "$repo$id$cmd")"
128
+ } > "$BODY"
129
+ hermes kanban create "drift: $(basename "$repo") $id" --assignee architect \
130
+ --skill goblin-drift-audit --idempotency-key "$KEY" --body-file "$BODY" \
131
+ --max-runtime 1800 --max-retries 1 >/dev/null 2>&1 \
132
+ || printf 'drift-audit: could not create the card for %s %s\n' "$(basename "$repo")" "$id" >&2
133
+ rm -f "$BODY"
134
+ done
135
+ else
136
+ printf 'drift-audit: hermes is not on PATH - the record is printed, no card was created\n' >&2
137
+ fi
138
+ fi
139
+ exit 1
@@ -0,0 +1,10 @@
1
+ key required shape
2
+ repo yes path or remote of the repository the defect is in
3
+ symptom yes one line, observable
4
+ expected yes one line
5
+ observed yes one line
6
+ repro_steps yes ordered list, one item per line, 4-space indented
7
+ revision yes a commit that exists in that repository
8
+ dedup_key yes bug:<repo>:<sha256(normalised symptom)[:12]> - content only, never a date
9
+ environment no platform/version; absent is recorded as unknown, never guessed
10
+ evidence no paths to captures; evidence for a human, never harness proof
package/bans/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # The ban list — forbidden must fail mechanically
2
+
3
+ `manifest/bans.tsv` is the table; `bin/goblin-bans` is the engine; this directory holds the
4
+ probes. Dune's rule 2, made a gate: **a ban without a mechanism is a wish.** A ban enters
5
+ `bans.tsv` only with a real command in its `detect` column, or it does not enter at all.
6
+
7
+ ## How a ban is run
8
+
9
+ ```
10
+ .goblin/bin/goblin-bans # every ban the config's `bans:` list turns on
11
+ .goblin/bin/goblin-bans --only BN-01 # one ban
12
+ .goblin/bin/goblin-bans --list # the table
13
+ ```
14
+
15
+ A `detect` command runs from the repo root and exits
16
+
17
+ | exit | meaning |
18
+ |---|---|
19
+ | 0 | the tree is clean |
20
+ | 1 | the ban is violated — the exit code alone is the signal; stdout, when any, names the offending lines |
21
+ | 2 | the check could not run — **fail closed**, never a silent pass |
22
+ | 3 | nothing to check (e.g. no `layers:` declared) — SKIP with that reason |
23
+
24
+ A ban the config does not name is SKIPPED with that reason; a ban whose `globs` match no file
25
+ is SKIPPED with that reason. An empty or missing table is **exit 2**.
26
+
27
+ ## Narrow exceptions (Dune rule 5)
28
+
29
+ Two escape hatches ship, and both are filtered **before the exit code is chosen** — a filter
30
+ applied to a probe's stdout afterwards cannot change a verdict, so it would be decorative, and
31
+ every violation inside an exempted path would be a permanent RED with no remedy (that was
32
+ **W5-1**, measured `rc 0` at `72490f0` → `rc 1` at `7fec08f`).
33
+
34
+ ```
35
+ bans_exempt: # in .goblin/goblin.yaml — a path prefix, or a whole path
36
+ - BN-03 src/legacy # <ban id> <path prefix>: that ban, that path, nothing else
37
+ ```
38
+
39
+ ```
40
+ export const a: any = 1; // BAN-OK(BN-01): the value is narrowed at the boundary
41
+ ```
42
+
43
+ An inline escape clears **one line** for **that ban**; a non-empty reason after the colon is
44
+ required, so `BAN-OK(BN-01)` on its own is not an escape. Path exemption is segment-aligned and
45
+ compared as written: `src` exempts `src/a.ts` and `src/legacy/b.ts`, never `src2/c.ts`, and a
46
+ prefix carrying a trailing slash (`src/legacy/`) strips nothing, so it matches no file and exempts
47
+ nothing - write the prefix without one. Both shipped probes are controlled in
48
+ `tests/t-verify-red.sh` (AA1): the trailing-slash case is the LAYER probe's control (`BN-05`, AA1
49
+ §7-6), and the same rule holds for the ban probe - measured on `BN-01`: `- BN-01 src` PASSes,
50
+ `- BN-01 src/` FAILs, because it exempts nothing.
51
+
52
+ The engine passes both to the probe through its environment rather than editing stdout:
53
+
54
+ | variable | meaning |
55
+ |---|---|
56
+ | `GOBLIN_BANS_ID` | the ban being probed — what an inline `BAN-OK(<id>)` must name |
57
+ | `GOBLIN_BANS_EXEMPT` | newline-separated path prefixes this ban exempts |
58
+
59
+ `bans/grep-ban.sh` and `bans/layer-check.sh` honour both (the layer probe honours the path list
60
+ only — its documented escape is `layers:` or a move, not an inline marker). A project's **own**
61
+ probe that ignores the variables keeps the old behaviour: a violation inside an exempted path
62
+ stays RED. That is deliberate — the failure is **closed**, never open — and it is recorded in
63
+ `docs/LIMITS.md`.
64
+
65
+ ## No npm, no AST
66
+
67
+ The engine uses `bash`/`grep`/`awk` only — the dependency contract in `docs/CONTRACTS.md`. So
68
+ these are **text probes**, not AST checks: a `: any` inside a string or a comment is reported,
69
+ and `Record<string, any>` (no leading `:`) is missed. The AST-grade form of the same bans needs
70
+ a parser the contract does not allow; that gap is `docs/LIMITS.md` #27, stated rather than
71
+ hidden. A text probe with a known false-positive set is still a gate — it goes red on the move
72
+ it forbids — which is what a ban is for.
73
+
74
+ ## Turning a ban on
75
+
76
+ ```yaml
77
+ bans: [BN-01, BN-02, BN-05] # the bans this project turns on; an unlisted ban SKIPs
78
+ bans_exempt: # narrow, explicit, reviewed (Dune rule 5)
79
+ - BN-03 src/legacy # <ban id> <path prefix>
80
+ layers: # what BN-05 reads; "<from> <to>"
81
+ - src/renderer src/main
82
+ ```
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # grep-ban.sh — a grep-backed ban probe with a fail-closed exit contract.
3
+ #
4
+ # usage: grep-ban.sh -e <pattern> [-e <pattern> ...] <dir|file> ...
5
+ #
6
+ # Exit: 0 the tree is clean | 1 the ban is violated (offending lines on stdout)
7
+ # 2 the check could not run (no pattern, no path, or grep itself failed).
8
+ # It never exits 0 on a tool error - a ban that silently passes when its tool is missing
9
+ # is worse than advisory (PR-03 / D6).
10
+ #
11
+ # It reads TEXT, with no AST and no comments blanked: a `: any` inside a string or a
12
+ # comment is reported. That limit is stated, not hidden - the AST-grade form needs a
13
+ # parser the no-npm contract (docs/CONTRACTS.md) does not allow (docs/LIMITS.md #27).
14
+ #
15
+ # NARROW, EXPLICIT EXCEPTIONS (Dune rule 5). The engine exports two variables, and the hits are
16
+ # filtered BEFORE the exit code is chosen - a filter applied to stdout after the fact cannot
17
+ # change a verdict and is therefore decorative (W5-1: `bans_exempt:` produced a permanent RED):
18
+ #
19
+ # GOBLIN_BANS_ID the ban being probed (BN-01 ...)
20
+ # GOBLIN_BANS_EXEMPT newline-separated path prefixes this ban exempts
21
+ #
22
+ # A hit is dropped when its FILE is under an exempt prefix, or when the offending LINE carries
23
+ # the inline escape `// BAN-OK(<id>): <reason>`. Both are segment-aligned and both require the
24
+ # documented form: `src` exempts `src/a.ts` and `src/legacy/b.ts`, never `src2/c.ts`, and a
25
+ # `BAN-OK(<id>)` with no `: <reason>` after it is NOT an escape (it is a wish). When nothing is
26
+ # left the probe exits 0: there is no violation outside the declared exception.
27
+
28
+ set -uo pipefail
29
+ pats=()
30
+ while [ $# -gt 0 ]; do
31
+ case "$1" in
32
+ -e) [ $# -ge 2 ] || { echo "grep-ban: -e needs a pattern" >&2; exit 2; }
33
+ pats+=("${2:-}"); shift 2 ;;
34
+ --) shift; break ;;
35
+ -*) echo "grep-ban: unknown option $1" >&2; exit 2 ;;
36
+ *) break ;;
37
+ esac
38
+ done
39
+ [ "${#pats[@]}" -gt 0 ] || { echo "grep-ban: no pattern given" >&2; exit 2; }
40
+ [ "$#" -gt 0 ] || { echo "grep-ban: no path given" >&2; exit 2; }
41
+ # A declared glob that does not exist on disk is not an error: the engine already turns
42
+ # "no matching files" into a SKIP with a reason before it calls this. Silently dropping a
43
+ # missing path here is what stops one absent sibling (BN-03's `app` beside `src/components`)
44
+ # from failing the ban closed on a tree that is clean.
45
+ paths=()
46
+ for p in "$@"; do [ -e "$p" ] && paths+=("$p"); done
47
+ [ "${#paths[@]}" -gt 0 ] || exit 0
48
+ args=()
49
+ for p in "${pats[@]}"; do args+=(-e "$p"); done
50
+ out=$(grep -rnE "${args[@]}" \
51
+ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.mjs' \
52
+ -- "${paths[@]}" 2>&1)
53
+ rc=$?
54
+
55
+ # The exemption filter. It runs on hits only, so the fail-closed arms below are untouched.
56
+ ban_exempt_filter() {
57
+ GOBLIN_BANS_EXEMPT="${GOBLIN_BANS_EXEMPT:-}" GOBLIN_BANS_ID="${GOBLIN_BANS_ID:-}" awk '
58
+ BEGIN {
59
+ n = 0
60
+ if (ENVIRON["GOBLIN_BANS_EXEMPT"] != "") n = split(ENVIRON["GOBLIN_BANS_EXEMPT"], ex, "\n")
61
+ id = ENVIRON["GOBLIN_BANS_ID"]
62
+ pat = ""
63
+ if (id != "") pat = "BAN-OK\\(" id "\\)[[:space:]]*:[[:space:]]*[^[:space:]]"
64
+ }
65
+ {
66
+ f = $0; sub(/:.*/, "", f)
67
+ for (i = 1; i <= n; i++) {
68
+ p = ex[i]
69
+ if (p == "") continue
70
+ if (f == p || index(f, p "/") == 1) next
71
+ }
72
+ if (pat != "" && $0 ~ pat) next
73
+ print
74
+ }'
75
+ }
76
+
77
+ case "$rc" in
78
+ 1) exit 0 ;; # no match: clean
79
+ 0) out=$(printf '%s\n' "$out" | ban_exempt_filter) ;; # matches: drop the declared exceptions
80
+ *) printf '%s\n' "$out" >&2; exit 2 ;; # grep could not run: fail closed
81
+ esac
82
+ [ -n "$(printf '%s' "$out" | tr -d '[:space:]')" ] || exit 0
83
+ printf '%s\n' "$out"
84
+ exit 1
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # layer-check.sh — BN-05's detect. The boundary ban Dune actually mechanises.
3
+ #
4
+ # Reads the config's `layers:` list, each item "<from> <to>", and fails when a file under
5
+ # <from> imports across into <to>. The import test is a text probe for an import path that
6
+ # names <to>'s last path segment - so it catches `from '../main/db'` in a renderer, and it
7
+ # does NOT resolve module aliases or dynamic imports. Stated, not hidden (docs/LIMITS.md #27).
8
+ #
9
+ # Exit: 0 clean | 1 a crossing import (lines on stdout) | 2 could not run | 3 no layers declared.
10
+
11
+ set -uo pipefail
12
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
13
+ ROOT=$(cd "$SELF_DIR/../.." && pwd) # .goblin/bans/ -> repo root
14
+ CONFIG="$ROOT/.goblin/goblin.yaml"
15
+ [ -f "$CONFIG" ] || { echo "layer-check: no $CONFIG" >&2; exit 2; }
16
+
17
+ layers=$(awk '
18
+ $0 ~ /^layers:[[:space:]]*$/ { inb = 1; next }
19
+ inb && /^[^ ]/ { inb = 0 }
20
+ inb && /^ - / { v = $0; sub(/^ - /, "", v); print v }
21
+ ' "$CONFIG")
22
+ [ -n "$layers" ] || { echo "no layers declared in $CONFIG - declare a from/to pair to turn BN-05 on"; exit 3; }
23
+
24
+ bad=""
25
+ while read -r from to; do
26
+ [ -n "$from" ] && [ -n "$to" ] || continue
27
+ [ -d "$ROOT/$from" ] || continue
28
+ seg=${to##*/}
29
+ hits=$(cd "$ROOT" && grep -rnE "from[[:space:]]+['\"][^'\"]*${seg}/" \
30
+ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' "$from" 2>/dev/null || true)
31
+ [ -n "$hits" ] && bad="$bad$hits"$'\n'
32
+ done <<EOF
33
+ $layers
34
+ EOF
35
+
36
+ # A narrow, explicit exception (Dune rule 5) reaches this probe through the same environment the
37
+ # engine gives grep-ban.sh: GOBLIN_BANS_EXEMPT is a newline-separated list of path prefixes, and a
38
+ # hit whose FILE is under one of them does not count. Filtering here, before the exit code, is what
39
+ # makes the exception able to change a verdict (W5-1). BN-05's documented escape is `layers:` or a
40
+ # move, not the inline marker, so no BAN-OK is honoured here. A probe that ignores the variable
41
+ # keeps the old, fail-closed behaviour.
42
+ if [ -n "$bad" ] && [ -n "${GOBLIN_BANS_EXEMPT:-}" ]; then
43
+ bad=$(GOBLIN_BANS_EXEMPT="$GOBLIN_BANS_EXEMPT" printf '%s' "$bad" | awk '
44
+ BEGIN { n = split(ENVIRON["GOBLIN_BANS_EXEMPT"], ex, "\n") }
45
+ {
46
+ f = $0; sub(/:.*/, "", f)
47
+ for (i = 1; i <= n; i++) {
48
+ p = ex[i]
49
+ if (p == "") continue
50
+ if (f == p || index(f, p "/") == 1) next
51
+ }
52
+ print
53
+ }')
54
+ fi
55
+
56
+ if [ -n "$bad" ]; then printf '%s' "$bad"; exit 1; fi
57
+ exit 0
package/bin/goblin ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # goblin — the global CLI dispatcher (W1, PLAN-V1 §2.3 / W1-SPEC §3).
3
+ #
4
+ # goblin verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
5
+ # goblin bans [--only <id[,id...]>] [--list]
6
+ # goblin audit [--target <dir>] [--print]
7
+ # goblin doctor [--platform <p>] # W4a
8
+ # goblin emit --platform <p> [...] # W4a/W4b
9
+ # goblin init [...] # W6: the first-run wizard
10
+ # goblin upgrade [--target .] [...] # W3
11
+ # goblin --version
12
+ #
13
+ # Identity: the package is goblin-stack, the command is `goblin`. W1 ships the DISPATCH
14
+ # SHELL only — the node shim and npm packaging are W2, doctor/emit are W4a (exit-2
15
+ # placeholders naming their workstream), upgrade is W3 (same). G3 forbids a runtime
16
+ # rewrite: the engine stays bash, this file only routes and propagates.
17
+ #
18
+ # The four-value verify contract is unchanged and non-negotiable (bin/goblin-verify:5-9):
19
+ # 0 every executed check passed
20
+ # 1 at least one check failed
21
+ # 2 could not run
22
+ # 3 the manifest itself is broken
23
+ # Every subcommand propagates the engine's exit code verbatim; no wrapper translates a
24
+ # 1 into a 0. A placeholder that silently exited 0 would be a green build doing nothing.
25
+ #
26
+ # Root resolution: find_installed_root() at bin/goblin-verify:64-77 is preserved
27
+ # BYTE-FOR-BYTE (walk up from $PWD to .goblin/goblin.yaml; fall back to git toplevel,
28
+ # then $PWD — the D20 nested-repo fix). W1 changes nothing about its logic.
29
+ #
30
+ # No npm, no jq, no yq, no network. bash/awk/sed/grep only.
31
+
32
+ set -uo pipefail
33
+
34
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
35
+ SRC=$(cd "$SELF_DIR/.." && pwd)
36
+ VERSION=$(cat "$SRC/VERSION" 2>/dev/null || printf 'unknown')
37
+
38
+ g_err() { printf 'error: %s\n' "$*" >&2; }
39
+
40
+ usage() {
41
+ cat <<'USAGE'
42
+ goblin — the goblin-stack command line.
43
+
44
+ goblin verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
45
+ goblin bans [--only <id[,id...]>] [--list]
46
+ goblin audit [--target <dir>] [--print]
47
+ goblin doctor [--platform <p>] [--target <dir>]
48
+ goblin emit --platform <p> --scope project|global [...]
49
+ goblin init [--target <dir>] [--class app|A-F] [--dry-run]
50
+ goblin upgrade [--target .] [--dry-run] [--yes] [--engine-dir <path>]
51
+ goblin --version
52
+
53
+ Exit codes (verify): 0 pass | 1 a check failed | 2 could not run | 3 the manifest is
54
+ broken. Every subcommand propagates the engine's exit code verbatim.
55
+ USAGE
56
+ }
57
+
58
+ # ---- the repo-root locator, preserved byte-for-byte from bin/goblin-verify:64-77 ----
59
+ find_installed_root() {
60
+ local d="$PWD"
61
+ while :; do
62
+ if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
63
+ [ "$d" = "/" ] && return 1
64
+ d=$(dirname "$d")
65
+ done
66
+ }
67
+
68
+ # ---- subcommand dispatch ------------------------------------------------------
69
+ CMD="${1:-}"
70
+ [ -n "$CMD" ] || { usage; exit 2; }
71
+ shift
72
+
73
+ case "$CMD" in
74
+ verify)
75
+ # The engine is whatever the per-repo chain resolves (W1 §2.3): --source, vendored,
76
+ # engine_dir:, GOBLIN_ENGINE_DIR, ~/.goblin/engine — all inside goblin-verify. The
77
+ # CLI is a pass-through, not a second root-resolution opinion (§3.4).
78
+ exec bash "$SRC/bin/goblin-verify" "$@"
79
+ ;;
80
+ bans)
81
+ exec bash "$SRC/bin/goblin-bans" "$@"
82
+ ;;
83
+ audit)
84
+ exec bash "$SRC/bin/goblin-audit" "$@"
85
+ ;;
86
+ doctor)
87
+ # W4a: one run, three platforms (the §7 report); the dispatcher routes and
88
+ # propagates the doctor's 0/1/2 verbatim, like every other subcommand.
89
+ exec bash "$SRC/bin/goblin-doctor" "$@"
90
+ ;;
91
+ emit)
92
+ # W4a: per-platform emission (the §4 contract); the 0/1/2 exit contract is the
93
+ # same three values verify's wrapper states, propagated verbatim.
94
+ exec bash "$SRC/bin/goblin-emit" "$@"
95
+ ;;
96
+ init)
97
+ # W6: the first-run wizard. It drives install/emit/verify and propagates their
98
+ # exit contract verbatim, like every other subcommand here.
99
+ exec bash "$SRC/bin/goblin-init" "$@"
100
+ ;;
101
+ upgrade)
102
+ # W3: the migration lives in its own checkout-level script (W3-SPEC §1.1);
103
+ # the dispatcher routes and propagates the four-value contract verbatim.
104
+ exec bash "$SRC/bin/goblin-upgrade" "$@"
105
+ ;;
106
+ --version|-V|-v)
107
+ printf '%s\n' "$VERSION"
108
+ exit 0
109
+ ;;
110
+ -h|--help|help)
111
+ usage
112
+ exit 0
113
+ ;;
114
+ *)
115
+ g_err "unknown subcommand: $CMD"
116
+ usage >&2
117
+ exit 2
118
+ ;;
119
+ esac