@jenga-ai/agent 3.6.0 → 4.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.
Files changed (86) hide show
  1. package/README.md +32 -1
  2. package/lib/generate-skill-allow-list.js +42 -5
  3. package/lib/skill-allow-list.json +1 -1
  4. package/package.json +1 -2
  5. package/project/app/api/lib/resolve-project-root.js +1 -1
  6. package/project/app/api/scripts/capture-snapshot.js +12 -7
  7. package/project/app/package.json +4 -0
  8. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  9. package/project/app/ui/dist/assets/index-DX2pfTAW.js +104 -0
  10. package/project/app/ui/dist/index.html +2 -2
  11. package/scripts/acquire-concurrency-slot.sh +34 -4
  12. package/scripts/apply-j-prefix.sh +1 -1
  13. package/scripts/delete-bare-skill-dirs.sh +1 -2
  14. package/scripts/idea_manager.sh +16 -1
  15. package/scripts/release-concurrency-slot.sh +34 -4
  16. package/scripts/render-ranked-list.sh +270 -0
  17. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  18. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  19. package/scripts/strip-polyfill-frontmatter.py +166 -0
  20. package/scripts/todo_manager.sh +16 -1
  21. package/scripts/validate-typed-object.sh +750 -0
  22. package/scripts/verify-postinstall-reconcile.sh +33 -7
  23. package/skills/index/scripts/board_index.py +29 -1
  24. package/skills/j-brainstorm/SKILL.md +3 -4
  25. package/skills/j-btw/SKILL.md +3 -4
  26. package/skills/j-clearify/SKILL.md +3 -4
  27. package/skills/j-close-story/SKILL.md +11 -12
  28. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  29. package/skills/j-commit/SKILL.md +3 -4
  30. package/skills/j-continue/SKILL.md +5 -6
  31. package/skills/j-deep-dive/SKILL.md +3 -4
  32. package/skills/j-distribute/SKILL.md +3 -4
  33. package/skills/j-do/SKILL.md +13 -15
  34. package/skills/j-doc/SKILL.md +3 -4
  35. package/skills/j-doc/scripts/resolve_last_update.py +27 -1
  36. package/skills/j-doc-sync/SKILL.md +3 -4
  37. package/skills/j-dooo/SKILL.md +6 -15
  38. package/skills/j-error/SKILL.md +3 -4
  39. package/skills/j-evaluate/SKILL.md +3 -4
  40. package/skills/j-examplify/SKILL.md +3 -4
  41. package/skills/j-help/SKILL.md +3 -4
  42. package/skills/j-idea/SKILL.md +3 -4
  43. package/skills/j-improve/SKILL.md +3 -4
  44. package/skills/j-init/SKILL.md +20 -11
  45. package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
  46. package/skills/j-jbp/SKILL.md +3 -4
  47. package/skills/j-lgtm/SKILL.md +3 -4
  48. package/skills/j-pi-plan/SKILL.md +3 -4
  49. package/skills/j-proceed/SKILL.md +3 -4
  50. package/skills/j-publish/SKILL.md +3 -4
  51. package/skills/j-publish/scripts/run_gates.sh +1 -1
  52. package/skills/j-reconcile/SKILL.md +38 -5
  53. package/skills/j-reconcile/assets/report_format.md +11 -0
  54. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
  55. package/skills/j-reconcile-origin/SKILL.md +3 -4
  56. package/skills/j-redo/SKILL.md +3 -4
  57. package/skills/j-skillify/SKILL.md +3 -4
  58. package/skills/j-spinoff/SKILL.md +3 -4
  59. package/skills/j-status/SKILL.md +4 -5
  60. package/skills/j-todo/SKILL.md +42 -5
  61. package/skills/j-todo/scripts/add_trivial_task.sh +12 -1
  62. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  63. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  64. package/skills/j-uncharted/SKILL.md +251 -12
  65. package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
  66. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  67. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  68. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  69. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  70. package/skills/j-wtf/SKILL.md +3 -4
  71. package/skills/jenga/SKILL.md +106 -10
  72. package/skills/jenga/playbooks/schema.json +4 -4
  73. package/skills/jenga/scripts/enrich-nl-prompt.sh +225 -0
  74. package/skills/jenga/scripts/load-nl-catalog.js +6 -3
  75. package/skills/jenga/scripts/load-playbooks.sh +289 -4
  76. package/skills/jenga/scripts/match-playbook.sh +6 -5
  77. package/skills/jenga/scripts/run-playbook-step.sh +270 -3
  78. package/templates/permission-levels/level-1-locked.json +1 -1
  79. package/templates/permission-levels/level-2-guarded.json +1 -1
  80. package/templates/permission-levels/level-3-standard.json +1 -1
  81. package/templates/permission-levels/level-4-elevated.json +1 -1
  82. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  83. package/templates/playbook-types.json +6 -0
  84. package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
  85. package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
  86. package/scripts/audit-twin-divergence.sh +0 -693
