session-orchestrator 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.agents/skills/autopilot/SKILL.md +1 -0
  2. package/.agents/skills/bootstrap/SKILL.md +2 -0
  3. package/.agents/skills/brainstorm/SKILL.md +3 -0
  4. package/.agents/skills/close/SKILL.md +17 -0
  5. package/.agents/skills/debug/SKILL.md +2 -0
  6. package/.agents/skills/discovery/SKILL.md +2 -1
  7. package/.agents/skills/dispatcher/SKILL.md +2 -0
  8. package/.agents/skills/eli5/SKILL.md +2 -0
  9. package/.agents/skills/eval/SKILL.md +1 -0
  10. package/.agents/skills/evolve/SKILL.md +2 -1
  11. package/.agents/skills/go/SKILL.md +18 -0
  12. package/.agents/skills/grill/SKILL.md +2 -0
  13. package/.agents/skills/harness-audit/SKILL.md +16 -0
  14. package/.agents/skills/memory-cleanup/SKILL.md +1 -0
  15. package/.agents/skills/persona-panel/SKILL.md +1 -0
  16. package/.agents/skills/plan/SKILL.md +3 -1
  17. package/.agents/skills/portfolio/SKILL.md +17 -0
  18. package/.agents/skills/reconcile/SKILL.md +1 -0
  19. package/.agents/skills/release/SKILL.md +18 -0
  20. package/.agents/skills/repo-audit/SKILL.md +1 -0
  21. package/.agents/skills/spinout/SKILL.md +1 -0
  22. package/.agents/skills/sunset-review/SKILL.md +2 -0
  23. package/.agents/skills/test/SKILL.md +17 -0
  24. package/.agents/skills/ux-grill/SKILL.md +2 -0
  25. package/.claude-plugin/marketplace.json +1 -1
  26. package/.claude-plugin/plugin.json +1 -1
  27. package/.codex-plugin/plugin.json +1 -1
  28. package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
  29. package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
  30. package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
  31. package/.codex-plugin/skills/close/SKILL.md +3 -3
  32. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
  33. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/debug/SKILL.md +11 -4
  35. package/.codex-plugin/skills/discovery/SKILL.md +8 -4
  36. package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
  37. package/.codex-plugin/skills/eli5/SKILL.md +9 -4
  38. package/.codex-plugin/skills/eval/SKILL.md +9 -4
  39. package/.codex-plugin/skills/evolve/SKILL.md +9 -4
  40. package/.codex-plugin/skills/go/SKILL.md +3 -3
  41. package/.codex-plugin/skills/grill/SKILL.md +11 -4
  42. package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
  43. package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
  44. package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
  45. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
  46. package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
  47. package/.codex-plugin/skills/plan/SKILL.md +8 -4
  48. package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
  49. package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
  50. package/.codex-plugin/skills/release/SKILL.md +3 -3
  51. package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
  52. package/.codex-plugin/skills/spinout/SKILL.md +4 -4
  53. package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
  54. package/.codex-plugin/skills/test/SKILL.md +3 -3
  55. package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
  56. package/.cursor/commands/autopilot.md +4 -4
  57. package/.cursor/commands/bootstrap.md +5 -4
  58. package/.cursor/commands/brainstorm.md +5 -4
  59. package/.cursor/commands/close.md +4 -3
  60. package/.cursor/commands/convergence-monitoring.md +13 -0
  61. package/.cursor/commands/debug.md +4 -4
  62. package/.cursor/commands/discovery.md +4 -4
  63. package/.cursor/commands/dispatcher.md +4 -4
  64. package/.cursor/commands/eli5.md +4 -4
  65. package/.cursor/commands/eval.md +4 -4
  66. package/.cursor/commands/evolve.md +4 -4
  67. package/.cursor/commands/go.md +4 -3
  68. package/.cursor/commands/grill.md +4 -4
  69. package/.cursor/commands/harness-audit.md +3 -3
  70. package/.cursor/commands/memory-cleanup.md +4 -4
  71. package/.cursor/commands/npm-publish.md +13 -0
  72. package/.cursor/commands/persona-panel.md +4 -4
  73. package/.cursor/commands/plan.md +5 -4
  74. package/.cursor/commands/portfolio.md +3 -3
  75. package/.cursor/commands/reconcile.md +4 -4
  76. package/.cursor/commands/release.md +4 -3
  77. package/.cursor/commands/repo-audit.md +4 -4
  78. package/.cursor/commands/spinout.md +4 -4
  79. package/.cursor/commands/sunset-review.md +4 -4
  80. package/.cursor/commands/test.md +3 -3
  81. package/.cursor/commands/ux-grill.md +4 -4
  82. package/.cursor/rules/010-session-workflow.mdc +2 -2
  83. package/.cursor/skills/bootstrap/SKILL.md +1 -0
  84. package/.cursor/skills/close/SKILL.md +13 -0
  85. package/.cursor/skills/debug/SKILL.md +0 -1
  86. package/.cursor/skills/discovery/SKILL.md +0 -1
  87. package/.cursor/skills/dispatcher/SKILL.md +0 -1
  88. package/.cursor/skills/eli5/SKILL.md +0 -1
  89. package/.cursor/skills/evolve/SKILL.md +0 -1
  90. package/.cursor/skills/go/SKILL.md +13 -0
  91. package/.cursor/skills/grill/SKILL.md +0 -1
  92. package/.cursor/skills/harness-audit/SKILL.md +12 -0
  93. package/.cursor/skills/portfolio/SKILL.md +12 -0
  94. package/.cursor/skills/release/SKILL.md +13 -0
  95. package/.cursor/skills/repo-audit/SKILL.md +0 -1
  96. package/.cursor/skills/sunset-review/SKILL.md +0 -1
  97. package/.cursor/skills/test/SKILL.md +12 -0
  98. package/.cursor/skills/ux-grill/SKILL.md +0 -1
  99. package/.cursor-plugin/plugin.json +1 -1
  100. package/.orchestrator/policy/blocked-commands.json +1 -1
  101. package/AGENTS.md +1 -1
  102. package/CHANGELOG.md +61 -0
  103. package/README.md +11 -9
  104. package/commands/session.md +10 -0
  105. package/docs/ci-setup.md +53 -0
  106. package/docs/codex-setup.md +1 -1
  107. package/docs/components.md +11 -6
  108. package/docs/events-schema.md +4 -1
  109. package/docs/install.md +16 -0
  110. package/docs/persona-panel.md +1 -1
  111. package/docs/pi-setup.md +1 -1
  112. package/docs/rule-authoring.md +83 -14
  113. package/docs/scope-collision-guard.md +2 -0
  114. package/docs/session-config-reference.md +6 -4
  115. package/hooks/_lib/hook-import-set.json +46 -6
  116. package/hooks/_lib/subagent-paths.mjs +15 -0
  117. package/hooks/_lib/vcs-create-matcher.mjs +217 -62
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/on-session-end.mjs +14 -2
  121. package/hooks/on-stop.mjs +43 -1
  122. package/hooks/post-bash-write-verify.mjs +3 -0
  123. package/hooks/pre-auq-clarity.mjs +3 -0
  124. package/hooks/pre-bash-issue-budget.mjs +103 -17
  125. package/hooks/pre-task-scope-disjoint.mjs +152 -3
  126. package/hooks/skill-invocation-telemetry.mjs +2 -1
  127. package/package.json +2 -1
  128. package/pi/prompts/autopilot.md +3 -3
  129. package/pi/prompts/bootstrap.md +3 -3
  130. package/pi/prompts/brainstorm.md +3 -3
  131. package/pi/prompts/close.md +2 -2
  132. package/pi/prompts/convergence-monitoring.md +11 -0
  133. package/pi/prompts/debug.md +3 -3
  134. package/pi/prompts/discovery.md +3 -3
  135. package/pi/prompts/dispatcher.md +3 -3
  136. package/pi/prompts/eli5.md +3 -3
  137. package/pi/prompts/eval.md +3 -3
  138. package/pi/prompts/evolve.md +3 -3
  139. package/pi/prompts/go.md +2 -2
  140. package/pi/prompts/grill.md +3 -3
  141. package/pi/prompts/harness-audit.md +2 -3
  142. package/pi/prompts/memory-cleanup.md +3 -3
  143. package/pi/prompts/npm-publish.md +11 -0
  144. package/pi/prompts/persona-panel.md +3 -3
  145. package/pi/prompts/plan.md +3 -3
  146. package/pi/prompts/portfolio.md +2 -2
  147. package/pi/prompts/reconcile.md +3 -3
  148. package/pi/prompts/release.md +3 -3
  149. package/pi/prompts/repo-audit.md +3 -4
  150. package/pi/prompts/session.md +1 -1
  151. package/pi/prompts/spinout.md +3 -3
  152. package/pi/prompts/sunset-review.md +3 -3
  153. package/pi/prompts/templates-ack.md +1 -1
  154. package/pi/prompts/test.md +3 -3
  155. package/pi/prompts/ux-grill.md +3 -3
  156. package/scripts/archive-closed-prds.mjs +2 -2
  157. package/scripts/auq-audit.mjs +2 -3
  158. package/scripts/backfill-abandoned-sessions.mjs +57 -3
  159. package/scripts/backfill-evidence-digest.mjs +2 -1
  160. package/scripts/backfill-learnings-from-vault.mjs +2 -2
  161. package/scripts/check-package-manager.mjs +2 -2
  162. package/scripts/ci/assert-vitest-green.mjs +2 -1
  163. package/scripts/emit-session.mjs +2 -3
  164. package/scripts/export-hw-learnings.mjs +2 -1
  165. package/scripts/express-path.mjs +1 -1
  166. package/scripts/gc-stale-worktrees.mjs +2 -1
  167. package/scripts/generate-codex-skills.mjs +48 -4
  168. package/scripts/generate-cursor-adapter.mjs +173 -9
  169. package/scripts/generate-hook-import-set.mjs +12 -27
  170. package/scripts/generate-pi-prompts.mjs +183 -13
  171. package/scripts/github-protection-audit.mjs +2 -3
  172. package/scripts/lib/agent-frontmatter.mjs +23 -1
  173. package/scripts/lib/claude-md-budget-lint.mjs +2 -5
  174. package/scripts/lib/command-blocker.mjs +133 -5
  175. package/scripts/lib/config/drift-check.mjs +19 -0
  176. package/scripts/lib/convergence-monitor.mjs +2 -2
  177. package/scripts/lib/cursor-hook-bridge.mjs +2 -2
  178. package/scripts/lib/description-surface.mjs +2 -5
  179. package/scripts/lib/dispatcher/cli.mjs +2 -1
  180. package/scripts/lib/ecosystem-wizard.mjs +2 -1
  181. package/scripts/lib/fetch-baseline.mjs +3 -8
  182. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
  183. package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
  184. package/scripts/lib/instruction-budget-guard.mjs +186 -46
  185. package/scripts/lib/is-main-module.mjs +82 -0
  186. package/scripts/lib/locks/index.mjs +32 -25
  187. package/scripts/lib/maintenance-due-banner.mjs +69 -3
  188. package/scripts/lib/peer-discovery.mjs +2 -5
  189. package/scripts/lib/playwright-driver/runner.mjs +2 -1
  190. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
  191. package/scripts/lib/rules-sync.mjs +2 -5
  192. package/scripts/lib/scope-echo.mjs +392 -7
  193. package/scripts/lib/session-close-backfill.mjs +58 -6
  194. package/scripts/lib/state-md.mjs +84 -3
  195. package/scripts/lib/sunset/walker.mjs +31 -4
  196. package/scripts/lib/tests-src-ratio.mjs +2 -6
  197. package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
  198. package/scripts/lib/user-invocable-skills.mjs +185 -0
  199. package/scripts/lib/validate/check-banner-parity.mjs +2 -2
  200. package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
  201. package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
  202. package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
  203. package/scripts/lib/validate/check-entry-guard.mjs +366 -0
  204. package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
  205. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
  206. package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
  207. package/scripts/lib/validate/check-skill-links.mjs +27 -6
  208. package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
  209. package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
  210. package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
  211. package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
  212. package/scripts/lib/validate/check-unwired-features.mjs +91 -7
  213. package/scripts/lib/validate/check-validator-registration.mjs +2 -2
  214. package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
  215. package/scripts/lib/validate-vendored-rules.mjs +35 -9
  216. package/scripts/lib/wave-transcript-tail.mjs +2 -2
  217. package/scripts/lock-reaper.mjs +2 -1
  218. package/scripts/materialize-wave-scope.mjs +87 -4
  219. package/scripts/migrate-sessions-jsonl.mjs +2 -1
  220. package/scripts/migrate-vault-paths.mjs +2 -3
  221. package/scripts/release.mjs +80 -35
  222. package/scripts/relocate-vault-corpus.mjs +2 -3
  223. package/scripts/repair-invalid-sessions.mjs +2 -2
  224. package/scripts/session-shape.mjs +2 -2
  225. package/scripts/site-numbers.mjs +35 -11
  226. package/scripts/sweep-expired-rules.mjs +216 -0
  227. package/scripts/validate-plugin.mjs +9 -0
  228. package/scripts/vault-consolidate.mjs +2 -2
  229. package/scripts/vault-mirror.mjs +2 -3
  230. package/scripts/wave-scope-binding.mjs +2 -3
  231. package/skills/_shared/bootstrap-gate.md +1 -1
  232. package/skills/_shared/monitor-patterns.md +1 -1
  233. package/skills/_shared/research-evidence.md +53 -0
  234. package/skills/_shared/state-ownership.md +3 -0
  235. package/skills/autopilot/SKILL.md +58 -4
  236. package/skills/bootstrap/SKILL.md +51 -1
  237. package/skills/brainstorm/SKILL.md +16 -0
  238. package/skills/claude-md-drift-check/checker.mjs +49 -11
  239. package/{commands/close.md → skills/close/SKILL.md} +9 -3
  240. package/skills/debug/SKILL.md +10 -0
  241. package/skills/discovery/SKILL.md +24 -1
  242. package/skills/discovery/probes-session.md +2 -2
  243. package/skills/dispatcher/SKILL.md +38 -7
  244. package/skills/eli5/SKILL.md +11 -0
  245. package/skills/eval/SKILL.md +14 -0
  246. package/skills/evolve/SKILL.md +8 -1
  247. package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
  248. package/{commands/go.md → skills/go/SKILL.md} +9 -1
  249. package/skills/grill/SKILL.md +19 -0
  250. package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
  251. package/skills/hook-development/SKILL.md +46 -41
  252. package/skills/memory-cleanup/SKILL.md +7 -0
  253. package/skills/npm-publish/SKILL.md +1 -1
  254. package/skills/persona-panel/SKILL.md +56 -1
  255. package/skills/persona-panel/persona-format.md +1 -1
  256. package/skills/plan/SKILL.md +28 -1
  257. package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
  258. package/skills/reconcile/SKILL.md +10 -0
  259. package/{commands/release.md → skills/release/SKILL.md} +16 -2
  260. package/skills/repo-audit/SKILL.md +7 -0
  261. package/skills/session-end/plan-verification.md +2 -2
  262. package/skills/session-plan/SKILL.md +1 -1
  263. package/skills/session-start/SKILL.md +5 -4
  264. package/skills/session-start/phase-8-5-express-path.md +6 -6
  265. package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
  266. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
  267. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  268. package/skills/spinout/SKILL.md +12 -1
  269. package/skills/sunset-review/SKILL.md +13 -0
  270. package/{commands/test.md → skills/test/SKILL.md} +10 -4
  271. package/skills/ux-grill/SKILL.md +19 -1
  272. package/skills/wave-executor/SKILL.md +7 -4
  273. package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
  274. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  275. package/skills/wave-executor/references/wave-loop-review.md +17 -1
  276. package/commands/autopilot.md +0 -80
  277. package/commands/bootstrap.md +0 -56
  278. package/commands/brainstorm.md +0 -48
  279. package/commands/debug.md +0 -36
  280. package/commands/discovery.md +0 -32
  281. package/commands/dispatcher.md +0 -59
  282. package/commands/eli5.md +0 -33
  283. package/commands/eval.md +0 -28
  284. package/commands/evolve.md +0 -10
  285. package/commands/grill.md +0 -45
  286. package/commands/memory-cleanup.md +0 -26
  287. package/commands/persona-panel.md +0 -121
  288. package/commands/plan.md +0 -15
  289. package/commands/reconcile.md +0 -23
  290. package/commands/repo-audit.md +0 -24
  291. package/commands/spinout.md +0 -15
  292. package/commands/sunset-review.md +0 -27
  293. package/commands/ux-grill.md +0 -51
