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
@@ -22,9 +22,9 @@
22
22
  * Part of #503 (Wave 2; sibling to schema.mjs / reader.mjs / merger.mjs).
23
23
  */
24
24
 
25
- import { writeFile, rename, mkdir, unlink } from 'node:fs/promises';
25
+ import { mkdir } from 'node:fs/promises';
26
26
  import { join } from 'node:path';
27
- import { randomUUID } from 'node:crypto';
27
+ import { atomicWriteText, envelopeToError } from '../io.mjs';
28
28
 
29
29
  import { validatePeerCardFrontmatter } from './schema.mjs';
30
30
 
@@ -118,21 +118,9 @@ export async function writePeerCard(repoRoot, target, card) {
118
118
 
119
119
  const bodyStr = typeof card.body === 'string' ? card.body : '';
120
120
  const content = `${serializeFrontmatter(fm)}\n\n${bodyStr.trimStart()}`;
121
- const tmpPath = `${finalPath}.${randomUUID().slice(0, 8)}.tmp`;
122
-
123
- try {
124
- await writeFile(tmpPath, content, 'utf8');
125
- await rename(tmpPath, finalPath);
126
- return { ok: true, path: finalPath };
127
- } catch (err) {
128
- // Best-effort cleanup of orphaned tmp file. Swallow secondary failures —
129
- // the primary error is what the caller needs to see.
130
- try {
131
- await unlink(tmpPath);
132
- } catch {
133
- /* tmp file may not exist if writeFile failed before creating it */
134
- }
135
- throw err;
136
- }
121
+ // The helper unlinks its own tmp on failure; re-raise with the same code.
122
+ const res = await atomicWriteText(finalPath, content, { tmpPrefix: `.${filename}.tmp` });
123
+ if (!res.ok) throw envelopeToError(res);
124
+ return { ok: true, path: finalPath };
137
125
  }
138
126
 
@@ -13,7 +13,7 @@
13
13
  * NOTHING in this module touches the filesystem at import time.
14
14
  */
15
15
 
