@jenga-ai/agent 3.4.0 → 3.6.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 (97) hide show
  1. package/README.md +85 -78
  2. package/agents/developer.md +1 -1
  3. package/agents/scrum-master.md +20 -2
  4. package/agents/tester.md +3 -3
  5. package/hooks/on_session_end.sh +5 -5
  6. package/lib/generate-agent-context.js +2 -2
  7. package/lib/generate-copilot-hooks.js +1 -1
  8. package/lib/generate-skill-allow-list.js +37 -3
  9. package/lib/mirror.js +1 -1
  10. package/lib/postinstall-manifest.js +1 -1
  11. package/lib/skill-allow-list.json +2 -2
  12. package/mcp/help/index.js +8 -17
  13. package/mcp/help/scan.js +73 -0
  14. package/package.json +6 -1
  15. package/project/app/api/parsers/knowledge-graph.js +100 -9
  16. package/project/app/api/routes/health.js +36 -0
  17. package/project/app/api/scripts/capture-snapshot.js +9 -6
  18. package/project/app/ui/dist/assets/{index-CdK3Qrep.css → index-BVR_7Owg.css} +1 -1
  19. package/project/app/ui/dist/assets/index-CtU2xLQm.js +104 -0
  20. package/project/app/ui/dist/index.html +2 -2
  21. package/project/app/ui/package.json +4 -0
  22. package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
  23. package/scripts/acquire-concurrency-slot.sh +1 -1
  24. package/scripts/apply-j-prefix.sh +46 -5
  25. package/scripts/audit-twin-divergence.sh +73 -5
  26. package/scripts/build-pages-site.sh +1 -1
  27. package/scripts/check-public-playbook-steps.sh +158 -52
  28. package/scripts/check-publicignore-match.sh +2 -2
  29. package/scripts/compute-deploy-reconcile.sh +5 -5
  30. package/scripts/delete-bare-skill-dirs.sh +330 -0
  31. package/scripts/generate-legacy-shipped-paths.js +2 -2
  32. package/scripts/idea_manager.sh +258 -3
  33. package/scripts/mark-deployed.sh +2 -2
  34. package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
  35. package/scripts/populate-knowledge-graph.js +213 -5
  36. package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
  37. package/scripts/postinstall.js +1 -1
  38. package/scripts/repoint-skill-refs.sh +539 -0
  39. package/scripts/todo_manager.sh +1 -1
  40. package/scripts/verify-legacy-seed-reconcile.sh +10 -10
  41. package/scripts/verify-postinstall-reconcile.sh +7 -7
  42. package/scripts/write-context-digest.sh +1 -1
  43. package/skills/j-clearify/SKILL.md +2 -2
  44. package/skills/j-close-story/scripts/check-privatized.sh +4 -4
  45. package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
  46. package/skills/j-do/SKILL.md +101 -17
  47. package/skills/j-doc-sync/SKILL.md +1 -0
  48. package/skills/j-gitignore/SKILL.md +157 -0
  49. package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
  50. package/skills/j-gitignore/scripts/_catalog.sh +105 -0
  51. package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
  52. package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
  53. package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
  54. package/skills/j-idea/SKILL.md +78 -6
  55. package/skills/j-idea/assets/idea_template.md +1 -1
  56. package/skills/j-improve/SKILL.md +1 -1
  57. package/skills/j-init/SKILL.md +53 -14
  58. package/skills/j-init/assets/.gitignore_template +1 -2
  59. package/skills/j-init/assets/scope-thresholds_template.json +5 -2
  60. package/skills/j-init/scripts/apply-scaffold-visibility.sh +192 -0
  61. package/skills/j-init/scripts/init.sh +22 -8
  62. package/skills/j-playbook/SKILL.md +1 -1
  63. package/skills/j-publish/SKILL.md +1 -1
  64. package/skills/j-publish/adapters/npm-ci.md +6 -1
  65. package/skills/j-publish/adapters/npm.md +1 -1
  66. package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
  67. package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
  68. package/skills/j-reconcile/SKILL.md +2 -2
  69. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +11 -11
  70. package/skills/j-redo/SKILL.md +1 -1
  71. package/skills/j-skillify/assets/init-new/assets/.gitignore_template +1 -2
  72. package/skills/j-spinoff/SKILL.md +1 -1
  73. package/skills/j-status/SKILL.md +15 -0
  74. package/skills/j-todo/SKILL.md +3 -1
  75. package/skills/j-uncharted/SKILL.md +55 -8
  76. package/skills/j-uncharted/assets/NODE_QUESTION_TEMPLATE.md +69 -0
  77. package/skills/j-uncharted/scripts/detect-dependencies.sh +1 -1
  78. package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
  79. package/skills/j-uncharted/scripts/elicitation-state.sh +46 -8
  80. package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
  81. package/skills/j-wtf/SKILL.md +1 -1
  82. package/skills/jenga/SKILL.md +43 -9
  83. package/skills/jenga/playbooks/board-hygiene.json +32 -0
  84. package/skills/jenga/playbooks/schema.json +73 -6
  85. package/skills/jenga/playbooks/understand-then-commit.json +19 -0
  86. package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
  87. package/skills/jenga/scripts/load-playbooks.sh +23 -11
  88. package/skills/jenga/scripts/match-playbook.sh +1 -1
  89. package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
  90. package/templates/SKILL_TEMPLATE.md +12 -0
  91. package/templates/permission-levels/level-4-elevated.json +1 -1
  92. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  93. package/templates/playbook-types.json +34 -6
  94. package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
  95. package/scripts/generate-j-alias.sh +0 -333
  96. package/skills/j-dev-done/SKILL.md +0 -53
  97. package/skills/j-dev-done/scripts/classify-commit-outcome.sh +0 -114
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Jenga AI Dashboard</title>
7
- <script type="module" crossorigin src="/assets/index-7fj-vllY.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-CdK3Qrep.css">
7
+ <script type="module" crossorigin src="/assets/index-CtU2xLQm.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BVR_7Owg.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -11,6 +11,10 @@
11
11
  "dashboard:open": "node scripts/dashboard-open.cjs"