@@ -7,6 +7,18 @@
7
7
  * `skills/`; these wrappers keep one maintained body while giving Cursor a
8
8
  * native entry per command/skill (same pattern as `generate-pi-prompts.mjs`).
9
9
  *
10
+ * `.cursor/commands/` is generated from the UNION of two sources: every
11
+ * `commands/*.md`, plus every skill whose frontmatter carries an explicit
12
+ * `user-invocable: true`. The second source exists because the operator-facing
13
+ * slash-command marker moved INTO the skill frontmatter when the command bodies
14
+ * were folded into their same-named `skills/<name>/SKILL.md`. A name present in
15
+ * both sources is a generator ERROR, not a precedence question — two documents
16
+ * claiming one public `/name` is a merge that did not finish.
17
+ *
18
+ * `disable-model-invocation` is propagated from the SOURCE frontmatter and is
19
+ * NOT derived from `user-invocable` — see {@link disablesModelInvocation} for
20
+ * the two grounds and the measurement behind them.
21
+ *
10
22
  * Usage:
11
23
  * node scripts/generate-cursor-adapter.mjs
12
24
  * node scripts/generate-cursor-adapter.mjs --check
@@ -15,6 +27,7 @@
15
27
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
28
  import path from 'node:path';
17
29
  import { fileURLToPath } from 'node:url';
