@jenga-ai/agent 1.1.0 → 1.1.1

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,470 @@
1
+ #!/usr/bin/env bash
2
+ # skills/uncharted/scripts/enumerate-target.sh
3
+ #
4
+ # Read-only target resolution and structure enumeration for the /uncharted investigative engine.
5
+ #
6
+ # Resolves a single target argument to one of three target types and emits a machine-readable
7
+ # JSON structure summary on stdout. Deterministic: the output is a pure function of the target
8
+ # and the flags -- no timestamps, no hostnames, no unsorted collections -- so two runs against an
9
+ # unchanged target produce byte-identical output and can be diffed directly.
10
+ #
11
+ # The script has NO side effects. It writes nothing to disk, creates no temp files, and invokes
12
+ # only read-only git plumbing (rev-parse, ls-files).
13
+ #
14
+ # ---------------------------------------------------------------------------------------------
15
+ # USAGE
16
+ # ---------------------------------------------------------------------------------------------
17
+ # enumerate-target.sh [options] <target>
18
+ #
19
+ # <target> A file path, a directory path, or a repository root.
20
+ #
21
+ # Options:
22
+ # --max-depth N Bound the emitted directory tree to N levels below the target.
23
+ # Default: 3. Use 0 for unlimited depth. Bounds the TREE only -- file
24
+ # counts and line counts always cover the whole target.
25
+ # --top N Report the N largest files by line count. Default: 10. Use 0 for all.
26
+ # --no-gitignore Force `find`-based discovery even inside a git work tree, so that
27
+ # .gitignore'd content is enumerated. Sets "gitignore_respected": false.
28
+ # -h, --help Print this usage block to stdout and exit 0.
29
+ # -- End of options; everything after is the target.
30
+ #
31
+ # Exit codes:
32
+ # 0 Success. JSON summary on stdout.
33
+ # 1 Usage or argument error (unknown flag, missing/extra target, bad numeric value).
34
+ # 2 Target error (does not exist, is not readable/traversable, or is neither a regular
35
+ # file nor a directory).
36
+ #
37
+ # ---------------------------------------------------------------------------------------------
38
+ # TARGET TYPES
39
+ # ---------------------------------------------------------------------------------------------
40
+ # "file" The target is a regular file.
41
+ # "repo_root" The target is a directory that is the top level of a git work tree.
42
+ # "directory" Any other directory.
43
+ #
44
+ # ---------------------------------------------------------------------------------------------
45
+ # FILE DISCOVERY
46
+ # ---------------------------------------------------------------------------------------------
47
+ # When the target is inside a git work tree, discovery uses:
48
+ # git ls-files --cached --others --exclude-standard -z
49
+ # so tracked files AND untracked-but-not-ignored files are both included, while .gitignore'd
50
+ # paths are excluded by construction. Including untracked files matters for /uncharted's `import`
51
+ # mode, where a freshly pulled-in source has not been committed yet.
52
+ #
53
+ # When the target is NOT inside a git work tree, discovery falls back to `find` with .git
54
+ # directories pruned. Ignore rules cannot be applied in that case, and the output says so
55
+ # explicitly via "listing_source": "find" and "gitignore_respected": false -- no silent pruning
56
+ # of paths the caller did not ask to prune.
57
+ #
58
+ # IGNORED TARGETS. Passing a target that is itself .gitignore'd -- or whose entire contents are
59
+ # ignored -- is a first-class case for /uncharted's `import` mode: a source dropped into vendor/,
60
+ # tmp/, or any other ignored path IS inside the work tree, so `git ls-files` runs and correctly
61
+ # returns nothing. Left alone that would render an empty result byte-identical to a genuinely empty
62
+ # directory, and a downstream understanding document would confidently describe a directory that
63
+ # actually has content. To prevent that silent-empty failure, an empty git listing for a directory
64
+ # target is cross-checked against a bounded `find` probe; if the probe finds files, discovery falls
65
+ # back to `find` and the output carries "ignored_target": true with "gitignore_respected": false.
66
+ # An explicitly-named ignored FILE is always enumerated (an explicit target wins) and is likewise
67
+ # flagged with "ignored_target": true. Use --no-gitignore to force the fallback unconditionally.
68
+ #
69
+ # Symlinks, unreadable files, and paths listed by git but absent from the working tree are all
70
+ # excluded from the counts and reported under the "skipped" key rather than aborting the run.
71
+ # Empty directories are not represented: both discovery mechanisms enumerate files, not dirs.
72
+ #
73
+ # ---------------------------------------------------------------------------------------------
74
+ # OUTPUT CONTRACT (stdout, JSON)
75
+ # ---------------------------------------------------------------------------------------------
76
+ # Additive by design -- consumers should ignore keys they do not need, and new keys may be added
77
+ # without notice. Depth is counted in path components relative to the target: for "a/b/c.sh" the
78
+ # file is depth 3 and the directory "a/b" is depth 2.
79
+ #
80
+ # {
81
+ # "target": absolute, symlink-resolved path to the target
82
+ # "target_type": "file" | "directory" | "repo_root"
83
+ # "base_dir": absolute directory all "path" values are relative to
84
+ # "listing_source": "git-ls-files" | "find" | "explicit"
85
+ # "explicit" means the target was a single named file, taken as given
86
+ # with no discovery pass and therefore no ignore filtering
87
+ # "gitignore_respected": true when listing_source is "git-ls-files"
88
+ # "ignored_target": true when the target is .gitignore'd (or all of its content is), in
89
+ # which case discovery fell back to `find` so the content is not lost
90
+ # "max_depth": the depth bound in effect (0 == unlimited)
91
+ # "total_files": count of enumerated regular files
92
+ # "total_lines": summed line count across non-binary files
93
+ # "total_bytes": summed size in bytes
94
+ # "binary_files": count of files detected as binary (lines reported as 0)
95
+ # "extensions": [ { "extension", "files", "lines" } ] sorted by files desc, then name
96
+ # "tree": [ tree entries with depth <= max_depth ] sorted by path
97
+ # "tree_truncated": true when at least one tree entry was truncated by the depth bound
98
+ # "largest_files": [ { "path", "lines", "bytes", "binary" } ] sorted by lines desc, then path
99
+ # "skipped": { "<reason>": { "count", "paths" } } paths capped at 25 per reason
100
+ # }
101
+ #
102
+ # Tree entries are one of:
103
+ # { "path", "type": "directory", "depth", "file_count", "truncated" }
104
+ # file_count is RECURSIVE -- it includes files below the depth bound, so a truncated
105
+ # directory still carries a coarse size signal for `onboard` mode.
106
+ # { "path", "type": "file", "depth", "lines", "bytes", "binary" }
107
+ #
108
+ # Files with no extension (including dotfiles such as .gitignore) bucket under "(none)".
109
+ #
110
+ # ---------------------------------------------------------------------------------------------
111
+ # EXAMPLES
112
+ # ---------------------------------------------------------------------------------------------
113
+ # enumerate-target.sh scripts/board_resolver.sh
114
+ # enumerate-target.sh --max-depth 2 skills/
115
+ # enumerate-target.sh --max-depth 1 --top 20 .
116
+ # enumerate-target.sh skills/ | jq -r '.extensions[] | "\(.files)\t\(.extension)"'
117
+ #
118
+ # Requires: bash, git, python3. (python3 is already a hard dependency of scripts/validate-board.sh.)
119
+ # jq is NOT required -- JSON is emitted by python3.
120
+
121
+ set -euo pipefail
122
+
123
+ MAX_DEPTH=3
124
+ TOP=10
125
+ TARGET=""
126
+ NO_GITIGNORE=0
127
+
128
+ usage() {
129
+ cat <<EOF
130
+ Usage: $(basename "$0") [options] <target>
131
+
132
+ Resolve <target> (a file, a directory, or a repository root) and print a JSON structure
133
+ summary to stdout. Read-only: nothing is written to disk.
134
+
135
+ Options:
136
+ --max-depth N Bound the emitted directory tree to N levels (default: 3, 0 = unlimited)
137
+ --top N Report the N largest files by line count (default: 10, 0 = all)
138
+ --no-gitignore Enumerate .gitignore'd content too (forces find-based discovery)
139
+ -h, --help Show this help and exit
140
+
141
+ Exit codes: 0 success, 1 usage error, 2 target missing/unreadable/unsupported.
142
+ EOF
143
+ }
144
+
145
+ die_usage() {
146
+ echo "Error: $1" >&2
147
+ echo >&2
148
+ usage >&2
149
+ exit 1
150
+ }
151
+
152
+ require_int() {
153
+ # require_int <flag> <value> -- non-negative integer
154
+ case "$2" in
155
+ ''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
156
+ esac
157
+ }
158
+
159
+ # --- argument parsing --------------------------------------------------------------------------
160
+ while [ "$#" -gt 0 ]; do
161
+ case "$1" in
162
+ --max-depth)
163
+ [ "$#" -ge 2 ] || die_usage "--max-depth requires a value"
164
+ require_int "--max-depth" "$2"
165
+ MAX_DEPTH="$2"; shift 2 ;;
166
+ --max-depth=*)
167
+ require_int "--max-depth" "${1#*=}"
168
+ MAX_DEPTH="${1#*=}"; shift ;;
169
+ --top)
170
+ [ "$#" -ge 2 ] || die_usage "--top requires a value"
171
+ require_int "--top" "$2"
172
+ TOP="$2"; shift 2 ;;
173
+ --top=*)
174
+ require_int "--top" "${1#*=}"
175
+ TOP="${1#*=}"; shift ;;
176
+ --no-gitignore)
177
+ NO_GITIGNORE=1; shift ;;
178
+ -h|--help)
179
+ usage; exit 0 ;;
180
+ --)
181
+ shift
182
+ [ "$#" -eq 1 ] || die_usage "exactly one target is required"
183
+ TARGET="$1"; shift ;;
184
+ -*)
185
+ die_usage "unknown option \"$1\"" ;;
186
+ *)
187
+ [ -z "$TARGET" ] || die_usage "exactly one target is required (got \"$TARGET\" and \"$1\")"
188
+ TARGET="$1"; shift ;;
189
+ esac
190
+ done
191
+
192
+ [ -n "$TARGET" ] || die_usage "a target is required"
193
+
194
+ # --- target resolution -------------------------------------------------------------------------
195
+ # Portable absolute-path resolution. `readlink -f` is GNU-only and absent from older macOS/BSD
196
+ # userland, so resolve via cd + `pwd -P` instead.
197
+ resolve_path() {
198
+ local p="$1" dir base rdir
199
+ if [ -d "$p" ]; then
200
+ (cd -- "$p" && pwd -P)
201
+ else
202
+ dir=$(dirname -- "$p")
203
+ base=$(basename -- "$p")
204
+ rdir=$(cd -- "$dir" && pwd -P)
205
+ printf '%s\n' "${rdir%/}/$base"
206
+ fi
207
+ }
208
+
209
+ if [ ! -e "$TARGET" ]; then
210
+ echo "Error: target does not exist: $TARGET" >&2
211
+ exit 2
212
+ fi
213
+ if [ ! -r "$TARGET" ]; then
214
+ echo "Error: target is not readable: $TARGET" >&2
215
+ exit 2
216
+ fi
217
+
218
+ RESOLVED=$(resolve_path "$TARGET") || {
219
+ echo "Error: could not resolve target to an absolute path: $TARGET" >&2
220
+ exit 2
221
+ }
222
+
223
+ if [ -f "$RESOLVED" ]; then
224
+ TARGET_TYPE="file"
225
+ BASE_DIR=$(dirname -- "$RESOLVED")
226
+ SEARCH_DIR="$BASE_DIR"
227
+ elif [ -d "$RESOLVED" ]; then
228
+ if [ ! -x "$RESOLVED" ]; then
229
+ echo "Error: target directory is not traversable (no execute permission): $RESOLVED" >&2
230
+ exit 2
231
+ fi
232
+ BASE_DIR="$RESOLVED"
233
+ SEARCH_DIR="$RESOLVED"
234
+ TOPLEVEL=$(git -C "$RESOLVED" rev-parse --show-toplevel 2>/dev/null || true)
235
+ if [ -n "$TOPLEVEL" ] && [ "$(cd -- "$TOPLEVEL" && pwd -P)" = "$RESOLVED" ]; then
236
+ TARGET_TYPE="repo_root"
237
+ else
238
+ TARGET_TYPE="directory"
239
+ fi
240
+ else
241
+ echo "Error: target is neither a regular file nor a directory: $RESOLVED" >&2
242
+ exit 2
243
+ fi
244
+
245
+ # --- file discovery ----------------------------------------------------------------------------
246
+ IN_GIT=0
247
+ if git -C "$SEARCH_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
248
+ IN_GIT=1
249
+ fi
250
+
251
+ IGNORED_TARGET=0
252
+
253
+ # Discovery implementations. Both emit NUL-separated paths relative to the target directory.
254
+ git_list() { git -C "$RESOLVED" ls-files --cached --others --exclude-standard -z -- . ; }
255
+ find_list() {
256
+ ( cd -- "$RESOLVED" && find . \( -name .git -type d \) -prune -o -type f -print0 )
257
+ }
258
+
259
+ # Cheap emptiness probes. `head -c 1` closes the pipe after the first byte, so the producer is
260
+ # SIGPIPE'd immediately instead of walking the whole tree -- these stay O(1) on large targets.
261
+ # `|| true` absorbs both that SIGPIPE and, for git, a non-zero exit under `set -o pipefail`.
262
+ produces_output() {
263
+ # Do NOT collapse the declaration and the assignment into `local n=$(...)`: `local` would then
264
+ # supply the statement's exit status and mask the pipeline's (shellcheck SC2155), defeating the
265
+ # `|| true` below. The split, the `|| true`, and the fact that every call site is a condition
266
+ # context (where `set -e` is suspended) are all three load-bearing.
267
+ local n
268
+ n=$( "$1" 2>/dev/null | head -c 1 | wc -c | tr -d '[:space:]' ) || true
269
+ [ "${n:-0}" -gt 0 ]
270
+ }
271
+
272
+ if [ "$TARGET_TYPE" = "file" ]; then
273
+ # A single file needs no discovery pass; git/find would only re-derive the one path.
274
+ # An explicitly-named file is ALWAYS enumerated even when ignored -- an explicit target wins --
275
+ # but the caller is told about it so `import` mode can reason about provenance.
276
+ if [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 0 ] \
277
+ && git -C "$BASE_DIR" check-ignore -q -- "$RESOLVED" 2>/dev/null; then
278
+ IGNORED_TARGET=1
279
+ fi
280
+ LISTING_SOURCE="explicit"
281
+ GITIGNORE_RESPECTED=0
282
+ list_files() { printf '%s\0' "$(basename -- "$RESOLVED")"; }
283
+ elif [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 0 ]; then
284
+ if produces_output git_list; then
285
+ LISTING_SOURCE="git-ls-files"
286
+ GITIGNORE_RESPECTED=1
287
+ list_files() { git_list; }
288
+ elif produces_output find_list; then
289
+ # Git found nothing but files exist on disk: the target itself is ignored, or all of its
290
+ # content is. Falling back keeps `import` mode from silently analysing an empty payload.
291
+ IGNORED_TARGET=1
292
+ LISTING_SOURCE="find"
293
+ GITIGNORE_RESPECTED=0
294
+ list_files() { find_list; }
295
+ else
296
+ # Genuinely empty. Report it as a normal gitignore-respecting run with zero files.
297
+ LISTING_SOURCE="git-ls-files"
298
+ GITIGNORE_RESPECTED=1
299
+ list_files() { git_list; }
300
+ fi
301
+ else
302
+ if [ "$IN_GIT" -eq 1 ] && [ "$NO_GITIGNORE" -eq 1 ] \
303
+ && ! produces_output git_list && produces_output find_list; then
304
+ IGNORED_TARGET=1
305
+ fi
306
+ LISTING_SOURCE="find"
307
+ GITIGNORE_RESPECTED=0
308
+ list_files() { find_list; }
309
+ fi
310
+
311
+ # --- aggregation and JSON emission ---------------------------------------------------------------
312
+ # The aggregator is held in a variable and handed to `python3 -c` rather than piped in on stdin:
313
+ # `python3 -` would consume stdin as the program text, leaving nothing for the file list to arrive on.
314
+ PY_SRC=$(cat <<'PY'
315
+ import json
316
+ import os
317
+ import sys
318
+ from collections import defaultdict
319
+
320
+ (base_dir, target, target_type, listing_source,
321
+ max_depth_s, top_s, gitignore_s, ignored_target_s) = sys.argv[1:9]
322
+ max_depth = int(max_depth_s)
323
+ top = int(top_s)
324
+ gitignore_respected = gitignore_s == "1"
325
+ ignored_target = ignored_target_s == "1"
326
+ unlimited = max_depth <= 0
327
+
328
+ CHUNK = 1 << 20 # 1 MiB read window -- keeps memory flat on very large files
329
+ SNIFF = 8192 # bytes inspected for a NUL byte when classifying binary
330
+ SKIP_CAP = 25 # per-reason cap on reported skipped paths
331
+
332
+
333
+ def normalise(raw):
334
+ s = os.fsdecode(raw)
335
+ while s.startswith("./"):
336
+ s = s[2:]
337
+ return s
338
+
339
+
340
+ data = sys.stdin.buffer.read()
341
+ rel_paths = sorted({normalise(p) for p in data.split(b"\0") if p})
342
+
343
+ files = []
344
+ skipped = defaultdict(list)
345
+
346
+ for rel in rel_paths:
347
+ abs_path = os.path.join(base_dir, rel)
348
+ if os.path.islink(abs_path):
349
+ # Excluded deliberately: following them would double-count, or escape the target entirely.
350
+ skipped["symlinks"].append(rel)
351
+ continue
352
+ if not os.path.isfile(abs_path):
353
+ # git ls-files reports index entries that may be deleted on disk, and gitlink entries
354
+ # for submodules, neither of which is a readable regular file here.
355
+ skipped["missing"].append(rel)
356
+ continue
357
+ try:
358
+ size = os.path.getsize(abs_path)
359
+ binary = False
360
+ lines = 0
361
+ with open(abs_path, "rb") as fh:
362
+ head = fh.read(SNIFF)
363
+ if b"\0" in head:
364
+ binary = True
365
+ else:
366
+ lines = head.count(b"\n")
367
+ last = head[-1:]
368
+ while True:
369
+ chunk = fh.read(CHUNK)
370
+ if not chunk:
371
+ break
372
+ lines += chunk.count(b"\n")
373
+ last = chunk[-1:]
374
+ if size > 0 and last != b"\n":
375
+ lines += 1 # count a final line with no trailing newline
376
+ except OSError:
377
+ skipped["unreadable"].append(rel)
378
+ continue
379
+ files.append({"path": rel, "lines": 0 if binary else lines, "bytes": size, "binary": binary})
380
+
381
+ # --- per-extension aggregation -------------------------------------------------------------------
382
+ ext_files = defaultdict(int)
383
+ ext_lines = defaultdict(int)
384
+ for f in files:
385
+ ext = os.path.splitext(f["path"])[1] or "(none)"
386
+ ext_files[ext] += 1
387
+ ext_lines[ext] += f["lines"]
388
+
389
+ extensions = [
390
+ {"extension": e, "files": ext_files[e], "lines": ext_lines[e]}
391
+ for e in sorted(ext_files, key=lambda e: (-ext_files[e], e))
392
+ ]
393
+
394
+ # --- depth-bounded tree ----------------------------------------------------------------------------
395
+ dir_file_count = defaultdict(int) # recursive: every ancestor of a file is credited with it
396
+ for f in files:
397
+ parts = f["path"].split("/")
398
+ for i in range(1, len(parts)):
399
+ dir_file_count["/".join(parts[:i])] += 1
400
+
401
+ tree = []
402
+ tree_truncated = False
403
+
404
+ for d in dir_file_count:
405
+ depth = d.count("/") + 1
406
+ if not unlimited and depth > max_depth:
407
+ continue
408
+ # A directory sitting exactly at the bound has all of its children excluded, so it is
409
+ # truncated by definition -- directories only exist here because they contain files.
410
+ truncated = (not unlimited) and depth == max_depth
411
+ tree_truncated = tree_truncated or truncated
412
+ tree.append({
413
+ "path": d,
414
+ "type": "directory",
415
+ "depth": depth,
416
+ "file_count": dir_file_count[d],
417
+ "truncated": truncated,
418
+ })
419
+
420
+ for f in files:
421
+ depth = f["path"].count("/") + 1
422
+ if not unlimited and depth > max_depth:
423
+ tree_truncated = True
424
+ continue
425
+ tree.append({
426
+ "path": f["path"],
427
+ "type": "file",
428
+ "depth": depth,
429
+ "lines": f["lines"],
430
+ "bytes": f["bytes"],
431
+ "binary": f["binary"],
432
+ })
433
+
434
+ tree.sort(key=lambda e: (e["path"], e["type"]))
435
+
436
+ # --- largest files ------------------------------------------------------------------------------
437
+ largest = sorted(files, key=lambda f: (-f["lines"], f["path"]))
438
+ if top > 0:
439
+ largest = largest[:top]
440
+
441
+ summary = {
442
+ "target": target,
443
+ "target_type": target_type,
444
+ "base_dir": base_dir,
445
+ "listing_source": listing_source,
446
+ "gitignore_respected": gitignore_respected,
447
+ "ignored_target": ignored_target,
448
+ "max_depth": max_depth,
449
+ "total_files": len(files),
450
+ "total_lines": sum(f["lines"] for f in files),
451
+ "total_bytes": sum(f["bytes"] for f in files),
452
+ "binary_files": sum(1 for f in files if f["binary"]),
453
+ "extensions": extensions,
454
+ "tree": tree,
455
+ "tree_truncated": tree_truncated,
456
+ "largest_files": largest,
457
+ "skipped": {
458
+ reason: {"count": len(paths), "paths": sorted(paths)[:SKIP_CAP]}
459
+ for reason, paths in sorted(skipped.items())
460
+ },
461
+ }
462
+
463
+ json.dump(summary, sys.stdout, indent=2, ensure_ascii=False)
464
+ sys.stdout.write("\n")
465
+ PY
466
+ )
467
+
468
+ list_files | python3 -c "$PY_SRC" \
469
+ "$BASE_DIR" "$RESOLVED" "$TARGET_TYPE" "$LISTING_SOURCE" \
470
+ "$MAX_DEPTH" "$TOP" "$GITIGNORE_RESPECTED" "$IGNORED_TARGET"