session-orchestrator 5.6.0 → 5.8.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 (207) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +75 -0
  42. package/README.md +10 -10
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/USER-GUIDE.md +1 -1
  45. package/docs/baseline.md +4 -3
  46. package/docs/codex-setup.md +6 -4
  47. package/docs/components.md +5 -4
  48. package/docs/events-schema.md +4 -3
  49. package/docs/github-mirror-protection.md +3 -2
  50. package/docs/install.md +39 -3
  51. package/docs/pi-setup.md +1 -1
  52. package/docs/rule-authoring.md +12 -2
  53. package/docs/session-config-reference.md +8 -6
  54. package/docs/session-config-template.md +4 -3
  55. package/evals/README.md +60 -0
  56. package/evals/instruction-ablation/README.md +147 -0
  57. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  58. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  59. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  60. package/evals/instruction-ablation/run.mjs +336 -0
  61. package/evals/psa-003-foreign-work/case.yaml +16 -0
  62. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  63. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  64. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  65. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  66. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  67. package/evals/psa-003-foreign-work/prompt.md +6 -0
  68. package/hooks/_lib/hook-import-set.json +10 -2
  69. package/hooks/hooks-codex.json +1 -1
  70. package/hooks/hooks.json +1 -1
  71. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  72. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  73. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  74. package/hooks/wave-scope-commit-guard.mjs +41 -10
  75. package/monitors/monitors.json +1 -1
  76. package/package.json +3 -1
  77. package/pi/prompts/architecture.md +11 -0
  78. package/pi/prompts/eval.md +1 -1
  79. package/pi/prompts/tmux-layout.md +11 -0
  80. package/pi/prompts/write-executable-plan.md +11 -0
  81. package/scripts/backfill-abandoned-sessions.mjs +6 -8
  82. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  83. package/scripts/backfill-sessions.mjs +9 -7
  84. package/scripts/codex-install.mjs +108 -23
  85. package/scripts/emit-session.mjs +15 -2
  86. package/scripts/fleet-auflagen.mjs +54 -0
  87. package/scripts/generate-agents-skills.mjs +9 -16
  88. package/scripts/generate-codex-skills.mjs +8 -6
  89. package/scripts/generate-cursor-adapter.mjs +9 -74
  90. package/scripts/generate-pi-prompts.mjs +8 -74
  91. package/scripts/github-protection-audit.mjs +8 -0
  92. package/scripts/lib/auto-dialectic.mjs +10 -9
  93. package/scripts/lib/auto-dream.mjs +8 -10
  94. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  95. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  96. package/scripts/lib/config/drift-check.mjs +1 -1
  97. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  98. package/scripts/lib/config.mjs +5 -1
  99. package/scripts/lib/convergence-monitor.mjs +5 -3
  100. package/scripts/lib/dispatcher/rank.mjs +5 -1
  101. package/scripts/lib/ecosystem-health.mjs +5 -3
  102. package/scripts/lib/eval/engine.mjs +61 -27
  103. package/scripts/lib/eval/judge.mjs +14 -8
  104. package/scripts/lib/eval/session-resolve.mjs +7 -2
  105. package/scripts/lib/events-retention-banner.mjs +5 -2
  106. package/scripts/lib/events.mjs +81 -54
  107. package/scripts/lib/fleet-protocol.mjs +98 -1
  108. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  109. package/scripts/lib/io.mjs +181 -39
  110. package/scripts/lib/learnings/candidates.mjs +14 -44
  111. package/scripts/lib/learnings/io.mjs +7 -2
  112. package/scripts/lib/learnings/judgment.mjs +5 -8
  113. package/scripts/lib/learnings/select.mjs +3 -3
  114. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  115. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  116. package/scripts/lib/memory-proposals/store.mjs +12 -7
  117. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  118. package/scripts/lib/native-command.mjs +48 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  120. package/scripts/lib/peer-cards/writer.mjs +6 -18
  121. package/scripts/lib/platform.mjs +113 -4
  122. package/scripts/lib/plugin-package-stage.mjs +169 -0
  123. package/scripts/lib/plugin-surface.mjs +121 -0
  124. package/scripts/lib/quality-gate.mjs +30 -25
  125. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  126. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
  127. package/scripts/lib/reconcile/writer.mjs +11 -17
  128. package/scripts/lib/rule-loader.mjs +32 -6
  129. package/scripts/lib/scope-echo.mjs +12 -2
  130. package/scripts/lib/scope-gate.mjs +2 -1
  131. package/scripts/lib/session-close-backfill.mjs +21 -4
  132. package/scripts/lib/session-id.mjs +2 -1
  133. package/scripts/lib/session-record-repair.mjs +9 -13
  134. package/scripts/lib/session-schema/constants.mjs +20 -5
  135. package/scripts/lib/session-start-probes.mjs +2 -2
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +585 -72
  156. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  157. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  158. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  159. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  160. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  161. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  162. package/scripts/lib/wave-transcript-tail.mjs +19 -6
  163. package/scripts/lib/worktree/meta.mjs +7 -3
  164. package/scripts/mcp-server.mjs +158 -0
  165. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  166. package/scripts/migrate-vault-paths.mjs +7 -4
  167. package/scripts/print-applicable-rules.mjs +1 -25
  168. package/scripts/print-learnings-index.mjs +2 -1
  169. package/scripts/release.mjs +4 -0
  170. package/scripts/relocate-vault-corpus.mjs +5 -3
  171. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  172. package/scripts/self-update.mjs +179 -39
  173. package/scripts/site-numbers.mjs +4 -3
  174. package/scripts/sweep-expired-learnings.mjs +6 -4
  175. package/scripts/sync-vault-schema.mjs +3 -7
  176. package/scripts/vault-mirror-prune.mjs +145 -0
  177. package/scripts/vault-mirror.mjs +77 -12
  178. package/scripts/windows-setup.mjs +45 -0
  179. package/skills/_shared/fleet-protocol.md +8 -0
  180. package/skills/architecture/SKILL.md +1 -0
  181. package/skills/bootstrap/_shared-template.md +5 -2
  182. package/skills/claude-md-drift-check/SKILL.md +4 -1
  183. package/skills/claude-md-drift-check/checker.mjs +89 -26
  184. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  185. package/skills/eval/SKILL.md +12 -13
  186. package/skills/eval/rubric-v3.md +521 -0
  187. package/skills/frontmatter-guard/SKILL.md +1 -0
  188. package/skills/gitlab-portfolio/SKILL.md +1 -0
  189. package/skills/hook-development/SKILL.md +1 -0
  190. package/skills/mcp-builder/SKILL.md +1 -0
  191. package/skills/repo-audit/SKILL.md +2 -2
  192. package/skills/session-end/SKILL.md +32 -1
  193. package/skills/session-end/vault-operations.md +12 -2
  194. package/skills/session-start/SKILL.md +2 -2
  195. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  196. package/skills/session-start/references/operations-contract.md +4 -1
  197. package/skills/tmux-layout/SKILL.md +1 -0
  198. package/skills/vault-mirror/SKILL.md +28 -2
  199. package/skills/vault-sync/SKILL.md +6 -6
  200. package/skills/vault-sync/validator.sh +29 -10
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
  202. package/skills/write-executable-plan/SKILL.md +1 -0
  203. package/hooks/agent-teams-h3-test.sh +0 -362
  204. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  205. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  206. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  207. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -42,6 +42,7 @@
