@techgoblin/gobstack 0.5.0-beta.6 → 0.5.0-beta.8

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/README.md CHANGED
@@ -100,6 +100,7 @@ The measurement and the vacuous-pass reading are in `docs/CONTRACTS.md`.
100
100
  | `gob emit` | write the skills + context block for one platform (`--scope project` or `global`); `--unshadow` removes a hermes project skill whose hash equals the source; `gob sync` is the same command under its friendlier name — both spellings work |
101
101
  | `gob sync` | the emit verb, renamed (wizard v2): same engine, same flags, same exit contract; `gob emit --help` and `gob sync --help` are byte-identical apart from the verb name |
102
102
  | `gob init` | the first-run wizard: detect → class → identity → health check → ci → sync → done, one screen per question; every question has a flag (`--class software --branch main --email a@b.c --gate 'cmd' --emit hermes`), so CI runs it with zero prompts; the ci step defaults to no — nothing under .github/ unless you opt in (`--ci-gate yes|no` overrides); `--no-verify` skips the closing health check; `--dry-run` prints the plan and writes nothing |
103
+ | `gob map` | generate a starter feature map for this repo (standalone; no install needed): scans Next.js app/pages router, Nuxt, route files, or top-level src/lib modules and writes `features/README.md` + one file per detected feature; never clobbers — an existing map refuses until `--force`, which regenerates the index only |
103
104
 
104
105
  `goblin` remains as a legacy alias for every command above — existing scripts keep working, but
105
106
  new commands and docs use `gob`.
package/bin/goblin CHANGED
@@ -49,6 +49,7 @@ gob — the gobstack command line.
49
49
  gob emit --platform <p> --scope project|global [...] # alias: gob sync
50
50
  gob sync --platform <p> --scope project|global [...] # the same verb, friendlier name
51
51
  gob init [--target <dir>] [--class app|A-F] [--dry-run]
52
+ gob map [target] [--force] # the standalone feature-map generator (no install needed)
52
53
  gob upgrade [--target .] [--dry-run] [--yes] [--engine-dir <path>]
53
54
  gob --version
54
55
 
@@ -105,6 +106,11 @@ case "$CMD" in
105
106
  # exit contract verbatim, like every other subcommand here.
106
107
  exec bash "$SRC/bin/goblin-init" "$@"
107
108
  ;;
109
+ map)
110
+ # The standalone feature-map generator: routes and propagates its 0/1/2 contract
111
+ # verbatim, like every other subcommand. It needs no .goblin/ install by design.
112
+ exec bash "$SRC/bin/goblin-map" "$@"
113
+ ;;
108
114
  upgrade)
109
115
  # W3: the migration lives in its own checkout-level script (W3-SPEC §1.1);
110
116
  # the dispatcher routes and propagates the four-value contract verbatim.
package/bin/goblin-init CHANGED
@@ -752,7 +752,10 @@ elif [ "$DRYRUN" -eq 0 ]; then
752
752
  if [ "$VERIFY_RC" -eq 0 ]; then
753
753
  printf ' %s▙ the goblin sees you. keep the gate green.%s\n' "$C_ACCENT" "$C_RESET"
754
754
  else
755
- printf ' %s▙ the goblin sees you. the gate is RED — start there.%s\n' "$C_RED" "$C_RESET"
755
+ # Red runs end on the concrete first command, not the mascot: the day-one verdict IS the
756
+ # FAIL list, and the first row's remedy line is the thing to act on (UX minor polish —
757
+ # the "the goblin sees you" sign-off stays a green-run line only).
758
+ printf ' %sstart with the first FAIL above — its remedy line says the fix.%s\n' "$C_RED" "$C_RESET"
756
759
  fi
757
760
  fi
758
761
 
