session-orchestrator 3.22.0 → 3.23.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 (268) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,275 @@
1
+ /**
2
+ * scripts/lib/validate/repo-files.mjs
3
+ *
4
+ * File enumeration for the `scripts/lib/validate/` scanners — TWO exports,
5
+ * because the population a scanner needs genuinely splits in two:
6
+ *
7
+ * listRepoFiles() — what git TRACKS. The right default for any scanner
8
+ * asking "what does this repository contain?".
9
+ * listOnDiskFiles() — what the filesystem HOLDS, minus a named exclusion
10
+ * set. For the scanners whose oracle is deliberately
11
+ * "exists on disk" — `check-untracked-test-deps.mjs`
12
+ * states it outright: "untracked = ignored OR (exists on
13
+ * disk AND not in git ls-files)". That check needs BOTH
14
+ * halves and diffs them; collapsing them into one
15
+ * enumerator would delete its subject.
16
+ *
17
+ * Why this module exists (#1143). A scanner that walks with `readdirSync`
18
+ * cannot see `.gitignore`, so every gitignored artefact under a scan root
19
+ * enters its census as if it were repository content. Two populations arrive
20
+ * that way, and only the first is widely known:
21
+ *
22
+ * 1. A git worktree created inside the repo. This repo's own convention is
23
+ * `.claude/worktrees/<name>` (gitignored, `.gitignore:20`), which drops a
24
+ * COMPLETE second checkout into any root-anchored walk. Measured by
25
+ * `check-unwired-features.mjs` (see its EXCLUDED_DIRS comment) with one
26
+ * peer worktree present: +755 `.md` and +1209 `.mjs` files, 133 MB — and,
27
+ * worse than the cost, the peer's copy of a rule file was counted as an
28
+ * INDEPENDENT document, so a finding cited two sources where one exists.
29
+ *
30
+ * 2. Ordinary gitignored content, present with no worktree at all. Measured
31
+ * 2026-08-26 on a clean checkout of this repo (`.claude/worktrees/` empty,
32
+ * `find .claude/worktrees -mindepth 1 | wc -l` → 0): the `.md` corpus of
33
+ * `check-doc-cli-commands.mjs` was 4 files wider on disk than in the index
34
+ * — `.claude/STATE.md` (per-session mutable state) and three
35
+ * `docs/specs/*.md` (gitignored design notes). So the exposure is LIVE,
36
+ * not merely latent on the worktree case.
37
+ *
38
+ * The index is also the cheaper oracle: one `git ls-files -z` beats a
39
+ * recursive `readdirSync` over the same subtree, and it never descends into
40
+ * `node_modules` at all.
41
+ *
42
+ * Both exports return ABSOLUTE paths, sorted, and never throw for a missing
43
+ * directory (it contributes zero files). Symlinks are skipped in the walk —
44
+ * following them can leave the root entirely, and a symlinked file is not
45
+ * independent content.
46
+ *
47
+ * @module scripts/lib/validate/repo-files
48
+ */
49
+
50
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
51
+ import path from 'node:path';
52
+ import { execFileSync } from 'node:child_process';
53
+
54
+ /**
55
+ * Environment handed to `git`. An allowlist rather than `process.env`: an
56
+ * inherited `GIT_DIR` / `GIT_WORK_TREE` (set by any hook that spawned us)
57
+ * would silently re-point `ls-files` at a DIFFERENT repository, and the
58
+ * result would look like a plausible file list. Same allowlist as
59
+ * `check-banner-parity.mjs`, whose enumeration pattern this lifts.
60
+ */
61
+ const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
62
+
63
+ /**
64
+ * Directory names excluded from every filesystem walk in this module.
65
+ *
66
+ * `worktrees` is the #1143 entry and is here for a MEASURED reason, not by
67
+ * analogy to `node_modules` (see the module header). The other four are the
68
+ * usual generated/vendored trees; excluding them is what makes the walk
69
+ * fallback comparable in cost to the `ls-files` path it replaces.
70
+ *
71
+ * NOT exhaustive by intent: this is the exclusion set for a walk that has
72
+ * already lost `.gitignore`. The fix for a new gitignored tree is to use
73
+ * `listRepoFiles()`, not to grow this list.
74
+ */
75
+ export const EXCLUDED_DIRS = Object.freeze([
76
+ 'node_modules',
77
+ '.git',
78
+ 'worktrees',
79
+ 'dist',
80
+ 'coverage',
81
+ ]);
82
+
83
+ /**
84
+ * `realpathSync` that degrades to its input. Used only to compare two paths
85
+ * for identity, where an unresolvable path can never equal a resolvable one.
86
+ * @param {string} target
87
+ * @returns {string}
88
+ */
89
+ function safeRealpath(target) {
90
+ try {
91
+ return realpathSync(target);
92
+ } catch {
93
+ return target;
94
+ }
95
+ }
96
+
97
+ /** @returns {NodeJS.ProcessEnv} the allowlisted git environment */
98
+ function gitEnv() {
99
+ /** @type {NodeJS.ProcessEnv} */
100
+ const env = {};
101
+ for (const key of GIT_ENV_ALLOWLIST) {
102
+ if (process.env[key] !== undefined) env[key] = process.env[key];
103
+ }
104
+ return env;
105
+ }
106
+
107
+ /**
108
+ * True when `root` is the top level of a git working tree — the precondition
109
+ * for trusting `git ls-files` to describe it.
110
+ *
111
+ * The realpath comparison is load-bearing: inside a SUBDIRECTORY of a repo
112
+ * (or inside a worktree of one), `rev-parse --show-toplevel` succeeds and
113
+ * names a DIFFERENT directory, and `ls-files` run there would enumerate a
114
+ * corpus that is not the caller's root.
115
+ *
116
+ * @param {string} root absolute path
117
+ * @param {NodeJS.ProcessEnv} [env] allowlisted git environment (default: {@link gitEnv})
118
+ * @returns {boolean}
119
+ */
120
+ export function isGitToplevel(root, env = gitEnv()) {
121
+ try {
122
+ const toplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], {
123
+ cwd: root,
124
+ encoding: 'utf8',
125
+ stdio: ['ignore', 'pipe', 'ignore'],
126
+ env,
127
+ }).trim();
128
+ return Boolean(toplevel) && safeRealpath(toplevel) === safeRealpath(root);
129
+ } catch {
130
+ return false; // not a git checkout, or no git binary
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Normalise the `exts` option into a predicate.
136
+ * @param {string[] | null | undefined} exts
137
+ * @returns {(absolute: string) => boolean}
138
+ */
139
+ function extFilter(exts) {
140
+ if (!exts || exts.length === 0) return () => true;
141
+ const set = new Set(exts.map((e) => (e.startsWith('.') ? e : `.${e}`)));
142
+ return (absolute) => set.has(path.extname(absolute));
143
+ }
144
+
145
+ /**
146
+ * Recursive `readdirSync` walk under `absDir`.
147
+ *
148
+ * Defensive by design: an unreadable sub-directory is skipped rather than
149
+ * thrown, so one bad entry never aborts a whole census. A directory whose
150
+ * basename is in `exclude` is not descended into.
151
+ *
152
+ * @param {string} absDir
153
+ * @param {(absolute: string) => boolean} matches
154
+ * @param {Set<string>} exclude directory BASENAMES to skip
155
+ * @param {string[]} acc
156
+ * @returns {string[]}
157
+ */
158
+ function walk(absDir, matches, exclude, acc = []) {
159
+ let entries;
160
+ try {
161
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return acc;
162
+ entries = readdirSync(absDir, { withFileTypes: true });
163
+ } catch {
164
+ return acc;
165
+ }
166
+ for (const entry of entries) {
167
+ // A symlink can leave the root entirely, and a symlinked file is not
168
+ // independent content — skip both directions.
169
+ if (entry.isSymbolicLink()) continue;
170
+ const full = path.join(absDir, entry.name);
171
+ if (entry.isDirectory()) {
172
+ if (exclude.has(entry.name)) continue;
173
+ walk(full, matches, exclude, acc);
174
+ continue;
175
+ }
176
+ if (entry.isFile() && matches(full)) acc.push(full);
177
+ }
178
+ return acc;
179
+ }
180
+
181
+ /**
182
+ * Resolve the `dirs` option to absolute directories under `root`.
183
+ * `'.'` (or an empty list) means the root itself.
184
+ * @param {string} root
185
+ * @param {string[] | undefined} dirs
186
+ * @returns {string[]}
187
+ */
188
+ function resolveDirs(root, dirs) {
189
+ if (!dirs || dirs.length === 0) return [root];
190
+ return dirs.map((d) => (d === '.' ? root : path.join(root, d)));
191
+ }
192
+
193
+ /**
194
+ * Files git TRACKS under `dirs` — the repository's own account of its content.
195
+ *
196
+ * `git ls-files` is authoritative and honours `.gitignore` for free. When
197
+ * `root` is not the top level of a git checkout (a tarball export, a tmpdir
198
+ * fixture, a vendored copy), this falls back to a filesystem walk carrying the
199
+ * SAME exclusion set as {@link listOnDiskFiles} — a fallback that walked
200
+ * naively would reintroduce the exact bug this function exists to remove.
201
+ *
202
+ * @param {string} root absolute repository root
203
+ * @param {{dirs?: string[], exts?: string[] | null}} [options]
204
+ * `dirs` repo-relative directories to scan (default: the whole root);
205
+ * `exts` extensions to keep, with or without the dot (default: every file).
206
+ * @returns {string[]} absolute paths, sorted, deduplicated
207
+ */
208
+ export function listRepoFiles(root, options = {}) {
209
+ const { dirs, exts = null } = options;
210
+ const matches = extFilter(exts);
211
+ const env = gitEnv();
212
+
213
+ if (isGitToplevel(root, env)) {
214
+ const pathspecs = dirs && dirs.length > 0 ? dirs.filter((d) => d !== '.') : [];
215
+ try {
216
+ const out = execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], {
217
+ cwd: root,
218
+ encoding: 'utf8',
219
+ stdio: ['ignore', 'pipe', 'ignore'],
220
+ maxBuffer: 64 * 1024 * 1024,
221
+ env,
222
+ });
223
+ return out
224
+ .split('\0')
225
+ .filter(Boolean)
226
+ .map((rel) => path.join(root, rel))
227
+ .filter(matches)
228
+ // A tracked path can be absent from the working tree (sparse checkout,
229
+ // a deletion staged elsewhere). A scanner that then read it would
230
+ // report a tool-error for a file nobody removed.
231
+ .filter((absolute) => {
232
+ try {
233
+ return statSync(absolute).isFile();
234
+ } catch {
235
+ return false;
236
+ }
237
+ })
238
+ .sort();
239
+ } catch {
240
+ // fall through to the walk — a git that answered rev-parse but failed
241
+ // ls-files leaves us with no index to trust.
242
+ }
243
+ }
244
+
245
+ const exclude = new Set(EXCLUDED_DIRS);
246
+ const found = new Set();
247
+ for (const dir of resolveDirs(root, dirs)) {
248
+ for (const file of walk(dir, matches, exclude)) found.add(file);
249
+ }
250
+ return [...found].sort();
251
+ }
252
+
253
+ /**
254
+ * Files the FILESYSTEM holds under `dirs`, minus {@link EXCLUDED_DIRS}.
255
+ *
256
+ * Use this only when "exists on disk" is genuinely the oracle — i.e. when the
257
+ * point is to find files git does NOT track. Every other scanner wants
258
+ * {@link listRepoFiles}.
259
+ *
260
+ * @param {string} root absolute repository root
261
+ * @param {{dirs?: string[], exts?: string[] | null, exclude?: string[]}} [options]
262
+ * `exclude` REPLACES the default exclusion set when given; pass
263
+ * `[...EXCLUDED_DIRS, 'extra']` to extend it rather than to swap it.
264
+ * @returns {string[]} absolute paths, sorted, deduplicated
265
+ */
266
+ export function listOnDiskFiles(root, options = {}) {
267
+ const { dirs, exts = null, exclude = EXCLUDED_DIRS } = options;
268
+ const matches = extFilter(exts);
269
+ const excludeSet = new Set(exclude);
270
+ const found = new Set();
271
+ for (const dir of resolveDirs(root, dirs)) {
272
+ for (const file of walk(dir, matches, excludeSet)) found.add(file);
273
+ }
274
+ return [...found].sort();
275
+ }
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
38
- import { join, resolve, relative } from 'node:path';
38
+ import { join, resolve, relative, basename } from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
40
40
  import { execFileSync } from 'node:child_process';
