@jenga-ai/agent 1.1.0 → 1.2.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 (91) hide show
  1. package/README.md +7 -3
  2. package/agents/developer.md +82 -2
  3. package/agents/scrum-master.md +140 -21
  4. package/agents/tester.md +90 -8
  5. package/hooks/on_session_end.sh +171 -20
  6. package/package.json +1 -1
  7. package/scripts/check-permission-level.sh +107 -0
  8. package/scripts/check-publicignore-match.sh +122 -0
  9. package/scripts/check-worktree-liveness.sh +193 -0
  10. package/scripts/generate-rapport-manifest.sh +43 -0
  11. package/scripts/idea_manager.sh +47 -0
  12. package/scripts/install-worktree-commit-guard.sh +134 -0
  13. package/scripts/jenga-permission-level-switch.sh +109 -0
  14. package/scripts/smoke-harness.sh +139 -0
  15. package/scripts/validate-board.sh +62 -0
  16. package/scripts/with-lock.sh +158 -0
  17. package/scripts/worktree-remove-guard.sh +204 -0
  18. package/skills/clearify/SKILL.md +52 -0
  19. package/skills/commit/SKILL.md +13 -4
  20. package/skills/distribute/CONFIG_SCHEMA.md +60 -2
  21. package/skills/do/SKILL.md +48 -11
  22. package/skills/doc-sync/SKILL.md +16 -0
  23. package/skills/doc-sync/assets/doc_targets.md +11 -0
  24. package/skills/idea/SKILL.md +56 -0
  25. package/skills/idea/assets/idea_handoff_template.md +26 -0
  26. package/skills/idea/assets/idea_template.md +3 -0
  27. package/skills/init/SKILL.md +100 -7
  28. package/skills/init/assets/directory_structure.txt +1 -0
  29. package/skills/init/assets/workflow_template.json +1 -1
  30. package/skills/init/scripts/apply-project-visibility.sh +176 -0
  31. package/skills/init/scripts/detect-existing-codebase.sh +166 -0
  32. package/skills/init/scripts/init.sh +30 -1
  33. package/skills/jenga/SKILL.md +160 -17
  34. package/skills/jenga/scripts/board-scan.sh +238 -0
  35. package/skills/jenga/scripts/cascade-resolve.sh +297 -0
  36. package/skills/jenga/scripts/render-confirmation.sh +679 -0
  37. package/skills/jenga/scripts/render-picker.sh +439 -0
  38. package/skills/jenga/scripts/resolve-id.sh +367 -0
  39. package/skills/jenga-permission-level/SKILL.md +81 -0
  40. package/skills/proceed/SKILL.md +1 -1
  41. package/skills/publish/SKILL.md +8 -5
  42. package/skills/publish/assets/ci-contract.md +2 -2
  43. package/skills/publish/assets/ownership-matrix.md +1 -1
  44. package/skills/publish/scripts/finalize_changelog.sh +115 -0
  45. package/skills/publish/scripts/generate_release_notes.sh +475 -28
  46. package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
  47. package/skills/publish/scripts/publish_deploy.sh +38 -8
  48. package/skills/publish/scripts/run_gates.sh +2 -2
  49. package/skills/reconcile/SKILL.md +117 -5
  50. package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
  51. package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
  52. package/skills/spinoff/SKILL.md +12 -7
  53. package/skills/todo/SKILL.md +2 -0
  54. package/skills/uncharted/SKILL.md +711 -0
  55. package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
  56. package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
  57. package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
  58. package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
  59. package/skills/uncharted/scripts/detect-tests.sh +553 -0
  60. package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
  61. package/skills/uncharted/scripts/enumerate-target.sh +470 -0
  62. package/skills/uncharted/scripts/import-source.sh +517 -0
  63. package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
  64. package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
  65. package/skills/uncharted/scripts/run-engine.sh +655 -0
  66. package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
  67. package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
  68. package/skills/wtf/SKILL.md +20 -0
  69. package/templates/CHANGELOG_TEMPLATE.md +13 -0
  70. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
  71. package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
  72. package/templates/permission-levels/README.md +73 -0
  73. package/templates/permission-levels/level-1-locked.json +71 -0
  74. package/templates/permission-levels/level-2-guarded.json +64 -0
  75. package/templates/permission-levels/level-3-standard.json +62 -0
  76. package/templates/permission-levels/level-4-elevated.json +60 -0
  77. package/templates/permission-levels/level-5-unrestricted.json +58 -0
  78. package/skills/convert/SKILL.md +0 -124
  79. package/skills/convert/convert_cli.py +0 -235
  80. package/skills/convert/tests/sample.csv +0 -4
  81. package/skills/convert/tests/sample.json +0 -5
  82. package/skills/convert/tests/sample.jsonl +0 -3
  83. package/skills/convert/tests/sample.yaml +0 -18
  84. package/skills/convert/tests/sample_obj.csv +0 -2
  85. package/skills/convert/tests/sample_obj.json +0 -9
  86. package/skills/mirror-public/SKILL.md +0 -237
  87. package/skills/mirror-public/assets/config.json +0 -5
  88. package/skills/mirror-public/scripts/mirror.sh +0 -374
  89. package/skills/self-sync/SKILL.md +0 -73
  90. package/skills/self-sync/scripts/run.js +0 -136
  91. package/skills/strategy/SKILL.md +0 -312
