@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,640 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/uncharted/scripts/resolve-segment-target.sh
4
+ #
5
+ # Deterministic front half of `/uncharted segment`. Given the raw argument the
6
+ # user typed, it answers two mechanical questions so the agent never has to
7
+ # guess at them:
8
+ #
9
+ # 1. Is this argument an existing path, or free text?
10
+ # 2. If it is a path — does it already have BOARD PROVENANCE? That is, does
11
+ # any file under `project/board/` reference it?
12
+ #
13
+ # It resolves, it classifies, it reports. It NEVER writes anything, never runs
14
+ # the engine, and never touches application code. Deciding what to do with the
15
+ # answer — which candidate to pick, whether the segment is worth investigating
16
+ # — is agent judgement and lives in `skills/uncharted/SKILL.md`.
17
+ #
18
+ # ---------------------------------------------------------------------------
19
+ # BOARD-LINKAGE CHECK — the reusable interface
20
+ # ---------------------------------------------------------------------------
21
+ # The linkage half is a documented, stable interface, because `/reconcile`
22
+ # (E40_S05_T02) reuses it instead of growing a second, divergent answer to
23
+ # "is this path on the board". Two entry points:
24
+ #
25
+ # Single: resolve-segment-target.sh --json-only <path>
26
+ # Batch: git ls-files | resolve-segment-target.sh --paths-from -
27
+ #
28
+ # Both emit the same `results[]` records, so a caller can read
29
+ # `.results[].board_linkage.status` either way. Batch mode exists so a caller
30
+ # with hundreds of paths reads the board ONCE rather than forking this script
31
+ # per file.
32
+ #
33
+ # board_linkage.status "linked" >=1 board file references the path
34
+ # "unlinked" no board file references it <-- the
35
+ # condition that makes a segment worth
36
+ # investigating at all
37
+ # "not_checked" the question is not meaningful here:
38
+ # target is the repo root (every board
39
+ # file would "match"), the target is
40
+ # outside this repo, or there is no
41
+ # `project/board/` directory. Read
42
+ # `board_linkage.reason` for which.
43
+ #
44
+ # board_linkage.items Board IDs (E##, E##_S##, E##_S##_T##) that reference
45
+ # the path, sorted, capped by --limit.
46
+ # board_linkage.files Repo-relative board files that reference it, sorted,
47
+ # capped by --limit.
48
+ # board_linkage.match_count Total referencing files BEFORE the cap.
49
+ #
50
+ # MATCH SEMANTICS. Board text is tokenised into maximal path-like runs
51
+ # (`[A-Za-z0-9_./-]+`, trailing `.`/`-` stripped so a filename ending a sentence
52
+ # still counts). A board file references the path when one of its tokens either
53
+ # equals the path or is a DESCENDANT of it — so `skills/uncharted` is referenced
54
+ # by a board item that only names `skills/uncharted/scripts/run-engine.sh`.
55
+ #
56
+ # This is a path-boundary test, not a substring test: `docs/hooks` does not make
57
+ # `hooks` linked, and `src/app.js` does not make `src/app` linked.
58
+ #
59
+ # This is deliberately the same question `run-engine.sh` answers for the
60
+ # understanding document's `Board Linkage` row, so the document and this script
61
+ # never contradict each other. The boundary requirement is the one refinement:
62
+ # `run-engine.sh` uses a bare substring test, which would report a short path
63
+ # like `hooks` as linked because some board item happens to contain the word
64
+ # "hooks" in prose. Collapsing the two implementations into one is a deliberate
65
+ # follow-up, not something to do by accident — see the note in SKILL.md.
66
+ #
67
+ # ---------------------------------------------------------------------------
68
+ # CANDIDATES — hints, not a decision
69
+ # ---------------------------------------------------------------------------
70
+ # When the argument is NOT an existing path, it is treated as a free-text
71
+ # feature description and the script returns `candidates[]`: tracked paths
72
+ # whose repo-relative path or basename matches the description's significant
73
+ # tokens, ranked by how many distinct tokens they matched and capped by
74
+ # --limit.
75
+ #
76
+ # These are raw material for the numbered choice list the agent presents, per
77
+ # the Interaction Pattern in `CLAUDE.md`. They are NOT a resolution. An empty
78
+ # `candidates[]` is a normal, valid result — it means "ask the user", not
79
+ # "fail". The script never picks one.
80
+ #
81
+ # ---------------------------------------------------------------------------
82
+ # Usage
83
+ # ---------------------------------------------------------------------------
84
+ # resolve-segment-target.sh [options] <path-or-description>
85
+ # resolve-segment-target.sh [options] --paths-from <file|->
86
+ #
87
+ # Options:
88
+ # --paths-from <file|-> Batch mode. Read newline-separated paths from a file
89
+ # (or stdin with `-`) and emit one record per path.
90
+ # Blank lines and lines starting with `#` are skipped.
91
+ # Implies --json-only.
92
+ # --board-dir <dir> Board directory to scan.
93
+ # Default: <repo-root>/project/board
94
+ # --repo-root <dir> Treat this directory as the repo root instead of
95
+ # asking git. Mainly for testing.
96
+ # --limit N Cap on candidates, and on the board IDs/files listed
97
+ # per record. Default 10. 0 = unlimited.
98
+ # --json-only Suppress the leading resolved-path line; print only
99
+ # the JSON object.
100
+ # -h, --help Show this help and exit 0.
101
+ #
102
+ # ---------------------------------------------------------------------------
103
+ # Output
104
+ # ---------------------------------------------------------------------------
105
+ # stdout, single-argument mode:
106
+ # line 1 the resolved absolute path, or `-` when the argument is not a path
107
+ # (suppressed by --json-only, so `TARGET=$(… | head -1)` works)
108
+ # then one JSON object
109
+ # stdout, batch mode: the JSON object only.
110
+ # stderr: notices and diagnostics. Never mixed into stdout.
111
+ #
112
+ # JSON shape:
113
+ # {
114
+ # "schema": "resolve-segment-target/1",
115
+ # "repo_root": "/abs/path",
116
+ # "board_dir": "/abs/path/project/board",
117
+ # "batch": false,
118
+ # "results": [
119
+ # {
120
+ # "argument": "<as typed>",
121
+ # "kind": "path" | "description",
122
+ # "target": "/abs/path", // null when kind=description
123
+ # "target_relative": "skills/x", // null when outside the repo
124
+ # "target_type": "file" | "directory" | "repo_root" | null,
125
+ # "in_repo": true,
126
+ # "readable": true,
127
+ # "board_linkage": { "status": …, "reason": …, "items": [],
128
+ # "files": [], "match_count": 0 },
129
+ # "candidates": [ { "path": …, "type": …, "score": N } ],
130
+ # "notices": []
131
+ # }
132
+ # ]
133
+ # }
134
+ #
135
+ # Exit codes:
136
+ # 0 the argument resolved to an existing, readable path (or, in batch mode,
137
+ # every input path did)
138
+ # 1 usage error
139
+ # 2 the argument is not an existing path — it is a description. The JSON is
140
+ # STILL written, with `candidates[]`. This is the one non-zero exit that
141
+ # produces normal stdout output: it means "ask the user", not "broke".
142
+ # In batch mode: at least one input path did not exist.
143
+ # 3 the path exists but is not readable
144
+ # 4 environment error (python3 unavailable, repo root undeterminable)
145
+ #
146
+ # Requires: bash, python3, git. jq is NOT required.
147
+ # ---------------------------------------------------------------------------
148
+
149
+ set -euo pipefail
150
+
151
+ SELF="$(basename "$0")"
152
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
153
+
154
+ die() {
155
+ local code="$1"; shift
156
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
157
+ exit "$code"
158
+ }
159
+
160
+ usage() {
161
+ sed -n '/^# Usage$/,/^# Requires:/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^-\{10,\}$/d'
162
+ }
163
+
164
+ die_usage() {
165
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
166
+ echo >&2
167
+ usage >&2
168
+ exit 1
169
+ }
170
+
171
+ ARGUMENT=""
172
+ HAVE_ARGUMENT=0
173
+ PATHS_FROM=""
174
+ BOARD_DIR=""
175
+ REPO_ROOT=""
176
+ LIMIT=10
177
+ JSON_ONLY=0
178
+
179
+ require_value() {
180
+ # require_value <flag> <remaining-arg-count>
181
+ [ "$2" -ge 2 ] || die_usage "$1 requires a value"
182
+ }
183
+
184
+ while [ "$#" -gt 0 ]; do
185
+ case "$1" in
186
+ --paths-from) require_value "--paths-from" "$#"; PATHS_FROM="$2"; shift 2 ;;
187
+ --paths-from=*) PATHS_FROM="${1#*=}"; shift ;;
188
+ --board-dir) require_value "--board-dir" "$#"; BOARD_DIR="$2"; shift 2 ;;
189
+ --board-dir=*) BOARD_DIR="${1#*=}"; shift ;;
190
+ --repo-root) require_value "--repo-root" "$#"; REPO_ROOT="$2"; shift 2 ;;
191
+ --repo-root=*) REPO_ROOT="${1#*=}"; shift ;;
192
+ --limit) require_value "--limit" "$#"; LIMIT="$2"; shift 2 ;;
193
+ --limit=*) LIMIT="${1#*=}"; shift ;;
194
+ --json-only) JSON_ONLY=1; shift ;;
195
+ -h|--help) usage; exit 0 ;;
196
+ --)
197
+ shift
198
+ [ "$#" -eq 1 ] || die_usage "exactly one argument is required after --"
199
+ ARGUMENT="$1"; HAVE_ARGUMENT=1; shift ;;
200
+ -*)
201
+ die_usage "unknown option \"$1\"" ;;
202
+ *)
203
+ # An argument that happens to start with `-` must be passed after `--`.
204
+ [ "$HAVE_ARGUMENT" -eq 0 ] || die_usage "exactly one argument is required (got \"$ARGUMENT\" and \"$1\")"
205
+ ARGUMENT="$1"; HAVE_ARGUMENT=1; shift ;;
206
+ esac
207
+ done
208
+
209
+ case "$LIMIT" in
210
+ ''|*[!0-9]*) die_usage "--limit requires a non-negative integer, got \"$LIMIT\"" ;;
211
+ esac
212
+
213
+ if [ -n "$PATHS_FROM" ]; then
214
+ [ "$HAVE_ARGUMENT" -eq 0 ] || die_usage "--paths-from and a positional argument are mutually exclusive"
215
+ JSON_ONLY=1
216
+ elif [ "$HAVE_ARGUMENT" -eq 0 ]; then
217
+ die_usage "a path or description argument is required"
218
+ fi
219
+
220
+ command -v python3 >/dev/null 2>&1 || die 4 "python3 is required but was not found on PATH"
221
+
222
+ # --- repo root ------------------------------------------------------------------------------
223
+ # Anchored on THIS SCRIPT, matching run-engine.sh: the board being consulted is the board of the
224
+ # project that owns the engine, even when the target lives outside it.
225
+ if [ -z "$REPO_ROOT" ]; then
226
+ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
227
+ [ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
228
+ fi
229
+ [ -d "$REPO_ROOT" ] || die 4 "repo root is not a directory: $REPO_ROOT"
230
+ REPO_ROOT=$(cd -- "$REPO_ROOT" && pwd -P)
231
+
232
+ [ -n "$BOARD_DIR" ] || BOARD_DIR="$REPO_ROOT/project/board"
233
+
234
+ # --- assemble the argument list -------------------------------------------------------------
235
+ ARGS_FILE=$(mktemp "${TMPDIR:-/tmp}/resolve-segment-target.XXXXXX")
236
+ cleanup() { rm -f "$ARGS_FILE"; }
237
+ trap cleanup EXIT
238
+
239
+ BATCH=0
240
+ if [ -n "$PATHS_FROM" ]; then
241
+ BATCH=1
242
+ if [ "$PATHS_FROM" = "-" ]; then
243
+ cat > "$ARGS_FILE"
244
+ else
245
+ [ -r "$PATHS_FROM" ] || die 1 "--paths-from file is not readable: $PATHS_FROM"
246
+ cat -- "$PATHS_FROM" > "$ARGS_FILE"
247
+ fi
248
+ else
249
+ printf '%s\n' "$ARGUMENT" > "$ARGS_FILE"
250
+ fi
251
+
252
+ # --- tracked-file listing, for candidate matching -------------------------------------------
253
+ # Only consulted in the description case, but gathered unconditionally and cheaply: `git
254
+ # ls-files` is one call, and keeping the python side pure (no shelling out) keeps it testable.
255
+ TRACKED_FILE=$(mktemp "${TMPDIR:-/tmp}/resolve-segment-tracked.XXXXXX")
256
+ cleanup() { rm -f "$ARGS_FILE" "$TRACKED_FILE"; }
257
+ trap cleanup EXIT
258
+ git -C "$REPO_ROOT" ls-files -z > "$TRACKED_FILE" 2>/dev/null || : > "$TRACKED_FILE"
259
+
260
+ PY_SRC=$(cat <<'PY'
261
+ import json
262
+ import os
263
+ import re
264
+ import sys
265
+
266
+ repo_root, board_dir, args_path, tracked_path, limit_s, batch_s, json_only_s = sys.argv[1:8]
267
+ limit = int(limit_s)
268
+ batch = batch_s == "1"
269
+
270
+ root = repo_root.rstrip("/")
271
+
272
+ # Cap raw scan volume so a pathological board (or a huge tracked tree) cannot hang the caller.
273
+ BOARD_FILE_CAP = 5000
274
+ TRACKED_CAP = 20000
275
+ TOKEN_MIN_LEN = 3
276
+
277
+ # Excluded from CANDIDATE HINTS ONLY. An explicit path argument is never filtered -- if the user
278
+ # names `project/board`, that is what gets resolved. These prefixes are excluded from the
279
+ # free-text search because they are Jenga's own scaffolding and generated build outputs, and a
280
+ # description like "the permission level skill" otherwise ranks `project/board/tasks` (which
281
+ # mentions every skill in the repo) above the skill itself. Kept deliberately in step with the
282
+ # exclusion list `/reconcile` uses in E40_S05_T02.
283
+ CANDIDATE_EXCLUDED_PREFIXES = (
284
+ "project/", ".claude/", ".agents/", "node_modules/", "dist/", "build/",
285
+ "vendor/", "target/", ".git/",
286
+ )
287
+ STOPWORDS = {
288
+ "the", "and", "for", "with", "that", "this", "from", "into", "our", "its",
289
+ "all", "any", "are", "was", "were", "has", "have", "had", "not", "but",
290
+ "code", "file", "files", "dir", "directory", "feature", "module", "thing",
291
+ "stuff", "part", "some", "new", "old", "add", "adds", "added",
292
+ }
293
+
294
+
295
+ def cap(seq):
296
+ return list(seq) if limit == 0 else list(seq)[:limit]
297
+
298
+
299
+ def read_lines(path):
300
+ out = []
301
+ with open(path, encoding="utf-8", errors="replace") as fh:
302
+ for line in fh:
303
+ line = line.rstrip("\n").rstrip("\r")
304
+ if batch:
305
+ stripped = line.strip()
306
+ if not stripped or stripped.startswith("#"):
307
+ continue
308
+ out.append(stripped)
309
+ else:
310
+ out.append(line)
311
+ return out
312
+
313
+
314
+ arguments = read_lines(args_path)
315
+ if not arguments:
316
+ sys.stderr.write("Notice: no arguments to resolve\n")
317
+
318
+ try:
319
+ with open(tracked_path, "rb") as fh:
320
+ tracked = [p.decode("utf-8", "replace") for p in fh.read().split(b"\0") if p]
321
+ except OSError:
322
+ tracked = []
323
+ if len(tracked) > TRACKED_CAP:
324
+ sys.stderr.write("Notice: tracked file listing capped at %d entries for candidate matching\n"
325
+ % TRACKED_CAP)
326
+ tracked = tracked[:TRACKED_CAP]
327
+
328
+
329
+ # --- board index --------------------------------------------------------------------------
330
+ # The board is read ONCE and inverted into a prefix index. A batch caller (e.g. /reconcile in
331
+ # E40_S05_T02) then classifies thousands of paths with a dict lookup each, instead of running a
332
+ # fresh regex over every board file per path -- which is the difference between a fraction of a
333
+ # second and half a minute on this repo.
334
+ PATH_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]+")
335
+ BOARD_ID_RE = re.compile(r"^(E\d+(?:_S\d+)?(?:_T\d+)?)")
336
+
337
+
338
+ def load_board():
339
+ """-> (docs, prefix_index, state).
340
+
341
+ docs [(repo-relative board file, board id)]
342
+ prefix_index {path prefix -> {doc index, ...}}
343
+
344
+ Every path-like run in a board file is registered under itself AND under each of its
345
+ ancestor directories, so `skills/uncharted` is found via a board item that only ever names
346
+ `skills/uncharted/scripts/run-engine.sh`.
347
+ """
348
+ docs = []
349
+ index = {}
350
+ if not os.path.isdir(board_dir):
351
+ return docs, index, "no-board-dir"
352
+ count = 0
353
+ for dirpath, dirnames, filenames in os.walk(board_dir):
354
+ dirnames.sort()
355
+ for fn in sorted(filenames):
356
+ if not fn.endswith(".md"):
357
+ continue
358
+ if count >= BOARD_FILE_CAP:
359
+ sys.stderr.write("Notice: board scan capped at %d files\n" % BOARD_FILE_CAP)
360
+ return docs, index, "ok"
361
+ full = os.path.join(dirpath, fn)
362
+ try:
363
+ with open(full, encoding="utf-8", errors="replace") as fh:
364
+ text = fh.read()
365
+ except OSError as exc:
366
+ sys.stderr.write("Notice: could not read board file %s: %s\n" % (full, exc))
367
+ continue
368
+ rel_board = full[len(root) + 1:] if full.startswith(root + "/") else full
369
+ m = BOARD_ID_RE.match(fn)
370
+ docs.append((rel_board, m.group(1) if m else fn[:-3]))
371
+ doc_i = len(docs) - 1
372
+ seen_tokens = set()
373
+ seen_prefixes = set()
374
+ for raw_tok in PATH_TOKEN_RE.findall(text):
375
+ # Trailing punctuation only: a leading dot is meaningful (`.claude/settings.json`),
376
+ # a trailing one is almost always the end of a sentence.
377
+ tok = raw_tok.rstrip(".-")
378
+ if not tok or tok in seen_tokens:
379
+ continue
380
+ seen_tokens.add(tok)
381
+ parts = tok.split("/")
382
+ for k in range(1, len(parts) + 1):
383
+ prefix = "/".join(parts[:k])
384
+ # The token itself is registered too, not just its ancestors -- an exact
385
+ # mention of `skills/uncharted/SKILL.md` must make that file linked.
386
+ if not prefix or prefix in seen_prefixes:
387
+ continue
388
+ seen_prefixes.add(prefix)
389
+ index.setdefault(prefix, set()).add(doc_i)
390
+ count += 1
391
+ return docs, index, "ok"
392
+
393
+
394
+ BOARD_DOCS, BOARD_INDEX, BOARD_STATE = load_board()
395
+
396
+
397
+ def linkage_for(target_rel):
398
+ """Which board files reference this repo-relative path? -> (items, files).
399
+
400
+ A board file references the path when it contains a path-like run (trailing punctuation
401
+ stripped) that either equals the path or is a descendant of it. See the header for why
402
+ this is a path-boundary test rather than a substring test.
403
+ """
404
+ hits = BOARD_INDEX.get(target_rel)
405
+ if not hits:
406
+ return [], []
407
+ items, files = set(), set()
408
+ for doc_i in hits:
409
+ rel_board, item_id = BOARD_DOCS[doc_i]
410
+ items.add(item_id)
411
+ files.add(rel_board)
412
+ return sorted(items), sorted(files)
413
+
414
+
415
+ def tokens_of(text):
416
+ raw = re.split(r"[^A-Za-z0-9]+", text.lower())
417
+ return [t for t in raw if len(t) >= TOKEN_MIN_LEN and t not in STOPWORDS]
418
+
419
+
420
+ def word_match(token, word):
421
+ """Does a description token match a path word?
422
+
423
+ Deliberately not a raw substring test. `"end" in "dependencies"` is true, which is how a
424
+ search for "session end hook" ends up recommending `detect-dependencies.sh`. Matching is
425
+ on whole path words with a bounded prefix allowance, so plural/derived forms still hit
426
+ (`level`/`levels`, `dependency`/`dependencies`, `detect`/`detection`) without accidental
427
+ infixes.
428
+ """
429
+ if token == word:
430
+ return True
431
+ if word.startswith(token) or (len(word) >= 3 and token.startswith(word)):
432
+ return True
433
+ n = 0
434
+ for a, b in zip(token, word):
435
+ if a != b:
436
+ break
437
+ n += 1
438
+ return n >= 5
439
+
440
+
441
+ WORD_SPLIT_RE = re.compile(r"[^a-z0-9]+")
442
+
443
+
444
+ def words_of(text):
445
+ return [w for w in WORD_SPLIT_RE.split(text.lower()) if w]
446
+
447
+
448
+ def score_path(path, toks):
449
+ """Distinct-token score for one path.
450
+
451
+ Counts DISTINCT tokens, never occurrences. Summing occurrences would rank a directory
452
+ holding a thousand files above the one directory actually named after the description.
453
+ A token matched in the last path segment is worth double -- `skills/jenga-permission-level`
454
+ should beat `scripts/check-permission-level.sh` for "the permission level skill".
455
+ """
456
+ low = path.lower()
457
+ last_words = words_of(low.rsplit("/", 1)[-1])
458
+ all_words = words_of(low)
459
+ in_last = {t for t in toks if any(word_match(t, w) for w in last_words)}
460
+ in_rest = {t for t in toks if any(word_match(t, w) for w in all_words)} - in_last
461
+ return 2 * len(in_last) + len(in_rest)
462
+
463
+
464
+ def candidates_for(description):
465
+ """Bounded, deterministic hints for the agent's numbered list. Never a decision."""
466
+ toks = set(tokens_of(description))
467
+ if not toks:
468
+ return []
469
+
470
+ scored = {}
471
+ dir_children = {}
472
+ for path in tracked:
473
+ if path.startswith(CANDIDATE_EXCLUDED_PREFIXES):
474
+ continue
475
+ hits = score_path(path, toks)
476
+ if hits:
477
+ scored[path] = hits
478
+ # Every ancestor directory is a candidate in its own right: the useful unit for
479
+ # `segment` is usually a directory, not a single file.
480
+ parent = os.path.dirname(path)
481
+ while parent:
482
+ if hits:
483
+ dir_children[parent] = dir_children.get(parent, 0) + 1
484
+ if parent not in scored:
485
+ d_hits = score_path(parent, toks)
486
+ if d_hits:
487
+ scored[parent] = d_hits
488
+ parent = os.path.dirname(parent)
489
+
490
+ # A small, capped bonus for directories that actually contain matching files -- enough to
491
+ # break ties, never enough to let breadth outrank a name match.
492
+ for d, n in dir_children.items():
493
+ if d in scored and n >= 2:
494
+ scored[d] += 1
495
+
496
+ if not scored:
497
+ return []
498
+ ordered = sorted(scored.items(), key=lambda kv: (-kv[1], len(kv[0]), kv[0]))
499
+ out = []
500
+ for path, score in ordered:
501
+ abs_path = os.path.join(root, path)
502
+ if os.path.isdir(abs_path):
503
+ kind = "directory"
504
+ elif os.path.isfile(abs_path):
505
+ kind = "file"
506
+ else:
507
+ continue
508
+ out.append({"path": path, "type": kind, "score": score})
509
+ if limit and len(out) >= limit:
510
+ break
511
+ return out
512
+
513
+
514
+ def resolve(argument):
515
+ notices = []
516
+ rec = {
517
+ "argument": argument,
518
+ "kind": "description",
519
+ "target": None,
520
+ "target_relative": None,
521
+ "target_type": None,
522
+ "in_repo": False,
523
+ "readable": False,
524
+ "board_linkage": {
525
+ "status": "not_checked",
526
+ "reason": "argument is not an existing path",
527
+ "items": [],
528
+ "files": [],
529
+ "match_count": 0,
530
+ },
531
+ "candidates": [],
532
+ "notices": notices,
533
+ }
534
+
535
+ raw = argument.strip()
536
+ if not raw:
537
+ notices.append("empty argument")
538
+ rec["board_linkage"]["reason"] = "empty argument"
539
+ return rec, 2
540
+
541
+ # Resolve relative to the repo root when the caller's cwd is elsewhere -- batch callers feed
542
+ # us repo-relative paths from `git ls-files`. An absolute path is used as given.
543
+ probe = raw
544
+ if not os.path.isabs(raw) and not os.path.exists(probe):
545
+ alt = os.path.join(root, raw)
546
+ if os.path.exists(alt):
547
+ probe = alt
548
+
549
+ if not os.path.exists(probe):
550
+ # Batch callers feed us paths, not descriptions -- a miss there means "this path is
551
+ # gone", and running the token search per miss would be pure cost.
552
+ if batch:
553
+ notices.append("path does not exist")
554
+ rec["board_linkage"]["reason"] = "path does not exist"
555
+ return rec, 2
556
+ rec["candidates"] = candidates_for(raw)
557
+ if not rec["candidates"]:
558
+ notices.append("no tracked path matched this description; ask the user directly")
559
+ return rec, 2
560
+
561
+ rec["kind"] = "path"
562
+ abs_path = os.path.realpath(probe)
563
+ rec["target"] = abs_path
564
+
565
+ if not os.access(abs_path, os.R_OK):
566
+ notices.append("path exists but is not readable")
567
+ rec["board_linkage"]["reason"] = "target is not readable"
568
+ return rec, 3
569
+ rec["readable"] = True
570
+
571
+ in_repo = abs_path == root or abs_path.startswith(root + "/")
572
+ rec["in_repo"] = in_repo
573
+ target_rel = abs_path[len(root) + 1:] if abs_path.startswith(root + "/") else None
574
+ if abs_path == root:
575
+ target_rel = "."
576
+ rec["target_relative"] = target_rel
577
+
578
+ if os.path.isdir(abs_path):
579
+ is_root = os.path.isdir(os.path.join(abs_path, ".git")) or abs_path == root
580
+ rec["target_type"] = "repo_root" if is_root else "directory"
581
+ else:
582
+ rec["target_type"] = "file"
583
+
584
+ link = rec["board_linkage"]
585
+ if not in_repo:
586
+ link["status"] = "not_checked"
587
+ link["reason"] = "target is outside this repository"
588
+ elif rec["target_type"] == "repo_root" or target_rel in (None, "."):
589
+ # A boundary scan for "." would match every board file. Say nothing rather than lie.
590
+ link["status"] = "not_checked"
591
+ link["reason"] = "target is the repository root"
592
+ elif BOARD_STATE == "no-board-dir":
593
+ link["status"] = "not_checked"
594
+ link["reason"] = "no board directory at %s" % (
595
+ board_dir[len(root) + 1:] if board_dir.startswith(root + "/") else board_dir)
596
+ else:
597
+ items, files = linkage_for(target_rel)
598
+ link["match_count"] = len(files)
599
+ link["items"] = cap(items)
600
+ link["files"] = cap(files)
601
+ link["status"] = "linked" if files else "unlinked"
602
+ link["reason"] = (
603
+ "referenced by %d board file(s)" % len(files) if files
604
+ else "no board file references this path"
605
+ )
606
+ return rec, 0
607
+
608
+
609
+ results = []
610
+ worst = 0
611
+ first_path = None
612
+ for argument in arguments:
613
+ rec, code = resolve(argument)
614
+ results.append(rec)
615
+ if code > worst:
616
+ worst = code
617
+ if first_path is None and rec["target"]:
618
+ first_path = rec["target"]
619
+
620
+ payload = {
621
+ "schema": "resolve-segment-target/1",
622
+ "repo_root": root,
623
+ "board_dir": board_dir,
624
+ "batch": batch,
625
+ "results": results,
626
+ }
627
+
628
+ if json_only_s != "1" and not batch:
629
+ print(first_path if first_path else "-")
630
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
631
+ sys.exit(worst)
632
+ PY
633
+ )
634
+
635
+ set +e
636
+ python3 -c "$PY_SRC" \
637
+ "$REPO_ROOT" "$BOARD_DIR" "$ARGS_FILE" "$TRACKED_FILE" "$LIMIT" "$BATCH" "$JSON_ONLY"
638
+ RC=$?
639
+ set -e
640
+ exit "$RC"