@jenga-ai/agent 3.1.1 → 3.4.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 +52 -12
- package/agents/developer.md +31 -16
- package/agents/scrum-master.md +18 -17
- package/agents/tester.md +25 -15
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +7 -2
- package/package.json +21 -2
- package/project/app/api/lib/resolve-project-root.js +120 -0
- package/project/app/api/package.json +16 -0
- package/project/app/api/parsers/architecture.js +72 -0
- package/project/app/api/parsers/board.js +141 -0
- package/project/app/api/parsers/documentation.js +125 -0
- package/project/app/api/parsers/git-log.js +52 -0
- package/project/app/api/parsers/ideas.js +62 -0
- package/project/app/api/parsers/knowledge-graph.js +73 -0
- package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
- package/project/app/api/parsers/rapports.js +148 -0
- package/project/app/api/parsers/todo.js +179 -0
- package/project/app/api/response.js +47 -0
- package/project/app/api/routes/architecture.js +23 -0
- package/project/app/api/routes/board.js +46 -0
- package/project/app/api/routes/documentation.js +24 -0
- package/project/app/api/routes/health.js +25 -0
- package/project/app/api/routes/history.js +55 -0
- package/project/app/api/routes/rapports.js +24 -0
- package/project/app/api/scripts/capture-snapshot.js +294 -0
- package/project/app/api/server.js +112 -0
- package/project/app/api/types.js +40 -0
- package/project/app/package.json +21 -0
- package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
- package/project/app/ui/dist/index.html +13 -0
- package/project/app/ui/package.json +23 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
- package/project/app/ui/scripts/dashboard-open.cjs +88 -0
- package/project/app/ui/scripts/dashboard-start.cjs +87 -0
- package/scripts/acquire-concurrency-slot.sh +220 -0
- package/scripts/audit-twin-divergence.sh +625 -0
- package/scripts/check-public-playbook-steps.sh +136 -0
- package/scripts/compute-deploy-reconcile.sh +439 -0
- package/scripts/jenga-permission-level-switch.sh +19 -3
- package/scripts/mark-deployed.sh +532 -0
- package/scripts/populate-knowledge-graph.js +429 -0
- package/scripts/release-concurrency-slot.sh +129 -0
- package/scripts/validate-board.sh +60 -2
- package/scripts/verify-consumer-install.sh +470 -0
- package/skills/j-close-story/SKILL.md +1 -1
- package/skills/j-cloud-connect/SKILL.md +95 -0
- package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
- package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
- package/skills/j-dashboard/SKILL.md +144 -0
- package/skills/j-dashboard/scripts/launch.sh +121 -0
- package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
- package/skills/j-dashboard/scripts/snapshot.sh +267 -0
- package/skills/j-dashboard-share/SKILL.md +96 -0
- package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
- package/skills/j-do/SKILL.md +19 -19
- package/skills/j-doc-sync/SKILL.md +12 -1
- package/skills/j-idea/SKILL.md +1 -1
- package/skills/j-init/SKILL.md +5 -4
- package/skills/j-init/assets/directory_structure.txt +1 -0
- package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
- package/skills/j-init/scripts/init.sh +13 -2
- package/skills/j-playbook/SKILL.md +93 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-proceed/SKILL.md +1 -1
- package/skills/j-publish/SKILL.md +1 -1
- package/skills/j-publish/adapters/npm-ci.md +29 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
- package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
- package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
- package/skills/j-reconcile/SKILL.md +1 -0
- package/skills/j-redo/SKILL.md +1 -1
- package/skills/j-status/SKILL.md +12 -0
- package/skills/j-todo/SKILL.md +2 -2
- package/skills/j-uncharted/SKILL.md +8 -7
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
- package/skills/jenga/SKILL.md +55 -16
- package/skills/jenga/playbooks/idea-to-committed.json +20 -0
- package/skills/jenga/playbooks/schema.json +1 -1
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +968 -41
- package/skills/jenga/scripts/match-playbook.sh +1 -1
- package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
- package/skills/jenga/scripts/run-playbook-step.sh +535 -42
- package/skills/jenga-permission-level/SKILL.md +4 -4
- package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
- package/templates/playbook-types.json +8 -0
- package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/audit-twin-divergence.sh — report UNEXPECTED content divergence between
|
|
3
|
+
# each skills/<name>/ source and its skills/j-<name>/ twin (E50_S19_T01).
|
|
4
|
+
#
|
|
5
|
+
# Why this exists
|
|
6
|
+
# ---------------
|
|
7
|
+
# Every skills/j-<name>/ twin differs from its skills/<name>/ source. Most of that
|
|
8
|
+
# difference is produced deliberately by scripts/generate-j-alias.sh and is CORRECT.
|
|
9
|
+
# Some of it is missing content — a fix that landed in a bare directory and never
|
|
10
|
+
# reached its twin. That second class is invisible in this repo (a bare path still
|
|
11
|
+
# resolves, because the bare directory is still here) but is fatal downstream:
|
|
12
|
+
# .publicignore blocks the bare-name directories, so the public mirror ships ONLY the
|
|
13
|
+
# twins. A gap in a twin is a gap for every consumer.
|
|
14
|
+
#
|
|
15
|
+
# Confirmed instance, and this script's primary fixture: E22_S09_T07 (commit f3daaa8)
|
|
16
|
+
# added parse_stage_id_from_text() to skills/publish/scripts/npm_stage_pipeline.sh and
|
|
17
|
+
# a CI-log capture block to skills/publish/adapters/npm-ci.md. Neither reached
|
|
18
|
+
# skills/j-publish/. A public-mirror consumer reported it as "E22_S09_T07 has not been
|
|
19
|
+
# addressed" while the board read Merged. Both were right.
|
|
20
|
+
#
|
|
21
|
+
# This script is READ-ONLY. It reports; it never writes. Back-fill is E50_S19_T02.
|
|
22
|
+
#
|
|
23
|
+
# Classification rules — derived from the generator, not from observed diffs
|
|
24
|
+
# -------------------------------------------------------------------------
|
|
25
|
+
# Every rule below is a port of what scripts/generate-j-alias.sh actually does, with
|
|
26
|
+
# its source line references. Inferring the rules from observed diffs instead would
|
|
27
|
+
# let a transform the generator performs be misread as a gap and — far worse — let a
|
|
28
|
+
# genuine gap be waved through as "probably just a path rewrite."
|
|
29
|
+
#
|
|
30
|
+
# Transform 1 (generate-j-alias.sh L180-182): full rmtree + copytree of
|
|
31
|
+
# skills/<name>/ into skills/j-<name>/.
|
|
32
|
+
# => Audited as a FILE-SET comparison. A file in the source with no counterpart in
|
|
33
|
+
# the twin is MISSING_FILE; the reverse is EXTRA_FILE. Both are unexpected.
|
|
34
|
+
#
|
|
35
|
+
# Transform 2 (L176-177, L184-195): literal substring replace of "skills/<name>/"
|
|
36
|
+
# with "skills/j-<name>/" across every copied file. This is also what rewrites the
|
|
37
|
+
# .claude/skills/<name>/ and .agents/skills/<name>/ mirrored-install fallback
|
|
38
|
+
# paths — they fall out of the same replace.
|
|
39
|
+
# => Audited by reconstructing the expected twin file as
|
|
40
|
+
# source.replace("skills/<name>/", "skills/j-<name>/") and comparing. A
|
|
41
|
+
# mismatch is CONTENT_DRIFT. Note this is directional in both senses: a twin
|
|
42
|
+
# that still carries a bare "skills/<name>/" self-reference fails here too,
|
|
43
|
+
# which is the same defect class E50_S11 exists to remove.
|
|
44
|
+
#
|
|
45
|
+
# Transform 3 (L262): frontmatter rewrite name: j.<name> -> name: j.j-<name>.
|
|
46
|
+
# => TOLERATED IN BOTH DIRECTIONS. The generator emits j.j-<name>, but E50_S10's
|
|
47
|
+
# settled contract keeps the canonical twin at j.<name>, and E50_S15 lands that
|
|
48
|
+
# rewrite. This audit must not force either value, so it accepts j.j-<name> or
|
|
49
|
+
# j.<name> and reports neither. Any OTHER name: value is unexpected.
|
|
50
|
+
#
|
|
51
|
+
# Transform 4 (L264-308): description: reframed as a polyfill alias; keywords: gets
|
|
52
|
+
# j-<name> and polyfill appended (list created if absent); examples: gets a
|
|
53
|
+
# "j-<name>" entry appended if that list already exists; and a generated
|
|
54
|
+
# lockstep/duplicate-alias note inserted just below the body's first H1.
|
|
55
|
+
# => Reconstructed byte-for-byte and compared. A mismatch is SKILL_DRIFT.
|
|
56
|
+
#
|
|
57
|
+
# Anything a transform above does not explain is UNEXPECTED and is a candidate content
|
|
58
|
+
# gap. That is the entire output of this script.
|
|
59
|
+
#
|
|
60
|
+
# Scope decisions
|
|
61
|
+
# ---------------
|
|
62
|
+
# Three bare-name directories have no twin and are skipped, reported as informational:
|
|
63
|
+
# - skills/index/ — not a skill, has no SKILL.md; the generator
|
|
64
|
+
# hard-errors on it (L118-121).
|
|
65
|
+
# - skills/jenga/ — hard-excluded from twin generation (L110-113,
|
|
66
|
+
# - skills/jenga-permission-level/ E50_S06_T01). A twin must never exist.
|
|
67
|
+
# A j-<name> directory with no bare source is likewise skipped and reported: it is
|
|
68
|
+
# already at its sole canonical name (e.g. skills/j-playbook/, renamed by E50_S13), so
|
|
69
|
+
# there is no source to diverge from.
|
|
70
|
+
#
|
|
71
|
+
# The skills/init/ <-> skills/j-init/ pair IS AUDITED. This is a deliberate decision,
|
|
72
|
+
# recorded here because the generator refuses that pair (L97-100) as hand-maintained
|
|
73
|
+
# and predating it, so one could argue for skipping it outright. Skipping is the wrong
|
|
74
|
+
# call: j-init's scripts/ tree is shipped to the public mirror exactly like every other
|
|
75
|
+
# twin's, so it can carry exactly the content gap this script hunts, and excluding it
|
|
76
|
+
# would create a blind spot precisely where nothing else is watching.
|
|
77
|
+
#
|
|
78
|
+
# Because the generator never runs on that pair, its SKILL.md's generator-authored
|
|
79
|
+
# regions are hand-written by design and reconstruction is not the specification for
|
|
80
|
+
# them. For a hand-maintained pair only, SKILL.md is compared as:
|
|
81
|
+
# - frontmatter: every field EXCEPT name/description/keywords/examples must match the
|
|
82
|
+
# source after transform 2 (this still catches, say, a frontmatter field added to
|
|
83
|
+
# the source and never mirrored);
|
|
84
|
+
# - body: compared from the first "## " heading onward, since the H1 and the
|
|
85
|
+
# alias note above it are hand-authored.
|
|
86
|
+
# The preamble between H1 and the first "## " heading is therefore not compared — so
|
|
87
|
+
# to stop that becoming a silent blind spot, the script asserts the SOURCE's preamble
|
|
88
|
+
# is empty and reports PREAMBLE_UNCOMPARED if it ever stops being empty. Everything
|
|
89
|
+
# outside SKILL.md in that pair is compared with no tolerance at all.
|
|
90
|
+
#
|
|
91
|
+
# Usage:
|
|
92
|
+
# scripts/audit-twin-divergence.sh [repo-root] [--diff]
|
|
93
|
+
#
|
|
94
|
+
# repo-root Root of the tree to audit. Defaults to $JENGA_PROJECT_DIR. Passing it
|
|
95
|
+
# explicitly is what lets E50_S19_T03 run this against a
|
|
96
|
+
# public-mirror-shaped tree (bare-name directories absent) rather than
|
|
97
|
+
# only against the private repo, where the surviving bare directories
|
|
98
|
+
# mask this whole defect class.
|
|
99
|
+
# --diff Also print a unified diff for every unexpected divergence.
|
|
100
|
+
# --min-pairs <n>
|
|
101
|
+
# Fail unless at least <n> twinned pairs were actually audited.
|
|
102
|
+
# Default 0 (no constraint).
|
|
103
|
+
#
|
|
104
|
+
# This exists because a clean exit here is otherwise fail-open in a
|
|
105
|
+
# specific and easily-missed way: a tree containing NO twinned pairs at
|
|
106
|
+
# all audits nothing, finds nothing, and reports success. E50_S19_T03 hit
|
|
107
|
+
# exactly that — run against a public-mirror-shaped tree, where the
|
|
108
|
+
# bare-name sources are stripped by .publicignore, this script audits 0
|
|
109
|
+
# pairs and exits 0. That is a true statement and a worthless one, and it
|
|
110
|
+
# would read as a green gate to anyone not checking the pair count. Pass
|
|
111
|
+
# --min-pairs to assert the audit examined the population you expected.
|
|
112
|
+
#
|
|
113
|
+
# Exit codes:
|
|
114
|
+
# 0 no unexpected divergence — usable as a gate
|
|
115
|
+
# 1 at least one unexpected divergence
|
|
116
|
+
# 2 usage or environment error, including --min-pairs not being met
|
|
117
|
+
|
|
118
|
+
set -euo pipefail
|
|
119
|
+
|
|
120
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
121
|
+
|
|
122
|
+
REPO_ROOT=""
|
|
123
|
+
OPT_DIFF="0"
|
|
124
|
+
OPT_MIN_PAIRS="0"
|
|
125
|
+
|
|
126
|
+
usage() {
|
|
127
|
+
echo "Usage: $(basename "$0") [repo-root] [--diff] [--min-pairs <n>]" >&2
|
|
128
|
+
echo " Reports unexpected divergence between skills/<name>/ and skills/j-<name>/." >&2
|
|
129
|
+
echo " Exits 0 when none remain, 1 when any do, 2 on a usage/environment error." >&2
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
while [ "$#" -gt 0 ]; do
|
|
133
|
+
case "$1" in
|
|
134
|
+
-h|--help)
|
|
135
|
+
usage
|
|
136
|
+
exit 0
|
|
137
|
+
;;
|
|
138
|
+
--diff)
|
|
139
|
+
OPT_DIFF="1"
|
|
140
|
+
;;
|
|
141
|
+
--min-pairs)
|
|
142
|
+
shift
|
|
143
|
+
if [ "$#" -eq 0 ]; then
|
|
144
|
+
echo "audit-twin-divergence.sh: error: --min-pairs requires a number" >&2
|
|
145
|
+
usage
|
|
146
|
+
exit 2
|
|
147
|
+
fi
|
|
148
|
+
case "$1" in
|
|
149
|
+
''|*[!0-9]*)
|
|
150
|
+
echo "audit-twin-divergence.sh: error: --min-pairs expects a non-negative integer, got '$1'" >&2
|
|
151
|
+
exit 2
|
|
152
|
+
;;
|
|
153
|
+
esac
|
|
154
|
+
OPT_MIN_PAIRS="$1"
|
|
155
|
+
;;
|
|
156
|
+
-*)
|
|
157
|
+
echo "audit-twin-divergence.sh: error: unknown option '$1'" >&2
|
|
158
|
+
usage
|
|
159
|
+
exit 2
|
|
160
|
+
;;
|
|
161
|
+
*)
|
|
162
|
+
if [ -n "$REPO_ROOT" ]; then
|
|
163
|
+
echo "audit-twin-divergence.sh: error: more than one repo-root given ('$REPO_ROOT', '$1')" >&2
|
|
164
|
+
usage
|
|
165
|
+
exit 2
|
|
166
|
+
fi
|
|
167
|
+
REPO_ROOT="$1"
|
|
168
|
+
;;
|
|
169
|
+
esac
|
|
170
|
+
shift
|
|
171
|
+
done
|
|
172
|
+
|
|
173
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
174
|
+
# Only fall back to the resolver when no explicit root was given, so this script
|
|
175
|
+
# stays runnable against a tree that is not a Jenga project checkout at all (the
|
|
176
|
+
# mirror-shaped-tree case in E50_S19_T03).
|
|
177
|
+
# SC1091 is disabled rather than left as an accepted info-level finding: this
|
|
178
|
+
# script's AC requires a clean default-level shellcheck run, and the sourced file
|
|
179
|
+
# does exist — shellcheck simply will not follow it without -x.
|
|
180
|
+
# shellcheck source=lib/resolve-project-dir.sh disable=SC1091
|
|
181
|
+
source "$SCRIPT_DIR/../lib/resolve-project-dir.sh"
|
|
182
|
+
REPO_ROOT="$JENGA_PROJECT_DIR"
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
if [ ! -d "$REPO_ROOT" ]; then
|
|
186
|
+
echo "audit-twin-divergence.sh: error: repo root '$REPO_ROOT' is not a directory." >&2
|
|
187
|
+
exit 2
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
if [ ! -d "$REPO_ROOT/skills" ]; then
|
|
191
|
+
echo "audit-twin-divergence.sh: error: '$REPO_ROOT' has no skills/ directory — not a tree this audit understands." >&2
|
|
192
|
+
exit 2
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
export REPO_ROOT OPT_DIFF OPT_MIN_PAIRS
|
|
196
|
+
|
|
197
|
+
# Implemented as a python heredoc for the same reason generate-j-alias.sh is: the
|
|
198
|
+
# classification is a byte-exact port of that generator's frontmatter/body rewrite,
|
|
199
|
+
# which is written in python. Keeping both in one language is what makes "traceable to
|
|
200
|
+
# the generator" checkable by reading rather than by trusting a re-derivation in awk.
|
|
201
|
+
python3 - <<'PY'
|
|
202
|
+
import difflib
|
|
203
|
+
import os
|
|
204
|
+
import re
|
|
205
|
+
import sys
|
|
206
|
+
|
|
207
|
+
repo_root = os.environ["REPO_ROOT"]
|
|
208
|
+
want_diff = os.environ.get("OPT_DIFF") == "1"
|
|
209
|
+
min_pairs = int(os.environ.get("OPT_MIN_PAIRS") or "0")
|
|
210
|
+
skills_dir = os.path.join(repo_root, "skills")
|
|
211
|
+
|
|
212
|
+
# Pairs the generator refuses outright. See generate-j-alias.sh L110-113 (hard error,
|
|
213
|
+
# E50_S06_T01) and L118-121 (no SKILL.md).
|
|
214
|
+
NEVER_TWINNED = ("jenga", "jenga-permission-level", "index")
|
|
215
|
+
|
|
216
|
+
# Pairs the generator refuses because they are hand-maintained and predate it
|
|
217
|
+
# (generate-j-alias.sh L97-100). Audited anyway — see this file's Scope decisions.
|
|
218
|
+
HAND_MAINTAINED = ("init",)
|
|
219
|
+
|
|
220
|
+
key_re = re.compile(r"^([A-Za-z_][\w-]*):(.*)$")
|
|
221
|
+
|
|
222
|
+
findings = [] # (pair, relpath, code, detail, expected_text, actual_text)
|
|
223
|
+
notes = [] # informational, never affects exit status
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def record(pair, relpath, code, detail, expected=None, actual=None):
|
|
227
|
+
findings.append((pair, relpath, code, detail, expected, actual))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read_text(path):
|
|
231
|
+
"""Returns (text, is_text). Binary/unreadable files fall back to byte compare."""
|
|
232
|
+
try:
|
|
233
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
234
|
+
return fh.read(), True
|
|
235
|
+
except (UnicodeDecodeError, OSError):
|
|
236
|
+
return None, False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def read_bytes(path):
|
|
240
|
+
with open(path, "rb") as fh:
|
|
241
|
+
return fh.read()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def walk_files(root):
|
|
245
|
+
out = set()
|
|
246
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
247
|
+
for fn in filenames:
|
|
248
|
+
out.add(os.path.relpath(os.path.join(dirpath, fn), root))
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def split_frontmatter(text, label):
|
|
253
|
+
"""Returns (opening_line, fields, closing_line, body_lines).
|
|
254
|
+
|
|
255
|
+
Field parsing is the same informal line-oriented approach generate-j-alias.sh
|
|
256
|
+
L217-234 uses — deliberately, so the two agree on what a 'field' is.
|
|
257
|
+
"""
|
|
258
|
+
lines = text.splitlines(keepends=True)
|
|
259
|
+
if not lines or lines[0].strip() != "---":
|
|
260
|
+
return None, None, None, None
|
|
261
|
+
close_idx = None
|
|
262
|
+
for idx in range(1, len(lines)):
|
|
263
|
+
if lines[idx].strip() == "---":
|
|
264
|
+
close_idx = idx
|
|
265
|
+
break
|
|
266
|
+
if close_idx is None:
|
|
267
|
+
return None, None, None, None
|
|
268
|
+
|
|
269
|
+
fields = []
|
|
270
|
+
i = 1
|
|
271
|
+
while i < close_idx:
|
|
272
|
+
line = lines[i]
|
|
273
|
+
m = key_re.match(line)
|
|
274
|
+
if not m:
|
|
275
|
+
i += 1
|
|
276
|
+
continue
|
|
277
|
+
block = [line]
|
|
278
|
+
j = i + 1
|
|
279
|
+
if m.group(2).strip() == "":
|
|
280
|
+
while j < close_idx and lines[j].startswith(" "):
|
|
281
|
+
block.append(lines[j])
|
|
282
|
+
j += 1
|
|
283
|
+
fields.append({"key": m.group(1), "lines": block})
|
|
284
|
+
i = j
|
|
285
|
+
return lines[0], fields, lines[close_idx], lines[close_idx + 1:]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def find_field(fields, key):
|
|
289
|
+
for f in fields:
|
|
290
|
+
if f["key"] == key:
|
|
291
|
+
return f
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_expected_skill_md(src_text, name):
|
|
296
|
+
"""Byte-for-byte port of generate-j-alias.sh's transforms 2-4 on SKILL.md.
|
|
297
|
+
|
|
298
|
+
Mirrors L176-195 (path replace), L262 (name), L264-271 (description),
|
|
299
|
+
L273-286 (keywords/examples), L292-327 (note insertion + reassembly).
|
|
300
|
+
"""
|
|
301
|
+
old_ref = "skills/%s/" % name
|
|
302
|
+
new_ref = "skills/j-%s/" % name
|
|
303
|
+
|
|
304
|
+
opening, fields, closing, body_lines = split_frontmatter(
|
|
305
|
+
src_text.replace(old_ref, new_ref), "source"
|
|
306
|
+
)
|
|
307
|
+
if fields is None:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
name_field = find_field(fields, "name")
|
|
311
|
+
desc_field = find_field(fields, "description")
|
|
312
|
+
if name_field is None or len(name_field["lines"]) != 1:
|
|
313
|
+
return None
|
|
314
|
+
if desc_field is None or len(desc_field["lines"]) != 1:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
# L262
|
|
318
|
+
name_field["lines"][0] = "name: j.j-%s\n" % name
|
|
319
|
+
|
|
320
|
+
# L264-271
|
|
321
|
+
original_desc = (
|
|
322
|
+
key_re.match(desc_field["lines"][0]).group(2).strip().strip("\"'").rstrip(".")
|
|
323
|
+
)
|
|
324
|
+
desc_field["lines"][0] = (
|
|
325
|
+
"description: Polyfill alias of the {n} skill under a collision-safe directory "
|
|
326
|
+
"name. Identical behavior to /{n} — {d}. Use when the bare /{n} form is "
|
|
327
|
+
"shadowed by another tool's own built-in command of the same name.\n"
|
|
328
|
+
).format(n=name, d=original_desc)
|
|
329
|
+
|
|
330
|
+
# L273-286
|
|
331
|
+
keywords_field = find_field(fields, "keywords")
|
|
332
|
+
if keywords_field is not None:
|
|
333
|
+
keywords_field["lines"].append(" - j-%s\n" % name)
|
|
334
|
+
keywords_field["lines"].append(" - polyfill\n")
|
|
335
|
+
else:
|
|
336
|
+
fields.append({
|
|
337
|
+
"key": "keywords",
|
|
338
|
+
"lines": ["keywords:\n", " - j-%s\n" % name, " - polyfill\n"],
|
|
339
|
+
})
|
|
340
|
+
examples_field = find_field(fields, "examples")
|
|
341
|
+
if examples_field is not None:
|
|
342
|
+
examples_field["lines"].append(' - "j-%s"\n' % name)
|
|
343
|
+
|
|
344
|
+
new_frontmatter = []
|
|
345
|
+
for f in fields:
|
|
346
|
+
new_frontmatter.extend(f["lines"])
|
|
347
|
+
|
|
348
|
+
# L294-308
|
|
349
|
+
note_text = (
|
|
350
|
+
"This skill is a literal-directory-name duplicate of `skills/{n}/`. It exists "
|
|
351
|
+
"so that `/j-{n}` (and `j.j-{n}`) give a guaranteed-unshadowed way to reach "
|
|
352
|
+
"the same flow as `/{n}`, even if a host tool's own built-in command of the "
|
|
353
|
+
"same name would otherwise shadow or override the bare `/{n}` alias (Claude "
|
|
354
|
+
"Code's native skill resolution is a literal-string, directory-name-based "
|
|
355
|
+
"match — see `docs/skill-authoring.md`'s \"Invocation Convention\").\n\n"
|
|
356
|
+
"This file is generated/synced by `scripts/generate-j-alias.sh {n}` from "
|
|
357
|
+
"`skills/{n}/SKILL.md` — do not hand-edit it; re-run the generator instead to "
|
|
358
|
+
"pick up source changes."
|
|
359
|
+
).format(n=name)
|
|
360
|
+
note_block = []
|
|
361
|
+
for para in note_text.split("\n\n"):
|
|
362
|
+
note_block.append(para + "\n")
|
|
363
|
+
note_block.append("\n")
|
|
364
|
+
|
|
365
|
+
heading_idx = None
|
|
366
|
+
for idx, bl in enumerate(body_lines):
|
|
367
|
+
if bl.startswith("# "):
|
|
368
|
+
heading_idx = idx
|
|
369
|
+
break
|
|
370
|
+
if heading_idx is not None:
|
|
371
|
+
insert_at = heading_idx + 1
|
|
372
|
+
if insert_at < len(body_lines) and body_lines[insert_at].strip() == "":
|
|
373
|
+
insert_at += 1
|
|
374
|
+
prefix = body_lines[:insert_at]
|
|
375
|
+
if prefix and prefix[-1].strip() != "":
|
|
376
|
+
prefix = prefix + ["\n"]
|
|
377
|
+
new_body = prefix + note_block + body_lines[insert_at:]
|
|
378
|
+
else:
|
|
379
|
+
new_body = note_block + body_lines
|
|
380
|
+
|
|
381
|
+
return "".join([opening] + new_frontmatter + [closing] + new_body)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def normalize_name_line(text, name):
|
|
385
|
+
"""Applies the transform-3 tolerance: j.j-<name> and j.<name> are both accepted.
|
|
386
|
+
|
|
387
|
+
Rewrites the twin's `name:` line to the generator's j.j-<name> form purely for the
|
|
388
|
+
purpose of comparison, and only when it holds one of the two accepted values. Any
|
|
389
|
+
third value is left alone so the diff surfaces it as unexpected.
|
|
390
|
+
"""
|
|
391
|
+
accepted = ("j.j-%s" % name, "j.%s" % name)
|
|
392
|
+
out = []
|
|
393
|
+
seen = False
|
|
394
|
+
for line in text.splitlines(keepends=True):
|
|
395
|
+
if not seen:
|
|
396
|
+
m = key_re.match(line)
|
|
397
|
+
if m and m.group(1) == "name":
|
|
398
|
+
seen = True
|
|
399
|
+
value = m.group(2).strip().strip("\"'")
|
|
400
|
+
if value in accepted:
|
|
401
|
+
out.append("name: j.j-%s\n" % name)
|
|
402
|
+
continue
|
|
403
|
+
out.append(line)
|
|
404
|
+
return "".join(out)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def body_from_first_h2(body_lines):
|
|
408
|
+
for idx, bl in enumerate(body_lines):
|
|
409
|
+
if bl.startswith("## "):
|
|
410
|
+
return "".join(body_lines[idx:])
|
|
411
|
+
return ""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def preamble_between_h1_and_h2(body_lines):
|
|
415
|
+
"""Body text between the first H1 and the first H2, exclusive of both."""
|
|
416
|
+
start = None
|
|
417
|
+
for idx, bl in enumerate(body_lines):
|
|
418
|
+
if bl.startswith("# "):
|
|
419
|
+
start = idx + 1
|
|
420
|
+
break
|
|
421
|
+
if start is None:
|
|
422
|
+
start = 0
|
|
423
|
+
end = len(body_lines)
|
|
424
|
+
for idx in range(start, len(body_lines)):
|
|
425
|
+
if body_lines[idx].startswith("## "):
|
|
426
|
+
end = idx
|
|
427
|
+
break
|
|
428
|
+
return "".join(body_lines[start:end]).strip()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def audit_skill_md_hand_maintained(pair, src_text, twin_text):
|
|
432
|
+
"""SKILL.md comparison for a pair the generator refuses (L97-100).
|
|
433
|
+
|
|
434
|
+
Compares every frontmatter field except the four transform-3/4 rewrites, and the
|
|
435
|
+
body from the first H2 onward. See this file's Scope decisions for why.
|
|
436
|
+
"""
|
|
437
|
+
old_ref = "skills/%s/" % pair
|
|
438
|
+
new_ref = "skills/j-%s/" % pair
|
|
439
|
+
src_text = src_text.replace(old_ref, new_ref)
|
|
440
|
+
|
|
441
|
+
_o1, src_fields, _c1, src_body = split_frontmatter(src_text, "source")
|
|
442
|
+
_o2, twin_fields, _c2, twin_body = split_frontmatter(twin_text, "twin")
|
|
443
|
+
if src_fields is None or twin_fields is None:
|
|
444
|
+
record(pair, "SKILL.md", "FRONTMATTER_UNPARSEABLE",
|
|
445
|
+
"SKILL.md frontmatter could not be parsed on one or both sides")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
generator_owned = ("name", "description", "keywords", "examples")
|
|
449
|
+
for f in src_fields:
|
|
450
|
+
if f["key"] in generator_owned:
|
|
451
|
+
continue
|
|
452
|
+
twin_f = find_field(twin_fields, f["key"])
|
|
453
|
+
if twin_f is None:
|
|
454
|
+
record(pair, "SKILL.md", "FRONTMATTER_FIELD_MISSING",
|
|
455
|
+
"twin frontmatter has no '%s:' field, but the source does" % f["key"],
|
|
456
|
+
"".join(f["lines"]), "")
|
|
457
|
+
elif "".join(twin_f["lines"]) != "".join(f["lines"]):
|
|
458
|
+
record(pair, "SKILL.md", "FRONTMATTER_FIELD_DRIFT",
|
|
459
|
+
"twin frontmatter field '%s:' differs from the source" % f["key"],
|
|
460
|
+
"".join(f["lines"]), "".join(twin_f["lines"]))
|
|
461
|
+
|
|
462
|
+
# The uncompared region must stay empty, or this tolerance becomes a blind spot.
|
|
463
|
+
src_preamble = preamble_between_h1_and_h2(src_body)
|
|
464
|
+
if src_preamble:
|
|
465
|
+
record(pair, "SKILL.md", "PREAMBLE_UNCOMPARED",
|
|
466
|
+
"source now has body text between its H1 and its first '## ' heading, "
|
|
467
|
+
"which this pair's hand-maintained tolerance does not compare — review "
|
|
468
|
+
"by hand and mirror it into the twin if it belongs there",
|
|
469
|
+
src_preamble, "")
|
|
470
|
+
|
|
471
|
+
src_rest = body_from_first_h2(src_body)
|
|
472
|
+
twin_rest = body_from_first_h2(twin_body)
|
|
473
|
+
if src_rest != twin_rest:
|
|
474
|
+
record(pair, "SKILL.md", "SKILL_DRIFT",
|
|
475
|
+
"twin body (from the first '## ' heading onward) differs from the source",
|
|
476
|
+
src_rest, twin_rest)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def audit_pair(pair):
|
|
480
|
+
src_dir = os.path.join(skills_dir, pair)
|
|
481
|
+
twin_dir = os.path.join(skills_dir, "j-%s" % pair)
|
|
482
|
+
old_ref = "skills/%s/" % pair
|
|
483
|
+
new_ref = "skills/j-%s/" % pair
|
|
484
|
+
|
|
485
|
+
src_files = walk_files(src_dir)
|
|
486
|
+
twin_files = walk_files(twin_dir)
|
|
487
|
+
|
|
488
|
+
for rel in sorted(src_files - twin_files):
|
|
489
|
+
record(pair, rel, "MISSING_FILE",
|
|
490
|
+
"present in skills/%s/ but absent from the twin the mirror ships" % pair)
|
|
491
|
+
for rel in sorted(twin_files - src_files):
|
|
492
|
+
record(pair, rel, "EXTRA_FILE",
|
|
493
|
+
"present in the twin but absent from skills/%s/" % pair)
|
|
494
|
+
|
|
495
|
+
for rel in sorted(src_files & twin_files):
|
|
496
|
+
src_path = os.path.join(src_dir, rel)
|
|
497
|
+
twin_path = os.path.join(twin_dir, rel)
|
|
498
|
+
|
|
499
|
+
src_text, src_is_text = read_text(src_path)
|
|
500
|
+
twin_text, twin_is_text = read_text(twin_path)
|
|
501
|
+
|
|
502
|
+
if not (src_is_text and twin_is_text):
|
|
503
|
+
if read_bytes(src_path) != read_bytes(twin_path):
|
|
504
|
+
record(pair, rel, "BINARY_DRIFT",
|
|
505
|
+
"binary (or non-UTF-8) file differs; the generator copies it verbatim")
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
if rel == "SKILL.md":
|
|
509
|
+
if pair in HAND_MAINTAINED:
|
|
510
|
+
audit_skill_md_hand_maintained(pair, src_text, twin_text)
|
|
511
|
+
continue
|
|
512
|
+
expected = build_expected_skill_md(src_text, pair)
|
|
513
|
+
if expected is None:
|
|
514
|
+
record(pair, rel, "FRONTMATTER_UNPARSEABLE",
|
|
515
|
+
"source SKILL.md frontmatter does not have the single-line "
|
|
516
|
+
"name:/description: shape the generator requires (L247-250)")
|
|
517
|
+
continue
|
|
518
|
+
actual = normalize_name_line(twin_text, pair)
|
|
519
|
+
if expected != actual:
|
|
520
|
+
record(pair, rel, "SKILL_DRIFT",
|
|
521
|
+
"twin SKILL.md differs from the source after the generator's "
|
|
522
|
+
"transforms 2-4 are applied",
|
|
523
|
+
expected, actual)
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
expected = src_text.replace(old_ref, new_ref)
|
|
527
|
+
if expected != twin_text:
|
|
528
|
+
record(pair, rel, "CONTENT_DRIFT",
|
|
529
|
+
"twin differs from skills/%s/%s after transform 2 (the "
|
|
530
|
+
"skills/%s/ -> skills/j-%s/ path rewrite)" % (pair, rel, pair, pair),
|
|
531
|
+
expected, twin_text)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---- discover pairs -------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
if not os.path.isdir(skills_dir):
|
|
537
|
+
sys.exit("audit-twin-divergence.sh: error: %s is not a directory" % skills_dir)
|
|
538
|
+
|
|
539
|
+
entries = sorted(
|
|
540
|
+
d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))
|
|
541
|
+
)
|
|
542
|
+
bare_dirs = [d for d in entries if not d.startswith("j-")]
|
|
543
|
+
twin_dirs = [d for d in entries if d.startswith("j-")]
|
|
544
|
+
|
|
545
|
+
pairs = []
|
|
546
|
+
for d in twin_dirs:
|
|
547
|
+
bare = d[2:]
|
|
548
|
+
if bare in bare_dirs:
|
|
549
|
+
pairs.append(bare)
|
|
550
|
+
else:
|
|
551
|
+
notes.append(
|
|
552
|
+
"skipped skills/%s/ — no skills/%s/ source; already at its sole canonical "
|
|
553
|
+
"name, so there is nothing to diverge from" % (d, bare)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
for d in bare_dirs:
|
|
557
|
+
if ("j-%s" % d) in twin_dirs:
|
|
558
|
+
continue
|
|
559
|
+
if d in NEVER_TWINNED:
|
|
560
|
+
notes.append(
|
|
561
|
+
"skipped skills/%s/ — hard-excluded from twin generation by the generator; "
|
|
562
|
+
"a twin must never exist for it" % d
|
|
563
|
+
)
|
|
564
|
+
else:
|
|
565
|
+
notes.append(
|
|
566
|
+
"skipped skills/%s/ — no skills/j-%s/ twin exists" % (d, d)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
for pair in pairs:
|
|
570
|
+
audit_pair(pair)
|
|
571
|
+
|
|
572
|
+
# ---- report ---------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
for note in notes:
|
|
575
|
+
print("note: %s" % note)
|
|
576
|
+
if notes:
|
|
577
|
+
print("")
|
|
578
|
+
|
|
579
|
+
if findings:
|
|
580
|
+
current_pair = None
|
|
581
|
+
for pair, rel, code, detail, expected, actual in findings:
|
|
582
|
+
if pair != current_pair:
|
|
583
|
+
print("skills/j-%s/" % pair)
|
|
584
|
+
current_pair = pair
|
|
585
|
+
print(" %-26s %s" % (code, "skills/j-%s/%s" % (pair, rel)))
|
|
586
|
+
print(" %s" % detail)
|
|
587
|
+
if want_diff and expected is not None and actual is not None:
|
|
588
|
+
diff = difflib.unified_diff(
|
|
589
|
+
expected.splitlines(keepends=True),
|
|
590
|
+
actual.splitlines(keepends=True),
|
|
591
|
+
fromfile="expected (from skills/%s/%s)" % (pair, rel),
|
|
592
|
+
tofile="actual (skills/j-%s/%s)" % (pair, rel),
|
|
593
|
+
n=2,
|
|
594
|
+
)
|
|
595
|
+
for line in diff:
|
|
596
|
+
sys.stdout.write(" | " + line if line.endswith("\n")
|
|
597
|
+
else " | " + line + "\n")
|
|
598
|
+
print("")
|
|
599
|
+
|
|
600
|
+
affected = sorted({f[0] for f in findings})
|
|
601
|
+
print("audit-twin-divergence.sh: audited %d twinned pair(s) under %s"
|
|
602
|
+
% (len(pairs), skills_dir))
|
|
603
|
+
|
|
604
|
+
# Checked before the findings verdict: "I audited fewer pairs than you expected" is a
|
|
605
|
+
# statement about whether this run means anything at all, which has to be settled
|
|
606
|
+
# before "and I found nothing wrong" is worth printing as a pass.
|
|
607
|
+
if len(pairs) < min_pairs:
|
|
608
|
+
print("audit-twin-divergence.sh: error: only %d twinned pair(s) were audited, but "
|
|
609
|
+
"--min-pairs %d was required. A clean result over too small a population is "
|
|
610
|
+
"not a pass — check the tree actually contains the pairs you expected."
|
|
611
|
+
% (len(pairs), min_pairs))
|
|
612
|
+
sys.exit(2)
|
|
613
|
+
|
|
614
|
+
if findings:
|
|
615
|
+
print("audit-twin-divergence.sh: %d unexpected divergence(s) across %d pair(s): %s"
|
|
616
|
+
% (len(findings), len(affected), ", ".join(affected)))
|
|
617
|
+
print("audit-twin-divergence.sh: each of the above is a candidate content gap — "
|
|
618
|
+
"back-fill it into the twin, or record it as an intentional twin-only "
|
|
619
|
+
"difference with a stated reason (E50_S19_T02).")
|
|
620
|
+
sys.exit(1)
|
|
621
|
+
|
|
622
|
+
print("audit-twin-divergence.sh: no unexpected divergence — every difference between "
|
|
623
|
+
"each source and its twin is explained by the generator's transforms.")
|
|
624
|
+
sys.exit(0)
|
|
625
|
+
PY
|