@@ -7,8 +7,10 @@
7
7
  # 2. Detect new problem rapports using a manifest (not a timestamp)
8
8
  # and write trigger payloads to the scrum master queue
9
9
  # 3. Write a status-review trigger to the scrum master queue
10
- # 4. Read .session_handoff.json (if present) and route the assignment
11
- # to the correct next agent queue, then delete the handoff file
10
+ # 4. Process every pending file under project/queue/handoffs/ (if any),
11
+ # routing each one's assignment to the correct next agent queue, then
12
+ # deleting that individual file immediately after it is routed — so a
13
+ # problem with one file never blocks or loses any of the others
12
14
  #
13
15
  # Pipeline routing (section 4):
14
16
  # scrum-master planning_complete → developer_triggers.jsonl
@@ -26,18 +28,27 @@ source "$(git rev-parse --show-toplevel)/lib/resolve-project-dir.sh"
26
28
 
27
29
  PROJECT_DIR="$JENGA_PROJECT_DIR"
28
30
  RAPPORT_DIR="$PROJECT_DIR/project/rapports/problems"
29
- MANIFEST="$PROJECT_DIR/.claude/hooks/.rapport_manifest.json"
31
+ # NOTE: this manifest lives under project/ (not .claude/) because .claude/
32
+ # and .agents/ are generated build outputs, clobbered on every /distribute
33
+ # or /self-sync run — see CLAUDE.md. Canonical, committed state must not
34
+ # live inside a distribution target. Seed generated via, and kept in sync
35
+ # by, scripts/generate-rapport-manifest.sh (see section 2 below).
36
+ MANIFEST="$PROJECT_DIR/project/data/rapport_manifest.json"
30
37
  QUEUE_DIR="$PROJECT_DIR/project/queue"
31
38
  QUEUE_FILE="$QUEUE_DIR/scrum_triggers.jsonl"
32
39
  DEV_QUEUE="$QUEUE_DIR/developer_triggers.jsonl"
33
40
  TESTER_QUEUE="$QUEUE_DIR/tester_triggers.jsonl"
34
- HANDOFF_FILE="$QUEUE_DIR/.session_handoff.json"
41
+ # Per-session handoff files (E37_S01_T01) replace the old single-slot
42
+ # .session_handoff.json. Every file under this directory is processed in
43
+ # section 4 below; see templates/SCRUM_BOARD_SCHEMA.md's handoffs/ section
44
+ # for the write-side filename convention.
45
+ HANDOFF_DIR="$QUEUE_DIR/handoffs"
35
46
  EVENTS_LOG="$PROJECT_DIR/project/logs/events.json"
36
47
  AGENT="${JENGA_AGENT_TYPE:-unknown}"
37
48
  SESSION_ID="${JENGA_SESSION_ID:-}"
38
49
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
39
50
 
40
- mkdir -p "$PROJECT_DIR/project/logs" "$QUEUE_DIR"
51
+ mkdir -p "$PROJECT_DIR/project/logs" "$QUEUE_DIR" "$HANDOFF_DIR"
41
52
 
42
53
  # --- 1. Log sender object ---
43
54
 
@@ -58,18 +69,55 @@ SENDER=$(jq -n \
58
69
  }
