@techgoblin/gobstack 0.5.0-beta.8 → 0.6.0-alpha.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 (59) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +161 -124
  3. package/VERSION +1 -1
  4. package/automations/drift-audit.sh +4 -4
  5. package/bans/layer-check.sh +10 -8
  6. package/bin/goblin +69 -57
  7. package/bin/goblin-audit +11 -13
  8. package/bin/goblin-bans +11 -11
  9. package/bin/goblin-extras +342 -0
  10. package/bin/goblin-init +382 -711
  11. package/bin/goblin-install +160 -114
  12. package/bin/goblin-lib.sh +234 -1
  13. package/bin/goblin-map +226 -21
  14. package/bin/goblin-mcp.js +492 -0
  15. package/bin/goblin-model +4 -4
  16. package/bin/goblin-upgrade +1 -1
  17. package/bin/goblin-verify +159 -145
  18. package/bin/goblin.js +35 -51
  19. package/docs/ADOPTION.md +15 -15
  20. package/docs/CONTRACTS.md +16 -15
  21. package/docs/DESIGN.md +1 -1
  22. package/docs/ENFORCEMENT.md +89 -90
  23. package/docs/FLOWS.md +1 -1
  24. package/docs/GLOSSARY.md +3 -3
  25. package/docs/GUARDRAILS.md +5 -5
  26. package/docs/GUIDE.md +194 -177
  27. package/docs/INTEGRATION.md +1 -1
  28. package/docs/LIMITS.md +25 -0
  29. package/docs/LOOP.md +12 -12
  30. package/docs/RE-PLAYBOOK.md +3 -3
  31. package/docs/ROLES.md +5 -5
  32. package/extras-catalogue/catalogue.tsv +42 -0
  33. package/extras-catalogue/payload/README.md +14 -0
  34. package/extras-catalogue/payload/taste-skill/taste/REFERENCE.md +3 -0
  35. package/extras-catalogue/payload/taste-skill/taste/SKILL.md +9 -0
  36. package/manifest/bans.tsv +8 -8
  37. package/manifest/classes.tsv +3 -3
  38. package/manifest/enforcement.tsv +40 -40
  39. package/manifest/glossary.tsv +3 -3
  40. package/manifest/playbooks.tsv +1 -1
  41. package/package.json +3 -1
  42. package/presets/electron-overlay.yaml +2 -2
  43. package/presets/fleet.yaml +8 -7
  44. package/presets/game.yaml +1 -1
  45. package/presets/research.yaml +1 -1
  46. package/presets/service.yaml +1 -1
  47. package/presets/software.yaml +1 -1
  48. package/skills/goblin-bootstrap/SKILL.md +2 -2
  49. package/templates/AGENTS.md.tmpl +8 -18
  50. package/templates/HANDOFF.md.tmpl +5 -5
  51. package/templates/agents-block.tmpl +45 -0
  52. package/templates/audit-waiver.tsv.tmpl +2 -2
  53. package/templates/boundary-waivers.tmpl +1 -1
  54. package/templates/checks/gate.sh.tmpl +6 -6
  55. package/templates/install-hooks.allowlist.tmpl +1 -1
  56. package/templates/ci/goblin-gate.yml.tmpl +0 -46
  57. package/templates/goblin.yaml.tmpl +0 -146
  58. package/templates/loop/decisions.tsv.tmpl +0 -1
  59. package/templates/loop/predicate.tmpl +0 -16
package/bin/goblin CHANGED
@@ -1,20 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # goblin — the global CLI dispatcher (W1, PLAN-V1 §2.3 / W1-SPEC §3).
2
+ # goblin — the CLI dispatcher.
3
3
  #
4
- # gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
5
- # gob bans [--only <id[,id...]>] [--list]
6
- # gob audit [--target <dir>] [--print]
7
- # gob doctor [--platform <p>] # W4a
8
- # gob emit --platform <p> [...] # W4a/W4b; `gob sync` is the same verb, renamed
9
- # gob init [...] # W6: the first-run wizard
10
- # gob upgrade [--target .] [...] # W3
4
+ # gob init [--heuristic | --write <proposal>] [--target <dir>] [...]
5
+ # gob map [target] [--agent | --heuristic] [--write <dir>] [--force]
6
+ # gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
7
+ # gob bans [--only <id[,id...]>] [--list]
8
+ # gob extras list [category] | show <id> | install <id...> [--target <dir>]
9
+ # [--platform <id>] [--with-mcp-config] [--dry-run]
10
+ # gob mcp serve the harness over MCP stdio
11
+ # gob uninstall --target <dir>
11
12
  # gob --version
12
13
  #
14
+ # v2 SURFACE (AI-driven development): the CLI calls no AI API. `gob init` and `gob map`
15
+ # EMIT a prompt + schema that the agent already running in this repo fulfils with its own
16
+ # tools; heuristic detection demotes to the --heuristic fallback.
17
+ #
18
+ # UNWIRED this session (code kept under bin/, deletion is session 3): doctor, audit,
19
+ # upgrade, emit/sync. `sync` becomes an init-internal step (hidden --resync flag).
20
+ #
13
21
  # Identity: the package is gobstack (npm @techgoblin/gobstack), the command is `gob`
