@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,1029 @@
1
+ #!/usr/bin/env bash
2
+ # discover-subsystems.sh — bounded, coarse-grained subsystem discovery for /uncharted onboard
3
+ #
4
+ # Usage: discover-subsystems.sh [options] <root>
5
+ # discover-subsystems.sh --help
6
+ #
7
+ # Given the root of an existing codebase, emits a ranked JSON list of candidate SUBSYSTEMS on
8
+ # stdout: the major modules/packages a human would name when asked "what are the main parts of
9
+ # this project?". It is deliberately COARSE. `onboard` mode backfills a board for a codebase
10
+ # that was never built through Jenga, and a per-file investigation of such a codebase is both
11
+ # useless as a board and prohibitively expensive. Boundedness is the point of this script, not
12
+ # a limitation of it:
13
+ #
14
+ # - discovery depth is capped (default 2 levels below the root, --max-depth)
15
+ # - build outputs, vendored dependencies and .gitignore'd paths are never candidates
16
+ # - Jenga's own scaffolding is never a candidate — it is the framework, not the application
17
+ # - the directory tree is partitioned, never double-counted (see CANDIDATES VS CONTAINERS)
18
+ #
19
+ # This script is STRICTLY READ-ONLY. It creates no files, no temp files, and mutates no git
20
+ # state. It never writes into the codebase being analysed — `onboard` mode must never modify,
21
+ # move, or restructure a consumer's application code, and this script is the analysis half of
22
+ # that guarantee. Its only outputs are JSON on stdout and diagnostics on stderr.
23
+ #
24
+ # Enumeration is DELEGATED, not reimplemented: every file count, line count and directory
25
+ # listing comes from `enumerate-target.sh` in this same directory. This script contributes the
26
+ # clustering, bounding and scoring on top of it.
27
+ #
28
+ # Determinism: the output is a pure function of the tree and the flags. No timestamps, no
29
+ # hostnames, no unsorted collections — two runs against an unchanged tree produce byte-identical
30
+ # output and can be diffed directly.
31
+ #
32
+ # ---------------------------------------------------------------------------
33
+ # Options
34
+ # ---------------------------------------------------------------------------
35
+ # --max-depth N How many levels below <root> a candidate may sit. Default 2.
36
+ # Must be >= 1; there is deliberately no "unlimited" value, because an
37
+ # unbounded walk is exactly what this script exists to prevent.
38
+ # --min-files N Noise floor: a directory holding fewer than N files (recursively) is
39
+ # not a candidate. Default 1 (i.e. off).
40
+ # --max-candidates N Runtime safety bound on how many directories receive a second,
41
+ # per-candidate enumeration pass. Default 200. When the bound is hit the
42
+ # largest candidates are kept and the remainder are reported under
43
+ # "excluded" with a notice.
44
+ # THIS IS NOT THE `onboard` EPIC CAP. It is a cost guard on the analysis
45
+ # itself. The epic cap — and the surfacing of anything it drops — is
46
+ # applied by a separate layer on top of this script's output.
47
+ # --include-framework Do not exclude Jenga scaffolding. Needed to point the script at the
48
+ # JengaAgent repo itself, where the scaffolding IS the application.
49
+ # --include-training-scaffolding
50
+ # Do not exclude .training/ job-template scaffolding. Needed to point the
51
+ # script at JengaAgent's own /train subsystem, or at a project that
52
+ # genuinely wants job templates catalogued as a candidate.
53
+ # --with-dependencies Additionally compute the "cohesion" signal by calling
54
+ # detect-dependencies.sh once per candidate. Off by default: it is an
55
+ # extra full-text scan per candidate and the default pass must stay
56
+ # coarse. Degrades to a notice if that script is unavailable.
57
+ # -h, --help Show help and exit.
58
+ # -- End of options; the next argument is <root>.
59
+ #
60
+ # ---------------------------------------------------------------------------
61
+ # Exclusions
62
+ # ---------------------------------------------------------------------------
63
+ # Four independent layers, and every exclusion is REPORTED in the "excluded" array with its
64
+ # reason — nothing is silently dropped:
65
+ #
66
+ # 1. .gitignore'd paths. Free, and by construction: enumerate-target.sh lists via
67
+ # `git ls-files --cached --others --exclude-standard`, so ignored content never reaches
68
+ # this script. When it reports "gitignore_respected": false (the root is not in a git work
69
+ # tree, or its content is itself ignored) a notice says so, because layer 2 is then the
70
+ # only guard left.
71
+ # 2. Build/vendor/VCS directory NAMES at any depth: node_modules, vendor, dist, build, .git,
72
+ # target, out, coverage, .venv, venv, __pycache__, .next, bower_components, .terraform,
73
+ # .gradle, .tox, .mypy_cache, .pytest_cache, .idea, .svn, .hg, .cache, .claude/worktrees.
74
+ # This layer applies BOTH when selecting candidates AND inside a candidate that has already
75
+ # been selected. The second half matters for committed (non-gitignored) vendor content —
76
+ # common in Go and PHP projects, which is exactly the kind of codebase `onboard` targets.
77
+ # Without it a candidate's own totals and score would silently absorb its vendored tree.
78
+ # enumerate-target.sh has no name-exclusion concept — .gitignore is its only filter — so
79
+ # each nested match is measured by enumerate-target.sh in its own right and SUBTRACTED from
80
+ # the candidate, keeping enumeration delegated rather than recounted here. Gitignored
81
+ # directories never reach this step (layer 1 already removed them from the listing), so the
82
+ # subtraction cannot double-count. Every discount is reported twice: on the candidate as
83
+ # "nested_excluded", and in the top-level "excluded" array.
84
+ # 3. Jenga scaffolding: project/, skills/, agents/, hooks/, templates/, .claude/, .agents/.
85
+ # Matched as ROOT-RELATIVE PATHS, not as bare names at any depth — a consumer's own
86
+ # src/agents/ or app/templates/ is a real subsystem and must not be swallowed by a rule
87
+ # about Jenga's top-level layout. Disable with --include-framework.
88
+ # 4. Training-job scaffolding: .training/. Matched as a ROOT-RELATIVE PATH at depth 1, same
89
+ # discipline as layer 3 and for the same reason — a consumer's own src/.training/ (unlikely,
90
+ # but the rule should not assume) would need the same protection a bare-name match at any
91
+ # depth would remove. This is deliberately its own layer rather than folded into
92
+ # FRAMEWORK_PATHS: .training/ is job-template scaffolding for the /train skill, not Jenga's
93
+ # own runtime scaffolding, and the two can be disabled independently. Content under it
94
+ # (e.g. .training/template/{transformers,nlp,classifiers}/train.py) describes how to run a
95
+ # training job, not application code an `onboard` backfill should reason about. Disable with
96
+ # --include-training-scaffolding.
97
+ #
98
+ # ---------------------------------------------------------------------------
99
+ # Candidates vs containers
100
+ # ---------------------------------------------------------------------------
101
+ # Ranking packages/ alongside packages/api and packages/web would count the same files two or
102
+ # three times and bury real subsystems under their own parents. So the tree is PARTITIONED:
103
+ # walking down from the root, each directory becomes exactly one of
104
+ #
105
+ # candidate — a subsystem. It is scored and ranked, and its subtree is NOT descended further.
106
+ # container — a folder OF subsystems (the classic packages/ or apps/ case). It is expanded
107
+ # into its children, is not itself ranked, and is reported in "containers" with
108
+ # the reason it was expanded and what it expanded into.
109
+ #
110
+ # A directory is expanded into its children only when ALL of these hold:
111
+ # - it sits above --max-depth (a directory at the depth bound is always a candidate),
112
+ # - it has at least 2 child directories that would themselves be viable candidates,
113
+ # - its own direct files are incidental (<= 3 of them, or <= 20% of its recursive file count),
114
+ # - it does NOT carry its own manifest.
115
+ # The manifest rule is what makes monorepos work: a directory with its own package.json /
116
+ # go.mod / pyproject.toml is a unit by definition and is never split apart, whatever its shape.
117
+ #
118
+ # Files sitting loose at the root belong to no subsystem, so they are reported under
119
+ # "root_files" rather than being invented as a candidate.
120
+ #
121
+ # ---------------------------------------------------------------------------
122
+ # Scoring
123
+ # ---------------------------------------------------------------------------
124
+ # Each candidate carries every signal that scored it — name, normalised 0..1 value, weight,
125
+ # contribution and a human-readable detail — so a ranking can be audited rather than trusted.
126
+ #
127
+ # size 30 log-scaled line count relative to the largest candidate. Log-scaled so
128
+ # one enormous module does not flatten everything else to zero.
129
+ # manifest 20 own dependency manifest — the strongest "this is a unit" signal there is
130
+ # own_tests 15 own test directory or test-named files
131
+ # source_density 15 share of files with recognised source extensions, which separates a
132
+ # code module from an assets/fixtures/docs folder of the same size
133
+ # structure 10 has internal sub-structure rather than being a flat bag of files
134
+ # docs 10 own README / docs/ — somebody thought this was worth describing
135
+ # cohesion 20 --with-dependencies only: internal vs external dependency ratio
136
+ #
137
+ # score = 100 * sum(value * weight) / sum(weight), renormalised over whichever weights were in
138
+ # play, so enabling --with-dependencies changes the ranking but never the 0-100 scale. Ranks are
139
+ # assigned by score descending, then path ascending.
140
+ #
141
+ # ---------------------------------------------------------------------------
142
+ # Output contract (stdout, JSON)
143
+ # ---------------------------------------------------------------------------
144
+ # Additive by design — consumers should ignore keys they do not need, and new keys may be added
145
+ # without notice.
146
+ #
147
+ # {
148
+ # "script": "discover-subsystems.sh",
149
+ # "version": 1,
150
+ # "root": "<repo-relative path to the analysed root>",
151
+ # "root_absolute": "<absolute path>",
152
+ # "repo_root": "<absolute path to the git root, or null when not in a work tree>",
153
+ # "max_depth": <int>, // the depth bound in effect
154
+ # "min_files": <int>,
155
+ # "max_candidates": <int>, // runtime bound, NOT the onboard epic cap
156
+ # "framework_excluded": <bool>,
157
+ # "gitignore_respected": <bool>, // false => layer-1 exclusions did not apply
158
+ # "with_dependencies": <bool>,
159
+ # "totals": { "files": <int>, "lines": <int>, "bytes": <int> }, // whole analysed root
160
+ # "candidate_count": <int>,
161
+ # "candidates": [
162
+ # {
163
+ # "rank": <int>, // 1 = strongest
164
+ # "path": "<path relative to root>",
165
+ # "absolute_path": "<absolute path>",
166
+ # "depth": <int>, // levels below root
167
+ # "parent": "<path relative to root, or null at depth 1>",
168
+ # "score": <float 0-100>,
169
+ # "files": <int>, // recursive, from enumerate-target.sh, net of
170
+ # "lines": <int>, // anything listed in "nested_excluded" below
171
+ # "bytes": <int>,
172
+ # "direct_files": <int>, // files sitting directly in it, not in subdirectories
173
+ # "subdirectories": <int>,
174
+ # "manifests": [ "<name>", ... ],
175
+ # "test_paths": [ "<path relative to the candidate>", ... ], // capped at 5
176
+ # "doc_paths": [ "<path relative to the candidate>", ... ], // capped at 5
177
+ # "top_extensions": [ { "extension", "files", "lines" }, ... ], // capped at 5
178
+ # "largest_files": [ { "path", "lines" }, ... ], // capped at 5
179
+ # "nested_excluded": [ // vendored/build dirs found inside it and discounted from its
180
+ # // totals above; empty in the common (gitignored) case
181
+ # { "path", "reason", "files", "lines" }
182
+ # ],
183
+ # "signals": [
184
+ # { "name", "value": <0..1>, "weight": <int>, "contribution": <float>, "detail": "<why>" }
185
+ # ]
186
+ # }
187
+ # ],
188
+ # "containers": [
189
+ # { "path", "depth", "reason", "expanded_into": [ "<child path>", ... ] }
190
+ # ],
191
+ # "excluded": [
192
+ # { "path", "reason" }
193
+ # ],
194
+ # "root_files": { "count": <int>, "paths": [ "<name>", ... ] }, // paths capped at 25
195
+ # "notices": [ "<non-fatal diagnostic>", ... ]
196
+ # }
197
+ #
198
+ # ---------------------------------------------------------------------------
199
+ # Behaviour notes
200
+ # ---------------------------------------------------------------------------
201
+ # - No candidates found: NOT an error. Exits 0 with an empty "candidates" array and a notice
202
+ # explaining why (empty root, everything excluded, or everything below --min-files).
203
+ # - Root outside a git repository: NOT an error. "repo_root" is null, "gitignore_respected"
204
+ # is false, and a notice says only name-based exclusions applied.
205
+ # - Cost: the tree is read roughly twice — once for the repo-wide sweep that finds the
206
+ # candidates, then once per candidate for its statistics. That is acceptable for a one-shot
207
+ # onboarding pass and is bounded by --max-candidates.
208
+ # - Nested vendored/build content is hunted 4 levels into a candidate, not indefinitely. A
209
+ # node_modules/ buried deeper than that is still counted in its candidate's totals. The bound
210
+ # is stated rather than silently assumed: real vendor directories sit at a module's root, and
211
+ # an unbounded per-candidate walk would contradict the whole point of this script.
212
+ # - The candidate/container split is a heuristic and will sometimes disagree with a human's
213
+ # mental model of a codebase. That is why every decision is emitted with its reason: a wrong
214
+ # call is inspectable and overridable, not hidden.
215
+ #
216
+ # Exit codes:
217
+ # 0 — success (including every graceful-degradation path above)
218
+ # 1 — usage error (unknown flag, missing/extra root, bad numeric value)
219
+ # 2 — root missing, unreadable, or not a directory
220
+ # 3 — enumerate-target.sh is missing, not executable, or failed on the root
221
+ #
222
+ # Examples:
223
+ # discover-subsystems.sh .
224
+ # discover-subsystems.sh --max-depth 3 --min-files 5 /path/to/project
225
+ # discover-subsystems.sh --include-framework --with-dependencies .
226
+ # discover-subsystems.sh . | jq -r '.candidates[] | "\(.rank)\t\(.score)\t\(.path)"'
227
+ #
228
+ # Requires: bash, git, python3. jq is NOT required — JSON is emitted by python3.
229
+
230
+ set -euo pipefail
231
+
232
+ MAX_DEPTH=2
233
+ MIN_FILES=1
234
+ MAX_CANDIDATES=200
235
+ INCLUDE_FRAMEWORK=0
236
+ INCLUDE_TRAINING=0
237
+ WITH_DEPENDENCIES=0
238
+ ROOT=""
239
+
240
+ usage() {
241
+ cat <<EOF
242
+ Usage: $(basename "$0") [options] <root>
243
+
244
+ Print a ranked JSON list of the major subsystems of an existing codebase. Coarse by design:
245
+ depth-bounded, build/vendor/ignored paths excluded, Jenga scaffolding excluded.
246
+ Read-only: nothing is written to disk, and the analysed codebase is never modified.
247
+
248
+ Arguments:
249
+ <root> Root directory of the codebase to analyse.
250
+
251
+ Options:
252
+ --max-depth N Levels below <root> a candidate may sit (default: 2, minimum: 1)
253
+ --min-files N Ignore directories holding fewer than N files (default: 1)
254
+ --max-candidates N Runtime bound on candidates enumerated (default: 200).
255
+ Not the onboard epic cap — that is applied by a separate layer.
256
+ --include-framework Do not exclude project/ skills/ agents/ hooks/ templates/ .claude/ .agents/
257
+ --include-training-scaffolding
258
+ Do not exclude .training/ job-template scaffolding
259
+ --with-dependencies Also compute the cohesion signal via detect-dependencies.sh (slower)
260
+ -h, --help Show this help and exit
261
+
262
+ Exit codes: 0 success, 1 usage error, 2 root missing/unreadable/not a directory,
263
+ 3 enumerate-target.sh unavailable or failed.
264
+
265
+ Examples:
266
+ $(basename "$0") .
267
+ $(basename "$0") --max-depth 3 --min-files 5 /path/to/project
268
+ EOF
269
+ }
270
+
271
+ die_usage() {
272
+ echo "Error: $1" >&2
273
+ echo >&2
274
+ usage >&2
275
+ exit 1
276
+ }
277
+
278
+ require_int() {
279
+ # require_int <flag> <value> <minimum>
280
+ case "$2" in
281
+ ''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
282
+ esac
283
+ if [ "$2" -lt "$3" ]; then
284
+ die_usage "$1 must be >= $3, got \"$2\""
285
+ fi
286
+ }
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Argument parsing
290
+ # ---------------------------------------------------------------------------
291
+
292
+ while [ "$#" -gt 0 ]; do
293
+ case "$1" in
294
+ --max-depth)
295
+ [ "$#" -ge 2 ] || die_usage "--max-depth requires a value"
296
+ require_int "--max-depth" "$2" 1
297
+ MAX_DEPTH="$2"; shift 2 ;;
298
+ --max-depth=*)
299
+ require_int "--max-depth" "${1#*=}" 1
300
+ MAX_DEPTH="${1#*=}"; shift ;;
301
+ --min-files)
302
+ [ "$#" -ge 2 ] || die_usage "--min-files requires a value"
303
+ require_int "--min-files" "$2" 1
304
+ MIN_FILES="$2"; shift 2 ;;
305
+ --min-files=*)
306
+ require_int "--min-files" "${1#*=}" 1
307
+ MIN_FILES="${1#*=}"; shift ;;
308
+ --max-candidates)
309
+ [ "$#" -ge 2 ] || die_usage "--max-candidates requires a value"
310
+ require_int "--max-candidates" "$2" 1
311
+ MAX_CANDIDATES="$2"; shift 2 ;;
312
+ --max-candidates=*)
313
+ require_int "--max-candidates" "${1#*=}" 1
314
+ MAX_CANDIDATES="${1#*=}"; shift ;;
315
+ --include-framework)
316
+ INCLUDE_FRAMEWORK=1; shift ;;
317
+ --include-training-scaffolding)
318
+ INCLUDE_TRAINING=1; shift ;;
319
+ --with-dependencies)
320
+ WITH_DEPENDENCIES=1; shift ;;
321
+ -h|--help)
322
+ usage; exit 0 ;;
323
+ --)
324
+ shift
325
+ [ "$#" -eq 1 ] || die_usage "exactly one root is required"
326
+ ROOT="$1"; shift ;;
327
+ -*)
328
+ die_usage "unknown option \"$1\"" ;;
329
+ *)
330
+ [ -z "$ROOT" ] || die_usage "exactly one root is required (got \"$ROOT\" and \"$1\")"
331
+ ROOT="$1"; shift ;;
332
+ esac
333
+ done
334
+
335
+ [ -n "$ROOT" ] || die_usage "a root directory is required"
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Root resolution
339
+ # ---------------------------------------------------------------------------
340
+
341
+ if [ ! -e "$ROOT" ]; then
342
+ echo "Error: root does not exist: $ROOT" >&2
343
+ exit 2
344
+ fi
345
+ if [ ! -d "$ROOT" ]; then
346
+ echo "Error: root is not a directory: $ROOT" >&2
347
+ exit 2
348
+ fi
349
+ if [ ! -r "$ROOT" ] || [ ! -x "$ROOT" ]; then
350
+ echo "Error: root is not readable/traversable: $ROOT" >&2
351
+ exit 2
352
+ fi
353
+
354
+ # `readlink -f` is GNU-only and absent from older macOS/BSD userland; cd + `pwd -P` is portable.
355
+ ROOT_ABS=$(cd -- "$ROOT" && pwd -P)
356
+ REPO_ROOT=$(git -C "$ROOT_ABS" rev-parse --show-toplevel 2>/dev/null || true)
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Sibling scripts
360
+ # ---------------------------------------------------------------------------
361
+
362
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
363
+ ENUMERATE="$SCRIPT_DIR/enumerate-target.sh"
364
+ DETECT_DEPS="$SCRIPT_DIR/detect-dependencies.sh"
365
+
366
+ if [ ! -x "$ENUMERATE" ]; then
367
+ echo "Error: required helper is missing or not executable: $ENUMERATE" >&2
368
+ echo " discover-subsystems.sh delegates all enumeration to it rather than duplicating it." >&2
369
+ exit 3
370
+ fi
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Discover and emit
374
+ # ---------------------------------------------------------------------------
375
+
376
+ python3 - \
377
+ "$ROOT_ABS" "$REPO_ROOT" "$ENUMERATE" "$DETECT_DEPS" \
378
+ "$MAX_DEPTH" "$MIN_FILES" "$MAX_CANDIDATES" "$INCLUDE_FRAMEWORK" "$INCLUDE_TRAINING" \
379
+ "$WITH_DEPENDENCIES" <<'PY'
380
+ import json
381
+ import math
382
+ import os
383
+ import re
384
+ import subprocess
385
+ import sys
386
+
387
+ (ROOT_ABS, REPO_ROOT, ENUMERATE, DETECT_DEPS,
388
+ MAX_DEPTH_S, MIN_FILES_S, MAX_CANDIDATES_S,
389
+ INCLUDE_FRAMEWORK_S, INCLUDE_TRAINING_S, WITH_DEPENDENCIES_S) = sys.argv[1:11]
390
+
391
+ MAX_DEPTH = int(MAX_DEPTH_S)
392
+ MIN_FILES = int(MIN_FILES_S)
393
+ MAX_CANDIDATES = int(MAX_CANDIDATES_S)
394
+ INCLUDE_FRAMEWORK = INCLUDE_FRAMEWORK_S == "1"
395
+ INCLUDE_TRAINING = INCLUDE_TRAINING_S == "1"
396
+ WITH_DEPENDENCIES = WITH_DEPENDENCIES_S == "1"
397
+
398
+ notices = []
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Exclusion vocabulary
402
+ # ---------------------------------------------------------------------------
403
+
404
+ # Build outputs, vendored dependencies and VCS/tooling metadata. Excluded by NAME at any depth,
405
+ # because they mean the same thing wherever they appear. Baseline kept in step with
406
+ # detect-tests.sh / detect-dependencies.sh so the three scripts agree on what "not code" means.
407
+ SKIP_DIRS = {
408
+ ".git", ".svn", ".hg",
409
+ "node_modules", "bower_components", "vendor",
410
+ "dist", "build", "out", "target",
411
+ ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".tox",
412
+ ".next", ".nuxt", ".output", ".parcel-cache", ".turbo", ".cache",
413
+ "coverage", ".nyc_output",
414
+ ".gradle", ".idea", ".vscode", ".terraform", ".serverless", ".dart_tool",
415
+ "Pods", "DerivedData",
416
+ }
417
+
418
+ # Jenga's own scaffolding. Excluded by ROOT-RELATIVE PATH, never by bare name: a consumer's
419
+ # src/agents/ or app/templates/ is a real subsystem of their application and must survive.
420
+ FRAMEWORK_PATHS = {
421
+ "project", "skills", "agents", "hooks", "templates", ".claude", ".agents",
422
+ }
423
+
424
+ # Training-job template scaffolding for the /train skill (e.g. .training/template/{transformers,
425
+ # nlp,classifiers}/train.py). Kept separate from FRAMEWORK_PATHS — same root-relative-path
426
+ # discipline, but a distinct concept with its own opt-out, since a caller may want Jenga
427
+ # scaffolding excluded while still wanting job templates as a candidate, or vice versa.
428
+ TRAINING_SCAFFOLD_PATHS = {
429
+ ".training",
430
+ }
431
+
432
+ # Own-manifest = "this directory is a unit". Strong enough to veto splitting it apart.
433
+ MANIFEST_NAMES = {
434
+ "package.json", "deno.json", "deno.jsonc",
435
+ "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile",
436
+ "go.mod", "Cargo.toml", "Gemfile", "composer.json",
437
+ "pom.xml", "build.gradle", "build.gradle.kts", "build.sbt",
438
+ "mix.exs", "pubspec.yaml", "Package.swift", "CMakeLists.txt",
439
+ "Chart.yaml", "terraform.tf",
440
+ }
441
+ MANIFEST_SUFFIXES = (".csproj", ".fsproj", ".gemspec", ".podspec", ".cabal")
442
+
443
+ TEST_DIR_NAMES = {"test", "tests", "spec", "specs", "__tests__", "e2e", "testing", "it"}
444
+ TEST_FILE_PATTERNS = [
445
+ re.compile(r".+_test\.[A-Za-z0-9]+$"),
446
+ re.compile(r".+\.test\.[A-Za-z0-9]+$"),
447
+ re.compile(r"^test_.+\.[A-Za-z0-9]+$"),
448
+ re.compile(r".+_spec\.[A-Za-z0-9]+$"),
449
+ re.compile(r".+\.spec\.[A-Za-z0-9]+$"),
450
+ re.compile(r".+Tests?\.(?:java|kt|kts|cs|scala)$"),
451
+ re.compile(r".+\.bats$"),
452
+ re.compile(r"^conftest\.py$"),
453
+ ]
454
+
455
+ DOC_DIR_NAMES = {"docs", "doc"}
456
+ DOC_FILE_PATTERN = re.compile(r"^(README|readme|Readme)(\..+)?$")
457
+
458
+ SOURCE_EXTS = {
459
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".vue", ".svelte",
460
+ ".py", ".pyi", ".go", ".rs", ".rb", ".php", ".java", ".kt", ".kts",
461
+ ".scala", ".swift", ".m", ".mm", ".cs", ".fs", ".c", ".h", ".cc",
462
+ ".cpp", ".hpp", ".hh", ".ex", ".exs", ".erl", ".dart", ".lua", ".pl",
463
+ ".r", ".jl", ".sh", ".bash", ".zsh", ".sql", ".proto", ".tf", ".hcl",
464
+ }
465
+
466
+ # Reporting caps — these bound the SIZE OF THE OUTPUT, not the analysis.
467
+ CAP_TEST_PATHS = 5
468
+ CAP_DOC_PATHS = 5
469
+ CAP_EXTENSIONS = 5
470
+ CAP_LARGEST = 5
471
+
472
+ # Pass-2 depth windows, deliberately separate:
473
+ # CANDIDATE_TREE_DEPTH how deep a candidate's own tree is walked when hunting for nested
474
+ # vendored/build content to discount. Deeper than the signal window
475
+ # because vendor directories are not always at a module's root
476
+ # (packages/api/src/deep/node_modules is unusual but real). Costs only
477
+ # JSON size: enumerate-target.sh counts the whole subtree either way.
478
+ # CANDIDATE_SIGNAL_DEPTH how deep manifests, tests and docs are looked for. Stays shallow on
479
+ # purpose -- a manifest or test directory that far from a module's root
480
+ # is not evidence about the module itself.
481
+ CANDIDATE_TREE_DEPTH = 4
482
+ CANDIDATE_SIGNAL_DEPTH = 2
483
+ CAP_ROOT_FILES = 25
484
+
485
+ WEIGHTS = {
486
+ "size": 30,
487
+ "manifest": 20,
488
+ "own_tests": 15,
489
+ "source_density": 15,
490
+ "structure": 10,
491
+ "docs": 10,
492
+ "cohesion": 20, # only in play under --with-dependencies
493
+ }
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # enumerate-target.sh delegation
497
+ # ---------------------------------------------------------------------------
498
+
499
+
500
+ def enumerate_target(path, depth, top):
501
+ """Run enumerate-target.sh and return its parsed JSON, or (None, reason)."""
502
+ cmd = [ENUMERATE, "--max-depth", str(depth), "--top", str(top), "--", path]
503
+ try:
504
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
505
+ except OSError as exc:
506
+ return None, "could not execute enumerate-target.sh: %s" % exc
507
+ if proc.returncode != 0:
508
+ detail = proc.stderr.decode("utf-8", "replace").strip().splitlines()
509
+ return None, "enumerate-target.sh exited %d%s" % (
510
+ proc.returncode, (": " + detail[0]) if detail else "")
511
+ try:
512
+ return json.loads(proc.stdout.decode("utf-8", "replace")), None
513
+ except ValueError as exc:
514
+ return None, "enumerate-target.sh emitted unparseable JSON: %s" % exc
515
+
516
+
517
+ def parent_of(rel):
518
+ return rel.rsplit("/", 1)[0] if "/" in rel else ""
519
+
520
+
521
+ def basename_of(rel):
522
+ return rel.rsplit("/", 1)[-1]
523
+
524
+
525
+ def is_manifest(name):
526
+ return name in MANIFEST_NAMES or name.endswith(MANIFEST_SUFFIXES)
527
+
528
+
529
+ def looks_like_test_file(name):
530
+ return any(p.match(name) for p in TEST_FILE_PATTERNS)
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Pass 1 — a single repo-wide enumeration builds the directory graph
535
+ # ---------------------------------------------------------------------------
536
+ # MAX_DEPTH + 1 is required, not generous: deciding whether a directory AT the depth bound is a
537
+ # candidate needs to see the files sitting directly inside it, which live one level deeper.
538
+
539
+ survey, err = enumerate_target(ROOT_ABS, MAX_DEPTH + 1, 1)
540
+ if survey is None:
541
+ sys.stderr.write("Error: %s\n" % err)
542
+ sys.stderr.write(" root: %s\n" % ROOT_ABS)
543
+ sys.exit(3)
544
+
545
+ gitignore_respected = bool(survey.get("gitignore_respected"))
546
+ if not gitignore_respected:
547
+ if survey.get("ignored_target"):
548
+ notices.append(
549
+ "The analysed root is itself .gitignore'd, so enumerate-target.sh fell back to a "
550
+ "filesystem walk. .gitignore exclusions did not apply; only the build/vendor name "
551
+ "list did."
552
+ )
553
+ else:
554
+ notices.append(
555
+ "The analysed root is not inside a git work tree, so .gitignore could not be "
556
+ "honoured. Only the build/vendor name list and the framework exclusions applied — "
557
+ "ignored-but-present paths may appear as candidates."
558
+ )
559
+
560
+ dir_files = {} # rel dir path -> recursive file count
561
+ dir_depth = {}
562
+ file_paths = set() # rel file paths within MAX_DEPTH + 1
563
+
564
+ for entry in survey.get("tree", []):
565
+ if entry.get("type") == "directory":
566
+ dir_files[entry["path"]] = entry.get("file_count", 0)
567
+ dir_depth[entry["path"]] = entry.get("depth", entry["path"].count("/") + 1)
568
+ elif entry.get("type") == "file":
569
+ file_paths.add(entry["path"])
570
+
571
+ children_by_dir = {}
572
+ for d in dir_files:
573
+ children_by_dir.setdefault(parent_of(d), []).append(d)
574
+ for key in children_by_dir:
575
+ children_by_dir[key].sort()
576
+
577
+ files_by_dir = {}
578
+ for f in file_paths:
579
+ files_by_dir.setdefault(parent_of(f), []).append(f)
580
+ for key in files_by_dir:
581
+ files_by_dir[key].sort()
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Exclusion, viability, and the candidate/container partition
585
+ # ---------------------------------------------------------------------------
586
+
587
+ excluded = []
588
+ containers = []
589
+ candidate_paths = []
590
+
591
+
592
+ def exclusion_reason(rel, depth):
593
+ name = basename_of(rel)
594
+ if name in SKIP_DIRS:
595
+ return "build output, vendored dependency, or tooling directory (%s/)" % name
596
+ if name == "worktrees" and parent_of(rel).endswith(".claude"):
597
+ return "worktree directory (.claude/worktrees holds full copies of the repo)"
598
+ if not INCLUDE_FRAMEWORK and depth == 1 and rel in FRAMEWORK_PATHS:
599
+ return ("Jenga framework scaffolding, not consumer application code "
600
+ "(use --include-framework to keep it)")
601
+ if not INCLUDE_TRAINING and depth == 1 and rel in TRAINING_SCAFFOLD_PATHS:
602
+ return ("training-job template scaffolding, not consumer application code "
603
+ "(use --include-training-scaffolding to keep it)")
604
+ return None
605
+
606
+
607
+ def viable_children(dirpath, depth):
608
+ """Child directories of dirpath that survive exclusion and the --min-files floor."""
609
+ out = []
610
+ for child in children_by_dir.get(dirpath, []):
611
+ if exclusion_reason(child, depth + 1):
612
+ continue
613
+ if dir_files.get(child, 0) < MIN_FILES:
614
+ continue
615
+ out.append(child)
616
+ return out
617
+
618
+
619
+ def has_own_manifest(dirpath):
620
+ return any(is_manifest(basename_of(f)) for f in files_by_dir.get(dirpath, []))
621
+
622
+
623
+ def descend(dirpath, depth):
624
+ """Classify every child of dirpath as candidate, container, or excluded."""
625
+ for child in children_by_dir.get(dirpath, []):
626
+ reason = exclusion_reason(child, depth + 1)
627
+ if reason:
628
+ excluded.append({"path": child, "reason": reason})
629
+ continue
630
+ count = dir_files.get(child, 0)
631
+ if count < MIN_FILES:
632
+ excluded.append({
633
+ "path": child,
634
+ "reason": "below the --min-files floor of %d (%d file%s)" % (
635
+ MIN_FILES, count, "" if count == 1 else "s"),
636
+ })
637
+ continue
638
+ classify(child, depth + 1)
639
+
640
+
641
+ def classify(dirpath, depth):
642
+ """Decide whether dirpath is a subsystem or a folder of subsystems."""
643
+ if depth >= MAX_DEPTH:
644
+ candidate_paths.append(dirpath)
645
+ return
646
+ if has_own_manifest(dirpath):
647
+ # A directory with its own manifest is a unit by definition. This is the rule that keeps
648
+ # monorepo workspace packages intact regardless of their internal shape.
649
+ candidate_paths.append(dirpath)
650
+ return
651
+
652
+ kids = viable_children(dirpath, depth)
653
+ if len(kids) < 2:
654
+ candidate_paths.append(dirpath)
655
+ return
656
+
657
+ direct = len(files_by_dir.get(dirpath, []))
658
+ total = dir_files.get(dirpath, 0)
659
+ incidental = direct <= 3 or (total > 0 and (float(direct) / total) <= 0.20)
660
+ if not incidental:
661
+ # It holds substantial code of its own, so it is a subsystem in its own right rather
662
+ # than a wrapper around its children.
663
+ candidate_paths.append(dirpath)
664
+ return
665
+
666
+ containers.append({
667
+ "path": dirpath,
668
+ "depth": depth,
669
+ "reason": ("holds %d viable child directories and only %d file%s of its own, so it "
670
+ "reads as a folder of subsystems rather than a subsystem" % (
671
+ len(kids), direct, "" if direct == 1 else "s")),
672
+ "expanded_into": kids,
673
+ })
674
+ descend(dirpath, depth)
675
+
676
+
677
+ descend("", 0)
678
+ candidate_paths.sort()
679
+
680
+ # --- runtime bound (NOT the onboard epic cap) --------------------------------------------------
681
+ if len(candidate_paths) > MAX_CANDIDATES:
682
+ ordered = sorted(candidate_paths, key=lambda p: (-dir_files.get(p, 0), p))
683
+ kept, dropped = ordered[:MAX_CANDIDATES], ordered[MAX_CANDIDATES:]
684
+ for p in sorted(dropped):
685
+ excluded.append({
686
+ "path": p,
687
+ "reason": "beyond the --max-candidates runtime bound of %d (analysis cost guard, "
688
+ "not the onboard epic cap)" % MAX_CANDIDATES,
689
+ })
690
+ notices.append(
691
+ "%d candidate directories were found but only the %d largest were enumerated "
692
+ "(--max-candidates). The remainder are listed under \"excluded\". This is a cost guard "
693
+ "on the analysis, not the onboard epic cap." % (len(candidate_paths), MAX_CANDIDATES)
694
+ )
695
+ candidate_paths = sorted(kept)
696
+
697
+ # ---------------------------------------------------------------------------
698
+ # Pass 2 — per-candidate statistics, delegated to enumerate-target.sh
699
+ # ---------------------------------------------------------------------------
700
+
701
+ deps_available = WITH_DEPENDENCIES and os.access(DETECT_DEPS, os.X_OK)
702
+ if WITH_DEPENDENCIES and not deps_available:
703
+ notices.append(
704
+ "--with-dependencies was requested but detect-dependencies.sh is missing or not "
705
+ "executable at %s; the cohesion signal was dropped and the remaining weights were "
706
+ "renormalised." % DETECT_DEPS
707
+ )
708
+
709
+
710
+ def cohesion_for(abs_path):
711
+ """internal / (internal + external) dependency ratio, or None when unavailable."""
712
+ try:
713
+ proc = subprocess.run([DETECT_DEPS, abs_path],
714
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
715
+ except OSError:
716
+ return None
717
+ if proc.returncode != 0:
718
+ return None
719
+ try:
720
+ report = json.loads(proc.stdout.decode("utf-8", "replace"))
721
+ except ValueError:
722
+ return None
723
+ counts = report.get("counts") or {}
724
+ internal = counts.get("internal", 0)
725
+ external = counts.get("external", 0)
726
+ if internal + external == 0:
727
+ return None
728
+ return float(internal) / (internal + external), internal, external
729
+
730
+
731
+ def skip_dir_reason(rel_in_candidate):
732
+ """Layer-2 exclusion, evaluated INSIDE an already-selected candidate."""
733
+ name = basename_of(rel_in_candidate)
734
+ if name in SKIP_DIRS:
735
+ return "build output, vendored dependency, or tooling directory (%s/)" % name
736
+ if name == "worktrees" and parent_of(rel_in_candidate).endswith(".claude"):
737
+ return "worktree directory (.claude/worktrees holds full copies of the repo)"
738
+ return None
739
+
740
+
741
+ def nested_skip_roots(stats, candidate_rel):
742
+ """Outermost SKIP_DIRS directories sitting inside a candidate's own subtree.
743
+
744
+ enumerate-target.sh has no name-exclusion concept -- .gitignore is its only filter -- so a
745
+ COMMITTED node_modules/vendor/dist/build inside a candidate would otherwise be folded into
746
+ that candidate's own totals and score. Gitignored ones never reach here: they are absent from
747
+ the parent's listing entirely, which is also why subtracting the ones that ARE here can never
748
+ double-subtract. Only outermost matches are returned; the tree is path-sorted, so a parent
749
+ always precedes its children.
750
+
751
+ Paths are re-qualified against candidate_rel before being tested. The tree is relative to the
752
+ candidate, but skip_dir_reason() reasons about a path's PARENT (that is how .claude/worktrees
753
+ is recognised), and a candidate-relative path has no parent to inspect at its top level. Test
754
+ "worktrees" alone and the .claude/worktrees rule silently never fires for a candidate that IS
755
+ .claude -- returning paths still candidate-relative, since that is what the caller subtracts
756
+ against.
757
+ """
758
+ roots = []
759
+ for entry in stats.get("tree", []):
760
+ if entry.get("type") != "directory":
761
+ continue
762
+ path = entry["path"]
763
+ if any(path == r or path.startswith(r + "/") for r, _ in roots):
764
+ continue
765
+ qualified = (candidate_rel + "/" + path) if candidate_rel else path
766
+ reason = skip_dir_reason(qualified)
767
+ if reason:
768
+ roots.append((path, reason))
769
+ return roots
770
+
771
+
772
+ measured = []
773
+ for rel in candidate_paths:
774
+ abs_path = os.path.join(ROOT_ABS, rel)
775
+ stats, err = enumerate_target(abs_path, CANDIDATE_TREE_DEPTH, CAP_LARGEST)
776
+ if stats is None:
777
+ excluded.append({"path": rel, "reason": "enumeration failed (%s)" % err})
778
+ notices.append("Skipped candidate %s: %s" % (rel, err))
779
+ continue
780
+
781
+ # --- discount any vendored/build content nested inside this candidate ---------------------
782
+ # Enumeration stays delegated: each nested directory's weight is measured by
783
+ # enumerate-target.sh in its own right and then subtracted, rather than recounted here.
784
+ skip_roots = nested_skip_roots(stats, rel)
785
+ nested_excluded = []
786
+ discount_files = discount_lines = discount_bytes = 0
787
+ discount_ext_files = {}
788
+ discount_ext_lines = {}
789
+
790
+ for skip_path, skip_reason in skip_roots:
791
+ sub, sub_err = enumerate_target(os.path.join(abs_path, skip_path), 1, 1)
792
+ if sub is None:
793
+ notices.append(
794
+ "Could not measure %s nested inside candidate %s (%s); its content remains "
795
+ "counted in that candidate's statistics." % (skip_path, rel, sub_err))
796
+ continue
797
+ discount_files += sub.get("total_files", 0)
798
+ discount_lines += sub.get("total_lines", 0)
799
+ discount_bytes += sub.get("total_bytes", 0)
800
+ for e in sub.get("extensions", []):
801
+ ext = e.get("extension")
802
+ discount_ext_files[ext] = discount_ext_files.get(ext, 0) + e.get("files", 0)
803
+ discount_ext_lines[ext] = discount_ext_lines.get(ext, 0) + e.get("lines", 0)
804
+ nested_excluded.append({
805
+ "path": skip_path,
806
+ "reason": skip_reason,
807
+ "files": sub.get("total_files", 0),
808
+ "lines": sub.get("total_lines", 0),
809
+ })
810
+ excluded.append({
811
+ "path": (rel + "/" + skip_path),
812
+ "reason": skip_reason + ", discounted from its parent candidate's statistics",
813
+ })
814
+
815
+ def under_skip_root(path):
816
+ return any(path == r or path.startswith(r + "/") for r, _ in skip_roots)
817
+
818
+ direct_files = []
819
+ subdirs = []
820
+ manifests = []
821
+ test_paths = []
822
+ doc_paths = []
823
+
824
+ for entry in stats.get("tree", []):
825
+ path = entry["path"]
826
+ if under_skip_root(path):
827
+ continue
828
+ depth = entry.get("depth", path.count("/") + 1)
829
+ name = basename_of(path)
830
+ if entry.get("type") == "directory":
831
+ if depth == 1:
832
+ subdirs.append(path)
833
+ if name.lower() in TEST_DIR_NAMES:
834
+ test_paths.append(path + "/")
835
+ if name.lower() in DOC_DIR_NAMES:
836
+ doc_paths.append(path + "/")
837
+ else:
838
+ if depth == 1:
839
+ direct_files.append(path)
840
+ if is_manifest(name):
841
+ manifests.append(name)
842
+ if DOC_FILE_PATTERN.match(name):
843
+ doc_paths.append(path)
844
+ if depth <= CANDIDATE_SIGNAL_DEPTH and looks_like_test_file(name):
845
+ test_paths.append(path)
846
+
847
+ # Clamped at zero: the subtraction is exact by construction, but a candidate's stats must
848
+ # never be able to go negative on the back of an enumeration disagreement.
849
+ total_files = max(0, stats.get("total_files", 0) - discount_files)
850
+ total_lines = max(0, stats.get("total_lines", 0) - discount_lines)
851
+ total_bytes = max(0, stats.get("total_bytes", 0) - discount_bytes)
852
+
853
+ extensions = []
854
+ for e in stats.get("extensions", []):
855
+ ext = e.get("extension")
856
+ files_left = e.get("files", 0) - discount_ext_files.get(ext, 0)
857
+ if files_left <= 0:
858
+ continue
859
+ extensions.append({
860
+ "extension": ext,
861
+ "files": files_left,
862
+ "lines": max(0, e.get("lines", 0) - discount_ext_lines.get(ext, 0)),
863
+ })
864
+ extensions.sort(key=lambda e: (-e["files"], e["extension"]))
865
+
866
+ source_files = sum(e["files"] for e in extensions if e["extension"] in SOURCE_EXTS)
867
+
868
+ measured.append({
869
+ "path": rel,
870
+ "absolute_path": abs_path,
871
+ "depth": dir_depth.get(rel, rel.count("/") + 1),
872
+ "parent": parent_of(rel) or None,
873
+ "files": total_files,
874
+ "lines": total_lines,
875
+ "bytes": total_bytes,
876
+ "direct_files": len(direct_files),
877
+ "subdirectories": len(subdirs),
878
+ "manifests": sorted(set(manifests)),
879
+ "test_paths": sorted(set(test_paths))[:CAP_TEST_PATHS],
880
+ "doc_paths": sorted(set(doc_paths))[:CAP_DOC_PATHS],
881
+ "top_extensions": extensions[:CAP_EXTENSIONS],
882
+ "largest_files": [{"path": f["path"], "lines": f["lines"]}
883
+ for f in stats.get("largest_files", [])
884
+ if not under_skip_root(f["path"])][:CAP_LARGEST],
885
+ "nested_excluded": sorted(nested_excluded, key=lambda n: n["path"]),
886
+ "_source_files": source_files,
887
+ })
888
+
889
+ # ---------------------------------------------------------------------------
890
+ # Scoring
891
+ # ---------------------------------------------------------------------------
892
+
893
+ max_lines = max([c["lines"] for c in measured], default=0)
894
+
895
+ for c in measured:
896
+ signals = []
897
+
898
+ def add(name, value, detail):
899
+ value = max(0.0, min(1.0, float(value)))
900
+ weight = WEIGHTS[name]
901
+ signals.append({
902
+ "name": name,
903
+ "value": round(value, 4),
904
+ "weight": weight,
905
+ "contribution": round(value * weight, 2),
906
+ "detail": detail,
907
+ })
908
+
909
+ if max_lines > 0:
910
+ size_value = math.log1p(c["lines"]) / math.log1p(max_lines)
911
+ else:
912
+ size_value = 0.0
913
+ add("size", size_value,
914
+ "%d line%s across %d file%s (log-scaled against the largest candidate, %d lines)" % (
915
+ c["lines"], "" if c["lines"] == 1 else "s",
916
+ c["files"], "" if c["files"] == 1 else "s", max_lines))
917
+
918
+ add("manifest", 1.0 if c["manifests"] else 0.0,
919
+ ("declares its own %s" % ", ".join(c["manifests"])) if c["manifests"]
920
+ else "no dependency manifest of its own")
921
+
922
+ add("own_tests", 1.0 if c["test_paths"] else 0.0,
923
+ ("carries its own tests (%s)" % ", ".join(c["test_paths"][:3])) if c["test_paths"]
924
+ else "no test directory or test-named files of its own")
925
+
926
+ density = (float(c["_source_files"]) / c["files"]) if c["files"] else 0.0
927
+ add("source_density", density,
928
+ "%d of %d file%s have a recognised source extension" % (
929
+ c["_source_files"], c["files"], "" if c["files"] == 1 else "s"))
930
+
931
+ add("structure", min(c["subdirectories"], 4) / 4.0,
932
+ "%d immediate subdirector%s" % (
933
+ c["subdirectories"], "y" if c["subdirectories"] == 1 else "ies"))
934
+
935
+ add("docs", 1.0 if c["doc_paths"] else 0.0,
936
+ ("documented by %s" % ", ".join(c["doc_paths"][:3])) if c["doc_paths"]
937
+ else "no README or docs/ of its own")
938
+
939
+ if deps_available:
940
+ result = cohesion_for(c["absolute_path"])
941
+ if result is None:
942
+ add("cohesion", 0.0, "no import/require statements could be resolved")
943
+ else:
944
+ ratio, internal, external = result
945
+ add("cohesion", ratio,
946
+ "%d internal vs %d external dependencies" % (internal, external))
947
+
948
+ total_weight = sum(s["weight"] for s in signals)
949
+ raw = sum(s["contribution"] for s in signals)
950
+ c["score"] = round(100.0 * raw / total_weight, 2) if total_weight else 0.0
951
+ c["signals"] = signals
952
+ del c["_source_files"]
953
+
954
+ measured.sort(key=lambda c: (-c["score"], c["path"]))
955
+
956
+ candidates = []
957
+ for i, c in enumerate(measured, start=1):
958
+ candidates.append({
959
+ "rank": i,
960
+ "path": c["path"],
961
+ "absolute_path": c["absolute_path"],
962
+ "depth": c["depth"],
963
+ "parent": c["parent"],
964
+ "score": c["score"],
965
+ "files": c["files"],
966
+ "lines": c["lines"],
967
+ "bytes": c["bytes"],
968
+ "direct_files": c["direct_files"],
969
+ "subdirectories": c["subdirectories"],
970
+ "manifests": c["manifests"],
971
+ "test_paths": c["test_paths"],
972
+ "doc_paths": c["doc_paths"],
973
+ "top_extensions": c["top_extensions"],
974
+ "largest_files": c["largest_files"],
975
+ "nested_excluded": c["nested_excluded"],
976
+ "signals": c["signals"],
977
+ })
978
+
979
+ # ---------------------------------------------------------------------------
980
+ # Assemble
981
+ # ---------------------------------------------------------------------------
982
+
983
+ if not candidates:
984
+ if not dir_files and not file_paths:
985
+ notices.append("No files were found under the analysed root — nothing to discover.")
986
+ elif excluded:
987
+ notices.append(
988
+ "No candidate subsystems survived filtering: all %d directory candidate(s) were "
989
+ "excluded. See \"excluded\" for the reason on each." % len(excluded)
990
+ )
991
+ else:
992
+ notices.append(
993
+ "No candidate subsystems were found. The root appears to hold only loose files — "
994
+ "see \"root_files\"."
995
+ )
996
+
997
+ root_file_names = files_by_dir.get("", [])
998
+
999
+ result = {
1000
+ "script": "discover-subsystems.sh",
1001
+ "version": 1,
1002
+ "root": os.path.relpath(ROOT_ABS, REPO_ROOT) if REPO_ROOT else ROOT_ABS,
1003
+ "root_absolute": ROOT_ABS,
1004
+ "repo_root": REPO_ROOT or None,
1005
+ "max_depth": MAX_DEPTH,
1006
+ "min_files": MIN_FILES,
1007
+ "max_candidates": MAX_CANDIDATES,
1008
+ "framework_excluded": not INCLUDE_FRAMEWORK,
1009
+ "gitignore_respected": gitignore_respected,
1010
+ "with_dependencies": deps_available,
1011
+ "totals": {
1012
+ "files": survey.get("total_files", 0),
1013
+ "lines": survey.get("total_lines", 0),
1014
+ "bytes": survey.get("total_bytes", 0),
1015
+ },
1016
+ "candidate_count": len(candidates),
1017
+ "candidates": candidates,
1018
+ "containers": sorted(containers, key=lambda c: c["path"]),
1019
+ "excluded": sorted(excluded, key=lambda e: e["path"]),
1020
+ "root_files": {
1021
+ "count": len(root_file_names),
1022
+ "paths": sorted(root_file_names)[:CAP_ROOT_FILES],
1023
+ },
1024
+ "notices": notices,
1025
+ }
1026
+
1027
+ json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
1028
+ sys.stdout.write("\n")
1029
+ PY