@jenga-ai/agent 3.2.0 → 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.
- package/README.md +52 -12
- package/agents/developer.md +16 -1
- package/agents/scrum-master.md +1 -0
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +6 -2
- package/package.json +21 -2
- package/project/app/api/lib/resolve-project-root.js +120 -0
- package/project/app/api/package.json +16 -0
- package/project/app/api/parsers/architecture.js +72 -0
- package/project/app/api/parsers/board.js +141 -0
- package/project/app/api/parsers/documentation.js +125 -0
- package/project/app/api/parsers/git-log.js +52 -0
- package/project/app/api/parsers/ideas.js +62 -0
- package/project/app/api/parsers/knowledge-graph.js +73 -0
- package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
- package/project/app/api/parsers/rapports.js +148 -0
- package/project/app/api/parsers/todo.js +179 -0
- package/project/app/api/response.js +47 -0
- package/project/app/api/routes/architecture.js +23 -0
- package/project/app/api/routes/board.js +46 -0
- package/project/app/api/routes/documentation.js +24 -0
- package/project/app/api/routes/health.js +25 -0
- package/project/app/api/routes/history.js +55 -0
- package/project/app/api/routes/rapports.js +24 -0
- package/project/app/api/scripts/capture-snapshot.js +294 -0
- package/project/app/api/server.js +112 -0
- package/project/app/api/types.js +40 -0
- package/project/app/package.json +21 -0
- package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
- package/project/app/ui/dist/index.html +13 -0
- package/project/app/ui/package.json +23 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
- package/project/app/ui/scripts/dashboard-open.cjs +88 -0
- package/project/app/ui/scripts/dashboard-start.cjs +87 -0
- package/scripts/acquire-concurrency-slot.sh +220 -0
- package/scripts/compute-deploy-reconcile.sh +439 -0
- package/scripts/jenga-permission-level-switch.sh +19 -3
- package/scripts/mark-deployed.sh +532 -0
- package/scripts/populate-knowledge-graph.js +429 -0
- package/scripts/release-concurrency-slot.sh +129 -0
- package/scripts/validate-board.sh +60 -2
- package/scripts/verify-consumer-install.sh +470 -0
- package/skills/j-cloud-connect/SKILL.md +95 -0
- package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
- package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
- package/skills/j-dashboard/SKILL.md +144 -0
- package/skills/j-dashboard/scripts/launch.sh +121 -0
- package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
- package/skills/j-dashboard/scripts/snapshot.sh +267 -0
- package/skills/j-dashboard-share/SKILL.md +96 -0
- package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
- package/skills/j-playbook/SKILL.md +12 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +123 -24
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# scripts/acquire-concurrency-slot.sh
|
|
4
|
+
#
|
|
5
|
+
# Acquire a role-scoped concurrency slot against a single orchestrating
|
|
6
|
+
# session's counter file, for E32_S15 (Per-Session Concurrency Cap for
|
|
7
|
+
# Developer/Tester Dispatch). Generalizes the existing per-epic boolean
|
|
8
|
+
# bundle lock ("Epic-Level Bundle Lock" in skills/do/SKILL.md) into a
|
|
9
|
+
# per-role bounded counter with multiple named holders.
|
|
10
|
+
#
|
|
11
|
+
# The counter file lives at:
|
|
12
|
+
# project/queue/concurrency-slots-<session_id>.json
|
|
13
|
+
# and has the shape:
|
|
14
|
+
# {
|
|
15
|
+
# "developer": {"cap": <int>, "holders": {"<holder-id>": "<ISO ts>"}},
|
|
16
|
+
# "tester": {"cap": <int>, "holders": {"<holder-id>": "<ISO ts>"}}
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# The counter file belongs to exactly the session_id in its own filename.
|
|
20
|
+
# This script never reads or writes a counter file for any session_id other
|
|
21
|
+
# than the one passed as its own argument.
|
|
22
|
+
#
|
|
23
|
+
# Behavior:
|
|
24
|
+
# 1. Before the cap check, any holder entry in the target role's holders
|
|
25
|
+
# map older than slot_ttl_minutes (project/configs/scope-thresholds.json)
|
|
26
|
+
# is dropped ("stale reclaim") — guards against a crashed subagent
|
|
27
|
+
# leaking its slot within THIS session; not a cross-session mechanism.
|
|
28
|
+
# 2. If the role's holder count (after reclaim) is >= its cap, this script
|
|
29
|
+
# exits non-zero and mutates the counter file NOT AT ALL (not even to
|
|
30
|
+
# persist the stale-reclaim result) — a denied acquire is a pure no-op
|
|
31
|
+
# on disk.
|
|
32
|
+
# 3. Otherwise a "<holder-id>: <current ISO timestamp>" entry is added
|
|
33
|
+
# under the role and the script exits 0. If the counter file doesn't
|
|
34
|
+
# exist yet, it is created and seeded with both roles' cap values (read
|
|
35
|
+
# fresh from scope-thresholds.json) and empty holders maps.
|
|
36
|
+
#
|
|
37
|
+
# The whole reclaim + cap-check + mutate sequence runs as ONE atomic critical
|
|
38
|
+
# section under a single scripts/with-lock.sh invocation (this script
|
|
39
|
+
# re-invokes itself with an internal subcommand as the wrapped command) —
|
|
40
|
+
# never as two separate locked calls — so there is no TOCTOU gap between
|
|
41
|
+
# checking the cap and writing the new holder entry.
|
|
42
|
+
#
|
|
43
|
+
# Usage:
|
|
44
|
+
# scripts/acquire-concurrency-slot.sh <role> <holder-id> <session_id>
|
|
45
|
+
#
|
|
46
|
+
# Exit codes:
|
|
47
|
+
# 0 slot acquired — holder entry added (or counter file created + seeded)
|
|
48
|
+
# 1 usage error (bad arguments)
|
|
49
|
+
# 2 scripts/with-lock.sh could not acquire the lock within its timeout —
|
|
50
|
+
# the acquire attempt never ran, counter file untouched
|
|
51
|
+
# 3 role is at/over cap after stale-reclaim — slot NOT acquired, counter
|
|
52
|
+
# file untouched (no mutation, per acceptance criteria)
|
|
53
|
+
# 5 environment error (jq missing, scope-thresholds.json missing/invalid,
|
|
54
|
+
# project root unresolvable)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
|
|
59
|
+
SELF="$(basename "$0")"
|
|
60
|
+
|
|
61
|
+
die() {
|
|
62
|
+
local code="$1"; shift
|
|
63
|
+
printf '%s: error: %s\n' "$SELF" "$*" >&2
|
|
64
|
+
exit "$code"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
usage() {
|
|
68
|
+
echo "Usage: $0 <role> <holder-id> <session_id>" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Resolve project root (mirrors scripts/write-context-digest.sh's own
|
|
74
|
+
# probing order: JENGA_PROJECT_DIR -> CLAUDE_PROJECT_DIR -> git toplevel ->
|
|
75
|
+
# pwd — kept consistent so this script behaves the same whether invoked
|
|
76
|
+
# directly, from a skill, or from another script).
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
resolve_project_dir() {
|
|
79
|
+
if [ -n "${JENGA_PROJECT_DIR:-}" ]; then
|
|
80
|
+
printf '%s\n' "$JENGA_PROJECT_DIR"
|
|
81
|
+
return 0
|
|
82
|
+
fi
|
|
83
|
+
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
84
|
+
printf '%s\n' "$CLAUDE_PROJECT_DIR"
|
|
85
|
+
return 0
|
|
86
|
+
fi
|
|
87
|
+
git rev-parse --show-toplevel 2>/dev/null || pwd
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Absolute path to this script itself, so the lock-holding re-invocation
|
|
91
|
+
# below works regardless of how this script was originally invoked (a
|
|
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]}")"
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Internal locked-mutation entrypoint. Not intended to be invoked directly —
|
|
97
|
+
# scripts/with-lock.sh calls back into this same script with this hidden
|
|
98
|
+
# subcommand once (and only once) the lock on the counter file is held. This
|
|
99
|
+
# is what makes reclaim + cap-check + mutate a single atomic operation
|
|
100
|
+
# instead of two racy locked calls.
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
if [ "${1:-}" = "__locked-acquire" ]; then
|
|
103
|
+
shift
|
|
104
|
+
ROLE="$1"
|
|
105
|
+
HOLDER_ID="$2"
|
|
106
|
+
COUNTER_FILE="$3"
|
|
107
|
+
CONFIG_FILE="$4"
|
|
108
|
+
|
|
109
|
+
command -v jq >/dev/null 2>&1 || die 5 "jq is required but not found on PATH"
|
|
110
|
+
[ -f "$CONFIG_FILE" ] || die 5 "config file not found: $CONFIG_FILE"
|
|
111
|
+
|
|
112
|
+
DEV_CAP="$(jq -r '.max_concurrent_developers // empty' "$CONFIG_FILE")"
|
|
113
|
+
TESTER_CAP="$(jq -r '.max_concurrent_testers // empty' "$CONFIG_FILE")"
|
|
114
|
+
TTL_MINUTES="$(jq -r '.slot_ttl_minutes // empty' "$CONFIG_FILE")"
|
|
115
|
+
|
|
116
|
+
case "$DEV_CAP" in ''|*[!0-9]*) die 5 "invalid or missing max_concurrent_developers in $CONFIG_FILE" ;; esac
|
|
117
|
+
case "$TESTER_CAP" in ''|*[!0-9]*) die 5 "invalid or missing max_concurrent_testers in $CONFIG_FILE" ;; esac
|
|
118
|
+
case "$TTL_MINUTES" in ''|*[!0-9]*) die 5 "invalid or missing slot_ttl_minutes in $CONFIG_FILE" ;; esac
|
|
119
|
+
|
|
120
|
+
case "$ROLE" in
|
|
121
|
+
developer) CAP="$DEV_CAP" ;;
|
|
122
|
+
tester) CAP="$TESTER_CAP" ;;
|
|
123
|
+
*) die 1 "invalid role '$ROLE' (expected developer or tester)" ;;
|
|
124
|
+
esac
|
|
125
|
+
|
|
126
|
+
TTL_SECONDS=$(( TTL_MINUTES * 60 ))
|
|
127
|
+
NOW_EPOCH="$(date -u +%s)"
|
|
128
|
+
NOW_ISO="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
129
|
+
|
|
130
|
+
# Base document: the existing counter file, or an in-memory seed if the
|
|
131
|
+
# file doesn't exist yet. The seed is NOT written to disk here — it only
|
|
132
|
+
# gets persisted below, and only on a successful acquire.
|
|
133
|
+
if [ -f "$COUNTER_FILE" ]; then
|
|
134
|
+
BASE_JSON="$(cat "$COUNTER_FILE")"
|
|
135
|
+
else
|
|
136
|
+
BASE_JSON="$(jq -n \
|
|
137
|
+
--argjson dev_cap "$DEV_CAP" \
|
|
138
|
+
--argjson tester_cap "$TESTER_CAP" \
|
|
139
|
+
'{developer: {cap: $dev_cap, holders: {}}, tester: {cap: $tester_cap, holders: {}}}')"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# Compute the reclaimed+decision document entirely in memory. Nothing is
|
|
143
|
+
# written to $COUNTER_FILE by this jq invocation.
|
|
144
|
+
DECISION_JSON="$(printf '%s' "$BASE_JSON" | jq \
|
|
145
|
+
--arg role "$ROLE" \
|
|
146
|
+
--arg holder "$HOLDER_ID" \
|
|
147
|
+
--arg now_iso "$NOW_ISO" \
|
|
148
|
+
--argjson now_epoch "$NOW_EPOCH" \
|
|
149
|
+
--argjson ttl_seconds "$TTL_SECONDS" \
|
|
150
|
+
--argjson cap "$CAP" \
|
|
151
|
+
'
|
|
152
|
+
# Self-heal the role cap to the freshly-read config value, and drop any
|
|
153
|
+
# stale holder (older than ttl_seconds) before the cap check.
|
|
154
|
+
.[$role].cap = $cap
|
|
155
|
+
| .[$role].holders = ((.[$role].holders // {})
|
|
156
|
+
| with_entries(select((.value | fromdateiso8601) >= ($now_epoch - $ttl_seconds))))
|
|
157
|
+
| (.[$role].holders | length) as $count
|
|
158
|
+
| if $count >= $cap then
|
|
159
|
+
{ok: false, doc: .}
|
|
160
|
+
else
|
|
161
|
+
{ok: true, doc: (.[$role].holders[$holder] = $now_iso)}
|
|
162
|
+
end
|
|
163
|
+
')"
|
|
164
|
+
|
|
165
|
+
OK="$(printf '%s' "$DECISION_JSON" | jq -r '.ok')"
|
|
166
|
+
|
|
167
|
+
if [ "$OK" != "true" ]; then
|
|
168
|
+
# At/over cap: exit non-zero, counter file untouched (no write at all).
|
|
169
|
+
exit 3
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# Success: persist atomically (temp file + rename in the same directory).
|
|
173
|
+
mkdir -p "$(dirname "$COUNTER_FILE")" 2>/dev/null || true
|
|
174
|
+
TMP_FILE="$(mktemp "${COUNTER_FILE}.tmp.XXXXXX")"
|
|
175
|
+
printf '%s' "$DECISION_JSON" | jq '.doc' > "$TMP_FILE"
|
|
176
|
+
mv "$TMP_FILE" "$COUNTER_FILE"
|
|
177
|
+
exit 0
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Public entrypoint
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
[ "$#" -eq 3 ] || usage
|
|
184
|
+
|
|
185
|
+
ROLE="$1"
|
|
186
|
+
HOLDER_ID="$2"
|
|
187
|
+
SESSION_ID="$3"
|
|
188
|
+
|
|
189
|
+
case "$ROLE" in
|
|
190
|
+
developer|tester) ;;
|
|
191
|
+
*) die 1 "invalid role '$ROLE' (expected developer or tester)" ;;
|
|
192
|
+
esac
|
|
193
|
+
|
|
194
|
+
[ -n "$HOLDER_ID" ] || die 1 "<holder-id> must not be empty"
|
|
195
|
+
[ -n "$SESSION_ID" ] || die 1 "<session_id> must not be empty"
|
|
196
|
+
|
|
197
|
+
# session_id is used verbatim in a filename below — refuse path separators
|
|
198
|
+
# or traversal rather than silently mangling or escaping it (mirrors
|
|
199
|
+
# scripts/write-context-digest.sh's own path-safety checks).
|
|
200
|
+
case "$SESSION_ID" in
|
|
201
|
+
*/*|*..*) die 1 "invalid <session_id> '$SESSION_ID': must not contain '/' or '..'" ;;
|
|
202
|
+
esac
|
|
203
|
+
|
|
204
|
+
PROJECT_DIR="$(resolve_project_dir)"
|
|
205
|
+
COUNTER_FILE="$PROJECT_DIR/project/queue/concurrency-slots-${SESSION_ID}.json"
|
|
206
|
+
CONFIG_FILE="$PROJECT_DIR/project/configs/scope-thresholds.json"
|
|
207
|
+
|
|
208
|
+
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"
|
|
211
|
+
|
|
212
|
+
mkdir -p "$PROJECT_DIR/project/queue" 2>/dev/null || true
|
|
213
|
+
|
|
214
|
+
set +e
|
|
215
|
+
"$PROJECT_DIR/scripts/with-lock.sh" "$COUNTER_FILE" -- \
|
|
216
|
+
"$SCRIPT_ABS_PATH" __locked-acquire "$ROLE" "$HOLDER_ID" "$COUNTER_FILE" "$CONFIG_FILE"
|
|
217
|
+
STATUS=$?
|
|
218
|
+
set -e
|
|
219
|
+
|
|
220
|
+
exit "$STATUS"
|
|
@@ -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
|
-
|
|
67
|
-
|
|
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
|
|