12
12
  },
13
13
  "dependencies": {
14
+ "dagre": "^0.8.5",
15
+ "d3-selection": "^3.0.0",
16
+ "d3-shape": "^3.2.0",
17
+ "d3-zoom": "^3.0.0",
14
18
  "dompurify": "^3.4.2",
15
19
  "marked": "^18.0.3",
16
20
  "react": "^18.2.0",
@@ -29,7 +29,13 @@
29
29
  * which src/api/client.js's get() already reads instead of calling fetch()
30
30
  * whenever it is present. That runtime branch is NOT build-mode-gated, so
31
31
  * the ordinary `vite build` output is already snapshot-capable.
32
- * 5. Fails loudly if any local asset reference survives, so a future UI change
32
+ * 5. Rewrites the static <title> to "<ProjectName>'s Dashboard" using the
33
+ * project name captured from the /v1/health route (E47_S06_T02) — the
34
+ * exported file has zero network dependency once opened via file://, so the
35
+ * client-side document.title fix (E47_S06_T01) never runs against it; this
36
+ * has to happen here, at export time, instead. Leaves the static title
37
+ * untouched when no name is resolvable (see resolveProjectName() below).
38
+ * 6. Fails loudly if any local asset reference survives, so a future UI change
33
39
  * that adds an image/font/chunk can never silently ship a broken snapshot.
34
40
  *
35
41
  * Usage:
@@ -125,6 +131,42 @@ function attr(tag, name) {
125
131
  return match ? match[1] : null;
126
132
  }
127
133
 
134
+ // Minimal HTML-escaping for text placed inside a <title> element — a project
135
+ // name is untrusted input (it comes from a consumer's own package.json), so it
136
+ // must not be able to break out of the element or inject markup.
137
+ function escapeHtml(str) {
138
+ return str
139
+ .replace(/&/g, '&amp;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;');
142
+ }
143
+
144
+ // Candidate keys checked, in order, on the captured /v1/health route's `data`
145
+ // object. E47_S06_T01 (sibling task, dispatched concurrently in a separate
146
+ // worktree/branch, not yet visible when this was written) owns the exact field
147
+ // name /v1/health returns the project name under — the story only commits to
148
+ // "returns the consuming project's name", not a specific key. Checking an
149
+ // ordered list instead of a single guessed key avoids a silent no-op title
150
+ // fallback if the merged field name differs from the first guess.
151
+ const PROJECT_NAME_KEYS = ['projectName', 'project_name', 'name'];
152
+
153
+ /**
154
+ * Pull a non-empty project name string out of the captured snapshot data's
155
+ * /v1/health route, or return null if none is resolvable (route missing from
156
+ * the capture, envelope has no usable data, or none of the candidate keys hold
157
+ * a non-empty string). Never throws — a resolution failure here must fall back
158
+ * to the static default title, not abort the whole snapshot build.
159
+ */
160
+ function resolveProjectName(data) {
161
+ const healthData = data && data.routes && data.routes.health && data.routes.health.data;
162
+ if (!healthData || typeof healthData !== 'object') return null;
163
+ for (const key of PROJECT_NAME_KEYS) {
164
+ const value = healthData[key];
165
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim();
166
+ }
167
+ return null;
168
+ }
169
+
128
170
  // ── 0. Reserve the real </head> injection point ──────────────────────────────
129
171
  // This MUST run before step 1. Step 4 injects the payload before `</head>` with
130
172
  // a first-match regex, and step 1 inlines the ESM bundle INTO the head. The
@@ -182,7 +224,26 @@ const dataTag = `<script id="jenga-dashboard-data" type="application/json">${ser
182
224
  // competing '</head>'; see there for why the anchor cannot be taken here.
183
225
  html = html.replace(DATA_PLACEHOLDER, () => ` ${dataTag}`);
184
226
 
185
- // ── 5. Refuse to emit a snapshot that still points at files it does not carry ─
227
+ // ── 5. Rewrite <title> using the resolved project name, when available ──────
228
+ // Only replaces the exact static default — if some earlier step already
229
+ // changed it, there's nothing to do here. No project name resolvable means the
230
+ // static title is left exactly as-is (no "undefined's Dashboard").
231
+ const projectName = resolveProjectName(snapshotData);
232
+ if (projectName) {
233
+ const titleReplaced = html.replace(
234
+ /<title>Jenga AI Dashboard<\/title>/,
235
+ () => `<title>${escapeHtml(projectName)}'s Dashboard</title>`
236
+ );
237
+ if (titleReplaced === html) {
238
+ console.warn(
239
+ 'Warning: could not find the static "<title>Jenga AI Dashboard</title>" to replace — ' +
240
+ 'leaving the title untouched.'
241
+ );
242
+ }
243
+ html = titleReplaced;
244
+ }
245
+
246
+ // ── 6. Refuse to emit a snapshot that still points at files it does not carry ─
186
247
  // Without this, adding (say) a background image to the UI would produce a