59
70
  }')
60
71
 
61
- if [ -f "$EVENTS_LOG" ]; then
62
- jq --argjson entry "$SENDER" '. += [$entry]' "$EVENTS_LOG" > /tmp/events_tmp.json \
63
- && mv /tmp/events_tmp.json "$EVENTS_LOG"
72
+ # The read-modify-write below is wrapped in scripts/with-lock.sh (E37_S01_T03)
73
+ # so concurrent on_session_end.sh invocations never interleave their read and
74
+ # write of $EVENTS_LOG — see E37_S01_T04 and the two rapports it links
75
+ # (project/rapports/problems/E37_S01-concurrent-session-end-race-verification-gap.md,
76
+ # project/rapports/problems/E39_S03_T05-events-json-concurrent-write-corruption.md)
77
+ # for the concurrent-invocation data loss (events.json truncated to 0 bytes)
78
+ # this replaces. The temp file used for the atomic `mv` is created via
79
+ # `mktemp` in the SAME directory as $EVENTS_LOG (not a shared
80
+ # /tmp/events_tmp.json path) so it is (a) unique per invocation — no two
81
+ # concurrent processes can ever collide on the same temp filename — and
82
+ # (b) on the same filesystem as the destination, so the final `mv` stays an
83
+ # atomic rename rather than a cross-filesystem copy. The update logic is
84
+ # captured as a standalone script string (via a quoted heredoc, so jq's own
85
+ # `$entry` and quoting pass through untouched by this outer shell) and run
86
+ # through `bash -c` with $EVENTS_LOG/$SENDER passed as positional args
87
+ # rather than interpolated into the script text, avoiding fragile
88
+ # nested-quote escaping while keeping this fix self-contained in this file.
89
+ APPEND_EVENT_SCRIPT=$(cat <<'EOS'
90
+ events_log="$1"
91
+ sender_json="$2"
92
+ tmp_file=$(mktemp "$(dirname "$events_log")/events_tmp.XXXXXX") || exit 1
93
+ if [ -s "$events_log" ]; then
94
+ jq --argjson entry "$sender_json" '. += [$entry]' "$events_log" > "$tmp_file" \
95
+ && mv "$tmp_file" "$events_log"
64
96
  else
65
- echo "[$SENDER]" > "$EVENTS_LOG"
97
+ printf '[%s]' "$sender_json" > "$tmp_file" \
98
+ && mv "$tmp_file" "$events_log"
66
99
  fi
100
+ rc=$?
101
+ [ -f "$tmp_file" ] && rm -f "$tmp_file"
102
+ exit "$rc"
103
+ EOS
104
+ )
105
+
106
+ "$PROJECT_DIR/scripts/with-lock.sh" "$EVENTS_LOG" -- bash -c "$APPEND_EVENT_SCRIPT" _ "$EVENTS_LOG" "$SENDER"
67
107
 
68
108
  # --- 2. Manifest-based rapport detection ---
69
109
  # Use a JSON array of known filenames instead of a mtime sentinel file.
70
110
  # This prevents silently missing rapports written before the session ends
71
111
  # but after the sentinel was last touched.
112
+ #
113
+ # The manifest itself is committed to the repo (seeded and kept in sync via
114
+ # scripts/generate-rapport-manifest.sh), so on a normal checkout it already
115
+ # exists and reflects the rapports present at commit time. The bootstrap
116
+ # below is a non-destructive fallback for the genuinely unexpected case
117
+ # where the file is missing (e.g. project/data/ was pruned) — it does not
118
+ # run on a fresh clone with the manifest intact.
72
119
 
120
+ mkdir -p "$(dirname "$MANIFEST")"
73
121
  if [ ! -f "$MANIFEST" ]; then
74
122
  echo "[]" > "$MANIFEST"
75
123
  fi
@@ -77,12 +125,28 @@ fi
77
125
  if [ -d "$RAPPORT_DIR" ]; then
78
126
  # Collect current rapport files (exclude .IGNORE.md files — already resolved)
79
127
  # Results are stored in a bash array to avoid word-splitting on paths.
