@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 +1 -0
- package/bin/goblin +6 -0
- package/bin/goblin-init +4 -1
- package/bin/goblin-map +402 -0
- package/bin/goblin-verify +11 -1
- package/bin/goblin.js +3 -1
- package/docs/GUIDE.md +21 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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;
|
|
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