@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,289 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-lib.sh — shared helpers for goblin-stack.
3
+ #
4
+ # Sourced by bin/goblin-install, bin/goblin-verify and bin/goblin-model.
5
+ # Dependencies: bash 4+, git, awk, sed, grep, sha256sum (or shasum).
6
+ # No npm, no jq, no yq, no network.
7
+ #
8
+ # The config format is a FLAT, LINE-ORIENTED YAML SUBSET. It is parsed here, never by a
9
+ # YAML library, so the same file parses identically on any machine:
10
+ #
11
+ # key: value top-level scalar
12
+ # key: block or list header
13
+ # member: value two-space-indented block member
14
+ # - item two-space-indented list item
15
+ # gates:
16
+ # - name: typecheck four-space-indented second member of a list entry
17
+ # cmd: npx tsc --noEmit
18
+
19
+ GOBLIN_LIB_VERSION="0.4.4"
20
+
21
+ # ---------------------------------------------------------------- output -----
22
+ g_pass() { printf 'PASS %-6s %s\n' "$1" "$2"; }
23
+ g_fail() { printf 'FAIL %-6s %s\n' "$1" "$2"; }
24
+ g_adv() { printf 'ADV %-6s %s\n' "$1" "$2"; }
25
+ g_skip() { printf 'SKIP %-6s %s\n' "$1" "$2"; }
26
+ g_info() { printf '%s\n' "$*"; }
27
+ g_err() { printf 'error: %s\n' "$*" >&2; }
28
+
29
+ # ------------------------------------------------------------------ hash -----
30
+ g_sha256_file() {
31
+ if [ -f "$1" ]; then
32
+ if command -v sha256sum >/dev/null 2>&1; then
33
+ sha256sum "$1" | awk '{print $1}'
34
+ else
35
+ shasum -a 256 "$1" | awk '{print $1}'
36
+ fi
37
+ else
38
+ printf 'missing\n'
39
+ fi
40
+ }
41
+
42
+ g_sha256_text() {
43
+ if command -v sha256sum >/dev/null 2>&1; then
44
+ printf '%s' "$1" | sha256sum | awk '{print $1}'
45
+ else
46
+ printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
47
+ fi
48
+ }
49
+
50
+ # ------------------------------------------------------------------ paths ----
51
+ # g_abspath <path> — absolute path without requiring the file to exist.
52
+ g_abspath() {
53
+ case "$1" in
54
+ /*) printf '%s\n' "$1" ;;
55
+ "~"|"~/"*) printf '%s\n' "$HOME/${1#\~/}" ;;
56
+ *) printf '%s\n' "$PWD/$1" ;;
57
+ esac
58
+ }
59
+
60
+ # g_expand_tilde <path>
61
+ g_expand_tilde() {
62
+ case "$1" in
63
+ "~"|"~/"*) printf '%s\n' "$HOME/${1#\~/}" ;;
64
+ *) printf '%s\n' "$1" ;;
65
+ esac
66
+ }
67
+
68
+ # ------------------------------------------------------------------ yaml -----
69
+ # g_models_field <mapping-file> <profile> <field> — read one field of one profile out of a
70
+ # model mapping file. That file is two-level (a profile header, then its fields), so it is
71
+ # read with python3 — the same way the fleet's own tool reads it. Prints nothing if absent.
72
+ g_models_field() {
73
+ python3 -c '
74
+ import re, sys
75
+ path, profile, field = sys.argv[1], sys.argv[2], sys.argv[3]
76
+ try:
77
+ lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
78
+ except OSError:
79
+ sys.exit(0)
80
+ start, base = None, 0
81
+ for i, ln in enumerate(lines):
82
+ m = re.match(r"^(\s*)([A-Za-z_][\w.-]*):\s*$", ln)
83
+ if m and m.group(2) == profile:
84
+ start, base = i, len(m.group(1)); break
85
+ if start is not None:
86
+ for ln in lines[start + 1:]:
87
+ if not ln.strip() or ln.lstrip().startswith("#"):
88
+ continue
89
+ if len(ln) - len(ln.lstrip()) <= base:
90
+ break
91
+ m = re.match(r"^\s*([A-Za-z_][\w.-]*):\s*(.*)$", ln)
92
+ if m and m.group(1) == field:
93
+ v = m.group(2).strip()
94
+ print(v[1:-1] if len(v) > 1 and v[0] == v[-1] and v[0] in "\"\x27" else v)
95
+ break
96
+ ' "$1" "$2" "$3"
97
+ }
98
+
99
+ # g_unquote <value> — strip one layer of surrounding double quotes.
100
+ g_unquote() {
101
+ local v="$1"
102
+ v=${v%\"}; v=${v#\"}
103
+ printf '%s' "$v"
104
+ }
105
+
106
+ # g_yaml_scalar <file> <key> — value of a top-level scalar key (empty if absent).
107
+ g_yaml_scalar() {
108
+ sed -n "s/^$2:[[:space:]]*//p" "$1" | head -n 1
109
+ }
110
+
111
+ # g_yaml_block_scalar <file> <block> <key> — value of a 2-space-indented block member.
112
+ g_yaml_block_scalar() {
113
+ awk -v b="$2" -v k="$3" '
114
+ $0 ~ ("^" b ":[[:space:]]*$") { inb = 1; next }
115
+ inb && /^[^ ]/ { inb = 0 }
116
+ inb && $0 ~ ("^ " k ":") {
117
+ v = $0; sub("^ " k ":[[:space:]]*", "", v); print v; exit
118
+ }
119
+ ' "$1"
120
+ }
121
+
122
+ # g_yaml_list <file> <key> — 2-space-indented list items under a top-level list key.
123
+ g_yaml_list() {
124
+ awk -v k="$2" '
125
+ $0 ~ ("^" k ":[[:space:]]*$") { inb = 1; next }
126
+ inb && /^[^ ]/ { inb = 0 }
127
+ inb && /^ - / { v = $0; sub(/^ - /, "", v); print v }
128
+ ' "$1"
129
+ }
130
+
131
+ # g_yaml_gates <file> — one "name<TAB>cmd" line per declared gate.
132
+ g_yaml_gates() {
133
+ awk '
134
+ /^gates:[[:space:]]*$/ { inb = 1; next }
135
+ inb && /^[^ ]/ { inb = 0 }
136
+ inb && /^ - name:/ { n = $0; sub(/^ - name:[[:space:]]*/, "", n); name = n; next }
137
+ inb && /^ cmd:/ { c = $0; sub(/^ cmd:[[:space:]]*/, "", c);
138
+ if (name != "") { print name "\t" c; name = "" } }
139
+ ' "$1"
140
+ }
141
+
142
+ # g_yaml_gate_names <file> — one declared gate NAME per line, whether or not a `cmd:` follows it.
143
+ # g_yaml_gates prints a name only when a `cmd:` LINE follows it, so a gate whose `cmd:` was
144
+ # deleted, blanked or re-indented vanishes from it in silence. This reader keeps the name
145
+ # visible, which is what lets GT-01 FAIL on the disappearance instead of reporting the
146
+ # survivors (G8-3).
147
+ g_yaml_gate_names() {
148
+ awk '
149
+ /^gates:[[:space:]]*$/ { inb = 1; next }
150
+ inb && /^[^ ]/ { inb = 0 }
151
+ inb && /^ - name:/ { n = $0; sub(/^ - name:[[:space:]]*/, "", n); print n }
152
+ ' "$1"
153
+ }
154
+
155
+ # g_yaml_gates_all <file> — one "name<TAB>cmd" line per DECLARED gate, including a gate whose
156
+ # `cmd:` is missing or blank (the value is printed empty). g_yaml_gates emits only name+cmd
157
+ # PAIRS, which is right for running a gate (GT-02) and wrong for counting declarations
158
+ # (GT-01): a gate could lose its `cmd:` and the row still printed the survivors (G8-3).
159
+ g_yaml_gates_all() {
160
+ awk '
161
+ function flush() { if (name != "") { print name "\t"; name = "" } }
162
+ /^gates:[[:space:]]*$/ { inb = 1; next }
163
+ inb && /^[^ ]/ { flush(); inb = 0 }
164
+ inb && /^ - name:/ { flush(); n = $0; sub(/^ - name:[[:space:]]*/, "", n); name = n; next }
165
+ inb && /^ cmd:/ { c = $0; sub(/^ cmd:[[:space:]]*/, "", c); print name "\t" c; name = "" }
166
+ END { if (inb) flush() }
167
+ ' "$1"
168
+ }
169
+
170
+ # g_yaml_disabled <file> — inline list of disabled parts, one per line.
171
+ g_yaml_disabled() {
172
+ local v
173
+ v=$(g_yaml_scalar "$1" disabled)
174
+ case "$v" in
175
+ ""|"[]") return 0 ;;
176
+ esac
177
+ v=${v#[}; v=${v%]}
178
+ printf '%s\n' "$v" | tr ',' '\n' \
179
+ | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' || true
180
+ }
181
+
182
+ # g_part_disabled <config> <part>
183
+ g_part_disabled() {
184
+ g_yaml_disabled "$1" | grep -qx "$2"
185
+ }
186
+
187
+ # ------------------------------------------------------------- class data ----
188
+ # g_class_need <classes.tsv> <class> <part> -> R | O | -
189
+ g_class_need() {
190
+ awk -F'\t' -v c="$2" -v p="$3" '
191
+ NR > 1 && $1 == c && $2 == p { print $3; found = 1; exit }
192
+ END { if (!found) print "-" }
193
+ ' "$1"
194
+ }
195
+
196
+ # ---------------------------------------------------------------- json -------
197
+ # installed.json is emitted by goblin-install in a fixed, line-oriented shape so it can
198
+ # be read without a JSON library. g_json_object <installed.json> <object-name> -> "key<TAB>value".
199
+ g_json_object() {
200
+ awk -v name="$2" '
201
+ index($0, "\"" name "\"") && /:[[:space:]]*\{/ { inf = 1; next }
202
+ inf && /^[[:space:]]*\}/ { inf = 0 }
203
+ inf && /"/ {
204
+ line = $0
205
+ sub(/^[[:space:]]*"/, "", line)
206
+ p = line; sub(/".*/, "", p)
207
+ h = line; sub(/^[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
208
+ if (p != "") print p "\t" h
209
+ }
210
+ ' "$1"
211
+ }
212
+
213
+ g_installed_files() { g_json_object "$1" files; }
214
+
215
+ g_installed_scalar() {
216
+ sed -n "s/^[[:space:]]*\"$2\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}$/\1/p" "$1" | head -n 1
217
+ }
218
+
219
+ # ------------------------------------------------------------- self-test -----
220
+ # Proves the parser actually parses. Every assertion is a real comparison against a
221
+ # value written to a temp file in this function — blank the awk in g_yaml_scalar and
222
+ # this exits non-zero (that is the RED control for the reader itself).
223
+ g_self_test() {
224
+ local tmp rc=0
225
+ tmp=$(mktemp -d 2>/dev/null || mktemp -d -t goblin) || { g_err "mktemp failed"; return 2; }
226
+ cat > "$tmp/g.yaml" <<'YAML'
227
+ class: B
228
+ branch: master
229
+ archive: false
230
+ disabled: [spec, tokens]
231
+ gates:
232
+ - name: typecheck
233
+ cmd: npx tsc --noEmit
234
+ ratchet:
235
+ name: hex
236
+ ceiling: 160
237
+ runtime_data:
238
+ - .goblin/state.json
239
+ YAML
240
+
241
+ local got
242
+ got=$(g_yaml_scalar "$tmp/g.yaml" class)
243
+ [ "$got" = "B" ] || { g_err "scalar: expected B, got '$got'"; rc=1; }
244
+ got=$(g_yaml_scalar "$tmp/g.yaml" branch)
245
+ [ "$got" = "master" ] || { g_err "scalar: expected master, got '$got'"; rc=1; }
246
+ got=$(g_yaml_scalar "$tmp/g.yaml" nosuchkey)
247
+ [ -z "$got" ] || { g_err "scalar: absent key should be empty, got '$got'"; rc=1; }
248
+ got=$(g_yaml_block_scalar "$tmp/g.yaml" ratchet ceiling)
249
+ [ "$got" = "160" ] || { g_err "block scalar: expected 160, got '$got'"; rc=1; }
250
+ got=$(g_yaml_block_scalar "$tmp/g.yaml" ratchet name)
251
+ [ "$got" = "hex" ] || { g_err "block scalar: expected hex, got '$got'"; rc=1; }
252
+ got=$(g_yaml_gates "$tmp/g.yaml" | tr '\t' ':')
253
+ [ "$got" = "typecheck:npx tsc --noEmit" ] || { g_err "gates: got '$got'"; rc=1; }
254
+ # The gates-all readers exist for the one case the pair reader hides: a gate whose `cmd:` LINE
255
+ # is missing (deleted or re-indented). g_yaml_gates drops it; g_yaml_gates_all keeps it with an
256
+ # empty cmd and g_yaml_gate_names still names it. Blank either awk and this exits non-zero -
257
+ # that is this self-test's own RED control for the G8-3 fix (the disappearance GT-01 must FAIL).
258
+ printf 'gates:\n - name: typecheck\n cmd: npx tsc --noEmit\n - name: reindented\n cmd: false\n' > "$tmp/g2.yaml"
259
+ got=$(g_yaml_gates "$tmp/g2.yaml" | wc -l | tr -d '[:space:]')
260
+ [ "$got" = "1" ] || { g_err "gates: a cmd-less gate should vanish from the pair reader, got $got line(s)"; rc=1; }
261
+ got=$(g_yaml_gates_all "$tmp/g2.yaml" | wc -l | tr -d '[:space:]')
262
+ [ "$got" = "2" ] || { g_err "gates-all: expected 2 declared gates, got $got"; rc=1; }
263
+ got=$(g_yaml_gates_all "$tmp/g2.yaml" | awk -F'\t' '$1 == "reindented" && $2 == ""' | wc -l | tr -d '[:space:]')
264
+ [ "$got" = "1" ] || { g_err "gates-all: the cmd-less gate was not reported with an empty cmd"; rc=1; }
265
+ got=$(g_yaml_gate_names "$tmp/g2.yaml" | tr '\n' ',')
266
+ [ "$got" = "typecheck,reindented," ] || { g_err "gate-names: got '$got'"; rc=1; }
267
+ got=$(g_yaml_list "$tmp/g.yaml" runtime_data | tr '\n' ',')
268
+ [ "$got" = ".goblin/state.json," ] || { g_err "list: got '$got'"; rc=1; }
269
+ got=$(g_yaml_disabled "$tmp/g.yaml" | tr '\n' ',')
270
+ [ "$got" = "spec,tokens," ] || { g_err "disabled: got '$got'"; rc=1; }
271
+
272
+ rm -rf "$tmp"
273
+ if [ "$rc" -eq 0 ]; then
274
+ printf 'OK\n'
275
+ else
276
+ printf 'SELF-TEST FAILED\n' >&2
277
+ fi
278
+ return "$rc"
279
+ }
280
+
281
+ # Allow `bash bin/goblin-lib.sh --self-test` and `source bin/goblin-lib.sh`.
282
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
283
+ case "${1:-}" in
284
+ --self-test) g_self_test ;;
285
+ --version) printf '%s\n' "$GOBLIN_LIB_VERSION" ;;
286
+ *) printf 'usage: goblin-lib.sh --self-test\n' >&2; exit 2 ;;
287
+ esac
288
+ exit $?
289
+ fi
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-model — resolve a ROLE to provider/model/effort, by reading one mapping file.
3
+ #
4
+ # Usage:
5
+ # goblin-model <role> [--models <file>] [--config <goblin.yaml>] [--roles <roles.yaml>] [--json]
6
+ # goblin-model --list
7
+ #
8
+ # A role is what the work needs (code | judgment | review-panel | synthesis | investigate).
9
+ # A profile is a worker identity. This tool never invents a model: it looks the role's
10
+ # default profile up in the mapping file and prints what that file says.
11
+ #
12
+ # The mapping file is the ONE install-time machine input. goblin-stack READS it and never
13
+ # writes it. Absent on this machine -> every role resolves to `unknown`, and the model-related
14
+ # checks in goblin-verify report advisory instead of failing.
15
+ #
16
+ # Output, one line per lane: <profile> <provider> <model> <effort>
17
+
18
+ set -uo pipefail
19
+
20
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
21
+ # shellcheck source=goblin-lib.sh
22
+ . "$SELF_DIR/goblin-lib.sh"
23
+
24
+ ROLE=""
25
+ MODELS="${GOBLIN_MODELS:-$HOME/projects/fleet-model.yaml}"
26
+ CONFIG=""
27
+ ROLES=""
28
+ JSON=0
29
+ LIST=0
30
+
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in
33
+ --models) MODELS="${2:-}"; MODELS_EXPLICIT=1; shift 2 ;;
34
+ --config) CONFIG="${2:-}"; shift 2 ;;
35
+ --roles) ROLES="${2:-}"; shift 2 ;;
36
+ --json) JSON=1; shift ;;
37
+ --list) LIST=1; shift ;;
38
+ -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
39
+ -*) g_err "unknown option: $1"; exit 2 ;;
40
+ *) ROLE="$1"; shift ;;
41
+ esac
42
+ done
43
+
44
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
45
+ [ -n "$ROOT" ] || ROOT="$PWD"
46
+
47
+ # Config: explicit, else the installed one, else none.
48
+ if [ -z "$CONFIG" ]; then
49
+ if [ -f "$ROOT/.goblin/goblin.yaml" ]; then CONFIG="$ROOT/.goblin/goblin.yaml"; fi
50
+ fi
51
+ if [ -n "$CONFIG" ] && [ -z "${MODELS_EXPLICIT:-}" ]; then
52
+ m=$(g_yaml_scalar "$CONFIG" models_file)
53
+ [ -n "$m" ] && MODELS="$m"
54
+ fi
55
+ MODELS=$(g_expand_tilde "$MODELS")
56
+
57
+ if [ -z "$ROLES" ]; then
58
+ if [ -f "$ROOT/.goblin/roles.yaml" ]; then ROLES="$ROOT/.goblin/roles.yaml"
59
+ else ROLES="$SELF_DIR/../roles.yaml"; fi
60
+ fi
61
+ [ -f "$ROLES" ] || { g_err "no roles file at $ROLES"; exit 2; }
62
+
63
+ roles_list() {
64
+ awk '/^role-/{ n = $0; sub(/^role-/, "", n); sub(/:.*/, "", n); print n }' "$ROLES"
65
+ }
66
+
67
+ if [ "$LIST" -eq 1 ]; then
68
+ for r in $(roles_list); do
69
+ printf '%-14s %s\n' "$r" "$(g_yaml_block_scalar "$ROLES" "role-$r" capability)"
70
+ done
71
+ exit 0
72
+ fi
73
+
74
+ [ -n "$ROLE" ] || { g_err "a role is required (see --list)"; exit 2; }
75
+
76
+ if ! roles_list | grep -qx "$ROLE"; then
77
+ g_err "unknown role '$ROLE'; known: $(roles_list | paste -sd'|' -)"
78
+ exit 2
79
+ fi
80
+
81
+ profiles=$(g_yaml_block_scalar "$ROLES" "role-$ROLE" profiles)
82
+ profiles=$(printf '%s' "$profiles" | tr -d '[]' | tr ',' ' ')
83
+ [ -n "${profiles// /}" ] || { g_err "role $ROLE declares no profiles"; exit 2; }
84
+
85
+ if [ ! -f "$MODELS" ]; then
86
+ g_info "no model mapping configured: $MODELS is absent"
87
+ for p in $profiles; do
88
+ if [ "$JSON" -eq 1 ]; then printf '{"role":"%s","profile":"%s","provider":"unknown","model":"unknown","effort":"unknown"}\n' "$ROLE" "$p"
89
+ else printf '%s unknown unknown unknown\n' "$p"; fi
90
+ done
91
+ exit 0
92
+ fi
93
+
94
+ for p in $profiles; do
95
+ prov=$(g_models_field "$MODELS" "$p" provider)
96
+ model=$(g_models_field "$MODELS" "$p" model)
97
+ eff=$(g_models_field "$MODELS" "$p" effort)
98
+ prov=${prov:-unknown}; model=${model:-unknown}; eff=${eff:-unknown}
99
+ if [ "$JSON" -eq 1 ]; then
100
+ printf '{"role":"%s","profile":"%s","provider":"%s","model":"%s","effort":"%s"}\n' "$ROLE" "$p" "$prov" "$model" "$eff"
101
+ else
102
+ printf '%s %s %s %s\n' "$p" "$prov" "$model" "$eff"
103
+ fi
104
+ done
105
+ exit 0