187
248
  // snapshot that looks fine here and renders broken on the recipient's machine.
188
249
  const leftovers = [...html.matchAll(/\b(?:src|href)=["'](?!data:|https?:|#|\/\/)([^"']+)["']/gi)]
@@ -5,7 +5,7 @@
5
5
  # Acquire a role-scoped concurrency slot against a single orchestrating
6
6
  # session's counter file, for E32_S15 (Per-Session Concurrency Cap for
7
7
  # Developer/Tester Dispatch). Generalizes the existing per-epic boolean
8
- # bundle lock ("Epic-Level Bundle Lock" in skills/do/SKILL.md) into a
8
+ # bundle lock ("Epic-Level Bundle Lock" in skills/j-do/SKILL.md) into a
9
9
  # per-role bounded counter with multiple named holders.
10
10
  #
11
11
  # The counter file lives at:
@@ -44,11 +44,25 @@
44
44
  # silently skipped, not re-applied or reported as an error. Skills still carrying
45
45
  # the legacy "j:<name>" form are REPAIRED to "j.<name>" rather than skipped —
46
46
  # skipping them would silently leave a Copilot-breaking name in place.
47
+ #
48
+ # Retire-or-fix decision (E50_S14_T02, 2026-09-19): kept, not retired — see
49
+ # docs/skill-authoring.md's "The Canonical Naming Contract" > "Generation" subsection
50
+ # for the full reasoning. Fixed in place for E50_S10's settled contract (canonical
51
+ # directory skills/j-<name>/, frontmatter name: j.<name> — NOT j.j-<name>):
52
+ # - Step 1's directory-name<->frontmatter-name check no longer requires exact
53
+ # string equality; it strips a "j-" directory prefix before comparing, so the
54
+ # settled j-<name>/j.<name> pairing is accepted rather than always rejected.
55
+ # - Step 2's prose rewrite strips a discovered "j-<name>" directory's "j-" prefix
56
+ # before emitting "j.<name>", so it never emits the doubled "j.j-<name>" form.
47
57
 
48
58
  set -euo pipefail
49
59
 
50
60
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
51
- # shellcheck source=lib/resolve-project-dir.sh
61
+ # E50_S14_T02: disabled rather than left as an accepted info-level finding — this
62
+ # script's AC requires a clean default-level shellcheck run, and the sourced file does
63
+ # exist; shellcheck simply will not follow it without -x (same fix already applied to
64
+ # scripts/audit-twin-divergence.sh and scripts/build-pages-site.sh).
65
+ # shellcheck source=lib/resolve-project-dir.sh disable=SC1091
52
66
  source "$SCRIPT_DIR/../lib/resolve-project-dir.sh"
53
67
 
54
68
  PROJECT_DIR="$JENGA_PROJECT_DIR"
@@ -141,17 +155,29 @@ if os.path.isdir(skills_dir):
141
155
  # this is repaired to "j.<name>" rather than skipped as "already migrated".
142
156
  base_name = current_name[2:] if current_name.startswith("j:") else current_name
143
157
 
