@techgoblin/gobstack 0.5.0-beta.7 → 0.6.0-alpha.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 (54) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +112 -123
  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 +61 -52
  7. package/bin/goblin-audit +11 -13
  8. package/bin/goblin-bans +11 -11
  9. package/bin/goblin-init +275 -713
  10. package/bin/goblin-install +160 -114
  11. package/bin/goblin-lib.sh +234 -1
  12. package/bin/goblin-map +607 -0
  13. package/bin/goblin-mcp.js +492 -0
  14. package/bin/goblin-model +4 -4
  15. package/bin/goblin-upgrade +1 -1
  16. package/bin/goblin-verify +159 -145
  17. package/bin/goblin.js +33 -49
  18. package/docs/ADOPTION.md +15 -15
  19. package/docs/CONTRACTS.md +16 -15
  20. package/docs/DESIGN.md +1 -1
  21. package/docs/ENFORCEMENT.md +89 -90
  22. package/docs/FLOWS.md +1 -1
  23. package/docs/GLOSSARY.md +3 -3
  24. package/docs/GUARDRAILS.md +5 -5
  25. package/docs/GUIDE.md +178 -169
  26. package/docs/INTEGRATION.md +1 -1
  27. package/docs/LIMITS.md +25 -0
  28. package/docs/LOOP.md +12 -12
  29. package/docs/RE-PLAYBOOK.md +3 -3
  30. package/docs/ROLES.md +5 -5
  31. package/manifest/bans.tsv +8 -8
  32. package/manifest/classes.tsv +3 -3
  33. package/manifest/enforcement.tsv +40 -40
  34. package/manifest/glossary.tsv +3 -3
  35. package/manifest/playbooks.tsv +1 -1
  36. package/package.json +1 -1
  37. package/presets/electron-overlay.yaml +2 -2
  38. package/presets/fleet.yaml +8 -7
  39. package/presets/game.yaml +1 -1
  40. package/presets/research.yaml +1 -1
  41. package/presets/service.yaml +1 -1
  42. package/presets/software.yaml +1 -1
  43. package/skills/goblin-bootstrap/SKILL.md +2 -2
  44. package/templates/AGENTS.md.tmpl +8 -18
  45. package/templates/HANDOFF.md.tmpl +5 -5
  46. package/templates/agents-block.tmpl +45 -0
  47. package/templates/audit-waiver.tsv.tmpl +2 -2
  48. package/templates/boundary-waivers.tmpl +1 -1
  49. package/templates/checks/gate.sh.tmpl +6 -6
  50. package/templates/install-hooks.allowlist.tmpl +1 -1
  51. package/templates/ci/goblin-gate.yml.tmpl +0 -46
  52. package/templates/goblin.yaml.tmpl +0 -146
  53. package/templates/loop/decisions.tsv.tmpl +0 -1
  54. package/templates/loop/predicate.tmpl +0 -16
package/bin/goblin-lib.sh CHANGED
@@ -16,7 +16,7 @@
16
16
  # - name: typecheck four-space-indented second member of a list entry
17
17
  # cmd: npx tsc --noEmit
18
18
 
19
- GOBLIN_LIB_VERSION="0.5.0"
19
+ GOBLIN_LIB_VERSION="0.6.0-alpha.1"
20
20
 
21
21
  # ---------------------------------------------------------------- output -----
22
22
  # g_trunc <width> <text> — fold a long detail to one line at <width> columns, keeping the
@@ -265,6 +265,168 @@ g_part_disabled() {
265
265
  }
266
266
 
267
267
  # ------------------------------------------------------------- class data ----
