@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-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'
package/bin/goblin-map CHANGED
@@ -1,13 +1,36 @@
1
1
  #!/usr/bin/env bash
2
- # goblin-map — `gob map`: generate a STARTER feature map for this repo (standalone).
2
+ # goblin-map — `gob map`: the AI-DRIVEN feature map (v2 prompt engine) + the heuristic starter.
3
3
  #
4
- # gob map [target] [--force] [--target <dir>] [--help]
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
- # Standalone by design (product decision, 2026-10): works in ANY git repo with NO .goblin/
7
- # install and NEVER reads goblin.yaml. The verify rows FM-01/FM-02 stay opt-in through a
8
- # `feature_map:` declaration in .goblin/goblin.yaml (empty = SKIP — unchanged); this command
9
- # only writes the starter files the human pass then edits. See docs/GUIDE.md, the feature-map
10
- # section, and skills/goblin-feature-map/SKILL.md (the format contract this output follows).
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 — generate a starter feature map for this repo (standalone; no install needed).
75
+ gob map — the AI-driven feature map (prompt + schema), with a heuristic starter fallback.
53
76
 
54
- gob map [target] [--force]
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
- target the repo to scan (default: the current directory)
57
- --force when features/ already exists: regenerate ONLY the README index and add
58
- files for NEW slugs; existing feature files are never touched
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
- Detection: Next.js app router (app/**/page.tsx|jsx|route.ts), Next.js pages router
61
- (pages/**/*.tsx|jsx), Nuxt (pages/**/*.vue), route files (routes/ dirs, *route* files),
62
- or — with no framework — top-level src/ and lib/ module dirs as TODO placeholders.
63
- package.json dev/build/test/lint scripts seed the Baseline preconditions section.
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
- The output is a STARTER: each feature file needs a human pass before its verified: date
66
- means anything. It never reads or writes .goblin/, and FM-01/FM-02 stay opt-in through a
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 or nothing-to-do | 1 refusal (existing map, no --force) | 2 bad input.
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; }