80
- mapfile -t CURRENT_FILES < <(find "$RAPPORT_DIR" -name "*.md" ! -name "*.IGNORE.md" 2>/dev/null | sort)
128
+ # Uses a portable `while read` loop rather than mapfile/readarray: macOS
129
+ # ships bash 3.2 (no mapfile support), and this hook must run there —
130
+ # matches the convention already established in scripts/smoke-harness.sh,
131
+ # skills/publish/scripts/generate_release_notes.sh, and
132
+ # skills/publish/scripts/finalize_changelog.sh. Fixed incidentally here
133
+ # because this task's acceptance criteria require the hook to actually
134
+ # execute end-to-end (mapfile silently failed on stock macOS bash,
135
+ # leaving CURRENT_FILES empty and masking real detection results).
136
+ CURRENT_FILES=()
137
+ while IFS= read -r line; do
138
+ [ -n "$line" ] && CURRENT_FILES+=("$line")
139
+ done < <(find "$RAPPORT_DIR" -name "*.md" ! -name "*.IGNORE.md" 2>/dev/null | sort)
81
140
 
82
- # Identify new files not present in the manifest
141
+ # Identify new files not present in the manifest. The manifest stores
142
+ # paths relative to $PROJECT_DIR (portable across clones/worktrees), so
143
+ # each absolute CURRENT_FILES entry is relativized before the lookup.
144
+ # NEW_FILES itself stays absolute — it feeds rapport_files in the
145
+ # trigger below, which the scrum master reads directly.
83
146
  NEW_FILES=()
84
147
  for file in "${CURRENT_FILES[@]}"; do
85
- known=$(jq --arg f "$file" 'index($f) != null' "$MANIFEST" 2>/dev/null)
148
+ rel="${file#"$PROJECT_DIR"/}"
149
+ known=$(jq --arg f "$rel" 'index($f) != null' "$MANIFEST" 2>/dev/null)
86
150
  if [ "$known" != "true" ]; then
87
151
  NEW_FILES+=("$file")
88
152
  fi
@@ -109,8 +173,11 @@ if [ -d "$RAPPORT_DIR" ]; then
109
173
 
110
174
  echo "$TRIGGER" >> "$QUEUE_FILE"
111
175
 
112
- # Update the manifest to include all current files
113
- printf '%s\n' "${CURRENT_FILES[@]}" | jq -R . | jq -s . > "$MANIFEST"
176
+ # Update the manifest to include all current files. Delegates to the
177
+ # shared generator script (rather than re-inlining the same jq
178
+ # pipeline) so the runtime update and the committed seed can never
179
+ # drift in how they compute "current rapport files".
180
+ bash "$PROJECT_DIR/scripts/generate-rapport-manifest.sh" "$MANIFEST" >/dev/null
114
181
  fi
115
182
  fi
116
183
 
@@ -129,15 +196,97 @@ TRIGGER=$(jq -n \
129
196
 
130
197
  echo "$TRIGGER" >> "$QUEUE_FILE"
131
198
 
199
+ # --- Helper: has this handoff's referenced task already reached a
200
+ # terminal board status? ---------------------------------------------------
201
+ # Guards against resurrecting a stale handoff file (e.g. one that was
202
+ # accidentally committed to git alongside unrelated work, or one left over
203
+ # from before this directory was ever consumed) as a fresh routing signal
204
+ # for work that has already been fully verified. Terminal here means "no
205
+ # further routing action should occur": Passed / Passed with remarks /
206
+ # Rejected / Done are all end states, and Blocked is included because a
207
+ # blocked task must not be silently re-touched by any agent per
208
+ # agents/developer.md's Blocked-halt contract (only a human may clear it).
209
+ # Deliberately fails open (returns 1 / "not terminal") when the task file
210
+ # can't be found or has no readable status, so a lookup miss falls back to
211
+ # today's behavior (route it) rather than silently dropping a handoff whose
212
+ # task genuinely can't be identified.
213
+ is_task_terminal() {
214
+ local task_id="$1" task_file task_status
215
+ task_file=$(find "$PROJECT_DIR/project/board/tasks" -maxdepth 1 -iname "${task_id}_*.md" 2>/dev/null | head -1)
216
+ [ -z "$task_file" ] && return 1
217
+ task_status=$(awk -F': ' '/^status:/ {print $2; exit}' "$task_file" 2>/dev/null)
218
+ case "$task_status" in
219
+ Passed|"Passed with remarks"|Rejected|Done|Blocked) return 0 ;;
220
+ *) return 1 ;;
221
+ esac
222
+ }
223
+
132
224
  # --- 4. Session handoff routing ---
