session-orchestrator 5.6.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +75 -0
  42. package/README.md +10 -10
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/USER-GUIDE.md +1 -1
  45. package/docs/baseline.md +4 -3
  46. package/docs/codex-setup.md +6 -4
  47. package/docs/components.md +5 -4
  48. package/docs/events-schema.md +4 -3
  49. package/docs/github-mirror-protection.md +3 -2
  50. package/docs/install.md +39 -3
  51. package/docs/pi-setup.md +1 -1
  52. package/docs/rule-authoring.md +12 -2
  53. package/docs/session-config-reference.md +8 -6
  54. package/docs/session-config-template.md +4 -3
  55. package/evals/README.md +60 -0
  56. package/evals/instruction-ablation/README.md +147 -0
  57. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  58. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  59. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  60. package/evals/instruction-ablation/run.mjs +336 -0
  61. package/evals/psa-003-foreign-work/case.yaml +16 -0
  62. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  63. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  64. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  65. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  66. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  67. package/evals/psa-003-foreign-work/prompt.md +6 -0
  68. package/hooks/_lib/hook-import-set.json +10 -2
  69. package/hooks/hooks-codex.json +1 -1
  70. package/hooks/hooks.json +1 -1
  71. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  72. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  73. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  74. package/hooks/wave-scope-commit-guard.mjs +41 -10
  75. package/monitors/monitors.json +1 -1
  76. package/package.json +3 -1
  77. package/pi/prompts/architecture.md +11 -0
  78. package/pi/prompts/eval.md +1 -1
  79. package/pi/prompts/tmux-layout.md +11 -0
  80. package/pi/prompts/write-executable-plan.md +11 -0
  81. package/scripts/backfill-abandoned-sessions.mjs +6 -8
  82. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  83. package/scripts/backfill-sessions.mjs +9 -7
  84. package/scripts/codex-install.mjs +108 -23
  85. package/scripts/emit-session.mjs +15 -2
  86. package/scripts/fleet-auflagen.mjs +54 -0
  87. package/scripts/generate-agents-skills.mjs +9 -16
  88. package/scripts/generate-codex-skills.mjs +8 -6
  89. package/scripts/generate-cursor-adapter.mjs +9 -74
  90. package/scripts/generate-pi-prompts.mjs +8 -74
  91. package/scripts/github-protection-audit.mjs +8 -0
  92. package/scripts/lib/auto-dialectic.mjs +10 -9
  93. package/scripts/lib/auto-dream.mjs +8 -10
  94. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  95. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  96. package/scripts/lib/config/drift-check.mjs +1 -1
  97. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  98. package/scripts/lib/config.mjs +5 -1
  99. package/scripts/lib/convergence-monitor.mjs +5 -3
  100. package/scripts/lib/dispatcher/rank.mjs +5 -1
  101. package/scripts/lib/ecosystem-health.mjs +5 -3
  102. package/scripts/lib/eval/engine.mjs +61 -27
  103. package/scripts/lib/eval/judge.mjs +14 -8
  104. package/scripts/lib/eval/session-resolve.mjs +7 -2
  105. package/scripts/lib/events-retention-banner.mjs +5 -2
  106. package/scripts/lib/events.mjs +81 -54
  107. package/scripts/lib/fleet-protocol.mjs +98 -1
  108. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  109. package/scripts/lib/io.mjs +181 -39
  110. package/scripts/lib/learnings/candidates.mjs +14 -44
  111. package/scripts/lib/learnings/io.mjs +7 -2
  112. package/scripts/lib/learnings/judgment.mjs +5 -8
  113. package/scripts/lib/learnings/select.mjs +3 -3
  114. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  115. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  116. package/scripts/lib/memory-proposals/store.mjs +12 -7
  117. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  118. package/scripts/lib/native-command.mjs +48 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  120. package/scripts/lib/peer-cards/writer.mjs +6 -18
  121. package/scripts/lib/platform.mjs +113 -4
  122. package/scripts/lib/plugin-package-stage.mjs +169 -0
  123. package/scripts/lib/plugin-surface.mjs +121 -0
  124. package/scripts/lib/quality-gate.mjs +30 -25
  125. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  126. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
  127. package/scripts/lib/reconcile/writer.mjs +11 -17
  128. package/scripts/lib/rule-loader.mjs +32 -6
  129. package/scripts/lib/scope-echo.mjs +12 -2
  130. package/scripts/lib/scope-gate.mjs +2 -1
  131. package/scripts/lib/session-close-backfill.mjs +21 -4
  132. package/scripts/lib/session-id.mjs +2 -1
  133. package/scripts/lib/session-record-repair.mjs +9 -13
  134. package/scripts/lib/session-schema/constants.mjs +20 -5
  135. package/scripts/lib/session-start-probes.mjs +2 -2
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +585 -72
  156. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  157. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  158. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  159. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  160. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  161. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  162. package/scripts/lib/wave-transcript-tail.mjs +19 -6
  163. package/scripts/lib/worktree/meta.mjs +7 -3
  164. package/scripts/mcp-server.mjs +158 -0
  165. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  166. package/scripts/migrate-vault-paths.mjs +7 -4
  167. package/scripts/print-applicable-rules.mjs +1 -25
  168. package/scripts/print-learnings-index.mjs +2 -1
  169. package/scripts/release.mjs +4 -0
  170. package/scripts/relocate-vault-corpus.mjs +5 -3
  171. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  172. package/scripts/self-update.mjs +179 -39
  173. package/scripts/site-numbers.mjs +4 -3
  174. package/scripts/sweep-expired-learnings.mjs +6 -4
  175. package/scripts/sync-vault-schema.mjs +3 -7
  176. package/scripts/vault-mirror-prune.mjs +145 -0
  177. package/scripts/vault-mirror.mjs +77 -12
  178. package/scripts/windows-setup.mjs +45 -0
  179. package/skills/_shared/fleet-protocol.md +8 -0
  180. package/skills/architecture/SKILL.md +1 -0
  181. package/skills/bootstrap/_shared-template.md +5 -2
  182. package/skills/claude-md-drift-check/SKILL.md +4 -1
  183. package/skills/claude-md-drift-check/checker.mjs +89 -26
  184. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  185. package/skills/eval/SKILL.md +12 -13
  186. package/skills/eval/rubric-v3.md +521 -0
  187. package/skills/frontmatter-guard/SKILL.md +1 -0
  188. package/skills/gitlab-portfolio/SKILL.md +1 -0
  189. package/skills/hook-development/SKILL.md +1 -0
  190. package/skills/mcp-builder/SKILL.md +1 -0
  191. package/skills/repo-audit/SKILL.md +2 -2
  192. package/skills/session-end/SKILL.md +32 -1
  193. package/skills/session-end/vault-operations.md +12 -2
  194. package/skills/session-start/SKILL.md +2 -2
  195. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  196. package/skills/session-start/references/operations-contract.md +4 -1
  197. package/skills/tmux-layout/SKILL.md +1 -0
  198. package/skills/vault-mirror/SKILL.md +28 -2
  199. package/skills/vault-sync/SKILL.md +6 -6
  200. package/skills/vault-sync/validator.sh +29 -10
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
  202. package/skills/write-executable-plan/SKILL.md +1 -0
  203. package/hooks/agent-teams-h3-test.sh +0 -362
  204. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  205. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  206. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  207. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -145,10 +145,7 @@ export const JUDGMENT_DECISIONS = Object.freeze([
145
145
  ]);