14
- # (`goblin` remains as a legacy alias). W1 ships the DISPATCH
15
- # SHELL only — the node shim and npm packaging are W2, doctor/emit are W4a (exit-2
16
- # placeholders naming their workstream), upgrade is W3 (same). G3 forbids a runtime
17
- # rewrite: the engine stays bash, this file only routes and propagates.
22
+ # (`goblin` remains as a legacy alias).
18
23
  #
19
24
  # The four-value verify contract is unchanged and non-negotiable (bin/goblin-verify:5-9):
20
25
  # 0 every executed check passed
@@ -22,11 +27,10 @@
22
27
  # 2 could not run
23
28
  # 3 the manifest itself is broken
24
29
  # Every subcommand propagates the engine's exit code verbatim; no wrapper translates a
25
- # 1 into a 0. A placeholder that silently exited 0 would be a green build doing nothing.
30
+ # 1 into a 0.
26
31
  #
27
- # Root resolution: find_installed_root() at bin/goblin-verify:64-77 is preserved
28
- # BYTE-FOR-BYTE (walk up from $PWD to .goblin/goblin.yaml; fall back to git toplevel,
29
- # then $PWD — the D20 nested-repo fix). W1 changes nothing about its logic.
32
+ # Root resolution: find_installed_root() walks up from $PWD to an AGENTS.md carrying the
33
+ # gob marker block; fall back to git toplevel, then $PWD — the D20 nested-repo fix.
30
34
  #
31
35
  # No npm, no jq, no yq, no network. bash/awk/sed/grep only.
32
36
 
@@ -42,27 +46,31 @@ usage() {
42
46
  cat <<'USAGE'
43
47
  gob — the gobstack command line.
44
48
 
45
- gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
46
- gob bans [--only <id[,id...]>] [--list]
47
- gob audit [--target <dir>] [--print]
48
- gob doctor [--platform <p>] [--target <dir>]
49
- gob emit --platform <p> --scope project|global [...] # alias: gob sync
50
- gob sync --platform <p> --scope project|global [...] # the same verb, friendlier name
51
- gob init [--target <dir>] [--class app|A-F] [--dry-run]
52
- gob map [target] [--force] # the standalone feature-map generator (no install needed)
53
- gob upgrade [--target .] [--dry-run] [--yes] [--engine-dir <path>]
49
+ gob init [--heuristic | --write <proposal>] [--target <dir>] [--class app|A-F] [--dry-run]
50
+ gob map [target] [--agent | --heuristic] [--write <dir>] [--force]
51
+ gob verify [--only <id[,id...]>] [--json] [--list] [--source <path>]
52
+ gob bans [--only <id[,id...]>] [--list]
53
+ gob extras list [category] | show <id> | install <id...> # the curated extras catalogue
54
+ gob mcp serve the harness to your agent over MCP stdio (verify/map/init tools)
55
+ gob uninstall --target <dir>
54
56
  gob --version
55
57
 
58
+ gob init and gob map are AI-DRIVEN: they print a prompt + schema for the agent already
59
+ running in this repo, which writes the proposal file; `--write` validates and installs
60
+ it. The heuristic detectors are the --heuristic fallback.
61
+
56
62
  Exit codes (verify): 0 pass | 1 a check failed | 2 could not run | 3 the manifest is
57
63
  broken. Every subcommand propagates the engine's exit code verbatim.
58
64
  USAGE
59
65
  }
60
66
 
61
- # ---- the repo-root locator, preserved byte-for-byte from bin/goblin-verify:64-77 ----
67
+ # ---- the repo-root locator (an AGENTS.md gob block is the install marker) ----
62
68
  find_installed_root() {
63
69
  local d="$PWD"
64
70
  while :; do
65
- if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
71
+ if [ -f "$d/AGENTS.md" ] && grep -q '^<!-- gob:begin' "$d/AGENTS.md" 2>/dev/null; then
72
+ printf '%s\n' "$d"; return 0
73
+ fi
66
74
  [ "$d" = "/" ] && return 1
67
75
  d=$(dirname "$d")
68
76
  done
@@ -74,6 +82,15 @@ CMD="${1:-}"
74
82
  shift
75
83
 
76
84
  case "$CMD" in
85
+ init)
86
+ # The prompt+schema engine: prints the agent brief, or installs a proposal.
87
+ exec bash "$SRC/bin/goblin-init" "$@"
88
+ ;;
89
+ map)
90
+ # The standalone feature-map generator: `--agent` (default) prints prompt+schema;
91
+ # --heuristic runs the scanner. Routes and propagates its 0/1/2 contract verbatim.
92
+ exec bash "$SRC/bin/goblin-map" "$@"
93
+ ;;
77
94
  verify)
78
95
  # The engine is whatever the per-repo chain resolves (W1 §2.3): --source, vendored,
79
96
  # engine_dir:, GOBLIN_ENGINE_DIR, ~/.goblin/engine — all inside goblin-verify. The