144
- if base_name != entry:
158
+ # E50_S14_T02: under E50_S10's settled contract the canonical skill directory
159
+ # carries a "j-" prefix (skills/j-<name>/) while its frontmatter name is the
160
+ # UNPREFIXED "j.<name>" — deliberately not "j.j-<name>" (see
161
+ # docs/skill-authoring.md's "The Canonical Naming Contract"). A plain
162
+ # base_name != entry equality would reject every "j-<name>" directory by
163
+ # construction (e.g. directory "j-commit", frontmatter "commit", stripped
164
+ # "commit" != "j-commit"). expected_base strips that one directory-name prefix
165
+ # before comparing, so the check still catches a genuine mismatch (a
166
+ # frontmatter name that matches neither the bare nor the "j-"-stripped
167
+ # directory name) without hard-failing on the settled pairing itself.
168
+ expected_base = entry[2:] if entry.startswith("j-") else entry
169
+
170
+ if base_name != expected_base:
145
171
  errors.append(
146
172
  f"{skill_path}: frontmatter name '{current_name}' does not match "
147
- f"directory name '{entry}' — skipped for safety"
173
+ f"directory name '{entry}' (expected base '{expected_base}') — skipped for safety"
148
174
  )
149
175
  continue
150
176
 
151
177
  if not do_skills:
152
178
  continue
153
179
 
154
- new_line = f"name: j.{entry}\n"
180
+ new_line = f"name: j.{expected_base}\n"
155
181
  changes.append({
156
182
  "type": "frontmatter",
157
183
  "file": skill_path,
@@ -175,6 +201,21 @@ if do_agents and os.path.isdir(agents_dir):
175
201
  "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-:"
176
202
  )
177
203
 
204
+ def canonical_prose_name(discovered_name):
205
+ """E50_S14_T02: skill_names includes every directory under skills/ that has a
206
+ SKILL.md, which — under E50_S10's settled contract — includes "j-<name>"
207
+ canonical directories alongside the three bare-name exceptions (jenga,
208
+ jenga-permission-level; index has no SKILL.md and never reaches this list).
209
+ A literal prose mention of "/j-<name>" is matched on the RAW discovered name
210
+ (so the pattern still finds it), but the emitted replacement must strip the
211
+ "j-" directory prefix before prepending "j." — otherwise this would emit the
212
+ doubled "j.j-<name>" form the settled contract rejects (see
213
+ docs/skill-authoring.md's "The Canonical Naming Contract"). Bare names
214
+ without a "j-" prefix (including the jenga/jenga-permission-level
215
+ exceptions, which never carry one) are returned unchanged.
216
+ """
217
+ return discovered_name[2:] if discovered_name.startswith("j-") else discovered_name
218
+
178
219
  for entry in sorted(os.listdir(agents_dir)):
179
220
  if not entry.endswith(".md"):
180
221
  continue
@@ -198,7 +239,7 @@ if do_agents and os.path.isdir(agents_dir):
198
239
  continue # e.g. "/doc" inside "/doc-sync"
199
240
  matches.append((start, end))
200
241
  for start, end in reversed(matches):
201
- text = text[:start] + f"j.{name}" + text[end:]
242
+ text = text[:start] + f"j.{canonical_prose_name(name)}" + text[end:]
202
243
  file_changes += 1
203
244
 
204
245
  if file_changes:
@@ -13,8 +13,8 @@
13
13
  # twins. A gap in a twin is a gap for every consumer.
14
14
  #
15
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
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
18
  # skills/j-publish/. A public-mirror consumer reported it as "E22_S09_T07 has not been
19
19
  # addressed" while the board read Merged. Both were right.
20
20
  #
@@ -202,6 +202,7 @@ python3 - <<'PY'
202
202
  import difflib
203
203
  import os
204
204
  import re
205
+ import subprocess
205
206
  import sys
206
207
 
207
208
  repo_root = os.environ["REPO_ROOT"]
@@ -209,6 +210,66 @@ want_diff = os.environ.get("OPT_DIFF") == "1"
209
210
  min_pairs = int(os.environ.get("OPT_MIN_PAIRS") or "0")
210
211
  skills_dir = os.path.join(repo_root, "skills")
211
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
+
212
273
  # Pairs the generator refuses outright. See generate-j-alias.sh L110-113 (hard error,
213
274
  # E50_S06_T01) and L118-121 (no SKILL.md).
214
275
  NEVER_TWINNED = ("jenga", "jenga-permission-level", "index")
@@ -242,11 +303,18 @@ def read_bytes(path):
242
303
 
243
304
 
244
305
  def walk_files(root):
245
- out = set()
306
+ rel_by_abs = {}
246
307
  for dirpath, _dirnames, filenames in os.walk(root):
247
308
  for fn in filenames:
248
- out.add(os.path.relpath(os.path.join(dirpath, fn), root))
249
- return out
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
+ }
250
318
 