146
146
 
147
147
  /** Decisions that assert a relation to at least one other record. */
148
- export const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
149
-
150
- /** Decisions that assert no relation (`skip`) or no judgment (`abstain`). */
151
- export const NO_RELATION_DECISIONS = Object.freeze(['skip', 'abstain']);
148
+ const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
152
149
 
153
150
  /**
154
151
  * The eight failure modes. Closed set — the free-text `detail` field carries
@@ -174,7 +171,7 @@ export const FAILURE_MODES = Object.freeze([
174
171
  * on purpose: registering the AUQ in the SAME map as the archive writer is what
175
172
  * makes "an AUQ is a write" structural rather than a comment someone can miss.
176
173
  */
177
- export const EFFECT_BY_DECISION = Object.freeze({
174
+ const EFFECT_BY_DECISION = Object.freeze({
178
175
  skip: null,
179
176
  abstain: null,
180
177
  refine: 'refine',
@@ -191,7 +188,7 @@ export const EFFECT_BY_DECISION = Object.freeze({
191
188
  * the corpus develops genuine clusters (max pairwise similarity > ~0.4) or if
192
189
  * live judgments start naming a truncated-away record in their rationale.
193
190
  */
194
- export const DEFAULT_MAX_NEIGHBOURS = 12;
191
+ const DEFAULT_MAX_NEIGHBOURS = 12;
195
192
 
196
193
  /**
197
194
  * Judge wall-clock budget. Ceiling: 60s — a judgment pass runs at session-end
@@ -206,7 +203,7 @@ export const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
206
203
  * the judge to lean on `scope` or `host_class` would be steering it by a field
207
204
  * that is constant across the corpus.
208
205
  */
209
- export const SURFACE_HINT = [
206
+ const SURFACE_HINT = [
210
207
  'Judge the SURFACE, not the subject line.',
211
208
  '',
212
209
  'Measured 2026-08-13 over the 100 live records: `scope` is "local" in 100/100 and',
@@ -233,7 +230,7 @@ export const SURFACE_HINT = [
233
230
  * restatement drifts silently, and the failure mode of drift here is that every
234
231
  * batch voids and the feature is dead while looking wired.
235
232
  */
236
- export const JUDGMENT_OUTPUT_CONTRACT = [
233
+ const JUDGMENT_OUTPUT_CONTRACT = [
237
234
  'Answer with JSON only, exactly this shape:',
238
235
  '',
239
236
  '{"candidate_id": "<the candidate id you were given>",',
@@ -182,7 +182,7 @@ export const DEFAULT_MAX_GLOBAL = 4;
182
182
  * overlaps only, so a directory-prefix match scores 0.75 without appearing
183
183
  * there. Using it as a proxy would silently drop the strongest partial matches.
184
184
  */
185
- export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
185
+ const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
186
186
 
187
187
  /**
188
188
  * Blend of relevance (affinity to this agent's scope) against quality
@@ -190,7 +190,7 @@ export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
190
190
  * IS the acceptance criterion. Weight-normalized like `affinity()`, so the
191
191
  * result stays in [0,1] for any non-negative pair.
192
192
  */
193
- export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
193
+ const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
194
194
 
195
195
  /**
196
196
  * How many active entries the file entry-point pulls before ranking.
@@ -496,7 +496,7 @@ export function renderIndexLine(entry, opts = {}) {
496
496
  * @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
497
497
  * @returns {{score: number, relevance: number, quality: number, pathScore: number}}
498
498
  */
499
- export function scoreLearning(entry, scope, opts = {}) {
499
+ function scoreLearning(entry, scope, opts = {}) {
500
500
  const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
501
501
  if (!_isRecord(entry)) return zero;
502
502
 
@@ -73,7 +73,7 @@
73
73
  import { existsSync, statSync } from 'node:fs';
74
74
  import path from 'node:path';
75
75
 
76
- import { SCAN_CHUNK_BYTES, scanEventsBackwards } from './events.mjs';
76
+ import { SCAN_CHUNK_BYTES, eventsFilePath, scanEventsBackwards } from './events.mjs';
77
77
  import { computeReconcileNudge } from './reconcile-nudge-banner.mjs';
78
78
  import { sweepExpiredLearnings } from './learnings/expiry-sweep.mjs';
79
79
  import { shouldDispatchAutoDialectic } from './auto-dialectic.mjs';
@@ -204,7 +204,7 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
204
204
  * `events-rotation.max-backups`), so a miss has not proven "never" either.
205
205
  */
206
206
  function readLastEvolveRun(repoRoot) {
207
- const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
207
+ const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
208
208
  let lastAt = null;
209
209
  const scan = scanEventsBackwards({
210
210
  filePath: file,
@@ -42,11 +42,11 @@
42
42
  */
43
43
 
44
44
  import { appendFile, mkdir, readdir, rm } from 'node:fs/promises';
45
- import { writeFileSync, renameSync, readFileSync, existsSync } from 'node:fs';
45
+ import { readFileSync, existsSync } from 'node:fs';
46
46
  import path from 'node:path';
47
- import crypto from 'node:crypto';
48
47
  import { appendLearning } from '../learnings/io.mjs';
49
48
  import { validatePathInsideProject } from '../path-utils.mjs';
49
+ import { atomicWriteWithBackup } from '../io.mjs';
50
50
 
51
51
  // ---------------------------------------------------------------------------
52
52
  // Path constants (relative to repoRoot)
@@ -320,10 +320,9 @@ export async function clearProposalsJsonl({ repoRoot }) {
320
320
  // target — a concurrent hook reading proposals.jsonl mid-clear observes
321
321
  // either the pre-clear content or the fully-cleared file, never a
322
322
  // partial truncate.
323
- const tmpSuffix = crypto.randomBytes(6).toString('hex');
324
- const tmpFile = path.join(metricsDirPath, `.proposals.jsonl.tmp.${tmpSuffix}`);
325
- writeFileSync(tmpFile, '', 'utf8');
326
- renameSync(tmpFile, proposalsPath);
323
+ // Same tmp-name shape as before: `.proposals.jsonl.tmp.<12 hex>`.
324
+ const res = atomicWriteWithBackup(proposalsPath, '', { tmpPrefix: '.proposals.jsonl.tmp' });
325
+ if (!res.ok) return { cleared: false, summariesCleared: 0 };
327
326
  } catch {
328
327
  return { cleared: false, summariesCleared: 0 };
329
328
  }
@@ -16,10 +16,10 @@
16
16
 
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
- import crypto from 'node:crypto';
20
19
  import { serializeProposal } from './schema.mjs';
21
20
  import { validatePathInsideProject } from '../path-utils.mjs';
22
21
  import { tryAcquireFileLock, releaseFileLock } from '../file-lock.mjs';
22
+ import { writeJsonAtomicSync } from '../io.mjs';
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Constants
@@ -285,10 +285,8 @@ function incrementSummary(summaryPath, field) {
285
285
  ensureDir(summaryPath);
286
286
  const current = readSummary(summaryPath);
287
287
  current[field] = (current[field] ?? 0) + 1;
288
- const tmpSuffix = crypto.randomBytes(4).toString('hex');
289
- const tmpFile = `${summaryPath}.tmp.${tmpSuffix}`;
290
- fs.writeFileSync(tmpFile, JSON.stringify(current, null, 2) + '\n', 'utf8');
291
- fs.renameSync(tmpFile, summaryPath);
288
+ // Same bytes (indent 2 + trailing newline); a failure is swallowed as before.
289
+ writeJsonAtomicSync(summaryPath, current, { tmpPrefix: `.${path.basename(summaryPath)}.tmp` });
292
290
  } catch {
293
291
  /* best-effort: summary writes are non-critical */
294
292
  }
@@ -413,7 +411,10 @@ export async function appendProposal({
413
411
  * Reads proposals.jsonl without acquiring the lock — suitable for diagnostics
414
412
  * and reads where eventual consistency is acceptable.
415
413
  *
416
- * Returns 0 when the file does not exist.
414
+ * Returns 0 when the file does not exist (silently — `countWaveLines` maps
415
+ * ENOENT to 0). Any other read failure also returns 0, but with a stderr WARN
416
+ * so a 0 caused by an unreadable file is distinguishable from an empty queue
417
+ * (#1216).
417
418
  *
418
419
  * @param {object} opts
419
420
  * @param {string} opts.repoRoot
@@ -424,7 +425,11 @@ export async function countProposalsForWave({ repoRoot, waveId }) {
424
425
  const jsonlPath = jsonlPathFor(repoRoot);
425
426
  try {
426
427
  return countWaveLines(jsonlPath, waveId);
427
- } catch {
428
+ } catch (err) {
429
+ process.stderr.write(
430
+ `[memory-proposals] WARN: countProposalsForWave: ` +
431
+ `${err?.code ?? '?'}: ${err?.message ?? String(err)} — returning 0\n`,
432
+ );
428
433
  return 0;
429
434
  }
430
435
  }
@@ -61,7 +61,9 @@ const MESSAGE_ISSUE_CAP = 5;
61
61
  *
62
62
  * `query-failed` is the residual bucket for a `gh` that ran and exited
63
63
  * non-zero for a reason that is neither missing-CLI, timeout, nor auth
64
- * (network down, repo renamed, rate limit). It is a deliberate fifth member
64
+ * (network down, repo renamed, rate limit — including GitHub's HTTP 403
65
+ * rate-limit answer, which `classifyFailure` checks before its 403 → auth
66
+ * rule). It is a deliberate fifth member
65
67
  * beyond the four originally specified: folding those into `parse-error`
66
68
  * would mislabel a network failure as malformed output, reintroducing the
67
69
  * dishonest-state class this module was built to remove.
@@ -122,6 +124,14 @@ function classifyFailure(err) {
122
124
 
123
125
  const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
124
126
  const haystack = `${message}\n${stderr}`.toLowerCase();
127
+ // Rate limit BEFORE the auth check (#1034 item 2): GitHub answers a primary
128
+ // or secondary rate limit with HTTP 403, so the `http 403` test below would
129
+ // otherwise tell the operator to re-authenticate when waiting is the fix. A
130
+ // 403 without rate-limit wording (expired token, missing scope) stays
131
+ // `auth-error`, and so does an UNAUTHENTICATED rate limit, whose fix is to
132
+ // log in. `abuse detection` is GitHub's older wording for the secondary limit.
133
+ const rateLimited = haystack.includes('rate limit') || haystack.includes('abuse detection');
134
+ if (rateLimited && !haystack.includes('authenticated requests get a higher')) return 'query-failed';
125
135
  if (
126
136
  haystack.includes('gh auth login') ||
127
137
  haystack.includes('not logged in') ||
@@ -0,0 +1,48 @@
1
+ /** Shell-free command launching for native Windows and Unix hosts. */
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /**
7
+ * Windows cannot spawn npm's .cmd shim without cmd.exe. Run its JavaScript
8
+ * entrypoint with this Node instead, preserving each argument literally.
9
+ * Supports Node's bundled npm (including nvm-windows) and npm on PATH.
10
+ * Other commands and Unix retain their normal executable lookup.
11
+ * @param {string} cmd
12
+ * @param {string[]} args
13
+ * @param {object} [context] Host overrides for platform regression tests.
14
+ * @returns {{cmd: string, args: string[]}}
15
+ */
16
+ export function resolveNativeCommand(cmd, args, {
17
+ platform = process.platform, execPath = process.execPath, env = process.env, exists = existsSync,
18
+ } = {}) {
19
+ if (platform !== 'win32' || !/^npm(?:\.cmd)?$/i.test(cmd)) return { cmd, args };
20
+ const winPath = path.win32;
21
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path');
22
+ const dirs = [winPath.dirname(execPath), ...(env[pathKey] || '').split(';')];
23
+ for (const dir of dirs) {
24
+ // Ignore empty/relative PATH entries: never select npm from the working repo.
25
+ const absolute = dir.replace(/^"(.*)"$/, '$1');
26
+ if (!winPath.isAbsolute(absolute)) continue;
27
+ const cli = winPath.join(absolute, 'node_modules', 'npm', 'bin', 'npm-cli.js');
28
+ if (exists(cli)) return { cmd: execPath, args: [cli, ...args] };
29
+ }
30
+ throw Object.assign(new Error('Cannot locate npm-cli.js beside Node or on PATH; install npm with Node.js.'), { code: 'ENOENT' });
31
+ }
32
+
33
+ /** Like spawnSync, with shell-free Windows npm support and spawn-style errors. */
34
+ export function spawnNativeSync(cmd, args, options = {}) {
35
+ try {
36
+ const command = resolveNativeCommand(cmd, args, { env: options.env ?? process.env });
37
+ return spawnSync(command.cmd, command.args, { ...options, shell: false });
38
+ } catch (error) {
39
+ return { error, status: null, stdout: '', stderr: '' };
40
+ }
41
+ }
42
+
43
+ /** Test for a command using the host's executable locator, without a shell. */
44
+ export function commandOnPath(bin) {
45
+ return spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [bin], {
46
+ encoding: 'utf8', timeout: 10_000, shell: false,
47
+ }).status === 0;
48
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {
11
+ GATE_PROCESS_LEDGER_RELPATH,
11
12
  killProcessGroup,
12
13
  pruneGateProcessLedger,
13
14
  readGateProcessLedger,
@@ -25,9 +26,16 @@ import {
25
26
  } from './reaper-audit.mjs';
26
27
  import { decideReapCandidates, verifyGroupMemberIdentity } from './reaper-decide.mjs';
27
28
 
28
- /** Name const for the one event this module emits. @see docs/events-schema.md */
29
+ /** Name const for the scan-summary event. @see docs/events-schema.md */
29
30
  export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
30
31
 
32
+ /**
33
+ * Name const for the degraded-scan event (#1505 point 5a, owner decision
34
+ * 2026-10-03). An unreadable process register disables the scan; this record
35
+ * is what makes that countable (HR-105). @see docs/events-schema.md
36
+ */
37
+ export const LEDGER_UNREADABLE_EVENT = 'orchestrator.ledger.unreadable';
38
+
31
39
  /** Default own-session-id reader. Lazily imported so the static import closure
32
40
  * of this module stays small — it is destined for a hot-path hook (#1432).
33
41
  * @param {string} repoRoot @returns {Promise<string|null>} */
@@ -237,16 +245,34 @@ export async function runOrphanScan({
237
245
 
238
246
  const { rows, malformed } = parsePsSnapshotDetailed(text);
239
247
 
248
+ // HR-105 (#1505 point 5a): an unreadable register switches the scan off, and
249
+ // the detached hook child ignores the exit 2 that says so — without a record
250
+ // the instrument is off with no trace. Once per degraded scan; the scan runs
251
+ // throttled, so this cannot become a per-fire signal (HR-101). Repo-relative
252
+ // path only, never a host path. A failing emit never fails the scan.
253
+ const unreadable = async (reason) => {
254
+ try {
255
+ await d.emitEvent(LEDGER_UNREADABLE_EVENT, {
256
+ path: GATE_PROCESS_LEDGER_RELPATH,
257
+ reason,
258
+ consumer: 'orphan-reaper',
259
+ }, { repoRoot });
260
+ } catch {
261
+ /* telemetry that can fail a scan would fail the hook the scan runs in */
262
+ }
263
+ return empty('ledger-unreadable', malformed);
264
+ };
265
+
240
266
  let ledger;
241
267
  try {
242
268
  ledger = d.readLedger(repoRoot, { nowMs: startedAt });
243
269
  } catch {
244
- return empty('ledger-unreadable', malformed);
270
+ return unreadable('reader-threw');
245
271
  }
246
272
  // The default reader never throws: a refused or unreadable ledger comes back
247
273
  // as no records with `state: 'unreadable'` (#1498). Read as "no register
248
274
  // hits" it ran a clean-looking scan that measured nothing.
249
- if (ledger?.state === 'unreadable') return empty('ledger-unreadable', malformed);
275
+ if (ledger?.state === 'unreadable') return unreadable('refused');
250
276
 
251
277
  let ownSessionId = null;
252
278
  try {
@@ -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
+ }