@techgoblin/gobstack 0.5.0-beta.7 → 0.6.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +112 -123
- package/VERSION +1 -1
- package/automations/drift-audit.sh +4 -4
- package/bans/layer-check.sh +10 -8
- package/bin/goblin +61 -52
- package/bin/goblin-audit +11 -13
- package/bin/goblin-bans +11 -11
- package/bin/goblin-init +275 -713
- package/bin/goblin-install +160 -114
- package/bin/goblin-lib.sh +234 -1
- package/bin/goblin-map +607 -0
- package/bin/goblin-mcp.js +492 -0
- package/bin/goblin-model +4 -4
- package/bin/goblin-upgrade +1 -1
- package/bin/goblin-verify +159 -145
- package/bin/goblin.js +33 -49
- package/docs/ADOPTION.md +15 -15
- package/docs/CONTRACTS.md +16 -15
- package/docs/DESIGN.md +1 -1
- package/docs/ENFORCEMENT.md +89 -90
- package/docs/FLOWS.md +1 -1
- package/docs/GLOSSARY.md +3 -3
- package/docs/GUARDRAILS.md +5 -5
- package/docs/GUIDE.md +178 -169
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITS.md +25 -0
- package/docs/LOOP.md +12 -12
- package/docs/RE-PLAYBOOK.md +3 -3
- package/docs/ROLES.md +5 -5
- package/manifest/bans.tsv +8 -8
- package/manifest/classes.tsv +3 -3
- package/manifest/enforcement.tsv +40 -40
- package/manifest/glossary.tsv +3 -3
- package/manifest/playbooks.tsv +1 -1
- package/package.json +1 -1
- package/presets/electron-overlay.yaml +2 -2
- package/presets/fleet.yaml +8 -7
- package/presets/game.yaml +1 -1
- package/presets/research.yaml +1 -1
- package/presets/service.yaml +1 -1
- package/presets/software.yaml +1 -1
- package/skills/goblin-bootstrap/SKILL.md +2 -2
- package/templates/AGENTS.md.tmpl +8 -18
- package/templates/HANDOFF.md.tmpl +5 -5
- package/templates/agents-block.tmpl +45 -0
- package/templates/audit-waiver.tsv.tmpl +2 -2
- package/templates/boundary-waivers.tmpl +1 -1
- package/templates/checks/gate.sh.tmpl +6 -6
- package/templates/install-hooks.allowlist.tmpl +1 -1
- package/templates/ci/goblin-gate.yml.tmpl +0 -46
- package/templates/goblin.yaml.tmpl +0 -146
- package/templates/loop/decisions.tsv.tmpl +0 -1
- package/templates/loop/predicate.tmpl +0 -16
package/bin/goblin-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
|