session-orchestrator 5.7.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 (190) 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 +49 -0
  42. package/README.md +10 -9
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/baseline.md +4 -3
  45. package/docs/codex-setup.md +6 -4
  46. package/docs/components.md +5 -4
  47. package/docs/events-schema.md +4 -3
  48. package/docs/github-mirror-protection.md +3 -2
  49. package/docs/install.md +39 -3
  50. package/docs/pi-setup.md +1 -1
  51. package/docs/rule-authoring.md +2 -2
  52. package/docs/session-config-reference.md +7 -5
  53. package/docs/session-config-template.md +3 -2
  54. package/evals/README.md +60 -0
  55. package/evals/instruction-ablation/README.md +147 -0
  56. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  57. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  58. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  59. package/evals/instruction-ablation/run.mjs +336 -0
  60. package/evals/psa-003-foreign-work/case.yaml +16 -0
  61. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  62. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  63. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  64. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  65. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  66. package/evals/psa-003-foreign-work/prompt.md +6 -0
  67. package/hooks/_lib/hook-import-set.json +10 -2
  68. package/hooks/hooks-codex.json +1 -1
  69. package/hooks/hooks.json +1 -1
  70. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  71. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  72. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  73. package/monitors/monitors.json +1 -1
  74. package/package.json +3 -1
  75. package/pi/prompts/architecture.md +11 -0
  76. package/pi/prompts/eval.md +1 -1
  77. package/pi/prompts/tmux-layout.md +11 -0
  78. package/pi/prompts/write-executable-plan.md +11 -0
  79. package/scripts/backfill-abandoned-sessions.mjs +2 -2
  80. package/scripts/backfill-sessions.mjs +9 -7
  81. package/scripts/codex-install.mjs +108 -23
  82. package/scripts/emit-session.mjs +15 -2
  83. package/scripts/fleet-auflagen.mjs +54 -0
  84. package/scripts/generate-agents-skills.mjs +9 -16
  85. package/scripts/generate-codex-skills.mjs +8 -6
  86. package/scripts/generate-cursor-adapter.mjs +9 -74
  87. package/scripts/generate-pi-prompts.mjs +8 -74
  88. package/scripts/github-protection-audit.mjs +8 -0
  89. package/scripts/lib/auto-dialectic.mjs +10 -9
  90. package/scripts/lib/auto-dream.mjs +8 -10
  91. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  92. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  93. package/scripts/lib/config/drift-check.mjs +1 -1
  94. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  95. package/scripts/lib/config.mjs +5 -1
  96. package/scripts/lib/convergence-monitor.mjs +5 -3
  97. package/scripts/lib/ecosystem-health.mjs +5 -3
  98. package/scripts/lib/eval/engine.mjs +61 -27
  99. package/scripts/lib/eval/judge.mjs +14 -8
  100. package/scripts/lib/eval/session-resolve.mjs +7 -2
  101. package/scripts/lib/events-retention-banner.mjs +5 -2
  102. package/scripts/lib/events.mjs +19 -5
  103. package/scripts/lib/fleet-protocol.mjs +98 -1
  104. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  105. package/scripts/lib/io.mjs +172 -35
  106. package/scripts/lib/learnings/candidates.mjs +7 -28
  107. package/scripts/lib/learnings/io.mjs +7 -2
  108. package/scripts/lib/learnings/judgment.mjs +5 -8
  109. package/scripts/lib/learnings/select.mjs +3 -3
  110. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  111. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  112. package/scripts/lib/memory-proposals/store.mjs +3 -5
  113. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  114. package/scripts/lib/native-command.mjs +48 -0
  115. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  116. package/scripts/lib/peer-cards/writer.mjs +6 -18
  117. package/scripts/lib/platform.mjs +113 -4
  118. package/scripts/lib/plugin-package-stage.mjs +169 -0
  119. package/scripts/lib/plugin-surface.mjs +121 -0
  120. package/scripts/lib/quality-gate.mjs +30 -25
  121. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  122. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  123. package/scripts/lib/reconcile/writer.mjs +11 -17
  124. package/scripts/lib/rule-loader.mjs +32 -6
  125. package/scripts/lib/scope-echo.mjs +12 -2
  126. package/scripts/lib/scope-gate.mjs +2 -1
  127. package/scripts/lib/session-close-backfill.mjs +3 -4
  128. package/scripts/lib/session-id.mjs +2 -1
  129. package/scripts/lib/session-record-repair.mjs +9 -13
  130. package/scripts/lib/session-schema/constants.mjs +20 -5
  131. package/scripts/lib/session-start-probes.mjs +2 -2
  132. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  133. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  134. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  135. package/scripts/lib/slopcheck.mjs +9 -7
  136. package/scripts/lib/sunset/walker.mjs +3 -3
  137. package/scripts/lib/telemetry/sync.mjs +3 -1
  138. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  139. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  140. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  141. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  142. package/scripts/lib/user-invocable-skills.mjs +37 -10
  143. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  144. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  145. package/scripts/lib/validate/check-skills.mjs +12 -2
  146. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  147. package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
  148. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  149. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  150. package/scripts/lib/vault-archive.mjs +2 -4
  151. package/scripts/lib/vault-mirror/process.mjs +207 -26
  152. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  153. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  154. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  155. package/scripts/lib/worktree/meta.mjs +7 -3
  156. package/scripts/mcp-server.mjs +158 -0
  157. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  158. package/scripts/migrate-vault-paths.mjs +7 -4
  159. package/scripts/print-learnings-index.mjs +2 -1
  160. package/scripts/relocate-vault-corpus.mjs +5 -3
  161. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  162. package/scripts/self-update.mjs +179 -39
  163. package/scripts/site-numbers.mjs +4 -3
  164. package/scripts/sweep-expired-learnings.mjs +6 -4
  165. package/scripts/sync-vault-schema.mjs +3 -7
  166. package/scripts/windows-setup.mjs +45 -0
  167. package/skills/_shared/fleet-protocol.md +8 -0
  168. package/skills/architecture/SKILL.md +1 -0
  169. package/skills/bootstrap/_shared-template.md +5 -2
  170. package/skills/claude-md-drift-check/SKILL.md +4 -1
  171. package/skills/claude-md-drift-check/checker.mjs +83 -10
  172. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  173. package/skills/eval/SKILL.md +12 -13
  174. package/skills/eval/rubric-v3.md +521 -0
  175. package/skills/frontmatter-guard/SKILL.md +1 -0
  176. package/skills/gitlab-portfolio/SKILL.md +1 -0
  177. package/skills/hook-development/SKILL.md +1 -0
  178. package/skills/mcp-builder/SKILL.md +1 -0
  179. package/skills/repo-audit/SKILL.md +2 -2
  180. package/skills/session-end/SKILL.md +32 -1
  181. package/skills/session-end/vault-operations.md +12 -2
  182. package/skills/session-start/SKILL.md +2 -2
  183. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  184. package/skills/session-start/references/operations-contract.md +4 -1
  185. package/skills/tmux-layout/SKILL.md +1 -0
  186. package/skills/vault-mirror/SKILL.md +1 -0
  187. package/skills/vault-sync/SKILL.md +6 -6
  188. package/skills/vault-sync/validator.sh +29 -10
  189. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  190. package/skills/write-executable-plan/SKILL.md +1 -0