268
+ # ------------------------------------------------------------- agents.md -----
269
+ # v2 config engine: the project config lives in AGENTS.md FRONTMATTER, delimited by
270
+ # fixed markers. There is no goblin.yaml. One file is the single source of truth:
271
+ #
272
+ # <!-- gob:begin (gobstack config) -->
273
+ # class: software
274
+ # branch: main
275
+ # ratchet.ceiling: 160 # one nested level = a dotted key
276
+ # bans: [BN-01, BN-02] # a list is a one-line JSON-ish array
277
+ # gate_commit_cmd: git rev-parse --verify --quiet HEAD
278
+ # <!-- gob:end -->
279
+ #
280
+ # The parser is the same flat, line-oriented reader the yaml subset used (no YAML
281
+ # library, no network, no npm) — it just reads ONLY the lines between the markers, so
282
+ # the rest of AGENTS.md (the prose the agent reads) is invisible to it. Rules:
283
+ # * a value is the rest of the line after `key:` — never quoted, never folded
284
+ # * one nested level: `block.key: value` (read with the dotted spelling)
285
+ # * a list: `key: [a, b, c]` on ONE line (g_agents_list splits it)
286
+ # * gates: one key per gate, `gate_<name>_cmd: <cmd>` (g_agents_gates)
287
+ # * comments on their own line, `#` first; blank lines allowed
288
+
289
+ # The literal markers (grep -F targets; the begin marker carries no closing paren so a
290
+ # future annotation after it cannot break the reader).
291
+ GOB_AGENTS_BEGIN='<!-- gob:begin'
292
+ GOB_AGENTS_END='<!-- gob:end -->'
293
+
294
+ # g_agents_block <file> — the config lines between the markers (empty if no block).
295
+ g_agents_block() {
296
+ sed -n "/^$GOB_AGENTS_BEGIN/,/^$GOB_AGENTS_END/p" "$1" 2>/dev/null | sed '1d;$d'
297
+ }
298
+
299
+ # g_agents_read <file> <key> — value of one key (dotted spelling for the nested level),
300
+ # empty if the block or the key is absent.
301
+ g_agents_read() {
302
+ g_agents_block "$1" | sed -n "s/^$2:[[:space:]]*//p" | head -n 1
303
+ }
304
+
305
+ # g_agents_keys <file> — every declared key, one per line, in file order.
306
+ g_agents_keys() {
307
+ g_agents_block "$1" | sed -n 's/^\([A-Za-z_][A-Za-z0-9_.-]*\):.*/\1/p'
308
+ }
309
+
310
+ # g_agents_gates <file> — one "name<TAB>cmd" line per DECLARED gate. A gate is the key
311
+ # `gate_<name>_cmd:`; the declaration and its command are one line, so a gate cannot
312
+ # lose its cmd and survive the count (the G8-3 failure mode has no shape here).
313
+ g_agents_gates() {
314
+ g_agents_block "$1" | sed -n 's/^gate_\([A-Za-z0-9_-]*\)_cmd:[[:space:]]*/\1\t/p'
315
+ }
316
+
317
+ # g_agents_gate_names <file> — one declared gate NAME per line.
318
+ g_agents_gate_names() {
319
+ g_agents_gates "$1" | cut -f1
320
+ }
321
+
322
+ # g_agents_pairs <file> — every block line as `key<TAB>value`, file order. The round-trip
323
+ # reader: `g_agents_pairs | g_agents_write` rewrites a block byte-identically, including
324
+ # values that contain tabs (a keys+read loop through g_agents_read mangles those — the
325
+ # sed in the reader stops at the first colon and the value is rebuilt, so a raw tab inside
326
+ # a gate command lost its place and the line drifted on every re-write). Use this for any
327
+ # read-modify-write of the block; g_agents_keys is for membership tests only.
328
+ # A VALUE-LESS line is stored "key:" (no trailing space — the writer strips it), so the
329
+ # ": "-split regex cannot fire: without the key fix below, k kept the trailing colon and
330
+ # the pair read back "key:<TAB>key:" - on the next rewrite that rendered "key:: key:"
331
+ # and every later read of the key returned its own name (IN-02/PF-01/FM-01 reds on a
332
+ # fresh install that declares no practice/feature_map/measured).
333
+ g_agents_pairs() {
334
+ # Split at the FIRST ": " (or a trailing bare ":" for a value-less line): index()
335
+ # drives the branch because the ": "-regex cannot fire on the stored "key:" form and
336
+ # a blind sub left k carrying its colon, so pairs read back "key:<TAB>key:".
337
+ g_agents_block "$1" | awk '{
338
+ p = index($0, ": ")
339
+ if (p > 0) { k = substr($0, 1, p - 1); v = substr($0, p + 2) }
340
+ else if ($0 ~ /:$/) { k = substr($0, 1, length($0) - 1); v = "" }
341
+ else { k = $0; v = "" }
342
+ printf "%s\t%s\n", k, v
343
+ }'
344
+ }
345
+
346
+ # g_agents_list <file> <key> — the items of a one-line `[a, b, c]` array, one per line.
347
+ # Empty output = the key is absent or the array is empty. An item keeps its inner text
348
+ # verbatim (trimmed); a comma inside an item cannot be expressed — split the key.
349
+ g_agents_list() {
350
+ local v
351
+ v=$(g_agents_read "$1" "$2")
352
+ case "$v" in
353
+ ""|"[]") return 0 ;;
354
+ \[*\]) ;;
355
+ *) return 0 ;; # a malformed array reads as absent, never as one garbage item
356
+ esac
357
+ v=${v#\[}; v=${v%\]}
358
+ printf '%s\n' "$v" | tr ',' '\n' \
359
+ | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' || true
360
+ }
361
+
362
+ # g_agents_disabled <file> — the disabled: array, one part per line.
363
+ g_agents_disabled() { g_agents_list "$1" disabled; }
364
+
365
+ # g_part_disabled <agents-file> <part>
366
+ g_agents_part_disabled() {
367
+ g_agents_disabled "$1" | grep -qx "$2"
368
+ }
369
+
370
+ # g_agents_write <file> — rewrite ONLY the marker block from `key<TAB>value` lines on
371
+ # stdin, preserving every line of the body. Idempotent: a second identical write leaves
372
+ # the file byte-identical (it prints "unchanged", not "written"). No block + a body:
373
+ # the block is inserted before the first line. No file: it is created.
374
+ g_agents_write() {
375
+ local f="$1" tmp newbody
376
+ [ -f "$f" ] || : > "$f"
377
+ tmp=$(mktemp "${TMPDIR:-/tmp}/gob-agents.XXXXXX") || return 1
378
+ {
379
+ printf '%s (gobstack config — edit in place; the parser reads only this block) -->\n' "$GOB_AGENTS_BEGIN"
380
+ # A marker line on stdin is a RENDERED TEMPLATE's own first line, not a key: skip
381
+ # it, so a caller may pipe a whole rendered block in. A line is EITHER "key<TAB>
382
+ # value" (the tsv form) OR already-rendered "key: value" (a template form): with a
383
+ # tab, field 1 is the key and the value is rebuilt; without one, the line's own
384
+ # "key: value" shape is kept verbatim (a rendered placeholder keeps its text).
385
+ # A RENDERED line whose VALUE contains a tab would otherwise split at the value's
386
+ # own tab and corrupt it on the next rewrite ("a<TAB>b" became "a: b"), so a line
387
+ # whose pre-tab part already carries ": " is a rendered line: kept verbatim. A tsv
388
+ # key is a bare identifier and never contains ": ".
389
+ awk -F'\t' '
390
+ /^<!-- gob:(begin|end)/ { next }
391
+ NF >= 2 {
392
+ if ($1 ~ /: /) { print; next }
393
+ v = $2; for (i = 3; i <= NF; i++) v = v "\t" $i
394
+ sub(/ -->$/, "", v) # a template end-marker glued to a value line
395
+ print $1 ": " v
396
+ next
397
+ }
398
+ NF == 1 && $1 != "" { print $1 }' \
399
+ | sed 's/: $/:/'
400
+ printf '%s\n' "$GOB_AGENTS_END"
401
+ } > "$tmp"
402
+ newbody=$(cat "$tmp")
403
+ # Replace the existing block, or insert the block before the first body line.
404
+ # All three values travel via ENVIRON, never -v: gawk (and mawk) process backslash
405
+ # escapes in -v assignment values (\b in a gate command became a backspace on every
406
+ # rewrite - through BOTH this splice and the render above it). ENVIRON passes the
407
+ # bytes raw with no escape processing on any awk.
408
+ if grep -q "^$GOB_AGENTS_BEGIN" "$f"; then
409
+ GOB_BLOCK="$newbody" GOB_BEGIN="$GOB_AGENTS_BEGIN" GOB_END="$GOB_AGENTS_END" \
410
+ awk '
411
+ BEGIN {
412
+ begin = "^" ENVIRON["GOB_BEGIN"]
413
+ end = "^" ENVIRON["GOB_END"]
414
+ repl = ENVIRON["GOB_BLOCK"]
415
+ }
416
+ $0 ~ begin { inb = 1; print repl; next }
417
+ $0 ~ end { inb = 0; next }
418
+ !inb { print }
419
+ ' "$f" > "$tmp.out" || { rm -f "$tmp" "$tmp.out"; return 1; }
420
+ else
421
+ { printf '%s\n' "$newbody"; cat "$f"; } > "$tmp.out"
422
+ fi
423
+ if cmp -s "$tmp.out" "$f"; then
424
+ rm -f "$tmp" "$tmp.out"; printf 'unchanged\n'; return 0
425
+ fi
426
+ cp "$tmp.out" "$f" && rm -f "$tmp" "$tmp.out" && { printf 'written\n'; return 0; }
427
+ rm -f "$tmp" "$tmp.out"; return 1
428
+ }
429
+
268
430
  # g_class_canon <spelling> -> the canonical class NAME
