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
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /** Native, dependency-free MCP stdio transport. No shell, WSL, or jq required. */
3
+ import { execFileSync } from 'node:child_process';
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import { isSessionConfigHeading } from './lib/config/section-extractor.mjs';
8
+ import { isMainModule } from './lib/is-main-module.mjs';
9
+
10
+ const SERVER_INFO = { name: 'session-orchestrator', version: '2.0.0' };
11
+ const TOOLS = [
12
+ {
13
+ name: 'session_config',
14
+ description: 'Reads Session Config section from the project instruction file (CLAUDE.md or AGENTS.md alias)',
15
+ inputSchema: { type: 'object', properties: {}, required: [] },
16
+ },
17
+ {
18
+ name: 'session_metrics',
19
+ description: 'Reads the last 5 REAL session metrics entries from .orchestrator/metrics/sessions.jsonl (abandoned phantom stubs excluded)',
20
+ inputSchema: { type: 'object', properties: {}, required: [] },
21
+ },
22
+ ];
23
+ const textContent = (text) => ({ content: [{ type: 'text', text }] });
24
+ const errorResponse = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
25
+
26
+ /** Resolve the caller's repository without passing native paths through a shell. */
27
+ export function resolveProjectRoot(cwd = process.cwd()) {
28
+ try {
29
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
30
+ cwd, encoding: 'utf8', windowsHide: true, timeout: 10_000,
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function isFile(path, nonempty = false) {
39
+ try {
40
+ const stat = statSync(path);
41
+ return stat.isFile() && (!nonempty || stat.size > 0);
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ /** Read the same raw config body as the legacy shell tool (including comments). */
49
+ export function readSessionConfig(root) {
50
+ const file = ['CLAUDE.md', 'AGENTS.md'].map((name) => join(root, name))
51
+ .find((path) => isFile(path, true));
52
+ if (!file) return `No CLAUDE.md or AGENTS.md found at ${root}`;
53
+ const body = [];
54
+ let inSection = false;
55
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
56
+ if (isSessionConfigHeading(line)) {
57
+ inSection = true;
58
+ } else if (line.startsWith('## ')) {
59
+ inSection = false;
60
+ } else if (inSection) {
61
+ body.push(line);
62
+ }
63
+ }
64
+ // Shell command substitution removes LF bytes, not spaces or CR bytes.
65
+ return body.join('\n').replace(/\n+$/, '') || `No '## Session Config' section found in ${file}`;
66
+ }
67
+
68
+ /** Read the last five real records, with whole-ledger stub count and token coverage. */
69
+ export function readSessionMetrics(root) {
70
+ const file = ['.orchestrator', '.claude'].map((dir) => join(root, dir, 'metrics', 'sessions.jsonl'))
71
+ .find((path) => isFile(path));
72
+ if (!file) return 'No metrics found (checked .orchestrator/metrics/ and .claude/metrics/)';
73
+ const entries = [];
74
+ let stubCount = 0;
75
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
76
+ let record;
77
+ try { record = JSON.parse(line); } catch { continue; }
78
+ // Torn writes and non-record JSON must not hide subsequent valid sessions.
79
+ if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
80
+ if (record.status === 'abandoned') { stubCount++; continue; }
81
+ entries.push(record);
82
+ if (entries.length > 5) entries.shift();
83
+ }
84
+ if (!entries.length) {
85
+ return stubCount ? `No real sessions (abandoned stubs excluded: ${stubCount})`
86
+ : 'No metrics found (file is empty)';
87
+ }
88
+ const record = entries.findLast((entry) => (entry.total_token_input !== null && entry.total_token_input !== undefined)
89
+ || (entry.total_token_output !== null && entry.total_token_output !== undefined));
90
+ const coverage = record?.subagents_with_tokens ?? 0;
91
+ const summary = record
92
+ ? `tokens: ${record.total_token_input ?? '?'} in / ${record.total_token_output ?? '?'} out`
93
+ + ` (coverage: ${coverage} subagents)`
94
+ + (coverage === 0 ? ' ⚠ partial — subagent token data missing; total is not a reliable cost estimate' : '')
95
+ + ` [session: ${record.session_id ?? 'unknown'}]`
96
+ : 'tokens: no token data in last 5 sessions (subagent telemetry not yet captured)';
97
+ return entries.map((entry) => JSON.stringify(entry)).join('\n')
98
+ + `\n\nabandoned stubs excluded: ${stubCount}\n\n--- token summary ---\n${summary}`;
99
+ }
100
+
101
+ /** Handle one JSON-RPC line. null means a notification or blank line, not a reply. */
102
+ export function handleLine(line, { cwd = process.cwd(), resolveRoot = resolveProjectRoot } = {}) {
103
+ if (!line.trim()) return null;
104
+ let request;
105
+ try { request = JSON.parse(line); } catch { return errorResponse(null, -32700, 'Parse error'); }
106
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
107
+ return errorResponse(null, -32600, 'Invalid Request');
108
+ }
109
+ const hasId = Object.hasOwn(request, 'id');
110
+ const validId = request.id === null || typeof request.id === 'string' || typeof request.id === 'number';
111
+ const id = hasId && validId ? request.id : null;
112
+ if (request.jsonrpc !== '2.0' || typeof request.method !== 'string' || (hasId && !validId)) {
113
+ return errorResponse(id, -32600, 'Invalid Request');
114
+ }
115
+ if (!hasId || request.method.startsWith('notifications/')) return null;
116
+ let result;
117
+ switch (request.method) {
118
+ case 'initialize':
119
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER_INFO };
120
+ break;
121
+ case 'tools/list':
122
+ result = { tools: TOOLS };
123
+ break;
124
+ case 'tools/call': {
125
+ const name = request.params?.name;
126
+ if (typeof name !== 'string') return errorResponse(id, -32602, 'Invalid tool name');
127
+ if (!TOOLS.some((tool) => tool.name === name)) return errorResponse(id, -32602, `Unknown tool: ${name}`);
128
+ try {
129
+ const root = resolveRoot(cwd);
130
+ result = textContent(root
131
+ ? (name === 'session_config' ? readSessionConfig(root) : readSessionMetrics(root))
132
+ : 'Error: not inside a git repository');
133
+ } catch {
134
+ return errorResponse(id, -32603, 'Unable to read session data');
135
+ }
136
+ break;
137
+ }
138
+ default:
139
+ return errorResponse(id, -32601, `Method not found: ${request.method}`);
140
+ }
141
+ return { jsonrpc: '2.0', id, result };
142
+ }
143
+
144
+ /** Start newline-delimited JSON-RPC on the supplied streams. */
145
+ export async function serve({ input = process.stdin, output = process.stdout, cwd = process.cwd() } = {}) {
146
+ const lines = createInterface({ input, crlfDelay: Infinity });
147
+ for await (const line of lines) {
148
+ const response = handleLine(line, { cwd });
149
+ if (response) output.write(`${JSON.stringify(response)}\n`);
150
+ }
151
+ }
152
+
153
+ if (isMainModule(import.meta.url)) {
154
+ serve().catch(() => {
155
+ process.stderr.write('session-orchestrator: MCP stdio transport failed.\n');
156
+ process.exitCode = 2;
157
+ });
158
+ }
@@ -38,8 +38,9 @@
38
38
  * "already_canonical":N,"unmappable":N,"parse_errors":N,"backup":"<path>|null"}
