@jenga-ai/agent 3.5.0 → 4.0.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 (136) 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 +79 -8
  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 +5 -1
  15. package/project/app/api/lib/resolve-project-root.js +1 -1
  16. package/project/app/api/parsers/knowledge-graph.js +100 -9
  17. package/project/app/api/routes/health.js +36 -0
  18. package/project/app/api/scripts/capture-snapshot.js +9 -6
  19. package/project/app/package.json +4 -0
  20. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  21. package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
  22. package/project/app/ui/dist/index.html +2 -2
  23. package/project/app/ui/package.json +4 -0
  24. package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
  25. package/scripts/acquire-concurrency-slot.sh +35 -5
  26. package/scripts/apply-j-prefix.sh +46 -5
  27. package/scripts/build-pages-site.sh +1 -1
  28. package/scripts/check-public-playbook-steps.sh +158 -52
  29. package/scripts/check-publicignore-match.sh +2 -2
  30. package/scripts/compute-deploy-reconcile.sh +5 -5
  31. package/scripts/delete-bare-skill-dirs.sh +329 -0
  32. package/scripts/generate-legacy-shipped-paths.js +2 -2
  33. package/scripts/idea_manager.sh +273 -3
  34. package/scripts/mark-deployed.sh +2 -2
  35. package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
  36. package/scripts/populate-knowledge-graph.js +213 -5
  37. package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
  38. package/scripts/postinstall.js +1 -1
  39. package/scripts/release-concurrency-slot.sh +34 -4
  40. package/scripts/render-ranked-list.sh +270 -0
  41. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  42. package/scripts/repoint-skill-refs.sh +539 -0
  43. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  44. package/scripts/strip-polyfill-frontmatter.py +166 -0
  45. package/scripts/todo_manager.sh +16 -1
  46. package/scripts/validate-typed-object.sh +750 -0
  47. package/scripts/verify-legacy-seed-reconcile.sh +10 -10
  48. package/scripts/verify-postinstall-reconcile.sh +7 -7
  49. package/scripts/write-context-digest.sh +1 -1
  50. package/skills/j-brainstorm/SKILL.md +3 -4
  51. package/skills/j-btw/SKILL.md +3 -4
  52. package/skills/j-clearify/SKILL.md +5 -6
  53. package/skills/j-close-story/SKILL.md +11 -12
  54. package/skills/j-close-story/scripts/check-privatized.sh +4 -4
  55. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  56. package/skills/j-commit/SKILL.md +3 -4
  57. package/skills/j-continue/SKILL.md +5 -6
  58. package/skills/j-deep-dive/SKILL.md +3 -4
  59. package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
  60. package/skills/j-distribute/SKILL.md +3 -4
  61. package/skills/j-do/SKILL.md +100 -18
  62. package/skills/j-doc/SKILL.md +3 -4
  63. package/skills/j-doc-sync/SKILL.md +4 -4
  64. package/skills/j-dooo/SKILL.md +6 -15
  65. package/skills/j-error/SKILL.md +3 -4
  66. package/skills/j-evaluate/SKILL.md +3 -4
  67. package/skills/j-examplify/SKILL.md +3 -4
  68. package/skills/j-gitignore/SKILL.md +157 -0
  69. package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
  70. package/skills/j-gitignore/scripts/_catalog.sh +105 -0
  71. package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
  72. package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
  73. package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
  74. package/skills/j-help/SKILL.md +3 -4
  75. package/skills/j-idea/SKILL.md +80 -9
  76. package/skills/j-idea/assets/idea_template.md +1 -1
  77. package/skills/j-improve/SKILL.md +4 -5
  78. package/skills/j-init/SKILL.md +23 -14
  79. package/skills/j-init/assets/scope-thresholds_template.json +5 -2
  80. package/skills/j-init/scripts/apply-scaffold-visibility.sh +9 -7
  81. package/skills/j-init/scripts/init.sh +4 -4
  82. package/skills/j-jbp/SKILL.md +3 -4
  83. package/skills/j-lgtm/SKILL.md +3 -4
  84. package/skills/j-pi-plan/SKILL.md +3 -4
  85. package/skills/j-playbook/SKILL.md +1 -1
  86. package/skills/j-proceed/SKILL.md +3 -4
  87. package/skills/j-publish/SKILL.md +4 -5
  88. package/skills/j-publish/adapters/npm-ci.md +6 -1
  89. package/skills/j-publish/adapters/npm.md +1 -1
  90. package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
  91. package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
  92. package/skills/j-publish/scripts/run_gates.sh +1 -1
  93. package/skills/j-reconcile/SKILL.md +40 -7
  94. package/skills/j-reconcile/assets/report_format.md +11 -0
  95. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +13 -13
  96. package/skills/j-reconcile-origin/SKILL.md +3 -4
  97. package/skills/j-redo/SKILL.md +4 -5
  98. package/skills/j-skillify/SKILL.md +3 -4
  99. package/skills/j-spinoff/SKILL.md +4 -5
  100. package/skills/j-status/SKILL.md +18 -4
  101. package/skills/j-todo/SKILL.md +44 -5
  102. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  103. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  104. package/skills/j-uncharted/SKILL.md +252 -13
  105. package/skills/j-uncharted/scripts/detect-dependencies.sh +80 -22
  106. package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
  107. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  108. package/skills/j-uncharted/scripts/elicitation-state.sh +1 -1
  109. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  110. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  111. package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
  112. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  113. package/skills/j-wtf/SKILL.md +4 -5
  114. package/skills/jenga/SKILL.md +106 -11
  115. package/skills/jenga/playbooks/board-hygiene.json +32 -0
  116. package/skills/jenga/playbooks/schema.json +73 -6
  117. package/skills/jenga/playbooks/understand-then-commit.json +19 -0
  118. package/skills/jenga/scripts/load-nl-catalog.js +5 -2
  119. package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
  120. package/skills/jenga/scripts/load-playbooks.sh +290 -5
  121. package/skills/jenga/scripts/match-playbook.sh +4 -4
  122. package/skills/jenga/scripts/run-playbook-step.sh +267 -1
  123. package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
  124. package/templates/SKILL_TEMPLATE.md +12 -0
  125. package/templates/permission-levels/level-1-locked.json +1 -1
  126. package/templates/permission-levels/level-2-guarded.json +1 -1
  127. package/templates/permission-levels/level-3-standard.json +1 -1
  128. package/templates/permission-levels/level-4-elevated.json +2 -2
  129. package/templates/permission-levels/level-5-unrestricted.json +2 -2
  130. package/templates/playbook-types.json +40 -6
  131. package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
  132. package/project/app/ui/dist/assets/index-CdK3Qrep.css +0 -1
  133. package/scripts/audit-twin-divergence.sh +0 -625
  134. package/scripts/generate-j-alias.sh +0 -333
  135. package/skills/j-dev-done/SKILL.md +0 -53
  136. 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-C3oiuli_.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BADc5mmH.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:
