@jenga-ai/agent 3.1.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +31 -16
  3. package/agents/scrum-master.md +18 -17
  4. package/agents/tester.md +25 -15
  5. package/bin/jenga.js +10 -0
  6. package/lib/commands/dashboard.js +92 -0
  7. package/lib/skill-allow-list.json +7 -2
  8. package/package.json +21 -2
  9. package/project/app/api/lib/resolve-project-root.js +120 -0
  10. package/project/app/api/package.json +16 -0
  11. package/project/app/api/parsers/architecture.js +72 -0
  12. package/project/app/api/parsers/board.js +141 -0
  13. package/project/app/api/parsers/documentation.js +125 -0
  14. package/project/app/api/parsers/git-log.js +52 -0
  15. package/project/app/api/parsers/ideas.js +62 -0
  16. package/project/app/api/parsers/knowledge-graph.js +73 -0
  17. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  18. package/project/app/api/parsers/rapports.js +148 -0
  19. package/project/app/api/parsers/todo.js +179 -0
  20. package/project/app/api/response.js +47 -0
  21. package/project/app/api/routes/architecture.js +23 -0
  22. package/project/app/api/routes/board.js +46 -0
  23. package/project/app/api/routes/documentation.js +24 -0
  24. package/project/app/api/routes/health.js +25 -0
  25. package/project/app/api/routes/history.js +55 -0
  26. package/project/app/api/routes/rapports.js +24 -0
  27. package/project/app/api/scripts/capture-snapshot.js +294 -0
  28. package/project/app/api/server.js +112 -0
  29. package/project/app/api/types.js +40 -0
  30. package/project/app/package.json +21 -0
  31. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  32. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  33. package/project/app/ui/dist/index.html +13 -0
  34. package/project/app/ui/package.json +23 -0
  35. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  36. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  37. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  38. package/scripts/acquire-concurrency-slot.sh +220 -0
  39. package/scripts/audit-twin-divergence.sh +625 -0
  40. package/scripts/check-public-playbook-steps.sh +136 -0
  41. package/scripts/compute-deploy-reconcile.sh +439 -0
  42. package/scripts/jenga-permission-level-switch.sh +19 -3
  43. package/scripts/mark-deployed.sh +532 -0
  44. package/scripts/populate-knowledge-graph.js +429 -0
  45. package/scripts/release-concurrency-slot.sh +129 -0
  46. package/scripts/validate-board.sh +60 -2
  47. package/scripts/verify-consumer-install.sh +470 -0
  48. package/skills/j-close-story/SKILL.md +1 -1
  49. package/skills/j-cloud-connect/SKILL.md +95 -0
  50. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  51. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  52. package/skills/j-dashboard/SKILL.md +144 -0
  53. package/skills/j-dashboard/scripts/launch.sh +121 -0
  54. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  55. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  56. package/skills/j-dashboard-share/SKILL.md +96 -0
  57. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  58. package/skills/j-do/SKILL.md +19 -19
  59. package/skills/j-doc-sync/SKILL.md +12 -1
  60. package/skills/j-idea/SKILL.md +1 -1
  61. package/skills/j-init/SKILL.md +5 -4
  62. package/skills/j-init/assets/directory_structure.txt +1 -0
  63. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  64. package/skills/j-init/scripts/init.sh +13 -2
  65. package/skills/j-playbook/SKILL.md +93 -0
  66. package/skills/j-playbook-new/SKILL.md +155 -0
  67. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  68. package/skills/j-proceed/SKILL.md +1 -1
  69. package/skills/j-publish/SKILL.md +1 -1
  70. package/skills/j-publish/adapters/npm-ci.md +29 -0
  71. package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
  72. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  73. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  74. package/skills/j-reconcile/SKILL.md +1 -0
  75. package/skills/j-redo/SKILL.md +1 -1
  76. package/skills/j-status/SKILL.md +12 -0
  77. package/skills/j-todo/SKILL.md +2 -2
  78. package/skills/j-uncharted/SKILL.md +8 -7
  79. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  80. package/skills/jenga/SKILL.md +55 -16
  81. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  82. package/skills/jenga/playbooks/schema.json +1 -1
  83. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  84. package/skills/jenga/scripts/load-playbooks.sh +968 -41
  85. package/skills/jenga/scripts/match-playbook.sh +1 -1
  86. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  87. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  88. package/skills/jenga-permission-level/SKILL.md +4 -4
  89. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  90. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
  91. package/templates/playbook-types.json +8 -0
  92. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # scripts/check-public-playbook-steps.sh — every public playbook's steps must ship publicly