30
+ import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
18
31
 
19
32
  const __filename = fileURLToPath(import.meta.url);
20
33
  const SCRIPT_DIR = path.dirname(__filename);
@@ -31,12 +44,14 @@ function isDir(p) {
31
44
  }
32
45
 
33
46
  function commandFiles() {
47
+ if (!existsSync(COMMANDS_DIR)) return [];
34
48
  return readdirSync(COMMANDS_DIR)
35
49
  .filter((name) => name.endsWith('.md'))
36
50
  .sort();
37
51
  }
38
52
 
39
53
  function skillDirs() {
54
+ if (!existsSync(SKILLS_DIR)) return [];
40
55
  return readdirSync(SKILLS_DIR)
41
56
  .filter((name) => isDir(path.join(SKILLS_DIR, name)) && existsSync(path.join(SKILLS_DIR, name, 'SKILL.md')))
42
57
  .sort();
@@ -44,15 +59,30 @@ function skillDirs() {
44
59
 
45
60
  /**
46
61
  * Parse YAML-ish frontmatter including `>` / `|` folded scalars.
62
+ *
63
+ * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
64
+ * two probes below miss the block entirely: the file parsed as "no
65
+ * frontmatter", so every flag in it (`user-invocable`,
66
+ * `disable-model-invocation`) silently disappeared and the skill was demoted
67
+ * out of `.cursor/commands/` with no diagnostic anywhere. Both are normalised
68
+ * away first — the same treatment `parseAgentFrontmatter`
69
+ * (`scripts/lib/agent-frontmatter.mjs`) gives them.
70
+ *
71
+ * NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
72
+ * `__BLOCK_SCALAR__` sentinel for a `description: >`, which is the form every
73
+ * merged skill actually uses — routing through it would emit the sentinel as
74
+ * the wrapper description.
75
+ *
47
76
  * @param {string} content
48
77
  * @returns {Record<string, string>}
49
78
  */
50
79
  function parseFrontmatter(content) {
51
- if (!content.startsWith('---\n')) return {};
52
- const end = content.indexOf('\n---\n', 4);
80
+ const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
81
+ if (!text.startsWith('---\n')) return {};
82
+ const end = text.indexOf('\n---\n', 4);
53
83
  if (end === -1) return {};
54
84
 
55
- const lines = content.slice(4, end).split('\n');
85
+ const lines = text.slice(4, end).split('\n');
56
86
  const fields = {};
57
87
  let i = 0;
58
88
  while (i < lines.length) {
@@ -158,8 +188,108 @@ Cursor has no Skill tool. When the command says to invoke a skill, Read \`skills
158
188
  `;
159
189
  }
160
190
 
161
- function isUserInvocable(value) {
162
- return value === 'true' || value === true;
191
+ /**
192
+ * Skills that declare themselves operator-facing slash commands.
193
+ *
194
+ * The predicate is `isUserInvocableValue` from `scripts/lib/user-invocable-skills.mjs`
195
+ * — the ONE normaliser for this marker, shared with the Pi and Codex generators
196
+ * and with every counter. The private copy this replaced read only a
197
+ * whitespace-trimmed bare `true`, so `True` and `true # note` were demoted HERE
198
+ * while the shared counter listed them as commands; the SKILL.md path is passed
199
+ * so a demotion WARN names the file.
200
+ *
201
+ * @returns {string[]} skill names, sorted
202
+ */
203
+ function userInvocableSkills() {
204
+ return skillDirs().filter((name) => {
205
+ const file = path.join(SKILLS_DIR, name, 'SKILL.md');
206
+ const fields = parseFrontmatter(readFileSync(file, 'utf8'));
207
+ return isUserInvocableValue(fields['user-invocable'], file);
208
+ });
209
+ }
210
+
211
+ /**
212
+ * A `.cursor/commands/<name>.md` wrapper for a skill that IS the slash command
213
+ * (`user-invocable: true`). Same contract as {@link renderCommand}, pointing at
214
+ * the skill body instead of a command file.
215
+ *
216
+ * @param {string} skillName
217
+ * @returns {string}
218
+ */
219
+ function renderSkillCommand(skillName) {
220
+ const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
221
+ const fields = parseFrontmatter(readFileSync(skillPath, 'utf8'));
222
+ const description = clampDescription(fields.description || `Session Orchestrator skill: ${skillName}`);
223
+ // Same GH#54 rule as renderCommand: `argument-hint` ALWAYS goes through
224
+ // yamlQuote(), because its canonical authored form (`[mode] [--flag]`) is a
225
+ // YAML flow sequence when emitted bare.
226
+ // The command wrapper carries the SOURCE value only — never the library-skill
227
+ // ground of {@link disablesModelInvocation}, which cannot apply here: this
228
+ // surface exists BECAUSE the skill is `user-invocable: true`. Propagated so an
229
+ // operator-only command stays operator-only on whichever of the two surfaces
230
+ // Cursor reads the policy from (same mapping the Codex adapter makes onto
231
+ // `policy.allow_implicit_invocation`, `scripts/generate-codex-skills.mjs`).
232
+ const frontmatter = [
233
+ '---',
234
+ frontmatterLine('description', yamlQuote(description)),
235
+ frontmatterLine('argument-hint', yamlQuote(fields['argument-hint'])),
236
+ isUserInvocableValue(fields['disable-model-invocation'], skillPath) ? 'disable-model-invocation: true' : null,
237
+ '---',
238
+ ].filter(Boolean).join('\n');
239
+
240
+ return `${frontmatter}
241
+
242
+ # /${skillName}
243
+
244
+ Use the Session Orchestrator skill definition at \`skills/${skillName}/SKILL.md\`.
245
+
246
+ Arguments: $ARGUMENTS
247
+
248
+ Read that skill file and follow it exactly. When it references \`$ARGUMENTS\`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
249
+
250
+ Cursor has no Skill tool. When the skill says to invoke another skill, Read \`skills/<skill-name>/SKILL.md\` and follow it. Supporting files (\`soul.md\`, phase docs) live in that same \`skills/<skill-name>/\` directory.
251
+ `;
252
+ }
253
+
254
+ /**
255
+ * Emit `disable-model-invocation: true` into a Cursor wrapper?
256
+ *
257
+ * TWO independent grounds, OR-ed — the flag is a restriction, so the rule is
258
+ * fail-closed:
259
+ * 1. the SOURCE skill declares it (the single truth for operator-only skills).
260
+ * That flag is an INDEPENDENT axis from `user-invocable`, not its inverse:
261
+ * six skills carry BOTH (measured 2026-09-17: bootstrap, brainstorm, close,
262
+ * go, plan, release), and deriving the wrapper's flag from `user-invocable`
263
+ * alone emitted NO flag for exactly those six — letting a Cursor model
264
+ * auto-invoke `/close`, `/go` and `/release`;
265
+ * 2. the skill is not `user-invocable`, i.e. a library skill whose Cursor
266
+ * wrapper is reached by an explicit Read from a command body, never by a
267
+ * model's own dispatch. This ground predates the #1 fix and is kept
268
+ * deliberately: dropping it would REMOVE the flag from 24 of 50 wrappers
269
+ * (measured 2026-09-17) on no Cursor-side evidence, which is the one
270
+ * direction a guard fix must never move.
271
+ *
272
+ * Truth table — both readings normalised by `isUserInvocableValue`, so every
273
+ * form a YAML parser reads as `true` (`True`, `"true"`, `true # note`) counts
274
+ * as `true` on BOTH axes. Ground 2 made that load-bearing rather than cosmetic:
275
+ * while this file kept a private bare-`true` predicate, a `True`-marked
276
+ * user-invocable skill read as NOT user-invocable HERE and got the restriction
277
+ * STAMPED, while the shared counter listed it as a command:
278
+ *
279
+ * | source flag | user-invocable | emitted |
280
+ * |-------------|----------------|---------|
281
+ * | true | true | yes (1) |
282
+ * | true | false/absent | yes (1+2) |
283
+ * | false/absent| true | no |
284
+ * | false/absent| false/absent | yes (2) |
285
+ *
286
+ * @param {Record<string, string>} fields source skill frontmatter
287
+ * @param {string} [file] SKILL.md path, named in a demotion WARN
288
+ * @returns {boolean}
289
+ */
290
+ function disablesModelInvocation(fields, file) {
291
+ return isUserInvocableValue(fields['disable-model-invocation'], file)
292
+ || !isUserInvocableValue(fields['user-invocable'], file);
163
293
  }
164
294
 
165
295
  function renderSkill(skillName) {
@@ -174,7 +304,7 @@ function renderSkill(skillName) {
174
304
  `name: ${yamlQuote(skillName)}`,
175
305
  `description: ${yamlQuote(description)}`,
176
306
  ];
177
- if (!isUserInvocable(fields['user-invocable'])) {
307
+ if (disablesModelInvocation(fields, skillPath)) {
178
308
  lines.push('disable-model-invocation: true');
179
309
  }
180
310
  lines.push('---');
@@ -191,12 +321,37 @@ Cursor has no Skill tool. Treat "invoke the ${skillName} skill" as: Read \`skill
191
321
  `;
192
322
  }
193
323
 
324
+ /**
325
+ * The expected `.cursor/commands/` set: every `commands/*.md` PLUS every skill
326
+ * marked `user-invocable: true`.
327
+ *
328
+ * @returns {Map<string, string>} file name → content, sorted by file name
329
+ * @throws {Error} when one public name is claimed by both sources
330
+ */
194
331
  function expectedCommands() {
195
332
  const commands = new Map();
196
333
  for (const commandFile of commandFiles()) {
197
334
  commands.set(commandFile, renderCommand(commandFile));
198
335
  }
199
- return commands;
336
+
337
+ const collisions = [];
338
+ for (const skillName of userInvocableSkills()) {
339
+ const fileName = `${skillName}.md`;
340
+ if (commands.has(fileName)) {
341
+ collisions.push(skillName);
342
+ continue;
343
+ }
344
+ commands.set(fileName, renderSkillCommand(skillName));
345
+ }
346
+ if (collisions.length > 0) {
347
+ throw new Error(
348
+ `${collisions.length} public name(s) claimed by BOTH commands/ and a user-invocable skill: ${collisions.join(', ')}. `
349
+ + 'Exactly one document may own a slash command — delete the commands/<name>.md whose body was folded into skills/<name>/SKILL.md, '
350
+ + 'or drop `user-invocable: true` from the skill.',
351
+ );
352
+ }
353
+
354
+ return new Map([...commands].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
200
355
  }
201
356
 
202
357
  function expectedSkills() {
@@ -284,7 +439,16 @@ function writeAll(expectedCmds, expectedSkillsMap) {
284
439
  process.stdout.write(`cursor adapter: wrote ${expectedCmds.size} command(s), ${expectedSkillsMap.size} skill(s)\n`);
285
440
  }
286
441
 
287
- const expectedCmds = expectedCommands();
288
- const expectedSkillsMap = expectedSkills();
442
+ let expectedCmds;
443
+ let expectedSkillsMap;
444
+ try {
445
+ expectedCmds = expectedCommands();
446
+ expectedSkillsMap = expectedSkills();
447
+ } catch (error) {
448
+ // Loud and diagnosable: a source conflict must never degrade into a partial
449
+ // write or a stack trace read as "some node thing went wrong".
450
+ process.stderr.write(`FAIL: ${error.message}\n`);
451
+ process.exit(1);
452
+ }
289
453
  if (CHECK_ONLY) checkAll(expectedCmds, expectedSkillsMap);
290
454
  else writeAll(expectedCmds, expectedSkillsMap);
@@ -24,10 +24,10 @@
24
24
  * write is NEVER performed on a usage error.
25
25
  */
26
26
 
27
- import { readFileSync, writeFileSync, existsSync, statSync, realpathSync } from 'node:fs';
27
+ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
29
- import { fileURLToPath } from 'node:url';
30
29
  import { execFileSync } from 'node:child_process';
30
+ import { isMainModule } from './lib/is-main-module.mjs';
31
31
 
32
32
  /** The four hook manifests whose entry files seed the crawl. */
33
33
  export const HOOK_MANIFESTS = [
@@ -263,30 +263,15 @@ export function main(argv) {
263
263
  return 0;
264
264
  }
265
265
 
266
- /**
267
- * True when this module was launched as the process entry script.
268
- *
269
- * A string compare of `import.meta.url` against `file://${process.argv[1]}` is
270
- * fragile in exactly the invocations CI and husky use: any symlinked or
271
- * realpath-differing path (measured: `/tmp` → `/private/tmp` on macOS) makes
272
- * the compare false, so the whole CLI body becomes a silent no-op that still
273
- * exits 0 — a fail-OPEN drift gate. A space or `#` in the path breaks the
274
- * hand-built URL the same way. Same shape as `hooks/post-bash-write-verify.mjs`.
275
- *
276
- * @returns {boolean}
277
- */
278
- function invokedAsScript() {
279
- const entry = process.argv[1];
280
- if (!entry) return false;
281
- const self = fileURLToPath(import.meta.url);
282
- try {
283
- return realpathSync(entry) === realpathSync(self);
284
- } catch {
285
- // argv[1] unresolvable (deleted/renamed mid-run) — best-effort raw compare.
286
- return entry === self;
287
- }
288
- }
289
-
290
- if (invokedAsScript()) {
266
+ // Entry guard via the canonical predicate (`scripts/lib/is-main-module.mjs`).
267
+ // A string compare of `import.meta.url` against `file://${process.argv[1]}` is
268
+ // fragile in exactly the invocations CI and husky use: any symlinked or
269
+ // realpath-differing path (measured: `/tmp` → `/private/tmp` on macOS) makes the
270
+ // compare false, so the whole CLI body becomes a silent no-op that still exits 0
271
+ // — a fail-OPEN drift gate. This script is a plain CLI, not a hook, so it may
272
+ // import from `scripts/lib/` (the hooks that still inline the same predicate do
273
+ // so to keep `scripts/lib` off their hot path — see the ceiling comment in
274
+ // `hooks/post-bash-write-verify.mjs`).
275
+ if (isMainModule(import.meta.url)) {
291
276
  process.exit(main(process.argv));
292
277
  }
@@ -6,56 +6,162 @@
6
6
  * cross-harness `$ARGUMENTS` placeholder. The generated wrapper keeps one
7
7
  * maintained command source while giving Pi a native prompt entry per command.
8
8
  *
9
+ * `pi/prompts/` is generated from the UNION of two sources: every
10
+ * `commands/*.md`, plus every skill whose frontmatter carries an explicit
11
+ * `user-invocable: true`. The second source exists because the operator-facing
12
+ * slash-command marker moved INTO the skill frontmatter when the command bodies
13
+ * were folded into their same-named `skills/<name>/SKILL.md`. A name present in
14
+ * both sources is a generator ERROR, not a precedence question — two documents
15
+ * claiming one public `/name` is a merge that did not finish.
16
+ *
9
17
  * Usage:
10
18
  * node scripts/generate-pi-prompts.mjs
11
19
  * node scripts/generate-pi-prompts.mjs --check
12
20
  */
13
21
 
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
23
  import path from 'node:path';
16
24
  import { fileURLToPath } from 'node:url';
25
+ import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
17
26
 
18
27
  const __filename = fileURLToPath(import.meta.url);
19
28
  const SCRIPT_DIR = path.dirname(__filename);
20
29
  const ROOT = path.dirname(SCRIPT_DIR);
21
30
  const COMMANDS_DIR = path.join(ROOT, 'commands');
31
+ const SKILLS_DIR = path.join(ROOT, 'skills');
22
32
  const PROMPTS_DIR = path.join(ROOT, 'pi', 'prompts');
23
33
  const CHECK_ONLY = process.argv.includes('--check');
34
+ const DESCRIPTION_MAX = 1024;
35
+
36
+ function isDir(p) {
37
+ try { return statSync(p).isDirectory(); } catch { return false; }
38
+ }
24
39
 
25
40
  function commandFiles() {
41
+ if (!existsSync(COMMANDS_DIR)) return [];
26
42
  return readdirSync(COMMANDS_DIR)
27
43
  .filter((name) => name.endsWith('.md'))
28
44
  .sort();
29
45
  }
30
46
 
47
+ function skillDirs() {
48
+ if (!existsSync(SKILLS_DIR)) return [];
49
+ return readdirSync(SKILLS_DIR)
50
+ .filter((name) => isDir(path.join(SKILLS_DIR, name)) && existsSync(path.join(SKILLS_DIR, name, 'SKILL.md')))
51
+ .sort();
52
+ }
53
+
54
+ /**
55
+ * Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
56
+ * DECODED values (surrounding quotes stripped). Skill descriptions are folded
57
+ * scalars in practice, which the previous line-at-a-time parser read as the
58
+ * literal `>`. Mirrors `generate-cursor-adapter.mjs`.
59
+ *
60
+ * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
61
+ * two probes below miss the block entirely: the file parsed as "no
62
+ * frontmatter", so `user-invocable` silently disappeared and the skill was
63
+ * demoted out of `pi/prompts/` with no diagnostic anywhere. Both are normalised
64
+ * away first — the same treatment `parseAgentFrontmatter`
65
+ * (`scripts/lib/agent-frontmatter.mjs`) gives them.
66
+ *
67
+ * @param {string} content
68
+ * @returns {Record<string, string>}
69
+ */
31
70
  function parseFrontmatter(content) {
32
- if (!content.startsWith('---\n')) return {};
33
- const end = content.indexOf('\n---\n', 4);
71
+ const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
72
+ if (!text.startsWith('---\n')) return {};
73
+ const end = text.indexOf('\n---\n', 4);
34
74
  if (end === -1) return {};
35
75
 
76
+ const lines = text.slice(4, end).split('\n');
36
77
  const fields = {};
37
- const block = content.slice(4, end).split('\n');
38
- for (const line of block) {
39
- const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
40
- if (!match) continue;
41
- fields[match[1]] = match[2];
78
+ let i = 0;
79
+ while (i < lines.length) {
80
+ const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
81
+ if (!match) {
82
+ i += 1;
83
+ continue;
84
+ }
85
+ const key = match[1];
86
+ const raw = match[2];
87
+ if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
88
+ const folded = [];
89
+ i += 1;
90
+ while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
91
+ folded.push(lines[i].replace(/^\s+/, ''));
92
+ i += 1;
93
+ }
94
+ const joiner = raw.startsWith('|') ? '\n' : ' ';
95
+ fields[key] = folded.filter(Boolean).join(joiner).trim();
96
+ continue;
97
+ }
98
+ fields[key] = raw.replace(/^["']|["']$/g, '');
99
+ i += 1;
42
100
  }
43
101
  return fields;
44
102
  }
45
103
 
104
+ /** @see generate-cursor-adapter.mjs — same allow-list, same reasoning. */
105
+ const YAML_SCALAR_LOOKALIKE = /^(?:true|false|yes|no|on|off|null|nan|[-+]?\.?inf|~)$/i;
106
+ const YAML_PLAIN_SAFE = /^[A-Za-z0-9_][A-Za-z0-9 _.,()/-]*$/;
107
+
108
+ /**
109
+ * Render a frontmatter VALUE as YAML. `argument-hint`'s canonical authored form
110
+ * (`[mode] [--flag]`) is a YAML flow SEQUENCE when emitted bare — GH#54, which
111
+ * made Copilot CLI >= 1.0.65 silently drop the file.
112
+ *
113
+ * @param {unknown} value
114
+ * @returns {string|null} the YAML scalar, or `null` when there is nothing to emit
115
+ */
116
+ function yamlQuote(value) {
117
+ if (value === undefined || value === null || value === '') return null;
118
+ const text = String(value);
119
+ if (text.trim() === '') return null;
120
+ if (text !== text.trim()) return JSON.stringify(text);
121
+ if (!YAML_PLAIN_SAFE.test(text)) return JSON.stringify(text);
122
+ if (YAML_SCALAR_LOOKALIKE.test(text)) return JSON.stringify(text);
123
+ if (/^\d/.test(text)) return JSON.stringify(text);
124
+ return text;
125
+ }
126
+
46
127
  function frontmatterLine(key, value) {
47
- if (value === undefined || value === '') return null;
128
+ if (value === undefined || value === '' || value === null) return null;
48
129
  return `${key}: ${value}`;
49
130
  }
50
131
 
132
+ function clampDescription(text) {
133
+ const collapsed = String(text || '').replace(/\s+/g, ' ').trim();
134
+ if (collapsed.length <= DESCRIPTION_MAX) return collapsed;
135
+ return collapsed.slice(0, DESCRIPTION_MAX - 1).trimEnd() + '…';
136
+ }
137
+
138
+ /**
139
+ * Skills that declare themselves operator-facing slash commands.
140
+ *
141
+ * The predicate is `isUserInvocableValue` from `scripts/lib/user-invocable-skills.mjs`
142
+ * — the ONE normaliser for this marker, shared with the Cursor and Codex
143
+ * generators and with every counter. A local copy here is how the quoted-value
144
+ * disagreement arose (`"true"`: wrapper generated on two adapters, counted by
145
+ * neither counter, crash on the third).
146
+ *
147
+ * @returns {string[]} skill names, sorted
148
+ */
149
+ function userInvocableSkills() {
150
+ return skillDirs().filter((name) => {
151
+ const file = path.join(SKILLS_DIR, name, 'SKILL.md');
152
+ const fields = parseFrontmatter(readFileSync(file, 'utf8'));
153
+ return isUserInvocableValue(fields['user-invocable'], file);
154
+ });
155
+ }
156
+
51
157
  function renderPrompt(commandFile) {
52
158
  const commandPath = path.join(COMMANDS_DIR, commandFile);
53
159
  const commandName = commandFile.replace(/\.md$/, '');
54
160
  const fields = parseFrontmatter(readFileSync(commandPath, 'utf8'));
55
161
  const frontmatter = [
56
162
  '---',
57
- frontmatterLine('description', fields.description),
58
- frontmatterLine('argument-hint', fields['argument-hint']),
163
+ frontmatterLine('description', yamlQuote(clampDescription(fields.description))),
164
+ frontmatterLine('argument-hint', yamlQuote(fields['argument-hint'])),
59
165
  '---',
60
166
  ].filter(Boolean).join('\n');
61
167
 
@@ -71,12 +177,68 @@ Read that command file and follow it exactly. When it references \`$ARGUMENTS\`,
71
177
  `;
72
178
  }
73
179
 
180
+ /**
181
+ * A `pi/prompts/<name>.md` wrapper for a skill that IS the slash command
182
+ * (`user-invocable: true`). Same contract as {@link renderPrompt}, pointing at
183
+ * the skill body instead of a command file.
184
+ *
185
+ * @param {string} skillName
186
+ * @returns {string}
187
+ */
188
+ function renderSkillPrompt(skillName) {
189
+ const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
190
+ const fields = parseFrontmatter(readFileSync(skillPath, 'utf8'));
191
+ const description = clampDescription(fields.description || `Session Orchestrator skill: ${skillName}`);
192
+ const frontmatter = [
193
+ '---',
194
+ frontmatterLine('description', yamlQuote(description)),
195
+ frontmatterLine('argument-hint', yamlQuote(fields['argument-hint'])),
196
+ '---',
197
+ ].filter(Boolean).join('\n');
198
+
199
+ return `${frontmatter}
200
+
201
+ # /${skillName}
202
+
203
+ Use the Session Orchestrator skill definition at \`skills/${skillName}/SKILL.md\`.
204
+
205
+ Arguments: $@
206
+
207
+ Read that skill file and follow it exactly. When it references \`$ARGUMENTS\`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
208
+ `;
209
+ }
210
+
211
+ /**
212
+ * The expected `pi/prompts/` set: every `commands/*.md` PLUS every skill marked
213
+ * `user-invocable: true`.
214
+ *
215
+ * @returns {Map<string, string>} file name -> content, sorted by file name
216
+ * @throws {Error} when one public name is claimed by both sources
217
+ */
74
218
  function expectedPrompts() {
75
219
  const prompts = new Map();
76
220
  for (const commandFile of commandFiles()) {
77
221
  prompts.set(commandFile, renderPrompt(commandFile));
78
222
  }
79
- return prompts;
223
+
224
+ const collisions = [];
225
+ for (const skillName of userInvocableSkills()) {
226
+ const fileName = `${skillName}.md`;
227
+ if (prompts.has(fileName)) {
228
+ collisions.push(skillName);
229
+ continue;
230
+ }
231
+ prompts.set(fileName, renderSkillPrompt(skillName));
232
+ }
233
+ if (collisions.length > 0) {
234
+ throw new Error(
235
+ `${collisions.length} public name(s) claimed by BOTH commands/ and a user-invocable skill: ${collisions.join(', ')}. `
236
+ + 'Exactly one document may own a slash command — delete the commands/<name>.md whose body was folded into skills/<name>/SKILL.md, '
237
+ + 'or drop `user-invocable: true` from the skill.',
238
+ );
239
+ }
240
+
241
+ return new Map([...prompts].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
80
242
  }
81
243
 
82
244
  function checkPrompts(expected) {
@@ -122,6 +284,14 @@ function writePrompts(expected) {
122
284
  console.log(`pi prompts: wrote ${expected.size} file(s)`);
123
285
  }
124
286
 
125
- const expected = expectedPrompts();
287
+ let expected;
288
+ try {
289
+ expected = expectedPrompts();
290
+ } catch (error) {
291
+ // Loud and diagnosable: a source conflict must never degrade into a partial
292
+ // write or a stack trace read as "some node thing went wrong".
293
+ console.error(`FAIL: ${error.message}`);
294
+ process.exit(1);
295
+ }
126
296
  if (CHECK_ONLY) checkPrompts(expected);
127
297
  else writePrompts(expected);
@@ -34,8 +34,8 @@
34
34
 
35
35
  import { execFile as _execFile } from 'node:child_process';
36
36
  import { promisify } from 'node:util';
37
- import { pathToFileURL } from 'node:url';
38
37
  import { resolveRepoSpec as _resolveRepoSpec, resolveRepoHost as _resolveRepoHost } from './lib/vcs-repo-spec.mjs';
38
+ import { isMainModule } from './lib/is-main-module.mjs';
39
39
 
40
40
  const execFileAsync = promisify(_execFile);
41
41
 
@@ -347,8 +347,7 @@ export async function auditGithubBranchProtection(opts = {}, deps = {}) {
347
347
  }
348
348
 
349
349
  // CLI entry — only when run directly, not when imported (e.g. by tests).
350
- const isMain =
351
- process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
350
+ const isMain =isMainModule(import.meta.url);
352
351
 
353
352
  if (isMain) {
354
353
  auditGithubBranchProtection({ repoRoot: process.cwd() }).then((result) => {
@@ -58,7 +58,12 @@ export function parseAgentFrontmatter(contents) {
58
58
  };
59
59
  }
60
60
 
61
- const match = FRONTMATTER_RE.exec(contents);
61
+ // A UTF-8 BOM before `---` makes FRONTMATTER_RE miss the block entirely, so a
62
+ // BOM-prefixed SKILL.md would parse as "no frontmatter" and every flag in it
63
+ // (`user-invocable` included) silently disappear.
64
+ const text = contents.charCodeAt(0) === 0xfeff ? contents.slice(1) : contents;
65
+
66
+ const match = FRONTMATTER_RE.exec(text);
62
67
  if (!match) {
63
68
  return {
64
69
  ok: false,
@@ -76,19 +81,36 @@ export function parseAgentFrontmatter(contents) {
76
81
  const frontmatter = /** @type {Record<string,string>} */ ({});
77
82
  const lines = fmText.split(/\r?\n/);
78
83
 
84
+ // Indented lines belong to whatever the preceding top-level key opened (a
85
+ // nested mapping, or a block scalar) — they are NOT top-level keys. Without
86
+ // this, `metadata:\n user-invocable: true` was HOISTED to top level and read
87
+ // as the slash-command marker, so the counters saw a skill no generator did.
88
+ let inNestedBlock = false;
89
+
79
90
  for (const rawLine of lines) {
80
91
  const line = rawLine.replace(/\s+$/, '');
81
92
  if (line === '' || /^\s*#/.test(line)) continue;
82
93
 
94
+ if (/^\s/.test(rawLine)) {
95
+ if (inNestedBlock) continue;
96
+ } else {
97
+ inNestedBlock = false;
98
+ }
99
+
83
100
  // Detect block-scalar indicators on a key line, e.g. `description: >` or `description: |`
84
101
  // These are valid YAML but break Claude Code's parser.
85
102
  const blockScalarMatch = /^(\w[\w-]*):\s*[>|]\s*$/.exec(line);
86
103
  if (blockScalarMatch) {
87
104
  // Store a sentinel so the validator can emit the correct rule.
88
105
  frontmatter[blockScalarMatch[1]] = '__BLOCK_SCALAR__';
106
+ inNestedBlock = true;
89
107
  continue;
90
108
  }
91
109
 
110
+ // A bare `key:` at top level opens a nested mapping (or an empty value);
111
+ // everything indented under it is that mapping's business, not ours.
112
+ if (/^\w[\w-]*:$/.test(line)) inNestedBlock = true;
113
+
92
114
  const idx = line.indexOf(':');
93
115
  if (idx === -1) continue;
94
116
  const key = line.slice(0, idx).trim();
@@ -67,6 +67,7 @@ import { basename, resolve, sep } from 'node:path';
67
67
  import { homedir } from 'node:os';
68
68
  import { resolveInstructionFile } from './common.mjs';
69
69
  import { isSessionConfigHeading } from './config/section-extractor.mjs';
70
+ import { isMainModule } from './is-main-module.mjs';
70
71
 
71
72
  /** Thrown by `lintClaudeMd()` on any infra-level failure (missing/unreadable file). */
72
73
  export class ClaudeMdLintInfraError extends Error {
@@ -536,10 +537,6 @@ function main() {
536
537
  }
537
538
 
538
539
  const __filename = fileURLToPath(import.meta.url);
539
- const isMain =
540
- typeof process !== 'undefined' &&
541
- process.argv[1] !== null &&
542
- process.argv[1] !== undefined &&
543
- resolve(process.argv[1]) === resolve(__filename);
540
+ const isMain =isMainModule(import.meta.url);
544
541
 
545
542
  if (isMain) main();