@techgoblin/gobstack 0.5.0-beta.8 → 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 -124
- package/VERSION +1 -1
- package/automations/drift-audit.sh +4 -4
- package/bans/layer-check.sh +10 -8
- package/bin/goblin +61 -58
- 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 +226 -21
- 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 -51
- 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 +167 -177
- 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'
|
package/bin/goblin-map
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# goblin-map — `gob map`:
|
|
2
|
+
# goblin-map — `gob map`: the AI-DRIVEN feature map (v2 prompt engine) + the heuristic starter.
|
|
3
3
|
#
|
|
4
|
-
# gob map
|
|
4
|
+
# gob map print the AGENT BRIEF + feature-file schema
|
|
5
|
+
# gob map --heuristic [target] [--force] run the starter scanner (the fallback)
|
|
6
|
+
# gob map --write <dir> [--force] validate the agent-written map and install it
|
|
7
|
+
# gob map [target] --force shorthand: heuristic --force (kept for scripts)
|
|
5
8
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# v2 SURFACE (AI-driven development): a bare `gob map` prints:
|
|
10
|
+
# 1. the AGENT BRIEF — a structured prompt telling whichever agent is already running in
|
|
11
|
+
# this repo exactly what to inventory (routes, entry points, user-visible features)
|
|
12
|
+
# and how to justify each entry;
|
|
13
|
+
# 2. the SCHEMA of the feature files it must write under a directory it chooses (for
|
|
14
|
+
# example features/) — the same frontmatter + four-H2 contract the heuristic starter
|
|
15
|
+
# renders and FM-01/FM-02 verify.
|
|
16
|
+
# The agent writes the files with its own tools, then (or a human does) runs
|
|
17
|
+
# `gob map --write <dir>` to validate: every file carries the frontmatter block, >=1
|
|
18
|
+
# entry_paths, the four H2s in order, and a `verified:` line that is either a date or the
|
|
19
|
+
# never-driven form. Exit 0 validated | 2 bad input (the named file + the fix).
|
|
20
|
+
#
|
|
21
|
+
# NEVER-CLOBBER (both modes, mirror of the heuristic contract):
|
|
22
|
+
# --write into a dir that is already a map WITHOUT --force -> refusal, exit 1, nothing
|
|
23
|
+
# written, the path and the remedy named;
|
|
24
|
+
# --write --force -> regenerate ONLY the README index (indexing every existing feature
|
|
25
|
+
# file too) and ADD files for new slugs; hand-written feature files are never touched.
|
|
26
|
+
#
|
|
27
|
+
# The heuristic detector (package.json scripts + route tokens) is the --heuristic
|
|
28
|
+
# FALLBACK: a starter skeleton, never the answer. Its never-clobber rules are unchanged.
|
|
29
|
+
#
|
|
30
|
+
# Standalone by design: works in ANY git repo with NO .gob/ install and NEVER reads the
|
|
31
|
+
# AGENTS.md gob block. The verify rows FM-01/FM-02 stay opt-in through a `feature_map:`
|
|
32
|
+
# declaration in AGENTS.md frontmatter (empty = SKIP — unchanged). See docs/GUIDE.md, the
|
|
33
|
+
# feature-map section, and skills/goblin-feature-map/SKILL.md (the format contract).
|
|
11
34
|
#
|
|
12
35
|
# What it scans (best-effort, honest about being a STARTER):
|
|
13
36
|
# next-app app/**/page.tsx|page.jsx|route.ts -> one feature per top-level route segment
|
|
@@ -49,32 +72,108 @@ g_err() { printf 'error: map: %s\n' "$*" >&2; }
|
|
|
49
72
|
|
|
50
73
|
usage() {
|
|
51
74
|
cat <<'USAGE'
|
|
52
|
-
gob map —
|
|
75
|
+
gob map — the AI-driven feature map (prompt + schema), with a heuristic starter fallback.
|
|
53
76
|
|
|
54
|
-
gob map
|
|
77
|
+
gob map print the AGENT BRIEF + the feature-file schema
|
|
78
|
+
gob map --write <dir> [--force] validate the agent-written map in <dir>
|
|
79
|
+
gob map --heuristic [target] [--force]
|
|
80
|
+
run the starter scanner (the fallback)
|
|
81
|
+
gob map [target] --force shorthand for the heuristic --force path
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
The brief tells the agent already running in this repo what to inventory (routes, entry
|
|
84
|
+
points, user-visible features) and the schema its feature files must follow (frontmatter,
|
|
85
|
+
>=1 entry_paths, the four H2 sections, verified: date-or-never-driven). The agent writes
|
|
86
|
+
the files with its own tools; `gob map --write <dir>` validates them.
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
NEVER-CLOBBER: --write (or the heuristic path) into a dir that is already a map without
|
|
89
|
+
--force is a refusal — the path and the remedy are named, nothing is written. --force
|
|
90
|
+
regenerates ONLY the README index and adds NEW files; existing feature files are never
|
|
91
|
+
touched.
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
feature_map: declaration in goblin.yaml.
|
|
93
|
+
Standalone: no .gob/ install needed, none created; FM-01/FM-02 stay opt-in through a
|
|
94
|
+
feature_map: declaration in the AGENTS.md gob block.
|
|
68
95
|
|
|
69
|
-
Exit codes: 0 generated
|
|
96
|
+
Exit codes: 0 generated/validated/nothing-to-do | 1 refusal (existing map, no --force) |
|
|
97
|
+
2 bad input.
|
|
70
98
|
USAGE
|
|
71
99
|
}
|
|
72
100
|
|
|
101
|
+
# ------------------------------------------------------------- the brief ------
|
|
102
|
+
# `gob map` with no --write and no --heuristic: print the prompt + schema.
|
|
103
|
+
print_brief() {
|
|
104
|
+
cat <<'BRIEF'
|
|
105
|
+
|
|
106
|
+
== gob map — AGENT BRIEF =======================================================
|
|
107
|
+
|
|
108
|
+
You are the agent running inside this repository. gobstack is an AI-driven-development
|
|
109
|
+
harness: the CLI does not scan this repo — YOU do, with your own tools, and you write the
|
|
110
|
+
feature map directly. Do this now:
|
|
111
|
+
|
|
112
|
+
1. INVENTORY the repository:
|
|
113
|
+
- routes and entry points (app/ or pages/ trees, routes/ dirs, *route* files)
|
|
114
|
+
- the user-visible features behind them (what a user can DO, not what a file contains)
|
|
115
|
+
- the test/harness layout, so each feature names how it is driven
|
|
116
|
+
|
|
117
|
+
2. WRITE the map under a directory you choose (the convention is features/):
|
|
118
|
+
- features/README.md — the index: baseline preconditions (launch/seed/health-check
|
|
119
|
+
commands), driving conventions, proof and skip reporting, one line per feature
|
|
120
|
+
- one features/<slug>.md per feature (slug: lowercase [a-z0-9-]), exactly in the
|
|
121
|
+
SCHEMA below
|
|
122
|
+
|
|
123
|
+
3. VALIDATE what you wrote:
|
|
124
|
+
|
|
125
|
+
npx @techgoblin/gobstack map --write features/
|
|
126
|
+
|
|
127
|
+
Notes:
|
|
128
|
+
- `verified:` is NOT a drive claim: write `never-driven (generated <date>)` until a
|
|
129
|
+
human pass has actually driven the feature once.
|
|
130
|
+
- entry_paths tokens must be repo-relative paths that EXIST, and should occur in
|
|
131
|
+
source text (a route literal, an import) so FM-02 can grep for them.
|
|
132
|
+
- An existing map is never overwritten without --force (the index only); if a map
|
|
133
|
+
already exists, hand-pass the files instead of regenerating them.
|
|
134
|
+
|
|
135
|
+
== FEATURE-FILE SCHEMA =========================================================
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
feature: <slug — lowercase [a-z0-9-], equals the filename stem>
|
|
139
|
+
entry_paths:
|
|
140
|
+
- <repo-relative path that exists, one or more>
|
|
141
|
+
verified: never-driven (generated <today>)
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
# <slug>
|
|
145
|
+
|
|
146
|
+
<one paragraph of user-visible behaviour, no implementation detail>
|
|
147
|
+
|
|
148
|
+
## Sub-features
|
|
149
|
+
|
|
150
|
+
- <the sub-behaviours, one `<id>` per line>
|
|
151
|
+
|
|
152
|
+
## How to get to it (user POV)
|
|
153
|
+
|
|
154
|
+
- <how a user reaches this feature, and what they should see>
|
|
155
|
+
|
|
156
|
+
## Driving it with <harness>
|
|
157
|
+
|
|
158
|
+
Preconditions: <what must be true first>.
|
|
159
|
+
**<action>.** Run `<exact command>`. <the observable result.>
|
|
160
|
+
|
|
161
|
+
## Gotchas
|
|
162
|
+
|
|
163
|
+
- <what commonly breaks, or what the entry-path token does and does not prove>
|
|
164
|
+
============================================================================== =
|
|
165
|
+
BRIEF
|
|
166
|
+
}
|
|
167
|
+
|
|
73
168
|
# ----------------------------------------------------------------- input ----
|
|
74
|
-
TARGET="" FORCE=0
|
|
169
|
+
TARGET="" FORCE=0 AGENT=0 HEURISTIC=0 WRITE_DIR=""
|
|
75
170
|
while [ $# -gt 0 ]; do
|
|
76
171
|
case "$1" in
|
|
77
172
|
--force) FORCE=1; shift ;;
|
|
173
|
+
--agent) AGENT=1; shift ;;
|
|
174
|
+
--heuristic) HEURISTIC=1; shift ;;
|
|
175
|
+
--write) [ -n "$WRITE_DIR" ] && { g_err "write-dir given twice"; exit 2; }
|
|
176
|
+
WRITE_DIR="${2:-}"; shift 2 ;;
|
|
78
177
|
--target) [ -z "$TARGET" ] || { g_err "target given twice"; exit 2; }
|
|
79
178
|
TARGET="${2:-}"; shift 2 ;;
|
|
80
179
|
-h|--help) usage; exit 0 ;;
|
|
@@ -84,6 +183,112 @@ while [ $# -gt 0 ]; do
|
|
|
84
183
|
else g_err "unexpected argument: $1"; exit 2; fi ;;
|
|
85
184
|
esac
|
|
86
185
|
done
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------ mode split ------
|
|
188
|
+
# Bare invocation (no --write, no --heuristic): the agent brief. This IS the product.
|
|
189
|
+
# `--agent` is the explicit spelling of the same thing; a --write suppresses it.
|
|
190
|
+
if [ "$HEURISTIC" -eq 0 ] && [ -z "$WRITE_DIR" ]; then
|
|
191
|
+
print_brief
|
|
192
|
+
printf '%s\n' "== end of brief — the agent writes the map; gob map --write <dir> validates it =="
|
|
193
|
+
exit 0
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# --write <dir>: validate the agent-written map. Needs --force semantics from the
|
|
197
|
+
# never-clobber contract; --heuristic and --write are separate modes, never both.
|
|
198
|
+
if [ -n "$WRITE_DIR" ]; then
|
|
199
|
+
[ "$HEURISTIC" -eq 0 ] || { g_err "--write and --heuristic are separate modes"; exit 2; }
|
|
200
|
+
[ -d "$WRITE_DIR" ] || { g_err "--write dir does not exist (the agent creates it): $WRITE_DIR"; exit 2; }
|
|
201
|
+
WDIR=$(cd "$WRITE_DIR" && pwd) || { g_err "cannot enter: $WRITE_DIR"; exit 2; }
|
|
202
|
+
# A map, for --write purposes, is the README index: --write never writes feature
|
|
203
|
+
# files (they are its INPUT), so the only thing it could clobber is the index.
|
|
204
|
+
HAVE_WMAP=0
|
|
205
|
+
if [ -f "$WDIR/README.md" ]; then HAVE_WMAP=1; fi
|
|
206
|
+
if [ "$HAVE_WMAP" -eq 1 ] && [ "$FORCE" -ne 1 ]; then
|
|
207
|
+
g_err "refusing to overwrite an existing feature map: $WDIR"
|
|
208
|
+
printf 'error: map: pass --force to regenerate the README index and add NEW files only; existing files are never rewritten\n' >&2
|
|
209
|
+
exit 1
|
|
210
|
+
fi
|
|
211
|
+
# Validation: the frontmatter + H2 + verified contract, per file, fail closed.
|
|
212
|
+
BAD=0; N=0
|
|
213
|
+
for f in "$WDIR"/*.md; do
|
|
214
|
+
[ -f "$f" ] || continue
|
|
215
|
+
case "$(basename "$f")" in README.md) continue ;; esac
|
|
216
|
+
N=$((N + 1))
|
|
217
|
+
slug=$(basename "$f"); slug=${slug%.md}
|
|
218
|
+
# frontmatter block present
|
|
219
|
+
sed -n '1,2p' "$f" | grep -q '^---[[:space:]]*$' \
|
|
220
|
+
|| { g_err "$f: no frontmatter block (must start: ---)"; BAD=1; continue; }
|
|
221
|
+
fm=$(awk 'BEGIN{fm=0} /^---[[:space:]]*$/ {fm++; if (fm==1) next; else exit} fm==1 {print}' "$f")
|
|
222
|
+
printf '%s\n' "$fm" | grep -q "^feature:[[:space:]]*$slug[[:space:]]*$" \
|
|
223
|
+
|| { g_err "$f: feature: must equal the filename stem ($slug)"; BAD=1; }
|
|
224
|
+
printf '%s\n' "$fm" | awk '/^entry_paths:/{f=1;next} f&&/^ - /{found=1} END{exit !found}' \
|
|
225
|
+
|| { g_err "$f: entry_paths: carries no item (>=1 existing repo-relative path)"; BAD=1; }
|
|
226
|
+
# every entry path exists under the CWD (repo-relative) and is a single token
|
|
227
|
+
while IFS= read -r tok; do
|
|
228
|
+
[ -n "$tok" ] || continue
|
|
229
|
+
case "$tok" in *" "*) g_err "$f: entry path with whitespace: $tok"; BAD=1; continue ;; esac
|
|
230
|
+
[ -e "$tok" ] || { g_err "$f: entry path does not exist: $tok"; BAD=1; }
|
|
231
|
+
done < <(printf '%s\n' "$fm" | awk '/^entry_paths:/{f=1;next} f&&/^ - /{sub(/^ - /,"");print;next} f&&!/^[[:space:]]/{f=0}')
|
|
232
|
+
v=$(printf '%s\n' "$fm" | sed -n 's/^verified:[[:space:]]*//p')
|
|
233
|
+
case "$v" in
|
|
234
|
+
""|"")
|
|
235
|
+
g_err "$f: no verified: line"; BAD=1 ;;
|
|
236
|
+
"never-driven"*)
|
|
237
|
+
: ;;
|
|
238
|
+
*) # a date, or anything else is refused — the line is either a date or never-driven
|
|
239
|
+
case "$v" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;; *)
|
|
240
|
+
g_err "$f: verified: must be a date or the never-driven form (got: $v)"; BAD=1 ;;
|
|
241
|
+
esac ;;
|
|
242
|
+
esac
|
|
243
|
+
# the four H2s, in order
|
|
244
|
+
hn=0; hok=1
|
|
245
|
+
while IFS= read -r line; do
|
|
246
|
+
hn=$((hn + 1))
|
|
247
|
+
case "$hn" in
|
|
248
|
+
1) [ "$line" = "Sub-features" ] || hok=0 ;;
|
|
249
|
+
2) [ "$line" = "How to get to it (user POV)" ] || hok=0 ;;
|
|
250
|
+
3) case "$line" in "Driving it with "*) ;; *) hok=0 ;; esac ;;
|
|
251
|
+
4) [ "$line" = "Gotchas" ] || hok=0 ;;
|
|
252
|
+
*) hok=0 ;;
|
|
253
|
+
esac
|
|
254
|
+
done < <(grep -n '^## ' "$f" | sed 's/^[0-9]*:## //; s/[[:space:]]*$//')
|
|
255
|
+
[ "$hn" -eq 4 ] && [ "$hok" -eq 1 ] || { g_err "$f: the four H2 sections are missing or out of order (Sub-features / How to get to it (user POV) / Driving it with <harness> / Gotchas)"; BAD=1; }
|
|
256
|
+
done
|
|
257
|
+
[ "$N" -ge 1 ] || { g_err "$WDIR carries no feature file (only README.md) — the map is the features"; exit 2; }
|
|
258
|
+
[ "$BAD" -eq 0 ] || { g_err "validation failed — fix the named file(s) and re-run"; exit 2; }
|
|
259
|
+
# --write --force: regenerate ONLY the README index (every existing feature file is
|
|
260
|
+
# listed; the files themselves are never touched). Without --force, the index is
|
|
261
|
+
# untouched.
|
|
262
|
+
if [ "$FORCE" -eq 1 ]; then
|
|
263
|
+
SLUGS_TSV="$WDIR/.wmap.tsv"; : > "$SLUGS_TSV"
|
|
264
|
+
for f in "$WDIR"/*.md; do
|
|
265
|
+
[ -f "$f" ] || continue
|
|
266
|
+
slug=$(basename "$f"); slug=${slug%.md}
|
|
267
|
+
case "$slug" in README.md) continue ;; esac
|
|
268
|
+
printf '%s\t%s\n' "$slug" "$(printf '%s' "${f#"$WDIR"/}" | sed 's#^#./#')" >> "$SLUGS_TSV"
|
|
269
|
+
done
|
|
270
|
+
TODAY=$(date +%F)
|
|
271
|
+
{
|
|
272
|
+
printf '%s\n' '# Features'
|
|
273
|
+
printf '%s\n' ''
|
|
274
|
+
printf '%s\n' "regenerated by gob map --write ${TODAY} — the index only; every feature file was written by hand (or by an agent) and is never touched by --write."
|
|
275
|
+
printf '%s\n' ''
|
|
276
|
+
printf '%s\n' '## Features'
|
|
277
|
+
printf '%s\n' ''
|
|
278
|
+
while IFS= read -r row; do
|
|
279
|
+
[ -n "$row" ] || continue
|
|
280
|
+
slug=${row%% *}
|
|
281
|
+
printf '%s\n' "- [${slug}](./${slug}.md) — entry listed in the file's frontmatter"
|
|
282
|
+
done < "$SLUGS_TSV"
|
|
283
|
+
} > "$WDIR/README.md"
|
|
284
|
+
rm -f "$SLUGS_TSV"
|
|
285
|
+
printf 'map --write: validated %d feature file(s) in %s and regenerated the README index (feature files untouched; declare feature_map: in AGENTS.md to opt FM-01/FM-02 in)\n' "$N" "$WDIR"
|
|
286
|
+
else
|
|
287
|
+
printf 'map --write: validated %d feature file(s) in %s (index untouched; declare feature_map: in AGENTS.md to opt FM-01/FM-02 in)\n' "$N" "$WDIR"
|
|
288
|
+
fi
|
|
289
|
+
exit 0
|
|
290
|
+
fi
|
|
291
|
+
|
|
87
292
|
[ -n "$TARGET" ] || TARGET="$PWD"
|
|
88
293
|
[ -d "$TARGET" ] || { g_err "not a directory: $TARGET"; exit 2; }
|
|
89
294
|
T=$(cd "$TARGET" && pwd) || { g_err "cannot enter: $TARGET"; exit 2; }
|