@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.
Files changed (117) hide show
  1. package/README.md +10 -7
  2. package/agents/developer.md +82 -2
  3. package/agents/scrum-master.md +215 -21
  4. package/agents/tester.md +90 -8
  5. package/hooks/on_session_end.sh +171 -20
  6. package/mcp/router/embedder.js +1 -1
  7. package/mcp/training_runner/index.js +239 -0
  8. package/mcp/training_runner/package-lock.json +1065 -0
  9. package/mcp/training_runner/package.json +15 -0
  10. package/package.json +14 -16
  11. package/scripts/check-permission-level.sh +107 -0
  12. package/scripts/check-publicignore-match.sh +122 -0
  13. package/scripts/check-worktree-liveness.sh +193 -0
  14. package/scripts/generate-rapport-manifest.sh +43 -0
  15. package/scripts/idea_manager.sh +47 -0
  16. package/scripts/install-worktree-commit-guard.sh +134 -0
  17. package/scripts/jenga-permission-level-switch.sh +109 -0
  18. package/scripts/smoke-harness.sh +139 -0
  19. package/scripts/validate-board.sh +62 -0
  20. package/scripts/with-lock.sh +158 -0
  21. package/scripts/worktree-remove-guard.sh +204 -0
  22. package/skills/clearify/SKILL.md +52 -0
  23. package/skills/close-story/SKILL.md +203 -0
  24. package/skills/close-story/scripts/check-story-closeable.sh +195 -0
  25. package/skills/close-story/scripts/compute-scope-divergence.sh +128 -0
  26. package/skills/close-story/scripts/extract-diff-stats.sh +48 -0
  27. package/skills/close-story/scripts/extract-task-diff-stats.sh +97 -0
  28. package/skills/close-story/scripts/update-task-frontmatter.sh +103 -0
  29. package/skills/commit/SKILL.md +30 -3
  30. package/skills/distribute/CONFIG_SCHEMA.md +148 -0
  31. package/skills/distribute/SKILL.md +173 -0
  32. package/skills/distribute/scripts/check-version.sh +74 -0
  33. package/skills/distribute/scripts/commit-version-bump.sh +108 -0
  34. package/skills/distribute/scripts/distribute-changes.sh +381 -0
  35. package/skills/do/SKILL.md +352 -1
  36. package/skills/do/assets/intent-vs-diff-prompt.md +69 -0
  37. package/skills/doc/assets/path-objectives.yaml +13 -0
  38. package/skills/doc-sync/SKILL.md +16 -0
  39. package/skills/doc-sync/assets/doc_targets.md +11 -0
  40. package/skills/idea/SKILL.md +56 -0
  41. package/skills/idea/assets/idea_handoff_template.md +26 -0
  42. package/skills/idea/assets/idea_template.md +3 -0
  43. package/skills/init/SKILL.md +101 -7
  44. package/skills/init/assets/directory_structure.txt +1 -0
  45. package/skills/init/assets/strategy_stub_template.md +38 -0
  46. package/skills/init/assets/workflow_template.json +1 -1
  47. package/skills/init/scripts/apply-project-visibility.sh +176 -0
  48. package/skills/init/scripts/detect-existing-codebase.sh +166 -0
  49. package/skills/init/scripts/init.sh +35 -1
  50. package/skills/jenga/SKILL.md +206 -14
  51. package/skills/jenga/scripts/board-scan.sh +238 -0
  52. package/skills/jenga/scripts/cascade-resolve.sh +297 -0
  53. package/skills/jenga/scripts/render-confirmation.sh +679 -0
  54. package/skills/jenga/scripts/render-picker.sh +439 -0
  55. package/skills/jenga/scripts/resolve-id.sh +367 -0
  56. package/skills/jenga-permission-level/SKILL.md +81 -0
  57. package/skills/proceed/SKILL.md +1 -1
  58. package/skills/publish/SKILL.md +8 -5
  59. package/skills/publish/assets/ci-contract.md +2 -2
  60. package/skills/publish/assets/ownership-matrix.md +1 -1
  61. package/skills/publish/scripts/finalize_changelog.sh +115 -0
  62. package/skills/publish/scripts/generate_release_notes.sh +475 -28
  63. package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
  64. package/skills/publish/scripts/publish_deploy.sh +38 -8
  65. package/skills/publish/scripts/run_gates.sh +2 -2
  66. package/skills/reconcile/SKILL.md +117 -5
  67. package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
  68. package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
  69. package/skills/spinoff/SKILL.md +12 -7
  70. package/skills/todo/SKILL.md +2 -0
  71. package/skills/uncharted/SKILL.md +711 -0
  72. package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
  73. package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
  74. package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
  75. package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
  76. package/skills/uncharted/scripts/detect-tests.sh +553 -0
  77. package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
  78. package/skills/uncharted/scripts/enumerate-target.sh +470 -0
  79. package/skills/uncharted/scripts/import-source.sh +517 -0
  80. package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
  81. package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
  82. package/skills/uncharted/scripts/run-engine.sh +655 -0
  83. package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
  84. package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
  85. package/skills/wtf/SKILL.md +20 -0
  86. package/templates/CHANGELOG_TEMPLATE.md +13 -0
  87. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
  88. package/templates/SCRUM_BOARD_SCHEMA.md +206 -10
  89. package/templates/permission-levels/README.md +73 -0
  90. package/templates/permission-levels/level-1-locked.json +71 -0
  91. package/templates/permission-levels/level-2-guarded.json +64 -0
  92. package/templates/permission-levels/level-3-standard.json +62 -0
  93. package/templates/permission-levels/level-4-elevated.json +60 -0
  94. package/templates/permission-levels/level-5-unrestricted.json +58 -0
  95. package/skills/convert/SKILL.md +0 -124
  96. package/skills/convert/convert_cli.py +0 -235
  97. package/skills/convert/tests/sample.csv +0 -4
  98. package/skills/convert/tests/sample.json +0 -5
  99. package/skills/convert/tests/sample.jsonl +0 -3
  100. package/skills/convert/tests/sample.yaml +0 -18
  101. package/skills/convert/tests/sample_obj.csv +0 -2
  102. package/skills/convert/tests/sample_obj.json +0 -9
  103. package/skills/mirror-public/SKILL.md +0 -237
  104. package/skills/mirror-public/assets/config.json +0 -5
  105. package/skills/mirror-public/scripts/mirror.sh +0 -374
  106. package/skills/self-sync/SKILL.md +0 -73
  107. package/skills/self-sync/scripts/run.js +0 -136
  108. package/skills/train/SKILL.md +0 -116
  109. package/skills/train/assets/dashboard-templates/classifiers.html +0 -106
  110. package/skills/train/assets/dashboard-templates/nlp.html +0 -102
  111. package/skills/train/assets/dashboard-templates/transformers.html +0 -98
  112. package/skills/train/assets/results-parsers/__init__.py +0 -9
  113. package/skills/train/assets/results-parsers/classifiers.py +0 -84
  114. package/skills/train/assets/results-parsers/nlp.py +0 -88
  115. package/skills/train/assets/results-parsers/reporter.py +0 -154
  116. package/skills/train/assets/results-parsers/transformers.py +0 -120
  117. package/skills/train/train_cli.py +0 -786
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/reconcile/scripts/detect-unlinked-code.sh
4
+ #
5
+ # The inverse half of `/reconcile`. The normal pass asks "does this board item
6
+ # exist in the code?"; this asks "does this code exist on the board?" and finds
7
+ # paths with NO Jenga provenance of any kind.
8
+ #
9
+ # A tracked path is UNLINKED when BOTH provenance signals are absent:
10
+ #
11
+ # Signal A board linkage -- no file under `project/board/` references it,
12
+ # and no board file references the directory it
13
+ # lives in
14
+ # Signal B commit tag -- no commit naming a board item added it
15
+ #
16
+ # Either signal alone is provenance. `lib/inject-settings.js` has no board file
17
+ # naming it but WAS added by a `task(...)` commit, so it is linked, not unlinked.
18
+ #
19
+ # Signal B accepts three subject forms. The first is the current EST convention;
20
+ # the other two exist because this repo's own history predates it, and a commit
21
+ # that plainly names the board item it implements is provenance regardless of
22
+ # the punctuation around it:
23
+ #
24
+ # est_tag epic(...) / story(...) / task(E##_S##_T##)
25
+ # id_prefix E04_S01: Implement core /convert skill
26
+ # conventional feat(train): implement E01_S05 - results parsers
27
+ #
28
+ # Crediting too narrowly is the expensive mistake: it makes /reconcile offer to
29
+ # re-onboard code the board demonstrably already owns.
30
+ #
31
+ # It scans, it classifies, it reports. It NEVER writes anything, never touches
32
+ # the board, and never invokes `/uncharted`. Deciding which groups are worth
33
+ # investigating and presenting the offer is agent judgement and lives in
34
+ # `skills/reconcile/SKILL.md`.
35
+ #
36
+ # ---------------------------------------------------------------------------
37
+ # SIGNAL A IS BORROWED, NOT REBUILT
38
+ # ---------------------------------------------------------------------------
39
+ # The board-linkage question is answered by
40
+ # `skills/uncharted/scripts/resolve-segment-target.sh` (E40_S02_T01) through its
41
+ # documented batch interface:
42
+ #
43
+ # git ls-files | resolve-segment-target.sh --paths-from -
44
+ #
45
+ # That interface exists FOR this caller. The board is read and inverted into a
46
+ # prefix index once, then each path costs a dict lookup, so the whole repo is
47
+ # classified in well under a second. We read `.results[].board_linkage.status`
48
+ # and never re-derive it.
49
+ #
50
+ # If the resolver is missing, this script FAILS (exit 4). It does not fall back
51
+ # to a local copy of the check. A second, quietly divergent answer to "is this
52
+ # path on the board" is the exact outcome this design is meant to prevent.
53
+ #
54
+ # ---------------------------------------------------------------------------
55
+ # `not_checked` IS A THIRD STATE, NOT A SYNONYM FOR `unlinked`
56
+ # ---------------------------------------------------------------------------
57
+ # The resolver reports three linkage states, and the third one is load-bearing:
58
+ #
59
+ # linked >=1 board file references the path
60
+ # unlinked the board was read and nothing references it
61
+ # not_checked the question could not be answered here -- the target is
62
+ # outside this repository, is the repo root, no `project/board/`
63
+ # exists, or the path is a stale index entry that no longer
64
+ # exists on disk
65
+ #
66
+ # `not_checked` means "we could not check", NOT "nothing references it". A path
67
+ # in that state is reported in its own `not_checked[]` array with the resolver's
68
+ # reason verbatim. It is never placed in a directory group, never counted as
69
+ # unlinked, and never included in the `/uncharted segment` offer.
70
+ #
71
+ # This is deliberate. `run-engine.sh` currently renders the out-of-repo
72
+ # `not_checked` case as `unlinked` in its understanding document, which asserts
73
+ # a verified absence that was never verified. `skills/uncharted/SKILL.md` names
74
+ # Step 1 (`resolve-segment-target.sh`) authoritative on linkage where the two
75
+ # disagree, so this script passes the resolver's status through unchanged and
76
+ # does not repeat that conflation.
77
+ #
78
+ # ---------------------------------------------------------------------------
79
+ # GROUPING -- FILES ARE CLASSIFIED, DIRECTORIES ARE ONLY REPORTED
80
+ # ---------------------------------------------------------------------------
81
+ # A flat list of a hundred files is not actionable; the useful unit for
82
+ # `/uncharted segment` is a directory or feature. But grouping must not be done
83
+ # by testing directory paths. Board matching is prefix-based, so nearly every
84
+ # top-level directory reports `linked` on the strength of one board mention
85
+ # while specific files inside it are `unlinked` -- `mcp/` is linked while
86
+ # `mcp/training_runner/` is not. Testing directories and reporting the survivors
87
+ # would report zero unlinked code in a repo that demonstrably has some.
88
+ #
89
+ # So: only FILES are classified. Groups are then derived from the results.
90
+ #
91
+ # Rollup. Every directory gets subtree totals. A directory is `fully_unlinked`
92
+ # when every candidate beneath it is unlinked; each unlinked file is keyed to
93
+ # its SHALLOWEST fully-unlinked ancestor, falling back to its immediate parent.
94
+ # That collapses a wholly-uncharted subtree into the one directory worth naming
95
+ # instead of one group per leaf folder.
96
+ #
97
+ # A `not_checked` file inside a subtree blocks that subtree from being
98
+ # `fully_unlinked` -- we will not claim a whole directory is uncharted while
99
+ # part of it was never checked.
100
+ #
101
+ # ---------------------------------------------------------------------------
102
+ # THE GROUP DIRECTORY IS CHECKED TOO -- `covered_groups`
103
+ # ---------------------------------------------------------------------------
104
+ # Classifying files and then reporting directories would assert about a
105
+ # directory something that was only ever verified about its contents. Because
106
+ # the resolver's match is a path-boundary test, a board item naming
107
+ # `skills/convert/` links THAT DIRECTORY without linking
108
+ # `skills/convert/convert_cli.py`. Both facts are true, and the directory-level
109
+ # one is what decides whether a segment is worth investigating -- offering
110
+ # `/uncharted segment` there would duplicate a board item that already exists.
111
+ #
112
+ # So every group directory goes back through the same borrowed checker in a
113
+ # second batch call, and groups are split:
114
+ #
115
+ # groups[] directory is `unlinked` (or `not_checked`) -- neither the
116
+ # files nor the directory have provenance. These are the
117
+ # offer candidates.
118
+ # covered_groups[] directory is `linked` -- the files have no provenance of
119
+ # their own but sit under a directory the board references.
120
+ # Reported with the owning board items, never offered.
121
+ #
122
+ # `not_checked` on a DIRECTORY keeps the group rather than dropping it: under-
123
+ # reporting a finding is worse than reporting one the user can dismiss.
124
+ #
125
+ # MIRROR SPELLINGS. Per CLAUDE.md the canonical file lives in the root tree and
126
+ # `.agents/`, `.claude/` are generated build outputs -- but older board items
127
+ # were often written against the mirror path. E17_S05 owns `/reconcile-origin`
128
+ # and names it `.agents/skills/reconcile-origin/SKILL.md`, so a match on the
129
+ # root path alone misses a board item that plainly owns the directory. Each
130
+ # group directory is therefore asked about under its own name and under both
131
+ # mirror prefixes, and `directory_linkage.matched_as` records which spelling
132
+ # answered. This adds spellings to the QUESTION; it does not add a second
133
+ # answer to it.
134
+ #
135
+ # ---------------------------------------------------------------------------
136
+ # Usage
137
+ # ---------------------------------------------------------------------------
138
+ # detect-unlinked-code.sh [options]
139
+ #
140
+ # Options:
141
+ # --repo-root <dir> Treat this directory as the repo root instead of asking
142
+ # git. Mainly for testing.
143
+ # --board-dir <dir> Board directory to consult.
144
+ # Default: <repo-root>/project/board
145
+ # --resolver <path> Path to resolve-segment-target.sh.
146
+ # Default: ../../uncharted/scripts/resolve-segment-target.sh
147
+ # --exclude <glob> Additional exclusion, matched against the repo-relative
148
+ # path with shell-glob semantics. Repeatable.
149
+ # --limit N Cap on files listed per group. Default 10. 0 = unlimited.
150
+ # Group counts are always the true totals, pre-cap.
151
+ # -h, --help Show this help and exit 0.
152
+ #
153
+ # ---------------------------------------------------------------------------
154
+ # Exclusions
155
+ # ---------------------------------------------------------------------------
156
+ # Removed from the candidate set before anything is classified:
157
+ # - Jenga's own scaffolding and generated build outputs: `project/`,
158
+ # `.claude/`, `.agents/`
159
+ # - build outputs and vendored dependencies: `node_modules/`, `dist/`,
160
+ # `build/`, `out/`, `target/`, `vendor/`, `.venv/`, `venv/`, `__pycache__/`,
161
+ # `.git/`
162
+ # - anything `.gitignore` matches. Checked explicitly with
163
+ # `git check-ignore --no-index`, because a path that is both tracked and
164
+ # ignored stays tracked -- listing it in `git ls-files` is not evidence that
165
+ # the ignore rules do not cover it.
166
+ # - anything given via `--exclude`
167
+ #
168
+ # The default prefix list is kept deliberately in step with
169
+ # CANDIDATE_EXCLUDED_PREFIXES in `resolve-segment-target.sh`.
170
+ #
171
+ # ---------------------------------------------------------------------------
172
+ # Output
173
+ # ---------------------------------------------------------------------------
174
+ # stdout: one JSON object. stderr: notices. Never mixed.
175
+ #
176
+ # {
177
+ # "schema": "detect-unlinked-code/2",
178
+ # "repo_root": "/abs/path",
179
+ # "board_dir": "/abs/path/project/board",
180
+ # "resolver": "/abs/path/resolve-segment-target.sh",
181
+ # "summary": { "tracked_paths": N, "excluded_paths": N,
182
+ # "candidate_paths": N, "linked_paths": N,
183
+ # "linked_by_board": N, "linked_by_commit": N,
184
+ # "unlinked_paths": N, // files with neither file-level signal
185
+ # "uncharted_paths": N, // of those, the ones in groups[]
186
+ # "covered_paths": N, // of those, the ones in covered_groups[]
187
+ # "not_checked_paths": N,
188
+ # "groups": N, "covered_groups": N },
189
+ # "groups": [
190
+ # { "directory": "skills/skillify",
191
+ # "unlinked_count": N, // files keyed to THIS group
192
+ # "subtree_candidates": N, // all candidates under the directory
193
+ # "subtree_unlinked": N, // all unlinked under the directory
194
+ # "fully_unlinked": true,
195
+ # "directory_linkage": { "status": "unlinked", "reason": "...",
196
+ # "items": [], "match_count": 0,
197
+ # "matched_as": "skills/skillify" },
198
+ # "files": ["..."], // capped by --limit
199
+ # "files_truncated": N }
200
+ # ],
201
+ # "covered_groups": [ /* same shape; directory_linkage.status == "linked" */ ],
202
+ # "not_checked": [ { "path": "...", "reason": "..." } ],
203
+ #
204
+ # Paths are reported exactly as `git ls-files` gives them. A tracked symlink is classified by
205
+ # what it points at -- the resolver resolves the target -- but is still reported under its own
206
+ # name, so it never displaces the record of the file it points to.
207
+ # "exclusions": { "prefixes": [...], "globs": [...], "gitignored": N },
208
+ # "notices": [...]
209
+ # }
210
+ #
211
+ # Both group arrays are sorted by unlinked_count descending, then by path.
212
+ # `unlinked_paths` == `uncharted_paths` + `covered_paths`.
213
+ #
214
+ # Exit codes:
215
+ # 0 the scan completed -- findings or not. Callers read
216
+ # `summary.unlinked_paths`; finding unlinked code is a normal result, not
217
+ # an error, and must stay safe under `set -e` inside the reconcile pass.
218
+ # 1 usage error
219
+ # 4 environment error (python3/git unavailable, repo root undeterminable,
220
+ # resolver missing or not executable, resolver failed)
221
+ #
222
+ # Requires: bash, python3, git, and resolve-segment-target.sh. jq is NOT required.
223
+ # ---------------------------------------------------------------------------
224
+
225
+ set -euo pipefail
226
+
227
+ SELF="$(basename "$0")"
228
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
229
+
230
+ die() {
231
+ local code="$1"; shift
232
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
233
+ exit "$code"
234
+ }
235
+
236
+ usage() {
237
+ sed -n '/^# Usage$/,/^# Requires:/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^-\{10,\}$/d'
238
+ }
239
+
240
+ die_usage() {
241
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
242
+ echo >&2
243
+ usage >&2
244
+ exit 1
245
+ }
246
+
247
+ REPO_ROOT=""
248
+ BOARD_DIR=""
249
+ RESOLVER=""
250
+ LIMIT=10
251
+ EXCLUDES=""
252
+
253
+ require_value() {
254
+ # require_value <flag> <remaining-arg-count>
255
+ [ "$2" -ge 2 ] || die_usage "$1 requires a value"
256
+ }
257
+
258
+ while [ "$#" -gt 0 ]; do
259
+ case "$1" in
260
+ --repo-root) require_value "--repo-root" "$#"; REPO_ROOT="$2"; shift 2 ;;
261
+ --repo-root=*) REPO_ROOT="${1#*=}"; shift ;;
262
+ --board-dir) require_value "--board-dir" "$#"; BOARD_DIR="$2"; shift 2 ;;
263
+ --board-dir=*) BOARD_DIR="${1#*=}"; shift ;;
264
+ --resolver) require_value "--resolver" "$#"; RESOLVER="$2"; shift 2 ;;
265
+ --resolver=*) RESOLVER="${1#*=}"; shift ;;
266
+ --exclude) require_value "--exclude" "$#"; EXCLUDES="${EXCLUDES}$2"$'\n'; shift 2 ;;
267
+ --exclude=*) EXCLUDES="${EXCLUDES}${1#*=}"$'\n'; shift ;;
268
+ --limit) require_value "--limit" "$#"; LIMIT="$2"; shift 2 ;;
269
+ --limit=*) LIMIT="${1#*=}"; shift ;;
270
+ -h|--help) usage; exit 0 ;;
271
+ *) die_usage "unknown argument \"$1\"" ;;
272
+ esac
273
+ done
274
+
275
+ case "$LIMIT" in
276
+ ''|*[!0-9]*) die_usage "--limit requires a non-negative integer, got \"$LIMIT\"" ;;
277
+ esac
278
+
279
+ command -v python3 >/dev/null 2>&1 || die 4 "python3 is required but was not found on PATH"
280
+ command -v git >/dev/null 2>&1 || die 4 "git is required but was not found on PATH"
281
+
282
+ # --- repo root ------------------------------------------------------------------------------
283
+ # Anchored on THIS SCRIPT, matching resolve-segment-target.sh and run-engine.sh: the board being
284
+ # consulted is the board of the project that owns the skill.
285
+ if [ -z "$REPO_ROOT" ]; then
286
+ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
287
+ [ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
288
+ fi
289
+ [ -d "$REPO_ROOT" ] || die 4 "repo root is not a directory: $REPO_ROOT"
290
+ REPO_ROOT=$(cd -- "$REPO_ROOT" && pwd -P)
291
+
292
+ [ -n "$BOARD_DIR" ] || BOARD_DIR="$REPO_ROOT/project/board"
293
+
294
+ # --- the borrowed linkage check -------------------------------------------------------------
295
+ [ -n "$RESOLVER" ] || RESOLVER="$SCRIPT_DIR/../../uncharted/scripts/resolve-segment-target.sh"
296
+ [ -f "$RESOLVER" ] || die 4 "board-linkage checker not found: $RESOLVER
297
+ This script deliberately has no fallback implementation -- see the header. Restore
298
+ skills/uncharted/scripts/resolve-segment-target.sh or pass --resolver <path>."
299
+ [ -x "$RESOLVER" ] || die 4 "board-linkage checker is not executable: $RESOLVER"
300
+ RESOLVER=$(cd -- "$(dirname -- "$RESOLVER")" && pwd -P)/$(basename -- "$RESOLVER")
301
+
302
+ TMPDIR_RUN=$(mktemp -d "${TMPDIR:-/tmp}/detect-unlinked-code.XXXXXX")
303
+ cleanup() { rm -rf "$TMPDIR_RUN"; }
304
+ trap cleanup EXIT
305
+
306
+ TRACKED="$TMPDIR_RUN/tracked"
307
+ IGNORED="$TMPDIR_RUN/ignored"
308
+ CANDIDATES="$TMPDIR_RUN/candidates"
309
+ LINKAGE="$TMPDIR_RUN/linkage.json"
310
+ COMMITS="$TMPDIR_RUN/commits"
311
+ EXCLUDE_GLOBS="$TMPDIR_RUN/excludes"
312
+
313
+ # --- tracked paths --------------------------------------------------------------------------
314
+ git -C "$REPO_ROOT" ls-files -z > "$TRACKED" 2>/dev/null || : > "$TRACKED"
315
+
316
+ # --- gitignored paths -----------------------------------------------------------------------
317
+ # --no-index is the point: without it, git refuses to call a TRACKED path ignored, so a path
318
+ # that is both tracked and covered by .gitignore would silently survive the filter.
319
+ : > "$IGNORED"
320
+ if [ -s "$TRACKED" ]; then
321
+ set +e
322
+ git -C "$REPO_ROOT" check-ignore --no-index --stdin -z < "$TRACKED" > "$IGNORED" 2>/dev/null
323
+ CI_RC=$?
324
+ set -e
325
+ # 0 = some paths ignored, 1 = none ignored. Anything else means check-ignore itself failed,
326
+ # in which case an empty ignore list is the safe reading (over-report, never under-report).
327
+ if [ "$CI_RC" -gt 1 ]; then
328
+ printf '%s: notice: git check-ignore exited %d; proceeding with no gitignore filter\n' \
329
+ "$SELF" "$CI_RC" >&2
330
+ : > "$IGNORED"
331
+ fi
332
+ fi
333
+
334
+ # --- EST-tagged commit provenance -----------------------------------------------------------
335
+ # One pass over HEAD. --no-renames is deliberate: it decomposes a rename into delete-old +
336
+ # add-new, so the ADDED entry is unambiguously the path as it exists today. With rename
337
+ # detection on, a rename is one record and crediting the right side of it becomes guesswork.
338
+ # Merge commits contribute no file list by default, which is correct: a merge does not originate
339
+ # content, and the branch commits that do are already walked.
340
+ # \x01 prefixes the subject line so the parser can tell subjects from paths unambiguously.
341
+ git -C "$REPO_ROOT" log --diff-filter=A --no-renames --name-only \
342
+ --format='%x01%s' HEAD -- . > "$COMMITS" 2>/dev/null || : > "$COMMITS"
343
+
344
+ # --- candidate set --------------------------------------------------------------------------
345
+ printf '%s' "$EXCLUDES" > "$EXCLUDE_GLOBS"
346
+
347
+ PY_FILTER=$(cat <<'PY'
348
+ import fnmatch, os, sys
349
+
350
+ tracked_path, ignored_path, excludes_path, out_path = sys.argv[1:5]
351
+
352
+ # Kept deliberately in step with CANDIDATE_EXCLUDED_PREFIXES in resolve-segment-target.sh.
353
+ EXCLUDED_PREFIXES = (
354
+ "project/", ".claude/", ".agents/", "node_modules/", "dist/", "build/",
355
+ "out/", "vendor/", "target/", ".venv/", "venv/", "__pycache__/", ".git/",
356
+ )
357
+
358
+
359
+ def read_nul(path):
360
+ try:
361
+ with open(path, "rb") as fh:
362
+ return [p.decode("utf-8", "replace") for p in fh.read().split(b"\0") if p]
363
+ except OSError:
364
+ return []
365
+
366
+
367
+ tracked = read_nul(tracked_path)
368
+ ignored = set(read_nul(ignored_path))
369
+ try:
370
+ with open(excludes_path, encoding="utf-8") as fh:
371
+ globs = [ln.strip() for ln in fh if ln.strip()]
372
+ except OSError:
373
+ globs = []
374
+
375
+
376
+ def excluded(path):
377
+ if path.startswith(EXCLUDED_PREFIXES):
378
+ return True
379
+ # A nested build/vendor directory counts too: `mcp/help/node_modules/x` is not source.
380
+ for part in path.split("/")[:-1]:
381
+ if part + "/" in EXCLUDED_PREFIXES:
382
+ return True
383
+ if path in ignored:
384
+ return True
385
+ return any(fnmatch.fnmatch(path, g) for g in globs)
386
+
387
+
388
+ candidates = [p for p in tracked if not excluded(p)]
389
+ with open(out_path, "w", encoding="utf-8") as fh:
390
+ for p in candidates:
391
+ fh.write(p + "\n")
392
+ sys.stdout.write("%d %d %d\n" % (len(tracked), len(candidates), len(ignored)))
393
+ PY
394
+ )
395
+
396
+ COUNTS=$(python3 -c "$PY_FILTER" "$TRACKED" "$IGNORED" "$EXCLUDE_GLOBS" "$CANDIDATES") \
397
+ || die 4 "failed to build the candidate path set"
398
+
399
+ # --- signal A: board linkage, via the borrowed checker --------------------------------------
400
+ # Exit 2 means "at least one input path does not exist" -- a stale index entry. That is a normal
401
+ # finding here, the JSON is still complete, and the resolver reports it as not_checked. Any other
402
+ # non-zero status is a real failure.
403
+ set +e
404
+ "$RESOLVER" --paths-from "$CANDIDATES" --board-dir "$BOARD_DIR" --repo-root "$REPO_ROOT" \
405
+ --limit 0 > "$LINKAGE"
406
+ RESOLVER_RC=$?
407
+ set -e
408
+ if [ "$RESOLVER_RC" -ne 0 ] && [ "$RESOLVER_RC" -ne 2 ]; then
409
+ die 4 "board-linkage checker failed (exit $RESOLVER_RC): $RESOLVER"
410
+ fi
411
+ [ -s "$LINKAGE" ] || die 4 "board-linkage checker produced no output: $RESOLVER"
412
+
413
+ PY_MAIN=$(cat <<'PY'
414
+ import json
415
+ import os
416
+ import re
417
+ import subprocess
418
+ import sys
419
+
420
+ (repo_root, board_dir, resolver, linkage_path, commits_path,
421
+ excludes_path, limit_s, counts_s) = sys.argv[1:9]
422
+ limit = int(limit_s)
423
+ tracked_n, candidate_n, ignored_n = (int(x) for x in counts_s.split())
424
+
425
+ notices = []
426
+
427
+ EXCLUDED_PREFIXES = [
428
+ "project/", ".claude/", ".agents/", "node_modules/", "dist/", "build/",
429
+ "out/", "vendor/", "target/", ".venv/", "venv/", "__pycache__/", ".git/",
430
+ ]
431
+ try:
432
+ with open(excludes_path, encoding="utf-8") as fh:
433
+ globs = [ln.strip() for ln in fh if ln.strip()]
434
+ except OSError:
435
+ globs = []
436
+
437
+ # --- signal B: EST commit tags ---------------------------------------------------------------
438
+ # Three accepted subject forms. The current EST convention is the first; the other two exist
439
+ # because this repo's own history predates it, and a commit that plainly names the board item it
440
+ # implements is provenance regardless of the punctuation around it. Crediting too narrowly is the
441
+ # expensive mistake here -- it makes /reconcile offer to re-onboard code the board already owns.
442
+ #
443
+ # est_tag epic(...) / story(...) / task(E##_S##_T##)
444
+ # id_prefix E04_S01: Implement core /convert skill
445
+ # conventional feat(train): implement E01_S05 - results parsers
446
+ #
447
+ # A bare `E##` is only honoured inside an EST tag or as a subject prefix; loose in a sentence it
448
+ # is as likely to be a version or a variable as a board ID.
449
+ BOARD_ID = r"E\d+(?:_S\d+(?:_T\d+)?)?"
450
+ EST_TAG_RE = re.compile(r"^(epic|story|task)\(([^)]*)\)")
451
+ ID_PREFIX_RE = re.compile(r"^(%s)\b\s*[:\-–—]" % BOARD_ID)
452
+ CONVENTIONAL_RE = re.compile(r"^\w+(?:\([^)]*\))?!?:")
453
+ QUALIFIED_ID_RE = re.compile(r"\bE\d+_S\d+(?:_T\d+)?\b")
454
+ ANY_ID_RE = re.compile(r"\b%s\b" % BOARD_ID)
455
+
456
+
457
+ def est_provenance(subject):
458
+ """-> (board id or tag word, matched form) or None."""
459
+ m = EST_TAG_RE.match(subject)
460
+ if m:
461
+ ids = ANY_ID_RE.findall(m.group(2))
462
+ return (ids[0] if ids else m.group(1)), "est_tag"
463
+ m = ID_PREFIX_RE.match(subject)
464
+ if m:
465
+ return m.group(1), "id_prefix"
466
+ if CONVENTIONAL_RE.match(subject):
467
+ ids = QUALIFIED_ID_RE.findall(subject)
468
+ if ids:
469
+ return ids[0], "conventional"
470
+ return None
471
+
472
+
473
+ commit_provenance = {}
474
+ try:
475
+ with open(commits_path, encoding="utf-8", errors="replace") as fh:
476
+ raw = fh.read()
477
+ except OSError:
478
+ raw = ""
479
+
480
+ current = None
481
+ for line in raw.split("\n"):
482
+ if line.startswith("\x01"):
483
+ current = est_provenance(line[1:])
484
+ continue
485
+ path = line.strip()
486
+ if path and current is not None:
487
+ # First writer wins: git log walks newest-first, so this records the most recent EST
488
+ # commit that put the path in place, which is the one worth citing.
489
+ commit_provenance.setdefault(path, current)
490
+
491
+ # --- signal A: read the borrowed linkage result ----------------------------------------------
492
+ try:
493
+ with open(linkage_path, encoding="utf-8") as fh:
494
+ linkage = json.load(fh)
495
+ except (OSError, ValueError) as exc:
496
+ sys.stderr.write("error: could not read board-linkage output: %s\n" % exc)
497
+ sys.exit(4)
498
+
499
+ if linkage.get("schema") != "resolve-segment-target/1":
500
+ notices.append("board-linkage checker returned unexpected schema %r; "
501
+ "results may be incomplete" % linkage.get("schema"))
502
+
503
+ # --- classify --------------------------------------------------------------------------------
504
+ # Order matters. Commit provenance is checked FIRST so that a path the board check could not
505
+ # evaluate is still rescued by a real EST commit, instead of being reported as unknown.
506
+ status_of = {}
507
+ not_checked = []
508
+ linked_by_board = linked_by_commit = 0
509
+
510
+ for rec in linkage.get("results", []):
511
+ # Key on the path we ASKED about, not the resolver's `target_relative`. The resolver
512
+ # realpath()s its target, so a tracked symlink (`AGENTS.md -> AGENT.md`) reports its
513
+ # destination and would silently overwrite that destination's own record.
514
+ path = rec.get("argument") or rec.get("target_relative")
515
+ if not path:
516
+ continue
517
+ link = rec.get("board_linkage") or {}
518
+ board_status = link.get("status", "not_checked")
519
+
520
+ if path in commit_provenance:
521
+ status_of[path] = "linked"
522
+ linked_by_commit += 1
523
+ elif board_status == "linked":
524
+ status_of[path] = "linked"
525
+ linked_by_board += 1
526
+ elif board_status == "unlinked":
527
+ status_of[path] = "unlinked"
528
+ else:
529
+ # THE THIRD STATE. "Could not check" is not "nothing references it". Reported on its
530
+ # own, never grouped, never counted as unlinked, never offered to /uncharted segment.
531
+ status_of[path] = "not_checked"
532
+ not_checked.append({
533
+ "path": path,
534
+ "reason": link.get("reason") or "board linkage could not be determined",
535
+ })
536
+
537
+ # --- subtree rollup ----------------------------------------------------------------------------
538
+ def ancestors(path):
539
+ """Shallowest first: 'a/b/c.txt' -> ['.', 'a', 'a/b']."""
540
+ parts = path.split("/")[:-1]
541
+ out = ["."]
542
+ for k in range(1, len(parts) + 1):
543
+ out.append("/".join(parts[:k]))
544
+ return out
545
+
546
+
547
+ sub_total = {}
548
+ sub_unlinked = {}
549
+ for path, status in status_of.items():
550
+ for d in ancestors(path):
551
+ sub_total[d] = sub_total.get(d, 0) + 1
552
+ if status == "unlinked":
553
+ sub_unlinked[d] = sub_unlinked.get(d, 0) + 1
554
+
555
+
556
+ def fully_unlinked(d):
557
+ """Every candidate beneath d is unlinked. A single not_checked file blocks this."""
558
+ t = sub_total.get(d, 0)
559
+ return t > 0 and sub_unlinked.get(d, 0) == t
560
+
561
+
562
+ grouped = {}
563
+ for path, status in sorted(status_of.items()):
564
+ if status != "unlinked":
565
+ continue
566
+ chain = ancestors(path)
567
+ key = next((d for d in chain if fully_unlinked(d)), chain[-1])
568
+ grouped.setdefault(key, []).append(path)
569
+
570
+ # --- second pass: is the GROUP DIRECTORY itself on the board? ---------------------------------
571
+ # Reporting a directory while only ever having checked the files inside it asserts an absence
572
+ # that was never verified -- the same error this script is careful to avoid for `not_checked`.
573
+ # The resolver's match is a path-boundary test, so a board item naming `skills/convert/` links
574
+ # that directory without linking `skills/convert/convert_cli.py`. Both facts are true and the
575
+ # directory-level one is the one that decides whether a segment is worth investigating.
576
+ #
577
+ # Same borrowed checker, same batch interface, one extra call. No new linkage logic -- the only
578
+ # thing added on this side is WHICH SPELLINGS of a path we ask about.
579
+ #
580
+ # MIRROR SPELLINGS. Per CLAUDE.md the canonical file lives in the root tree and `.agents/` and
581
+ # `.claude/` are generated build outputs, but plenty of older board items were written against
582
+ # the mirror path -- E17_S05 owns `/reconcile-origin` and names it as
583
+ # `.agents/skills/reconcile-origin/SKILL.md`. A boundary match on the root path alone therefore
584
+ # misses a board item that plainly owns the directory. So each directory is asked about under its
585
+ # own name and under both mirror prefixes, and a hit on any spelling is board provenance. This
586
+ # adds path spellings to the QUESTION; it does not add a second answer to it.
587
+ MIRROR_PREFIXES = (".agents/", ".claude/")
588
+
589
+
590
+ def spellings(directory):
591
+ if directory == "." or directory.startswith(MIRROR_PREFIXES):
592
+ return [directory]
593
+ return [directory] + [pre + directory for pre in MIRROR_PREFIXES]
594
+
595
+
596
+ def resolve_directories(dirs):
597
+ if not dirs:
598
+ return {}
599
+ queries = []
600
+ for d in dirs:
601
+ queries.extend(spellings(d))
602
+ cmd = [resolver, "--paths-from", "-", "--board-dir", board_dir,
603
+ "--repo-root", repo_root, "--limit", "0"]
604
+ try:
605
+ proc = subprocess.run(cmd, input="\n".join(queries) + "\n", stdout=subprocess.PIPE,
606
+ stderr=subprocess.DEVNULL, universal_newlines=True)
607
+ except OSError as exc:
608
+ notices.append("directory linkage check could not run (%s); every group is reported "
609
+ "with an unverified directory" % exc)
610
+ return {}
611
+ # Exit 2 means some input path did not exist -- normal, and the JSON is still complete.
612
+ if proc.returncode not in (0, 2):
613
+ notices.append("directory linkage check failed (exit %d); every group is reported with "
614
+ "an unverified directory" % proc.returncode)
615
+ return {}
616
+ try:
617
+ payload = json.loads(proc.stdout)
618
+ except ValueError:
619
+ notices.append("directory linkage check returned unreadable output; every group is "
620
+ "reported with an unverified directory")
621
+ return {}
622
+ by_query = {r.get("argument"): (r.get("board_linkage") or {}) for r in payload.get("results", [])}
623
+
624
+ out = {}
625
+ for d in dirs:
626
+ chosen = None
627
+ for spelling in spellings(d):
628
+ link = by_query.get(spelling)
629
+ if not link:
630
+ continue
631
+ if link.get("status") == "linked":
632
+ chosen = dict(link, matched_as=spelling)
633
+ break
634
+ # Keep the root spelling's own verdict as the fallback, so a `not_checked` or
635
+ # `unlinked` answer is still the one reported when no spelling is linked.
636
+ if chosen is None:
637
+ chosen = dict(link, matched_as=spelling)
638
+ if chosen is not None:
639
+ out[d] = chosen
640
+ return out
641
+
642
+
643
+ dir_linkage = resolve_directories(sorted(grouped))
644
+
645
+ UNVERIFIED = {
646
+ "status": "not_checked",
647
+ "reason": "directory linkage was not verified",
648
+ "items": [], "files": [], "match_count": 0,
649
+ }
650
+
651
+
652
+ def build_group(directory, files):
653
+ link = dir_linkage.get(directory) or dict(UNVERIFIED)
654
+ shown = files if limit == 0 else files[:limit]
655
+ return {
656
+ "directory": directory,
657
+ "unlinked_count": len(files),
658
+ "subtree_candidates": sub_total.get(directory, 0),
659
+ "subtree_unlinked": sub_unlinked.get(directory, 0),
660
+ "fully_unlinked": fully_unlinked(directory),
661
+ "directory_linkage": {
662
+ "status": link.get("status", "not_checked"),
663
+ "reason": link.get("reason") or "directory linkage was not verified",
664
+ "items": link.get("items") or [],
665
+ "match_count": link.get("match_count", 0),
666
+ "matched_as": link.get("matched_as", directory),
667
+ },
668
+ "files": shown,
669
+ "files_truncated": len(files) - len(shown),
670
+ }
671
+
672
+
673
+ groups, covered_groups = [], []
674
+ for directory, files in grouped.items():
675
+ g = build_group(directory, files)
676
+ # `linked` is the only status that disqualifies a group. `not_checked` -- the repo root, or a
677
+ # failed second pass -- keeps the group and carries the caveat, because under-reporting a
678
+ # finding is worse than reporting one the user can dismiss.
679
+ (covered_groups if g["directory_linkage"]["status"] == "linked" else groups).append(g)
680
+
681
+
682
+ def order(g):
683
+ return (-g["unlinked_count"], g["directory"])
684
+
685
+
686
+ groups.sort(key=order)
687
+ covered_groups.sort(key=order)
688
+ not_checked.sort(key=lambda r: r["path"])
689
+
690
+ unlinked_n = sum(1 for s in status_of.values() if s == "unlinked")
691
+ uncharted_n = sum(g["unlinked_count"] for g in groups)
692
+ covered_n = sum(g["unlinked_count"] for g in covered_groups)
693
+
694
+ if len(status_of) != candidate_n:
695
+ notices.append("board-linkage checker returned %d records for %d candidate paths"
696
+ % (len(status_of), candidate_n))
697
+ if not_checked:
698
+ notices.append("%d path(s) could not be checked for board linkage; they are reported "
699
+ "separately and are NOT counted as unlinked" % len(not_checked))
700
+ if covered_groups:
701
+ notices.append("%d file(s) in %d director(y|ies) have no provenance of their own but sit "
702
+ "under a board-referenced directory; reported as covered, not offered"
703
+ % (covered_n, len(covered_groups)))
704
+
705
+ payload = {
706
+ "schema": "detect-unlinked-code/2",
707
+ "repo_root": repo_root,
708
+ "board_dir": board_dir,
709
+ "resolver": resolver,
710
+ "summary": {
711
+ "tracked_paths": tracked_n,
712
+ "excluded_paths": tracked_n - candidate_n,
713
+ "candidate_paths": candidate_n,
714
+ "linked_paths": linked_by_board + linked_by_commit,
715
+ "linked_by_board": linked_by_board,
716
+ "linked_by_commit": linked_by_commit,
717
+ "unlinked_paths": unlinked_n,
718
+ "uncharted_paths": uncharted_n,
719
+ "covered_paths": covered_n,
720
+ "not_checked_paths": len(not_checked),
721
+ "groups": len(groups),
722
+ "covered_groups": len(covered_groups),
723
+ },
724
+ "groups": groups,
725
+ "covered_groups": covered_groups,
726
+ "not_checked": not_checked,
727
+ "exclusions": {
728
+ "prefixes": EXCLUDED_PREFIXES,
729
+ "globs": globs,
730
+ "gitignored": ignored_n,
731
+ },
732
+ "notices": notices,
733
+ }
734
+
735
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
736
+ PY
737
+ )
738
+
739
+ python3 -c "$PY_MAIN" \
740
+ "$REPO_ROOT" "$BOARD_DIR" "$RESOLVER" "$LINKAGE" "$COMMITS" "$EXCLUDE_GLOBS" \
741
+ "$LIMIT" "$COUNTS"