251
319
 
252
320
  def split_frontmatter(text, label):
@@ -13,7 +13,7 @@
13
13
  # project/.wiki/documentation.md, project/.wiki/intro-guide.md, or
14
14
  # project/.wiki/concepts/*.md, re-run this script to refresh the Pages site
15
15
  # so it doesn't silently drift from the wiki the way README.md and the wiki
16
- # itself have drifted from each other before (see skills/doc-sync/SKILL.md
16
+ # itself have drifted from each other before (see skills/j-doc-sync/SKILL.md
17
17
  # for the doc-sync side of this convention).
18
18
  #
19
19
  # WHAT THIS SCRIPT DOES NOT TOUCH:
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # scripts/check-public-playbook-steps.sh — every public playbook's steps must ship publicly
3
+ # scripts/check-public-playbook-steps.sh — public playbook health guard
4
4
  #
5
5
  # E50_S20_T01. Guards the invariant that a playbook shipped to the public mirror can actually
6
6
  # run there: for each playbook JSON that `.publicignore` does NOT block, every step must resolve
@@ -16,23 +16,80 @@
16
16
  # Blocklist semantics are NOT re-derived here. Classification is delegated to
17
17
  # scripts/check-publicignore-match.sh, which itself reuses /mirror-public's own
18
18
  # `rsync --exclude-from=.publicignore` matching — a second, hand-rolled answer to "is this path
19
- # blocked" is exactly the drift this repo has been bitten by before.
19
+ # blocked" is exactly the drift this repo has been bitten by before. This holds for EVERY source
20
+ # directory scanned below, including the project-local one added by E28_S14_T02.
21
+ #
22
+ # Playbook sources (E28_S14_T02): both directories `load-playbooks.sh` merges into one catalog,
23
+ # in the same order — the framework-owned BUILTIN source `skills/jenga/playbooks/`, and the
24
+ # project-owned PROJECT source `project/.playbooks/` (E53_S09_T01). A missing BUILTIN directory
25
+ # exits 0 with a message (there is nothing to guard at all); a missing PROJECT directory is a
26
+ # SILENT no-op, never an error, matching the loader's own behaviour for the common case of a
27
+ # project with no custom playbooks.
28
+ #
29
+ # As of E28_S14_T01 `.publicignore` blocks `project/.playbooks/` in full, so in this repo every
30
+ # project-local playbook classifies BLOCKED and is counted as private/skipped. That is the
31
+ # expected steady state. Scanning the directory anyway is the point: the invariant then holds
32
+ # MECHANICALLY rather than resting on the blocklist staying as it is — if anyone unblocks the
33
+ # directory later, the guard covers it with no further change here.
20
34
  #
21
35
  # Composed steps: a StepObject of the form {"playbook": "<id>"} composes another playbook. Its
22
36
  # own steps are checked when that playbook is itself public; a public playbook composing a
23
37
  # BLOCKED playbook is a violation in its own right, since the composed id will not resolve in
24
38
  # the mirror (see tests/load-playbooks-composition.bats's nonexistent-playbook-id behaviour).
39
+ # A composed id is resolved against BOTH source directories in order, because load-playbooks.sh
40
+ # merges both into one catalog before resolving compositions — so a composed id can legally live
41
+ # in either, and a public playbook composing a (private) project-local one is a real violation.
42
+ #
43
+ # Terminal-step deny-list (E28_S14_T02, enforcing E53_S10's policy): "is this step blocklisted?"
44
+ # is a LOADABILITY question and cannot catch a publish step, because `j-publish` ships publicly.
45
+ # A public playbook chaining it therefore passes the blocklist check completely clean while
46
+ # violating the policy outright. The DATA block below closes that gap. See docs/skill-authoring.md
47
+ # ("Public Playbooks Terminate at `j-commit`") and docs/public-mirror-content-parity.md.
25
48
  #
26
49
  # Usage:
27
50
  # scripts/check-public-playbook-steps.sh [<repo-root>]
28
51
  #
29
52
  # Exit codes:
30
- # 0 every public playbook's every step (and composed playbook) is public
53
+ # 0 every public playbook's every step (and composed playbook) is public and policy-compliant
31
54
  # 1 at least one violation — each is named on stdout
32
55
  # 2 usage / environment error (bad root, missing helper, unparseable playbook)
33
56
 
34
57
  set -euo pipefail
35
58
 
