session-orchestrator 5.7.0 → 5.9.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 (202) 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 +68 -0
  42. package/README.md +13 -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 +45 -4
  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/ci-status-banner.mjs +38 -5
  94. package/scripts/lib/config/drift-check.mjs +1 -1
  95. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  96. package/scripts/lib/config.mjs +5 -1
  97. package/scripts/lib/convergence-monitor.mjs +5 -3
  98. package/scripts/lib/ecosystem-health.mjs +5 -3
  99. package/scripts/lib/eval/engine.mjs +61 -27
  100. package/scripts/lib/eval/judge.mjs +14 -8
  101. package/scripts/lib/eval/session-resolve.mjs +7 -2
  102. package/scripts/lib/events-retention-banner.mjs +5 -2
  103. package/scripts/lib/events-schema.mjs +8 -0
  104. package/scripts/lib/events.mjs +19 -5
  105. package/scripts/lib/fleet-protocol.mjs +98 -1
  106. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  107. package/scripts/lib/io.mjs +172 -35
  108. package/scripts/lib/learnings/candidates.mjs +7 -28
  109. package/scripts/lib/learnings/io.mjs +7 -2
  110. package/scripts/lib/learnings/judgment.mjs +5 -8
  111. package/scripts/lib/learnings/select.mjs +3 -3
  112. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  113. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  114. package/scripts/lib/memory-proposals/store.mjs +3 -5
  115. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  116. package/scripts/lib/native-command.mjs +48 -0
  117. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  118. package/scripts/lib/peer-cards/writer.mjs +6 -18
  119. package/scripts/lib/platform.mjs +113 -4
  120. package/scripts/lib/plugin-package-stage.mjs +169 -0
  121. package/scripts/lib/plugin-surface.mjs +121 -0
  122. package/scripts/lib/quality-gate-wave.mjs +40 -0
  123. package/scripts/lib/quality-gate.mjs +36 -30
  124. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  125. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  126. package/scripts/lib/reconcile/writer.mjs +11 -17
  127. package/scripts/lib/rule-loader.mjs +32 -6
  128. package/scripts/lib/scope-echo.mjs +12 -2
  129. package/scripts/lib/scope-gate.mjs +2 -1
  130. package/scripts/lib/session-close-backfill.mjs +3 -4
  131. package/scripts/lib/session-id.mjs +2 -1
  132. package/scripts/lib/session-record-repair.mjs +28 -21
  133. package/scripts/lib/session-schema/constants.mjs +20 -5
  134. package/scripts/lib/session-schema/validator.mjs +12 -0
  135. package/scripts/lib/session-start-probes.mjs +22 -7
  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 +3 -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 +207 -26
  156. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  157. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  158. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  159. package/scripts/lib/wave-resource-gate.mjs +2 -1
  160. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  161. package/scripts/lib/worktree/meta.mjs +7 -3
  162. package/scripts/mcp-server.mjs +158 -0
  163. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  164. package/scripts/migrate-vault-paths.mjs +7 -4
  165. package/scripts/print-learnings-index.mjs +2 -1
  166. package/scripts/release.mjs +4 -1
  167. package/scripts/relocate-vault-corpus.mjs +5 -3
  168. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  169. package/scripts/run-quality-gate.mjs +9 -39
  170. package/scripts/self-update.mjs +179 -39
  171. package/scripts/site-numbers.mjs +8 -3
  172. package/scripts/sweep-expired-learnings.mjs +6 -4
  173. package/scripts/sync-vault-schema.mjs +3 -7
  174. package/scripts/windows-setup.mjs +45 -0
  175. package/skills/_shared/fleet-protocol.md +10 -0
  176. package/skills/architecture/SKILL.md +1 -0
  177. package/skills/bootstrap/_shared-template.md +5 -2
  178. package/skills/claude-md-drift-check/SKILL.md +4 -1
  179. package/skills/claude-md-drift-check/checker.mjs +83 -10
  180. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  181. package/skills/eval/SKILL.md +12 -13
  182. package/skills/eval/rubric-v3.md +521 -0
  183. package/skills/frontmatter-guard/SKILL.md +1 -0
  184. package/skills/gitlab-portfolio/SKILL.md +1 -0
  185. package/skills/hook-development/SKILL.md +1 -0
  186. package/skills/mcp-builder/SKILL.md +1 -0
  187. package/skills/remote-offload/SKILL.md +1 -1
  188. package/skills/repo-audit/SKILL.md +2 -2
  189. package/skills/session-end/SKILL.md +32 -1
  190. package/skills/session-end/metrics-collection.md +15 -9
  191. package/skills/session-end/vault-operations.md +12 -2
  192. package/skills/session-start/SKILL.md +2 -2
  193. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  194. package/skills/session-start/references/operations-contract.md +4 -1
  195. package/skills/tmux-layout/SKILL.md +1 -0
  196. package/skills/vault-mirror/SKILL.md +1 -0
  197. package/skills/vault-sync/SKILL.md +6 -6
  198. package/skills/vault-sync/validator.sh +29 -10
  199. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  200. package/skills/wave-executor/references/wave-loop-review.md +13 -7
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  202. 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
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { classifyManifestSession, readProcessLocalSessionIds } from './session-identity/own-session.mjs';
3
+ import { findOwnScopeFile } from './scope-gate.mjs';
4
+
5
+ /**
6
+ * Resolve the active wave number from the wave-scope sidecar (#966 step 1).
7
+ *
8
+ * Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
9
+ * same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
10
+ * {@link findOwnScopeFile}, skipping a PEER session's manifest in the same
11
+ * working copy (#1504: a peer-only result is no wave of ours, never the peer's
12
+ * number) — with ONE deliberate difference: the hook returns `0` for "no
13
+ * wave-scope file", this returns `null`.
14
+ *
15
+ * Absent is not zero. A human running `npm run quality-gate` from a `git push`
16
+ * has no wave at all, and that is the common case; publishing `wave_number: 0`
17
+ * would invent a wave 0 that every consumer then has to special-case. The
18
+ * caller spreads the result so the KEY is omitted, exactly as `counts` is.
19
+ *
20
+ * A non-positive, unsafe or non-integer `wave` field is treated the same way — waves
21
+ * are 1-indexed, so `0` on disk carries no more information than an absent file.
22
+ *
23
+ * Never throws.
24
+ *
25
+ * @param {string} projectDir — directory whose wave-scope sidecar to read.
26
+ * @returns {number|null} positive safe integer wave number, or `null` when there is no wave.
27
+ */
28
+ export function resolveWaveNumber(projectDir) {
29
+ try {
30
+ const ownIds = new Set(readProcessLocalSessionIds({ env: process.env, hookInput: null }));
31
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
32
+ if (!waveFile || !existsSync(waveFile)) return null;
33
+ const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
34
+ if (!Number.isSafeInteger(wave) || wave <= 0) return null;
35
+ return wave;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
@@ -62,11 +62,12 @@
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
 
69
69
  import { emitEvent, sessionAttribution } from './events.mjs';
70
+ import { resolveWaveNumber } from './quality-gate-wave.mjs';
70
71
  import { parsePorcelainZ } from './git-porcelain.mjs';
71
72
  import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
72
73
  import {
@@ -75,6 +76,7 @@ import {
75
76
  spawnInGroup,
76
77
  } from './process-group.mjs';
77
78
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
79
+ import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
78
80
  import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
79
81
 
80
82
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
@@ -406,12 +408,10 @@ function writeLastGreenSha(repoRoot) {
406
408
  if (sha.status !== 0 || !sha.stdout) return;
407
409
  const head = sha.stdout.trim();
408
410
  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);
411
+ // Never throws; an fs failure is the same silent best-effort as before.
412
+ atomicWriteWithBackup(join(repoRoot, '.orchestrator', 'runtime', 'last-green-sha.txt'), head + '\n', {
413
+ tmpPrefix: '.tmp-last-green-sha',
414
+ });
415
415
  } catch {
416
416
  // best-effort
417
417
  }
@@ -657,14 +657,12 @@ function readCorrectiveContext(repoRoot) {
657
657
  function writeDiagnosticsBundle(repoRoot, bundle) {
658
658
  try {
659
659
  const dir = join(repoRoot, '.orchestrator', 'metrics', 'verification-failures');
660
- mkdirSync(dir, { recursive: true });
661
660
  // Replace colons in ISO timestamp for cross-fs portability.
662
661
  const ts = new Date().toISOString().replace(/:/g, '-');
663
662
  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;
663
+ // Same bytes as before: indent 2 + trailing newline, mkdir -p of `dir`.
664
+ const res = writeJsonAtomicSync(target, redactDiagnosticsBundle(bundle), { tmpPrefix: '.tmp-diagnostics' });
665
+ return res.ok ? target : null;
668
666
  } catch {
669
667
  return null;
670
668
  }
@@ -837,24 +835,30 @@ export function gateKillFields(run) {
837
835
  * test gate (fail-fast on lint/typecheck) or its output carried no parseable
838
836
  * count. See {@link suiteCountsFromOutput}.
839
837
  *
840
- * IT DOES NOT YET REPLACE THE PROSE PATH (#957/F1 — the earlier wording here
841
- * claimed it did). `waves[].suite_passed` / `suite_failed` still travel as prose
842
- * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
843
- * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
844
- * 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.
838
+ * Both skill readers now take `counts` from `orchestrator.quality_gate.*`:
839
+ * `skills/wave-executor/references/wave-loop-review.md` step 7 and
840
+ * `skills/session-end/metrics-collection.md` § 1.7. They filter the flat record
841
+ * by the same `semantic_session_id` + `wave_number` and select the last
842
+ * matching gate run; `counts.passed` / `counts.failed` supply the per-wave
843
+ * `suite_passed` / `suite_failed`. A third reader,
844
+ * `scripts/lib/convergence-monitor.mjs`, folds `counts` from gate records
845
+ * that also carry `wave_number`. This library emits ONE record per
846
+ * `runQualityGateWithRetry` call, not per retry; `attempts` carries retries.
847
+ *
848
+ * Both emitters resolve `wave_number` from the existing own-session manifest
849
+ * at the event's repoRoot and omit it without a usable source. No wall-clock
850
+ * attribution or guessed wave number is added. Missing counts are not zeros.
851
+ * Count fallback is limited to pre-#954 sessions, gates outside the event
852
+ * wrapper, or missing wave attribution; step 7 falls back to this wave's own
853
+ * gate output (its header is not written yet), session end to the header. Auto-fix with a known wave
854
+ * uses the event; missing counts alone do not permit header fallback.
855
+ *
856
+ * `suite_platform` comes from this wave's gate run (step 7) or the STATE.md
857
+ * Wave History header `— suite <passed>/<failed> on <platform>` (session end);
858
+ * the payload has NO platform field.
859
+ * Event-derived counts may be mirrored into that existing header as
860
+ * compatibility output, not a second measurement source. Neither the header
861
+ * nor sessions.jsonl changes its contract.
858
862
  *
859
863
  * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
860
864
  * true` (default `false`, and `false` in this repo's Session Config). The gate
@@ -886,12 +890,14 @@ export function gateKillFields(run) {
886
890
  */
887
891
  async function emitGateEvent(repoRoot, ok, attempts, gate, counts, killRun = null) {
888
892
  try {
893
+ const waveNumber = resolveWaveNumber(repoRoot);
889
894
  await emitEvent(
890
895
  `orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
891
896
  {
892
897
  variant: 'auto-fix-loop',
893
898
  exit_code: ok ? 0 : 1,
894
899
  attempts,
900
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
895
901
  ...(gate ? { gate } : {}),
896
902
  ...(counts ? { counts } : {}),
897
903
  ...gateKillFields(killRun),