41
41
  import { createRequire } from 'node:module';
@@ -237,6 +237,200 @@ function getTargetFileList(targetRoot) {
237
237
  return files;
238
238
  }
239
239
 
240
+ // ---------------------------------------------------------------------------
241
+ // Vendoring sanitizer (issue #1098)
242
+ //
243
+ // Lives here rather than in rules-sync.mjs because it has this module's exact
244
+ // shape — "judge one rule file, return findings" — and because rules-sync.mjs
245
+ // already imports from here, so the move adds no import edge and no cycle.
246
+ // rules-sync.mjs re-exports `scanVendoringLeaks` for its existing importers.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Path roots that exist in the PLUGIN repo but are not part of what a consumer
251
+ * repo receives. A rule that cites `scripts/lib/foo.mjs` reads fine here and
252
+ * dangles the moment the file is vendored.
253
+ *
254
+ * Deliberately a small, named list rather than "every top-level directory":
255
+ * the detector below additionally requires the cited path to resolve to a real
256
+ * FILE inside pluginRoot, which is what separates a plugin-internal citation
257
+ * from a consumer-repo path that merely shares a prefix (`docs/api.md`,
258
+ * `tests/` as a generic directory reference, `scripts/smoke-test.ts` in a code
259
+ * sample). Revisit if the plugin grows another top-level source directory that
260
+ * rules cite.
261
+ */
262
+ const REPO_LOCAL_PATH_ROOTS = ['scripts', 'hooks', 'skills', 'docs', 'tests'];
263
+
264
+ // Leading boundary excludes `react-hooks/exhaustive-deps` and
265
+ // `templates/shared/hooks/x.sh` — a root only counts at a token boundary.
266
+ const REPO_LOCAL_PATH_RE = new RegExp(
267
+ `(?<![\\w./-])((?:${REPO_LOCAL_PATH_ROOTS.join('|')})/[\\w./-]*\\w)`,
268
+ 'g',
269
+ );
270
+
271
+ const MD_REFERENCE_RE = /[\w][\w.-]*\.md/g;
272
+
273
+ /**
274
+ * `*.md` names a `## See Also` section may legitimately cite even though
275
+ * `rules/_index.md` does not register them: they are project-root instruction
276
+ * files every consumer repo has, not rules that travel through the sync.
277
+ */
278
+ const NON_RULE_MD_REFERENCES = new Set(['CLAUDE.md', 'AGENTS.md', 'README.md']);
279
+
280
+ /**
281
+ * 1-based line number of `index` within `content`.
282
+ * @param {string} content
283
+ * @param {number} index
284
+ * @returns {number}
285
+ */
286
+ function lineOf(content, index) {
287
+ return content.slice(0, index).split('\n').length;
288
+ }
289
+
290
+ /**
291
+ * Extracts the body of the trailing `## See Also` section, with the offset at
292
+ * which it starts. Returns null when the file has no such section.
293
+ * @param {string} content
294
+ * @returns {{ body: string, offset: number } | null}
295
+ */
296
+ function extractSeeAlso(content) {
297
+ const m = /^##\s+See Also\s*$/m.exec(content);
298
+ if (!m) return null;
299
+ const start = m.index + m[0].length;
300
+ const next = /^##\s+/m.exec(content.slice(start));
301
+ const end = next !== null ? start + next.index : content.length;
302
+ return { body: content.slice(start, end), offset: start };
303
+ }
304
+
305
+ /**
306
+ * True when `token` (a repo-relative path taken from rule prose) stays inside
307
+ * `pluginRoot` once resolved. `REPO_LOCAL_PATH_RE`'s character class admits
308
+ * `..`, so `scripts/../../../../etc/passwd` reaches this function; without the
309
+ * containment check its `statSync` would walk outside the plugin root and
310
+ * report a host file as a vendoring leak. Same shape as
311
+ * `git-config-drift.mjs`'s `hooksPathIsTracked` containment guard.
312
+ * @param {string} pluginRoot
313
+ * @param {string} token
314
+ * @returns {boolean}
315
+ */
316
+ function tokenStaysInside(pluginRoot, token) {
317
+ const abs = resolve(pluginRoot, token);
318
+ const rel = relative(resolve(pluginRoot), abs);
319
+ return rel !== '' && !rel.startsWith('..') && !/^([a-zA-Z]:)?[\\/]/.test(rel);
320
+ }
321
+
322
+ /**
323
+ * Report-only vendoring sanitizer (issue #1098).
324
+ *
325
+ * Scans one rule file for the two ways a rule that reads correctly INSIDE this
326
+ * plugin repo breaks once it is vendored into a consumer repo:
327
+ *
328
+ * - `repo-local-path` — a `scripts/…`, `hooks/…`, `skills/…`, `docs/…` or
329
+ * `tests/…` citation that resolves to a real file under `pluginRoot`. The
330
+ * consumer has no such file, so the citation dangles.
331
+ * - `unresolvable-see-also` — a `## See Also` entry naming a `*.md` rule that
332
+ * `rules/_index.md` does not register, so it is never vendored alongside.
333
+ *
334
+ * **This function never rewrites content.** Silent stripping would change the
335
+ * meaning of a rule at vendoring time, invisibly to both the author and the
336
+ * consumer; the finding is reported and a human decides. `@your-org/*`
337
+ * placeholders are deliberately NOT flagged — they are the documented
338
+ * placeholder convention for consumer-supplied package scopes, not a leak.
339
+ *
340
+ * @param {object} opts
341
+ * @param {string} opts.content - raw rule file content (never modified)
342
+ * @param {string} opts.relPath - manifest-relative path, used as `file`
343
+ * @param {string} opts.pluginRoot - repo root the repo-local check resolves against
344
+ * @param {Set<string>|null} [opts.manifestBasenames] - basenames registered in
345
+ * `rules/_index.md`; when null the `unresolvable-see-also` check is skipped
346
+ * @returns {Array<{file: string, line: number, kind: 'repo-local-path'|'unresolvable-see-also', text: string}>}
347
+ */
348
+ export function scanVendoringLeaks({ content, relPath, pluginRoot, manifestBasenames = null }) {
349
+ const findings = [];
350
+
351
+ REPO_LOCAL_PATH_RE.lastIndex = 0;
352
+ const seenPaths = new Set();
353
+ let m;
354
+ while ((m = REPO_LOCAL_PATH_RE.exec(content)) !== null) {
355
+ const token = m[1];
356
+ if (seenPaths.has(token)) continue;
357
+ let isFile;
358
+ try {
359
+ // Containment first: never stat a path that escapes pluginRoot.
360
+ if (!tokenStaysInside(pluginRoot, token)) continue;
361
+ const abs = join(pluginRoot, token);
362
+ isFile = existsSync(abs) && statSync(abs).isFile();
363
+ } catch {
364
+ isFile = false;
365
+ }
366
+ if (!isFile) continue;
367
+ seenPaths.add(token);
368
+ findings.push({
369
+ file: relPath,
370
+ line: lineOf(content, m.index),
371
+ kind: 'repo-local-path',
372
+ text: token,
373
+ });
374
+ }
375
+
376
+ if (manifestBasenames) {
377
+ const seeAlso = extractSeeAlso(content);
378
+ if (seeAlso) {
379
+ const ownBasename = basename(relPath);
380
+ const seenRefs = new Set();
381
+ MD_REFERENCE_RE.lastIndex = 0;
382
+ let r;
383
+ while ((r = MD_REFERENCE_RE.exec(seeAlso.body)) !== null) {
384
+ const ref = r[0];
385
+ const refBase = basename(ref);
386
+ if (refBase === ownBasename || seenRefs.has(refBase)) continue;
387
+ if (NON_RULE_MD_REFERENCES.has(refBase)) continue;
388
+ if (manifestBasenames.has(refBase)) continue;
389
+ seenRefs.add(refBase);
390
+ findings.push({
391
+ file: relPath,
392
+ line: lineOf(content, seeAlso.offset + r.index),
393
+ kind: 'unresolvable-see-also',
394
+ text: ref,
395
+ });
396
+ }
397
+ }
398
+ }
399
+
400
+ return findings;
401
+ }
402
+
403
+ /**
404
+ * Reads `<pluginRoot>/rules/_index.md` and returns the set of rule basenames it
405
+ * registers, for `scanVendoringLeaks()`'s `unresolvable-see-also` check.
406
+ * Returns `null` when the manifest is absent or unreadable — the caller then
407
+ * skips that check rather than reporting every See-Also entry as a leak.
408
+ *
409
+ * The bullet shape is the one `rules-sync.mjs`'s `parseIndex()` parses; it is
410
+ * matched here with a local regex rather than imported, because `rules-sync.mjs`
411
+ * imports from THIS module and an import back would create a module-load cycle
412
+ * (same reasoning as the `PLUGIN_HEADER_PREFIX` copy above).
413
+ *
414
+ * @param {string} pluginRoot
415
+ * @returns {Set<string>|null}
416
+ */
417
+ export function readManifestBasenames(pluginRoot) {
418
+ const indexPath = join(pluginRoot, 'rules', '_index.md');
419
+ let indexContent;
420
+ try {
421
+ indexContent = readFileSync(indexPath, 'utf8');
422
+ } catch {
423
+ return null;
424
+ }
425
+ const names = new Set();
426
+ const bulletRe = /^-\s+`([^`]+\.md)`/gm;
427
+ let m;
428
+ while ((m = bulletRe.exec(indexContent)) !== null) {
429
+ names.add(basename(m[1]));
430
+ }
431
+ return names;
432
+ }
433
+
240
434
  // ---------------------------------------------------------------------------
241
435
  // Public API
242
436
  // ---------------------------------------------------------------------------
@@ -396,13 +590,21 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
396
590
  * Validates every `*.md` rule file under `dir`, recursively (skips dotfiles
397
591
  * and `_index.md`).
398
592
  *
593
+ * When `pluginRoot` is given, every scanned file is additionally passed through
594
+ * `scanVendoringLeaks()` and the findings are collected into the additive
595
+ * `sanitizer[]` array (issue #1098). Report-only, exactly as in `syncRules()`:
596
+ * a finding contributes to neither `errorCount` nor `warnCount`, so it can
597
+ * never change this function's `ok` verdict or its CLI's exit code.
598
+ *
399
599
  * @param {object} opts
400
600
  * @param {string} opts.dir - absolute path to a directory of rule files
401
601
  * @param {string|null} [opts.targetRoot] - forwarded to validateRuleContent
402
602
  * @param {boolean} [opts.requireProvenance] - forwarded to validateRuleContent
403
- * @returns {{ ok: boolean, files: Array<{ file: string, violations: RuleViolation[] }>, errorCount: number, warnCount: number }}
603
+ * @param {string|null} [opts.pluginRoot] - when provided, enables the vendoring
604
+ * sanitizer scan against this plugin root
605
+ * @returns {{ ok: boolean, files: Array<{ file: string, violations: RuleViolation[] }>, errorCount: number, warnCount: number, sanitizer: Array<{file: string, line: number, kind: string, text: string}> }}
404
606
  */
405
- export function validateRulesDir({ dir, targetRoot = null, requireProvenance = false }) {
607
+ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = false, pluginRoot = null }) {
406
608
  function collectRuleFiles(absDir) {
407
609
  const files = [];
408
610
  for (const entry of readdirSync(absDir, { withFileTypes: true })) {
@@ -420,9 +622,12 @@ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = f
420
622
  const entries = collectRuleFiles(dir).sort();
421
623
 
422
624
  const files = [];
625
+ const sanitizer = [];
423
626
  let errorCount = 0;
424
627
  let warnCount = 0;
425
628
 
629
+ const manifestBasenames = pluginRoot ? readManifestBasenames(pluginRoot) : null;
630
+
426
631
  for (const name of entries) {
427
632
  const filePath = join(dir, name);
428
633
  const content = readFileSync(filePath, 'utf8');
@@ -432,9 +637,15 @@ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = f
432
637
  else if (v.severity === 'warn') warnCount++;
433
638
  }
434
639
  files.push({ file: name, violations });
640
+
641
+ if (pluginRoot) {
642
+ sanitizer.push(
643
+ ...scanVendoringLeaks({ content, relPath: name, pluginRoot, manifestBasenames }),
644
+ );
645
+ }
435
646
  }
436
647
 
437
- return { ok: errorCount === 0, files, errorCount, warnCount };
648
+ return { ok: errorCount === 0, files, errorCount, warnCount, sanitizer };
438
649
  }
439
650
 
440
651
  // ── CLI ───────────────────────────────────────────────────────────────────────
@@ -480,13 +691,17 @@ if (isMain) {
480
691
 
481
692
  if (args.includes('--help') || args.includes('-h')) {
482
693
  process.stdout.write(
483
- 'Usage: validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--require-provenance] [--json] [--mode hard|warn]\n',
694
+ 'Usage: validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--plugin-root <dir>] [--require-provenance] [--json] [--mode hard|warn]\n',
484
695
  );
485
696
  process.exit(0);
486
697
  }
487
698
 
488
699
  const dirArg = getArg('--dir');
489
700
  const targetRoot = getArg('--target-root') ?? null;
701
+ // Enables the report-only vendoring sanitizer (#1098). Never affects the
702
+ // exit code — findings are surfaced, the operator decides.
703
+ const pluginRootArg = getArg('--plugin-root');
704
+ const pluginRoot = pluginRootArg ? resolve(pluginRootArg) : null;
490
705
  const requireProvenance = args.includes('--require-provenance');
491
706
  const jsonOutput = args.includes('--json');
492
707
  const mode = getArg('--mode') ?? 'hard';
@@ -510,16 +725,23 @@ if (isMain) {
510
725
 
511
726
  let result;
512
727
  try {
513
- result = validateRulesDir({ dir, targetRoot, requireProvenance });
728
+ result = validateRulesDir({ dir, targetRoot, requireProvenance, pluginRoot });
514
729
  } catch (err) {
515
730
  process.stderr.write(`validate-vendored-rules: error: failed to read --dir ${dir}: ${err.message}\n`);
516
731
  process.exit(2);
517
732
  }
518
733
 
519
734
  if (jsonOutput) {
520
- process.stdout.write(JSON.stringify({ mode, dir, targetRoot, requireProvenance, ...result }, null, 2) + '\n');
735
+ process.stdout.write(
736
+ JSON.stringify({ mode, dir, targetRoot, pluginRoot, requireProvenance, ...result }, null, 2) + '\n',
737
+ );
521
738
  } else {
522
739
  process.stdout.write(formatSummary(result, dir) + '\n');
740
+ for (const f of result.sanitizer) {
741
+ process.stderr.write(
742
+ `validate-vendored-rules: sanitizer ${f.kind} ${f.file}:${f.line} — ${f.text}\n`,
743
+ );
744
+ }
523
745
  }
524
746
 
525
747
  const exitCode = result.errorCount > 0 && mode === 'hard' ? 1 : 0;