4
+ #
5
+ # E50_S20_T01. Guards the invariant that a playbook shipped to the public mirror can actually
6
+ # run there: for each playbook JSON that `.publicignore` does NOT block, every step must resolve
7
+ # to a skill directory that `.publicignore` also does not block.
8
+ #
9
+ # Why this exists: `skills/jenga/scripts/load-playbooks.sh` requires every step's
10
+ # `skills/<dir>/SKILL.md` to exist, and silently SKIPS the whole playbook (stderr warning only)
11
+ # when one is missing. In the private repo every skill is present, so a playbook referencing a
12
+ # private-only skill looks perfectly healthy — the breakage appears only in the public mirror,
13
+ # where it surfaced as a stage-deploy gate failure rather than as a test failure (E50_S13).
14
+ # This check reproduces that condition statically, on the private side, before anything ships.
15
+ #
16
+ # Blocklist semantics are NOT re-derived here. Classification is delegated to
17
+ # scripts/check-publicignore-match.sh, which itself reuses /mirror-public's own
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.
20
+ #
21
+ # Composed steps: a StepObject of the form {"playbook": "<id>"} composes another playbook. Its
22
+ # own steps are checked when that playbook is itself public; a public playbook composing a
23
+ # BLOCKED playbook is a violation in its own right, since the composed id will not resolve in
24
+ # the mirror (see tests/load-playbooks-composition.bats's nonexistent-playbook-id behaviour).
25
+ #
26
+ # Usage:
27
+ # scripts/check-public-playbook-steps.sh [<repo-root>]
28
+ #
29
+ # Exit codes:
30
+ # 0 every public playbook's every step (and composed playbook) is public
31
+ # 1 at least one violation — each is named on stdout
32
+ # 2 usage / environment error (bad root, missing helper, unparseable playbook)
33
+
34
+ set -euo pipefail
35
+
36
+ REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
37
+
38
+ if [ ! -d "$REPO_ROOT" ]; then
39
+ echo "check-public-playbook-steps.sh: error: not a directory: $REPO_ROOT" >&2
40
+ exit 2
41
+ fi
42
+
43
+ PLAYBOOKS_DIR="$REPO_ROOT/skills/jenga/playbooks"
44
+ MATCHER="$REPO_ROOT/scripts/check-publicignore-match.sh"
45
+
46
+ if [ ! -f "$REPO_ROOT/.publicignore" ]; then
47
+ echo "check-public-playbook-steps.sh: no .publicignore at $REPO_ROOT — nothing to enforce, skipping"
48
+ exit 0
49
+ fi
50
+
51
+ if [ ! -f "$MATCHER" ]; then
52
+ echo "check-public-playbook-steps.sh: error: missing $MATCHER (the single source of blocklist semantics)" >&2
53
+ exit 2
54
+ fi
55
+
56
+ if [ ! -d "$PLAYBOOKS_DIR" ]; then
57
+ echo "check-public-playbook-steps.sh: no playbooks directory at $PLAYBOOKS_DIR — nothing to check"
58
+ exit 0
59
+ fi
60
+
61
+ # classify <path-relative-to-repo-root> -> echoes PUBLIC or BLOCKED
62
+ classify() {
63
+ bash "$MATCHER" "$1" 2>/dev/null | head -1 | cut -f1
64
+ }
65
+
66
+ violations=0
67
+ checked=0
68
+ skipped_private=0
69
+
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 '
87
+ import json, sys
88
+ with open(sys.argv[1]) as fh:
89
+ pb = json.load(fh)
90
+ for s in pb.get("steps", []):
91
+ if isinstance(s, str):
92
+ print("skill\t" + s)
93
+ elif isinstance(s, dict):
94
+ if "skill" in s:
95
+ print("skill\t" + str(s["skill"]))
96
+ elif "playbook" in s:
97
+ print("playbook\t" + str(s["playbook"]))
98
+ ' "$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"
128
+ done
129
+
130
+ if [ "$violations" -gt 0 ]; then
131
+ echo "check-public-playbook-steps.sh: $violations violation(s) across $checked public playbook(s) ($skipped_private private playbook(s) not checked)"
132
+ exit 1
133
+ fi
134
+
135
+ echo "check-public-playbook-steps.sh: OK — $checked public playbook(s) checked, every step ships publicly ($skipped_private private playbook(s) skipped)"
136
+ exit 0
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/compute-deploy-reconcile.sh
4
+ #
5
+ # Computes "which public jenga-npm stage/prod tags has this private repo not
6
+ # yet reconciled, and what private-repo SHA does each one resolve to" — the
7
+ # read-only discovery half of E51_S05's "Deployed to Stage"/"Deployed to
8
+ # Prod" pipeline. E51_S05_T02 consumes this script's stdout to do the actual
9
+ # ticket-matching and board-status writes; this script never touches the
10
+ # board and never advances the marker file itself.
11
+ #
12
+ # Shared, non-skill-scoped location (like scripts/board_resolver.sh and
13
+ # scripts/validate-board.sh): both /self-sync and /status will invoke this
14
+ # script (E51_S05_T03, not yet dispatched), so it lives directly under
15
+ # scripts/ rather than duplicated into one skill's scripts/ folder.
16
+ #
17
+ # Reads:
18
+ # skills/mirror-public/assets/config.json
19
+ # - publicRepoUrl : URL of the public downstream repo (unauthenticated,
20
+ # public repo — no credential required)
21
+ # - worktreePath : scratch worktree path (relative to repo root) that
22
+ # /mirror-public maintains, reused here read-only if
23
+ # present
24
+ # project/data/deploy-reconcile-marker.json (read-only, via
25
+ # scripts/with-lock.sh — same locking discipline already required for
26
+ # project/queue/capacity-block-state-*.json)
27
+ # - reconciled_tags : array of tag names already processed by a prior
28
+ # E51_S05_T02 run. Any tag NOT in this array is
29
+ # reported by this script. This script NEVER writes
30
+ # this file — advancing it is E51_S05_T02's job,
31
+ # only after its own board-write pass succeeds.
32
+ #
33
+ # Tag discovery:
34
+ # - If the mirror-public scratch clone at <worktreePath> already exists on
35
+ # disk (i.e. /mirror-public has run in this environment before), reuse
36
+ # it: `git -C <worktree> fetch --tags origin` (read-only), then list
37
+ # local tags.
38
+ # - Otherwise, fall back to `git ls-remote --tags <publicRepoUrl>` for
39
+ # discovery, then a per-tag `git fetch --depth 1 <publicRepoUrl>
40
+ # refs/tags/<tag>:refs/tags/<tag>` into a throwaway scratch bare repo to
41
+ # pull just that tag's commit object. Both paths are unauthenticated
42
+ # (jenga-npm is public).
43
+ #
44
+ # Tags are filtered to exactly two shapes (anything else is silently
45
+ # ignored, not an error):
46
+ # vX.Y.Z-stage -> tag_type "stage"
47
+ # vX.Y.Z -> tag_type "prod"
48
+ #
49
+ # Source-Commit resolution: for each not-yet-reconciled, pattern-matching
50
+ # tag, `git log -1 --format=%B <tag>` is run against whichever clone/fetch
51
+ # supplied that tag's commit, and the `Source-Commit: <full-sha>` trailer
52
+ # line is extracted — the exact trailer key/format
53
+ # skills/mirror-public/scripts/mirror.sh already writes
54
+ # (`COMMIT_TRAILER="Source-Commit: $PRIVATE_FULL_SHA"`, a 40-char lowercase
55
+ # hex SHA). A tag whose commit has no such trailer is skipped with a stderr
56
+ # warning, not a script error (defensive — should not happen given
57
+ # E51_S04_T01's implementation, but a hand-pushed or malformed tag is
58
+ # possible).
59
+ #
60
+ # This script does NOT write to project/data/deploy-reconcile-marker.json.
61
+ # It only computes and prints — matching the established deferred-write
62
+ # convention already used by compute-sync-diff.sh (self-sync) and
63
+ # compute-publicize-diff.sh (mirror-public).
64
+ #
65
+ # Graceful degradation: if the public repo is unreachable (any non-zero exit
66
+ # from the discovery ls-remote/fetch calls), this is NEVER a fatal error —
67
+ # log a warning to stderr and exit 0 with empty stdout, so callers see "ran,
68
+ # nothing to reconcile" and "network unreachable" identically.
69
+ #
70
+ # Output contract:
71
+ # stdout — machine-parseable: one line per resolved, not-yet-reconciled
72
+ # tag, tab-separated:
73
+ # <tag_type>\t<version>\t<public_tag_name>\t<resolved_private_sha>
74
+ # tag_type is "stage" or "prod". Nothing else on stdout, ever.
75
+ # stderr — all human-readable log/diagnostic/warning output.
76
+ #
77
+ # Exit codes:
78
+ # 0 success — includes "nothing to reconcile" AND "network unreachable"
79
+ # 1 usage error / missing required config or script dependency
80
+ # ---------------------------------------------------------------------------
81
+
82
+ set -uo pipefail
83
+ IFS=$'\n\t'
84
+
85
+ # -----------------------------------------------------------------------------
86
+ # Helpers
87
+ # -----------------------------------------------------------------------------
88
+
89
+ die() {
90
+ printf 'compute-deploy-reconcile.sh: error: %s\n' "$*" >&2
91
+ exit 1
92
+ }
93
+
94
+ log() {
95
+ printf 'compute-deploy-reconcile.sh: %s\n' "$*" >&2
96
+ }
97
+
98
+ warn() {
99
+ printf 'compute-deploy-reconcile.sh: WARNING: %s\n' "$*" >&2
100
+ }
101
+
102
+ usage() {
103
+ cat <<'EOF'
104
+ Usage: compute-deploy-reconcile.sh [-h|--help]
105
+
106
+ Discovers vX.Y.Z-stage / vX.Y.Z tags on the public jenga-npm repo that are
107
+ not yet recorded in project/data/deploy-reconcile-marker.json's
108
+ reconciled_tags array, resolves each to a private-repo SHA via its commit's
109
+ Source-Commit: trailer, and prints one TSV line per resolved tag to stdout:
110
+
111
+ <tag_type>\t<version>\t<public_tag_name>\t<resolved_private_sha>
112
+
113
+ All logging goes to stderr. If the public repo is unreachable, this script
114
+ logs a warning and exits 0 with empty stdout (never a hard error). This
115
+ script never writes project/data/deploy-reconcile-marker.json — advancing it
116
+ is the caller's (E51_S05_T02's) responsibility.
117
+ EOF
118
+ }
119
+
120
+ if [ $# -gt 0 ]; then
121
+ case "$1" in
122
+ -h|--help) usage; exit 0 ;;
123
+ *) usage >&2; die "unknown argument: $1" ;;
124
+ esac
125
+ fi
126
+
127
+ # -----------------------------------------------------------------------------
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).
131
+ # -----------------------------------------------------------------------------
132
+
133
+ SCRIPT_PATH="${BASH_SOURCE[0]}"
134
+ while [ -h "$SCRIPT_PATH" ]; do
135
+ LINK_TARGET="$(readlink "$SCRIPT_PATH")"
136
+ case "$LINK_TARGET" in
137
+ /*) SCRIPT_PATH="$LINK_TARGET" ;;
138
+ *) SCRIPT_PATH="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)/$LINK_TARGET" ;;
139
+ esac
140
+ done
141
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
142
+
143
+ REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
144
+ [ -n "$REPO_ROOT" ] || die "could not locate repo root (git rev-parse failed from $SCRIPT_DIR)"
145
+
146
+ CONFIG_FILE="$REPO_ROOT/skills/mirror-public/assets/config.json"
147
+ [ -f "$CONFIG_FILE" ] || die "config not found: $CONFIG_FILE"
148
+
149
+ WITH_LOCK_SCRIPT="$REPO_ROOT/scripts/with-lock.sh"
150
+ [ -f "$WITH_LOCK_SCRIPT" ] || die "expected script not found: $WITH_LOCK_SCRIPT"
151
+
152
+ MARKER_FILE="$REPO_ROOT/project/data/deploy-reconcile-marker.json"
153
+
154
+ # -----------------------------------------------------------------------------
155
+ # Config field reader (jq preferred, python3 fallback) — same helper pattern
156
+ # as mirror.sh / compute-publicize-diff.sh.
157
+ # -----------------------------------------------------------------------------
158
+
159
+ read_config_field() {
160
+ local field="$1"
161
+ if command -v jq >/dev/null 2>&1; then
162
+ jq -er --arg f "$field" '.[$f]' "$CONFIG_FILE" 2>/dev/null || return 1
163
+ elif command -v python3 >/dev/null 2>&1; then
164
+ python3 - "$CONFIG_FILE" "$field" <<'PY' || return 1
165
+ import json, sys
166
+ path, field = sys.argv[1], sys.argv[2]
167
+ with open(path) as fh:
168
+ data = json.load(fh)
169
+ if field not in data:
170
+ sys.exit(1)
171
+ sys.stdout.write(str(data[field]))
172
+ PY
173
+ else
174
+ die "neither jq nor python3 available to parse $CONFIG_FILE"
175
+ fi
176
+ }
177
+
178
+ PUBLIC_URL="$(read_config_field publicRepoUrl)" || die "config missing publicRepoUrl"
179
+ WORKTREE_REL="$(read_config_field worktreePath)" || die "config missing worktreePath"
180
+
181
+ # Env override for local-remote testing, mirroring mirror.sh's own
182
+ # MIRROR_PUBLIC_URL_OVERRIDE convention (reused verbatim here rather than
183
+ # inventing a second override variable name).
184
+ if [ -n "${MIRROR_PUBLIC_URL_OVERRIDE:-}" ]; then
185
+ log "using MIRROR_PUBLIC_URL_OVERRIDE=$MIRROR_PUBLIC_URL_OVERRIDE (config value ignored)"
186
+ PUBLIC_URL="$MIRROR_PUBLIC_URL_OVERRIDE"
187
+ fi
188
+
189
+ case "$WORKTREE_REL" in
190
+ /*) WORKTREE_PATH="$WORKTREE_REL" ;;
191
+ *) WORKTREE_PATH="$REPO_ROOT/$WORKTREE_REL" ;;
192
+ esac
193
+
194
+ log "repo root: $REPO_ROOT"
195
+ log "public URL: $PUBLIC_URL"
196
+ log "worktree path: $WORKTREE_PATH"
197
+ log "marker file: $MARKER_FILE"
198
+
199
+ # -----------------------------------------------------------------------------
200
+ # Read the marker file's reconciled_tags array (read-only, lock-protected).
201
+ #
202
+ # Absence of the marker file is not an error — it means nothing has been
203
+ # reconciled yet (empty set). This script never creates or writes the file;
204
+ # that stays E51_S05_T02's job.
205
+ # -----------------------------------------------------------------------------
206
+
207
+ read_reconciled_tags() {
208
+ if [ ! -f "$MARKER_FILE" ]; then
209
+ log "marker file not found at $MARKER_FILE — treating reconciled_tags as empty"
210
+ return 0
211
+ fi
212
+
213
+ local reader_result
214
+ if command -v jq >/dev/null 2>&1; then
215
+ reader_result="$("$WITH_LOCK_SCRIPT" "$MARKER_FILE" -- jq -r '.reconciled_tags[]? // empty' "$MARKER_FILE" 2>/dev/null)" || {
216
+ warn "failed to read $MARKER_FILE under lock — treating reconciled_tags as empty"
217
+ return 0
218
+ }
219
+ elif command -v python3 >/dev/null 2>&1; then
220
+ reader_result="$("$WITH_LOCK_SCRIPT" "$MARKER_FILE" -- python3 -c '
221
+ import json, sys
222
+ try:
223
+ with open(sys.argv[1]) as fh:
224
+ data = json.load(fh)
225
+ for t in (data.get("reconciled_tags") or []):
226
+ print(t)
227
+ except Exception:
228
+ pass
229
+ ' "$MARKER_FILE" 2>/dev/null)" || {
230
+ warn "failed to read $MARKER_FILE under lock — treating reconciled_tags as empty"
231
+ return 0
232
+ }
233
+ else
234
+ die "neither jq nor python3 available to parse $MARKER_FILE"
235
+ fi
236
+
237
+ printf '%s' "$reader_result"
238
+ }
239
+
240
+ RECONCILED_TAGS_RAW="$(read_reconciled_tags)"
241
+
242
+ is_already_reconciled() {
243
+ local tag="$1"
244
+ [ -n "$RECONCILED_TAGS_RAW" ] || return 1
245
+ local line
246
+ while IFS= read -r line; do
247
+ [ -n "$line" ] || continue
248
+ if [ "$line" = "$tag" ]; then
249
+ return 0
250
+ fi
251
+ done <<EOF
252
+ $RECONCILED_TAGS_RAW
253
+ EOF
254
+ return 1
255
+ }
256
+
257
+ # -----------------------------------------------------------------------------
258
+ # Tag pattern filter — only vX.Y.Z-stage or vX.Y.Z survive. Anything else
259
+ # (e.g. last-mirror-sync, pre-force-*, or a malformed tag) is silently
260
+ # ignored, not an error.
261
+ #
262
+ # Prints "stage" or "prod" on match (to stdout of this function call), or
263
+ # nothing + returns 1 if the tag doesn't match either shape.
264
+ # -----------------------------------------------------------------------------
265
+
266
+ classify_tag() {
267
+ local tag="$1"
268
+ case "$tag" in
269
+ v[0-9]*.[0-9]*.[0-9]*-stage)
270
+ # Defensive: reject anything with characters beyond digits/dots in the
271
+ # numeric portion (e.g. v1.2.3rc1-stage) by re-validating with a
272
+ # stricter regex via case globbing is awkward, so use a regex here.
273
+ if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-stage$ ]]; then
274
+ printf 'stage\n'
275
+ return 0
276
+ fi
277
+ ;;
278
+ v[0-9]*.[0-9]*.[0-9]*)
279
+ if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
280
+ printf 'prod\n'
281
+ return 0
282
+ fi
283
+ ;;
284
+ esac
285
+ return 1
286
+ }
287
+
288
+ version_from_tag() {
289
+ local tag="$1"
290
+ local ver="$tag"
291
+ ver="${ver#v}"
292
+ ver="${ver%-stage}"
293
+ printf '%s\n' "$ver"
294
+ }
295
+
296
+ # -----------------------------------------------------------------------------
297
+ # Source-Commit trailer extraction — reads `git log -1 --format=%B <tag>`
298
+ # against the given git dir and greps for the exact trailer key/format
299
+ # mirror.sh writes: `Source-Commit: <40-char-lowercase-hex-sha>`. Prints the
300
+ # SHA on match, returns 1 (no output) if absent.
301
+ # -----------------------------------------------------------------------------
302
+
303
+ extract_source_commit() {
304
+ local git_dir="$1" tag="$2"
305
+ local body sha
306
+ body="$(git -C "$git_dir" log -1 --format=%B "$tag" 2>/dev/null)" || return 1
307
+ sha="$(printf '%s\n' "$body" | grep -E '^Source-Commit: [0-9a-f]{40}$' | head -1 | sed 's/^Source-Commit: //')"
308
+ [ -n "$sha" ] || return 1
309
+ printf '%s\n' "$sha"
310
+ }
311
+
312
+ # -----------------------------------------------------------------------------
313
+ # Discovery + resolution
314
+ #
315
+ # Two paths:
316
+ # A) Reuse existing mirror-public scratch clone (fetch --tags, read local
317
+ # tags directly from it).
318
+ # B) Bootstrap path: ls-remote for discovery, then a throwaway scratch bare
319
+ # repo + per-tag shallow fetch to pull just the commit object needed.
320
+ #
321
+ # Any network failure in either path triggers graceful degradation (warn +
322
+ # exit 0 + empty stdout) rather than a hard error.
323
+ # -----------------------------------------------------------------------------
324
+
325
+ RESULTS=() # each entry: "tag_type<TAB>version<TAB>tag<TAB>sha"
326
+
327
+ process_candidate_tag() {
328
+ local git_dir="$1" tag="$2"
329
+ local tag_type
330
+ tag_type="$(classify_tag "$tag")" || return 0 # not a recognized shape — silently skip
331
+
332
+ if is_already_reconciled "$tag"; then
333
+ log "skip (already reconciled): $tag"
334
+ return 0
335
+ fi
336
+
337
+ local sha
338
+ if ! sha="$(extract_source_commit "$git_dir" "$tag")"; then
339
+ warn "tag '$tag' has no Source-Commit: trailer on its commit — skipping"
340
+ return 0
341
+ fi
342
+
343
+ local version
344
+ version="$(version_from_tag "$tag")"
345
+ RESULTS+=("$tag_type"$'\t'"$version"$'\t'"$tag"$'\t'"$sha")
346
+ log "resolved: $tag_type $version $tag -> $sha"
347
+ }
348
+
349
+ SCRATCH_BARE=""
350
+ cleanup_scratch() {
351
+ if [ -n "$SCRATCH_BARE" ] && [ -d "$SCRATCH_BARE" ]; then
352
+ rm -rf "$SCRATCH_BARE"
353
+ fi
354
+ }
355
+ trap cleanup_scratch EXIT
356
+
357
+ if [ -d "$WORKTREE_PATH/.git" ]; then
358
+ log "reusing existing mirror-public scratch clone at $WORKTREE_PATH"
359
+ if ! git -C "$WORKTREE_PATH" fetch --tags origin >/dev/null 2>&1; then
360
+ warn "could not reach public repo $PUBLIC_URL (fetch --tags failed against $WORKTREE_PATH); skipping deploy-reconcile this run."
361
+ exit 0
362
+ fi
363
+
364
+ TAG_LIST="$(git -C "$WORKTREE_PATH" tag -l 'v*' 2>/dev/null || true)"
365
+ while IFS= read -r tag; do
366
+ [ -n "$tag" ] || continue
367
+ process_candidate_tag "$WORKTREE_PATH" "$tag"
368
+ done <<EOF
369
+ $TAG_LIST
370
+ EOF
371
+
372
+ else
373
+ log "no existing scratch clone at $WORKTREE_PATH — falling back to ls-remote + targeted fetch"
374
+
375
+ LS_REMOTE_OUTPUT="$(git ls-remote --tags "$PUBLIC_URL" 2>/dev/null)"
376
+ LS_REMOTE_STATUS=$?
377
+ if [ "$LS_REMOTE_STATUS" -ne 0 ]; then
378
+ warn "could not reach public repo $PUBLIC_URL; skipping deploy-reconcile this run."
379
+ exit 0
380
+ fi
381
+
382
+ # Parse refs/tags/<name> out of each line, stripping any ^{} peeled
383
+ # dereference suffix (annotated-tag dereference entries), and dedupe.
384
+ CANDIDATE_TAGS="$(printf '%s\n' "$LS_REMOTE_OUTPUT" \
385
+ | awk '{print $2}' \
386
+ | sed -n 's#^refs/tags/##p' \
387
+ | sed 's/\^{}$//' \
388
+ | sort -u)"
389
+
390
+ if [ -z "$CANDIDATE_TAGS" ]; then
391
+ log "no tags found on public repo"
392
+ exit 0
393
+ fi
394
+
395
+ SCRATCH_BARE="$(mktemp -d -t deploy-reconcile-scratch.XXXXXX)"
396
+ git init -q --bare "$SCRATCH_BARE"
397
+
398
+ FETCH_FAILED=0
399
+ while IFS= read -r tag; do
400
+ [ -n "$tag" ] || continue
401
+
402
+ # Only bother fetching tags that match the allowed shapes AND are not
403
+ # already reconciled — minimizes network calls in the bootstrap path.
404
+ tag_type="$(classify_tag "$tag")" || continue
405
+ if is_already_reconciled "$tag"; then
406
+ log "skip (already reconciled): $tag"
407
+ continue
408
+ fi
409
+
410
+ if ! git -C "$SCRATCH_BARE" fetch --depth 1 "$PUBLIC_URL" "refs/tags/$tag:refs/tags/$tag" >/dev/null 2>&1; then
411
+ warn "could not reach public repo $PUBLIC_URL (targeted fetch of tag '$tag' failed); skipping deploy-reconcile this run."
412
+ FETCH_FAILED=1
413
+ break
414
+ fi
415
+
416
+ process_candidate_tag "$SCRATCH_BARE" "$tag"
417
+ done <<EOF
418
+ $CANDIDATE_TAGS
419
+ EOF
420
+
421
+ if [ "$FETCH_FAILED" -eq 1 ]; then
422
+ exit 0
423
+ fi
424
+ fi
425
+
426
+ # -----------------------------------------------------------------------------
427
+ # Emit results
428
+ # -----------------------------------------------------------------------------
429
+
430
+ if [ "${#RESULTS[@]}" -eq 0 ]; then
431
+ log "nothing to reconcile"
432
+ exit 0
433
+ fi
434
+
435
+ for line in "${RESULTS[@]}"; do
436
+ printf '%s\n' "$line"
437
+ done
438
+
439
+ exit 0
@@ -63,14 +63,30 @@ case "$LEVEL" in
63
63
  5) NAME="unrestricted" ;;
64
64
  esac
65
65
 
66
- REPO_ROOT="$(git rev-parse --show-toplevel)"
67
- TEMPLATE="$REPO_ROOT/templates/permission-levels/level-${LEVEL}-${NAME}.json"
66
+ # PACKAGE_ROOT locates package-shipped assets (templates/). This script always
67
+ # lives at scripts/, one level below the package root, in both layouts:
68
+ # - this monorepo checkout
69
+ # - a consumer install, inside node_modules/@jenga-ai/agent/
70
+ # BASH_SOURCE-derived resolution is exact in both cases — no probing needed.
71
+ PACKAGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
72
+ TEMPLATE="$PACKAGE_ROOT/templates/permission-levels/level-${LEVEL}-${NAME}.json"
73
+
74
+ # REPO_ROOT locates the consumer's own repo — always the destination for
75
+ # .claude/settings.json, .agents/settings.json, and .jenga-permission-level.json,
76
+ # never node_modules/. Guarded against a non-git directory (set -euo pipefail
77
+ # would otherwise abort on git's raw stderr) following the existing pattern in
78
+ # scripts/check-public-playbook-steps.sh.
79
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
80
+ if [[ ! -d "$REPO_ROOT" ]]; then
81
+ echo "Error: could not resolve a repository root (not a git repository and pwd is unusable): $REPO_ROOT" >&2
82
+ exit 2
83
+ fi
68
84
  CLAUDE_SETTINGS="$REPO_ROOT/.claude/settings.json"
69
85
  AGENTS_SETTINGS="$REPO_ROOT/.agents/settings.json"
70
86
  LEVEL_FILE="$REPO_ROOT/.jenga-permission-level.json"
71
87
 
72
88
  if [[ ! -f "$TEMPLATE" ]]; then
73
- echo "Error: template file not found for level $LEVEL: $TEMPLATE" >&2
89
+ echo "Error: template file not found for level $LEVEL. Searched: $TEMPLATE (resolved from PACKAGE_ROOT: $PACKAGE_ROOT)" >&2
74
90
  exit 2
75
91
  fi
76
92