39
39
  */
40
40
 
41
- import { readFileSync, writeFileSync, renameSync, copyFileSync, existsSync } from 'node:fs';
42
- import { join, dirname, basename } from 'node:path';
41
+ import { readFileSync, copyFileSync, existsSync } from 'node:fs';
42
+ import { basename } from 'node:path';
43
+ import { atomicWriteWithBackup, envelopeToError } from './lib/io.mjs';
43
44
  import { validateSession, normalizeSession, ValidationError } from './lib/session-schema.mjs';
44
45
  import { isMainModule } from './lib/is-main-module.mjs';
45
46
 
@@ -422,11 +423,12 @@ async function main() {
422
423
  // Atomic write: backup → tmp → rename
423
424
  const iso = new Date().toISOString().replace(/[:.]/g, '-');
424
425
  const backupPath = `${args.file}.bak-${iso}`;
425
- const tmpPath = join(dirname(args.file), `.${basename(args.file)}.migrate-tmp-${process.pid}`);
426
426
  try {
427
427
  copyFileSync(args.file, backupPath);
428
- writeFileSync(tmpPath, outputLines.join('\n') + '\n', 'utf8');
429
- renameSync(tmpPath, args.file);
428
+ const res = atomicWriteWithBackup(args.file, outputLines.join('\n') + '\n', {
429
+ tmpPrefix: `.${basename(args.file)}.tmp`,
430
+ });
431
+ if (!res.ok) throw envelopeToError(res);
430
432
  summary.backup = backupPath;
431
433
  } catch (err) {
432
434
  const hint = existsSync(backupPath)
@@ -58,6 +58,7 @@ import {
58
58
  } from './lib/vault-migration-rules.mjs';
59
59
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
60
60
  import { isMainModule } from './lib/is-main-module.mjs';
61
+ import { atomicWriteText, envelopeToError } from './lib/io.mjs';
61
62
 
62
63
  // ---------------------------------------------------------------------------
63
64
  // Constants
@@ -529,12 +530,14 @@ function findHits(filePath, content) {
529
530
  }
530
531
 
531
532
  /**
532
- * Atomic write: write to <path>.migrate-tmp, then rename.
533
+ * Atomic write via `atomicWriteText` (`<dir>/.<name>.tmp.<hex>`, then rename);
534
+ * throws with the original message and `code` on failure.
533
535
  */
534
536
  async function atomicWrite(filePath, content) {
535
- const tmp = `${filePath}.migrate-tmp-${process.pid}-${Date.now()}`;
536
- await fs.writeFile(tmp, content, 'utf8');
537
- await fs.rename(tmp, filePath);
537
+ const res = await atomicWriteText(filePath, content, {
538
+ tmpPrefix: `.${path.basename(filePath)}.tmp`,
539
+ });
540
+ if (!res.ok) throw envelopeToError(res);
538
541
  }
539
542
 
540
543
  /**
@@ -98,9 +98,9 @@
98
98
  import { parseArgs } from 'node:util';
99
99
  import { readFileSync } from 'node:fs';
100
100
  import { join, relative } from 'node:path';
101
- import { createHash } from 'node:crypto';
102
101
 
103
102
  import { findProjectRoot } from './lib/common.mjs';
103
+ import { deriveFenceToken } from './lib/reconcile/sanitize.mjs';
104
104
  import { loadApplicableRules } from './lib/rule-loader.mjs';
105
105
  import { readHostClass } from './lib/autopilot/telemetry.mjs';
106
106
  import { resolveStateArtifactPath, resolveStateMdPath } from './lib/state-md.mjs';
@@ -218,30 +218,6 @@ function neutraliseWrapperForgeries(text) {
218
218
  return out;
219
219
  }
220
220
 
221
- /**
222
- * Derive this block's fence token from its own payload.
223
- *
224
- * Deterministic by construction (same input → same token), so the CLI's stdout
225
- * stays reproducible. The re-derivation loop makes the absence guarantee
226
- * STRUCTURAL rather than probabilistic: a token that literally occurred in the
227
- * payload would be forgeable in a closing tag, so we re-hash with a counter
228
- * until it does not occur. Each iteration is a fresh 32-bit draw against a
229
- * fixed payload, so termination is immediate in practice; the cap exists only
230
- * so a pathological input cannot spin, and its fallback (the full 64-hex
231
- * digest, which no realistic rule body contains) still satisfies the guarantee.
232
- *
233
- * @param {string} payload - the concatenated rule bodies this token must fence
234
- * @returns {string} a hex token provably absent from `payload`
235
- */
236
- function deriveFenceToken(payload) {
237
- const digest = (salt) => createHash('sha256').update(`${salt}\n${payload}`).digest('hex');
238
- for (let salt = 0; salt < 64; salt++) {
239
- const token = digest(salt).slice(0, 8);
240
- if (!payload.includes(token)) return token;
241
- }
242
- return digest(64);
243
- }
244
-
245
221
  /**
246
222
  * Render a rule's `src` attribute: repo-relative (an absolute path would print
247
223
  * the operator's home directory into every agent prompt) and reduced to a
@@ -101,6 +101,7 @@ import { join, dirname } from 'node:path';
101
101
  import { fileURLToPath } from 'node:url';
102
102
 
103
103
  import { findProjectRoot } from './lib/common.mjs';
104
+ import { eventsFilePath } from './lib/events.mjs';
104
105
  import { resolveStateArtifactPath } from './lib/state-md.mjs';
105
106
  import {
106
107
  CANDIDATE_POOL_SIZE,
@@ -249,7 +250,7 @@ const repoRoot = findProjectRoot(process.cwd());
249
250
  const learningsPath = opts.learnings
250
251
  ? opts.learnings
251
252
  : join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
252
- const eventsPath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
253
+ const eventsPath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
253
254
  // #1019 — the natively-delivered rule corpus. Every `.claude/rules/*.md` reaches
254
255
  // a dispatched agent in FULL through Claude Code's own project-instruction
255
256
  // loading (`docs/instruction-delivery.md` §1: the `globs:`/`tier:` frontmatter is
@@ -387,6 +387,10 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
387
387
  // `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
388
388
  // runtime warning names the version it was deprecated IN, so it legitimately carries the previous
389
389
  // release literal until its 6.0.0 removal — version history by construction, not drift.
390
+ // `site/_census.json` is deliberately NOT allowlisted: it carries a `"version"` field that
391
+ // `--set-version` re-stamps (scripts/site-numbers.mjs --write, see the post-bump hint in main()),
392
+ // so leaving it under the drift sweep makes the sweep double as its staleness gate — a census
393
+ // not re-stamped after a bump fails `--check` instead of shipping the previous version.
390
394
  export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
391
395
 
392
396
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
@@ -55,6 +55,7 @@ import {
55
55
  import { buildBackfillIndex, parseSessionId } from './lib/vault-repo-backfill.mjs';
56
56
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
57
57
  import { isMainModule } from './lib/is-main-module.mjs';
58
+ import { atomicWriteText, envelopeToError } from './lib/io.mjs';
58
59
 
59
60
  // ---------------------------------------------------------------------------
60
61
  // Constants
@@ -693,7 +694,6 @@ async function writeManifest({ vaultDir, derivableOnly, summary, moves }) {
693
694
 
694
695
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
695
696
  const manifestPath = path.join(manifestDir, `relocation-manifest-${ts}.json`);
696
- const tmpPath = `${manifestPath}.tmp-${process.pid}`;
697
697
 
698
698
  // Store vault-relative paths in the manifest
699
699
  const movesRel = moves.map((m) => ({
@@ -714,8 +714,10 @@ async function writeManifest({ vaultDir, derivableOnly, summary, moves }) {
714
714
  moves: movesRel,
715
715
  };
716
716
 
717
- await fs.writeFile(tmpPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
718
- await fs.rename(tmpPath, manifestPath);
717
+ const res = await atomicWriteText(manifestPath, JSON.stringify(manifest, null, 2) + '\n', {
718
+ tmpPrefix: `.${path.basename(manifestPath)}.tmp`,
719
+ });
720
+ if (!res.ok) throw envelopeToError(res);
719
721
 
720
722
  return manifestPath;
721
723
  }
@@ -32,9 +32,9 @@
32
32
  * with the next repo, and exits 2 at the end
33
33
  */
34
34
 
35
- import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs';
35
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
36
36
  import { mkdirSync } from 'node:fs';
37
- import { dirname, join } from 'node:path';
37
+ import { basename, dirname, join } from 'node:path';
38
38
  import { homedir } from 'node:os';
39
39
  import {
40
40
  migrateLegacyLearning,
@@ -43,6 +43,7 @@ import {
43
43
  import { LearningsLockError, withLearningsLock } from './lib/learnings/io.mjs';
44
44
  import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-repo.mjs';
45
45
  import { validatePathInsideProject } from './lib/path-utils.mjs';
46
+ import { atomicWriteWithBackup, envelopeToError } from './lib/io.mjs';
46
47
 
47
48
  const LEARNINGS_REL = '.orchestrator/metrics/learnings.jsonl';
48
49
 
@@ -316,12 +317,13 @@ function migrateStore(repoPath, learningsPath, apply) {
316
317
  try {
317
318
  // Backup original
318
319
  copyFileSync(learningsPath, backupPath);
319
- // Write migrated content atomically
320
+ // Write migrated content atomically (own backup above keeps its
321
+ // `.bak-cross-repo-migrate-<ms>` schema, so the helper takes none)
320
322
  const body = outputLines.join('\n') + '\n';
321
- const tmpPath = `${learningsPath}.migrate-cross-repo-tmp-${process.pid}-${timestamp}`;
322
- mkdirSync(dirname(learningsPath), { recursive: true });
323
- writeFileSync(tmpPath, body, 'utf8');
324
- renameSync(tmpPath, learningsPath);
323
+ const res = atomicWriteWithBackup(learningsPath, body, {
324
+ tmpPrefix: `.${basename(learningsPath)}.tmp`,
325
+ });
326
+ if (!res.ok) throw envelopeToError(res);
325
327
  } catch (err) {
326
328
  return {
327
329
  repo: repoPath,
@@ -15,10 +15,19 @@
15
15
  * Steps (each skipped when its harness is not installed on this host):
16
16
  * 1. repo — `git fetch origin` + `git merge --ff-only origin/main`, then
17
17
  * `npm ci` when HEAD moved or node_modules is missing.
18
- * 2. claude — marketplace update + plugin update, then verify version and
19
- * runtime dependencies in the reported installPath.
18
+ * 2. claude — `npm pack` this clone and extract it into STAGE_DIR, then
19
+ * marketplace update + plugin update, then verify version and
20
+ * runtime dependencies in the reported installPath. The
21
+ * marketplace must read STAGE_DIR, not this clone: a directory
22
+ * marketplace on the working checkout copied ALL of it into the
23
+ * plugin cache — 7557 files instead of 1164, `.env.local`
24
+ * (NPM_TOKEN) and `.orchestrator/` included (#1515, measured
25
+ * 2026-10-03). The step refuses, naming the remove + add
26
+ * commands, while ANY directory marketplace reads this clone;
27
+ * GitHub/npm sources update as before, with a one-line hint.
20
28
  * 3. codex — only if the plugin is already installed in Codex: re-run
21
- * scripts/codex-install.mjs (refreshes the installed bundle).
29
+ * scripts/codex-install.mjs, which installs from the same
30
+ * packed STAGE_DIR (#1518), never from this clone.
22
31
  *
23
32
  * Never deletes an old plugin cache directory: the running session still
24
33
  * loads it until the harness restarts.
@@ -26,20 +35,78 @@
26
35
  * Exit codes: 0 all steps ok/skipped · 1 usage error · 2 a step failed.
27
36
  */
28
37
 
29
- import { existsSync, readFileSync } from 'node:fs';
38
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
39
+ import os from 'node:os';
30
40
  import path from 'node:path';
31
- import { spawnSync } from 'node:child_process';
41
+ import { commandOnPath, spawnNativeSync } from './lib/native-command.mjs';
32
42
  import { fileURLToPath } from 'node:url';
33
43
 
34
44
  import { isMainModule } from './lib/is-main-module.mjs';
45
+ import { resolveStageDir, stagePackage } from './lib/plugin-package-stage.mjs';
35
46
 
36
47
  const SO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
48
+ // Codex install id; the Claude id is read from the installed entry, because
49
+ // the marketplace name is whatever the host registered (`kanevry` per
50
+ // .claude-plugin/marketplace.json, `session-orchestrator` on older hosts).
37
51
  const PLUGIN_ID = 'session-orchestrator@kanevry';
38
- const MARKETPLACE = 'kanevry';
52
+ const PLUGIN_NAME = 'session-orchestrator';
39
53
  const COMMAND_TIMEOUT_MS = 300_000;
40
54
  // `codex plugin list --available --json` is 2.56 MB on a host with several
41
55
  // marketplaces (measured 2026-10-02); the 1 MiB default fails with ENOBUFS.
42
56
  const MAX_BUFFER = 64 * 1024 * 1024;
57
+ /** Host-local copy of the packed package; the marketplace reads this, never the clone. */
58
+ const STAGE_DIR = resolveStageDir();
59
+
60
+ /**
61
+ * Claude Code config dir: CLAUDE_CONFIG_DIR when absolute, else ~/.claude. A
62
+ * relative value is ignored like a relative XDG_CACHE_HOME — resolved against
63
+ * whatever cwd this script runs in, it would point the marketplace guard at a
64
+ * file that does not exist and let the update run unguarded (#1519).
65
+ * @param {NodeJS.ProcessEnv} [env]
66
+ * @param {string} [home]
67
+ * @returns {{dir: string, ignored: string | null}}
68
+ */
69
+ export function resolveClaudeConfigDir(env = process.env, home = os.homedir()) {
70
+ const raw = (env.CLAUDE_CONFIG_DIR || '').trim();
71
+ if (path.isAbsolute(raw)) return { dir: raw, ignored: null };
72
+ return { dir: path.join(home, '.claude'), ignored: raw || null };
73
+ }
74
+
75
+ const CLAUDE_CONFIG = resolveClaudeConfigDir();
76
+ const KNOWN_MARKETPLACES = path.join(CLAUDE_CONFIG.dir, 'plugins', 'known_marketplaces.json');
77
+
78
+ const realOrResolved = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
79
+
80
+ /**
81
+ * Registered Claude Code marketplaces from `known_marketplaces.json`, read
82
+ * defensively: a missing or unparseable file yields `null`, never a throw.
83
+ * @param {string} file
84
+ * @returns {Array<{name: string, source: string | undefined, path: string | undefined}> | null}
85
+ */
86
+ export function readKnownMarketplaces(file) {
87
+ let raw;
88
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
89
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
90
+ return Object.entries(raw).map(([name, v]) => ({
91
+ name,
92
+ source: typeof v?.source?.source === 'string' ? v.source.source : undefined,
93
+ path: typeof v?.source?.path === 'string' ? v.source.path : undefined,
94
+ }));
95
+ }
96
+
97
+ /**
98
+ * Names of the `directory` marketplaces that read `dir` (realpath-compared) —
99
+ * whatever they are called.
100
+ * @param {ReturnType<typeof readKnownMarketplaces>} known
101
+ * @param {string} dir
102
+ * @returns {string[]}
103
+ */
104
+ export function directoryMarketplacesOn(known, dir) {
105
+ const target = realOrResolved(dir);
106
+ return (known ?? [])
107
+ .filter((m) => m.source === 'directory' && m.path && realOrResolved(m.path) === target)
108
+ .map((m) => m.name);
109
+ }
43
110
 
44
111
  /**
45
112
  * Pick the session-orchestrator entry from `claude plugin list --json`.
@@ -50,8 +117,9 @@ const MAX_BUFFER = 64 * 1024 * 1024;
50
117
  export function findClaudeEntry(listJson) {
51
118
  const entries = Array.isArray(listJson) ? listJson : listJson?.plugins;
52
119
  if (!Array.isArray(entries)) return null;
53
- return entries.find((e) => e?.id === PLUGIN_ID && e?.scope === 'user')
54
- ?? entries.find((e) => e?.id === PLUGIN_ID)
120
+ const ours = (e) => typeof e?.id === 'string' && e.id.startsWith(`${PLUGIN_NAME}@`);
121
+ return entries.find((e) => ours(e) && e?.scope === 'user')
122
+ ?? entries.find(ours)
55
123
  ?? null;
56
124
  }
57
125
 
@@ -90,16 +158,16 @@ to the newest Session Orchestrator, and verify each one afterwards.
90
158
  --json Print the per-step result as JSON
91
159
  `;
92
160
 
93
- function makeRunner({ dryRun, json }) {
161
+ export function makeRunner({ dryRun, json }) {
94
162
  const log = (line) => { if (!json) process.stdout.write(`${line}\n`); };
95
163
  /** Run a command; read-only queries also run under --dry-run. */
96
- function run(cmd, args, { cwd = SO_ROOT, mutates = true } = {}) {
164
+ function run(cmd, args, { cwd = SO_ROOT, mutates = true, env } = {}) {
97
165
  const shown = `${cmd} ${args.join(' ')}`;
98
166
  if (dryRun && mutates) {
99
167
  log(` [dry-run] ${shown}${cwd === SO_ROOT ? '' : ` (in ${cwd})`}`);
100
168
  return { ok: true, stdout: '' };
101
169
  }
102
- const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: MAX_BUFFER });
170
+ const r = spawnNativeSync(cmd, args, { cwd, env, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: MAX_BUFFER });
103
171
  if (r.error) return { ok: false, detail: `${shown}: ${r.error.message}` };
104
172
  if (r.status !== 0) {
105
173
  return { ok: false, detail: `${shown} exited ${r.status}: ${(r.stderr || r.stdout || '').trim().slice(-400)}` };
@@ -109,12 +177,8 @@ function makeRunner({ dryRun, json }) {
109
177
  return { run, log };
110
178
  }
111
179
 
112
- function onPath(bin) {
113
- return spawnSync('which', [bin], { encoding: 'utf8' }).status === 0;
114
- }
115
-
116
- function repoVersion() {
117
- return JSON.parse(readFileSync(path.join(SO_ROOT, 'package.json'), 'utf8')).version;
180
+ function repoVersion(root = SO_ROOT) {
181
+ return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).version;
118
182
  }
119
183
 
120
184
  function updateRepo({ run, log }, opts) {
@@ -139,36 +203,112 @@ function updateRepo({ run, log }, opts) {
139
203
  return { step: 'repo', status: 'ok', detail: `${repoVersion()}${moved ? ' (updated)' : ' (already current)'}` };
140
204
  }
141
205
 
142
- function updateClaude({ run, log }, opts) {
143
- if (!onPath('claude')) return { step: 'claude', status: 'skipped', detail: 'claude not on PATH' };
144
- log('claude: refreshing marketplace and plugin');
145
- for (const args of [['plugin', 'marketplace', 'update', MARKETPLACE], ['plugin', 'update', PLUGIN_ID]]) {
146
- const r = run('claude', args);
147
- if (!r.ok) return { step: 'claude', status: 'failed', detail: r.detail };
206
+ /** Marketplace name the packed copy registers under (its own marketplace.json). */
207
+ function packedMarketplaceName(root) {
208
+ try {
209
+ return JSON.parse(readFileSync(path.join(root, '.claude-plugin', 'marketplace.json'), 'utf8')).name || 'kanevry';
210
+ } catch {
211
+ return 'kanevry';
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The Claude Code step. `ctx` exists for tests (tmp config dir, tmp clone,
217
+ * no PATH lookup); production passes nothing.
218
+ */
219
+ export function updateClaude({ run, log }, opts, ctx = {}) {
220
+ const {
221
+ soRoot = SO_ROOT,
222
+ stageDir = STAGE_DIR,
223
+ knownMarketplacesFile = KNOWN_MARKETPLACES,
224
+ ignoredConfigDir = CLAUDE_CONFIG.ignored,
225
+ hasClaude = () => commandOnPath('claude'),
226
+ } = ctx;
227
+ const fail = (detail) => ({ step: 'claude', status: 'failed', detail });
228
+ if (!hasClaude()) return { step: 'claude', status: 'skipped', detail: 'claude not on PATH' };
229
+ if (ignoredConfigDir) {
230
+ log(`claude: ignoring relative CLAUDE_CONFIG_DIR '${ignoredConfigDir}' — reading ${knownMarketplacesFile}`);
231
+ }
232
+ const stage = () => stagePackage({
233
+ soRoot, stageDir, run, log: (line) => log(`claude: ${line}`), dryRun: opts.dryRun,
234
+ });
235
+
236
+ const parseEntry = (r) => {
237
+ if (!r.ok) return { error: r.detail };
238
+ try { return { entry: findClaudeEntry(JSON.parse(r.stdout)) }; } catch (e) {
239
+ return { error: `plugin list --json unparseable: ${e.message}` };
240
+ }
241
+ };
242
+ // Installed-check first: with nothing installed there is nothing to guard,
243
+ // and the refusal below would replace the host's stage dir for no install (#1519).
244
+ const before = parseEntry(run('claude', ['plugin', 'list', '--json'], { mutates: false }));
245
+ if (before.error) return fail(before.error);
246
+ if (!before.entry) return { step: 'claude', status: 'skipped', detail: `${PLUGIN_NAME} not installed` };
247
+
248
+ const known = readKnownMarketplaces(knownMarketplacesFile);
249
+ const leaking = directoryMarketplacesOn(known, soRoot);
250
+ if (leaking.length > 0) {
251
+ // Stage first so the `add` target named below exists.
252
+ const staged = stage();
253
+ const packedName = packedMarketplaceName(soRoot);
254
+ // A marketplace already holding the packed copy's name (e.g. a GitHub
255
+ // `kanevry`) would make `marketplace add <stage>` fail on the taken name.
256
+ const sameName = known?.find((m) => m.name === packedName);
257
+ const nameTaken = sameName && !leaking.includes(packedName)
258
+ && !(sameName.source === 'directory' && sameName.path && realOrResolved(sameName.path) === realOrResolved(stageDir));
259
+ const removes = [...leaking, ...(nameTaken ? [packedName] : [])]
260
+ .map((n) => `claude plugin marketplace remove ${n}`).join(' && ');
261
+ return fail(
262
+ `directory marketplace ${leaking.map((n) => `'${n}'`).join(', ')} reads this clone — the plugin cache `
263
+ + `becomes a full copy incl. .env.local (#1515). Re-register once: ${removes} && `
264
+ + `claude plugin marketplace add ${stageDir} && claude plugin install ${PLUGIN_NAME}@${packedName}`
265
+ + (staged.ok ? '' : ` (staging failed: ${staged.detail})`),
266
+ );
148
267
  }
149
- const list = run('claude', ['plugin', 'list', '--json'], { mutates: false });
150
- if (!list.ok) return { step: 'claude', status: 'failed', detail: list.detail };
151
- let entry;
152
- try { entry = findClaudeEntry(JSON.parse(list.stdout)); } catch (e) {
153
- return { step: 'claude', status: 'failed', detail: `plugin list --json unparseable: ${e.message}` };
268
+
269
+ const pluginId = before.entry.id;
270
+ const marketplace = pluginId.slice(pluginId.indexOf('@') + 1);
271
+ const reg = known?.find((m) => m.name === marketplace);
272
+ const fromStage = reg?.source === 'directory' && !!reg.path && realOrResolved(reg.path) === realOrResolved(stageDir);
273
+
274
+ if (fromStage) {
275
+ const staged = stage();
276
+ if (!staged.ok) return fail(staged.detail);
277
+ } else {
278
+ log(known
279
+ ? `claude: marketplace '${marketplace}' reads ${reg?.path ?? reg?.source ?? 'an unknown source'}; to install this clone instead, register the packed copy at ${stageDir}`
280
+ : `claude: ${knownMarketplacesFile} unreadable — cannot check the marketplace source; the packed-copy path is ${stageDir}`);
154
281
  }
155
- if (!entry) return { step: 'claude', status: 'skipped', detail: `${PLUGIN_ID} not installed` };
156
- if (!entry.installPath || !existsSync(entry.installPath)) {
157
- return { step: 'claude', status: 'failed', detail: `installPath missing: ${entry.installPath}` };
282
+
283
+ log(`claude: refreshing marketplace '${marketplace}' and plugin ${pluginId}`);
284
+ for (const args of [['plugin', 'marketplace', 'update', marketplace], ['plugin', 'update', pluginId]]) {
285
+ const r = run('claude', args);
286
+ if (!r.ok) return fail(r.detail);
158
287
  }
288
+ const after = parseEntry(run('claude', ['plugin', 'list', '--json'], { mutates: false }));
289
+ if (after.error) return fail(after.error);
290
+ const entry = after.entry;
291
+ if (!entry) return fail(`${pluginId} disappeared from plugin list after the update`);
292
+ if (!entry.installPath || !existsSync(entry.installPath)) return fail(`installPath missing: ${entry.installPath}`);
159
293
  const missing = missingRuntimeDeps(entry.installPath);
160
294
  if (missing.length > 0) {
161
295
  const refused = (entry.noteDetails ?? []).some((n) => n?.type === 'dependencies-refused');
162
296
  log(`claude: ${missing.length} runtime deps missing${refused ? ' (harness refused the install)' : ''} — installing`);
163
- const r = run('npm', ['ci', '--omit=dev'], { cwd: entry.installPath });
164
- if (!r.ok) return { step: 'claude', status: 'failed', detail: r.detail };
297
+ // The packed copy carries no .npmrc, so the repo's ignore-scripts=true
298
+ // (SEC-020) has to travel as a flag.
299
+ const r = run('npm', ['ci', '--omit=dev', '--ignore-scripts'], { cwd: entry.installPath });
300
+ if (!r.ok) return fail(r.detail);
165
301
  const still = opts.dryRun ? [] : missingRuntimeDeps(entry.installPath);
166
- if (still.length > 0) return { step: 'claude', status: 'failed', detail: `still missing after npm ci: ${still.join(', ')}` };
302
+ if (still.length > 0) return fail(`still missing after npm ci: ${still.join(', ')}`);
303
+ }
304
+ const want = repoVersion(soRoot);
305
+ if (entry.version !== want && fromStage && !opts.dryRun) {
306
+ // The source IS this clone's packed copy, so a different version means
307
+ // the update did not take effect — not that a remote is behind.
308
+ return fail(`update did not take effect: installed ${entry.version}, packed ${want}`);
167
309
  }
168
- const want = repoVersion();
169
- // The marketplace tracks GitHub main, so it can trail this clone until the
170
- // release is pushed — report it, do not fail on it.
171
- const lag = entry.version !== want ? `; clone is ${want} — marketplace not yet at it` : '';
310
+ const lag = entry.version !== want && !fromStage
311
+ ? `; clone is ${want} — marketplace '${marketplace}' source is not at it yet` : '';
172
312
  return {
173
313
  step: 'claude',
174
314
  status: 'ok',
@@ -177,7 +317,7 @@ function updateClaude({ run, log }, opts) {
177
317
  }
178
318
 
179
319
  function updateCodex({ run, log }, opts) {
180
- if (!onPath('codex')) return { step: 'codex', status: 'skipped', detail: 'codex not on PATH' };
320
+ if (!commandOnPath('codex')) return { step: 'codex', status: 'skipped', detail: 'codex not on PATH' };
181
321
  const list = run('codex', ['plugin', 'list'], { mutates: false });
182
322
  if (!list.ok) return { step: 'codex', status: 'failed', detail: list.detail };
183
323
  if (!new RegExp(`^${PLUGIN_ID}\\s+installed`, 'm').test(list.stdout)) {