@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
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/uncharted/scripts/import-source.sh
4
+ #
5
+ # Acquisition half of `/uncharted import`. Fetches an external source into a
6
+ # STAGING directory only — it never places anything at a final destination
7
+ # inside the repo, because placement requires user confirmation (E40_S03_T02)
8
+ # and the analysis handoff belongs to `segment` mode (E40_S03_T03).
9
+ #
10
+ # Three source types, auto-detected from the argument (override with --type):
11
+ # git shallow clone (--depth 1) of a git URL
12
+ # path recursive copy of a local directory or file OUTSIDE this repo
13
+ # snippet pasted text read from stdin, written under a caller-supplied name
14
+ #
15
+ # SECURITY CONTRACT — the acquired source is treated as hostile:
16
+ # * Nothing from the source is ever executed, sourced, built, or installed.
17
+ # No install scripts, no postinstall hooks, no makefiles, no git hooks.
18
+ # * Clones run with core.hooksPath=/dev/null and GIT_TERMINAL_PROMPT=0, with
19
+ # submodule recursion off, and the cloned .git directory is deleted after
20
+ # the clone so no hooks or remote config survive into staging.
21
+ # * The staging root lives under the system temp dir, OUTSIDE the repo
22
+ # working tree, so acquired source can never be committed by accident.
23
+ # * Source arguments are validated against path traversal and against being
24
+ # reinterpreted as command options.
25
+ #
26
+ # Usage:
27
+ # import-source.sh [options] <source>
28
+ # cat file | import-source.sh [options] --type snippet --name <filename> -
29
+ #
30
+ # Options:
31
+ # --type git|path|snippet Force the source type (default: auto-detect)
32
+ # --name <filename> Filename for snippet mode (default: snippet.txt).
33
+ # Must be a bare filename — no directory separators.
34
+ # --staging-dir <dir> Use this staging root instead of a fresh temp dir.
35
+ # Must not exist, or must be an empty directory.
36
+ # --json-only Print only the JSON summary on stdout.
37
+ # -h, --help Show this help.
38
+ #
39
+ # Output (stdout):
40
+ # <staging path> (suppressed by --json-only)
41
+ # <JSON summary object>
42
+ #
43
+ # JSON summary fields:
44
+ # source_type, source, staging_path, content_path,
45
+ # file_count, total_size_bytes, total_size_human, symlink_count, git_ref
46
+ #
47
+ # Exit codes:
48
+ # 0 source acquired and staged
49
+ # 1 usage error (bad flag, missing argument, invalid --name/--type)
50
+ # 2 environment error (git unavailable, cannot create staging dir)
51
+ # 3 git clone failed (network, auth, bad URL, missing ref)
52
+ # 4 source unreadable, missing, empty, or refused (in-repo path, traversal)
53
+ # 5 empty snippet input
54
+ # 6 staging directory refused (non-empty, not a directory, or inside the repo)
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
+ sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//' >&2
69
+ exit 1
70
+ }
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Argument parsing
74
+ # ---------------------------------------------------------------------------
75
+
76
+ SOURCE=""
77
+ FORCED_TYPE=""
78
+ SNIPPET_NAME="snippet.txt"
79
+ STAGING_ROOT=""
80
+ JSON_ONLY=0
81
+ SOURCE_SET=0
82
+
83
+ while [ "$#" -gt 0 ]; do
84
+ case "$1" in
85
+ --type)
86
+ [ "$#" -ge 2 ] || die 1 "--type requires a value (git|path|snippet)"
87
+ FORCED_TYPE="$2"; shift 2 ;;
88
+ --type=*)
89
+ FORCED_TYPE="${1#--type=}"; shift ;;
90
+ --name)
91
+ [ "$#" -ge 2 ] || die 1 "--name requires a value"
92
+ SNIPPET_NAME="$2"; shift 2 ;;
93
+ --name=*)
94
+ SNIPPET_NAME="${1#--name=}"; shift ;;
95
+ --staging-dir)
96
+ [ "$#" -ge 2 ] || die 1 "--staging-dir requires a value"
97
+ STAGING_ROOT="$2"; shift 2 ;;
98
+ --staging-dir=*)
99
+ STAGING_ROOT="${1#--staging-dir=}"; shift ;;
100
+ --json-only)
101
+ JSON_ONLY=1; shift ;;
102
+ -h|--help)
103
+ usage ;;
104
+ --)
105
+ shift
106
+ if [ "$#" -gt 0 ]; then SOURCE="$1"; SOURCE_SET=1; shift; fi
107
+ [ "$#" -eq 0 ] || die 1 "unexpected extra argument: $1" ;;
108
+ -)
109
+ SOURCE="-"; SOURCE_SET=1; shift ;;
110
+ -*)
111
+ die 1 "unknown option: $1 (use -- before a source that starts with '-')" ;;
112
+ *)
113
+ [ "$SOURCE_SET" -eq 0 ] || die 1 "unexpected extra argument: $1"
114
+ SOURCE="$1"; SOURCE_SET=1; shift ;;
115
+ esac
116
+ done
117
+
118
+ [ "$SOURCE_SET" -eq 1 ] || usage
119
+
120
+ if [ -n "$FORCED_TYPE" ]; then
121
+ case "$FORCED_TYPE" in
122
+ git|path|snippet) ;;
123
+ *) die 1 "invalid --type '$FORCED_TYPE' (expected git, path, or snippet)" ;;
124
+ esac
125
+ fi
126
+
127
+ # --name must be a bare filename: no separators, no traversal, no leading dash.
128
+ case "$SNIPPET_NAME" in
129
+ ""|.|..) die 1 "invalid --name '$SNIPPET_NAME'" ;;
130
+ */*|*\\*) die 1 "invalid --name '$SNIPPET_NAME': must not contain a path separator" ;;
131
+ -*) die 1 "invalid --name '$SNIPPET_NAME': must not start with '-'" ;;
132
+ esac
133
+ case "$SNIPPET_NAME" in
134
+ *..*) die 1 "invalid --name '$SNIPPET_NAME': must not contain '..'" ;;
135
+ esac
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Helpers
139
+ # ---------------------------------------------------------------------------
140
+
141
+ # Canonicalise a path without depending on GNU realpath (absent on macOS).
142
+ # Collapses symlinks and '..' components so traversal cannot be smuggled past
143
+ # the in-repo check below. Works for a path whose parent exists.
144
+ canonicalise() {
145
+ local target="$1" dir base link hops=0
146
+ # Resolve a symlinked FINAL component before canonicalising. The [ -d ]
147
+ # branch below already follows directory symlinks via `cd`, but a symlink to
148
+ # a *file* would otherwise be reported at its own location — letting a link
149
+ # that points at an in-repo file slip past is_inside_repo while `cp`
150
+ # dereferences it. The hop cap stops a symlink loop from hanging the script.
151
+ while [ -L "$target" ] && [ "$hops" -lt 40 ]; do
152
+ link="$(readlink "$target")" || break
153
+ case "$link" in
154
+ /*) target="$link" ;;
155
+ *) target="$(dirname "$target")/$link" ;;
156
+ esac
157
+ hops=$((hops + 1))
158
+ done
159
+ if [ -d "$target" ]; then
160
+ (cd "$target" 2>/dev/null && pwd -P)
161
+ else
162
+ dir="$(dirname "$target")"
163
+ base="$(basename "$target")"
164
+ if [ -d "$dir" ]; then
165
+ printf '%s/%s\n' "$(cd "$dir" && pwd -P)" "$base"
166
+ else
167
+ printf '%s\n' "$target"
168
+ fi
169
+ fi
170
+ }
171
+
172
+ # Echo the working-tree root containing $1, if any. Asks git about the
173
+ # CANDIDATE PATH rather than matching against a precomputed list of roots —
174
+ # a list can never cover every working tree on the host, and a lexical prefix
175
+ # match in particular misses the parent repo when this script runs from a git
176
+ # worktree (the main checkout is an ancestor of .claude/worktrees/..., so it
177
+ # never matches). Walks up to the nearest existing ancestor so it also answers
178
+ # for a staging directory that has not been created yet.
179
+ path_repo_root() {
180
+ local probe="$1"
181
+ while [ -n "$probe" ] && [ "$probe" != "/" ] && [ ! -d "$probe" ]; do
182
+ probe="$(dirname "$probe")"
183
+ done
184
+ [ -d "$probe" ] || return 1
185
+ git -C "$probe" rev-parse --show-toplevel 2>/dev/null
186
+ }
187
+
188
+ # True when $1 sits inside ANY git working tree; echoes that tree's root.
189
+ is_inside_repo() {
190
+ local root
191
+ root="$(path_repo_root "$1")" || return 1
192
+ [ -n "$root" ] || return 1
193
+ printf '%s\n' "$root"
194
+ return 0
195
+ }
196
+
197
+ # Working-tree roots belonging to THIS project's repository. A git worktree and
198
+ # its main checkout share one repository, so `git worktree list` enumerates all
199
+ # of them from either side — which is what the parent-of-worktree case needs and
200
+ # what a lexical prefix match over two guessed roots got wrong.
201
+ own_worktree_roots() {
202
+ local script_dir d
203
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || script_dir=""
204
+ for d in "$script_dir" "$(pwd -P)"; do
205
+ [ -n "$d" ] || continue
206
+ git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 || continue
207
+ git -C "$d" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p'
208
+ done | sort -u
209
+ }
210
+
211
+ # True when $1 is inside one of THIS project's working trees; echoes that root.
212
+ # Deliberately NOT "inside any git repository": an external cloned project is a
213
+ # git repo too, and importing one is the entire point of `import` mode. Only
214
+ # staging uses the broader any-repo test, because writing acquired source into
215
+ # any committable tree is wrong regardless of whose tree it is.
216
+ is_inside_own_repo() {
217
+ local candidate="$1" root real
218
+ while IFS= read -r root; do
219
+ [ -n "$root" ] || continue
220
+ real="$(cd "$root" 2>/dev/null && pwd -P)" || continue
221
+ if [ "$candidate" = "$real" ]; then printf '%s\n' "$real"; return 0; fi
222
+ case "$candidate" in "$real"/*) printf '%s\n' "$real"; return 0 ;; esac
223
+ done <<< "$OWN_WORKTREE_ROOTS"
224
+ return 1
225
+ }
226
+
227
+ # Detect the source type from the argument alone (pure; no filesystem writes).
228
+ detect_type() {
229
+ local src="$1"
230
+ if [ "$src" = "-" ]; then
231
+ printf 'snippet\n'; return 0
232
+ fi
233
+ case "$src" in
234
+ https://*|http://*|git://*|ssh://*|git+ssh://*|file://*)
235
+ printf 'git\n'; return 0 ;;
236
+ *@*:*)
237
+ # scp-style git remote, e.g. git@github.com:owner/repo.git
238
+ printf 'git\n'; return 0 ;;
239
+ *.git)
240
+ printf 'git\n'; return 0 ;;
241
+ esac
242
+ printf 'path\n'
243
+ }
244
+
245
+ human_size() {
246
+ local bytes="$1"
247
+ if [ "$bytes" -lt 1024 ]; then
248
+ printf '%sB\n' "$bytes"
249
+ elif [ "$bytes" -lt 1048576 ]; then
250
+ printf '%sKB\n' "$(( (bytes + 512) / 1024 ))"
251
+ elif [ "$bytes" -lt 1073741824 ]; then
252
+ printf '%sMB\n' "$(( (bytes + 524288) / 1048576 ))"
253
+ else
254
+ printf '%sGB\n' "$(( (bytes + 536870912) / 1073741824 ))"
255
+ fi
256
+ }
257
+
258
+ json_escape() {
259
+ # Minimal RFC 8259 string escaping for the jq-less fallback path.
260
+ printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
261
+ -e 's/\t/\\t/g' -e 's/\r/\\r/g' | awk 'BEGIN{ORS=""} {print sep $0; sep="\\n"}'
262
+ }
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Resolve type
266
+ # ---------------------------------------------------------------------------
267
+
268
+ if [ -n "$FORCED_TYPE" ]; then
269
+ SOURCE_TYPE="$FORCED_TYPE"
270
+ else
271
+ SOURCE_TYPE="$(detect_type "$SOURCE")"
272
+ fi
273
+
274
+ OWN_WORKTREE_ROOTS="$(own_worktree_roots)"
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Pre-flight validation (before creating any staging directory)
278
+ # ---------------------------------------------------------------------------
279
+
280
+ case "$SOURCE_TYPE" in
281
+ git)
282
+ command -v git >/dev/null 2>&1 || die 2 "git is required for git sources but was not found on PATH"
283
+ case "$SOURCE" in
284
+ -*) die 4 "refusing git URL '$SOURCE': must not start with '-'" ;;
285
+ esac
286
+ # Reject shell/option metacharacters that have no place in a git URL.
287
+ # shellcheck disable=SC2016 # '$(' is an intentional literal, not an expansion
288
+ case "$SOURCE" in
289
+ *';'*|*'|'*|*'&'*|*'$('*|*'`'*|*$'\n'*|*$'\r'*)
290
+ die 4 "refusing git URL '$SOURCE': contains disallowed characters" ;;
291
+ esac
292
+ ;;
293
+ path)
294
+ [ -e "$SOURCE" ] || die 4 "source path does not exist: $SOURCE"
295
+ [ -r "$SOURCE" ] || die 4 "source path is not readable: $SOURCE"
296
+ ABS_SOURCE="$(canonicalise "$SOURCE")"
297
+ case "$ABS_SOURCE" in
298
+ /*) ;;
299
+ *) die 4 "could not resolve source path to an absolute location: $SOURCE" ;;
300
+ esac
301
+ # This script imports EXTERNAL sources. An in-repo target is `segment`
302
+ # mode's job, and allowing it here would let a traversal argument
303
+ # ('../../repo/x') round-trip repo content through the import path.
304
+ if MATCHED_ROOT="$(is_inside_own_repo "$ABS_SOURCE")"; then
305
+ die 4 "refusing '$SOURCE': resolves to $ABS_SOURCE, inside this project's repo at $MATCHED_ROOT. Use \`/uncharted segment\` for in-repo targets."
306
+ fi
307
+ if [ -d "$ABS_SOURCE" ] && [ -z "$(ls -A "$ABS_SOURCE" 2>/dev/null)" ]; then
308
+ die 4 "source directory is empty: $SOURCE"
309
+ fi
310
+ if [ -f "$ABS_SOURCE" ] && [ ! -s "$ABS_SOURCE" ]; then
311
+ die 4 "source file is empty: $SOURCE"
312
+ fi
313
+ ;;
314
+ snippet)
315
+ : # stdin is validated at read time
316
+ ;;
317
+ esac
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Staging directory
321
+ # ---------------------------------------------------------------------------
322
+
323
+ if [ -n "$STAGING_ROOT" ]; then
324
+ # Staging must never live inside the repo working tree — that is the
325
+ # structural guarantee that acquired external source cannot be committed.
326
+ STAGING_PROBE="$(canonicalise "$STAGING_ROOT")"
327
+ if MATCHED_ROOT="$(is_inside_repo "$STAGING_PROBE")"; then
328
+ die 6 "refusing --staging-dir '$STAGING_ROOT': staging must live outside the repo working tree ($MATCHED_ROOT)"
329
+ fi
330
+ if [ -e "$STAGING_ROOT" ]; then
331
+ [ -d "$STAGING_ROOT" ] || die 6 "staging path exists and is not a directory: $STAGING_ROOT"
332
+ [ -z "$(ls -A "$STAGING_ROOT" 2>/dev/null)" ] || die 6 "refusing to write into non-empty staging directory: $STAGING_ROOT"
333
+ else
334
+ mkdir -p "$STAGING_ROOT" || die 2 "could not create staging directory: $STAGING_ROOT"
335
+ fi
336
+ STAGING_ROOT="$(canonicalise "$STAGING_ROOT")"
337
+ else
338
+ STAGING_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/uncharted-import.XXXXXXXX")" \
339
+ || die 2 "could not create a temporary staging directory"
340
+ STAGING_ROOT="$(canonicalise "$STAGING_ROOT")"
341
+ # The default path is only safe if TMPDIR itself is not inside a working
342
+ # tree. Exotic, but the guarantee above is stated absolutely, so assert it
343
+ # rather than assume it — and clean up the temp dir we just made.
344
+ if MATCHED_ROOT="$(is_inside_repo "$STAGING_ROOT")"; then
345
+ rmdir "$STAGING_ROOT" 2>/dev/null || true
346
+ die 6 "refusing default staging dir '$STAGING_ROOT': TMPDIR resolves inside a repo working tree ($MATCHED_ROOT). Set TMPDIR or pass --staging-dir to a location outside any repo."
347
+ fi
348
+ fi
349
+
350
+ # Acquired content never sits at the staging root itself — downstream steps
351
+ # need a stable subdirectory to point the placement prompt at.
352
+ CONTENT_DIR="$STAGING_ROOT/source"
353
+ [ ! -e "$CONTENT_DIR" ] || die 6 "refusing to write into non-empty staging directory: $STAGING_ROOT"
354
+
355
+ GIT_REF=""
356
+
357
+ # shellcheck disable=SC2329 # invoked via `trap`, not called directly
358
+ cleanup_on_failure() {
359
+ local code="$?"
360
+ if [ "$code" -ne 0 ] && [ -n "${STAGING_ROOT:-}" ] && [ -d "$STAGING_ROOT" ]; then
361
+ case "$STAGING_ROOT" in
362
+ /|/tmp|/var|/usr|/etc|"$HOME") : ;; # never blast a system root
363
+ *)
364
+ if is_inside_repo "$STAGING_ROOT" >/dev/null; then
365
+ : # never rm -rf anything inside a repo working tree
366
+ else
367
+ rm -rf "$STAGING_ROOT"
368
+ fi ;;
369
+ esac
370
+ fi
371
+ }
372
+ trap cleanup_on_failure EXIT
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Acquisition
376
+ # ---------------------------------------------------------------------------
377
+
378
+ case "$SOURCE_TYPE" in
379
+ git)
380
+ CLONE_LOG="$STAGING_ROOT/.clone.log"
381
+ # Hardened clone: no hooks, no interactive credential prompt, no tags,
382
+ # no submodule recursion, single branch, depth 1.
383
+ if ! GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/usr/bin/true \
384
+ git -c core.hooksPath=/dev/null \
385
+ -c protocol.ext.allow=never \
386
+ -c advice.detachedHead=false \
387
+ clone --depth 1 --no-tags --single-branch --no-recurse-submodules \
388
+ -- "$SOURCE" "$CONTENT_DIR" >"$CLONE_LOG" 2>&1; then
389
+ printf '%s: error: git clone failed for %s\n' "$SELF" "$SOURCE" >&2
390
+ printf -- '--- git output ---\n' >&2
391
+ cat "$CLONE_LOG" >&2 || true
392
+ printf -- '------------------\n' >&2
393
+ printf 'Hint: for a private repository this is usually missing or expired credentials.\n' >&2
394
+ printf ' Interactive prompts are disabled on purpose; configure git credentials and retry.\n' >&2
395
+ exit 3
396
+ fi
397
+ if [ -d "$CONTENT_DIR/.git" ]; then
398
+ GIT_REF="$(git -C "$CONTENT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
399
+ # Drop .git entirely: removes fetched hooks/config and prevents the
400
+ # staged tree from behaving as a nested repository downstream.
401
+ rm -rf "$CONTENT_DIR/.git"
402
+ fi
403
+ rm -f "$CLONE_LOG"
404
+ ;;
405
+
406
+ path)
407
+ mkdir -p "$CONTENT_DIR" || die 2 "could not create staging content directory: $CONTENT_DIR"
408
+ if [ -d "$ABS_SOURCE" ]; then
409
+ # Copy the directory's CONTENTS (not the directory itself) into staging.
410
+ # `cp -R src/. dest/` is the portable form that includes dotfiles.
411
+ cp -R "$ABS_SOURCE/." "$CONTENT_DIR/" || die 4 "failed to copy source directory: $SOURCE"
412
+ else
413
+ cp "$ABS_SOURCE" "$CONTENT_DIR/$(basename "$ABS_SOURCE")" || die 4 "failed to copy source file: $SOURCE"
414
+ fi
415
+ # A copied tree may carry its own .git; strip it for the same reasons as above.
416
+ rm -rf "$CONTENT_DIR/.git"
417
+ ;;
418
+
419
+ snippet)
420
+ mkdir -p "$CONTENT_DIR" || die 2 "could not create staging content directory: $CONTENT_DIR"
421
+ SNIPPET_FILE="$CONTENT_DIR/$SNIPPET_NAME"
422
+ if [ "$SOURCE" = "-" ] || [ "$SOURCE" = "" ]; then
423
+ cat > "$SNIPPET_FILE" || die 4 "failed to read snippet from stdin"
424
+ elif [ -f "$SOURCE" ]; then
425
+ [ -r "$SOURCE" ] || die 4 "snippet file is not readable: $SOURCE"
426
+ cat -- "$SOURCE" > "$SNIPPET_FILE" || die 4 "failed to read snippet file: $SOURCE"
427
+ else
428
+ printf '%s' "$SOURCE" > "$SNIPPET_FILE" || die 4 "failed to write snippet"
429
+ fi
430
+ if [ ! -s "$SNIPPET_FILE" ]; then
431
+ die 5 "empty snippet input — nothing to import"
432
+ fi
433
+ ;;
434
+ esac
435
+
436
+ if [ ! -d "$CONTENT_DIR" ] || [ -z "$(ls -A "$CONTENT_DIR" 2>/dev/null)" ]; then
437
+ die 4 "acquisition produced no content for source: $SOURCE"
438
+ fi
439
+
440
+ # Nothing in the staged tree should be executable — this script never runs it,
441
+ # and a non-executable tree makes accidental execution downstream harder.
442
+ find "$CONTENT_DIR" -type f -exec chmod a-x {} + 2>/dev/null || true
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Summary
446
+ # ---------------------------------------------------------------------------
447
+
448
+ FILE_COUNT="$(find "$CONTENT_DIR" -type f 2>/dev/null | wc -l | tr -d ' ')"
449
+
450
+ # Apparent size, not disk usage: `du` rounds every file up to a block, which
451
+ # reports 4KB for a 30-byte snippet. BSD and GNU `stat` disagree on flags, so
452
+ # resolve the right one once and batch with `find -exec ... +`.
453
+ # Probe on `stat --version`, which only GNU coreutils implements. Probing on
454
+ # `stat -f %z` instead is ambiguous: GNU's -f selects *filesystem* status, so
455
+ # it can exit 0 on Linux and wrongly select the BSD branch.
456
+ if stat --version >/dev/null 2>&1; then
457
+ STAT_ARGS=(-c %s) # GNU / coreutils
458
+ else
459
+ STAT_ARGS=(-f %z) # BSD / macOS
460
+ fi
461
+ TOTAL_BYTES="$(find "$CONTENT_DIR" -type f -exec stat "${STAT_ARGS[@]}" {} + 2>/dev/null \
462
+ | awk '{ s += $1 } END { print s + 0 }')"
463
+ [ -n "$TOTAL_BYTES" ] || TOTAL_BYTES=0
464
+ TOTAL_HUMAN="$(human_size "$TOTAL_BYTES")"
465
+
466
+ # Symlinks are preserved rather than dereferenced or stripped — deleting them
467
+ # would corrupt legitimate source trees. But a symlink escaping the staged tree
468
+ # would let the placement step (E40_S03_T02) pull arbitrary host files into the
469
+ # repo, so surface the count and let placement decide.
470
+ SYMLINK_COUNT="$(find "$CONTENT_DIR" -type l 2>/dev/null | wc -l | tr -d ' ')"
471
+ [ -n "$SYMLINK_COUNT" ] || SYMLINK_COUNT=0
472
+
473
+ trap - EXIT
474
+
475
+ if [ "$JSON_ONLY" -eq 0 ]; then
476
+ printf '%s\n' "$STAGING_ROOT"
477
+ fi
478
+
479
+ if command -v jq >/dev/null 2>&1; then
480
+ jq -n \
481
+ --arg source_type "$SOURCE_TYPE" \
482
+ --arg source "$SOURCE" \
483
+ --arg staging_path "$STAGING_ROOT" \
484
+ --arg content_path "$CONTENT_DIR" \
485
+ --argjson file_count "$FILE_COUNT" \
486
+ --argjson total_size_bytes "$TOTAL_BYTES" \
487
+ --arg total_size_human "$TOTAL_HUMAN" \
488
+ --argjson symlink_count "$SYMLINK_COUNT" \
489
+ --arg git_ref "$GIT_REF" \
490
+ '{source_type: $source_type,
491
+ source: $source,
492
+ staging_path: $staging_path,
493
+ content_path: $content_path,
494
+ file_count: $file_count,
495
+ total_size_bytes: $total_size_bytes,
496
+ total_size_human: $total_size_human,
497
+ symlink_count: $symlink_count,
498
+ git_ref: (if $git_ref == "" then null else $git_ref end)}'
499
+ else
500
+ if [ -n "$GIT_REF" ]; then
501
+ GIT_REF_JSON="\"$(json_escape "$GIT_REF")\""
502
+ else
503
+ GIT_REF_JSON="null"
504
+ fi
505
+ printf '{"source_type":"%s","source":"%s","staging_path":"%s","content_path":"%s","file_count":%s,"total_size_bytes":%s,"total_size_human":"%s","symlink_count":%s,"git_ref":%s}\n' \
506
+ "$(json_escape "$SOURCE_TYPE")" \
507
+ "$(json_escape "$SOURCE")" \
508
+ "$(json_escape "$STAGING_ROOT")" \
509
+ "$(json_escape "$CONTENT_DIR")" \
510
+ "$FILE_COUNT" \
511
+ "$TOTAL_BYTES" \
512
+ "$(json_escape "$TOTAL_HUMAN")" \
513
+ "$SYMLINK_COUNT" \
514
+ "$GIT_REF_JSON"
515
+ fi
516
+
517
+ exit 0