@jenga-ai/agent 1.0.1 → 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.
- package/README.md +10 -7
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +215 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/mcp/router/embedder.js +1 -1
- package/mcp/training_runner/index.js +239 -0
- package/mcp/training_runner/package-lock.json +1065 -0
- package/mcp/training_runner/package.json +15 -0
- package/package.json +14 -16
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/close-story/SKILL.md +203 -0
- package/skills/close-story/scripts/check-story-closeable.sh +195 -0
- package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
- package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
- package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
- package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
- package/skills/commit/SKILL.md +30 -3
- package/skills/distribute/CONFIG_SCHEMA.md +148 -0
- package/skills/distribute/SKILL.md +173 -0
- package/skills/distribute/scripts/check-version.sh +74 -0
- package/skills/distribute/scripts/commit-version-bump.sh +108 -0
- package/skills/distribute/scripts/distribute-changes.sh +381 -0
- package/skills/do/SKILL.md +352 -1
- package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
- package/skills/doc/assets/path-objectives.yaml +13 -0
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +101 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/strategy_stub_template.md +38 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +35 -1
- package/skills/jenga/SKILL.md +206 -14
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +206 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/train/SKILL.md +0 -116
- package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
- package/skills/train/assets/dashboard-templates/nlp.html +0 -102
- package/skills/train/assets/dashboard-templates/transformers.html +0 -98
- package/skills/train/assets/results-parsers/__init__.py +0 -9
- package/skills/train/assets/results-parsers/classifiers.py +0 -84
- package/skills/train/assets/results-parsers/nlp.py +0 -88
- package/skills/train/assets/results-parsers/reporter.py +0 -154
- package/skills/train/assets/results-parsers/transformers.py +0 -120
- package/skills/train/train_cli.py +0 -786
|
@@ -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"
|