59
+ # ---------------------------------------------------------------------------
60
+ # DATA — terminal-step deny-list (E28_S14_T02)
61
+ #
62
+ # Standing product decision (user, 2026-09-17, E53_S10), quoted verbatim in every violation
63
+ # this list produces:
64
+ #
65
+ # "No public playbook may contain a publishing or mirroring step; public build chains
66
+ # terminate at `j-commit`."
67
+ #
68
+ # Rationale lives in docs/skill-authoring.md's "Public Playbooks Terminate at `j-commit`"
69
+ # section: most users would not want a playbook that publishes or pushes to a public destination
70
+ # on their behalf, so publishing stays an explicit, separately invoked act. Note the rule forbids
71
+ # publish/mirror STEPS — it is not a requirement that every playbook end at `j-commit`; a
72
+ # read-only or triage chain ending elsewhere publishes nothing and is compliant.
73
+ #
74
+ # TO ADD A SKILL: add one line to this array. That is the whole edit. Nothing in the checking
75
+ # logic below names any individual skill — it asks is_denylisted_step(), which loops this array.
76
+ #
77
+ # Both naming forms are listed on purpose. The canonical directory is `skills/j-<name>/`, but the
78
+ # bare-name directories still exist on disk (deleted by E50_S15), and a playbook step is matched
79
+ # by the directory name it references — so a step could legitimately be written either way until
80
+ # that cutover lands. Over-listing costs nothing here; under-listing is a policy hole.
81
+ # ---------------------------------------------------------------------------
82
+ DENYLISTED_STEPS=(
83
+ j-publish
84
+ publish
85
+ j-mirror-public
86
+ mirror-public
87
+ )
88
+
89
+ # The policy sentence, verbatim. Kept as data alongside the list it explains so a violation
90
+ # message never paraphrases the rule it is enforcing.
91
+ DENYLIST_POLICY='No public playbook may contain a publishing or mirroring step; public build chains terminate at `j-commit`.'
92
+
36
93
  REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
37
94
 
38
95
  if [ ! -d "$REPO_ROOT" ]; then
@@ -40,7 +97,14 @@ if [ ! -d "$REPO_ROOT" ]; then
40
97
  exit 2
41
98
  fi
42
99
 
43
- PLAYBOOKS_DIR="$REPO_ROOT/skills/jenga/playbooks"
100
+ # Playbook source directories, repo-relative, in load-playbooks.sh's own merge order.
101
+ # Index 0 is the BUILTIN source and is required; every later entry is optional (silent no-op).
102
+ PLAYBOOK_SOURCE_DIRS=(
103
+ "skills/jenga/playbooks"
104
+ "project/.playbooks"
105
+ )
106
+
107
+ BUILTIN_PLAYBOOKS_DIR="$REPO_ROOT/${PLAYBOOK_SOURCE_DIRS[0]}"
44
108
  MATCHER="$REPO_ROOT/scripts/check-publicignore-match.sh"
45
109
 
46
110
  if [ ! -f "$REPO_ROOT/.publicignore" ]; then
@@ -53,8 +117,8 @@ if [ ! -f "$MATCHER" ]; then
53
117
  exit 2
54
118
  fi
55
119
 
56
- if [ ! -d "$PLAYBOOKS_DIR" ]; then
57
- echo "check-public-playbook-steps.sh: no playbooks directory at $PLAYBOOKS_DIR — nothing to check"
120
+ if [ ! -d "$BUILTIN_PLAYBOOKS_DIR" ]; then
121
+ echo "check-public-playbook-steps.sh: no playbooks directory at $BUILTIN_PLAYBOOKS_DIR — nothing to check"
58
122
  exit 0
59
123
  fi
60
124
 
@@ -63,27 +127,60 @@ classify() {
63
127
  bash "$MATCHER" "$1" 2>/dev/null | head -1 | cut -f1
64
128
  }
65
129
 
130
+ # is_denylisted_step <step-name> -> exit 0 if the step is on DENYLISTED_STEPS, 1 otherwise.
131
+ # Deliberately the ONLY place the deny-list is consulted, and it names no skill itself.
132
+ is_denylisted_step() {
133
+ local candidate="$1" entry
134
+ for entry in "${DENYLISTED_STEPS[@]}"; do
135
+ if [ "$entry" = "$candidate" ]; then
136
+ return 0
137
+ fi
138
+ done
139
+ return 1
140
+ }
141
+
142
+ # resolve_playbook_rel <playbook-id> -> echoes the repo-relative path of the first source
143
+ # directory containing <id>.json, or nothing if no source has it. Mirrors the fact that
144
+ # load-playbooks.sh merges every source into one catalog before resolving compositions.
145
+ resolve_playbook_rel() {
146
+ local id="$1" dir
147
+ for dir in "${PLAYBOOK_SOURCE_DIRS[@]}"; do
148
+ if [ -f "$REPO_ROOT/$dir/$id.json" ]; then
149
+ printf '%s/%s.json\n' "$dir" "$id"
150
+ return 0
151
+ fi
152
+ done
153
+ return 0
154
+ }
155
+
66
156
  violations=0
67
157
  checked=0
