@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,655 @@
1
+ #!/usr/bin/env bash
2
+ # run-engine.sh — mode-agnostic entry point for the /uncharted investigative engine
3
+ #
4
+ # Usage: run-engine.sh [options] <target>
5
+ # run-engine.sh --help
6
+ #
7
+ # This is the ONE script the calling agent invokes. It does not reimplement any analysis:
8
+ # it orchestrates the three deterministic detectors delivered by E40_S01_T02/T03, merges
9
+ # their JSON, renders the understanding document from
10
+ # skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md, writes it into
11
+ # project/rapports/analysis/, and prints the written path — and nothing else — on stdout.
12
+ #
13
+ # enumerate-target.sh → Target + Structure sections
14
+ # detect-dependencies.sh → Key Dependencies section
15
+ # detect-tests.sh → Existing Tests section
16
+ #
17
+ # All three modes (`segment`, `import`, `onboard`) call this script. That is what makes the
18
+ # engine shared: mode changes the filename, the Target section, and the default tree depth —
19
+ # never the analysis itself.
20
+ #
21
+ # MECHANICAL vs JUDGEMENT. This script fills Target, Structure, Key Dependencies and Existing
22
+ # Tests from real detector output. It leaves Purpose, Risk Areas and Open Questions as the
23
+ # template's `_TODO(agent):_` placeholders. It must never fabricate them; the scrum-master fills
24
+ # them in from the mechanical evidence.
25
+ #
26
+ # Side effects: exactly one file written (the document), plus the merged JSON when --json-out is
27
+ # given, plus mkdir -p on the output directory. The three detectors it calls are strictly
28
+ # read-only. No application code is ever modified.
29
+ #
30
+ # ---------------------------------------------------------------------------
31
+ # OPTIONS
32
+ # ---------------------------------------------------------------------------
33
+ # --mode segment|import|onboard
34
+ # Mode hint. Default: segment. Reflected in the output filename and in the document's
35
+ # Target section. `onboard` additionally defaults --max-depth to 2 (coarse by design).
36
+ # --max-depth N Tree depth bound, forwarded to enumerate-target.sh.
37
+ # Default 2 for onboard, 3 otherwise. 0 = unlimited.
38
+ # --top N Number of largest files to report. Default 10. 0 = all.
39
+ # --no-gitignore Forwarded to enumerate-target.sh: enumerate .gitignore'd content too.
40
+ # --origin <text> Value for the Target section's Origin row. Default is derived:
41
+ # "in-repo", or "outside this repository (<path>)". `import` mode should
42
+ # pass its acquired source, e.g. --origin "imported from <git url>".
43
+ # --out-dir <dir> Output directory. Default: <repo-root>/project/rapports/analysis
44
+ # --json-out <file> Also write the merged raw detector JSON here, for an agent that wants
45
+ # to reason over the evidence directly. Not written unless requested.
46
+ # --template <file> Override the understanding-document template. Defaults to
47
+ # ../assets/UNDERSTANDING_DOC_TEMPLATE.md relative to this script.
48
+ # -h, --help Show this help and exit 0.
49
+ #
50
+ # ---------------------------------------------------------------------------
51
+ # OUTPUT
52
+ # ---------------------------------------------------------------------------
53
+ # stdout : the absolute path of the written document. One line. Nothing else — so callers can
54
+ # do DOC=$(run-engine.sh ...) safely.
55
+ # stderr : notices and skipped-path diagnostics forwarded from the detectors, plus any render
56
+ # warnings. Never silently swallowed, never injected into the document (the seven
57
+ # document headings are fixed, so there is nowhere to put them).
58
+ #
59
+ # Filename: uncharted-<mode>-<slug>-<YYYYMMDDTHHMMSSZ>.md
60
+ # <slug> is derived from the target's basename. A collision suffix (-2, -3, …) is
61
+ # appended rather than overwriting an existing document.
62
+ #
63
+ # ---------------------------------------------------------------------------
64
+ # TEMPLATE CONTRACT CONSUMED
65
+ # ---------------------------------------------------------------------------
66
+ # SCALAR tokens {{TOKEN}} substituted once, in place.
67
+ # ROW templates a table row line ending in `<!-- ROW -->` is repeated once per record and
68
+ # dropped entirely when there are no records. A row whose tokens this renderer
69
+ # does not recognise is dropped with a stderr warning — an unsubstituted
70
+ # {{TOKEN}} must never reach the document.
71
+ # The template's own leading `<!-- OUTPUT CONTRACT ... -->` block is instructions to this
72
+ # renderer, not document content, and is removed from the output. Per-section
73
+ # MECHANICAL/JUDGEMENT comments are kept: they instruct the agent who fills the judgement
74
+ # sections.
75
+ #
76
+ # Exit codes:
77
+ # 0 — success; document written and its path printed
78
+ # 1 — usage error (unknown flag, bad mode, missing/duplicate target, bad numeric value)
79
+ # 2 — target error (does not exist / not readable)
80
+ # 3 — an upstream detector script is missing or failed (its stderr is surfaced)
81
+ # 4 — render or write failure (template missing, heading check failed, unwritable output or
82
+ # --json-out directory). No document is left behind on this path: both destinations are
83
+ # validated before any detector runs, and the document is the last thing written.
84
+ #
85
+ # Requires: bash, python3, and the three sibling detector scripts. jq is NOT required.
86
+
87
+ set -euo pipefail
88
+
89
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
90
+
91
+ MODE="segment"
92
+ MAX_DEPTH=""
93
+ TOP=10
94
+ NO_GITIGNORE=0
95
+ ORIGIN=""
96
+ OUT_DIR=""
97
+ JSON_OUT=""
98
+ TEMPLATE="$SCRIPT_DIR/../assets/UNDERSTANDING_DOC_TEMPLATE.md"
99
+ TARGET=""
100
+
101
+ usage() {
102
+ cat <<EOF
103
+ Usage: $(basename "$0") [options] <target>
104
+
105
+ Run the shared /uncharted investigative engine against <target> (a file, a directory, or a
106
+ repository root). Invokes enumerate-target.sh, detect-dependencies.sh and detect-tests.sh,
107
+ renders the understanding document, and prints the written path on stdout.
108
+
109
+ Options:
110
+ --mode MODE segment | import | onboard (default: segment)
111
+ --max-depth N tree depth bound (default: 2 for onboard, 3 otherwise; 0 = unlimited)
112
+ --top N largest-files count (default: 10; 0 = all)
113
+ --no-gitignore enumerate .gitignore'd content too
114
+ --origin TEXT Origin value for the Target section (default: derived)
115
+ --out-dir DIR output directory (default: <repo-root>/project/rapports/analysis)
116
+ --json-out FILE also write the merged raw detector JSON here
117
+ --template FILE override the understanding-document template
118
+ -h, --help show this help and exit
119
+
120
+ Exit codes: 0 success, 1 usage error, 2 target error, 3 detector failure, 4 render/write failure.
121
+ EOF
122
+ }
123
+
124
+ die_usage() {
125
+ echo "Error: $1" >&2
126
+ echo >&2
127
+ usage >&2
128
+ exit 1
129
+ }
130
+
131
+ require_int() {
132
+ case "$2" in
133
+ ''|*[!0-9]*) die_usage "$1 requires a non-negative integer, got \"$2\"" ;;
134
+ esac
135
+ }
136
+
137
+ require_value() {
138
+ # require_value <flag> <remaining-arg-count>
139
+ [ "$2" -ge 2 ] || die_usage "$1 requires a value"
140
+ }
141
+
142
+ while [ "$#" -gt 0 ]; do
143
+ case "$1" in
144
+ --mode) require_value "--mode" "$#"; MODE="$2"; shift 2 ;;
145
+ --mode=*) MODE="${1#*=}"; shift ;;
146
+ --max-depth) require_value "--max-depth" "$#"; require_int "--max-depth" "$2"; MAX_DEPTH="$2"; shift 2 ;;
147
+ --max-depth=*) require_int "--max-depth" "${1#*=}"; MAX_DEPTH="${1#*=}"; shift ;;
148
+ --top) require_value "--top" "$#"; require_int "--top" "$2"; TOP="$2"; shift 2 ;;
149
+ --top=*) require_int "--top" "${1#*=}"; TOP="${1#*=}"; shift ;;
150
+ --no-gitignore) NO_GITIGNORE=1; shift ;;
151
+ --origin) require_value "--origin" "$#"; ORIGIN="$2"; shift 2 ;;
152
+ --origin=*) ORIGIN="${1#*=}"; shift ;;
153
+ --out-dir) require_value "--out-dir" "$#"; OUT_DIR="$2"; shift 2 ;;
154
+ --out-dir=*) OUT_DIR="${1#*=}"; shift ;;
155
+ --json-out) require_value "--json-out" "$#"; JSON_OUT="$2"; shift 2 ;;
156
+ --json-out=*) JSON_OUT="${1#*=}"; shift ;;
157
+ --template) require_value "--template" "$#"; TEMPLATE="$2"; shift 2 ;;
158
+ --template=*) TEMPLATE="${1#*=}"; shift ;;
159
+ -h|--help) usage; exit 0 ;;
160
+ --)
161
+ shift
162
+ [ "$#" -eq 1 ] || die_usage "exactly one target is required"
163
+ TARGET="$1"; shift ;;
164
+ -*)
165
+ die_usage "unknown option \"$1\"" ;;
166
+ *)
167
+ [ -z "$TARGET" ] || die_usage "exactly one target is required (got \"$TARGET\" and \"$1\")"
168
+ TARGET="$1"; shift ;;
169
+ esac
170
+ done
171
+
172
+ case "$MODE" in
173
+ segment|import|onboard) ;;
174
+ *) die_usage "--mode must be one of segment, import, onboard (got \"$MODE\")" ;;
175
+ esac
176
+
177
+ [ -n "$TARGET" ] || die_usage "a target is required"
178
+
179
+ if [ ! -e "$TARGET" ]; then
180
+ echo "Error: target does not exist: $TARGET" >&2
181
+ exit 2
182
+ fi
183
+ if [ ! -r "$TARGET" ]; then
184
+ echo "Error: target is not readable: $TARGET" >&2
185
+ exit 2
186
+ fi
187
+
188
+ # `onboard` is coarse-first by design (E40 epic): it wants subsystems, not a per-file listing.
189
+ if [ -z "$MAX_DEPTH" ]; then
190
+ if [ "$MODE" = "onboard" ]; then MAX_DEPTH=2; else MAX_DEPTH=3; fi
191
+ fi
192
+
193
+ if [ ! -f "$TEMPLATE" ]; then
194
+ echo "Error: understanding-document template not found: $TEMPLATE" >&2
195
+ exit 4
196
+ fi
197
+ TEMPLATE=$(cd -- "$(dirname -- "$TEMPLATE")" && pwd -P)/$(basename -- "$TEMPLATE")
198
+
199
+ # --- repo root ----------------------------------------------------------------------------------
200
+ # Anchored on THIS SCRIPT, not on the target: the analysis rapport belongs to the project that owns
201
+ # the engine, even when the target lives outside it (import mode staging areas, out-of-repo paths).
202
+ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
203
+ [ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
204
+
205
+ [ -n "$OUT_DIR" ] || OUT_DIR="$REPO_ROOT/project/rapports/analysis"
206
+ mkdir -p "$OUT_DIR" 2>/dev/null || {
207
+ echo "Error: could not create output directory: $OUT_DIR" >&2
208
+ exit 4
209
+ }
210
+ [ -w "$OUT_DIR" ] || { echo "Error: output directory is not writable: $OUT_DIR" >&2; exit 4; }
211
+ OUT_DIR=$(cd -- "$OUT_DIR" && pwd -P)
212
+
213
+ # Pre-flight the --json-out destination too. Checking it here, before any detector runs, is what
214
+ # keeps the exit-4 contract honest: a bad path fails fast with nothing written, rather than
215
+ # surfacing after the document has already been created.
216
+ if [ -n "$JSON_OUT" ]; then
217
+ JSON_OUT_DIR=$(dirname -- "$JSON_OUT")
218
+ if [ ! -d "$JSON_OUT_DIR" ]; then
219
+ echo "Error: --json-out directory does not exist: $JSON_OUT_DIR" >&2
220
+ exit 4
221
+ fi
222
+ if [ ! -w "$JSON_OUT_DIR" ]; then
223
+ echo "Error: --json-out directory is not writable: $JSON_OUT_DIR" >&2
224
+ exit 4
225
+ fi
226
+ JSON_OUT=$(cd -- "$JSON_OUT_DIR" && pwd -P)/$(basename -- "$JSON_OUT")
227
+ fi
228
+
229
+ # --- run the three detectors --------------------------------------------------------------------
230
+ TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/uncharted-engine.XXXXXX")
231
+ cleanup() { rm -rf "$TMP_DIR"; }
232
+ trap cleanup EXIT
233
+
234
+ run_detector() {
235
+ # run_detector <script-name> <output-file> [args...]
236
+ local name="$1" out="$2"; shift 2
237
+ local path="$SCRIPT_DIR/$name" rc=0
238
+ if [ ! -f "$path" ]; then
239
+ echo "Error: required detector script is missing: $path" >&2
240
+ exit 3
241
+ fi
242
+ bash "$path" "$@" >"$out" 2>"$TMP_DIR/$name.err" || rc=$?
243
+ if [ "$rc" -ne 0 ]; then
244
+ echo "Error: $name exited $rc for target: $TARGET" >&2
245
+ sed 's/^/ ['"$name"'] /' "$TMP_DIR/$name.err" >&2 || true
246
+ exit 3
247
+ fi
248
+ if [ -s "$TMP_DIR/$name.err" ]; then
249
+ sed 's/^/ ['"$name"'] /' "$TMP_DIR/$name.err" >&2 || true
250
+ fi
251
+ }
252
+
253
+ GITIGNORE_ARGS=()
254
+ [ "$NO_GITIGNORE" -eq 1 ] && GITIGNORE_ARGS+=(--no-gitignore)
255
+
256
+ run_detector enumerate-target.sh "$TMP_DIR/enumerate.json" \
257
+ --max-depth "$MAX_DEPTH" --top "$TOP" "${GITIGNORE_ARGS[@]+"${GITIGNORE_ARGS[@]}"}" -- "$TARGET"
258
+ run_detector detect-dependencies.sh "$TMP_DIR/dependencies.json" "$TARGET"
259
+ run_detector detect-tests.sh "$TMP_DIR/tests.json" "$TARGET"
260
+
261
+ # --- output path --------------------------------------------------------------------------------
262
+ # Resolve before slugging: a target of "." or "path/to/dir/" would otherwise slug to nothing.
263
+ if [ -d "$TARGET" ]; then
264
+ RESOLVED_TARGET=$(cd -- "$TARGET" && pwd -P)
265
+ else
266
+ RESOLVED_TARGET=$(cd -- "$(dirname -- "$TARGET")" && pwd -P)/$(basename -- "$TARGET")
267
+ fi
268
+ BASE=$(basename -- "$RESOLVED_TARGET")
269
+ SLUG=$(printf '%s' "$BASE" \
270
+ | tr '[:upper:]' '[:lower:]' \
271
+ | sed -e 's/[^a-z0-9]\{1,\}/-/g' -e 's/^-*//' -e 's/-*$//' \
272
+ | cut -c1-40)
273
+ [ -n "$SLUG" ] || SLUG="target"
274
+
275
+ STAMP=$(date -u +%Y%m%dT%H%M%SZ)
276
+ ISO_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
277
+
278
+ OUT_FILE="$OUT_DIR/uncharted-$MODE-$SLUG-$STAMP.md"
279
+ n=2
280
+ while [ -e "$OUT_FILE" ]; do
281
+ OUT_FILE="$OUT_DIR/uncharted-$MODE-$SLUG-$STAMP-$n.md"
282
+ n=$((n + 1))
283
+ done
284
+
285
+ # --- render -------------------------------------------------------------------------------------
286
+ PY_SRC=$(cat <<'PY'
287
+ import json
288
+ import os
289
+ import re
290
+ import sys
291
+
292
+ (tpl_path, enum_path, deps_path, tests_path, mode, origin_arg,
293
+ timestamp, out_path, repo_root, json_out) = sys.argv[1:11]
294
+
295
+ warnings = []
296
+ notices = []
297
+
298
+ EXT_ROW_CAP = 20
299
+ LIST_CAP = 25
300
+ TREE_LINE_CAP = 200
301
+
302
+
303
+ def load(path, label):
304
+ try:
305
+ with open(path, encoding="utf-8") as fh:
306
+ return json.load(fh)
307
+ except (OSError, ValueError) as exc:
308
+ sys.stderr.write("Error: could not parse %s output: %s\n" % (label, exc))
309
+ sys.exit(4)
310
+
311
+
312
+ enum = load(enum_path, "enumerate-target.sh")
313
+ deps = load(deps_path, "detect-dependencies.sh")
314
+ tests = load(tests_path, "detect-tests.sh")
315
+
316
+ root = repo_root.rstrip("/")
317
+
318
+
319
+ def rel(path):
320
+ """Repo-relative when the path is inside the engine's repo, absolute otherwise."""
321
+ if not path:
322
+ return path
323
+ if path == root:
324
+ return "."
325
+ if path.startswith(root + "/"):
326
+ return path[len(root) + 1:]
327
+ return path
328
+
329
+
330
+ def code(s):
331
+ return "`%s`" % s
332
+
333
+
334
+ def num(n):
335
+ return "{:,}".format(n)
336
+
337
+
338
+ def bullets(items, empty):
339
+ if not items:
340
+ return empty
341
+ shown = items[:LIST_CAP]
342
+ out = ["- " + i for i in shown]
343
+ if len(items) > len(shown):
344
+ out.append("- _… %d more; re-run with `--json-out` for the full list._"
345
+ % (len(items) - len(shown)))
346
+ return "\n".join(out)
347
+
348
+
349
+ # --- Target ---------------------------------------------------------------------------------
350
+ target_abs = enum.get("target", "")
351
+ target_type = enum.get("target_type", "unknown")
352
+ target_rel = rel(target_abs)
353
+ target_name = target_rel if target_rel not in ("", ".") else os.path.basename(target_abs) or target_abs
354
+
355
+ if origin_arg:
356
+ origin = origin_arg
357
+ elif target_abs == root or target_abs.startswith(root + "/"):
358
+ origin = "in-repo"
359
+ else:
360
+ origin = "outside this repository (`%s`)" % target_abs
361
+
362
+
363
+ def board_linkage():
364
+ """Mechanical: does any board item's text mention this target's repo-relative path?"""
365
+ if target_type == "repo_root" or target_rel in ("", "."):
366
+ # A substring scan for "." would match every board file; say nothing rather than lie.
367
+ return "n/a — target is the repository root"
368
+ if not (target_abs == root or target_abs.startswith(root + "/")):
369
+ return "unlinked — target is outside this repository"
370
+ board = os.path.join(root, "project", "board")
371
+ if not os.path.isdir(board):
372
+ return "unlinked (no `project/board/` in this repository)"
373
+ ids = set()
374
+ for dirpath, _dirnames, filenames in os.walk(board):
375
+ for fn in filenames:
376
+ if not fn.endswith(".md"):
377
+ continue
378
+ try:
379
+ with open(os.path.join(dirpath, fn), encoding="utf-8", errors="replace") as fh:
380
+ text = fh.read()
381
+ except OSError:
382
+ continue
383
+ if target_rel in text:
384
+ m = re.match(r"^(E\d+(?:_S\d+)?(?:_T\d+)?)", fn)
385
+ ids.add(m.group(1) if m else fn[:-3])
386
+ if not ids:
387
+ return "unlinked"
388
+ ordered = sorted(ids)
389
+ shown = ordered[:8]
390
+ out = "linked to " + ", ".join(code(i) for i in shown)
391
+ if len(ordered) > len(shown):
392
+ out += " (+%d more)" % (len(ordered) - len(shown))
393
+ return out
394
+
395
+
396
+ # --- Structure ------------------------------------------------------------------------------
397
+ ext_rows = [{"EXT": code(e.get("extension", "(none)")), "EXT_COUNT": num(e.get("files", 0))}
398
+ for e in enum.get("extensions", [])]
399
+ if len(ext_rows) > EXT_ROW_CAP:
400
+ hidden = len(ext_rows) - EXT_ROW_CAP
401
+ ext_rows = ext_rows[:EXT_ROW_CAP]
402
+ ext_rows.append({"EXT": "_… %d more extensions_" % hidden, "EXT_COUNT": "—"})
403
+
404
+ largest_rows = []
405
+ for f in enum.get("largest_files", []):
406
+ lines = "— (binary, %s bytes)" % num(f.get("bytes", 0)) if f.get("binary") else num(f.get("lines", 0))
407
+ largest_rows.append({"LARGEST_FILE": f.get("path", ""), "LARGEST_FILE_LINES": lines})
408
+
409
+
410
+ def render_tree():
411
+ entries = enum.get("tree", [])
412
+ if not entries:
413
+ return "(no files enumerated)"
414
+ # A file target's "tree" is the one file itself; a root label would just repeat its name.
415
+ is_file = target_type == "file"
416
+ lines = [] if is_file else [target_name + "/"]
417
+ for e in entries[:TREE_LINE_CAP]:
418
+ depth = e.get("depth", 1)
419
+ indent = "" if is_file else " " * depth
420
+ name = e["path"].rsplit("/", 1)[-1]
421
+ if e.get("type") == "directory":
422
+ count = e.get("file_count", 0)
423
+ line = "%s%s/ (%s file%s)" % (indent, name, num(count), "" if count == 1 else "s")
424
+ if e.get("truncated"):
425
+ line += " [truncated]"
426
+ elif e.get("binary"):
427
+ line = "%s%s (binary, %s bytes)" % (indent, name, num(e.get("bytes", 0)))
428
+ else:
429
+ n_lines = e.get("lines", 0)
430
+ line = "%s%s (%s line%s)" % (indent, name, num(n_lines), "" if n_lines == 1 else "s")
431
+ lines.append(line)
432
+ if len(entries) > TREE_LINE_CAP:
433
+ lines.append("… %d further entries not shown (renderer line cap)"
434
+ % (len(entries) - TREE_LINE_CAP))
435
+ if enum.get("tree_truncated"):
436
+ lines.append("… tree bounded at max depth %s; deeper entries exist"
437
+ % enum.get("max_depth"))
438
+ return "\n".join(lines)
439
+
440
+
441
+ # --- Key Dependencies -----------------------------------------------------------------------
442
+ def dep_sources(entry):
443
+ src = entry.get("sources") or []
444
+ return " (e.g. %s)" % ", ".join(code(s) for s in src[:3]) if src else ""
445
+
446
+
447
+ internal_items = []
448
+ for d in deps.get("internal", []):
449
+ resolved = d.get("resolved")
450
+ target_part = code(resolved) if resolved else "_unresolved_"
451
+ internal_items.append("%s → %s — %d occurrence(s), %s%s" % (
452
+ code(d.get("raw", "")), target_part, d.get("occurrences", 0),
453
+ d.get("language", "unknown"), dep_sources(d)))
454
+
455
+ external_items = []
456
+ for d in deps.get("external", []):
457
+ external_items.append("%s — %s, %d occurrence(s), %s%s" % (
458
+ code(d.get("name", "")), d.get("kind", "unknown"), d.get("occurrences", 0),
459
+ d.get("language", "unknown"), dep_sources(d)))
460
+
461
+ if deps.get("unrecognised"):
462
+ empty_dep = "_No recognised source language in this target — nothing to extract._"
463
+ else:
464
+ empty_dep = "_None detected._"
465
+
466
+ manifest_items = []
467
+ for m in deps.get("manifests", []):
468
+ declared = m.get("declared_dependencies")
469
+ if declared is None:
470
+ decl = "declared dependencies not parsed"
471
+ else:
472
+ decl = "declares %d dependenc%s" % (len(declared), "y" if len(declared) == 1 else "ies")
473
+ where = "in the target" if m.get("distance", 0) == 0 else "%d level(s) above the target" % m.get("distance", 0)
474
+ manifest_items.append("%s — %s; %s" % (code(m.get("path", m.get("name", ""))), where, decl))
475
+
476
+ # --- Existing Tests -------------------------------------------------------------------------
477
+ coverage_status = tests.get("coverage_status", "unknown")
478
+ coverage_summary = tests.get("coverage_summary", "")
479
+ coverage_cell = code(coverage_status) + (" — %s" % coverage_summary if coverage_summary else "")
480
+
481
+ test_items = ["%s (inside the target)" % code(p) for p in tests.get("target_test_files", [])]
482
+ for r in tests.get("referencing_test_files", []):
483
+ matched = r.get("matched") or []
484
+ suffix = " — matches: %s" % ", ".join(code(m) for m in matched[:5]) if matched else ""
485
+ test_items.append("%s%s" % (code(r.get("path", "")), suffix))
486
+
487
+ if test_items:
488
+ test_block = bullets(test_items, "")
489
+ ref_names = tests.get("reference_names") or []
490
+ if ref_names:
491
+ test_block += "\n\n_Matched against reference name(s): %s._" % ", ".join(
492
+ code(n) for n in ref_names[:8])
493
+ else:
494
+ if coverage_status == "repo_tests_only":
495
+ test_block = ("_None. The repository contains %s test file(s), but none of them "
496
+ "reference this target._" % num(tests.get("repo_test_file_count", 0)))
497
+ elif coverage_status == "no_tests_in_repo":
498
+ test_block = "_None. No test files were found anywhere in the repository._"
499
+ else:
500
+ test_block = "_None._"
501
+
502
+ runner_items = []
503
+ for c in tests.get("test_runner_configs", []):
504
+ detail = c.get("detail")
505
+ runner_items.append("%s — runner: %s%s" % (
506
+ code(c.get("path", c.get("name", ""))), c.get("runner", "unknown"),
507
+ " (%s)" % detail if detail else ""))
508
+
509
+ # --- token maps ------------------------------------------------------------------------------
510
+ SCALARS = {
511
+ "TARGET_NAME": target_name,
512
+ "MODE": mode,
513
+ "TIMESTAMP": timestamp,
514
+ "TARGET_PATH": target_rel,
515
+ "TARGET_TYPE": target_type,
516
+ "BOARD_LINKAGE": board_linkage(),
517
+ "ORIGIN": origin,
518
+ "FILE_COUNT": num(enum.get("total_files", 0)),
519
+ "MAX_DEPTH": "unlimited" if enum.get("max_depth", 0) == 0 else str(enum.get("max_depth")),
520
+ "DIRECTORY_TREE": render_tree(),
521
+ "INTERNAL_DEPENDENCIES": bullets(internal_items, empty_dep),
522
+ "EXTERNAL_DEPENDENCIES": bullets(external_items, empty_dep),
523
+ "MANIFEST_FILES": bullets(manifest_items, "_None found in or above the target._"),
524
+ "TEST_COVERAGE_STATUS": coverage_cell,
525
+ "TEST_FILES": test_block,
526
+ "TEST_RUNNER_CONFIG": bullets(runner_items, "_None detected._"),
527
+ }
528
+
529
+ ROW_SPECS = [({"EXT", "EXT_COUNT"}, ext_rows),
530
+ ({"LARGEST_FILE", "LARGEST_FILE_LINES"}, largest_rows)]
531
+
532
+ TOKEN_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
533
+ ROW_SENTINEL = "<!-- ROW -->"
534
+
535
+ # --- render ----------------------------------------------------------------------------------
536
+ try:
537
+ with open(tpl_path, encoding="utf-8") as fh:
538
+ template = fh.read()
539
+ except OSError as exc:
540
+ sys.stderr.write("Error: could not read template: %s\n" % exc)
541
+ sys.exit(4)
542
+
543
+ # Drop the template's own contract block: it is instruction to this renderer, not document
544
+ # content, and it contains a literal {{DOUBLE_BRACE}} that would look like a missed substitution.
545
+ # The block is matched by its LINE-ANCHORED delimiters (`<!--` and `-->` each alone on a line).
546
+ # A plain non-greedy `<!--.*?-->` would stop at the indented `<!-- ROW -->` quoted *inside* the
547
+ # block and leave its tail behind as document text.
548
+ BLOCK_COMMENT_RE = re.compile(r"^<!--[ \t]*\n.*?^-->[ \t]*\n?", re.S | re.M)
549
+ for m in BLOCK_COMMENT_RE.finditer(template):
550
+ if "OUTPUT CONTRACT" in m.group(0):
551
+ template = template[:m.start()] + template[m.end():]
552
+ break
553
+ else:
554
+ warnings.append("template has no line-anchored OUTPUT CONTRACT block to strip")
555
+
556
+ out_lines = []
557
+ for line in template.split("\n"):
558
+ if ROW_SENTINEL not in line:
559
+ out_lines.append(line)
560
+ continue
561
+ row_tpl = line.replace(ROW_SENTINEL, "").rstrip()
562
+ tokens = set(TOKEN_RE.findall(row_tpl))
563
+ records = None
564
+ for spec_tokens, spec_records in ROW_SPECS:
565
+ if tokens & spec_tokens:
566
+ records = spec_records
567
+ break
568
+ if records is None:
569
+ # An unknown repeating row — added to the template after this renderer was written.
570
+ # Dropping it is the only safe move: emitting it would leak raw {{TOKEN}}s.
571
+ warnings.append("dropped unrecognised ROW template: %s" % row_tpl.strip())
572
+ continue
573
+ for rec in records:
574
+ rendered = row_tpl
575
+ for tok, val in rec.items():
576
+ rendered = rendered.replace("{{%s}}" % tok, val)
577
+ out_lines.append(rendered)
578
+
579
+ document = "\n".join(out_lines)
580
+
581
+
582
+ def substitute(match):
583
+ tok = match.group(1)
584
+ if tok in SCALARS:
585
+ return SCALARS[tok]
586
+ warnings.append("no value for token {{%s}}" % tok)
587
+ return "_(not provided by the engine)_"
588
+
589
+
590
+ document = TOKEN_RE.sub(substitute, document)
591
+
592
+ # --- self-check: the seven headings are the document's contract with downstream consumers ----
593
+ REQUIRED = ["## Target", "## Purpose", "## Structure", "## Key Dependencies",
594
+ "## Existing Tests", "## Risk Areas", "## Open Questions"]
595
+ present = [ln.rstrip() for ln in document.split("\n")]
596
+ missing = [h for h in REQUIRED if h not in present]
597
+ if missing:
598
+ sys.stderr.write("Error: rendered document is missing required heading(s): %s\n"
599
+ % ", ".join(missing))
600
+ sys.exit(4)
601
+
602
+ if json_out:
603
+ merged = {
604
+ "engine": {
605
+ "script": "run-engine.sh",
606
+ "version": 1,
607
+ "mode": mode,
608
+ "timestamp": timestamp,
609
+ "target": target_abs,
610
+ "target_relative": target_rel,
611
+ "origin": origin,
612
+ "document": out_path,
613
+ "warnings": warnings,
614
+ },
615
+ "enumerate": enum,
616
+ "dependencies": deps,
617
+ "tests": tests,
618
+ }
619
+ try:
620
+ with open(json_out, "w", encoding="utf-8") as fh:
621
+ json.dump(merged, fh, indent=2, ensure_ascii=False)
622
+ fh.write("\n")
623
+ except OSError as exc:
624
+ sys.stderr.write("Error: could not write --json-out file: %s\n" % exc)
625
+ sys.exit(4)
626
+
627
+ # The document is written LAST, deliberately. It is the script's advertised output, so it must be
628
+ # the final side effect: any earlier failure then exits non-zero with nothing left behind, which is
629
+ # what the exit-4 contract promises.
630
+ try:
631
+ with open(out_path, "w", encoding="utf-8") as fh:
632
+ fh.write(document.rstrip("\n") + "\n")
633
+ except OSError as exc:
634
+ sys.stderr.write("Error: could not write document: %s\n" % exc)
635
+ sys.exit(4)
636
+
637
+ # --- diagnostics to stderr, never into the document ------------------------------------------
638
+ for reason, info in sorted((enum.get("skipped") or {}).items()):
639
+ notices.append("enumerate-target.sh skipped %d path(s): %s" % (info.get("count", 0), reason))
640
+ for n in deps.get("notices", []) or []:
641
+ notices.append("detect-dependencies.sh: %s" % n)
642
+ for n in tests.get("notices", []) or []:
643
+ notices.append("detect-tests.sh: %s" % n)
644
+ for w in warnings:
645
+ sys.stderr.write("Warning: %s\n" % w)
646
+ for n in notices:
647
+ sys.stderr.write("Notice: %s\n" % n)
648
+
649
+ print(out_path)
650
+ PY
651
+ )
652
+
653
+ python3 -c "$PY_SRC" \
654
+ "$TEMPLATE" "$TMP_DIR/enumerate.json" "$TMP_DIR/dependencies.json" "$TMP_DIR/tests.json" \
655
+ "$MODE" "$ORIGIN" "$ISO_TS" "$OUT_FILE" "$REPO_ROOT" "$JSON_OUT"