@@ -83,38 +100,22 @@ case "$CMD" in
83
100
  bans)
84
101
  exec bash "$SRC/bin/goblin-bans" "$@"
85
102
  ;;
86
- audit)
87
- exec bash "$SRC/bin/goblin-audit" "$@"
88
- ;;
89
- doctor)
90
- # W4a: one run, three platforms (the §7 report); the dispatcher routes and
91
- # propagates the doctor's 0/1/2 verbatim, like every other subcommand.
92
- exec bash "$SRC/bin/goblin-doctor" "$@"
103
+ extras)
104
+ # The curated catalogue browser + installer (bin/goblin-extras): list/show/install
105
+ # over extras-catalogue/catalogue.tsv. The catalogue is the ALLOWLIST: nothing is
106
+ # ever installed that a catalogue row does not name, and `gob init` only suggests.
107
+ exec bash "$SRC/bin/goblin-extras" "$@"
93
108
  ;;
94
- emit)
95
- # W4a: per-platform emission (the §4 contract); the 0/1/2 exit contract is the
96
- # same three values verify's wrapper states, propagated verbatim.
97
- exec bash "$SRC/bin/goblin-emit" "$@"
109
+ mcp)
110
+ # The MCP stdio server (bin/goblin-mcp.js): JSON-RPC 2.0 over stdin/stdout, a LOCAL
111
+ # tool server over the vendored harness — no API call, no socket. node is the same
112
+ # runtime bin/goblin.js already needs; it is exec'd directly, args pass through.
113
+ exec node "$SRC/bin/goblin-mcp.js" "$@"
98
114
  ;;
99
- sync)
100
- # Wizard v2: the friendlier name for the same emission engine. `emit` stays a
101
- # first-class verb; this case only adds the alias and propagates identically.
102
- exec bash "$SRC/bin/goblin-emit" "$@"
103
- ;;
104
- init)
105
- # W6: the first-run wizard. It drives install/emit/verify and propagates their
106
- # exit contract verbatim, like every other subcommand here.
107
- exec bash "$SRC/bin/goblin-init" "$@"
108
- ;;
109
- map)
110
- # The standalone feature-map generator: routes and propagates its 0/1/2 contract
111
- # verbatim, like every other subcommand. It needs no .goblin/ install by design.
112
- exec bash "$SRC/bin/goblin-map" "$@"
113
- ;;
114
- upgrade)
115
- # W3: the migration lives in its own checkout-level script (W3-SPEC §1.1);
116
- # the dispatcher routes and propagates the four-value contract verbatim.
117
- exec bash "$SRC/bin/goblin-upgrade" "$@"
115
+ uninstall)
116
+ # Routes into the installer's uninstall job. `--uninstall` is appended FIRST so the
117
+ # user's own `--target <dir>` and options still parse.
118
+ exec bash "$SRC/bin/goblin-install" --uninstall "$@"
118
119
  ;;
119
120
  --version|-V|-v)
120
121
  printf '%s\n' "$VERSION"
@@ -124,6 +125,17 @@ case "$CMD" in
124
125
  usage
125
126
  exit 0
126
127
  ;;
128
+ doctor|audit|upgrade|emit|sync|install)
129
+ # Unwired from usage in v2 (code kept; deletion is session 3). The refusal names
130
+ # what replaced each verb rather than silently executing the old path.
131
+ g_err "gob $CMD is unwired in v2."
132
+ case "$CMD" in
133
+ install|init-legacy) g_err "fix: gob init --write <proposal> installs a repo now." ;;
134
+ sync|emit) g_err "fix: gob init --write <proposal> runs the sync step internally." ;;
135
+ doctor|audit|upgrade) g_err "fix: gob verify (audit stays a deliberate manual step; see docs/)." ;;
136
+ esac
137
+ exit 2
138
+ ;;
127
139
  *)
128
140
  g_err "unknown subcommand: $CMD"
129
141
  usage >&2
package/bin/goblin-audit CHANGED
@@ -16,14 +16,14 @@
16
16
  #
17
17
  # Exit codes:
18
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
19
+ # 2 usage, or no AGENTS.md frontmatter to read `security.audit_cmd` from
20
20
  # 3 the class declares no audit command (security.audit_cmd is empty) - nothing to run
21
21
  # 4 the declared command could not run
22
22
  # 5 the output could not be parsed as an audit report - REFUSING to write an empty record,
23
23
  # because an empty record reads to SC-07 as "clean" and that would be a fabricated pass
24
24
  set -uo pipefail
25
25
 
26
- GOBLIN_AUDIT_VERSION="0.5.0"
26
+ GOBLIN_AUDIT_VERSION="0.6.0-alpha.1"
27
27
 
28
28
  usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
29
29
 
@@ -40,22 +40,20 @@ done
40
40
  [ -n "$TARGET" ] || { printf 'goblin-audit: --target needs a directory\n' >&2; exit 2; }
41
41
  cd "$TARGET" || { printf 'goblin-audit: no such directory: %s\n' "$TARGET" >&2; exit 2; }