@@ -90,7 +90,37 @@ resolve_project_dir() {
90
90
  # Absolute path to this script itself, so the lock-holding re-invocation
91
91
  # below works regardless of how this script was originally invoked (a
92
92
  # relative path, a PATH lookup, etc.) and regardless of cwd.
93
- SCRIPT_ABS_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
93
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
94
+ SCRIPT_ABS_PATH="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Resolve with-lock.sh.
98
+ #
99
+ # scripts/postinstall.js mirrors only skills/ and agents/ into a consumer's
100
+ # .claude/ and .agents/ — scripts/ (which owns with-lock.sh) is never copied
101
+ # there, so a consumer install has no <consumer>/scripts/with-lock.sh at all.
102
+ # Probing "$PROJECT_DIR/scripts/with-lock.sh" alone therefore hard-fails every
103
+ # consumer install, even though the file ships fine inside the package.
104
+ #
105
+ # The sibling probe is what fixes that: with-lock.sh always lands in the same
106
+ # directory as this script, in a monorepo checkout AND inside the installed
107
+ # npm package. The remaining two tiers mirror the three-tier shape already
108
+ # used by skills/j-uncharted/scripts/elicitation-state.sh.
109
+ # ---------------------------------------------------------------------------
110
+ resolve_with_lock() {
111
+ local candidate
112
+ for candidate in \
113
+ "$SCRIPT_DIR/with-lock.sh" \
114
+ "$PROJECT_DIR/scripts/with-lock.sh" \
115
+ "$PROJECT_DIR/node_modules/@jenga-ai/agent/scripts/with-lock.sh"
116
+ do
117
+ if [ -f "$candidate" ]; then
118
+ printf '%s\n' "$candidate"
119
+ return 0
120
+ fi
121
+ done
122
+ return 1
123
+ }
94
124
 
95
125
  # ---------------------------------------------------------------------------
96
126
  # Internal locked-mutation entrypoint. Not intended to be invoked directly —
@@ -206,13 +236,13 @@ COUNTER_FILE="$PROJECT_DIR/project/queue/concurrency-slots-${SESSION_ID}.json"
206
236
  CONFIG_FILE="$PROJECT_DIR/project/configs/scope-thresholds.json"
207
237
 
208
238
  command -v jq >/dev/null 2>&1 || die 5 "jq is required but not found on PATH"
209
- [ -x "$PROJECT_DIR/scripts/with-lock.sh" ] || [ -f "$PROJECT_DIR/scripts/with-lock.sh" ] \
210
- || die 5 "scripts/with-lock.sh not found at $PROJECT_DIR/scripts/with-lock.sh"
239
+ WITH_LOCK="$(resolve_with_lock)" \
240
+ || die 5 "with-lock.sh not found (looked in $SCRIPT_DIR, $PROJECT_DIR/scripts, and $PROJECT_DIR/node_modules/@jenga-ai/agent/scripts)"
211
241
 
212
242
  mkdir -p "$PROJECT_DIR/project/queue" 2>/dev/null || true
213
243
 
214
244
  set +e
215
- "$PROJECT_DIR/scripts/with-lock.sh" "$COUNTER_FILE" -- \
245
+ "$WITH_LOCK" "$COUNTER_FILE" -- \
216
246
  "$SCRIPT_ABS_PATH" __locked-acquire "$ROLE" "$HOLDER_ID" "$COUNTER_FILE" "$CONFIG_FILE"
217
247
  STATUS=$?
218
248
  set -e
@@ -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/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,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"