@@ -1,693 +0,0 @@
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/j-publish/scripts/npm_stage_pipeline.sh and
17
- # a CI-log capture block to skills/j-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 subprocess
206
- import sys
207
-
208
- repo_root = os.environ["REPO_ROOT"]
209
- want_diff = os.environ.get("OPT_DIFF") == "1"
210
- min_pairs = int(os.environ.get("OPT_MIN_PAIRS") or "0")
211
- skills_dir = os.path.join(repo_root, "skills")
212
-
213
- # Gitignored-artifact exclusion (E50_S19_T04, Defect 1)
214
- # --------------------------------------------------------------------------
215
- # A build artifact that is gitignored (e.g. skills/train/__pycache__/*.pyc,
216
- # .gitignore:11's `**/__pycache__`) exists only because someone ran the tooling
217
- # on a real working checkout, not because a fix landed in the source and never
218
- # reached the twin. Comparing it at all is the bug: it is present in the bare
219
- # source and absent from the twin purely as a byproduct of git never tracking
220
- # it in either place, and gets reported as a false-positive MISSING_FILE.
221
- #
222
- # This is driven from the repo's OWN ignore rules (`git check-ignore`) rather
223
- # than a hardcoded `__pycache__`/`*.pyc` literal. A bespoke list was rejected
224
- # deliberately: the next generated-artifact class (a new lint cache, a new
225
- # compiled-language build dir, anything else `.gitignore` or `.publicignore`
226
- # already knows about) would silently reintroduce this exact false positive,
227
- # and a literal list is something every contributor has to remember to update
228
- # by hand. Reusing `git check-ignore` means this script's exclusion rule stays
229
- # correct automatically as `.gitignore` evolves.
230
- #
231
- # Degrades sensibly on a non-git repo root: this script's own AC (T01) requires
232
- # it to work against an arbitrary repo root, including a public-mirror-shaped
233
- # tree assembled by rsync that may not be a git repository at all. When that is
234
- # the case there is no ignore-rule authority to consult, so the exclusion is
235
- # skipped entirely rather than erroring -- acceptable because a mirror-shaped
236
- # tree is built from an explicit --exclude-from list (.publicignore) that does
237
- # not carry gitignored build artifacts across in the first place.
238
- IS_GIT_REPO = False
239
- try:
240
- _git_check = subprocess.run(
241
- ["git", "-C", repo_root, "rev-parse", "--is-inside-work-tree"],
242
- capture_output=True, text=True, timeout=10,
243
- )
244
- IS_GIT_REPO = _git_check.returncode == 0 and _git_check.stdout.strip() == "true"
245
- except (OSError, subprocess.SubprocessError):
246
- IS_GIT_REPO = False
247
-
248
-
249
- def git_ignored_paths(paths):
250
- """Returns the subset of the given absolute paths that git considers ignored.
251
-
252
- Batches every path into a single `git check-ignore --stdin` call rather than
253
- one subprocess per file. Exit code 1 (nothing matched) and 0 (something
254
- matched) are both legitimate outcomes; anything else (128, a missing git
255
- binary, a timeout) is treated as "cannot determine ignore status" and
256
- degrades to reporting nothing ignored, matching the non-git-repo behavior
257
- above rather than crashing the whole audit over an environment quirk.
258
- """
259
- if not IS_GIT_REPO or not paths:
260
- return set()
261
- try:
262
- proc = subprocess.run(
263
- ["git", "-C", repo_root, "check-ignore", "--stdin"],
264
- input="\n".join(paths) + "\n",
265
- capture_output=True, text=True, timeout=30,
266
- )
267
- except (OSError, subprocess.SubprocessError):
268
- return set()
269
- if proc.returncode not in (0, 1):
270
- return set()
271
- return {line for line in proc.stdout.splitlines() if line}
272
-
273
- # Pairs the generator refuses outright. See generate-j-alias.sh L110-113 (hard error,
274
- # E50_S06_T01) and L118-121 (no SKILL.md).
275
- NEVER_TWINNED = ("jenga", "jenga-permission-level", "index")
276
-
277
- # Pairs the generator refuses because they are hand-maintained and predate it
278
- # (generate-j-alias.sh L97-100). Audited anyway — see this file's Scope decisions.
279
- HAND_MAINTAINED = ("init",)
280
-
281
- key_re = re.compile(r"^([A-Za-z_][\w-]*):(.*)$")
282
-
283
- findings = [] # (pair, relpath, code, detail, expected_text, actual_text)
284
- notes = [] # informational, never affects exit status
285
-
286
-
287
- def record(pair, relpath, code, detail, expected=None, actual=None):
288
- findings.append((pair, relpath, code, detail, expected, actual))
289
-
290
-
291
- def read_text(path):
292
- """Returns (text, is_text). Binary/unreadable files fall back to byte compare."""
293
- try:
294
- with open(path, "r", encoding="utf-8") as fh:
295
- return fh.read(), True
296
- except (UnicodeDecodeError, OSError):
297
- return None, False
298
-
299
-
300
- def read_bytes(path):
301
- with open(path, "rb") as fh:
302
- return fh.read()
303
-
304
-
305
- def walk_files(root):
306
- rel_by_abs = {}
307
- for dirpath, _dirnames, filenames in os.walk(root):
308
- for fn in filenames:
309
- abs_path = os.path.join(dirpath, fn)
310
- rel_by_abs[abs_path] = os.path.relpath(abs_path, root)
311
- ignored = git_ignored_paths(list(rel_by_abs.keys()))
312
- if not ignored:
313
- return set(rel_by_abs.values())
314
- return {
315
- rel for abs_path, rel in rel_by_abs.items()
316
- if abs_path not in ignored
317
- }
318
-
319
-
320
- def split_frontmatter(text, label):
321
- """Returns (opening_line, fields, closing_line, body_lines).
322
-
323
- Field parsing is the same informal line-oriented approach generate-j-alias.sh
324
- L217-234 uses — deliberately, so the two agree on what a 'field' is.
325
- """
326
- lines = text.splitlines(keepends=True)
327
- if not lines or lines[0].strip() != "---":
328
- return None, None, None, None
329
- close_idx = None
330
- for idx in range(1, len(lines)):
331
- if lines[idx].strip() == "---":
332
- close_idx = idx
333
- break
334
- if close_idx is None:
335
- return None, None, None, None
336
-
337
- fields = []
338
- i = 1
339
- while i < close_idx:
340
- line = lines[i]
341
- m = key_re.match(line)
342
- if not m:
343
- i += 1
344
- continue
345
- block = [line]
346
- j = i + 1
347
- if m.group(2).strip() == "":
348
- while j < close_idx and lines[j].startswith(" "):
349
- block.append(lines[j])
350
- j += 1
351
- fields.append({"key": m.group(1), "lines": block})
352
- i = j
353
- return lines[0], fields, lines[close_idx], lines[close_idx + 1:]
354
-
355
-
356
- def find_field(fields, key):
357
- for f in fields:
358
- if f["key"] == key:
359
- return f
360
- return None
361
-
362
-
363
- def build_expected_skill_md(src_text, name):
364
- """Byte-for-byte port of generate-j-alias.sh's transforms 2-4 on SKILL.md.
365
-
366
- Mirrors L176-195 (path replace), L262 (name), L264-271 (description),
367
- L273-286 (keywords/examples), L292-327 (note insertion + reassembly).
368
- """
369
- old_ref = "skills/%s/" % name
370
- new_ref = "skills/j-%s/" % name
371
-
372
- opening, fields, closing, body_lines = split_frontmatter(
373
- src_text.replace(old_ref, new_ref), "source"
374
- )
375
- if fields is None:
376
- return None
377
-
378
- name_field = find_field(fields, "name")
379
- desc_field = find_field(fields, "description")
380
- if name_field is None or len(name_field["lines"]) != 1:
381
- return None
382
- if desc_field is None or len(desc_field["lines"]) != 1:
383
- return None
384
-
385
- # L262
386
- name_field["lines"][0] = "name: j.j-%s\n" % name
387
-
388
- # L264-271
389
- original_desc = (
390
- key_re.match(desc_field["lines"][0]).group(2).strip().strip("\"'").rstrip(".")
391
- )
392
- desc_field["lines"][0] = (
393
- "description: Polyfill alias of the {n} skill under a collision-safe directory "
394
- "name. Identical behavior to /{n} — {d}. Use when the bare /{n} form is "
395
- "shadowed by another tool's own built-in command of the same name.\n"
396
- ).format(n=name, d=original_desc)
397
-
398
- # L273-286
399
- keywords_field = find_field(fields, "keywords")
400
- if keywords_field is not None:
401
- keywords_field["lines"].append(" - j-%s\n" % name)
402
- keywords_field["lines"].append(" - polyfill\n")
403
- else:
404
- fields.append({
405
- "key": "keywords",
406
- "lines": ["keywords:\n", " - j-%s\n" % name, " - polyfill\n"],
407
- })
408
- examples_field = find_field(fields, "examples")
409
- if examples_field is not None:
410
- examples_field["lines"].append(' - "j-%s"\n' % name)
411
-
412
- new_frontmatter = []
413
- for f in fields:
414
- new_frontmatter.extend(f["lines"])
415
-
416
- # L294-308
417
- note_text = (
418
- "This skill is a literal-directory-name duplicate of `skills/{n}/`. It exists "
419
- "so that `/j-{n}` (and `j.j-{n}`) give a guaranteed-unshadowed way to reach "
420
- "the same flow as `/{n}`, even if a host tool's own built-in command of the "
421
- "same name would otherwise shadow or override the bare `/{n}` alias (Claude "
422
- "Code's native skill resolution is a literal-string, directory-name-based "
423
- "match — see `docs/skill-authoring.md`'s \"Invocation Convention\").\n\n"
424
- "This file is generated/synced by `scripts/generate-j-alias.sh {n}` from "
425
- "`skills/{n}/SKILL.md` — do not hand-edit it; re-run the generator instead to "
426
- "pick up source changes."
427
- ).format(n=name)
428
- note_block = []
429
- for para in note_text.split("\n\n"):
430
- note_block.append(para + "\n")
431
- note_block.append("\n")
432
-
433
- heading_idx = None
434
- for idx, bl in enumerate(body_lines):
435
- if bl.startswith("# "):
436
- heading_idx = idx
437
- break
438
- if heading_idx is not None:
439
- insert_at = heading_idx + 1
440
- if insert_at < len(body_lines) and body_lines[insert_at].strip() == "":
441
- insert_at += 1
442
- prefix = body_lines[:insert_at]
443
- if prefix and prefix[-1].strip() != "":
444
- prefix = prefix + ["\n"]
445
- new_body = prefix + note_block + body_lines[insert_at:]
446
- else:
447
- new_body = note_block + body_lines
448
-
449
- return "".join([opening] + new_frontmatter + [closing] + new_body)
450
-
451
-
452
- def normalize_name_line(text, name):
453
- """Applies the transform-3 tolerance: j.j-<name> and j.<name> are both accepted.
454
-
455
- Rewrites the twin's `name:` line to the generator's j.j-<name> form purely for the
456
- purpose of comparison, and only when it holds one of the two accepted values. Any
457
- third value is left alone so the diff surfaces it as unexpected.
458
- """
459
- accepted = ("j.j-%s" % name, "j.%s" % name)
460
- out = []
461
- seen = False
462
- for line in text.splitlines(keepends=True):
463
- if not seen:
464
- m = key_re.match(line)
465
- if m and m.group(1) == "name":
466
- seen = True
467
- value = m.group(2).strip().strip("\"'")
468
- if value in accepted:
469
- out.append("name: j.j-%s\n" % name)
470
- continue
471
- out.append(line)
472
- return "".join(out)
473
-
474
-
475
- def body_from_first_h2(body_lines):
476
- for idx, bl in enumerate(body_lines):
477
- if bl.startswith("## "):
478
- return "".join(body_lines[idx:])
479
- return ""
480
-
481
-
482
- def preamble_between_h1_and_h2(body_lines):
483
- """Body text between the first H1 and the first H2, exclusive of both."""
484
- start = None
485
- for idx, bl in enumerate(body_lines):
486
- if bl.startswith("# "):
487
- start = idx + 1
488
- break
489
- if start is None:
490
- start = 0
491
- end = len(body_lines)
492
- for idx in range(start, len(body_lines)):
493
- if body_lines[idx].startswith("## "):
494
- end = idx
495
- break
496
- return "".join(body_lines[start:end]).strip()
497
-
498
-
499
- def audit_skill_md_hand_maintained(pair, src_text, twin_text):
500
- """SKILL.md comparison for a pair the generator refuses (L97-100).
501
-
502
- Compares every frontmatter field except the four transform-3/4 rewrites, and the
503
- body from the first H2 onward. See this file's Scope decisions for why.
504
- """
505
- old_ref = "skills/%s/" % pair
506
- new_ref = "skills/j-%s/" % pair
507
- src_text = src_text.replace(old_ref, new_ref)
508
-
509
- _o1, src_fields, _c1, src_body = split_frontmatter(src_text, "source")
510
- _o2, twin_fields, _c2, twin_body = split_frontmatter(twin_text, "twin")
511
- if src_fields is None or twin_fields is None:
512
- record(pair, "SKILL.md", "FRONTMATTER_UNPARSEABLE",
513
- "SKILL.md frontmatter could not be parsed on one or both sides")
514
- return
515
-
516
- generator_owned = ("name", "description", "keywords", "examples")
517
- for f in src_fields:
518
- if f["key"] in generator_owned:
519
- continue
520
- twin_f = find_field(twin_fields, f["key"])
521
- if twin_f is None:
522
- record(pair, "SKILL.md", "FRONTMATTER_FIELD_MISSING",
523
- "twin frontmatter has no '%s:' field, but the source does" % f["key"],
524
- "".join(f["lines"]), "")
525
- elif "".join(twin_f["lines"]) != "".join(f["lines"]):
526
- record(pair, "SKILL.md", "FRONTMATTER_FIELD_DRIFT",
527
- "twin frontmatter field '%s:' differs from the source" % f["key"],
528
- "".join(f["lines"]), "".join(twin_f["lines"]))
529
-
530
- # The uncompared region must stay empty, or this tolerance becomes a blind spot.
531
- src_preamble = preamble_between_h1_and_h2(src_body)
532
- if src_preamble:
533
- record(pair, "SKILL.md", "PREAMBLE_UNCOMPARED",
534
- "source now has body text between its H1 and its first '## ' heading, "
535
- "which this pair's hand-maintained tolerance does not compare — review "
536
- "by hand and mirror it into the twin if it belongs there",
537
- src_preamble, "")
538
-
539
- src_rest = body_from_first_h2(src_body)
540
- twin_rest = body_from_first_h2(twin_body)
541
- if src_rest != twin_rest:
542
- record(pair, "SKILL.md", "SKILL_DRIFT",
543
- "twin body (from the first '## ' heading onward) differs from the source",
544
- src_rest, twin_rest)
545
-
546
-
547
- def audit_pair(pair):
548
- src_dir = os.path.join(skills_dir, pair)
549
- twin_dir = os.path.join(skills_dir, "j-%s" % pair)
550
- old_ref = "skills/%s/" % pair
551
- new_ref = "skills/j-%s/" % pair
552
-
553
- src_files = walk_files(src_dir)
554
- twin_files = walk_files(twin_dir)
555
-
556
- for rel in sorted(src_files - twin_files):
557
- record(pair, rel, "MISSING_FILE",
558
- "present in skills/%s/ but absent from the twin the mirror ships" % pair)
559
- for rel in sorted(twin_files - src_files):
560
- record(pair, rel, "EXTRA_FILE",
561
- "present in the twin but absent from skills/%s/" % pair)
562
-
563
- for rel in sorted(src_files & twin_files):
564
- src_path = os.path.join(src_dir, rel)
565
- twin_path = os.path.join(twin_dir, rel)
566
-
567
- src_text, src_is_text = read_text(src_path)
568
- twin_text, twin_is_text = read_text(twin_path)
569
-
570
- if not (src_is_text and twin_is_text):
571
- if read_bytes(src_path) != read_bytes(twin_path):
572
- record(pair, rel, "BINARY_DRIFT",
573
- "binary (or non-UTF-8) file differs; the generator copies it verbatim")
574
- continue
575
-
576
- if rel == "SKILL.md":
577
- if pair in HAND_MAINTAINED:
578
- audit_skill_md_hand_maintained(pair, src_text, twin_text)
579
- continue
580
- expected = build_expected_skill_md(src_text, pair)
581
- if expected is None:
582
- record(pair, rel, "FRONTMATTER_UNPARSEABLE",
583
- "source SKILL.md frontmatter does not have the single-line "
584
- "name:/description: shape the generator requires (L247-250)")
585
- continue
586
- actual = normalize_name_line(twin_text, pair)
587
- if expected != actual:
588
- record(pair, rel, "SKILL_DRIFT",
589
- "twin SKILL.md differs from the source after the generator's "
590
- "transforms 2-4 are applied",
591
- expected, actual)
592
- continue
593
-
594
- expected = src_text.replace(old_ref, new_ref)
595
- if expected != twin_text:
596
- record(pair, rel, "CONTENT_DRIFT",
597
- "twin differs from skills/%s/%s after transform 2 (the "
598
- "skills/%s/ -> skills/j-%s/ path rewrite)" % (pair, rel, pair, pair),
599
- expected, twin_text)
600
-
601
-
602
- # ---- discover pairs -------------------------------------------------------------
603
-
604
- if not os.path.isdir(skills_dir):
605
- sys.exit("audit-twin-divergence.sh: error: %s is not a directory" % skills_dir)
606
-
607
- entries = sorted(
608
- d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))
609
- )
610
- bare_dirs = [d for d in entries if not d.startswith("j-")]
611
- twin_dirs = [d for d in entries if d.startswith("j-")]
612
-
613
- pairs = []
614
- for d in twin_dirs:
615
- bare = d[2:]
616
- if bare in bare_dirs:
617
- pairs.append(bare)
618
- else:
619
- notes.append(
620
- "skipped skills/%s/ — no skills/%s/ source; already at its sole canonical "
621
- "name, so there is nothing to diverge from" % (d, bare)
622
- )
623
-
624
- for d in bare_dirs:
625
- if ("j-%s" % d) in twin_dirs:
626
- continue
627
- if d in NEVER_TWINNED:
628
- notes.append(
629
- "skipped skills/%s/ — hard-excluded from twin generation by the generator; "
630
- "a twin must never exist for it" % d
631
- )
632
- else:
633
- notes.append(
634
- "skipped skills/%s/ — no skills/j-%s/ twin exists" % (d, d)
635
- )
636
-
637
- for pair in pairs:
638
- audit_pair(pair)
639
-
640
- # ---- report ---------------------------------------------------------------------
641
-
642
- for note in notes:
643
- print("note: %s" % note)
644
- if notes:
645
- print("")
646
-
647
- if findings:
648
- current_pair = None
649
- for pair, rel, code, detail, expected, actual in findings:
650
- if pair != current_pair:
651
- print("skills/j-%s/" % pair)
652
- current_pair = pair
653
- print(" %-26s %s" % (code, "skills/j-%s/%s" % (pair, rel)))
654
- print(" %s" % detail)
655
- if want_diff and expected is not None and actual is not None:
656
- diff = difflib.unified_diff(
657
- expected.splitlines(keepends=True),
658
- actual.splitlines(keepends=True),
659
- fromfile="expected (from skills/%s/%s)" % (pair, rel),
660
- tofile="actual (skills/j-%s/%s)" % (pair, rel),
661
- n=2,
662
- )
663
- for line in diff:
664
- sys.stdout.write(" | " + line if line.endswith("\n")
665
- else " | " + line + "\n")
666
- print("")
667
-
668
- affected = sorted({f[0] for f in findings})
669
- print("audit-twin-divergence.sh: audited %d twinned pair(s) under %s"
670
- % (len(pairs), skills_dir))
671
-
672
- # Checked before the findings verdict: "I audited fewer pairs than you expected" is a
673
- # statement about whether this run means anything at all, which has to be settled
674
- # before "and I found nothing wrong" is worth printing as a pass.
675
- if len(pairs) < min_pairs:
676
- print("audit-twin-divergence.sh: error: only %d twinned pair(s) were audited, but "
677
- "--min-pairs %d was required. A clean result over too small a population is "
678
- "not a pass — check the tree actually contains the pairs you expected."
679
- % (len(pairs), min_pairs))
680
- sys.exit(2)
681
-
682
- if findings:
683
- print("audit-twin-divergence.sh: %d unexpected divergence(s) across %d pair(s): %s"
684
- % (len(findings), len(affected), ", ".join(affected)))
685
- print("audit-twin-divergence.sh: each of the above is a candidate content gap — "
686
- "back-fill it into the twin, or record it as an intentional twin-only "
687
- "difference with a stated reason (E50_S19_T02).")
688
- sys.exit(1)
689
-
690
- print("audit-twin-divergence.sh: no unexpected divergence — every difference between "
691
- "each source and its twin is explained by the generator's transforms.")
692
- sys.exit(0)
693
- PY