133
- # Each agent writes .session_handoff.json before its session ends.
134
- # This section reads that file and routes the work to the correct next
135
- # agent queue so the pipeline continues automatically.
225
+ # Each agent writes a per-session handoff file under project/queue/handoffs/
226
+ # before its session ends (see E37_S01_T01). This section processes every
227
+ # file currently present in that directory — not just one fixed path — so
228
+ # concurrent sessions ending close together each get routed instead of the
229
+ # last writer silently clobbering the others. Every file is routed through
230
+ # the same per-agent logic below, then deleted individually right after
231
+ # routing, so a problem with one file can't block or lose any of the rest.
232
+ #
233
+ # The glob is intentionally generic (*.json, not a pattern anchored to the
234
+ # <agent>-<session_id>-<task_id> convention) so it also picks up older
235
+ # ad-hoc-named files written before that convention existed — routing below
236
+ # only ever reads the .agent/.status fields from file contents, never the
237
+ # filename, so this is safe.
238
+ #
239
+ # Before routing, a developer/tester handoff whose referenced task is
240
+ # already terminal on the board is treated as stale (deleted, not routed)
241
+ # rather than as a live signal — see is_task_terminal() above. This closes
242
+ # a real regression found during E37_S01_T02 testing: this directory can
243
+ # accumulate committed leftover files for already-shipped work (nothing
244
+ # consumed them before this task existed), and a bare generic glob would
245
+ # otherwise resurrect all of them as fresh test_assignment/story_rollup
246
+ # triggers the moment this hook first runs against such a directory.
247
+ for HANDOFF_FILE in "$HANDOFF_DIR"/*.json; do
248
+ # Guard against the glob matching nothing (no nullglob dependency, so
249
+ # this stays portable to stock macOS bash 3.2 — same rationale as the
250
+ # portable `while read` loop in section 2 above).
251
+ [ -e "$HANDOFF_FILE" ] || continue
252
+
253
+ # Atomic per-file claim (E37_S01_T04) — closes the TOCTOU window between
254
+ # this per-process glob snapshot and the eventual delete at the bottom of
255
+ # the loop. `mv` between two paths on the same filesystem is a rename(2)
256
+ # syscall: the kernel guarantees that when multiple concurrent processes
257
+ # race to rename the same source path, exactly one succeeds and every
258
+ # other attempt fails because the source no longer exists. No GNU-only
259
+ # flags are involved, so this is portable to macOS/BSD as well as Linux.
260
+ # Without this claim, concurrent invocations could each see the same
261
+ # not-yet-deleted file, read it, and route it before either deleted it —
262
+ # confirmed in project/rapports/problems/E37_S01-concurrent-session-end-race-verification-gap.md
263
+ # as duplicate (2x/2x/4x observed) trigger entries for the same handoff.
264
+ # If the rename fails, another concurrent process already claimed this
265
+ # exact file — skip it without reading, routing, or deleting anything;
266
+ # that other process now owns it.
267
+ CLAIMED_HANDOFF_FILE="${HANDOFF_FILE}.claimed.$$"
268
+ if ! mv "$HANDOFF_FILE" "$CLAIMED_HANDOFF_FILE" 2>/dev/null; then
269
+ echo "[on_session_end] handoff already claimed by a concurrent invocation — skipping $(basename "$HANDOFF_FILE")"
270
+ continue
271
+ fi
272
+ HANDOFF_FILE="$CLAIMED_HANDOFF_FILE"
136
273
 
137
- if [ -f "$HANDOFF_FILE" ]; then
138
274
  HANDOFF_AGENT=$(jq -r '.agent // empty' "$HANDOFF_FILE" 2>/dev/null)
139
275
  HANDOFF_STATUS=$(jq -r '.status // empty' "$HANDOFF_FILE" 2>/dev/null)
140
276
 
277
+ # Staleness check — only meaningful for developer/tester handoffs, which
278
+ # each reference exactly one task_id. scrum-master's planning_complete
279
+ # handoff carries a task_ids array of just-created tasks that can't
280
+ # already be terminal in practice, so it is not checked here.
281
+ if [ "$HANDOFF_AGENT" = "developer" ] || [ "$HANDOFF_AGENT" = "tester" ]; then
282
+ CHECK_TASK_ID=$(jq -r '.task_id // empty' "$HANDOFF_FILE" 2>/dev/null)
283
+ if [ -n "$CHECK_TASK_ID" ] && is_task_terminal "$CHECK_TASK_ID"; then
284
+ echo "[on_session_end] stale handoff for already-terminal task $CHECK_TASK_ID — skipping routing, deleting $(basename "$HANDOFF_FILE")"
285
+ rm -f "$HANDOFF_FILE"
286
+ continue
287
+ fi
288
+ fi
289
+
141
290
  case "$HANDOFF_AGENT" in
142
291
 
143
292
  scrum-master)
@@ -228,9 +377,11 @@ if [ -f "$HANDOFF_FILE" ]; then
228
377
 
229
378
  esac
230
379
 
231
- # Consume the handoff file — it is single-use
380
+ # Consume this handoff file — it is single-use. Deleted immediately after
381
+ # routing (not batched after the loop) so a later file's failure can never
382
+ # cause an earlier, already-routed file to be left unconsumed or vice versa.
232
383
  rm -f "$HANDOFF_FILE"
233
- fi
384
+ done
234
385
 
235
386
  # --- 5. Todo cleanup ---
236
387
  # Remove project/todo.md if it is effectively empty (only blanks, # Todo, and HTML comments).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jenga-ai/agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "JengaAgent — agentic project management CLI",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-permission-level.sh — read-only gate for a skill's declared minimum
4
+ # permission level (the `minimum_permission_level` SKILL.md frontmatter field,
5
+ # see docs/skill-authoring.md).
6
+ #
7
+ # ============================================================================
8
+ # CHECK vs ENFORCE — READ THIS BEFORE WIRING THIS SCRIPT INTO A SKILL
9
+ # ============================================================================
10
+ # This script is READ-ONLY. It CHECKS the current session's permission level
11
+ # against a required minimum and SIGNALS the result. It does NOT:
12
+ # - prompt the user for confirmation
13
+ # - elevate the session level
14
+ # - write to .jenga-permission-level.json, .claude/settings.json, or
15
+ # .agents/settings.json under any code path
16
+ #
17
+ # The CALLING SKILL's own instructions are responsible for:
18
+ # 1. Detecting the `NEEDS_CONFIRMATION` signal (non-zero exit) below.
19
+ # 2. Explicitly asking the user to confirm elevation (no silent
20
+ # auto-elevation, ever).
21
+ # 3. On confirmation, invoking `jenga-permission-level-switch.sh <minimum-level>`
22
+ # (a sibling script, scripts/jenga-permission-level-switch.sh — task E33_S02_T02) to
23
+ # actually perform the elevation.
24
+ # 4. Immediately after the skill's own work completes, resetting the
25
+ # session back to Guarded (2) — typically via `jenga-permission-level-switch.sh 2` as
26
+ # the skill's last step. This reset is NOT automatic and is NOT this
27
+ # script's job.
28
+ #
29
+ # Usage:
30
+ # scripts/check-permission-level.sh <minimum-level>
31
+ #
32
+ # <minimum-level> Integer 1-5 — the value a calling skill read from its
33
+ # own `minimum_permission_level` frontmatter field.
34
+ #
35
+ # Exit codes:
36
+ # 0 Current session level already meets <minimum-level>. No output.
37
+ # 1 Current session level is below <minimum-level>. Prints:
38
+ # NEEDS_CONFIRMATION: elevate from <current> to <minimum-level>
39
+ # 2 Bad usage / invalid argument. Prints an error to stderr.
40
+ #
41
+ # Current level source:
42
+ # .jenga-permission-level.json at the repo root, field `session_level`
43
+ # (the format `jenga-permission-level-switch.sh`, E33_S02_T02, writes: {"session_level": <n>}).
44
+ # Missing file, unreadable file, malformed JSON, or a missing/non-integer
45
+ # `session_level` field are all treated as level 2 (Guarded), the
46
+ # permanent default — this script never fails merely because the level
47
+ # file is absent or malformed.
48
+ # ============================================================================
49
+
50
+ set -euo pipefail
51
+
52
+ usage() {
53
+ echo "Usage: $(basename "$0") <minimum-level>" >&2
54
+ echo " <minimum-level> must be an integer 1-5 (the skill's minimum_permission_level)." >&2
55
+ }
56
+
57
+ if [ "$#" -ne 1 ]; then
58
+ usage
59
+ exit 2
60
+ fi
61
+
62
+ MIN_LEVEL="$1"
63
+
64
+ case "$MIN_LEVEL" in
65
+ 1|2|3|4|5) ;;
66
+ *)
67
+ echo "Error: <minimum-level> must be an integer 1-5, got: '$MIN_LEVEL'" >&2
68
+ usage
69
+ exit 2
70
+ ;;
71
+ esac
72
+
73
+ LEVEL_FILE=".jenga-permission-level.json"
74
+ CURRENT_LEVEL=2
75
+
76
+ if [ -f "$LEVEL_FILE" ]; then
77
+ if command -v python3 >/dev/null 2>&1; then
78
+ PARSED=$(python3 -c "
79
+ import json, sys
80
+ try:
81
+ with open('$LEVEL_FILE') as f:
82
+ data = json.load(f)
83
+ level = data.get('session_level')
84
+ if isinstance(level, bool) or not isinstance(level, int) or not (1 <= level <= 5):
85
+ raise ValueError('invalid session_level')
86
+ print(level)
87
+ except Exception:
88
+ print(2)
89
+ " 2>/dev/null || echo 2)
90
+ CURRENT_LEVEL="$PARSED"
91
+ else
92
+ # No python3 available — fall back to a permissive grep-based extraction.
93
+ # Any failure to confidently parse an integer 1-5 falls back to level 2.
94
+ GREPPED=$(grep -o '"session_level"[[:space:]]*:[[:space:]]*[0-9]' "$LEVEL_FILE" 2>/dev/null | grep -o '[0-9]$' | head -n1 || true)
95
+ case "$GREPPED" in
96
+ 1|2|3|4|5) CURRENT_LEVEL="$GREPPED" ;;
97
+ *) CURRENT_LEVEL=2 ;;
98
+ esac
99
+ fi
100
+ fi
101
+
102
+ if [ "$CURRENT_LEVEL" -ge "$MIN_LEVEL" ]; then
103
+ exit 0
104
+ fi
105
+
106
+ echo "NEEDS_CONFIRMATION: elevate from $CURRENT_LEVEL to $MIN_LEVEL"
107
+ exit 1
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/check-publicignore-match.sh
4
+ #
5
+ # Classify one or more repo-relative paths as PUBLIC (would ship to the
6
+ # public mirror repo) or BLOCKED (excluded by .publicignore), using the
7
+ # exact same rsync --exclude-from=.publicignore matching semantics as
8
+ # skills/mirror-public/scripts/mirror.sh's --dry-run "ship list" computation
9
+ # (see the SHIP_LIST_FILE block in that script). A file classified as
10
+ # "would be blocked" by `/mirror-public --dry-run` is guaranteed to be
11
+ # classified as BLOCKED here too, and vice versa for PUBLIC.
12
+ #
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
15
+ # is never read) — it only needs a .publicignore file at the repo root.
16
+ # Rsync's include/exclude filter evaluation does not depend on destination
17
+ # state (destination state only affects delete/itemize-flag details for
18
+ # files already present there), so probing against an empty scratch temp
19
+ # directory yields an identical "would ship" set to probing against
20
+ # mirror.sh's real public-clone destination.
21
+ #
22
+ # Callers (e.g. /doc-sync step 4, and the E36 changelog generator's
23
+ # .publicignore-awareness) are expected to skip invoking this script
24
+ # entirely when .publicignore does not exist at the repo root — that is
25
+ # the established no-op-when-absent precedent, and it stays owned by each
26
+ # caller rather than being silently absorbed here.
27
+ #
28
+ # Usage:
29
+ # check-publicignore-match.sh <path> [<path> ...]
30
+ #
31
+ # Paths are interpreted relative to the repo root (same convention as
32
+ # `git ls-files`). Output: one "STATUS<TAB>path" line per input path, in
33
+ # input order. STATUS is PUBLIC or BLOCKED.
34
+ #
35
+ # Note: a path that does not exist on disk is classified BLOCKED (it will
36
+ # not appear in rsync's transfer list regardless of .publicignore). Callers
37
+ # such as /doc-sync only ever pass paths already confirmed to exist as
38
+ # real new-in-source candidates, so this does not arise in practice — but
39
+ # it is not the same guarantee as ".publicignore explicitly matched it".
40
+ #
41
+ # Exit codes:
42
+ # 0 classification completed (regardless of individual PUBLIC/BLOCKED results)
43
+ # 1 usage error
44
+ # 2 repo root not found, .publicignore missing, or rsync unavailable
45
+ # ---------------------------------------------------------------------------
46
+
47
+ set -euo pipefail
48
+ IFS=$'\n\t'
49
+
50
+ die() {
51
+ printf 'check-publicignore-match.sh: error: %s\n' "$*" >&2
52
+ exit 2
53
+ }
54
+
55
+ if [ "$#" -eq 0 ]; then
56
+ printf 'usage: check-publicignore-match.sh <path> [<path> ...]\n' >&2
57
+ exit 1
58
+ fi
59
+
60
+ SCRIPT_PATH="${BASH_SOURCE[0]}"
61
+ while [ -h "$SCRIPT_PATH" ]; do
62
+ LINK_TARGET="$(readlink "$SCRIPT_PATH")"
63
+ case "$LINK_TARGET" in
64
+ /*) SCRIPT_PATH="$LINK_TARGET" ;;
65
+ *) SCRIPT_PATH="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)/$LINK_TARGET" ;;
66
+ esac
67
+ done
68
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
69
+
70
+ REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
71
+ [ -n "$REPO_ROOT" ] || die "could not locate repo root (git rev-parse failed from $SCRIPT_DIR)"
72
+
73
+ PUBLICIGNORE="$REPO_ROOT/.publicignore"
74
+ [ -f "$PUBLICIGNORE" ] || die "no .publicignore at $PUBLICIGNORE (caller should skip invoking this script when absent — that is the no-op case, not an error the caller should surface)"
75
+
76
+ command -v rsync >/dev/null 2>&1 || die "rsync not installed"
77
+
78
+ TMP_DEST="$(mktemp -d)"
79
+ SHIP_LIST_FILE="$(mktemp)"
80
+ cleanup() {
81
+ rm -rf "$TMP_DEST"
82
+ rm -f "$SHIP_LIST_FILE"
83
+ }
84
+ trap cleanup EXIT
85
+
86
+ # Same rsync flags + itemize/awk filter as mirror.sh's --dry-run ship-list
87
+ # computation (kept in lockstep with that block intentionally), targeted at
88
+ # an empty scratch dir instead of a real public clone.
89
+ rsync -a --delete \
90
+ --exclude-from="$PUBLICIGNORE" \
91
+ --exclude=".git" \
92
+ --dry-run --itemize-changes --out-format='%i %n' \
93
+ "$REPO_ROOT/" \
94
+ "$TMP_DEST/" \
95
+ | awk '{
96
+ flag = $1;
97
+ first = substr(flag, 1, 1);
98
+ second = substr(flag, 2, 1);
99
+ # Skip directories — public tree recreates them implicitly.
100
+ if (second == "d") next;
101
+ # Keep only entries that transfer or create a file/symlink/hardlink.
102
+ keep = 0;
103
+ if (first == ">" || first == "<") keep = 1; # file transfer
104
+ else if (first == "c" && second == "L") keep = 1; # create symlink
105
+ else if (first == "h") keep = 1; # hard link
106
+ if (!keep) next;
107
+ $1 = "";
108
+ sub(/^ /, "");
109
+ sub(/\/$/, "");
110
+ if (length($0) > 0) print $0;
111
+ }' \
112
+ | LC_ALL=C sort -u > "$SHIP_LIST_FILE"
113
+
114
+ for candidate in "$@"; do
115
+ # Normalize a single leading "./" if present, so callers can pass either form.
116
+ norm="${candidate#./}"
117
+ if LC_ALL=C grep -Fxq "$norm" "$SHIP_LIST_FILE"; then
118
+ printf 'PUBLIC\t%s\n' "$candidate"
119
+ else
120
+ printf 'BLOCKED\t%s\n' "$candidate"
121
+ fi
122
+ done