269
431
  # software | service | game | research | fleet
270
432
  # The taxonomy is five domain-named classes. The letters A-E and the older taught domain names
@@ -422,6 +584,77 @@ YAML
422
584
  got=$(g_yaml_disabled "$tmp/g.yaml" | tr '\n' ',')
423
585
  [ "$got" = "spec,tokens," ] || { g_err "disabled: got '$got'"; rc=1; }
424
586
 
587
+ # ---- the v2 AGENTS.md frontmatter engine (the same RED control, new file) ----
588
+ cat > "$tmp/AGENTS.md" <<'EOF'
589
+ # AGENTS.md
590
+
591
+ House rules the agent reads. The block below is machine-read.
592
+
593
+ <!-- gob:begin (gobstack config — edit in place; the parser reads only this block) -->
594
+ class: software
595
+ branch: main
596
+ archive: false
597
+ owner_email: team@example.com
598
+ disabled: [spec, tokens]
599
+ ratchet.name: hex
600
+ ratchet.ceiling: 160
601
+ gate_typecheck_cmd: npx tsc --noEmit
602
+ gate_commit_cmd: git rev-parse --verify --quiet HEAD
603
+ <!-- gob:end -->
604
+
605
+ Body prose continues here. A line like `class: decoy` outside the block must stay
606
+ invisible to the parser.
607
+ EOF
608
+ printf 'class: decoy\n' >> "$tmp/AGENTS.md"
609
+ got=$(g_agents_read "$tmp/AGENTS.md" class)
610
+ [ "$got" = "software" ] || { g_err "agents scalar: expected software, got '$got'"; rc=1; }
611
+ got=$(g_agents_read "$tmp/AGENTS.md" nosuchkey)
612
+ [ -z "$got" ] || { g_err "agents scalar: absent key should be empty, got '$got'"; rc=1; }
613
+ got=$(g_agents_read "$tmp/AGENTS.md" ratchet.ceiling)
614
+ [ "$got" = "160" ] || { g_err "agents dotted key: expected 160, got '$got'"; rc=1; }
615
+ got=$(g_agents_read "$tmp/AGENTS.md" ratchet.name)
616
+ [ "$got" = "hex" ] || { g_err "agents dotted key: expected hex, got '$got'"; rc=1; }
617
+ got=$(g_agents_gates "$tmp/AGENTS.md" | tr '\t' ':')
618
+ [ "$got" = "typecheck:npx tsc --noEmit
619
+ commit:git rev-parse --verify --quiet HEAD" ] \
620
+ || { g_err "agents gates: got '$got'"; rc=1; }
621
+ got=$(g_agents_gate_names "$tmp/AGENTS.md" | tr '\n' ',')
622
+ [ "$got" = "typecheck,commit," ] || { g_err "agents gate-names: got '$got'"; rc=1; }
623
+ got=$(g_agents_list "$tmp/AGENTS.md" disabled | tr '\n' ',')
624
+ [ "$got" = "spec,tokens," ] || { g_err "agents list: got '$got'"; rc=1; }
625
+ got=$(g_agents_keys "$tmp/AGENTS.md" | head -n 1)
626
+ [ "$got" = "class" ] || { g_err "agents keys: got '$got'"; rc=1; }
627
+ # g_agents_write: stdin IS the whole new block (key<TAB>value lines); it rewrites
628
+ # ONLY the block and preserves the body. Idempotent on a second identical write.
629
+ {
630
+ printf '%s\tsoftware\n' class
631
+ printf '%s\tmain\n' branch
632
+ printf '%s\t42\n' max_dirty
633
+ } | g_agents_write "$tmp/AGENTS.md" >/dev/null
634
+ grep -q '^max_dirty: 42$' "$tmp/AGENTS.md" || { g_err "agents write: the new key is absent"; rc=1; }
635
+ grep -qF 'Body prose continues here' "$tmp/AGENTS.md" \
636
+ || { g_err "agents write: the body was not preserved"; rc=1; }
637
+ grep -qF 'class: decoy' "$tmp/AGENTS.md" \
638
+ || { g_err "agents write: the body below the block was not preserved"; rc=1; }
639
+ W1=$(g_agents_read "$tmp/AGENTS.md" class)
640
+ [ "$W1" = "software" ] || { g_err "agents write: the block was destroyed ($W1)"; rc=1; }
641
+ {
642
+ printf '%s\tsoftware\n' class
643
+ printf '%s\tmain\n' branch
644
+ printf '%s\t42\n' max_dirty
645
+ } | g_agents_write "$tmp/AGENTS.md" > "$tmp/w2"
646
+ grep -q unchanged "$tmp/w2" || { g_err "agents write: the second identical write is not a no-op"; rc=1; }
647
+ # A fresh file: the block is created, and a body-less write stays parseable.
648
+ printf '%s\ttrue\n' "electron" | g_agents_write "$tmp/fresh.md" >/dev/null
649
+ [ "$(g_agents_read "$tmp/fresh.md" electron)" = "true" ] \
650
+ || { g_err "agents write: a fresh file was not created parseable"; rc=1; }
651
+ # A body-only file: the block is inserted before the first line.
652
+ printf 'the body\n' > "$tmp/bodyonly.md"
653
+ printf '%s\tmain\n' "branch" | g_agents_write "$tmp/bodyonly.md" >/dev/null
654
+ [ "$(g_agents_read "$tmp/bodyonly.md" branch)" = "main" ] \
655
+ && grep -qx 'the body' "$tmp/bodyonly.md" \
656
+ || { g_err "agents write: insertion into a body-only file failed"; rc=1; }
657
+
425
658
  rm -rf "$tmp"
426
659
  if [ "$rc" -eq 0 ]; then
427
660
  printf 'OK\n'