42
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; }
43
+ CONFIG="AGENTS.md"
44
+ [ -f "$CONFIG" ] && grep -q "^$GOB_AGENTS_BEGIN" "$CONFIG" 2>/dev/null \
45
+ || { printf 'goblin-audit: %s has no %s gob block - run gob init first\n' "$TARGET" "$CONFIG" >&2; exit 2; }
45
46
 
46
47
  # Self-contained on purpose: the audit runner must work even if goblin-lib.sh was edited or is
47
48
  # 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"
49
+ g_agents_read_inline() { # <file> <dotted-key> — the one reader the audit needs, inline
50
+ local f="$1" k="$2" line
51
+ while IFS= read -r line; do
52
+ case "$line" in "$k:"*) printf '%s\n' "${line#"$k":}"; return 0 ;; esac
53
+ done < "$f"
56
54
  }
57
55
 
58
- AUDIT_CMD=$(g_yaml_block_scalar "$CONFIG" security audit_cmd)
56
+ AUDIT_CMD=$(g_agents_read_inline "$CONFIG" security.audit_cmd)
59
57
  if [ -z "$AUDIT_CMD" ]; then
60
58
  printf 'goblin-audit: this class declares no audit command (security.audit_cmd is empty in\n'
61
59
  printf ' %s). Nothing to run, and no record to write.\n' "$CONFIG"
package/bin/goblin-bans CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  set -uo pipefail
30
30
 
31
- GOBLIN_BANS_VERSION="0.5.0"
31
+ GOBLIN_BANS_VERSION="0.6.0-alpha.1"
32
32
  SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
33
33
  # shellcheck source=goblin-lib.sh
34
34
  . "$SELF_DIR/goblin-lib.sh"
@@ -49,7 +49,7 @@ done
49
49
  find_installed_root() {
50
50
  local d="$PWD"
51
51
  while :; do
52
- if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
52
+ if [ -f "$d/AGENTS.md" ] && grep -q "^$GOB_AGENTS_BEGIN" "$d/AGENTS.md" 2>/dev/null; then printf '%s\n' "$d"; return 0; fi
53
53
  [ "$d" = "/" ] && return 1
54
54
  d=$(dirname "$d")
55
55
  done
@@ -60,9 +60,9 @@ if [ -z "$ROOT" ]; then
60
60
  [ -n "$ROOT" ] || ROOT="$PWD"
61
61
  fi
62
62
 
63
- CONFIG="$ROOT/.goblin/goblin.yaml"
64
- if [ -f "$ROOT/.goblin/manifest/bans.tsv" ]; then
65
- BANS="$ROOT/.goblin/manifest/bans.tsv"
63
+ CONFIG="$ROOT/AGENTS.md"
64
+ if [ -f "$ROOT/.gob/manifest/bans.tsv" ]; then
65
+ BANS="$ROOT/.gob/manifest/bans.tsv"
66
66
  else
67
67
  BANS="$SELF_DIR/../manifest/bans.tsv"
68
68
  fi
@@ -85,13 +85,11 @@ fi
85
85
  # ---- the config: which bans this project turns on, and its narrow exceptions --
86
86
  ENABLED=""
87
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 ' ' ' ')
88
+ ENABLED=$(g_agents_list "$CONFIG" bans | tr '\n' ' ')
91
89
  # The electron opt-in (software class): `electron: true` turns the electron ban set ON even
92
90
  # when a hand-edited bans: list omits it — the declaration is the contract, and the engine
93
91
  # (not the LLM) is what makes a listed ban non-skippable. A repo without the key is unaffected.
94
- if [ "$(g_yaml_scalar "$CONFIG" electron)" = "true" ]; then
92
+ if [ "$(g_agents_read "$CONFIG" electron)" = "true" ]; then
95
93
  for e in BN-06 BN-07 BN-08 BN-09; do
96
94
  case " $ENABLED " in *" $e "*) ;; *) ENABLED="$ENABLED $e" ;; esac
97
95
  done
@@ -113,7 +111,9 @@ enabled() {
113
111
  # keeps the old behaviour, which fails CLOSED, never open.
114
112
  exempt_paths() { # exempt_paths <id> -> path prefixes, one per line
115
113
  [ -f "$CONFIG" ] || return 0
116
- g_yaml_list "$CONFIG" bans_exempt | awk -v id="$1" '$1==id {print $2}'
114
+ # v2 shape: one flat `[BN-01 src, BN-02 app]` array — item 1 is the ban id, item 2 its
115
+ # path prefix (g_agents_list splits on the commas, so an item keeps its inner space).
116
+ g_agents_list "$CONFIG" bans_exempt | awk -v id="$1" '$1==id {print $2}'
117
117
  }
118
118
 
119
119
  only_selected() {
@@ -131,7 +131,7 @@ while IFS=$'\t' read -r id ban globs detect replacement escape reviewer source;
131
131
  SEL=$((SEL + 1))
132
132
 
133
133
  if ! enabled "$id"; then
134
- SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to .goblin/goblin.yaml to turn it on)"
134
+ SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to the bans: array in AGENTS.md to turn it on)"
135
135
  continue