@@ -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
  }
@@ -32,6 +32,7 @@
32
32
  import fs from 'node:fs';
33
33
  import path from 'node:path';
34
34
 
35
+ import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
35
36
  import { findingsPath, runRecordPath } from './paths.mjs';
36
37
  import { makeRunRecord } from './schema.mjs';
37
38
 
@@ -290,9 +291,10 @@ export function updateRunRecordCompare(repoRoot, runId, compare) {
290
291
  out.push(raw);
291
292
  }
292
293
 
293
- const tmp = `${ledger}.${process.pid}.tmp`;
294
- fs.writeFileSync(tmp, out.length > 0 ? `${out.join('\n')}\n` : '', 'utf8');
295
- fs.renameSync(tmp, ledger);
294
+ const res = atomicWriteWithBackup(ledger, out.length > 0 ? `${out.join('\n')}\n` : '', {
295
+ tmpPrefix: `.${path.basename(ledger)}.tmp`,
296
+ });
297
+ if (!res.ok) throw envelopeToError(res);
296
298
  return { updated, skippedLines };
297
299
  }
298
300
 
@@ -187,6 +187,7 @@ export function collectMarkdownFiles(pluginRoot) {
187
187
  const output = execFileSync('git', ['ls-files', '-z', '--', ...SCAN_DIRS], {
188
188
  cwd: pluginRoot,
189
189
  encoding: 'utf8',
190
+ maxBuffer: 64 * 1024 * 1024,
190
191
  stdio: ['ignore', 'pipe', 'ignore'],
191
192
  env,
192
193
  });
@@ -93,7 +93,7 @@ function collectSkillFiles(skillsDir) {
93
93
  *
94
94
  * Rules: R1 frontmatter block present · R8 block parses as a YAML mapping ·
95
95
  * R2 `name` present · R3 `name` is kebab-case · R5 `name` === directory name ·
96
- * R6 `description` present and non-empty.
96
+ * R6 `description` present and non-empty · R9 `user-invocable` stated explicitly.
97
97
  *
98
98
  * @param {{ dirName: string, relPath: string, absPath: string }} skill
99
99
  * @returns {boolean} true when every rule holds
@@ -148,6 +148,16 @@ function validateSkillFrontmatter(skill) {
148
148
  fail(`${skill.relPath}: missing or empty required frontmatter field: description`);
149
149
  }
150
150
 
151
+ // R9 — `user-invocable` written down. Claude Code lists a skill WITHOUT the
152
+ // key in the `/` picker (#1515: 37 entries against a documented 27, from 10
153
+ // keyless skills). The census and the adapter generators now follow that
154
+ // reading (`isUserInvocableSkill`), but a forgotten key is still an
155
+ // unreviewed picker entry — so the decision must be explicit: `true` for an
156
+ // operator command, `false` for a helper the model dispatches.
157
+ if (!Object.hasOwn(frontmatter, 'user-invocable')) {
158
+ fail(`${skill.relPath}: missing required frontmatter field: user-invocable (Claude Code treats an absent key as true and lists the skill in the / picker — write \`user-invocable: true\` for an operator command or \`user-invocable: false\` for a helper)`);
159
+ }
160
+
151
161
  return failed === before;
152
162
  }
153
163
 
@@ -180,7 +190,7 @@ if (!existsSync(skillsDir)) {
180
190
  }
181
191
 
182
192
  if (valid === skills.length) {
183
- pass(`all ${skills.length} ${SKILL_FILE} frontmatter blocks parse as YAML and carry a kebab-case name matching their directory plus a non-empty description`);
193
+ pass(`all ${skills.length} ${SKILL_FILE} frontmatter blocks parse as YAML and carry a kebab-case name matching their directory, a non-empty description and an explicit user-invocable`);
184
194
  }
185
195
  }
