@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.
- package/CHANGELOG.md +38 -0
- package/README.md +112 -123
- package/VERSION +1 -1
- package/automations/drift-audit.sh +4 -4
- package/bans/layer-check.sh +10 -8
- package/bin/goblin +61 -52
- package/bin/goblin-audit +11 -13
- package/bin/goblin-bans +11 -11
- package/bin/goblin-init +275 -713
- package/bin/goblin-install +160 -114
- package/bin/goblin-lib.sh +234 -1
- package/bin/goblin-map +607 -0
- package/bin/goblin-mcp.js +492 -0
- package/bin/goblin-model +4 -4
- package/bin/goblin-upgrade +1 -1
- package/bin/goblin-verify +159 -145
- package/bin/goblin.js +33 -49
- package/docs/ADOPTION.md +15 -15
- package/docs/CONTRACTS.md +16 -15
- package/docs/DESIGN.md +1 -1
- package/docs/ENFORCEMENT.md +89 -90
- package/docs/FLOWS.md +1 -1
- package/docs/GLOSSARY.md +3 -3
- package/docs/GUARDRAILS.md +5 -5
- package/docs/GUIDE.md +178 -169
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITS.md +25 -0
- package/docs/LOOP.md +12 -12
- package/docs/RE-PLAYBOOK.md +3 -3
- package/docs/ROLES.md +5 -5
- package/manifest/bans.tsv +8 -8
- package/manifest/classes.tsv +3 -3
- package/manifest/enforcement.tsv +40 -40
- package/manifest/glossary.tsv +3 -3
- package/manifest/playbooks.tsv +1 -1
- package/package.json +1 -1
- package/presets/electron-overlay.yaml +2 -2
- package/presets/fleet.yaml +8 -7
- package/presets/game.yaml +1 -1
- package/presets/research.yaml +1 -1
- package/presets/service.yaml +1 -1
- package/presets/software.yaml +1 -1
- package/skills/goblin-bootstrap/SKILL.md +2 -2
- package/templates/AGENTS.md.tmpl +8 -18
- package/templates/HANDOFF.md.tmpl +5 -5
- package/templates/agents-block.tmpl +45 -0
- package/templates/audit-waiver.tsv.tmpl +2 -2
- package/templates/boundary-waivers.tmpl +1 -1
- package/templates/checks/gate.sh.tmpl +6 -6
- package/templates/install-hooks.allowlist.tmpl +1 -1
- package/templates/ci/goblin-gate.yml.tmpl +0 -46
- package/templates/goblin.yaml.tmpl +0 -146
- package/templates/loop/decisions.tsv.tmpl +0 -1
- 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.
|
|
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'
|