skillshop 2.0.0 → 2.1.0
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 +217 -104
- package/agents-content/000_communication-writing.block.md +11 -0
- package/agents-content/001_external-actions.block.md +9 -0
- package/agents-content/AGENTS.md +11 -0
- package/package.json +6 -3
- package/skills-content/architecture-scanner/SKILL.md +274 -0
- package/skills-content/architecture-scanner/files.json +10 -0
- package/skills-content/architecture-scanner/scan.sh +823 -0
- package/skills-content/change-review/SKILL.md +76 -0
- package/skills-content/change-review/agents/openai.yaml +4 -0
- package/skills-content/changelog/SKILL.md +99 -0
- package/skills-content/changeset/changeset.md +80 -0
- package/skills-content/code-buddy/SKILL.md +136 -0
- package/skills-content/design-system-catalog/SKILL.md +106 -0
- package/skills-content/design-system-catalog/extract-components.mjs +441 -0
- package/skills-content/design-system-catalog/generate-catalog.sh +255 -0
- package/skills-content/flare/SKILL.md +96 -0
- package/skills-content/grilling/SKILL.md +31 -0
- package/skills-content/install-sigmap/SKILL.md +97 -0
- package/skills-content/one-shot/SKILL.md +51 -0
- package/skills-content/release/SKILL.md +128 -0
- package/skills-content/shadow-clone/SKILL.md +228 -0
- package/skills-content/shadow-clone/extract-signatures.mjs +202 -0
- package/skills-content/shadow-clone/scan-repo.mjs +343 -0
- package/skills-content/ship/SKILL.md +95 -0
- package/skills-content/shots/SKILL.md +89 -0
- package/skills-content/shots/agents/openai.yaml +4 -0
- package/skills-content/skillshop/SKILL.md +104 -0
- package/skills-content/skillz/SKILL.md +15 -0
- package/skills-content/transcript-clean/SKILL.md +55 -0
- package/skills-content/transcript-edit/SKILL.md +80 -0
- package/skills-content/vitest/GENERATION.md +5 -0
- package/skills-content/vitest/SKILL.md +52 -0
- package/skills-content/vitest/references/advanced-environments.md +264 -0
- package/skills-content/vitest/references/advanced-projects.md +300 -0
- package/skills-content/vitest/references/advanced-type-testing.md +237 -0
- package/skills-content/vitest/references/advanced-vi.md +249 -0
- package/skills-content/vitest/references/core-cli.md +166 -0
- package/skills-content/vitest/references/core-config.md +174 -0
- package/skills-content/vitest/references/core-describe.md +193 -0
- package/skills-content/vitest/references/core-expect.md +219 -0
- package/skills-content/vitest/references/core-hooks.md +244 -0
- package/skills-content/vitest/references/core-test-api.md +233 -0
- package/skills-content/vitest/references/features-concurrency.md +250 -0
- package/skills-content/vitest/references/features-context.md +238 -0
- package/skills-content/vitest/references/features-coverage.md +207 -0
- package/skills-content/vitest/references/features-filtering.md +211 -0
- package/skills-content/vitest/references/features-mocking.md +265 -0
- package/skills-content/vitest/references/features-snapshots.md +207 -0
- package/skills-content/workleaf/SKILL.md +77 -0
- package/skills-content/worklog/SKILL.md +145 -0
- package/skills-content/worklog/references/summary-format.md +114 -0
- package/skills-content/worktree/SKILL.md +71 -0
- package/src/agents-instructions.js +219 -0
- package/src/cli.js +87 -23
- package/src/import.js +12 -7
- package/src/index.js +1 -0
- package/src/legacy-agents-content.js +36 -0
- package/src/legacy-cli.js +1234 -0
- package/src/library.js +9 -0
- package/src/specifier.js +11 -0
- package/src/user-install.js +48 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# scan.sh — Architecture scanner
|
|
4
|
+
#
|
|
5
|
+
# Scans the repository for architecturally significant files, maintains
|
|
6
|
+
# a files.json index, and generates architecture documentation index.
|
|
7
|
+
#
|
|
8
|
+
# Usage: .agents/skills/architecture-scanner/scan.sh
|
|
9
|
+
# --discover Scan repo, create/update files.json (preserves user-set priorities)
|
|
10
|
+
# --manifest Print JSON array of non-low files to document (full scan)
|
|
11
|
+
# --full Run discover + placeholders + index skeleton + verify
|
|
12
|
+
# --enrich Run agent-enrichment planning pass only (no file writes)
|
|
13
|
+
# --enrich-all Run full agent-enrichment planning pass for all files (no file writes)
|
|
14
|
+
# --stale Print manifest of files changed since last architecture commit (incremental)
|
|
15
|
+
# --verify Validate manifest coverage and index/doc link consistency
|
|
16
|
+
# --index Create the index skeleton if it does not exist
|
|
17
|
+
# --steps LIST Run selected steps from: discover, manifest, docs, stale, enrich, enrich-all, index, verify
|
|
18
|
+
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
shopt -s globstar nullglob
|
|
21
|
+
|
|
22
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
23
|
+
ARCH_DIR="$REPO_ROOT/.agents/architecture"
|
|
24
|
+
SKILL_DIR="$REPO_ROOT/.agents/skills/architecture-scanner"
|
|
25
|
+
FILES_JSON="$SKILL_DIR/files.json"
|
|
26
|
+
export REPO_ROOT ARCH_DIR SKILL_DIR FILES_JSON
|
|
27
|
+
|
|
28
|
+
# ── Discovery rules ──────────────────────────────────────────
|
|
29
|
+
# Maps glob patterns to category|default_importance.
|
|
30
|
+
# Globs are evaluated relative to REPO_ROOT.
|
|
31
|
+
# Order matters: first match wins for a given file.
|
|
32
|
+
DISCOVERY_RULES=(
|
|
33
|
+
# Root and package-level configuration
|
|
34
|
+
"config|high|vite.config.js"
|
|
35
|
+
"config|high|package*.json"
|
|
36
|
+
"config|high|pyproject.toml"
|
|
37
|
+
"config|high|Cargo.toml"
|
|
38
|
+
"config|high|go.mod"
|
|
39
|
+
"config|medium|eslint.config.js"
|
|
40
|
+
"config|low|playwright.config.*"
|
|
41
|
+
|
|
42
|
+
# Tests first so they stay low priority
|
|
43
|
+
"test|low|app/src/**/*.test.jsx"
|
|
44
|
+
"test|low|app/src/**/*.test.js"
|
|
45
|
+
"test|low|src/**/*.test.ts"
|
|
46
|
+
"test|low|src/**/*.test.tsx"
|
|
47
|
+
"test|low|src/**/*.spec.ts"
|
|
48
|
+
"test|low|src/**/*.spec.tsx"
|
|
49
|
+
"test|low|scripts/**/*.test.js"
|
|
50
|
+
"test|low|test/**/*.test.js"
|
|
51
|
+
"test|low|tests/**/*.py"
|
|
52
|
+
"test|low|tests/**/*.go"
|
|
53
|
+
"test|low|tests/**/*.rs"
|
|
54
|
+
|
|
55
|
+
# Common application entry points and interfaces
|
|
56
|
+
"entry|high|app/src/main.jsx"
|
|
57
|
+
"entry|high|src/main.js"
|
|
58
|
+
"entry|high|src/main.ts"
|
|
59
|
+
"entry|high|src/index.js"
|
|
60
|
+
"entry|high|src/index.ts"
|
|
61
|
+
"interface|high|app/src/**/*.jsx"
|
|
62
|
+
"interface|high|app/src/**/*.tsx"
|
|
63
|
+
"interface|medium|app/src/**/*.js"
|
|
64
|
+
"interface|medium|app/src/**/*.ts"
|
|
65
|
+
|
|
66
|
+
# Core implementation
|
|
67
|
+
"core|high|src/**/*.js"
|
|
68
|
+
"core|high|src/**/*.mjs"
|
|
69
|
+
"core|high|src/**/*.ts"
|
|
70
|
+
"core|high|src/**/*.tsx"
|
|
71
|
+
"core|high|src/**/*.py"
|
|
72
|
+
"core|high|src/**/*.go"
|
|
73
|
+
"core|high|src/**/*.rs"
|
|
74
|
+
"core|low|src/**/*.json"
|
|
75
|
+
|
|
76
|
+
# Common workspace package layouts
|
|
77
|
+
"config|high|packages/*/package.json"
|
|
78
|
+
"config|high|apps/*/package.json"
|
|
79
|
+
"config|high|crates/*/Cargo.toml"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# Read existing importance for a path from files.json, or return empty string
|
|
85
|
+
get_existing_importance() {
|
|
86
|
+
local filepath="$1"
|
|
87
|
+
if [ -f "$FILES_JSON" ]; then
|
|
88
|
+
# Use python3 for reliable JSON parsing
|
|
89
|
+
python3 -c "
|
|
90
|
+
import json, sys
|
|
91
|
+
with open('$FILES_JSON') as f:
|
|
92
|
+
data = json.load(f)
|
|
93
|
+
files = data.get('files', data) if isinstance(data, dict) else data
|
|
94
|
+
for entry in files:
|
|
95
|
+
if entry['path'] == '$filepath':
|
|
96
|
+
print(entry.get('importance', ''))
|
|
97
|
+
sys.exit(0)
|
|
98
|
+
print('')
|
|
99
|
+
" 2>/dev/null
|
|
100
|
+
fi
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
discover_files() {
|
|
104
|
+
# Collect discovered files
|
|
105
|
+
declare -A seen
|
|
106
|
+
declare -A discovered_cat
|
|
107
|
+
declare -A discovered_imp
|
|
108
|
+
local ordered_paths=()
|
|
109
|
+
|
|
110
|
+
for rule in "${DISCOVERY_RULES[@]}"; do
|
|
111
|
+
IFS='|' read -r category importance glob_pattern <<< "$rule"
|
|
112
|
+
for file in $REPO_ROOT/$glob_pattern; do
|
|
113
|
+
[ -f "$file" ] || continue
|
|
114
|
+
local relpath="${file#$REPO_ROOT/}"
|
|
115
|
+
if [[ -n "${seen[$relpath]+x}" ]]; then
|
|
116
|
+
continue
|
|
117
|
+
fi
|
|
118
|
+
seen[$relpath]=1
|
|
119
|
+
discovered_cat[$relpath]="$category"
|
|
120
|
+
discovered_imp[$relpath]="$importance"
|
|
121
|
+
ordered_paths+=("$relpath")
|
|
122
|
+
done
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
# Build new files.json, preserving existing importance values and categories
|
|
126
|
+
local tmp_json
|
|
127
|
+
tmp_json="$(mktemp)"
|
|
128
|
+
|
|
129
|
+
# Preserve existing categories array if present
|
|
130
|
+
python3 -c "
|
|
131
|
+
import json
|
|
132
|
+
try:
|
|
133
|
+
with open('$FILES_JSON') as f:
|
|
134
|
+
data = json.load(f)
|
|
135
|
+
if isinstance(data, dict) and 'categories' in data:
|
|
136
|
+
print(json.dumps(data['categories'], indent=4))
|
|
137
|
+
else:
|
|
138
|
+
print('null')
|
|
139
|
+
except:
|
|
140
|
+
print('null')
|
|
141
|
+
" > "${tmp_json}.cats"
|
|
142
|
+
|
|
143
|
+
local cats
|
|
144
|
+
cats="$(cat "${tmp_json}.cats")"
|
|
145
|
+
rm -f "${tmp_json}.cats"
|
|
146
|
+
|
|
147
|
+
if [ "$cats" = "null" ]; then
|
|
148
|
+
echo "{" > "$tmp_json"
|
|
149
|
+
echo ' "files": [' >> "$tmp_json"
|
|
150
|
+
else
|
|
151
|
+
echo "{" > "$tmp_json"
|
|
152
|
+
echo ' "categories": '"$cats"',' >> "$tmp_json"
|
|
153
|
+
echo ' "files": [' >> "$tmp_json"
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
local first=true
|
|
157
|
+
for relpath in "${ordered_paths[@]}"; do
|
|
158
|
+
local category="${discovered_cat[$relpath]}"
|
|
159
|
+
local default_importance="${discovered_imp[$relpath]}"
|
|
160
|
+
|
|
161
|
+
# Preserve user-set importance if file already exists in files.json
|
|
162
|
+
local existing_importance
|
|
163
|
+
existing_importance="$(get_existing_importance "$relpath")"
|
|
164
|
+
local importance="${existing_importance:-$default_importance}"
|
|
165
|
+
|
|
166
|
+
if [ "$first" = true ]; then
|
|
167
|
+
first=false
|
|
168
|
+
else
|
|
169
|
+
echo "," >> "$tmp_json"
|
|
170
|
+
fi
|
|
171
|
+
printf ' { "path": "%s", "category": "%s", "importance": "%s" }' \
|
|
172
|
+
"$relpath" "$category" "$importance" >> "$tmp_json"
|
|
173
|
+
done
|
|
174
|
+
|
|
175
|
+
echo "" >> "$tmp_json"
|
|
176
|
+
echo " ]" >> "$tmp_json"
|
|
177
|
+
echo "}" >> "$tmp_json"
|
|
178
|
+
|
|
179
|
+
mkdir -p "$SKILL_DIR"
|
|
180
|
+
mv "$tmp_json" "$FILES_JSON"
|
|
181
|
+
|
|
182
|
+
# Report what was found
|
|
183
|
+
echo "Discovered ${#ordered_paths[@]} files. Index updated:"
|
|
184
|
+
echo " $FILES_JSON"
|
|
185
|
+
echo ""
|
|
186
|
+
echo "Files:"
|
|
187
|
+
for relpath in "${ordered_paths[@]}"; do
|
|
188
|
+
local category="${discovered_cat[$relpath]}"
|
|
189
|
+
local existing_importance
|
|
190
|
+
existing_importance="$(get_existing_importance "$relpath")"
|
|
191
|
+
local importance="$existing_importance"
|
|
192
|
+
printf " %-12s %-6s %s\n" "[$category]" "$importance" "$relpath"
|
|
193
|
+
done
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
print_manifest() {
|
|
197
|
+
if [ ! -f "$FILES_JSON" ]; then
|
|
198
|
+
echo "files.json not found. Run --discover first." >&2
|
|
199
|
+
exit 1
|
|
200
|
+
fi
|
|
201
|
+
python3 -c "
|
|
202
|
+
import json
|
|
203
|
+
with open('$FILES_JSON') as f:
|
|
204
|
+
data = json.load(f)
|
|
205
|
+
files = data.get('files', data) if isinstance(data, dict) else data
|
|
206
|
+
categories = data.get('categories', []) if isinstance(data, dict) else []
|
|
207
|
+
cat_map = {c['id']: c for c in categories}
|
|
208
|
+
out = [e for e in files if e.get('importance') != 'low']
|
|
209
|
+
for e in out:
|
|
210
|
+
e['doc'] = e['path'] + '.md'
|
|
211
|
+
cat = cat_map.get(e.get('category'), {})
|
|
212
|
+
e['category_priority'] = cat.get('priority', 'normal')
|
|
213
|
+
print(json.dumps(out, indent=2))
|
|
214
|
+
"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
generate_placeholders() {
|
|
218
|
+
if [ ! -f "$FILES_JSON" ]; then
|
|
219
|
+
echo "files.json not found. Run --discover first." >&2
|
|
220
|
+
exit 1
|
|
221
|
+
fi
|
|
222
|
+
python3 - <<'PY'
|
|
223
|
+
import json
|
|
224
|
+
import os
|
|
225
|
+
from pathlib import Path
|
|
226
|
+
|
|
227
|
+
repo_root = os.environ["REPO_ROOT"]
|
|
228
|
+
arch_dir = os.environ["ARCH_DIR"]
|
|
229
|
+
files_json = os.environ["FILES_JSON"]
|
|
230
|
+
|
|
231
|
+
with open(files_json) as f:
|
|
232
|
+
data = json.load(f)
|
|
233
|
+
|
|
234
|
+
files = data.get("files", data) if isinstance(data, dict) else data
|
|
235
|
+
tracked = [e for e in files if e.get("importance") != "low"]
|
|
236
|
+
tracked_set = {e["path"] for e in tracked}
|
|
237
|
+
|
|
238
|
+
Path(arch_dir).mkdir(parents=True, exist_ok=True)
|
|
239
|
+
created = 0
|
|
240
|
+
|
|
241
|
+
for entry in tracked:
|
|
242
|
+
source_path = os.path.join(repo_root, entry["path"])
|
|
243
|
+
if not os.path.isfile(source_path):
|
|
244
|
+
continue
|
|
245
|
+
doc_path = os.path.join(arch_dir, f"{entry['path']}.md")
|
|
246
|
+
if os.path.isfile(doc_path):
|
|
247
|
+
continue
|
|
248
|
+
Path(doc_path).parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
index_link = os.path.relpath(os.path.join(arch_dir, "architecture.index.md"), os.path.dirname(doc_path)).replace(os.sep, "/")
|
|
250
|
+
doc_lines = [
|
|
251
|
+
f"# `{entry['path']}`",
|
|
252
|
+
"",
|
|
253
|
+
"<!--",
|
|
254
|
+
f"source: {entry['path']}",
|
|
255
|
+
f"category: {entry.get('category', 'core')}",
|
|
256
|
+
f"importance: {entry['importance']}",
|
|
257
|
+
"-->",
|
|
258
|
+
"",
|
|
259
|
+
f"> [\u2190 Architecture Index]({index_link})",
|
|
260
|
+
"",
|
|
261
|
+
"## Meaning",
|
|
262
|
+
"",
|
|
263
|
+
"<!-- Agent: explain what this file means in the architecture, why it exists, and what would be lost if it disappeared. -->",
|
|
264
|
+
"",
|
|
265
|
+
"## Composition",
|
|
266
|
+
"",
|
|
267
|
+
"<!-- Agent: describe the important exports, control flow, data shapes, and invariants using source evidence. Include small real code samples when useful. -->",
|
|
268
|
+
"",
|
|
269
|
+
"## Dependencies",
|
|
270
|
+
"",
|
|
271
|
+
"<!-- Agent: list meaningful dependencies and what each relationship contributes. Link architecture docs where they exist. -->",
|
|
272
|
+
"",
|
|
273
|
+
"## Dependents",
|
|
274
|
+
"",
|
|
275
|
+
"<!-- Agent: identify the important consumers and why they depend on this file. -->",
|
|
276
|
+
"",
|
|
277
|
+
"## Notes",
|
|
278
|
+
"",
|
|
279
|
+
"<!-- Agent: record sturdy-bit contracts, edge cases, decisions, and documentation/CR implications. -->",
|
|
280
|
+
"",
|
|
281
|
+
]
|
|
282
|
+
with open(doc_path, "w", encoding="utf-8") as f:
|
|
283
|
+
f.write("\n".join(doc_lines))
|
|
284
|
+
created += 1
|
|
285
|
+
|
|
286
|
+
for existing in Path(arch_dir).rglob("*.md"):
|
|
287
|
+
if existing.name == "architecture.index.md":
|
|
288
|
+
continue
|
|
289
|
+
rel = os.path.relpath(existing, repo_root).replace(os.sep, "/")
|
|
290
|
+
if rel.startswith("."):
|
|
291
|
+
continue
|
|
292
|
+
src = rel[:-3]
|
|
293
|
+
if src not in tracked_set:
|
|
294
|
+
existing.unlink()
|
|
295
|
+
|
|
296
|
+
print(f"created_placeholders={created}")
|
|
297
|
+
PY
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
print_stale() {
|
|
301
|
+
if [ ! -f "$FILES_JSON" ]; then
|
|
302
|
+
echo "files.json not found. Run --discover first." >&2
|
|
303
|
+
exit 1
|
|
304
|
+
fi
|
|
305
|
+
python3 -c "
|
|
306
|
+
import json, os, subprocess, sys
|
|
307
|
+
|
|
308
|
+
files_json = '$FILES_JSON'
|
|
309
|
+
arch_dir = '$ARCH_DIR'
|
|
310
|
+
repo_root = '$REPO_ROOT'
|
|
311
|
+
index_file = os.path.join(arch_dir, 'architecture.index.md')
|
|
312
|
+
|
|
313
|
+
with open(files_json) as f:
|
|
314
|
+
data = json.load(f)
|
|
315
|
+
|
|
316
|
+
files = data.get('files', data) if isinstance(data, dict) else data
|
|
317
|
+
categories = data.get('categories', []) if isinstance(data, dict) else []
|
|
318
|
+
cat_map = {c['id']: c for c in categories}
|
|
319
|
+
|
|
320
|
+
def git_name_only(args):
|
|
321
|
+
try:
|
|
322
|
+
out = subprocess.check_output(args, cwd=repo_root, text=True).strip()
|
|
323
|
+
except subprocess.CalledProcessError:
|
|
324
|
+
return set()
|
|
325
|
+
return set(out.splitlines()) if out else set()
|
|
326
|
+
|
|
327
|
+
# Find the most recent commit that touched .agents/architecture/
|
|
328
|
+
try:
|
|
329
|
+
last_arch_commit = subprocess.check_output(
|
|
330
|
+
['git', 'log', '-1', '--format=%H', '--', '.agents/architecture/'],
|
|
331
|
+
cwd=repo_root, text=True
|
|
332
|
+
).strip()
|
|
333
|
+
except subprocess.CalledProcessError:
|
|
334
|
+
last_arch_commit = ''
|
|
335
|
+
|
|
336
|
+
# Get list of source files changed since that commit (or all if no commit)
|
|
337
|
+
changed_files = set()
|
|
338
|
+
if last_arch_commit:
|
|
339
|
+
changed_files.update(git_name_only(['git', 'diff', '--name-only', last_arch_commit, 'HEAD']))
|
|
340
|
+
# Also include uncommitted changes (staged + unstaged)
|
|
341
|
+
changed_files.update(git_name_only(['git', 'diff', '--name-only', 'HEAD']))
|
|
342
|
+
changed_files.update(git_name_only(['git', 'diff', '--name-only', '--cached']))
|
|
343
|
+
|
|
344
|
+
tracked = [e for e in files if e.get('importance') != 'low']
|
|
345
|
+
tracked_paths = {e['path'] for e in tracked}
|
|
346
|
+
|
|
347
|
+
results = []
|
|
348
|
+
removed = []
|
|
349
|
+
removed_docs = set()
|
|
350
|
+
total_non_low_existing = 0
|
|
351
|
+
|
|
352
|
+
for entry in tracked:
|
|
353
|
+
src_rel = entry['path']
|
|
354
|
+
src = os.path.join(repo_root, src_rel)
|
|
355
|
+
doc_rel = src_rel + '.md'
|
|
356
|
+
doc = os.path.join(arch_dir, doc_rel)
|
|
357
|
+
cat = cat_map.get(entry.get('category'), {})
|
|
358
|
+
category_priority = cat.get('priority', 'normal')
|
|
359
|
+
|
|
360
|
+
if os.path.isfile(src):
|
|
361
|
+
total_non_low_existing += 1
|
|
362
|
+
status = None
|
|
363
|
+
if not os.path.isfile(doc):
|
|
364
|
+
status = 'missing'
|
|
365
|
+
elif not last_arch_commit:
|
|
366
|
+
# No architecture commits exist yet — everything is stale
|
|
367
|
+
status = 'stale'
|
|
368
|
+
elif src_rel in changed_files:
|
|
369
|
+
status = 'stale'
|
|
370
|
+
|
|
371
|
+
if status:
|
|
372
|
+
e = {**entry, 'doc': doc_rel, 'status': status, 'category_priority': category_priority}
|
|
373
|
+
if status == 'missing' and src_rel in changed_files:
|
|
374
|
+
e['change_type'] = 'added'
|
|
375
|
+
results.append(e)
|
|
376
|
+
elif os.path.isfile(doc):
|
|
377
|
+
removed.append({
|
|
378
|
+
**entry,
|
|
379
|
+
'doc': doc_rel,
|
|
380
|
+
'status': 'removed',
|
|
381
|
+
'reason': 'source_deleted',
|
|
382
|
+
'category_priority': category_priority
|
|
383
|
+
})
|
|
384
|
+
removed_docs.add(doc_rel)
|
|
385
|
+
|
|
386
|
+
# Find orphan docs for deleted/moved/untracked source files.
|
|
387
|
+
for root, _, filenames in os.walk(arch_dir):
|
|
388
|
+
for name in filenames:
|
|
389
|
+
if not name.endswith('.md') or name == 'architecture.index.md':
|
|
390
|
+
continue
|
|
391
|
+
doc_abs = os.path.join(root, name)
|
|
392
|
+
doc_rel = os.path.relpath(doc_abs, arch_dir).replace(os.sep, '/')
|
|
393
|
+
if doc_rel in removed_docs:
|
|
394
|
+
continue
|
|
395
|
+
src_rel = doc_rel[:-3]
|
|
396
|
+
src = os.path.join(repo_root, src_rel)
|
|
397
|
+
if src_rel not in tracked_paths:
|
|
398
|
+
reason = 'untracked_source' if os.path.isfile(src) else 'source_deleted_or_moved'
|
|
399
|
+
removed.append({
|
|
400
|
+
'path': src_rel,
|
|
401
|
+
'doc': doc_rel,
|
|
402
|
+
'status': 'removed',
|
|
403
|
+
'reason': reason
|
|
404
|
+
})
|
|
405
|
+
removed_docs.add(doc_rel)
|
|
406
|
+
|
|
407
|
+
index_reasons = []
|
|
408
|
+
if not os.path.isfile(index_file):
|
|
409
|
+
index_reasons.append('index_missing')
|
|
410
|
+
if results or removed:
|
|
411
|
+
index_reasons.append('assets_changed')
|
|
412
|
+
if '.agents/skills/architecture-scanner/files.json' in changed_files:
|
|
413
|
+
index_reasons.append('category_config_changed')
|
|
414
|
+
if any(
|
|
415
|
+
p.startswith('.agents/architecture/')
|
|
416
|
+
and p != '.agents/architecture/architecture.index.md'
|
|
417
|
+
for p in changed_files
|
|
418
|
+
):
|
|
419
|
+
index_reasons.append('docs_changed')
|
|
420
|
+
|
|
421
|
+
output = {
|
|
422
|
+
'index': {
|
|
423
|
+
'status': 'stale' if index_reasons else 'fresh',
|
|
424
|
+
'reasons': index_reasons
|
|
425
|
+
},
|
|
426
|
+
'files': results,
|
|
427
|
+
'removed': removed
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Suggest full scan if >50% of existing non-low files need regeneration.
|
|
431
|
+
if total_non_low_existing > 0 and len(results) > total_non_low_existing * 0.5:
|
|
432
|
+
output['suggestion'] = 'full_scan'
|
|
433
|
+
output['message'] = (
|
|
434
|
+
f'{len(results)} of {total_non_low_existing} documented files need updates. '
|
|
435
|
+
'Consider running --manifest (or --full) for a full scan instead.'
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
print(json.dumps(output, indent=2))
|
|
439
|
+
"
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
verify_architecture() {
|
|
443
|
+
if [ ! -f "$FILES_JSON" ]; then
|
|
444
|
+
echo "files.json not found. Run --discover first." >&2
|
|
445
|
+
exit 1
|
|
446
|
+
fi
|
|
447
|
+
python3 - <<'PY'
|
|
448
|
+
import json
|
|
449
|
+
import os
|
|
450
|
+
|
|
451
|
+
repo_root = os.environ["REPO_ROOT"]
|
|
452
|
+
arch_dir = os.environ["ARCH_DIR"]
|
|
453
|
+
files_json = os.environ["FILES_JSON"]
|
|
454
|
+
|
|
455
|
+
with open(files_json) as f:
|
|
456
|
+
data = json.load(f)
|
|
457
|
+
|
|
458
|
+
files = data.get("files", data) if isinstance(data, dict) else data
|
|
459
|
+
tracked = [entry for entry in files if entry.get("importance") != "low"]
|
|
460
|
+
tracked_docs = {f"{entry['path']}.md" for entry in tracked}
|
|
461
|
+
tracked_sources = {entry["path"] for entry in tracked}
|
|
462
|
+
|
|
463
|
+
index_file = os.path.join(arch_dir, "architecture.index.md")
|
|
464
|
+
index_lines = []
|
|
465
|
+
if os.path.isfile(index_file):
|
|
466
|
+
with open(index_file, "r", encoding="utf-8") as f:
|
|
467
|
+
index_lines = f.read().splitlines()
|
|
468
|
+
|
|
469
|
+
results = {
|
|
470
|
+
"status": "ok",
|
|
471
|
+
"missing_docs": [],
|
|
472
|
+
"orphan_docs": [],
|
|
473
|
+
"index_missing": False,
|
|
474
|
+
"docs_missing_backlink": [],
|
|
475
|
+
"index_unlinked_docs": []
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if not os.path.isfile(index_file):
|
|
479
|
+
results["status"] = "warn"
|
|
480
|
+
results["index_missing"] = True
|
|
481
|
+
else:
|
|
482
|
+
indexed_docs = set()
|
|
483
|
+
for line in index_lines:
|
|
484
|
+
line = line.strip()
|
|
485
|
+
if line.startswith("- [") and "(" in line and ")" in line:
|
|
486
|
+
start = line.find("(")
|
|
487
|
+
end = line.find(")", start + 1)
|
|
488
|
+
if start != -1 and end != -1:
|
|
489
|
+
link = line[start + 1:end]
|
|
490
|
+
if link.startswith("./"):
|
|
491
|
+
link = link[2:]
|
|
492
|
+
if link.endswith(".md"):
|
|
493
|
+
indexed_docs.add(link)
|
|
494
|
+
|
|
495
|
+
for relpath in sorted(tracked_docs):
|
|
496
|
+
doc = os.path.join(arch_dir, relpath)
|
|
497
|
+
if not os.path.isfile(doc):
|
|
498
|
+
results["status"] = "warn"
|
|
499
|
+
results["missing_docs"].append(relpath)
|
|
500
|
+
continue
|
|
501
|
+
with open(doc, "r", encoding="utf-8") as f:
|
|
502
|
+
text = f.read()
|
|
503
|
+
if "Architecture Index" not in text:
|
|
504
|
+
results["status"] = "warn"
|
|
505
|
+
results["docs_missing_backlink"].append(relpath)
|
|
506
|
+
|
|
507
|
+
for root, _, filenames in os.walk(arch_dir):
|
|
508
|
+
for name in filenames:
|
|
509
|
+
if not name.endswith(".md") or name == "architecture.index.md":
|
|
510
|
+
continue
|
|
511
|
+
rel = os.path.relpath(os.path.join(root, name), arch_dir).replace(os.sep, "/")
|
|
512
|
+
if rel not in tracked_docs:
|
|
513
|
+
results["status"] = "warn"
|
|
514
|
+
results["orphan_docs"].append(rel)
|
|
515
|
+
continue
|
|
516
|
+
if rel not in indexed_docs:
|
|
517
|
+
results["status"] = "warn"
|
|
518
|
+
results["index_unlinked_docs"].append(rel)
|
|
519
|
+
|
|
520
|
+
print(json.dumps(results, indent=2))
|
|
521
|
+
PY
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
generate_index() {
|
|
525
|
+
mkdir -p "$ARCH_DIR"
|
|
526
|
+
local index_file="$ARCH_DIR/architecture.index.md"
|
|
527
|
+
if [ -f "$index_file" ]; then
|
|
528
|
+
echo "Preserved existing index: $index_file"
|
|
529
|
+
return
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
python3 - <<'PY'
|
|
533
|
+
import json
|
|
534
|
+
import os
|
|
535
|
+
from pathlib import Path
|
|
536
|
+
|
|
537
|
+
repo_root = os.environ["REPO_ROOT"]
|
|
538
|
+
arch_dir = Path(os.environ["ARCH_DIR"])
|
|
539
|
+
files_json = os.environ["FILES_JSON"]
|
|
540
|
+
index_path = arch_dir / "architecture.index.md"
|
|
541
|
+
|
|
542
|
+
with open(files_json) as f:
|
|
543
|
+
data = json.load(f)
|
|
544
|
+
|
|
545
|
+
files = data.get("files", data) if isinstance(data, dict) else data
|
|
546
|
+
categories = data.get("categories", []) if isinstance(data, dict) else []
|
|
547
|
+
category_map = {item["id"]: item for item in categories}
|
|
548
|
+
tracked = [item for item in files if item.get("importance") != "low"]
|
|
549
|
+
|
|
550
|
+
lines = [
|
|
551
|
+
"# Architecture Index",
|
|
552
|
+
"",
|
|
553
|
+
"> Structural index generated by the architecture-scanner skill.",
|
|
554
|
+
"> The agent owns the repository overview, category concepts, and file descriptions.",
|
|
555
|
+
"",
|
|
556
|
+
"## Repository overview",
|
|
557
|
+
"",
|
|
558
|
+
"<!-- Agent: explain what this repository is, the user/problem it serves, its major runtime surfaces, and the path of data through them. Ground this in package manifests and source. -->",
|
|
559
|
+
"",
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
ordered_categories = sorted(categories, key=lambda item: item.get("order", 99))
|
|
563
|
+
for category in ordered_categories:
|
|
564
|
+
members = [item for item in tracked if item.get("category") == category.get("id")]
|
|
565
|
+
if not members:
|
|
566
|
+
continue
|
|
567
|
+
lines.extend([
|
|
568
|
+
f"## {category.get('name', category.get('id'))}",
|
|
569
|
+
"",
|
|
570
|
+
f"<!-- Agent: explain the concept represented by this category, its role in the repository, and how it relates to other categories. Priority: {category.get('priority', 'normal')}. -->",
|
|
571
|
+
"",
|
|
572
|
+
])
|
|
573
|
+
for item in sorted(members, key=lambda value: value["path"]):
|
|
574
|
+
doc = f"{item['path']}.md"
|
|
575
|
+
lines.append(f"- [`{item['path']}`](./{doc}) <!-- Agent: add a concise meaning statement. -->")
|
|
576
|
+
lines.append("")
|
|
577
|
+
|
|
578
|
+
index_path.write_text("\n".join(lines), encoding="utf-8")
|
|
579
|
+
print(f"Created index skeleton: {index_path}")
|
|
580
|
+
PY
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
build_enrich_plan() {
|
|
584
|
+
local enrich_mode="${1:-changed}"
|
|
585
|
+
if [ ! -f "$FILES_JSON" ]; then
|
|
586
|
+
echo "files.json not found. Run --discover first." >&2
|
|
587
|
+
exit 1
|
|
588
|
+
fi
|
|
589
|
+
|
|
590
|
+
ARCH_ENRICH_MODE="$enrich_mode" python3 - <<'PY'
|
|
591
|
+
import json
|
|
592
|
+
import os
|
|
593
|
+
import subprocess
|
|
594
|
+
|
|
595
|
+
repo_root = os.environ["REPO_ROOT"]
|
|
596
|
+
arch_dir = os.environ["ARCH_DIR"]
|
|
597
|
+
files_json = os.environ["FILES_JSON"]
|
|
598
|
+
mode = os.environ.get("ARCH_ENRICH_MODE", "changed")
|
|
599
|
+
|
|
600
|
+
with open(files_json) as f:
|
|
601
|
+
data = json.load(f)
|
|
602
|
+
|
|
603
|
+
files = data.get("files", data) if isinstance(data, dict) else data
|
|
604
|
+
tracked = [e for e in files if e.get("importance") != "low"]
|
|
605
|
+
tracked_paths = {entry["path"] for entry in tracked}
|
|
606
|
+
|
|
607
|
+
def changed_files(since_commit):
|
|
608
|
+
if not since_commit:
|
|
609
|
+
return set()
|
|
610
|
+
changed = set()
|
|
611
|
+
for args in (
|
|
612
|
+
["git", "diff", "--name-only", since_commit, "HEAD"],
|
|
613
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
614
|
+
["git", "diff", "--name-only", "--cached"],
|
|
615
|
+
):
|
|
616
|
+
try:
|
|
617
|
+
out = subprocess.check_output(args, cwd=repo_root, text=True).strip()
|
|
618
|
+
except subprocess.CalledProcessError:
|
|
619
|
+
continue
|
|
620
|
+
if out:
|
|
621
|
+
changed.update(out.splitlines())
|
|
622
|
+
return changed
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
last_arch_commit = subprocess.check_output(
|
|
627
|
+
["git", "log", "-1", "--format=%H", "--", ".agents/architecture/"],
|
|
628
|
+
cwd=repo_root,
|
|
629
|
+
text=True,
|
|
630
|
+
).strip()
|
|
631
|
+
except subprocess.CalledProcessError:
|
|
632
|
+
last_arch_commit = ""
|
|
633
|
+
|
|
634
|
+
changed = changed_files(last_arch_commit)
|
|
635
|
+
if not changed:
|
|
636
|
+
changed = set()
|
|
637
|
+
|
|
638
|
+
create = []
|
|
639
|
+
update = []
|
|
640
|
+
delete = []
|
|
641
|
+
|
|
642
|
+
for entry in tracked:
|
|
643
|
+
path = entry["path"]
|
|
644
|
+
category = entry.get("category", "core")
|
|
645
|
+
importance = entry.get("importance", "medium")
|
|
646
|
+
source_path = os.path.join(repo_root, path)
|
|
647
|
+
doc_rel = f"{path}.md"
|
|
648
|
+
doc_path = os.path.join(arch_dir, doc_rel)
|
|
649
|
+
|
|
650
|
+
if not os.path.isfile(source_path):
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
task = {
|
|
654
|
+
"path": path,
|
|
655
|
+
"doc": doc_rel,
|
|
656
|
+
"category": category,
|
|
657
|
+
"importance": importance,
|
|
658
|
+
"enrichment_focus": [
|
|
659
|
+
"Write the file's architectural meaning and why it exists.",
|
|
660
|
+
"Explain behavior, data flow, contracts, and important relationships from source evidence.",
|
|
661
|
+
"Record edge cases, sturdy-bit implications, and relevant records."
|
|
662
|
+
],
|
|
663
|
+
"status": "",
|
|
664
|
+
"reason": "",
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
try:
|
|
668
|
+
with open(doc_path, "r", encoding="utf-8") as doc_file:
|
|
669
|
+
existing_text = doc_file.read()
|
|
670
|
+
except Exception:
|
|
671
|
+
existing_text = ""
|
|
672
|
+
|
|
673
|
+
if existing_text:
|
|
674
|
+
lines = existing_text.splitlines()
|
|
675
|
+
for i, line in enumerate(lines):
|
|
676
|
+
if line.startswith("## Goal") and i + 1 < len(lines):
|
|
677
|
+
task["existing_goal_preview"] = lines[i + 1].strip()
|
|
678
|
+
break
|
|
679
|
+
|
|
680
|
+
if mode == "all":
|
|
681
|
+
if not os.path.isfile(doc_path):
|
|
682
|
+
task["status"] = "create"
|
|
683
|
+
task["reason"] = "missing_doc"
|
|
684
|
+
create.append(task)
|
|
685
|
+
else:
|
|
686
|
+
task["status"] = "update"
|
|
687
|
+
task["reason"] = "full_rewrite"
|
|
688
|
+
update.append(task)
|
|
689
|
+
else:
|
|
690
|
+
if not os.path.isfile(doc_path):
|
|
691
|
+
task["status"] = "create"
|
|
692
|
+
task["reason"] = "missing_doc"
|
|
693
|
+
create.append(task)
|
|
694
|
+
elif path in changed:
|
|
695
|
+
task["status"] = "update"
|
|
696
|
+
task["reason"] = "source_changed"
|
|
697
|
+
update.append(task)
|
|
698
|
+
|
|
699
|
+
for root, _, names in os.walk(arch_dir):
|
|
700
|
+
for name in names:
|
|
701
|
+
if not name.endswith(".md") or name == "architecture.index.md":
|
|
702
|
+
continue
|
|
703
|
+
rel = os.path.relpath(os.path.join(root, name), arch_dir).replace(os.sep, "/")
|
|
704
|
+
src_rel = rel[:-3]
|
|
705
|
+
if src_rel not in tracked_paths and os.path.isfile(os.path.join(repo_root, src_rel)):
|
|
706
|
+
delete.append({"path": src_rel, "doc": rel, "reason": "not_tracked"})
|
|
707
|
+
elif src_rel not in tracked_paths:
|
|
708
|
+
delete.append({"path": src_rel, "doc": rel, "reason": "source_deleted_or_untracked"})
|
|
709
|
+
|
|
710
|
+
output = {
|
|
711
|
+
"mode": "agent_enrichment",
|
|
712
|
+
"status": "ready",
|
|
713
|
+
"count": {
|
|
714
|
+
"create": len(create),
|
|
715
|
+
"update": len(update),
|
|
716
|
+
"delete": len(delete),
|
|
717
|
+
},
|
|
718
|
+
"notes": "The scanner provides structure only. The agent must read source and write the prose.",
|
|
719
|
+
"create": create,
|
|
720
|
+
"update": update,
|
|
721
|
+
"delete": delete,
|
|
722
|
+
"next_steps": [
|
|
723
|
+
"For each path in create/update, read the source and write the complete architecture document.",
|
|
724
|
+
"For each entry in delete, remove the stale architecture doc file.",
|
|
725
|
+
"Read and update architecture.index.md with the repository overview, category concepts, and file meanings; preserve authored prose.",
|
|
726
|
+
"Run .agents/skills/architecture-scanner/scan.sh --verify to confirm consistency.",
|
|
727
|
+
],
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
print(json.dumps(output, indent=2))
|
|
731
|
+
PY
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
run_steps() {
|
|
735
|
+
local steps_csv="${1:-}"
|
|
736
|
+
if [ -z "$steps_csv" ]; then
|
|
737
|
+
echo "--steps requires a comma-separated list" >&2
|
|
738
|
+
exit 1
|
|
739
|
+
fi
|
|
740
|
+
|
|
741
|
+
local old_ifs="$IFS"
|
|
742
|
+
IFS=','
|
|
743
|
+
for step in $steps_csv; do
|
|
744
|
+
case "$step" in
|
|
745
|
+
discover)
|
|
746
|
+
discover_files
|
|
747
|
+
;;
|
|
748
|
+
manifest)
|
|
749
|
+
print_manifest
|
|
750
|
+
;;
|
|
751
|
+
docs)
|
|
752
|
+
generate_placeholders
|
|
753
|
+
;;
|
|
754
|
+
stale)
|
|
755
|
+
print_stale
|
|
756
|
+
;;
|
|
757
|
+
enrich)
|
|
758
|
+
build_enrich_plan changed
|
|
759
|
+
;;
|
|
760
|
+
enrich-all)
|
|
761
|
+
build_enrich_plan all
|
|
762
|
+
;;
|
|
763
|
+
index)
|
|
764
|
+
generate_index
|
|
765
|
+
;;
|
|
766
|
+
verify)
|
|
767
|
+
verify_architecture
|
|
768
|
+
;;
|
|
769
|
+
*)
|
|
770
|
+
echo "Unknown step: $step" >&2
|
|
771
|
+
echo "Supported: discover, manifest, docs, stale, enrich, enrich-all, index, verify" >&2
|
|
772
|
+
exit 1
|
|
773
|
+
;;
|
|
774
|
+
esac
|
|
775
|
+
done
|
|
776
|
+
IFS="$old_ifs"
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
case "${1:-}" in
|
|
780
|
+
--discover)
|
|
781
|
+
discover_files
|
|
782
|
+
;;
|
|
783
|
+
--manifest)
|
|
784
|
+
print_manifest
|
|
785
|
+
;;
|
|
786
|
+
--full)
|
|
787
|
+
discover_files
|
|
788
|
+
generate_placeholders
|
|
789
|
+
generate_index
|
|
790
|
+
verify_architecture
|
|
791
|
+
;;
|
|
792
|
+
--enrich)
|
|
793
|
+
build_enrich_plan changed
|
|
794
|
+
;;
|
|
795
|
+
--enrich-all)
|
|
796
|
+
build_enrich_plan all
|
|
797
|
+
;;
|
|
798
|
+
--stale)
|
|
799
|
+
print_stale
|
|
800
|
+
;;
|
|
801
|
+
--verify)
|
|
802
|
+
verify_architecture
|
|
803
|
+
;;
|
|
804
|
+
--index)
|
|
805
|
+
generate_index
|
|
806
|
+
;;
|
|
807
|
+
--steps)
|
|
808
|
+
run_steps "${2:-}"
|
|
809
|
+
;;
|
|
810
|
+
*)
|
|
811
|
+
echo "Usage: $0 [--discover | --manifest | --full | --enrich | --enrich-all | --stale | --verify | --index | --steps]"
|
|
812
|
+
echo " --discover Scan repo and create/update files.json (preserves priorities)"
|
|
813
|
+
echo " --manifest Print JSON manifest of non-low files to document (full scan)"
|
|
814
|
+
echo " --full Discover + placeholders + index skeleton + verify"
|
|
815
|
+
echo " --enrich Generate an agent-only enrichment worklist (changed files only)"
|
|
816
|
+
echo " --enrich-all Generate an agent-only enrichment worklist for all non-low files"
|
|
817
|
+
echo " --stale Print manifest of files changed since last architecture commit (incremental)"
|
|
818
|
+
echo " --verify Validate manifest coverage and index/doc links"
|
|
819
|
+
echo " --index Create the index skeleton if it does not exist"
|
|
820
|
+
echo " --steps LIST Run comma-separated steps"
|
|
821
|
+
exit 1
|
|
822
|
+
;;
|
|
823
|
+
esac
|