42
42
  import { readFile } from 'node:fs/promises';
43
43
  import path from 'node:path';
44
44
 
45
+ import { eventsFilePath } from './events.mjs';
45
46
  import { readCanonicalSessions } from './sessions-canonical.mjs';
46
47
  import { readTailWindow } from './tail-window.mjs';
47
48
  import { withStateMdLock } from './session-lock.mjs';
@@ -207,7 +208,7 @@ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
207
208
  * @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
208
209
  */
209
210
  async function readSessionIdsFromEvents(repoRoot) {
210
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
211
+ const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
211
212
 
212
213
  let window;
213
214
  try {
@@ -68,6 +68,7 @@
68
68
  import fs from 'node:fs';
69
69
  import path from 'node:path';
70
70
 
71
+ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
71
72
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
72
73
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
73
74
  import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
@@ -716,7 +717,6 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
716
717
  renameSync = fs.renameSync,
717
718
  copyFileSync = fs.copyFileSync,
718
719
  unlinkSync = fs.unlinkSync,
719
- existsSync = fs.existsSync,
720
720
  } = deps;
721
721
 
722
722
  const { checkIntegrity = defaultCheckIntegrity } = deps;
@@ -751,18 +751,14 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
751
751
  }
752
752
 
753
753
  // -- atomic swap ----------------------------------------------------------
754
- const tmpPath = `${file}.tmp-${process.pid}`;
755
- try {
756
- writeFileSync(tmpPath, text, 'utf8');
757
- renameSync(tmpPath, file);
758
- } catch (err) {
759
- try {
760
- if (existsSync(tmpPath)) unlinkSync(tmpPath);
761
- } catch {
762
- /* best-effort cleanup */
763
- }
764
- throw err;
765
- }
754
+ // The helper unlinks its tmp on failure; the backup above keeps its own
755
+ // `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
756
+ // seams (`deps`) are threaded through so a test fake still sees the write.
757
+ const res = atomicWriteWithBackup(file, text, {
758
+ tmpPrefix: `.${path.basename(file)}.tmp`,
759
+ fs: { writeFileSync, renameSync, unlinkSync },
760
+ });
761
+ if (!res.ok) throw envelopeToError(res);
766
762
 
767
763
  // -- post-verification ----------------------------------------------------