16
- import { existsSync, realpathSync, statSync } from 'node:fs';
16
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
@@ -354,9 +354,14 @@ function _isStrictlyInside(child, parent) {
354
354
 
355
355
  /**
356
356
  * The SESSION ROOT (#1492) — the working copy whose control files belong to the
357
- * session a hook fires in: `wave-scope.json`, the `.orchestrator/` ledgers, the
358
- * event records. One resolver for every hook that reads them (enforce-scope,
359
- * enforce-commands, post-bash-write-verify, pre-task-scope-disjoint).
357
+ * session a hook fires in: `wave-scope.json`, the `filescopes/` manifests, the
358
+ * `.orchestrator/` dispatch ledger. One resolver for every hook that reads them
359
+ * (enforce-scope, enforce-commands, post-bash-write-verify,
360
+ * pre-task-scope-disjoint). The event records are written via
361
+ * `eventsFilePath(<session root>)` (`scripts/lib/events.mjs`), which maps a
362
+ * linked worktree to its main checkout — ONE ledger per repo (owner decision on
363
+ * #1514 point 1), so a worktree session's events stay readable after the
364
+ * worktree is gone.
360
365
  *
361
366
  * NOT the launch dir whenever the session works elsewhere: `$CLAUDE_PROJECT_DIR`
362
367
  * stays on the dir the session was LAUNCHED in after it enters a worktree
@@ -453,6 +458,109 @@ export function resolveSessionRoot(cwd, toplevel = '') {
453
458
  return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
454
459
  }
455
460
 
461
+ // ---------------------------------------------------------------------------
462
+ // resolveEventsLedgerRoot (#1514 point 1)
463
+ // ---------------------------------------------------------------------------
464
+
465
+ /** Repo root holding a `.git` entry → its main checkout ('' = none). Per process. */
466
+ const _mainCheckoutMemo = new Map();
467
+
468
+ /**
469
+ * The MAIN checkout of the linked git worktree rooted at `root` (whose `.git`
470
+ * is a FILE), or `''` when `root` is no linked worktree of a non-bare repo or
471
+ * its git metadata cannot be read.
472
+ *
473
+ * SPAWN-FREE, so it is safe on the hook hot path (every default `emitEvent()`):
474
+ * git's own on-disk layout answers it. A linked worktree's `.git` file reads
475
+ * `gitdir: <X>` (X = `<common>/worktrees/<name>`), and `<X>/commondir` names the
476
+ * common dir relative to X. Only a common dir named `.git` counts — its parent
477
+ * is the main checkout. A submodule (`gitdir:` into `.git/modules/<name>`, no
478
+ * `commondir` file) and a bare repo's worktree (common dir `repo.git`) fall
479
+ * through to `''`. Never throws.
480
+ *
481
+ * @param {string} root a directory holding a `.git` entry
482
+ * @returns {string}
483
+ */
484
+ function _mainCheckoutOfLinkedWorktree(root) {
485
+ if (_mainCheckoutMemo.has(root)) return _mainCheckoutMemo.get(root);
486
+ let main = '';
487
+ try {
488
+ const dotGit = path.join(root, '.git');
489
+ if (statSync(dotGit).isFile()) {
490
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGit, 'utf8'));
491
+ if (match) {
492
+ const gitDir = path.resolve(root, match[1]);
493
+ const commonDir = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
494
+ if (path.basename(commonDir) === '.git' && commonDir !== gitDir) main = path.dirname(commonDir);
495
+ }
496
+ }
497
+ } catch {
498
+ main = ''; // unreadable metadata, no `commondir` (a submodule) — the caller falls back
499
+ }
500
+ _mainCheckoutMemo.set(root, main);
501
+ return main;
502
+ }
503
+
504
+ /**
505
+ * `dir` mapped from a linked worktree to the SAME relative spot in its main
506
+ * checkout (a launch in a worktree's subdirectory keeps its subdirectory), or
507
+ * `''` when `dir` sits in no linked worktree.
508
+ *
509
+ * @param {string} dir absolute
510
+ * @returns {string}
511
+ */
512
+ function _inMainCheckout(dir) {
513
+ const ancestor = dotGitAncestor(dir);
514
+ if (!ancestor) return '';
515
+ const main = _mainCheckoutOfLinkedWorktree(ancestor);
516
+ return main ? path.join(main, path.relative(ancestor, dir)) : '';
517
+ }
518
+
519
+ /**
520
+ * The root of the ONE events ledger (`<root>/.orchestrator/metrics/events.jsonl`)
521
+ * — owner decision on #1514 point 1: "Event-Ledger immer im Haupt-Checkout".
522
+ *
523
+ * Precedence:
524
+ * 1. the launch dir from env ({@link launchDirFromEnv}, `path.resolve`d) —
525
+ * mapped to the main checkout when it IS a linked worktree. A session
526
+ * LAUNCHED in a worktree (headless, offload, Codex) hands its hooks
527
+ * `$CLAUDE_PROJECT_DIR` = the worktree; without the mapping its hooks wrote
528
+ * the worktree's ledger while its scripts (rung 2) wrote the main one;
529
+ * 2. when `cwd` sits in a LINKED git worktree, that repo's main checkout.
530
+ * Measured 2026-10-03: a Bash-tool command does NOT get
531
+ * `$CLAUDE_PROJECT_DIR`, and after `EnterWorktree` its cwd is the
532
+ * worktree — without this rung a coordinator-run script (`scope-echo
533
+ * --verify`, `materialize-wave-scope`) wrote and read the worktree's
534
+ * ledger while the hooks wrote the launch dir's;
535
+ * 3. {@link getProjectDir} — the pre-#1514 answer, unchanged for a plain
536
+ * repo, a bare repo's worktree, a submodule and a non-git directory.
537
+ *
538
+ * Spawn-free (see {@link _mainCheckoutOfLinkedWorktree}). Deliberately NOT
539
+ * folded into `resolveProjectDir()`: every other consumer of the project dir
540
+ * (config, STATE.md, manifests) keeps its answer.
541
+ *
542
+ * `repoRoot` given: the same mapping applied to that root — for a caller that
543
+ * holds a repo root and wants THE ledger of that repo.
544
+ *
545
+ * @param {string} [repoRoot] an explicit root to map (skips the env/cwd rungs)
546
+ * @param {string} [cwd=process.cwd()]
547
+ * @returns {string} Absolute path
548
+ */
549
+ export function resolveEventsLedgerRoot(repoRoot, cwd = process.cwd()) {
550
+ if (typeof repoRoot === 'string' && repoRoot.trim() !== '') {
551
+ const root = path.resolve(repoRoot);
552
+ return _inMainCheckout(root) || root;
553
+ }
554
+ const launchRaw = launchDirFromEnv();
555
+ if (launchRaw !== '') {
556
+ const launch = path.resolve(launchRaw);
557
+ return _inMainCheckout(launch) || launch;
558
+ }
559
+ const ancestor = dotGitAncestor(path.resolve(cwd));
560
+ const main = ancestor ? _mainCheckoutOfLinkedWorktree(ancestor) : '';
561
+ return main || getProjectDir();
562
+ }
563
+
456
564
  // ---------------------------------------------------------------------------
