@jenga-ai/agent 3.5.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.
- package/README.md +85 -78
- package/agents/developer.md +1 -1
- package/agents/scrum-master.md +20 -2
- package/agents/tester.md +3 -3
- package/hooks/on_session_end.sh +5 -5
- package/lib/generate-agent-context.js +2 -2
- package/lib/generate-copilot-hooks.js +1 -1
- package/lib/generate-skill-allow-list.js +37 -3
- package/lib/mirror.js +1 -1
- package/lib/postinstall-manifest.js +1 -1
- package/lib/skill-allow-list.json +2 -2
- package/mcp/help/index.js +8 -17
- package/mcp/help/scan.js +73 -0
- package/package.json +6 -1
- package/project/app/api/parsers/knowledge-graph.js +100 -9
- package/project/app/api/routes/health.js +36 -0
- package/project/app/api/scripts/capture-snapshot.js +9 -6
- package/project/app/ui/dist/assets/{index-CdK3Qrep.css → index-BVR_7Owg.css} +1 -1
- package/project/app/ui/dist/assets/index-CtU2xLQm.js +104 -0
- package/project/app/ui/dist/index.html +2 -2
- package/project/app/ui/package.json +4 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
- package/scripts/acquire-concurrency-slot.sh +1 -1
- package/scripts/apply-j-prefix.sh +46 -5
- package/scripts/audit-twin-divergence.sh +73 -5
- package/scripts/build-pages-site.sh +1 -1
- package/scripts/check-public-playbook-steps.sh +158 -52
- package/scripts/check-publicignore-match.sh +2 -2
- package/scripts/compute-deploy-reconcile.sh +5 -5
- package/scripts/delete-bare-skill-dirs.sh +330 -0
- package/scripts/generate-legacy-shipped-paths.js +2 -2
- package/scripts/idea_manager.sh +258 -3
- package/scripts/mark-deployed.sh +2 -2
- package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
- package/scripts/populate-knowledge-graph.js +213 -5
- package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
- package/scripts/postinstall.js +1 -1
- package/scripts/repoint-skill-refs.sh +539 -0
- package/scripts/todo_manager.sh +1 -1
- package/scripts/verify-legacy-seed-reconcile.sh +10 -10
- package/scripts/verify-postinstall-reconcile.sh +7 -7
- package/scripts/write-context-digest.sh +1 -1
- package/skills/j-clearify/SKILL.md +2 -2
- package/skills/j-close-story/scripts/check-privatized.sh +4 -4
- package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
- package/skills/j-do/SKILL.md +101 -17
- package/skills/j-doc-sync/SKILL.md +1 -0
- package/skills/j-gitignore/SKILL.md +157 -0
- package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
- package/skills/j-gitignore/scripts/_catalog.sh +105 -0
- package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
- package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
- package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
- package/skills/j-idea/SKILL.md +78 -6
- package/skills/j-idea/assets/idea_template.md +1 -1
- package/skills/j-improve/SKILL.md +1 -1
- package/skills/j-init/SKILL.md +3 -3
- package/skills/j-init/assets/scope-thresholds_template.json +5 -2
- package/skills/j-init/scripts/apply-scaffold-visibility.sh +1 -1
- package/skills/j-init/scripts/init.sh +4 -4
- package/skills/j-playbook/SKILL.md +1 -1
- package/skills/j-publish/SKILL.md +1 -1
- package/skills/j-publish/adapters/npm-ci.md +6 -1
- package/skills/j-publish/adapters/npm.md +1 -1
- package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
- package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
- package/skills/j-reconcile/SKILL.md +2 -2
- package/skills/j-reconcile/scripts/detect-unlinked-code.sh +11 -11
- package/skills/j-redo/SKILL.md +1 -1
- package/skills/j-spinoff/SKILL.md +1 -1
- package/skills/j-status/SKILL.md +15 -0
- package/skills/j-todo/SKILL.md +3 -1
- package/skills/j-uncharted/SKILL.md +1 -1
- package/skills/j-uncharted/scripts/detect-dependencies.sh +1 -1
- package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
- package/skills/j-uncharted/scripts/elicitation-state.sh +1 -1
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
- package/skills/j-wtf/SKILL.md +1 -1
- package/skills/jenga/SKILL.md +43 -9
- package/skills/jenga/playbooks/board-hygiene.json +32 -0
- package/skills/jenga/playbooks/schema.json +73 -6
- package/skills/jenga/playbooks/understand-then-commit.json +19 -0
- package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
- package/skills/jenga/scripts/load-playbooks.sh +1 -1
- package/skills/jenga/scripts/match-playbook.sh +1 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
- package/templates/SKILL_TEMPLATE.md +12 -0
- package/templates/permission-levels/level-4-elevated.json +1 -1
- package/templates/permission-levels/level-5-unrestricted.json +1 -1
- package/templates/playbook-types.json +34 -6
- package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
- package/scripts/generate-j-alias.sh +0 -333
- package/skills/j-dev-done/SKILL.md +0 -53
- 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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
|
@@ -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.
|
|
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, '&')
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>');
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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.{
|
|
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
|
-
|
|
306
|
+
rel_by_abs = {}
|
|
246
307
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
247
308
|
for fn in filenames:
|
|
248
|
-
|
|
249
|
-
|
|
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 —
|
|
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
|
-
|
|
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 "$
|
|
57
|
-
echo "check-public-playbook-steps.sh: no playbooks directory at $
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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"
|