68
158
  skipped_private=0
69
159
 
70
- for pb in "$PLAYBOOKS_DIR"/*.json; do
71
- [ -f "$pb" ] || continue
72
- base="$(basename "$pb")"
73
- # schema.json describes playbooks, it is not one.
74
- [ "$base" = "schema.json" ] && continue
75
-
76
- rel="skills/jenga/playbooks/$base"
77
- if [ "$(classify "$rel")" = "BLOCKED" ]; then
78
- skipped_private=$((skipped_private + 1))
79
- continue
80
- fi
81
-
82
- checked=$((checked + 1))
83
-
84
- # Emit one "<kind>\t<value>" line per step. A bare string and {"skill": ...} are both skills;
85
- # {"playbook": ...} is a composition.
86
- steps="$(python3 -c '
160
+ for source_dir in "${PLAYBOOK_SOURCE_DIRS[@]}"; do
161
+ abs_source_dir="$REPO_ROOT/$source_dir"
162
+ # A missing optional source (in practice project/.playbooks/) is a no-op, not an error —
163
+ # the same silent behaviour load-playbooks.sh gives it. The required BUILTIN source was
164
+ # already checked above.
165
+ [ -d "$abs_source_dir" ] || continue
166
+
167
+ for pb in "$abs_source_dir"/*.json; do
168
+ [ -f "$pb" ] || continue
169
+ base="$(basename "$pb")"
170
+ # schema.json describes playbooks, it is not one.
171
+ [ "$base" = "schema.json" ] && continue
172
+
173
+ rel="$source_dir/$base"
174
+ if [ "$(classify "$rel")" = "BLOCKED" ]; then
175
+ skipped_private=$((skipped_private + 1))
176
+ continue
177
+ fi
178
+
179
+ checked=$((checked + 1))
180
+
181
+ # Emit one "<kind>\t<value>" line per step. A bare string and {"skill": ...} are both skills;
182
+ # {"playbook": ...} is a composition.
183
+ steps="$(python3 -c '
87
184
  import json, sys
88
185
  with open(sys.argv[1]) as fh:
89
186
  pb = json.load(fh)
@@ -96,35 +193,44 @@ for s in pb.get("steps", []):
96
193
  elif "playbook" in s:
97
194
  print("playbook\t" + str(s["playbook"]))
98
195
  ' "$pb")" || {
99
- echo "check-public-playbook-steps.sh: error: could not parse $rel" >&2
100
- exit 2
101
- }
102
-
103
- while IFS=$'\t' read -r kind value; do
104
- [ -n "${kind:-}" ] || continue
105
- case "$kind" in
106
- skill)
107
- target="skills/$value/SKILL.md"
108
- if [ ! -f "$REPO_ROOT/$target" ]; then
109
- echo "VIOLATION $rel -> step '$value': no $target on disk"
110
- violations=$((violations + 1))
111
- elif [ "$(classify "$target")" = "BLOCKED" ]; then
112
- echo "VIOLATION $rel -> step '$value': $target is blocklisted, so this public playbook cannot load in the mirror"
113
- violations=$((violations + 1))
114
- fi
115
- ;;
116
- playbook)
117
- target="skills/jenga/playbooks/$value.json"
118
- if [ ! -f "$REPO_ROOT/$target" ]; then
119
- echo "VIOLATION $rel -> composes '$value': no $target on disk"
120
- violations=$((violations + 1))
121
- elif [ "$(classify "$target")" = "BLOCKED" ]; then
122
- echo "VIOLATION $rel -> composes '$value': $target is blocklisted, so the composed id will not resolve in the mirror"
123
- violations=$((violations + 1))
124
- fi
125
- ;;
126
- esac
127
- done <<< "$steps"
196
+ echo "check-public-playbook-steps.sh: error: could not parse $rel" >&2
197
+ exit 2
198
+ }
199
+
200
+ while IFS=$'\t' read -r kind value; do
201
+ [ -n "${kind:-}" ] || continue
202
+ case "$kind" in
203
+ skill)
204
+ # Policy first, and exclusive: a deny-listed step yields exactly one violation even
205
+ # when it is ALSO blocklisted (j-mirror-public is both). The policy breach is the more
206
+ # fundamental statement about the playbook, so it is the one reported.
207
+ if is_denylisted_step "$value"; then
208
+ echo "VIOLATION $rel -> step '$value': publishing/mirroring step in a public playbook — policy (E53_S10): \"$DENYLIST_POLICY\""
209
+ violations=$((violations + 1))
210
+ continue
211
+ fi
212
+ target="skills/$value/SKILL.md"
213
+ if [ ! -f "$REPO_ROOT/$target" ]; then
214
+ echo "VIOLATION $rel -> step '$value': no $target on disk"
215
+ violations=$((violations + 1))
216
+ elif [ "$(classify "$target")" = "BLOCKED" ]; then
217
+ echo "VIOLATION $rel -> step '$value': $target is blocklisted, so this public playbook cannot load in the mirror"
218
+ violations=$((violations + 1))
219
+ fi
220
+ ;;
221
+ playbook)
222
+ target="$(resolve_playbook_rel "$value")"
223
+ if [ -z "$target" ]; then
224
+ echo "VIOLATION $rel -> composes '$value': no $value.json under ${PLAYBOOK_SOURCE_DIRS[*]}"
225
+ violations=$((violations + 1))
226
+ elif [ "$(classify "$target")" = "BLOCKED" ]; then
227
+ echo "VIOLATION $rel -> composes '$value': $target is blocklisted, so the composed id will not resolve in the mirror"
228
+ violations=$((violations + 1))
229
+ fi
230
+ ;;
231
+ esac
232
+ done <<< "$steps"
233
+ done
128
234
  done
129
235
 
130
236
  if [ "$violations" -gt 0 ]; then
@@ -5,13 +5,13 @@
5
5
  # Classify one or more repo-relative paths as PUBLIC (would ship to the
6
6
  # public mirror repo) or BLOCKED (excluded by .publicignore), using the
7
7
  # exact same rsync --exclude-from=.publicignore matching semantics as
8
- # skills/mirror-public/scripts/mirror.sh's --dry-run "ship list" computation
8
+ # skills/j-mirror-public/scripts/mirror.sh's --dry-run "ship list" computation
9
9
  # (see the SHIP_LIST_FILE block in that script). A file classified as
10
10
  # "would be blocked" by `/mirror-public --dry-run` is guaranteed to be
11
11
  # classified as BLOCKED here too, and vice versa for PUBLIC.
12
12
  #
13
13
  # This does NOT touch the network, clone the public repo, or require
14
- # /mirror-public to be configured (skills/mirror-public/assets/config.json
14
+ # /mirror-public to be configured (skills/j-mirror-public/assets/config.json
15
15
  # is never read) — it only needs a .publicignore file at the repo root.
16
16
  # Rsync's include/exclude filter evaluation does not depend on destination
17
17
  # state (destination state only affects delete/itemize-flag details for
@@ -15,7 +15,7 @@
15
15
  # scripts/ rather than duplicated into one skill's scripts/ folder.
16
16
  #
17
17
  # Reads:
18
- # skills/mirror-public/assets/config.json
18
+ # skills/j-mirror-public/assets/config.json
19
19
  # - publicRepoUrl : URL of the public downstream repo (unauthenticated,
20
20
  # public repo — no credential required)
21
21
  # - worktreePath : scratch worktree path (relative to repo root) that
@@ -50,7 +50,7 @@
50
50
  # tag, `git log -1 --format=%B <tag>` is run against whichever clone/fetch
51
51
  # supplied that tag's commit, and the `Source-Commit: <full-sha>` trailer
52
52
  # line is extracted — the exact trailer key/format
53
- # skills/mirror-public/scripts/mirror.sh already writes
53
+ # skills/j-mirror-public/scripts/mirror.sh already writes
54
54
  # (`COMMIT_TRAILER="Source-Commit: $PRIVATE_FULL_SHA"`, a 40-char lowercase
55
55
  # hex SHA). A tag whose commit has no such trailer is skipped with a stderr
56
56
  # warning, not a script error (defensive — should not happen given
@@ -126,8 +126,8 @@ fi
126
126
 
127
127
  # -----------------------------------------------------------------------------
128
128
  # Locate script + repo root (same symlink-resolution + repo-root derivation
129
- # pattern as skills/mirror-public/scripts/mirror.sh and
130
- # skills/self-sync/scripts/compute-sync-diff.sh).
129
+ # pattern as skills/j-mirror-public/scripts/mirror.sh and
130
+ # skills/j-self-sync/scripts/compute-sync-diff.sh).
131
131
  # -----------------------------------------------------------------------------
132
132
 
133
133
  SCRIPT_PATH="${BASH_SOURCE[0]}"
@@ -143,7 +143,7 @@ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
143
143
  REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
144
144
  [ -n "$REPO_ROOT" ] || die "could not locate repo root (git rev-parse failed from $SCRIPT_DIR)"
145
145
 
146
- CONFIG_FILE="$REPO_ROOT/skills/mirror-public/assets/config.json"
146
+ CONFIG_FILE="$REPO_ROOT/skills/j-mirror-public/assets/config.json"
147
147
  [ -f "$CONFIG_FILE" ] || die "config not found: $CONFIG_FILE"
148
148
 
149
149
  WITH_LOCK_SCRIPT="$REPO_ROOT/scripts/with-lock.sh"