768
764
  const verdict = verifyWritten({
@@ -162,11 +162,11 @@ export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'faile
162
162
  * #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
163
163
  * cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
164
164
  *
165
- * NOT YET EXHAUSTIVE. `_validateOptionalFields` additionally shape-checks
166
- * `discovery_stats`, `review_stats`, `platform`, `branch`, `base_branch`,
167
- * `notes`, `duration_seconds`, `issues_closed` and `issues_created` without
168
- * listing them here. Treat membership as "declared optional", never absence as
169
- * "not a known field" — see the #964 follow-up note in the session report.
165
+ * Exhaustive since #986: every optional key `validateSession` inspects is
166
+ * declared here. tests/lib/session-schema/constants.test.mjs takes that census
167
+ * from the validator itself (a Proxy records the keys it reads) and also pins
168
+ * the declared-but-unchecked remainder (`session_start_ref`, `raw_session_id`,
169
+ * `_repair_source`).
170
170
  */
171
171
  export const OPTIONAL_FIELDS = Object.freeze([
172
172
  'agent_identity',
@@ -252,4 +252,19 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
252
  // 2026-10-01), so the counters say which of the two it is.
253
253
  'cost_records_priced',
254
254
  'cost_records_total',
255
+ // #986 — shape-checked by `_validateOptionalFields` since before this list
256
+ // existed, never declared until the census test made the gap visible.
257
+ 'discovery_stats',
258
+ 'review_stats',
259
+ 'platform',
260
+ 'branch',
261
+ 'base_branch',
262
+ 'notes',
263
+ 'duration_seconds',
264
+ 'issues_closed',
265
+ 'issues_created',
266
+ // `express_path` (canonical boolean, legacy {activated} object tolerated) and
267
+ // its forensic sidecar `_express_path_detail` written by `normalizeSession`.
268
+ 'express_path',
269
+ '_express_path_detail',
255
270
  ]);
@@ -50,7 +50,7 @@ import path from 'node:path';
50
50
  import { pathToFileURL } from 'node:url';
51
51
  import { promisify } from 'node:util';
52
52
 
53
- import { emitEvent } from './events.mjs';
53
+ import { emitEvent, eventsFilePath } from './events.mjs';
54
54
  import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
55
55
 
56
56
  /**
@@ -762,7 +762,7 @@ export const PROBES = [
762
762
  fn: 'checkEventsRetention',
763
763
  network: false,
764
764
  precondition: ({ repoRoot }) => {
765
- const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
765
+ const metrics = path.dirname(eventsFilePath(repoRoot)); // #1514: the ledger checkEventsRetention reads
766
766
  return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
767
767
  ? null
768
768
  : 'no-events-ledger';
@@ -143,6 +143,7 @@
143
143
  import { existsSync, readFileSync } from 'node:fs';
144
144
  import path from 'node:path';
145
145
 
146
+ import { eventsFilePath } from './events.mjs';
146
147
  import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
147
148
  import { isRealSession } from './session-schema/filters.mjs';
148
149
  import { readCanonicalSessions } from './sessions-canonical.mjs';
@@ -150,8 +151,6 @@ import { readCanonicalSessions } from './sessions-canonical.mjs';
150
151
  /** Repo-relative path to the session ledger (one record per closed session). */
151
152
  const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
152
153
 
153
- /** Repo-relative path to the generic telemetry stream (one line per event). */
154
- const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
155
154
 
156
155
  /** warn threshold: 2x the session-lock default TTL (8h) — imported, not duplicated. */
157
156
  export const WARN_THRESHOLD_HOURS = DEFAULT_TTL_HOURS * 2;
@@ -527,7 +526,7 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
527
526
  const ledger = lastLedgerEntry(sessionRecords);
528
527
  if (ledger === null) return null;
529
528
 
530
- const eventLines = readJsonlLines(path.join(repoRoot, EVENTS_PATH));
529
+ const eventLines = readJsonlLines(eventsFilePath(repoRoot)); // #1514: the repo's one ledger
531
530
  if (eventLines === null || eventLines.length === 0) return null;
532
531
 
533
532
  const { cutoffMs, ownSessionId } = resolveCutoff(repoRoot, nowMs);
@@ -49,9 +49,9 @@
49
49
  * Part of Epic #643 → issue #647 (C2 auto-repair engine).
50
50
  */
51
51
 
52
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
52
+ import { readFileSync } from 'node:fs';
53
53
  import path from 'node:path';
54
- import { randomBytes } from 'node:crypto';
54
+ import { atomicWriteWithBackup } from '../io.mjs';
55
55
 
56
56
  import { extractCandidates as realExtractCandidates } from './candidate-intake.mjs';
57
57
  import {
@@ -281,13 +281,10 @@ async function defaultApplyConfigRepair(candidate, repoRoot) {
281
281
  return { ok: true, applied: false, reason: 'already-current (no-op)' };
282
282
  }
283
283
 
284
- try {
285
- const dir = path.dirname(abs);
286
- const tmpFile = path.join(dir, `.${path.basename(abs)}.${randomBytes(6).toString('hex')}.tmp`);
287
- writeFileSync(tmpFile, next, { encoding: 'utf8' });
288
- renameSync(tmpFile, abs);
289
- } catch (err) {
290
- return { ok: true, applied: false, reason: `write failed: ${err?.message ?? String(err)}` };
284
+ // The helper's mkdir -p is a no-op here: `abs` was just read, so its dir exists.
285
+ const written = atomicWriteWithBackup(abs, next, { tmpPrefix: `.${path.basename(abs)}.tmp` });
286
+ if (!written.ok) {
287
+ return { ok: true, applied: false, reason: `write failed: ${written.error}` };
291
288
  }
292
289
 
293
290
  return { ok: true, applied: true };
@@ -45,9 +45,9 @@
45
45
  * Part of Epic #643 → issue #647 (C2 auto-repair engine).
46
46
  */
47
47
 
48
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
49
- import { dirname, isAbsolute, join } from 'node:path';
50
- import { randomBytes } from 'node:crypto';
48
+ import { readFileSync } from 'node:fs';
49
+ import { isAbsolute, join } from 'node:path';
50
+ import { atomicWriteWithBackup } from '../io.mjs';
51
51
 
52
52
  /**
53
53
  * @typedef {import('./candidate-intake.mjs').RepairCandidate} RepairCandidate
@@ -202,18 +202,16 @@ function readStore(absPath) {
202
202
  * @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
203
203
  */
204
204
  function writeStore(absPath, records) {
205
+ let content;
205
206
  try {
206
- const dir = dirname(absPath);
207
- mkdirSync(dir, { recursive: true });
208
207
  const body = records.map((r) => JSON.stringify(r)).join('\n');
209
- const content = records.length > 0 ? body + '\n' : '';
210
- const tmpFile = join(dir, `.repair-candidates.${randomBytes(6).toString('hex')}.tmp`);
211
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
212
- renameSync(tmpFile, absPath);
213
- return { ok: true, lines: records.length };
208
+ content = records.length > 0 ? body + '\n' : '';
214
209
  } catch (err) {
215
210
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
216
211
  }
212
+ // mkdir -p + tmp + rename; its failure envelope is this function's own.
213
+ const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.repair-candidates.tmp' });
214
+ return res.ok ? { ok: true, lines: records.length } : res;
217
215
  }
218
216
 
219
217
  // ---------------------------------------------------------------------------
@@ -43,6 +43,7 @@
43
43
  import { execFile } from 'node:child_process';
44
44
  import fs from 'node:fs';
45
45
  import path from 'node:path';
46
+ import { atomicWriteWithBackup } from './io.mjs';
46
47
 
47
48
  // ---------------------------------------------------------------------------
48
49
  // Constants
@@ -164,15 +165,16 @@ function ensureCacheLoaded(repoRoot) {
164
165
  function persistCache(repoRoot) {
165
166
  if (_cache === null) return;
166
167
  const file = cachePathFor(repoRoot);
167
- const dir = path.dirname(file);
168
+ let body;
168
169
  try {
169
- fs.mkdirSync(dir, { recursive: true });
170
- const obj = Object.fromEntries(_cache.entries());
171
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
172
- fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
173
- fs.renameSync(tmp, file);
170
+ body = JSON.stringify(Object.fromEntries(_cache.entries()), null, 2) + '\n';
174
171
  } catch (err) {
175
- console.warn(`slopcheck: cache write failed (${err.code ?? 'unknown'}): ${err.message}`);
172
+ console.warn(`slopcheck: cache write failed (serialize): ${err.message}`);
173
+ return;
174
+ }
175
+ const res = atomicWriteWithBackup(file, body, { tmpPrefix: `.${path.basename(file)}.tmp` });
176
+ if (!res.ok) {
177
+ console.warn(`slopcheck: cache write failed (${res.code ?? 'unknown'}): ${res.error}`);
176
178
  }
177
179
  }
178
180
 
@@ -606,8 +606,8 @@ export function staticReferenceScan(repoRoot, { kind, name }) {
606
606
  * NOTE since the 2026-09-16 command→skill fold (#1370): this linkage now covers
607
607
  * almost nothing. `commands/` holds two files (`session`, `templates-ack`), so
608
608
  * `skillToCommands` is near-empty by construction — the operator entry point for
609
- * every other slash command is an explicit `user-invocable: true` in the skill's
610
- * own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
609
+ * every other slash command is a skill whose `user-invocable` is `true` or absent
610
+ * (Claude Code picker semantics, #1515) in its own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
611
611
  * threads that flag alongside this linkage, and `classifyItem` treats the two as
612
612
  * equivalent evidence of "an operator can invoke this directly". Without that, 26
613
613
  * live slash commands would have been demoted the day their command file was
@@ -783,7 +783,7 @@ export function classifyItem({
783
783
  const strictRefs = staticRefs.strictRefs;
784
784
  const nonBoilerplateRefs = staticRefs.nonBoilerplateRefs;
785
785
  const invokedByCommands = linkage?.invokedByCommands ?? [];
786
- // #1370 fold: an explicit `user-invocable: true` in SKILL.md frontmatter is the
786
+ // #1370 fold: a picker-visible skill (`user-invocable` true or absent, #1515) is the
787
787
  // post-fold form of "an operator invokes this directly" — the same evidence a
788
788
  // command file used to carry. Default false so older callers/tests that do not
789
789
  // thread it behave exactly as before.
@@ -42,6 +42,7 @@ import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
42
42
  import { loadOwnerConfig } from '../owner-yaml.mjs';
43
43
  import { readJsonlFile } from '../io.mjs';
44
44
  import { readEventsWithRotations } from '../events.mjs';
45
+ import { resolveEventsLedgerRoot } from '../platform.mjs';
45
46
  import { readCanonicalSessions } from '../sessions-canonical.mjs';
46
47
  import { resolvePrivateConfigDir } from '../config/private-config-dir.mjs';
47
48
  import { readSessionProfile } from '../state-md.mjs';
@@ -382,7 +383,8 @@ export function deriveSessionFromEvents(metricsDir) {
382
383
  // once-per-flush cold read. Revisit if `max-size-mb` is raised past ~100,
383
384
  // if `max-backups` grows, or if the flush budget drops below ~1 s.
384
385
  const ledger = readEventsWithRotations(undefined, {
385
- filePath: path.join(metricsDir, 'events.jsonl'),
386
+ // #1514: the repo's one ledger (identity unless metricsDir is in a linked worktree).
387
+ filePath: path.join(resolveEventsLedgerRoot(metricsDir), 'events.jsonl'),
386
388
  });
387
389
  const events = ledger.events;
388
390
  // A missing archive is a GAP, never an empty window (#1407 AC-3). Carried
@@ -32,8 +32,8 @@
32
32
  */
33
33
 
34
34
  import { existsSync } from 'node:fs';
35
- import path from 'node:path';
36
35
 
36
+ import { eventsFilePath } from './events.mjs';
37
37
  import { readTailWindow } from './tail-window.mjs';
38
38
 
39
39
  /**
@@ -122,7 +122,7 @@ export function checkTelemetryFlushHealth({ repoRoot } = {}) {
122
122
  try {
123
123
  if (!repoRoot || typeof repoRoot !== 'string') return null;
124
124
 
125
- const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
125
+ const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
126
126
  if (!existsSync(file)) return null;
127
127
 
128
128
  const tail = readTail(file);
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-sibling-coverage.mjs — THE measurement behind the Test-Sibling Expansion
4
+ * figures (#970) in skills/wave-executor/references/wave-loop-scope-manifest.md.
5
+ *
6
+ * ## Why this file exists
7
+ *
8
+ * The expansion grants a GLOB (`tests/**\/{basename}*.test.mjs`) instead of a
9
+ * computed path, and the manifest justified that with three hand-measured counts
10
+ * (439 / 375 / 272 at `730ee9d`). Those numbers lived in prose, went stale, and a
11
+ * second contradicting copy appeared in a code comment (#1026.5, #1030 P3). Same
12
+ * cure as `tests-src-ratio.mjs`: the recipe is code, the `--json` envelope
13
+ * carries its own definition, and the docs point here instead of restating it.
14
+ *
15
+ * ## The recipe
16
+ *
17
+ * universe `git ls-files` (the index — tracked paths only)
18
+ * tests tracked files matching DEFAULT_TEST_PATH_PATTERNS (scope-gate)
19
+ * production tracked files matching a DEFAULT_TEST_SIBLING_RULES `source`
20
+ * that are NOT tests — defined by negation, so a new top-level
21
+ * directory is counted the moment it is committed
22
+ * glob production files where a sibling glob from `testSiblingsFor()`
23
+ * matches a test via `pathMatchesPattern()` — the exact matcher
24
+ * the scope gate grants with, reused rather than re-derived
25
+ * exact production files with a test under `tests/` whose basename is
26
+ * exactly `<stem>.test<ext>`
27
+ * mirror production files whose 1:1 mirror exists: `tests/` + the path
28
+ * with a leading `scripts/` dropped, `<ext>` → `.test<ext>`
29
+ *
30
+ * By construction mirror ⊆ exact ⊆ glob ⊆ production, so the counts must obey
31
+ * mirror ≤ exact ≤ glob ≤ total; a violation means a broken definition.
32
+ *
33
+ * Usage: test-sibling-coverage.mjs [<repo-root>] [--json]
34
+ * Exit: 0 measured · 2 tool error (not a git repo, bad argument)
35
+ */
36
+
37
+ import { execFileSync } from 'node:child_process';
38
+ import path from 'node:path';
39
+
40
+ import {
41
+ DEFAULT_TEST_PATH_PATTERNS,
42
+ DEFAULT_TEST_SIBLING_RULES,
43
+ pathMatchesPattern,
44
+ testSiblingsFor,
45
+ } from './scope-gate.mjs';
46
+ import { isMainModule } from './is-main-module.mjs';
47
+
48
+ /** Machine-readable schema tag for the --json envelope. */
49
+ export const SCHEMA = 'test-sibling-coverage/1';
50
+
51
+ const isTest = (p) => DEFAULT_TEST_PATH_PATTERNS.some((pat) => pathMatchesPattern(p, pat));
52
+ const isProduction = (p) =>
53
+ !isTest(p) && DEFAULT_TEST_SIBLING_RULES.some((r) => pathMatchesPattern(p, r.source));
54
+
55
+ /** `scripts/lib/x.mjs` → { stem: 'x', ext: '.mjs' } */
56
+ function stemOf(p) {
57
+ const ext = path.posix.extname(p);
58
+ return { stem: path.posix.basename(p, ext), ext };
59
+ }
60
+
61
+ /** The 1:1 mirror path the manifest's "naive mirror" figure counts. */
62
+ export function mirrorPathOf(p) {
63
+ const { ext } = stemOf(p);
64
+ const rel = p.startsWith('scripts/') ? p.slice('scripts/'.length) : p;
65
+ return `tests/${rel.slice(0, rel.length - ext.length)}.test${ext}`;
66
+ }
67
+
68
+ /**
69
+ * Measure the three coverage counts over a path list. Pure.
70
+ * @param {string[]} files — repo-relative POSIX paths
71
+ * @returns {{total: number, glob: number, exact: number, mirror: number, uncovered: string[]}}
72
+ */
73
+ export function measure(files) {
74
+ const tests = files.filter(isTest);
75
+ const testSet = new Set(tests);
76
+ const testBasenames = new Set(tests.filter((t) => t.startsWith('tests/')).map((t) => path.posix.basename(t)));
77
+ const production = files.filter(isProduction);
78
+ let glob = 0;
79
+ let exact = 0;
80
+ let mirror = 0;
81
+ const uncovered = [];
82
+ for (const p of production) {
83
+ const globs = testSiblingsFor([p]);
84
+ // Linear scan of every test per glob — 0.3 s wall for the whole CLI at 571
85
+ // production files (2026-10-03); revisit if the tracked test count passes ~5000.
86
+ if (globs.some((g) => tests.some((t) => pathMatchesPattern(t, g)))) glob += 1;
87
+ else uncovered.push(p);
88
+ const { stem, ext } = stemOf(p);
89
+ if (testBasenames.has(`${stem}.test${ext}`)) exact += 1;
90
+ if (testSet.has(mirrorPathOf(p))) mirror += 1;
91
+ }
92
+ return { total: production.length, glob, exact, mirror, uncovered };
93
+ }
94
+
95
+ /** The self-describing definition block shipped inside every --json envelope. */
96
+ export function definition() {
97
+ return {
98
+ source: 'git ls-files (tracked paths only)',
99
+ tests: `tracked paths matching scope-gate DEFAULT_TEST_PATH_PATTERNS: ${DEFAULT_TEST_PATH_PATTERNS.join(', ')}`,
100
+ total: `production files: tracked paths matching a sibling-rule source (${DEFAULT_TEST_SIBLING_RULES.map((r) => r.source).join(', ')}) that are not tests`,
101
+ glob: `production files where a scope-gate testSiblingsFor() glob (${DEFAULT_TEST_SIBLING_RULES.map((r) => r.sibling).join(', ')}) matches a test via pathMatchesPattern`,
102
+ exact: 'production files with a test under tests/ whose basename is exactly <stem>.test<ext>',
103
+ mirror: 'production files whose mirror tests/<path minus leading scripts/, <ext> -> .test<ext>> is tracked',
104
+ invariant: 'mirror <= exact <= glob <= total',
105
+ };
106
+ }
107
+
108
+ function git(root, args) {
109
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
110
+ }
111
+
112
+ const pct = (n, d) => (d === 0 ? null : Math.round((n / d) * 1000) / 10);
113
+
114
+ function main(argv) {
115
+ let json = false;
116
+ let root = process.cwd();
117
+ for (const a of argv) {
118
+ if (a === '--json') json = true;
119
+ else if (a.startsWith('--')) {
120
+ process.stderr.write(`Unknown argument '${a}'. Usage: test-sibling-coverage.mjs [<repo-root>] [--json]\n`);
121
+ return 2;
122
+ } else root = path.resolve(a);
123
+ }
124
+ let files;
125
+ let ref;
126
+ let dirty;
127
+ try {
128
+ files = git(root, ['ls-files']).split('\n').filter(Boolean);
129
+ ref = git(root, ['rev-parse', '--short', 'HEAD']).trim();
130
+ // The index IS the measured population: only a staged add/delete/rename
131
+ // makes (ref, counts) unreproducible. --no-optional-locks: no index refresh
132
+ // under a parallel session (PSA-007).
133
+ dirty = git(root, ['--no-optional-locks', 'diff', '--cached', '--name-only', '--diff-filter=ADR']).trim() !== '';
134
+ } catch (error) {
135
+ process.stderr.write(`ERROR: git failed in ${root}: ${error.message}\n`);
136
+ return 2;
137
+ }
138
+ const r = measure(files);
139
+ const envelope = {
140
+ schema: SCHEMA,
141
+ ref,
142
+ dirty,
143
+ measuredAt: new Date().toISOString().slice(0, 10),
144
+ total: r.total,
145
+ glob: r.glob,
146
+ exact: r.exact,
147
+ mirror: r.mirror,
148
+ percent: { glob: pct(r.glob, r.total), exact: pct(r.exact, r.total), mirror: pct(r.mirror, r.total) },
149
+ uncovered: r.uncovered,
150
+ definition: definition(),
151
+ };
152
+ if (json) {
153
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
154
+ } else {
155
+ const line = (k) => ` ${k.padEnd(7)} ${String(r[k]).padStart(5)} / ${r.total} (${envelope.percent[k]} %)`;
156
+ process.stdout.write(
157
+ `test-sibling coverage at ${ref}${dirty ? ' (index differs from HEAD)' : ''}, ${envelope.measuredAt}\n`
158
+ + `${line('glob')}\n${line('exact')}\n${line('mirror')}\n`
159
+ + ` uncovered by the glob: ${r.uncovered.length} (list: --json)\n`,
160
+ );
161
+ }
162
+ return 0;
163
+ }
164
+
165
+ if (isMainModule(import.meta.url)) {
166
+ process.exitCode = main(process.argv.slice(2));
167
+ }
@@ -15,11 +15,9 @@
15
15
  */
16
16
 
17
17
  import path from 'node:path';
18
- import { readEventsWithRotations } from '../events.mjs';
18
+ import { eventsFilePath, readEventsWithRotations } from '../events.mjs';
19
19
  import { isMainModule } from '../is-main-module.mjs';
20
20
 
21
- const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
22
-
23
21
  /** Every event this module reports on carries this prefix. */
24
22
  const TMUX_EVENT_PREFIX = 'tmux-layout.';
25
23
 
@@ -58,11 +56,11 @@ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
58
56
  * the session path. Revisit if this module gains a hot-path caller or if
59
57
  * `max-size-mb` is raised past ~100.
60
58
  *
61
- * @param {string} [eventsPath=.orchestrator/metrics/events.jsonl]
59
+ * @param {string} [eventsPath=eventsFilePath()] the repo's one ledger (#1514)
62
60
  * @returns {{events: Array<object>, complete: boolean|null, gaps: Array<object>,
63
61
  * notices: Array<object>}}
64
62
  */
65
- export function readTmuxEventsEnvelope(eventsPath = EVENTS_PATH) {
63
+ export function readTmuxEventsEnvelope(eventsPath = eventsFilePath()) {
66
64
  const { events, gaps, complete, notices } = readEventsWithRotations(undefined, {
67
65
  filePath: eventsPath,
68
66
  });
@@ -83,10 +81,10 @@ export function readTmuxEventsEnvelope(eventsPath = EVENTS_PATH) {
83
81
  * existing caller expects. Use the envelope when the completeness of the read
84
82
  * matters to the answer.
85
83
  *
86
- * @param {string} [eventsPath=.orchestrator/metrics/events.jsonl]
84
+ * @param {string} [eventsPath=eventsFilePath()] the repo's one ledger (#1514)
87
85
  * @returns {Array<object>} parsed event records (filtered to tmux-layout.* events)
88
86
  */
89
- export function readTmuxEvents(eventsPath = EVENTS_PATH) {
87
+ export function readTmuxEvents(eventsPath = eventsFilePath()) {
90
88
  return readTmuxEventsEnvelope(eventsPath).events;
91
89
  }
92
90
 
@@ -19,17 +19,15 @@ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
21
  import { findProjectRoot } from '../common.mjs';
22
+ import { eventsFilePath } from '../events.mjs';
22
23
  import { stampEventSchemaVersion, validateEventRecord } from '../events-schema.mjs';
23
24
 
24
- /**
25
- * Path FRAGMENT joined against a resolved repo root at write time — NOT a
26
- * relative path constant. A relative constant resolves against process.cwd(),
27
- * which is how ~8k test-emitted tmux events landed in the real ledger: the
28
- * suite spawns scripts/tmux-layout.mjs with cwd = repo root, so every
29
- * telemetry write went straight into production telemetry.
30
- * Same shape as scripts/lib/session-close-backfill.mjs § EVENTS_REL.
31
- */
32
- const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
25
+ // The ledger path is resolved against a repo root at write time — NEVER a
26
+ // relative path constant. A relative constant resolves against process.cwd(),
27
+ // which is how ~8k test-emitted tmux events landed in the real ledger: the
28
+ // suite spawns scripts/tmux-layout.mjs with cwd = repo root, so every
29
+ // telemetry write went straight into production telemetry. The path is
30
+ // `eventsFilePath(<root>)` (#1514: the repo's one ledger).
33
31
 
34
32
  /**
35
33
  * True when this process is a vitest run, or a child spawned by one
@@ -56,7 +54,7 @@ function isTestRunner() {
56
54
  export function emit(eventType, payload = {}, { repoRoot } = {}) {
57
55
  try {
58
56
  if (!repoRoot && isTestRunner()) return;
59
- const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
57
+ const eventsPath = eventsFilePath(repoRoot || findProjectRoot()); // #1514: the repo's one ledger
60
58
  const dir = path.dirname(eventsPath);
61
59
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
60
  // `stampEventSchemaVersion()` rather than an inline `schema_version:` —
@@ -6,11 +6,13 @@
6
6
  * Since the #1370 command→skill fold (2026-09-16) `commands/` holds only the two
7
7
  * names that cannot be a skill (`session`, `templates-ack` — see
8
8
  * `tests/commands/headless-bare-command-availability.test.mjs` for the measured
9
- * mechanism). Every other slash command IS a skill, marked by an EXPLICIT
10
- * `user-invocable: true` in its SKILL.md frontmatter. Consumers that used to
9
+ * mechanism). Every other slash command IS a skill. Claude Code lists a skill in
10
+ * the `/` picker unless its frontmatter says `user-invocable: false` — a MISSING
11
+ * key counts as `true` (#1515, measured 2026-10-03: 37 picker entries against a
12
+ * documented 27, because 10 skills carried no key). Consumers that used to
11
13
  * count or link `commands/*.md` therefore need the union:
12
14
  *
13
- * commands/*.md ∪ skills with explicit `user-invocable: true`
15
+ * commands/*.md ∪ skills whose `user-invocable` is absent or `true`
14
16
  *
15
17
  * This module exists so that union has one implementation rather than one per
16
18
  * consumer (site-numbers tile, sunset walker linkage, the guard tests). It is
@@ -29,6 +31,7 @@
29
31
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
30
32
  import path from 'node:path';
31
33
  import { parseAgentFrontmatter } from './agent-frontmatter.mjs';
34
+ import { isHiddenSourceName } from './plugin-surface.mjs';
32
35
 
33
36
  /**
34
37
  * YAML 1.1 boolean lookalikes that YAML 1.2 / js-yaml's CORE_SCHEMA (what
@@ -85,9 +88,10 @@ function normaliseScalar(text) {
85
88
  * route through this function; four private re-implementations disagreeing on a
86
89
  * quoted value is the defect this replaces.
87
90
  *
88
- * Explicit-true only. `undefined` (flag absent) is NOT user-invocable: the whole
89
- * point of the marker is that it is written down, so a skill that forgot it is a
90
- * defect to surface, not a default to guess.
91
+ * Judges a PRESENT value only: `undefined` returns `false`. Whether an ABSENT
92
+ * `user-invocable` key makes a skill a slash command is a question about the
93
+ * whole frontmatter, answered by {@link isUserInvocableSkill} (absent = true,
94
+ * as Claude Code reads it — #1515).
91
95
  *
92
96
  * Accepted: the boolean `true`, and a string that normalises to `true`
93
97
  * case-insensitively — so `"true"`, `'true'`, `True`, `true # note` and
@@ -155,7 +159,30 @@ export function parseSkillFrontmatter(content) {
155
159
  }
156
160
 
157
161
  /**
158
- * Names of skills carrying an explicit `user-invocable: true`, sorted.
162
+ * Claude Code's picker semantics for one parsed SKILL.md frontmatter: the key
163
+ * ABSENT means invocable; a present key goes through
164
+ * {@link isUserInvocableValue}. Kept apart from that predicate on purpose —
165
+ * the sibling marker `disable-model-invocation` shares the value normaliser
166
+ * but defaults to `false` when absent.
167
+ *
168
+ * Every generator (Cursor, Pi, Codex, agents surface) and every counter routes
169
+ * the skill-level question through here, so an absent key cannot be a slash
170
+ * command for the census and a non-command for the adapters (#1515 MED-2).
171
+ * `check-skills` additionally requires the key to be written down, so the
172
+ * absent branch is the harness-parity fallback, not the expected path.
173
+ *
174
+ * @param {Record<string, unknown>|null|undefined} frontmatter
175
+ * @param {string} [file] path named in a lookalike WARN
176
+ * @returns {boolean} `false` for a missing/non-object frontmatter (fail-closed)
177
+ */
178
+ export function isUserInvocableSkill(frontmatter, file) {
179
+ if (!frontmatter || typeof frontmatter !== 'object') return false;
180
+ if (!Object.hasOwn(frontmatter, 'user-invocable')) return true;
181
+ return isUserInvocableValue(frontmatter['user-invocable'], file);
182
+ }
183
+
184
+ /**
185
+ * Names of skills that appear in the `/` picker (key absent or `true`), sorted.
159
186
  *
160
187
  * @param {string} repoRoot plugin root (the directory holding `skills/`)
161
188
  * @returns {string[]}
@@ -165,7 +192,7 @@ export function userInvocableSkills(repoRoot) {
165
192
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
166
193
  const names = [];
167
194
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
168
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
195
+ if (!entry.isDirectory() || isHiddenSourceName(entry.name)) continue;
169
196
  const file = path.join(dir, entry.name, 'SKILL.md');
170
197
  if (!existsSync(file) || !statSync(file).isFile()) continue;
171
198
  let fm;
@@ -174,7 +201,7 @@ export function userInvocableSkills(repoRoot) {
174
201
  } catch {
175
202
  continue;
176
203
  }
177
- if (fm && isUserInvocableValue(fm['user-invocable'], file)) names.push(entry.name);
204
+ if (fm && isUserInvocableSkill(fm, file)) names.push(entry.name);
178
205
  }
179
206
  return names.sort();
180
207
  }
@@ -184,7 +211,7 @@ export function commandFileNames(repoRoot) {
184
211
  const dir = path.join(repoRoot, 'commands');
185
212
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
186
213
  return readdirSync(dir)
187
- .filter((f) => f.endsWith('.md') && !f.startsWith('.'))
214
+ .filter((f) => f.endsWith('.md') && !isHiddenSourceName(f))
188
215
  .map((f) => f.replace(/\.md$/, ''))
189
216
  .sort();
190
217
  }