186
196
  }
@@ -154,10 +154,10 @@ const MAGIC_COMMENT_SCAN_LINES = 5;
154
154
  function getTrackedTestFiles() {
155
155
  let output;
156
156
  try {
157
- output = execFileSync('git', ['ls-files'], { cwd: pluginRoot, encoding: 'utf8' });
157
+ output = execFileSync('git', ['ls-files'], { cwd: pluginRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
158
158
  } catch {
159
- // Not a git repo or git unavailable — return empty (caller treats as pass)
160
- return [];
159
+ console.error('Error: unable to enumerate tracked test files with git ls-files');
160
+ process.exit(2);
161
161
  }
162
162
  return output
163
163
  .split('\n')
@@ -461,9 +461,10 @@ const LOCAL_IMPORT = new RegExp(
461
461
  function trackedTestFiles() {
462
462
  let out;
463
463
  try {
464
- out = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' });
464
+ out = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
465
465
  } catch {
466
- return []; // not a git repo — nothing to scan
466
+ console.error('Error: unable to enumerate tracked test files with git ls-files');
467
+ process.exit(2);
467
468
  }
468
469
  return out
469
470
  .split('\n')
@@ -114,7 +114,7 @@ function walkDir(root, dir, acc = []) {
114
114
  export function getTrackedFiles(root) {
115
115
  let files;
116
116
  try {
117
- const output = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' });
117
+ const output = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
118
118
  files = output.split('\n').filter(Boolean).map((f) => join(root, f));
119
119
  } catch {
120
120
  files = walkDir(root, root);
@@ -451,7 +451,7 @@ const ALLOWLIST = Object.freeze({
451
451
  'auto-skill-dispatch':
452
452
  'prose-only consumer — skills/using-orchestrator/SKILL.md + skills/_shared/bootstrap-gate.md read this key as an LLM instruction; there is no .mjs gate by design',
453
453
  'auto-commit-per-wave':
454
- 'prose-only consumer — skills/wave-executor/wave-loop.md gates the per-wave commit step on this key; the commit itself is a coordinator action, not a script',
454
+ 'planned no-op (#1219) — only skills/wave-executor/references/wave-loop-review.md names this key, as contract prose; no per-wave commit step runs on it yet (scripts/lib/auto-commit.mjs does not exist)',
455
455
  'instruction-budget':
456
456
  'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
457
457
  });
@@ -16,10 +16,8 @@
16
16
  *
17
17
  * ── Frontmatter schema (mirror, not import) ─────────────────────────────────
18
18
  * The canonical vault frontmatter schema is the GENERATED Zod block in
19
- * skills/vault-sync/validator.mjs (Z.104-134). That schema is NOT exported, and
20
- * `zod` is not a dependency of scripts/ (it lives only as a skill-local shim in
21
- * skills/vault-sync/node_modules/). So the field constraints below are a
22
- * hand-rolled MIRROR of that schema. If the canonical schema changes, update the
19
+ * skills/vault-sync/validator.mjs (Z.104-134). That schema is NOT exported, so
20
+ * the field constraints below are a hand-rolled MIRROR of that schema. If the canonical schema changes, update the
23
21
  * constants here to match. The mirror is proven equivalent by an integration
24
22
  * test that runs the REAL validator subprocess over generated output
25
23
  * (tests/lib/vault-archive.test.mjs § "REAL validator").