457
565
  // resolveStateDir
458
566
  // ---------------------------------------------------------------------------
@@ -595,6 +703,7 @@ export function _resetPlatformCache() {
595
703
  _cache.projectDir = undefined;
596
704
  _cache.stateDir = undefined;
597
705
  _cache.configFile = undefined;
706
+ _mainCheckoutMemo.clear();
598
707
  }
599
708
 
600
709
  // The deprecated `export let SO_PLATFORM / SO_PLUGIN_ROOT / SO_PROJECT_DIR /
@@ -0,0 +1,169 @@
1
+ /**
2
+ * plugin-package-stage.mjs — the host-local packed copy of this package that
3
+ * every harness marketplace reads instead of the working checkout.
4
+ *
5
+ * WHY: a marketplace registered on the clone copies ALL of it into the
6
+ * harness's plugin cache. Measured 2026-10-03: Claude Code cached 7557 files
7
+ * instead of 1164, `.env.local` (NPM_TOKEN) and `.orchestrator/` included
8
+ * (#1515); the Codex cache on the same host held 15742 files, `.env.local`,
9
+ * `.orchestrator/` and `tests/` included (#1518). `npm pack` carries only the
10
+ * package.json `files` allowlist, so the staged copy cannot leak the rest.
11
+ *
12
+ * Shared by scripts/self-update.mjs (Claude Code) and scripts/codex-install.mjs
13
+ * (Codex): one stage dir, one staging routine.
14
+ */
15
+
16
+ import {
17
+ copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync,
18
+ } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { spawnNativeSync } from './native-command.mjs';
22
+
23
+ const COMMAND_TIMEOUT_MS = 300_000;
24
+ const WORK_PREFIX = '.stage-';
25
+ // A work dir older than this is the leftover of a killed run (the finally never
26
+ // ran). Ceiling: a concurrent stage that itself runs longer than 60 min would be
27
+ // swept mid-run — npm ci is the slow step at ~minutes; revisit if that changes.
28
+ const STALE_WORK_MS = 60 * 60 * 1000;
29
+
30
+ /**
31
+ * Remove `.stage-*` work dirs in `parent` older than STALE_WORK_MS. Only real
32
+ * directories carrying this helper's exact prefix; a symlink is never followed
33
+ * or removed. Best effort: a sweep failure never blocks staging.
34
+ * @param {string} parent
35
+ * @param {number} [now]
36
+ */
37
+ export function sweepStaleWorkDirs(parent, now = Date.now()) {
38
+ let entries;
39
+ try { entries = readdirSync(parent, { withFileTypes: true }); } catch { return; }
40
+ for (const entry of entries) {
41
+ if (!entry.name.startsWith(WORK_PREFIX) || !entry.isDirectory()) continue;
42
+ const dir = path.join(parent, entry.name);
43
+ try {
44
+ const st = lstatSync(dir);
45
+ if (st.isDirectory() && !st.isSymbolicLink() && now - st.mtimeMs > STALE_WORK_MS) {
46
+ rmSync(dir, { recursive: true, force: true });
47
+ }
48
+ } catch { /* best effort */ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * `<XDG_CACHE_HOME or ~/.cache>/session-orchestrator/plugin-package`. The XDG
54
+ * spec says a relative XDG_CACHE_HOME is invalid and must be ignored.
55
+ * @param {NodeJS.ProcessEnv} [env]
56
+ * @param {string} [home]
57
+ * @returns {string}
58
+ */
59
+ export function resolveStageDir(env = process.env, home = os.homedir()) {
60
+ const xdg = (env.XDG_CACHE_HOME || '').trim();
61
+ return path.join(path.isAbsolute(xdg) ? xdg : path.join(home, '.cache'), 'session-orchestrator', 'plugin-package');
62
+ }
63
+
64
+ /**
65
+ * Tarball filename from `npm pack --json`. npm <= 11 emits `[ { filename } ]`,
66
+ * npm >= 12.0.2 `{ "<name>": { filename } }` (same split pack-policy-floor.test
67
+ * documents for `files`).
68
+ * @param {unknown} packJson
69
+ * @returns {string | null}
70
+ */
71
+ export function packedFilename(packJson) {
72
+ const entry = Array.isArray(packJson) ? packJson[0] : Object.values(packJson ?? {})[0];
73
+ return typeof entry?.filename === 'string' && entry.filename !== '' ? entry.filename : null;
74
+ }
75
+
76
+ /**
77
+ * Minimal command runner with the shape stagePackage expects.
78
+ * @returns {{ok: true, stdout: string} | {ok: false, detail: string}}
79
+ */
80
+ export function spawnRun(cmd, args, { cwd, env } = {}) {
81
+ const shown = `${cmd} ${args.join(' ')}`;
82
+ const r = spawnNativeSync(cmd, args, { cwd, env, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
83
+ if (r.error) return { ok: false, detail: `${shown}: ${r.error.message}` };
84
+ if (r.status !== 0) {
85
+ return { ok: false, detail: `${shown} exited ${r.status}: ${(r.stderr || r.stdout || '').trim().slice(-400)}` };
86
+ }
87
+ return { ok: true, stdout: r.stdout };
88
+ }
89
+
90
+ /**
91
+ * Replace `stageDir` with the extracted `npm pack` of `soRoot`. The work dir
92
+ * sits beside `stageDir` so every rename stays on one filesystem, and it is
93
+ * removed on every path out — the previous stage is only swapped out once the
94
+ * new one is complete and valid, and is put back if the swap itself fails.
95
+ *
96
+ * @param {{soRoot: string, stageDir: string, run?: Function, log?: (line: string) => void,
97
+ * dryRun?: boolean, installDeps?: boolean,
98
+ * validate?: (dir: string) => ({ok: true} | {ok: false, detail: string})}} options
99
+ * `installDeps` runs `npm ci --omit=dev --ignore-scripts` in the new copy
100
+ * before the swap, for a harness that copies the marketplace as-is (Codex).
101
+ * `validate` vets the new copy before the swap; a failure keeps the old stage.
102
+ * @returns {{ok: true} | {ok: false, detail: string}}
103
+ */
104
+ export function stagePackage({
105
+ soRoot, stageDir, run = spawnRun, log = () => {}, dryRun = false, installDeps = false, validate,
106
+ }) {
107
+ log(`packing ${soRoot} into ${stageDir} (package files only)`);
108
+ if (dryRun) {
109
+ run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', '<work>']);
110
+ run('tar', ['-xzf', '<work>/<tarball>', '-C', '<work>']);
111
+ if (installDeps) run('npm', ['ci', '--omit=dev', '--ignore-scripts'], { cwd: '<work>/package' });
112
+ return { ok: true };
113
+ }
114
+ let work;
115
+ try {
116
+ mkdirSync(path.dirname(stageDir), { recursive: true });
117
+ sweepStaleWorkDirs(path.dirname(stageDir));
118
+ work = mkdtempSync(path.join(path.dirname(stageDir), WORK_PREFIX));
119
+ // An inherited silent loglevel suppresses the JSON parsed below.
120
+ const env = { ...process.env };
121
+ delete env.npm_config_loglevel;
122
+ delete env.NPM_CONFIG_LOGLEVEL;
123
+ // --ignore-scripts explicitly: never rely on the clone's .npmrc for prepack.
124
+ const pack = run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], { cwd: soRoot, env });
125
+ if (!pack.ok) return { ok: false, detail: pack.detail };
126
+ let filename;
127
+ try { filename = packedFilename(JSON.parse(pack.stdout)); } catch { filename = null; }
128
+ if (!filename) return { ok: false, detail: 'npm pack --json reported no tarball filename' };
129
+ const untar = run('tar', ['-xzf', path.join(work, filename), '-C', work]);
130
+ if (!untar.ok) return { ok: false, detail: untar.detail };
131
+ const pkg = path.join(work, 'package');
132
+ // npm never packs package-lock.json, and every dependency install of the
133
+ // plugin copy runs `npm ci`, which refuses to run without one.
134
+ copyFileSync(path.join(soRoot, 'package-lock.json'), path.join(pkg, 'package-lock.json'));
135
+ if (installDeps) {
136
+ // The packed copy carries no .npmrc, so the repo's ignore-scripts=true
137
+ // (SEC-020) has to travel as a flag.
138
+ const ci = run('npm', ['ci', '--omit=dev', '--ignore-scripts'], { cwd: pkg, env });
139
+ if (!ci.ok) return { ok: false, detail: ci.detail };
140
+ }
141
+ if (validate) {
142
+ const verdict = validate(pkg);
143
+ if (!verdict.ok) return { ok: false, detail: verdict.detail };
144
+ }
145
+ const previous = path.join(work, 'previous');
146
+ const hadPrevious = existsSync(stageDir);
147
+ if (hadPrevious) renameSync(stageDir, previous);
148
+ try {
149
+ renameSync(pkg, stageDir);
150
+ } catch (error) {
151
+ // Put the old stage back before the finally deletes the work dir with it.
152
+ // If even that fails, keep the work dir: it is the only copy left.
153
+ if (hadPrevious) {
154
+ try {
155
+ renameSync(previous, stageDir);
156
+ } catch (restoreError) {
157
+ work = undefined;
158
+ throw new Error(`${error.message}; restoring the previous stage failed (${restoreError.message}) — it is kept at ${previous}`, { cause: restoreError });
159
+ }
160
+ }
161
+ throw error;
162
+ }
163
+ return { ok: true };
164
+ } catch (error) {
165
+ return { ok: false, detail: `staging ${stageDir}: ${error.message}` };
166
+ } finally {
167
+ if (work) rmSync(work, { recursive: true, force: true });
168
+ }
169
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * plugin-surface.mjs — the one enumeration of the plugin's public sources
3
+ * (`commands/*.md`, `skills/<name>/SKILL.md`) and the one hand-written
4
+ * frontmatter parser the Cursor and Pi wrapper generators share (#1240).
5
+ *
6
+ * Four generators derive harness surfaces from the same sources
7
+ * (`generate-agents-skills.mjs`, `generate-codex-skills.mjs`,
8
+ * `generate-cursor-adapter.mjs`, `generate-pi-prompts.mjs`). Before this module
9
+ * the agents/codex pair skipped names with a leading `.` or `_` while the
10
+ * cursor/pi pair did not, so a `commands/_draft.md` produced Cursor and Pi
11
+ * wrappers but no Codex or `.agents` mirror. {@link isHiddenSourceName} is now
12
+ * the single NAME filter all four apply. The file/directory test is not yet
13
+ * shared: the list helpers here follow symlinks (statSync), while
14
+ * generate-codex-skills.mjs keeps its own dirent loop, which does not. No
15
+ * source is a symlink today (`find skills commands -maxdepth 2 -type l` → 0);
16
+ * revisit when one is added.
17
+ */
18
+
19
+ import { existsSync, readdirSync, statSync } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ /**
23
+ * A source name with a leading `.` or `_` is private (drafts, `_shared/`
24
+ * helpers, dotfiles) and never becomes a public command or skill.
25
+ *
26
+ * @param {string} name file or directory name
27
+ * @returns {boolean}
28
+ */
29
+ export function isHiddenSourceName(name) {
30
+ return /^[._]/.test(name);
31
+ }
32
+
33
+ function isDir(p) {
34
+ try { return statSync(p).isDirectory(); } catch { return false; }
35
+ }
36
+
37
+ function isFile(p) {
38
+ try { return statSync(p).isFile(); } catch { return false; }
39
+ }
40
+
41
+ /**
42
+ * Public `commands/*.md` file names (with extension), sorted.
43
+ *
44
+ * @param {string} root plugin root
45
+ * @returns {string[]}
46
+ */
47
+ export function listCommandFiles(root) {
48
+ const dir = path.join(root, 'commands');
49
+ if (!isDir(dir)) return [];
50
+ return readdirSync(dir)
51
+ .filter((name) => name.endsWith('.md') && !isHiddenSourceName(name) && isFile(path.join(dir, name)))
52
+ .sort();
53
+ }
54
+
55
+ /**
56
+ * Public `skills/<name>` directory names that carry a SKILL.md, sorted.
57
+ *
58
+ * @param {string} root plugin root
59
+ * @returns {string[]}
60
+ */
61
+ export function listSkillDirs(root) {
62
+ const dir = path.join(root, 'skills');
63
+ if (!isDir(dir)) return [];
64
+ return readdirSync(dir)
65
+ .filter((name) => !isHiddenSourceName(name) && isDir(path.join(dir, name)) && existsSync(path.join(dir, name, 'SKILL.md')))
66
+ .sort();
67
+ }
68
+
69
+ /**
70
+ * Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
71
+ * DECODED values (one layer of surrounding quotes stripped). Not js-yaml on
72
+ * purpose: the Cursor/Pi wrappers re-emit these values verbatim, and every
73
+ * merged skill uses `description: >`, which this parser folds to its text.
74
+ *
75
+ * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
76
+ * probes below miss the block entirely: the file parsed as "no frontmatter",
77
+ * so every flag in it (`user-invocable`, `disable-model-invocation`) silently
78
+ * disappeared and the skill was demoted out of the generated surface with no
79
+ * diagnostic anywhere. Both are normalised away first — the same treatment
80
+ * `parseAgentFrontmatter` (`scripts/lib/agent-frontmatter.mjs`) gives them.
81
+ *
82
+ * NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
83
+ * `__BLOCK_SCALAR__` sentinel for a `description: >`, which would be emitted
84
+ * as the wrapper description.
85
+ *
86
+ * @param {string} content
87
+ * @returns {Record<string, string>}
88
+ */
89
+ export function parseFrontmatter(content) {
90
+ const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
91
+ if (!text.startsWith('---\n')) return {};
92
+ const end = text.indexOf('\n---\n', 4);
93
+ if (end === -1) return {};
94
+
95
+ const lines = text.slice(4, end).split('\n');
96
+ const fields = {};
97
+ let i = 0;
98
+ while (i < lines.length) {
99
+ const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
100
+ if (!match) {
101
+ i += 1;
102
+ continue;
103
+ }
104
+ const key = match[1];
105
+ const raw = match[2];
106
+ if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
107
+ const folded = [];
108
+ i += 1;
109
+ while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
110
+ folded.push(lines[i].replace(/^\s+/, ''));
111
+ i += 1;
112
+ }
113
+ const joiner = raw.startsWith('|') ? '\n' : ' ';
114
+ fields[key] = folded.filter(Boolean).join(joiner).trim();
115
+ continue;
116
+ }
117
+ fields[key] = raw.replace(/^["']|["']$/g, '');
118
+ i += 1;
119
+ }
120
+ return fields;
121
+ }
@@ -62,7 +62,7 @@
62
62
  */
63
63
 
64
64
  import { spawnSync } from 'node:child_process';
65
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
65
+ import { existsSync, readFileSync } from 'node:fs';
66
66
  import { dirname, join, resolve } from 'node:path';
67
67
  import { fileURLToPath } from 'node:url';
68
68
 
@@ -75,6 +75,7 @@ import {
75
75
  spawnInGroup,
76
76
  } from './process-group.mjs';
77
77
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
78
+ import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
78
79
  import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
79
80
 
80
81
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
@@ -406,12 +407,10 @@ function writeLastGreenSha(repoRoot) {
406
407
  if (sha.status !== 0 || !sha.stdout) return;
407
408
  const head = sha.stdout.trim();
408
409
  if (!head) return;
409
- const runtimeDir = join(repoRoot, '.orchestrator', 'runtime');
410
- mkdirSync(runtimeDir, { recursive: true });
411
- const target = join(runtimeDir, 'last-green-sha.txt');
412
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
413
- writeFileSync(tmp, head + '\n', 'utf8');
414
- renameSync(tmp, target);
410
+ // Never throws; an fs failure is the same silent best-effort as before.
411
+ atomicWriteWithBackup(join(repoRoot, '.orchestrator', 'runtime', 'last-green-sha.txt'), head + '\n', {
412
+ tmpPrefix: '.tmp-last-green-sha',
413
+ });
415
414
  } catch {
416
415
  // best-effort
417
416
  }
@@ -657,14 +656,12 @@ function readCorrectiveContext(repoRoot) {
657
656
  function writeDiagnosticsBundle(repoRoot, bundle) {
658
657
  try {
659
658
  const dir = join(repoRoot, '.orchestrator', 'metrics', 'verification-failures');
660
- mkdirSync(dir, { recursive: true });
661
659
  // Replace colons in ISO timestamp for cross-fs portability.
662
660
  const ts = new Date().toISOString().replace(/:/g, '-');
663
661
  const target = join(dir, `${ts}.json`);
664
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
665
- writeFileSync(tmp, JSON.stringify(redactDiagnosticsBundle(bundle), null, 2) + '\n', 'utf8');
666
- renameSync(tmp, target);
667
- return target;
662
+ // Same bytes as before: indent 2 + trailing newline, mkdir -p of `dir`.
663
+ const res = writeJsonAtomicSync(target, redactDiagnosticsBundle(bundle), { tmpPrefix: '.tmp-diagnostics' });
664
+ return res.ok ? target : null;
668
665
  } catch {
669
666
  return null;
670
667
  }
@@ -842,19 +839,27 @@ export function gateKillFields(run) {
842
839
  * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
843
840
  * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
844
841
  * the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
845
- * machine-measured emission of the same fact, and as of 2026-07-31 it has zero
846
- * readers (`grep -c '"counts"' .orchestrator/metrics/events.jsonl` → 0 across
847
- * 4404 `orchestrator.quality_gate.*` records).
848
- *
849
- * Retiring the prose path needs a producer change no docblock can make:
850
- * `waves[].*` is PER-WAVE, and gate events carry no `wave_number` (0 of those
851
- * 4404 records). A session-end reader could only attribute a gate event to a
852
- * wave by a wall-clock window whose own boundaries (`waves[].started_at` /
853
- * `completed_at`) are themselves LLM-written — one LLM hop traded for another,
854
- * against the posture `scripts/lib/eval/session-resolve.mjs` already documents
855
- * for window-attributed gate events ("a contaminated window means
856
- * gate-attribution is unsafe"). The concrete remaining work is named in
857
- * `skills/session-end/metrics-collection.md` § 1.7.
842
+ * machine-measured emission of the same fact. Measured 2026-10-05 against this
843
+ * repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
844
+ * file has been rotated since the 4404-record 2026-07-31 reading this note
845
+ * used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
846
+ * `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
847
+ *
848
+ * `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
849
+ * `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
850
+ * sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
851
+ * 37 of the same 82 records carry it
852
+ * (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
853
+ * Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
854
+ * `counts` by `wave_number` first and keeps the STATE.md header only as a
855
+ * fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
856
+ * gate record only when it carries both. THIS emitter still sets no
857
+ * `wave_number` — its records are mid-wave auto-fix retries, so they fall to
858
+ * that fallback by design. A record without the key could only be attributed
859
+ * to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
860
+ * / `completed_at`) are themselves LLM-written, against the posture
861
+ * `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
862
+ * events ("a contaminated window means gate-attribution is unsafe").
858
863
  *
859
864
  * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
860
865
  * true` (default `false`, and `false` in this repo's Session Config). The gate
@@ -57,9 +57,10 @@
57
57
  * (absence reads as `'written'`, the only outcome that existed then).
58
58
  */
59
59
 
60
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
61
- import { dirname, isAbsolute, join } from 'node:path';
62
- import { createHash, randomBytes } from 'node:crypto';
60
+ import { readFileSync } from 'node:fs';
61
+ import { isAbsolute, join } from 'node:path';
62
+ import { createHash } from 'node:crypto';
63
+ import { atomicWriteWithBackup } from '../io.mjs';
63
64
 
64
65
  /** Default repo-relative location of the reconcile-candidate work-queue. */
65
66
  export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
@@ -159,18 +160,16 @@ function readStore(absPath) {
159
160
  * @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
160
161
  */
161
162
  function writeStore(absPath, records) {
163
+ let content;
162
164
  try {
163
- const dir = dirname(absPath);
164
- mkdirSync(dir, { recursive: true });
165
165
  const body = records.map((r) => JSON.stringify(r)).join('\n');
166
- const content = records.length > 0 ? body + '\n' : '';
167
- const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
168
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
169
- renameSync(tmpFile, absPath);
170
- return { ok: true, lines: records.length };
166
+ content = records.length > 0 ? body + '\n' : '';
171
167
  } catch (err) {
172
168
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
173
169
  }
170
+ // mkdir -p + tmp + rename; its failure envelope is this function's own.
171
+ const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
172
+ return res.ok ? { ok: true, lines: records.length } : res;
174
173
  }
175
174
 
176
175
  /**