package/bin/goblin-map ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env bash
2
+ # goblin-map — `gob map`: generate a STARTER feature map for this repo (standalone).
3
+ #
4
+ # gob map [target] [--force] [--target <dir>] [--help]
5
+ #
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).
11
+ #
12
+ # What it scans (best-effort, honest about being a STARTER):
13
+ # next-app app/**/page.tsx|page.jsx|route.ts -> one feature per top-level route segment
14
+ # pages-router pages/**/*.tsx|jsx -> same grouping
15
+ # nuxt pages/**/*.vue -> same grouping
16
+ # routes directories named routes/ or *route* files -> one feature per file
17
+ # modules no framework: top-level src/ or lib/ module dirs -> candidate slugs, TODOs
18
+ # The first detector that fires wins; hybrids keep only the first hit. Deliberately ignored:
19
+ # node_modules, .git, dist/build output, test and fixture directories, files whose names carry
20
+ # whitespace. A scan that finds nothing still writes the README index, with the Features
21
+ # section saying so — an honest hole, not an invented feature.
22
+ #
23
+ # Never-clobber contract:
24
+ # features/ absent (or empty of .md) -> generate README + one file per slug, exit 0
25
+ # features/ already a map, no --force -> write NOTHING, name the path and --force, exit 1
26
+ # --force -> regenerate ONLY README.md (indexing every existing
27
+ # feature file too) and add files for NEW slugs;
28
+ # existing feature files are never touched, exit 0
29
+ # --force, nothing would change -> write nothing, say so, exit 0 (nothing-to-do)
30
+ #
31
+ # Exit codes: 0 generated or nothing-to-do | 1 refusal (existing map without --force) |
32
+ # 2 bad input.
33
+ #
34
+ # Frontmatter: `verified:` is NOT a drive claim — the generation run drives the generator,
35
+ # not the features — so it reads `never-driven (generated <date>)` until a human pass replaces
36
+ # it with a date. FM-01 does not read the verified line at all; FM-02, once a map is declared,
37
+ # treats any non-date as never-stale (string compare), so a starter cannot fail freshness it
38
+ # never earned — the dishonesty guard is the wording, and the per-file body repeats it.
39
+ #
40
+ # Entry paths are repo-relative paths that EXIST under the target (checked at generation
41
+ # time). Caveat, stated in every generated file: FM-02 greps file CONTENTS for a token, so
42
+ # after the human pass prefer a token that occurs in source text.
43
+ #
44
+ # No npm, no jq, no network. bash/awk/sed/grep/find only (the repo constraint, mirrored).
45
+
46
+ set -uo pipefail
47
+
48
+ g_err() { printf 'error: map: %s\n' "$*" >&2; }
49
+
50
+ usage() {
51
+ cat <<'USAGE'
52
+ gob map — generate a starter feature map for this repo (standalone; no install needed).
53
+
54
+ gob map [target] [--force]
55
+
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
59
+
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.
64
+
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.
68
+
69
+ Exit codes: 0 generated or nothing-to-do | 1 refusal (existing map, no --force) | 2 bad input.
70
+ USAGE
71
+ }
72
+
73
+ # ----------------------------------------------------------------- input ----
74
+ TARGET="" FORCE=0
75
+ while [ $# -gt 0 ]; do
76
+ case "$1" in
77
+ --force) FORCE=1; shift ;;
78
+ --target) [ -z "$TARGET" ] || { g_err "target given twice"; exit 2; }
79
+ TARGET="${2:-}"; shift 2 ;;
80
+ -h|--help) usage; exit 0 ;;
81
+ --) shift; break ;;
82
+ -*) g_err "unknown option: $1"; usage >&2; exit 2 ;;
83
+ *) if [ -z "$TARGET" ]; then TARGET="$1"; shift
84
+ else g_err "unexpected argument: $1"; exit 2; fi ;;
85
+ esac
86
+ done
87
+ [ -n "$TARGET" ] || TARGET="$PWD"
88
+ [ -d "$TARGET" ] || { g_err "not a directory: $TARGET"; exit 2; }
89
+ T=$(cd "$TARGET" && pwd) || { g_err "cannot enter: $TARGET"; exit 2; }
90
+ FEAT="$T/features"
91
+ README_F="$FEAT/README.md"
92
+ TODAY=$(date +%F)
93
+
94
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/goblin-map.XXXXXX") || { g_err "mktemp failed"; exit 2; }
95
+ trap 'rm -rf "$WORK"' EXIT
96
+ SLUGS_TSV="$WORK/slugs.tsv"; : > "$SLUGS_TSV"
97
+
98
+ # ---------------------------------------------------------------- helpers ----
99
+ # slug form: lowercase, [a-z0-9-], no leading/trailing dash. Empty output = unusable.
100
+ sanitize_slug() {
101
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
102
+ | sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-*//' -e 's/-*$//'
103
+ }
104
+
105
+ scan_add() { # <slug> <repo-relative token> — single-token, deduped
106
+ case "$2" in
107
+ *" "*|""|"${2}"[[:space:]]*) return ;;
108
+ esac
109
+ case "$1" in ""|"-") return ;; esac
110
+ grep -qF "$(printf '%s\t%s' "$1" "$2")" "$SLUGS_TSV" \
111
+ || printf '%s\t%s\n' "$1" "$2" >> "$SLUGS_TSV"
112
+ }
113
+
114
+ # one feature per TOP-LEVEL route segment under <rootdir>/; the segment is the first path
115
+ # component (a Next route group `(marketing)` sanitizes to `marketing`); files sitting
116
+ # directly in <rootdir>/ are the root route and group under `home`.
117
+ group_slug_for() { # <relative path under the scan root> <scan root name>
118
+ local rest="$1" root="$2" seg
119
+ rest=${rest#"$root"/}
120
+ case "$rest" in
121
+ */*) seg=${rest%%/*} ;;
122
+ *) seg=home ;;
123
+ esac
124
+ sanitize_slug "$seg"
125
+ }
126
+
127
+ prune_find=(\( -name node_modules -o -name .git -o -name dist -o -name build -o -name .next -o -name .nuxt \) -prune -o)
128
+
129
+ # --------------------------------------------------------------- detectors ----
130
+ MODE=""
131
+ SLUG_CAP=40
132
+
133
+ if [ -d "$T/app" ]; then
134
+ while IFS= read -r f; do
135
+ [ -n "$f" ] || continue
136
+ rel=${f#"$T"/}
137
+ slug=$(group_slug_for "$rel" "app")
138
+ scan_add "$slug" "$rel"
139
+ done < <(find "$T/app" -type f \( -name 'page.tsx' -o -name 'page.jsx' -o -name 'route.ts' \) 2>/dev/null)
140
+ grep -q . "$SLUGS_TSV" && MODE=next-app
141
+ fi
142
+
143
+ if [ -z "$MODE" ] && [ -d "$T/pages" ]; then
144
+ # .vue under pages/ is Nuxt; .tsx/.jsx is the Next pages router. Same grouping either way.
145
+ while IFS= read -r f; do
146
+ [ -n "$f" ] || continue
147
+ rel=${f#"$T"/}
148
+ rest=${rel#pages/}
149
+ case "$rest" in
150
+ */*) slug=$(sanitize_slug "${rest%%/*}") ;;
151
+ *) slug=$(sanitize_slug "${rest%.*}") ;;
152
+ esac
153
+ scan_add "$slug" "$rel"
154
+ done < <(find "$T/pages" -type f \( -name '*.tsx' -o -name '*.jsx' -o -name '*.vue' \) 2>/dev/null)
155
+ grep -q . "$SLUGS_TSV" && MODE=pages-router
156
+ fi
157
+
158
+ if [ -z "$MODE" ]; then
159
+ # React Router and friends: any routes/ directory (or *route* file) within reach. Capped,
160
+ # because a repo with generated route tables would otherwise grow one slug per row.
161
+ while IFS= read -r d; do
162
+ [ -n "$d" ] || continue
163
+ while IFS= read -r f; do
164
+ [ -n "$f" ] || continue
165
+ rel=${f#"$T"/}
166
+ b=$(basename "$f"); stem=${b%.*}
167
+ stem=$(printf '%s' "$stem" | sed -e 's/[-.]route$//' -e 's/^route[-.]//')
168
+ case "$stem" in
169
+ index|routes) stem=$(basename "$d") ;;
170
+ esac
171
+ slug=$(sanitize_slug "$stem")
172
+ scan_add "$slug" "$rel"
173
+ done < <(find "$d" -maxdepth 2 -type f \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' -o -name '*.vue' \) 2>/dev/null)
174
+ done < <(find "$T" "${prune_find[@]}" -type d -name routes -print 2>/dev/null | head -n 8)
175
+ if [ -z "$MODE" ]; then
176
+ while IFS= read -r f; do
177
+ [ -n "$f" ] || continue
178
+ rel=${f#"$T"/}
179
+ b=$(basename "$f"); stem=${b%.*}
180
+ stem=$(printf '%s' "$stem" | sed -e 's/[-.]route$//' -e 's/^route[-.]//' -e 's/^routes$//')
181
+ slug=$(sanitize_slug "$stem")
182
+ scan_add "$slug" "$rel"
183
+ done < <(find "$T" "${prune_find[@]}" -type f -name '*route*' \
184
+ \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' \) -print 2>/dev/null | head -n 40)
185
+ fi
186
+ grep -q . "$SLUGS_TSV" && MODE=routes
187
+ fi
188
+
189
+ if [ -z "$MODE" ]; then
190
+ # Fallback: top-level src/ or lib/ module dirs become candidate slugs with TODO bodies.
191
+ for base in src lib; do
192
+ [ -d "$T/$base" ] || continue
193
+ for d in "$T/$base"/*/; do
194
+ [ -d "$d" ] || continue
195
+ b=$(basename "$d")
196
+ case "$b" in
197
+ test|tests|spec|specs|__tests__|fixtures|mocks|node_modules|dist|build|types|typings) continue ;;
198
+ esac
199
+ slug=$(sanitize_slug "$b")
200
+ scan_add "$slug" "$base/$b"
201
+ done
202
+ done
203
+ grep -q . "$SLUGS_TSV" && MODE=modules
204
+ fi
205
+
206
+ # Cap the map: a detector gone wild (generated routes) must not write 300 stub files.
207
+ if grep -q . "$SLUGS_TSV"; then
208
+ awk -F'\t' 'NR==FNR { if (!seen[$1]++ && n < 40) { n++; keep[$1]=1 } next } $1 in keep' \
209
+ "$SLUGS_TSV" "$SLUGS_TSV" > "$WORK/capped.tsv" && mv "$WORK/capped.tsv" "$SLUGS_TSV"
210
+ fi
211
+
212
+ SLUG_N=$(awk -F'\t' '!seen[$1]++ {n++} END {print n + 0}' "$SLUGS_TSV")
213
+
214
+ # ------------------------------------------------------------ existing map ----
215
+ HAVE_MAP=0
216
+ if [ -f "$README_F" ]; then
217
+ HAVE_MAP=1
218
+ elif [ -d "$FEAT" ] && ls "$FEAT"/*.md >/dev/null 2>&1; then
219
+ HAVE_MAP=1 # feature files without an index is still a map — the refusal must fire
220
+ fi
221
+
222
+ if [ "$HAVE_MAP" -eq 1 ] && [ "$FORCE" -ne 1 ]; then
223
+ g_err "refusing to overwrite an existing feature map: $FEAT"
224
+ printf 'error: map: pass --force to regenerate the README index and add NEW slugs only; existing feature files are never rewritten\n' >&2
225
+ exit 1
226
+ fi
227
+
228
+ # ------------------------------------------------------------ rendering ----
229
+ script_cmd() { # <script name> — the flat "name": "cmd" shape from package.json, no jq
230
+ local pk="$T/package.json" v
231
+ [ -f "$pk" ] || return 0
232
+ v=$(sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$pk" | head -n 1)
233
+ printf '%s' "$v"
234
+ }
235
+
236
+ # the H1 of an existing feature file, for the regenerated index line (fallback: the slug)
237
+ file_title() {
238
+ local t
239
+ t=$(sed -n 's/^#[[:space:]]//p' "$1" | head -n 1)
240
+ printf '%s' "${t:-$2}"
241
+ }
242
+
243
+ first_token() { # <slug>
244
+ awk -F'\t' -v s="$1" '$1 == s { print $2; exit }' "$SLUGS_TSV"
245
+ }
246
+
247
+ render_readme() { # > $WORK/readme.md
248
+ local slug tok title f
249
+ {
250
+ printf '%s\n' '# Features'
251
+ printf '%s\n' ''
252
+ printf '%s\n' "generated by gob map ${TODAY} — a STARTER; each feature file needs a human pass before its verified: date means anything."
253
+ printf '%s\n' ''
254
+ printf '%s\n' '## Baseline preconditions'
255
+ printf '%s\n' ''
256
+ if [ -f "$T/package.json" ]; then
257
+ got=0
258
+ for s in dev build test lint; do
259
+ c=$(script_cmd "$s")
260
+ if [ -n "$c" ]; then
261
+ printf '%s\n' "- $s: \`npm run $s\`"
262
+ got=1
263
+ fi
264
+ done
265
+ if [ "$got" -eq 1 ]; then
266
+ printf '%s\n' ''
267
+ printf '%s\n' '(read from package.json by the generator; edit by hand)'
268
+ else
269
+ printf '%s\n' '- TODO: package.json found, but no dev/build/test/lint script — write the launch + health-check commands here.'
270
+ fi
271
+ else
272
+ printf '%s\n' '- TODO: how to launch, isolate, seed, health-check (no package.json found — write your own commands here).'
273
+ fi
274
+ cat <<'MID'
275
+
276
+ ## Driving conventions
277
+
278
+ TODO: the stable-handle rule, the literal-command rule, the restore rule
279
+ (skills/goblin-feature-map/SKILL.md defines all three; write this repo's own forms here).
280
+
281
+ ## Proof and skip reporting
282
+
283
+ TODO: what counts as proof that a feature was driven, and how a skip is reported.
284
+
285
+ ## Features
286
+ MID
287
+ printf '%s\n' ''
288
+ if [ "$SLUG_N" -eq 0 ] && [ "$HAVE_MAP" -eq 0 ]; then
289
+ printf '%s\n' '- (none detected — this index is a STARTER; add features/<slug>.md by hand)'
290
+ fi
291
+ while IFS= read -r slug; do
292
+ [ -n "$slug" ] || continue
293
+ tok=$(first_token "$slug")
294
+ if [ -f "$FEAT/$slug.md" ]; then
295
+ title=$(file_title "$FEAT/$slug.md" "$slug")
296
+ printf '%s\n' "- [${title}](./${slug}.md) covers TODO — starter entry: \`${tok}\` (never driven)"
297
+ else
298
+ printf '%s\n' "- [${slug}](./${slug}.md) covers TODO — starter entry: \`${tok}\` (never driven)"
299
+ fi
300
+ done < <(awk -F'\t' '!seen[$1]++ {print $1}' "$SLUGS_TSV")
301
+ # --force keeps files the scanner no longer detects: they stay indexed (FM-01 would
302
+ # red an unlinked file), marked as existing rather than re-described.
303
+ if [ "$HAVE_MAP" -eq 1 ]; then
304
+ for f in "$FEAT"/*.md; do
305
+ [ -f "$f" ] || continue
306
+ slug=$(basename "$f"); slug=${slug%.md}
307
+ case "$slug" in README.md) continue ;; esac
308
+ awk -F'\t' -v s="$slug" '$1 == s { found=1 } END { exit !found }' "$SLUGS_TSV" && continue
309
+ title=$(file_title "$f" "$slug")
310
+ printf '%s\n' "- [${title}](./${slug}.md) covers TODO — existing feature file kept by --force (not re-described)"
311
+ done
312
+ fi
313
+ } > "$WORK/readme.md"
314
+ }
315
+
316
+ render_feature() { # <slug> — writes $FEAT/<slug>.md, never called on an existing file.
317
+ # The body renders in ONE pass: the entry_paths list is looped in awk (a bash body cannot
318
+ # loop the tab data cleanly), every other placeholder is data this function already holds.
319
+ # The awk program is single-quoted with no apostrophe inside (the repo's own rule).
320
+ local slug="$1"
321
+ awk -F'\t' -v slug="$slug" -v today="$TODAY" '
322
+ BEGIN {
323
+ print "---"
324
+ print "feature: " slug
325
+ print "entry_paths:"
326
+ eps = 0
327
+ }
328
+ $1 == slug && !seen[$2]++ {
329
+ print " - " $2
330
+ eps++
331
+ }
332
+ END {
333
+ if (eps == 0) print " - TODO: name one entry-path token"
334
+ print "verified: never-driven (generated " today ")"
335
+ print "---"
336
+ print ""
337
+ print "# " slug
338
+ print ""
339
+ print "TODO (human pass): one paragraph of user-visible behaviour, no implementation"
340
+ print "detail. Generated by gob map; nothing in this file has been driven."
341
+ print ""
342
+ print "## Sub-features"
343
+ print ""
344
+ print "- TODO: list the sub-behaviours, one `<id>` per line"
345
+ print ""
346
+ print "## How to get to it (user POV)"
347
+ print ""
348
+ print "- TODO: how a user reaches this feature, and what they should see"
349
+ print ""
350
+ print "## Driving it with <harness>"
351
+ print ""
352
+ print "Preconditions: TODO - what must be true first (see the README Baseline section)."
353
+ print "**TODO action.** Run `<exact command>`. TODO: the observable result."
354
+ print ""
355
+ print "## Gotchas"
356
+ print ""
357
+ print "- This file is GENERATED: the verified line above is not a drive claim - replace"
358
+ print " it with a date only after a human or an agent has driven the feature once."
359
+ print "- The entry path is a PATH that existed at generation time. If you declare this map"
360
+ print " later, FM-02 greps file CONTENTS for the token - prefer one that occurs in source"
361
+ print " text (a route literal, an import) so the tripwire can actually fire."
362
+ }' "$SLUGS_TSV" > "$FEAT/${slug}.md"
363
+ }
364
+
365
+ # ------------------------------------------------------------------ write ----
366
+ mkdir -p "$FEAT"
367
+
368
+ NEW_N=0
369
+ render_readme
370
+
371
+ if [ "$FORCE" -eq 1 ] && [ -f "$README_F" ]; then
372
+ KEPT_EXISTING=1
373
+ else
374
+ KEPT_EXISTING=0
375
+ fi
376
+
377
+ # new feature files: detected slugs with no file yet (in --force, or fresh generation)
378
+ NEW_LIST="$WORK/new.list"; : > "$NEW_LIST"
379
+ while IFS= read -r slug; do
380
+ [ -n "$slug" ] || continue
381
+ [ -f "$FEAT/$slug.md" ] || printf '%s\n' "$slug" >> "$NEW_LIST"
382
+ done < <(awk -F'\t' '!seen[$1]++ {print $1}' "$SLUGS_TSV")
383
+ NEW_N=$(wc -l < "$NEW_LIST" | tr -d '[:space:]')
384
+
385
+ if [ "$FORCE" -eq 1 ] && [ -f "$README_F" ] && [ "$NEW_N" -eq 0 ] \
386
+ && cmp -s "$WORK/readme.md" "$README_F"; then
387
+ printf '%s\n' "map: nothing to do — the index and every detected slug are already in place (${SLUG_N} slug(s), mode: ${MODE:-none})"
388
+ exit 0
389
+ fi
390
+
391
+ cp "$WORK/readme.md" "$README_F"
392
+ while IFS= read -r slug; do
393
+ [ -n "$slug" ] || continue
394
+ render_feature "$slug"
395
+ done < "$NEW_LIST"
396
+
397
+ if [ "$KEPT_EXISTING" -eq 1 ]; then
398
+ printf '%s\n' "map: regenerated ${README_F}; added ${NEW_N} new feature file(s), existing files untouched (${SLUG_N} slug(s), mode: ${MODE:-none})"
399
+ else
400
+ printf '%s\n' "map: generated ${README_F} + ${NEW_N} feature file(s) — a STARTER; hand-pass each file before trusting it (mode: ${MODE:-none})"
401
+ fi
402
+ exit 0
package/bin/goblin-verify CHANGED
@@ -268,7 +268,17 @@ if [ -n "$ONLY" ]; then
268
268
  fi
269
269
 
270
270
  if [ ! -f "$CONFIG" ]; then
271
- g_err "not installed: $CONFIG is absent. Run: goblin-install --target $ROOT --class <software|service|game|research|fleet>"
271
+ # Not installed. When PWD is not the target's own project root, the FIRST suggestion is to
272
+ # go to the repo you meant, and the install command is named second WITH its target spelled
273
+ # out — an out-of-context `goblin-install --target $PWD` is how a scratch directory gets
274
+ # initialized by accident (review 2 §1.6). Exit stays 2.
275
+ if [ "$ROOT" != "$PWD" ]; then
276
+ g_err "not installed: $CONFIG is absent — and $PWD is not the installed repo ($ROOT)."
277
+ g_err "fix: cd to your project repo, then re-run gob verify."
278
+ g_err " (installing here would initialize $PWD: goblin-install --target $PWD --class <software|service|game|research|fleet>)"
279
+ else
280
+ g_err "not installed: $CONFIG is absent. Run: goblin-install --target $ROOT --class <software|service|game|research|fleet>"
281
+ fi
272
282
  exit 2
273
283
  fi
274
284
 
package/bin/goblin.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // gob doctor [...] -> bin/goblin-doctor (W4a)
14
14
  // gob emit [...] -> bin/goblin-emit (W4a)
15
15
  // gob init [...] -> bin/goblin-init (W6, the first-run wizard)
16
+ // gob map [target] [--force] -> bin/goblin-map (the standalone feature-map generator)
16
17
  // gob uninstall [--target <dir>] -> bin/goblin-install --uninstall
17
18
  // gob install [...] -> bin/goblin-install (the one legacy fallback)
18
19
  // no args | -h/--help | any other unrecognized first arg
@@ -44,7 +45,7 @@ if (arg0 === "--version" || arg0 === "-V" || arg0 === "-v") {
44
45
  process.exit(0);
45
46
  }
46
47
 
47
- const SCRIPT = { verify: "goblin-verify", bans: "goblin-bans", audit: "goblin-audit", upgrade: "goblin-upgrade", doctor: "goblin-doctor", emit: "goblin-emit", sync: "goblin-emit", init: "goblin-init" };
48
+ const SCRIPT = { verify: "goblin-verify", bans: "goblin-bans", audit: "goblin-audit", upgrade: "goblin-upgrade", doctor: "goblin-doctor", emit: "goblin-emit", sync: "goblin-emit", init: "goblin-init", map: "goblin-map" };
48
49
  // `sync` is the friendlier name for `emit` (wizard v2): same engine, same flags, same exit
49
50
  // contract. `emit` stays a first-class verb - nothing is removed, this row only adds an alias.
50
51
  const [cmd, ...rest] = process.argv.slice(2);
@@ -66,6 +67,7 @@ function usage() {
66
67
  " gob upgrade migrate a repo to the shared global engine at ~/.goblin/engine",
67
68
  " gob doctor one detection/drift run across the agent platforms",
68
69
  " gob sync write the skills + context block for one platform (alias: gob emit)",
70
+ " gob map generate a starter feature map for this repo (standalone; no install needed)",
69
71
  " gob uninstall --target . remove exactly what an install wrote (preimages)",
70
72
  "",
71
73
  "start here: gob init",
package/docs/GUIDE.md CHANGED
@@ -83,7 +83,8 @@ You need node ≥ 18 (for the npm shim only), beyond the row above.
83
83
  npm i -g @techgoblin/gobstack
84
84
 
85
85
  This puts **two** commands on your PATH — `gob` and `goblin`, both the same node shim over the
86
- bash engine. The docs say `gob` throughout; either works.
86
+ bash engine. The docs say `gob` throughout; `goblin` remains as a legacy alias for existing
87
+ scripts.
87
88
 
88
89
  ---
89
90
 
@@ -181,6 +182,25 @@ nothing to read yet." On a brand-new install, two dozen rows skip — because th
181
182
  ban to scan, no feature map, no loop record, no pinned pre-change commit. That is correct on day
182
183
  one. The list of what is still skipping *is* your onboarding checklist.
183
184
 
185
+ ### Feature maps: generate with `gob map`, then opt in
186
+
187
+ The feature-map rows (`FM-01`, `FM-02`) are opt-in by declaration: while `feature_map:` in
188
+ `.goblin/goblin.yaml` is empty, both rows SKIP. When you are ready to keep a map honest, the flow
189
+ is:
190
+
191
+ 1. **Generate a starter.** `gob map` works in any git repo — no `.goblin/` install, no goblin.yaml.
192
+ It scans the repo (Next.js app/pages router, Nuxt, route files, or top-level `src/`/`lib/`
193
+ module dirs as TODO placeholders) and writes `features/README.md` plus one file per detected
194
+ feature. It never clobbers: an existing `features/` refuses until `--force`, which regenerates
195
+ only the index and adds new slugs — your hand-edited feature files are never rewritten.
196
+ 2. **Hand-pass every file.** The generated files say so themselves: a `verified: never-driven
197
+ (generated <date>)` line is not a drive claim. Edit each one into a real feature description
198
+ with concrete entry paths and driving steps.
199
+ 3. **Then, optionally, declare it.** Set `feature_map: features/README.md` in `.goblin/goblin.yaml`
200
+ and `FM-01`/`FM-02` start reading it on every verify — that declaration is the CI/verify opt-in,
201
+ never forced. A repo that wants the generator but not the rows can run `gob map` and never
202
+ declare anything.
203
+
184
204
  **Read the failure messages.** They are written to be actionable, not decorative. `HP-05` above is
185
205
  telling you the HANDOFF does not yet name a commit — fix it by naming your HEAD in the `State`
186
206
  section.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techgoblin/gobstack",
3
- "version": "0.5.0-beta.6",
3
+ "version": "0.5.0-beta.8",
4
4
  "description": "Agent-discipline toolkit: one verify command, an enforcement matrix, and LIMITS. bash engine, npm shim.",
5
5
  "bin": {
6
6
  "goblin": "bin/goblin.js",