136
136
  fi
137
137
  # A ban whose globs match no file is a SKIP with a reason, not a pass and not a fail.
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-extras — `gob extras`: the curated catalogue browser + installer (v2 W-extras).
3
+ #
4
+ # gob extras list [category] the catalogue, grouped by category
5
+ # gob extras show <id> one row, every field, + the install hint
6
+ # gob extras install <id...> [--target <dir>] [--platform <id>]
7
+ # [--with-mcp-config] [--dry-run]
8
+ #
9
+ # The ALLOWLIST RULE (non-negotiable): only ids that exist in catalogue.tsv are ever
10
+ # installed. An id the catalogue does not carry is refused by name — there is no path from
11
+ # a URL or a repo name on this command to a file write. `gob init` SUGGESTS rows and never
12
+ # auto-installs; a human (or the proposal the human approves) names ids explicitly, and
13
+ # the curator maintains the catalogue (add a row = PR or edit catalogue.tsv).
14
+ #
15
+ # Verdicts: RECOMMEND rows install; MAYBE rows install (an explicit choice, never
16
+ # pre-ticked at init); SKIP rows refuse — the curator's verdict is the contract.
17
+ #
18
+ # NO NETWORK. Skill and workflow installs copy from a VENDORED PAYLOAD the curator
19
+ # maintains under extras-catalogue/payload/<id>/ (payload/README.md is the layout
20
+ # contract); a row whose payload is absent is refused with the named fix, never
21
+ # downloaded. MCP installs are a printed mcp.json snippet plus — with
22
+ # --with-mcp-config — a merge into the target's .mcp.json that never overwrites an
23
+ # existing server entry (the ask-once rule .mcp.json has always had).
24
+ #
25
+ # Skill destinations follow the sync_platforms pattern: --platform <adapter-id> copies
26
+ # each vendored skill dir under that adapter's project skills root (read from
27
+ # adapters/<id>/adapter.tsv, column skills_path_project); with no --platform the skill
28
+ # lands in the repo-neutral .gob/extras/<skill-name>/ (standalone repos). Workflows copy
29
+ # the payload into .gob/playbooks/<id>/.
30
+ #
31
+ # Exit codes: 0 ok | 1 a refusal (named path + fix) | 2 bad input.
32
+ # No npm, no jq, no yq, no network. bash/awk/sed/grep only (python3 only for the
33
+ # .mcp.json merge, where a hand-rolled JSON edit would corrupt files it must not).
34
+
35
+ set -uo pipefail
36
+
37
+ SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
38
+ SRC=$(cd "$SELF_DIR/.." && pwd)
39
+ # shellcheck source=goblin-lib.sh
40
+ . "$SELF_DIR/goblin-lib.sh"
41
+
42
+ g_info() { printf '%s\n' "$*"; }
43
+
44
+ usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
45
+
46
+ # ---- the catalogue source (overridable for tests) ------------------------------
47
+ # The default is the shipped catalogue; GOB_EXTRAS_CATALOGUE points a test at a fixture.
48
+ # Same for the payload root: a test vendors a fixture payload and aims the installer at it.
49
+ CATALOGUE="${GOB_EXTRAS_CATALOGUE:-$SRC/extras-catalogue/catalogue.tsv}"
50
+ PAYLOAD="${GOB_EXTRAS_PAYLOAD:-$SRC/extras-catalogue/payload}"
51
+
52
+ die() { g_err "extras: $*"; exit "${2:-2}"; }
53
+
54
+ # catalogue_row <id> — the whole data row (tab-separated), empty when absent.
55
+ catalogue_row() {
56
+ awk -F'\t' -v id="$1" 'NR>1 && $1==id { print; exit }' "$CATALOGUE"
57
+ }
58
+
59
+ # catalogue_field <row> <n> — the nth tab field of a row.
60
+ catalogue_field() { printf '%s' "$1" | awk -F'\t' -v n="$2" '{ print $n }'; }
61
+
62
+ # catalogue_ids — every id, file order.
63
+ catalogue_ids() { awk -F'\t' 'NR>1 && $1!="" { print $1 }' "$CATALOGUE"; }
64
+
65
+ # category_order — the categories in catalogue file order of first appearance.
66
+ category_order() { awk -F'\t' 'NR>1 && !seen[$2]++ { print $2 }' "$CATALOGUE"; }
67
+
68
+ # ---- adapters (the platform enum is data, never literals) ----------------------
69
+ # MD-01: agent-family names are read from adapters/<id>/adapter.tsv at run time, never
70
+ # written here — the same discipline the sync engine's assembled platform list keeps.
71
+ adapter_ids() {
72
+ for t in "$SRC"/adapters/*/adapter.tsv; do
73
+ [ -f "$t" ] || continue
74
+ awk -F'\t' '!/^#/ && NF>1 && $1!="id" { print $1 }' "$t"
75
+ done
76
+ }
77
+
78
+ # adapter_skills_root <id> — the PROJECT skills root (the pattern with its
79
+ # `<name>/SKILL.md` tail stripped), one dot-dir per agent family. Read at run time;
80
+ # the family names live in the adapter tables, never here (the MD-01 discipline).
81
+ adapter_skills_root() {
82
+ local t="$SRC/adapters/$1/adapter.tsv" pat
83
+ [ -f "$t" ] || return 0
84
+ pat=$(awk -F'\t' '!/^#/ && NF>1 && $1!="id" { print $4 }' "$t")
85
+ [ -n "$pat" ] || return 0
86
+ printf '%s\n' "${pat%%<name>*}"
87
+ }
88
+
89
+ # ---- list ----------------------------------------------------------------------
90
+ cmd_list() {
91
+ local want="${1:-}"
92
+ [ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
93
+ if [ -n "$want" ]; then
94
+ # An unknown category is bad input, not an empty table: a typo must not read as
95
+ # "the catalogue has nothing here". Captured, not piped: under pipefail a `grep -q`
96
+ # match closes the pipe early and SIGPIPE-kills the producer (pipeline 141), which
97
+ # would read as "unknown category" for a category the catalogue carries.
98
+ CATS=$(category_order)
99
+ printf '%s\n' "$CATS" | grep -qx "$want" || die "unknown category '$want' (want one of: $(printf '%s' "$CATS" | tr '\n' ' '))" 2
100
+ fi
101
+ local total
102
+ total=$(catalogue_ids | wc -l | tr -d ' ')
103
+ g_info "gob extras — the curated catalogue ($total rows; RECOMMEND pre-ticks at init, MAYBE is visible but never pre-ticked, SKIP refuses install)"
104
+ g_info "install: gob extras install <id...> · one row: gob extras show <id>"
105
+ local cat
106
+ for cat in $(category_order); do
107
+ [ -z "$want" ] || [ "$cat" = "$want" ] || continue
108
+ printf '\n[%s]\n' "$cat"
109
+ printf ' %-34s %-8s %-7s %-24s %-10s %s\n' "id" "kind" "stars" "license" "verdict" "matches"
110
+ awk -F'\t' -v cat="$cat" 'NR>1 && $2==cat {
111
+ printf " %-34s %-8s %-7s %-24s %-10s %s\n", $1, $3, $9, $8, $15, $11
112
+ }' "$CATALOGUE"
113
+ done
114
+ return 0
115
+ }
116
+
117
+ # ---- show ----------------------------------------------------------------------
118
+ cmd_show() {
119
+ local id="${1:-}"
120
+ [ -n "$id" ] || die "show: an id is required (gob extras list)" 2
121
+ [ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
122
+ local row
123
+ row=$(catalogue_row "$id")
124
+ [ -n "$row" ] || die "unknown id '$id' — gob extras list names the catalogue" 2
125
+ local labels=(id category kind name source_repo path_within_repo has_skill_md license
126
+ stars last_push matches conflicts requires install_hint verdict
127
+ reviewed_by reviewed_date)
128
+ local i
129
+ for i in $(seq 1 17); do
130
+ printf '%-16s %s\n' "${labels[$((i-1))]}:" "$(catalogue_field "$row" "$i")"
131
+ done
132
+ return 0
133
+ }
134
+
135
+ # ---- install -------------------------------------------------------------------
136
+ TARGET="" PLATFORM="" WITH_MCP=0 DRYRUN=0 IDS=()
137
+ parse_install() {
138
+ while [ $# -gt 0 ]; do
139
+ case "$1" in
140
+ --target) TARGET="${2:-}"; shift 2 ;;
141
+ --platform) PLATFORM="${2:-}"; shift 2 ;;
142
+ --with-mcp-config) WITH_MCP=1; shift ;;
143
+ --dry-run) DRYRUN=1; shift ;;
144
+ --yes) shift ;; # accepted for pipe parity; install is already explicit
145
+ -h|--help) usage; exit 0 ;;
146
+ -*) die "install: unknown option: $1" 2 ;;
147
+ *) IDS+=("$1"); shift ;;
148
+ esac
149
+ done
150
+ [ "${#IDS[@]}" -gt 0 ] || die "install: at least one id is required (gob extras list)" 2
151
+ [ -f "$CATALOGUE" ] || die "catalogue not found: $CATALOGUE" 2
152
+ TARGET="${TARGET:-$PWD}"
153
+ TARGET=$(g_expand_tilde "$TARGET")
154
+ [ -d "$TARGET" ] || die "--target is not a directory: $TARGET" 2
155
+ TARGET=$(cd "$TARGET" && pwd)
156
+ if [ -n "$PLATFORM" ]; then
157
+ # Captured, not piped (see cmd_list: pipefail + grep -q closes the pipe early and
158
+ # SIGPIPE-kills the producer — a valid platform id read as unknown).
159
+ ADAPTERS=$(adapter_ids)
160
+ printf '%s\n' "$ADAPTERS" | grep -qx "$PLATFORM" \
161
+ || die "unknown --platform '$PLATFORM' (the adapters this checkout ships: $(printf '%s' "$ADAPTERS" | tr '\n' ' '))" 2
162
+ fi
163
+ }
164
+
165
+ # resolve_one <id> — validates against the ALLOWLIST and the curator's verdicts; prints
166
+ # the row. Every install path goes through here: no row, no install. That is the whole
167
+ # allowlist rule, in one gate.
168
+ resolve_one() {
169
+ local id="$1" row verdict
170
+ row=$(catalogue_row "$id")
171
+ # Captured, not piped (pipefail + grep -q early-exit trap — see cmd_list).
172
+ if [ -n "$row" ]; then
173
+ verdict=$(catalogue_field "$row" 15)
174
+ else
175
+ die "unknown id '$id' — nothing outside catalogue.tsv is ever installed (gob extras list names the allowlist)" 2
176
+ fi
177
+ case "$verdict" in
178
+ RECOMMEND|MAYBE) ;;
179
+ SKIP) die "id '$id' carries verdict SKIP — the curator declined it; it stays visible in the catalogue but refuses install" 1 ;;
180
+ *) die "id '$id' carries verdict '$verdict' — not an installable verdict (RECOMMEND|MAYBE|SKIP)" 2 ;;
181
+ esac
182
+ printf '%s\n' "$row"
183
+ }
184
+
185
+ # copy_tree <src> <dst> — byte-copy with the installer's ownership gate: an existing
186
+ # file is left alone when identical (unchanged) and REFUSED when it differs (a file the
187
+ # project wrote is never clobbered silently). Returns 0 ok, 1 on a refusal (named).
188
+ COPY_REFUSALS=0
189
+ copy_tree() {
190
+ local src="${1%/}" dst="$2" f rel sd dd
191
+ [ -d "$src" ] || die "payload missing: $src" 2
192
+ while IFS= read -r f; do
193
+ rel="${f#"$src"/}"
194
+ dd="$dst/$rel"
195
+ if [ "$DRYRUN" -eq 1 ]; then
196
+ g_info "plan: ${dd#"$TARGET"/}"
197
+ continue
198
+ fi
199
+ mkdir -p "$(dirname "$dd")" || die "cannot create $(dirname "$dd")" 1
200
+ if [ -f "$dd" ]; then
201
+ if [ "$(g_sha256_file "$dd")" = "$(g_sha256_file "$f")" ]; then
202
+ g_info "unchanged ${dd#"$TARGET"/}"
203
+ else
204
+ g_err "refusing to overwrite ${dd#"$TARGET"/}: it exists with different content and goblin-stack did not install it — remove or rename it first"
205
+ COPY_REFUSALS=$((COPY_REFUSALS + 1))
206
+ fi
207
+ continue
208
+ fi
209
+ cp "$f" "$dd" || die "cannot write $dd" 1
210
+ g_info "wrote ${dd#"$TARGET"/}"
211
+ done < <(find "$src" -type f | sort)
212
+ return 0
213
+ }
214
+
215
+ # install_skill <row> — copy the payload's skill dirs into the platform skills root
216
+ # (per sync_platforms) or .gob/extras/ (standalone).
217
+ install_skill() {
218
+ local row="$1" id name src_root dst_root d n=0
219
+ id=$(catalogue_field "$row" 1)
220
+ src_root="$PAYLOAD/$id"
221
+ [ -d "$src_root" ] \
222
+ || die "no vendored payload for '$id' at $src_root — extras install needs no network: vendor the accepted skill dirs there first (see extras-catalogue/payload/README.md)" 2
223
+ if [ -n "$PLATFORM" ]; then
224
+ dst_root="$TARGET/$(adapter_skills_root "$PLATFORM")"
225
+ dst_root="${dst_root%/}/"
226
+ else
227
+ dst_root="$TARGET/.gob/extras"
228
+ fi
229
+ [ -n "$(adapter_skills_root "$PLATFORM")" ] || [ -z "$PLATFORM" ] \
230
+ || die "adapter '$PLATFORM' carries no project skills path" 2
231
+ for d in "$src_root"/*/; do
232
+ [ -d "$d" ] || continue
233
+ name=$(basename "$d")
234
+ n=$((n + 1))
235
+ g_info "extras: skill $name -> ${dst_root#"$TARGET"/}$name/"
236
+ copy_tree "$d" "$dst_root/$name"
237
+ done
238
+ [ "$n" -gt 0 ] || die "payload for '$id' holds no skill dirs ($src_root/*/ with SKILL.md each) — see extras-catalogue/payload/README.md" 2
239
+ return 0
240
+ }
241
+
242
+ # install_workflow <row> — copy the payload into .gob/playbooks/<id>/.
243
+ install_workflow() {
244
+ local row="$1" id src dst
245
+ id=$(catalogue_field "$row" 1)
246
+ src="$PAYLOAD/$id"
247
+ [ -d "$src" ] \
248
+ || die "no vendored payload for '$id' at $src — vendor the playbook files there first (see extras-catalogue/payload/README.md)" 2
249
+ dst="$TARGET/.gob/playbooks/$id"
250
+ g_info "extras: workflow $id -> ${dst#"$TARGET"/}/"
251
+ copy_tree "$src" "$dst"
252
+ return 0
253
+ }
254
+
255
+ # install_mcp <row> — print the mcp.json snippet; --with-mcp-config merges it into
256
+ # the target's .mcp.json. The merge NEVER overwrites: an existing entry for the same
257
+ # server name must match the generated bytes (no-op) or the merge refuses (ask-once).
258
+ install_mcp() {
259
+ local row="$1" id hint cmd rest first_arg
260
+ id=$(catalogue_field "$row" 1)
261
+ hint=$(catalogue_field "$row" 14)
262
+ case "$hint" in
263
+ npx\ *|npm\ exec\ *)
264
+ read -r cmd rest <<< "$hint"
265
+ first_arg="$rest" ;;
266
+ *)
267
+ if [ "$WITH_MCP" -eq 1 ]; then
268
+ die "id '$id' carries install_hint '$hint' — only an 'npx <package>' hint can be merged into .mcp.json; follow the row's own hint by hand" 1
269
+ fi
270
+ g_info "extras: mcp $id — install per the row's own hint: $hint (no .mcp.json snippet is generated for it)"
271
+ return 0 ;;
272
+ esac
273
+ local snippet
274
+ snippet=$(python3 -c 'import json,sys; print(json.dumps({"mcpServers": {sys.argv[1]: {"command": sys.argv[2], "args": sys.argv[3].split()}}}, separators=(",",":")))' "$id" "$cmd" "$first_arg")
275
+ g_info "extras: mcp $id — mcp.json snippet:"
276
+ g_info " $snippet"
277
+ [ "$WITH_MCP" -eq 1 ] || return 0
278
+ [ "$DRYRUN" -eq 0 ] || { g_info "plan: merge $id into ${TARGET#.}/.mcp.json"; return 0; }
279
+ MERGE_OUT=$(MCP_ID="$id" MCP_SNIPPET="$snippet" python3 - "$TARGET/.mcp.json" <<'PYEOF'
280
+ import json, os, sys
281
+ path = sys.argv[1]
282
+ mcp_id = os.environ["MCP_ID"]
283
+ snippet = json.loads(os.environ["MCP_SNIPPET"])
284
+ server = snippet["mcpServers"][mcp_id]
285
+ data = {}
286
+ if os.path.exists(path):
287
+ raw = open(path).read().strip()
288
+ if raw:
289
+ try:
290
+ data = json.loads(raw)
291
+ except ValueError:
292
+ sys.stderr.write("extras: refusing %s: it is not valid JSON - edit it by hand\n" % path)
293
+ sys.exit(1)
294
+ servers = data.setdefault("mcpServers", {})
295
+ if mcp_id in servers:
296
+ if servers[mcp_id] == server:
297
+ print("extras: unchanged %s (already carries the %s registration)" % (path, mcp_id))
298
+ sys.exit(0)
299
+ sys.stderr.write("extras: refusing to overwrite the existing %s entry in %s - an mcp registration you customized is the project's (ask-once)\n" % (mcp_id, path))
300
+ sys.exit(1)
301
+ servers[mcp_id] = server
302
+ with open(path, "w") as fh:
303
+ json.dump(data, fh, separators=(",", ":"))
304
+ fh.write("\n")
305
+ print("extras: merged %s into %s" % (mcp_id, path))
306
+ PYEOF
307
+ )
308
+ local rc=$?
309
+ [ -n "$MERGE_OUT" ] && printf '%s\n' "$MERGE_OUT"
310
+ [ "$rc" -eq 0 ] || exit 1
311
+ return 0
312
+ }
313
+
314
+ cmd_install() {
315
+ parse_install "$@"
316
+ [ "$DRYRUN" -eq 0 ] || g_info "extras: --dry-run (nothing is written)"
317
+ local id row
318
+ for id in "${IDS[@]}"; do
319
+ row=$(resolve_one "$id") || exit $?
320
+ case "$(catalogue_field "$row" 3)" in
321
+ skill) install_skill "$row" ;;
322
+ workflow) install_workflow "$row" ;;
323
+ mcp) install_mcp "$row" ;;
324
+ *) die "row '$id' carries unknown kind '$(catalogue_field "$row" 3)' (want skill|mcp|workflow)" 2 ;;
325
+ esac
326
+ done
327
+ [ "$COPY_REFUSALS" -eq 0 ] || die "$COPY_REFUSALS file(s) refused (named above) — nothing overwritten" 1
328
+ return 0
329
+ }
330
+
331
+ # ---- dispatch ------------------------------------------------------------------
332
+ CMD="${1:-}"
333
+ [ -n "$CMD" ] || { usage; exit 2; }
334
+ shift
335
+ case "$CMD" in
336
+ list) cmd_list "$@" ;;
337
+ show) cmd_show "$@" ;;
338
+ install) cmd_install "$@" ;;
339
+ -h|--help|help) usage; exit 0 ;;
340
+ *) g_err "extras: unknown subcommand: $CMD"; usage >&2; exit 2 ;;
341
+ esac
342
+ exit 0