@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-map ADDED
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-map — `gob map`: the AI-DRIVEN feature map (v2 prompt engine) + the heuristic starter.
3
+ #
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)
8
+ #
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).
34
+ #
35
+ # What it scans (best-effort, honest about being a STARTER):
36
+ # next-app app/**/page.tsx|page.jsx|route.ts -> one feature per top-level route segment
37
+ # pages-router pages/**/*.tsx|jsx -> same grouping
38
+ # nuxt pages/**/*.vue -> same grouping
39
+ # routes directories named routes/ or *route* files -> one feature per file
40
+ # modules no framework: top-level src/ or lib/ module dirs -> candidate slugs, TODOs
41
+ # The first detector that fires wins; hybrids keep only the first hit. Deliberately ignored:
42
+ # node_modules, .git, dist/build output, test and fixture directories, files whose names carry
43
+ # whitespace. A scan that finds nothing still writes the README index, with the Features
44
+ # section saying so — an honest hole, not an invented feature.
45
+ #
46
+ # Never-clobber contract:
47
+ # features/ absent (or empty of .md) -> generate README + one file per slug, exit 0
48
+ # features/ already a map, no --force -> write NOTHING, name the path and --force, exit 1
49
+ # --force -> regenerate ONLY README.md (indexing every existing
50
+ # feature file too) and add files for NEW slugs;
51
+ # existing feature files are never touched, exit 0
52
+ # --force, nothing would change -> write nothing, say so, exit 0 (nothing-to-do)
53
+ #
54
+ # Exit codes: 0 generated or nothing-to-do | 1 refusal (existing map without --force) |
55
+ # 2 bad input.
56
+ #
57
+ # Frontmatter: `verified:` is NOT a drive claim — the generation run drives the generator,
58
+ # not the features — so it reads `never-driven (generated <date>)` until a human pass replaces
59
+ # it with a date. FM-01 does not read the verified line at all; FM-02, once a map is declared,
60
+ # treats any non-date as never-stale (string compare), so a starter cannot fail freshness it
61
+ # never earned — the dishonesty guard is the wording, and the per-file body repeats it.
62
+ #
63
+ # Entry paths are repo-relative paths that EXIST under the target (checked at generation
64
+ # time). Caveat, stated in every generated file: FM-02 greps file CONTENTS for a token, so
65
+ # after the human pass prefer a token that occurs in source text.
66
+ #
67
+ # No npm, no jq, no network. bash/awk/sed/grep/find only (the repo constraint, mirrored).
68
+
69
+ set -uo pipefail
70
+
71
+ g_err() { printf 'error: map: %s\n' "$*" >&2; }
72
+
73
+ usage() {
74
+ cat <<'USAGE'
75
+ gob map — the AI-driven feature map (prompt + schema), with a heuristic starter fallback.
76
+
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
82
+
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.
87
+
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.
92
+
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.
95
+
96
+ Exit codes: 0 generated/validated/nothing-to-do | 1 refusal (existing map, no --force) |
97
+ 2 bad input.
98
+ USAGE
99
+ }
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
+
168
+ # ----------------------------------------------------------------- input ----
169
+ TARGET="" FORCE=0 AGENT=0 HEURISTIC=0 WRITE_DIR=""
170
+ while [ $# -gt 0 ]; do
171
+ case "$1" in
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 ;;
177
+ --target) [ -z "$TARGET" ] || { g_err "target given twice"; exit 2; }
178
+ TARGET="${2:-}"; shift 2 ;;
179
+ -h|--help) usage; exit 0 ;;
180
+ --) shift; break ;;
181
+ -*) g_err "unknown option: $1"; usage >&2; exit 2 ;;
182
+ *) if [ -z "$TARGET" ]; then TARGET="$1"; shift
183
+ else g_err "unexpected argument: $1"; exit 2; fi ;;
184
+ esac
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
+
292
+ [ -n "$TARGET" ] || TARGET="$PWD"
293
+ [ -d "$TARGET" ] || { g_err "not a directory: $TARGET"; exit 2; }
294
+ T=$(cd "$TARGET" && pwd) || { g_err "cannot enter: $TARGET"; exit 2; }
295
+ FEAT="$T/features"
296
+ README_F="$FEAT/README.md"
297
+ TODAY=$(date +%F)
298
+
299
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/goblin-map.XXXXXX") || { g_err "mktemp failed"; exit 2; }
300
+ trap 'rm -rf "$WORK"' EXIT
301
+ SLUGS_TSV="$WORK/slugs.tsv"; : > "$SLUGS_TSV"
302
+
303
+ # ---------------------------------------------------------------- helpers ----
304
+ # slug form: lowercase, [a-z0-9-], no leading/trailing dash. Empty output = unusable.
305
+ sanitize_slug() {
306
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
307
+ | sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-*//' -e 's/-*$//'
308
+ }
309
+
310
+ scan_add() { # <slug> <repo-relative token> — single-token, deduped
311
+ case "$2" in
312
+ *" "*|""|"${2}"[[:space:]]*) return ;;
313
+ esac
314
+ case "$1" in ""|"-") return ;; esac
315
+ grep -qF "$(printf '%s\t%s' "$1" "$2")" "$SLUGS_TSV" \
316
+ || printf '%s\t%s\n' "$1" "$2" >> "$SLUGS_TSV"
317
+ }
318
+
319
+ # one feature per TOP-LEVEL route segment under <rootdir>/; the segment is the first path
320
+ # component (a Next route group `(marketing)` sanitizes to `marketing`); files sitting
321
+ # directly in <rootdir>/ are the root route and group under `home`.
322
+ group_slug_for() { # <relative path under the scan root> <scan root name>
323
+ local rest="$1" root="$2" seg
324
+ rest=${rest#"$root"/}
325
+ case "$rest" in
326
+ */*) seg=${rest%%/*} ;;
327
+ *) seg=home ;;
328
+ esac
329
+ sanitize_slug "$seg"
330
+ }
331
+
332
+ prune_find=(\( -name node_modules -o -name .git -o -name dist -o -name build -o -name .next -o -name .nuxt \) -prune -o)
333
+
334
+ # --------------------------------------------------------------- detectors ----
335
+ MODE=""
336
+ SLUG_CAP=40
337
+
338
+ if [ -d "$T/app" ]; then
339
+ while IFS= read -r f; do
340
+ [ -n "$f" ] || continue
341
+ rel=${f#"$T"/}
342
+ slug=$(group_slug_for "$rel" "app")
343
+ scan_add "$slug" "$rel"
344
+ done < <(find "$T/app" -type f \( -name 'page.tsx' -o -name 'page.jsx' -o -name 'route.ts' \) 2>/dev/null)
345
+ grep -q . "$SLUGS_TSV" && MODE=next-app
346
+ fi
347
+
348
+ if [ -z "$MODE" ] && [ -d "$T/pages" ]; then
349
+ # .vue under pages/ is Nuxt; .tsx/.jsx is the Next pages router. Same grouping either way.
350
+ while IFS= read -r f; do
351
+ [ -n "$f" ] || continue
352
+ rel=${f#"$T"/}
353
+ rest=${rel#pages/}
354
+ case "$rest" in
355
+ */*) slug=$(sanitize_slug "${rest%%/*}") ;;
356
+ *) slug=$(sanitize_slug "${rest%.*}") ;;
357
+ esac
358
+ scan_add "$slug" "$rel"
359
+ done < <(find "$T/pages" -type f \( -name '*.tsx' -o -name '*.jsx' -o -name '*.vue' \) 2>/dev/null)
360
+ grep -q . "$SLUGS_TSV" && MODE=pages-router
361
+ fi
362
+
363
+ if [ -z "$MODE" ]; then
364
+ # React Router and friends: any routes/ directory (or *route* file) within reach. Capped,
365
+ # because a repo with generated route tables would otherwise grow one slug per row.
366
+ while IFS= read -r d; do
367
+ [ -n "$d" ] || continue
368
+ while IFS= read -r f; do
369
+ [ -n "$f" ] || continue
370
+ rel=${f#"$T"/}
371
+ b=$(basename "$f"); stem=${b%.*}
372
+ stem=$(printf '%s' "$stem" | sed -e 's/[-.]route$//' -e 's/^route[-.]//')
373
+ case "$stem" in
374
+ index|routes) stem=$(basename "$d") ;;
375
+ esac
376
+ slug=$(sanitize_slug "$stem")
377
+ scan_add "$slug" "$rel"
378
+ done < <(find "$d" -maxdepth 2 -type f \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' -o -name '*.vue' \) 2>/dev/null)
379
+ done < <(find "$T" "${prune_find[@]}" -type d -name routes -print 2>/dev/null | head -n 8)
380
+ if [ -z "$MODE" ]; then
381
+ while IFS= read -r f; do
382
+ [ -n "$f" ] || continue
383
+ rel=${f#"$T"/}
384
+ b=$(basename "$f"); stem=${b%.*}
385
+ stem=$(printf '%s' "$stem" | sed -e 's/[-.]route$//' -e 's/^route[-.]//' -e 's/^routes$//')
386
+ slug=$(sanitize_slug "$stem")
387
+ scan_add "$slug" "$rel"
388
+ done < <(find "$T" "${prune_find[@]}" -type f -name '*route*' \
389
+ \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' \) -print 2>/dev/null | head -n 40)
390
+ fi
391
+ grep -q . "$SLUGS_TSV" && MODE=routes
392
+ fi
393
+
394
+ if [ -z "$MODE" ]; then
395
+ # Fallback: top-level src/ or lib/ module dirs become candidate slugs with TODO bodies.
396
+ for base in src lib; do
397
+ [ -d "$T/$base" ] || continue
398
+ for d in "$T/$base"/*/; do
399
+ [ -d "$d" ] || continue
400
+ b=$(basename "$d")
401
+ case "$b" in
402
+ test|tests|spec|specs|__tests__|fixtures|mocks|node_modules|dist|build|types|typings) continue ;;
403
+ esac
404
+ slug=$(sanitize_slug "$b")
405
+ scan_add "$slug" "$base/$b"
406
+ done
407
+ done
408
+ grep -q . "$SLUGS_TSV" && MODE=modules
409
+ fi
410
+
411
+ # Cap the map: a detector gone wild (generated routes) must not write 300 stub files.
412
+ if grep -q . "$SLUGS_TSV"; then
413
+ awk -F'\t' 'NR==FNR { if (!seen[$1]++ && n < 40) { n++; keep[$1]=1 } next } $1 in keep' \
414
+ "$SLUGS_TSV" "$SLUGS_TSV" > "$WORK/capped.tsv" && mv "$WORK/capped.tsv" "$SLUGS_TSV"
415
+ fi
416
+
417
+ SLUG_N=$(awk -F'\t' '!seen[$1]++ {n++} END {print n + 0}' "$SLUGS_TSV")
418
+
419
+ # ------------------------------------------------------------ existing map ----
420
+ HAVE_MAP=0
421
+ if [ -f "$README_F" ]; then
422
+ HAVE_MAP=1
423
+ elif [ -d "$FEAT" ] && ls "$FEAT"/*.md >/dev/null 2>&1; then
424
+ HAVE_MAP=1 # feature files without an index is still a map — the refusal must fire
425
+ fi
426
+
427
+ if [ "$HAVE_MAP" -eq 1 ] && [ "$FORCE" -ne 1 ]; then
428
+ g_err "refusing to overwrite an existing feature map: $FEAT"
429
+ printf 'error: map: pass --force to regenerate the README index and add NEW slugs only; existing feature files are never rewritten\n' >&2
430
+ exit 1
431
+ fi
432
+
433
+ # ------------------------------------------------------------ rendering ----
434
+ script_cmd() { # <script name> — the flat "name": "cmd" shape from package.json, no jq
435
+ local pk="$T/package.json" v
436
+ [ -f "$pk" ] || return 0
437
+ v=$(sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$pk" | head -n 1)
438
+ printf '%s' "$v"
439
+ }
440
+
441
+ # the H1 of an existing feature file, for the regenerated index line (fallback: the slug)
442
+ file_title() {
443
+ local t
444
+ t=$(sed -n 's/^#[[:space:]]//p' "$1" | head -n 1)
445
+ printf '%s' "${t:-$2}"
446
+ }
447
+
448
+ first_token() { # <slug>
449
+ awk -F'\t' -v s="$1" '$1 == s { print $2; exit }' "$SLUGS_TSV"
450
+ }
451
+
452
+ render_readme() { # > $WORK/readme.md
453
+ local slug tok title f
454
+ {
455
+ printf '%s\n' '# Features'
456
+ printf '%s\n' ''
457
+ printf '%s\n' "generated by gob map ${TODAY} — a STARTER; each feature file needs a human pass before its verified: date means anything."
458
+ printf '%s\n' ''
459
+ printf '%s\n' '## Baseline preconditions'
460
+ printf '%s\n' ''
461
+ if [ -f "$T/package.json" ]; then
462
+ got=0
463
+ for s in dev build test lint; do
464
+ c=$(script_cmd "$s")
465
+ if [ -n "$c" ]; then
466
+ printf '%s\n' "- $s: \`npm run $s\`"
467
+ got=1
468
+ fi
469
+ done
470
+ if [ "$got" -eq 1 ]; then
471
+ printf '%s\n' ''
472
+ printf '%s\n' '(read from package.json by the generator; edit by hand)'
473
+ else
474
+ printf '%s\n' '- TODO: package.json found, but no dev/build/test/lint script — write the launch + health-check commands here.'
475
+ fi
476
+ else
477
+ printf '%s\n' '- TODO: how to launch, isolate, seed, health-check (no package.json found — write your own commands here).'
478
+ fi
479
+ cat <<'MID'
480
+
481
+ ## Driving conventions
482
+
483
+ TODO: the stable-handle rule, the literal-command rule, the restore rule
484
+ (skills/goblin-feature-map/SKILL.md defines all three; write this repo's own forms here).
485
+
486
+ ## Proof and skip reporting
487
+
488
+ TODO: what counts as proof that a feature was driven, and how a skip is reported.
489
+
490
+ ## Features
491
+ MID
492
+ printf '%s\n' ''
493
+ if [ "$SLUG_N" -eq 0 ] && [ "$HAVE_MAP" -eq 0 ]; then
494
+ printf '%s\n' '- (none detected — this index is a STARTER; add features/<slug>.md by hand)'
495
+ fi
496
+ while IFS= read -r slug; do
497
+ [ -n "$slug" ] || continue
498
+ tok=$(first_token "$slug")
499
+ if [ -f "$FEAT/$slug.md" ]; then
500
+ title=$(file_title "$FEAT/$slug.md" "$slug")
501
+ printf '%s\n' "- [${title}](./${slug}.md) covers TODO — starter entry: \`${tok}\` (never driven)"
502
+ else
503
+ printf '%s\n' "- [${slug}](./${slug}.md) covers TODO — starter entry: \`${tok}\` (never driven)"
504
+ fi
505
+ done < <(awk -F'\t' '!seen[$1]++ {print $1}' "$SLUGS_TSV")
506
+ # --force keeps files the scanner no longer detects: they stay indexed (FM-01 would
507
+ # red an unlinked file), marked as existing rather than re-described.
508
+ if [ "$HAVE_MAP" -eq 1 ]; then
509
+ for f in "$FEAT"/*.md; do
510
+ [ -f "$f" ] || continue
511
+ slug=$(basename "$f"); slug=${slug%.md}
512
+ case "$slug" in README.md) continue ;; esac
513
+ awk -F'\t' -v s="$slug" '$1 == s { found=1 } END { exit !found }' "$SLUGS_TSV" && continue
514
+ title=$(file_title "$f" "$slug")
515
+ printf '%s\n' "- [${title}](./${slug}.md) covers TODO — existing feature file kept by --force (not re-described)"
516
+ done
517
+ fi
518
+ } > "$WORK/readme.md"
519
+ }
520
+
521
+ render_feature() { # <slug> — writes $FEAT/<slug>.md, never called on an existing file.
522
+ # The body renders in ONE pass: the entry_paths list is looped in awk (a bash body cannot
523
+ # loop the tab data cleanly), every other placeholder is data this function already holds.
524
+ # The awk program is single-quoted with no apostrophe inside (the repo's own rule).
525
+ local slug="$1"
526
+ awk -F'\t' -v slug="$slug" -v today="$TODAY" '
527
+ BEGIN {
528
+ print "---"
529
+ print "feature: " slug
530
+ print "entry_paths:"
531
+ eps = 0
532
+ }
533
+ $1 == slug && !seen[$2]++ {
534
+ print " - " $2
535
+ eps++
536
+ }
537
+ END {
538
+ if (eps == 0) print " - TODO: name one entry-path token"
539
+ print "verified: never-driven (generated " today ")"
540
+ print "---"
541
+ print ""
542
+ print "# " slug
543
+ print ""
544
+ print "TODO (human pass): one paragraph of user-visible behaviour, no implementation"
545
+ print "detail. Generated by gob map; nothing in this file has been driven."
546
+ print ""
547
+ print "## Sub-features"
548
+ print ""
549
+ print "- TODO: list the sub-behaviours, one `<id>` per line"
550
+ print ""
551
+ print "## How to get to it (user POV)"
552
+ print ""
553
+ print "- TODO: how a user reaches this feature, and what they should see"
554
+ print ""
555
+ print "## Driving it with <harness>"
556
+ print ""
557
+ print "Preconditions: TODO - what must be true first (see the README Baseline section)."
558
+ print "**TODO action.** Run `<exact command>`. TODO: the observable result."
559
+ print ""
560
+ print "## Gotchas"
561
+ print ""
562
+ print "- This file is GENERATED: the verified line above is not a drive claim - replace"
563
+ print " it with a date only after a human or an agent has driven the feature once."
564
+ print "- The entry path is a PATH that existed at generation time. If you declare this map"
565
+ print " later, FM-02 greps file CONTENTS for the token - prefer one that occurs in source"
566
+ print " text (a route literal, an import) so the tripwire can actually fire."
567
+ }' "$SLUGS_TSV" > "$FEAT/${slug}.md"
568
+ }
569
+
570
+ # ------------------------------------------------------------------ write ----
571
+ mkdir -p "$FEAT"
572
+
573
+ NEW_N=0
574
+ render_readme
575
+
576
+ if [ "$FORCE" -eq 1 ] && [ -f "$README_F" ]; then
577
+ KEPT_EXISTING=1
578
+ else
579
+ KEPT_EXISTING=0
580
+ fi
581
+
582
+ # new feature files: detected slugs with no file yet (in --force, or fresh generation)
583
+ NEW_LIST="$WORK/new.list"; : > "$NEW_LIST"
584
+ while IFS= read -r slug; do
585
+ [ -n "$slug" ] || continue
586
+ [ -f "$FEAT/$slug.md" ] || printf '%s\n' "$slug" >> "$NEW_LIST"
587
+ done < <(awk -F'\t' '!seen[$1]++ {print $1}' "$SLUGS_TSV")
588
+ NEW_N=$(wc -l < "$NEW_LIST" | tr -d '[:space:]')
589
+
590
+ if [ "$FORCE" -eq 1 ] && [ -f "$README_F" ] && [ "$NEW_N" -eq 0 ] \
591
+ && cmp -s "$WORK/readme.md" "$README_F"; then
592
+ printf '%s\n' "map: nothing to do — the index and every detected slug are already in place (${SLUG_N} slug(s), mode: ${MODE:-none})"
593
+ exit 0
594
+ fi
595
+
596
+ cp "$WORK/readme.md" "$README_F"
597
+ while IFS= read -r slug; do
598
+ [ -n "$slug" ] || continue
599
+ render_feature "$slug"
600
+ done < "$NEW_LIST"
601
+
602
+ if [ "$KEPT_EXISTING" -eq 1 ]; then
603
+ printf '%s\n' "map: regenerated ${README_F}; added ${NEW_N} new feature file(s), existing files untouched (${SLUG_N} slug(s), mode: ${MODE:-none})"
604
+ else
605
+ printf '%s\n' "map: generated ${README_F} + ${NEW_N} feature file(s) — a STARTER; hand-pass each file before trusting it (mode: ${MODE:-none})"
606
+ fi
607
+ exit 0