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
@@ -28,8 +28,9 @@
28
28
  * {"mode":"dry-run|apply|mark-deprecated-only","total":N,"rewritten":N,"deprecated":N,"unchanged":N,"backup":"<path>|null"}
29
29
  */
30
30
 
31
- import { readFileSync, writeFileSync, renameSync, copyFileSync, existsSync } from 'node:fs';
32
- import { join, dirname, basename } from 'node:path';
31
+ import { readFileSync, copyFileSync, existsSync } from 'node:fs';
32
+ import { basename } from 'node:path';
33
+ import { atomicWriteWithBackup, envelopeToError } from './lib/io.mjs';
33
34
  import { validateSession, normalizeSession, ValidationError } from './lib/session-schema.mjs';
34
35
 
35
36
  const DEFAULT_FILE = '.orchestrator/metrics/sessions.jsonl';
@@ -253,17 +254,18 @@ async function main() {
253
254
  // by a pre-write copy-to-backup. This eliminates the crash window where the
254
255
  // canonical path could be briefly missing if the rename dance were split:
255
256
  // 1. copyFileSync: canonical → .bak-<ISO> (backup; canonical still present)
256
- // 2. writeFileSync: rewritten → tmp (new content; canonical still present)
257
- // 3. renameSync: tmp → canonical (atomic replace per POSIX)
257
+ // 2+3. atomicWriteWithBackup: rewritten → tmp → rename over canonical
258
+ // (atomic replace per POSIX; the helper unlinks its tmp on failure)
258
259
  // At every point between steps, the canonical file exists and holds either
259
260
  // the old content or the new content. Recovery artifact: .bak-<ISO>.
260
261
  const iso = new Date().toISOString().replace(/[:.]/g, '-');
261
262
  const backupPath = `${args.file}.bak-${iso}`;
262
- const tmpPath = join(dirname(args.file), `.${basename(args.file)}.tmp-${process.pid}`);
263
263
  try {
264
264
  copyFileSync(args.file, backupPath);
265
- writeFileSync(tmpPath, rewritten.join('\n') + '\n', 'utf8');
266
- renameSync(tmpPath, args.file);
265
+ const res = atomicWriteWithBackup(args.file, rewritten.join('\n') + '\n', {
266
+ tmpPrefix: `.${basename(args.file)}.tmp`,
267
+ });
268
+ if (!res.ok) throw envelopeToError(res);
267
269
  summary.backup = backupPath;
268
270
  } catch (err) {
269
271
  const recoveryHint = existsSync(backupPath)
@@ -2,22 +2,31 @@
2
2
  /**
3
3
  * Install Session Orchestrator through Codex's public plugin lifecycle.
4
4
  *
5
+ * The marketplace reads the host-local packed copy (`npm pack` of this clone,
6
+ * with runtime dependencies, under ~/.cache/session-orchestrator/plugin-package),
7
+ * never the clone itself: a marketplace on the working checkout copied all of
8
+ * it into the Codex plugin cache — 15742 files, `.env.local`, `.orchestrator/`
9
+ * and `tests/` included (#1518, measured 2026-10-03).
10
+ *
5
11
  * Exit codes:
6
12
  * 0 — installed, enabled, and verified
7
13
  * 1 — argument or user-correctable environment error
8
14
  * 2 — system, upstream command/JSON, or runtime postcondition failure
9
15
  */
10
16
 
11
- import { readFileSync } from 'node:fs';
17
+ import { readFileSync, realpathSync } from 'node:fs';
18
+ import os from 'node:os';
12
19
  import path from 'node:path';
13
20
  import { spawnSync } from 'node:child_process';
14
21
  import { fileURLToPath } from 'node:url';
15
22
 
16
23
  import { validateCodexPluginContract } from './lib/codex/plugin-contract.mjs';
24
+ import { resolveStageDir, stagePackage } from './lib/plugin-package-stage.mjs';
17
25
 
18
26
  const __filename = fileURLToPath(import.meta.url);
19
27
  const SCRIPT_DIR = path.dirname(__filename);
20
28
  const SO_ROOT = path.dirname(SCRIPT_DIR);
29
+ const STAGE_DIR = resolveStageDir();
21
30
 
22
31
  const MIN_CODEX_VERSION = [0, 144, 4];
23
32
  const MARKETPLACE_NAME = 'kanevry';
@@ -166,27 +175,28 @@ function requireStableFeature(features, name) {
166
175
  }
167
176
  }
168
177
 
169
- function readLocalJson(relativePath, label) {
178
+ function readLocalJson(relativePath, label, root = SO_ROOT) {
170
179
  try {
171
- return JSON.parse(readFileSync(path.join(SO_ROOT, relativePath), 'utf8'));
180
+ return JSON.parse(readFileSync(path.join(root, relativePath), 'utf8'));
172
181
  } catch (error) {
173
182
  throw new InstallerError(`Cannot read valid ${label}: ${error.message}`);
174
183
  }
175
184
  }
176
185
 
177
- function readBaseVersion() {
178
- const packageJson = readLocalJson('package.json', 'package.json');
186
+ function readBaseVersion(root = SO_ROOT) {
187
+ const packageJson = readLocalJson('package.json', 'package.json', root);
179
188
  if (typeof packageJson.version !== 'string' || packageJson.version.trim() === '') {
180
189
  throw new InstallerError('package.json must contain a non-empty version string.');
181
190
  }
182
191
  return packageJson.version;
183
192
  }
184
193
 
185
- function validateLocalContract() {
186
- const expectedBaseVersion = readBaseVersion();
187
- const manifest = readLocalJson(path.join('.codex-plugin', 'plugin.json'), 'Codex plugin manifest');
194
+ /** Validates the plugin Codex will actually install: the staged packed copy. */
195
+ function validateLocalContract(root) {
196
+ const expectedBaseVersion = readBaseVersion(root);
197
+ const manifest = readLocalJson(path.join('.codex-plugin', 'plugin.json'), 'Codex plugin manifest', root);
188
198
  const verdict = validateCodexPluginContract({
189
- pluginRoot: SO_ROOT,
199
+ pluginRoot: root,
190
200
  expectedBaseVersion,
191
201
  });
192
202
 
@@ -210,12 +220,34 @@ function requireArray(value, label) {
210
220
  return value;
211
221
  }
212
222
 
223
+ const realOrResolved = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
224
+
213
225
  function sameLocalPath(left, right) {
214
- return typeof left === 'string' && path.resolve(left) === path.resolve(right);
226
+ return typeof left === 'string' && realOrResolved(left) === realOrResolved(right);
227
+ }
228
+
229
+ function marketplaceSource(entry) {
230
+ return { sourceType: entry.marketplaceSource?.sourceType, source: entry.marketplaceSource?.source ?? entry.root };
215
231
  }
216
232
 
217
233
  function assertMarketplaceCompatible(payload) {
218
234
  const marketplaces = requireArray(payload.marketplaces, 'marketplaces');
235
+ // Any local marketplace on the working checkout copies all of it into the
236
+ // plugin cache (#1518) — refuse, naming the one-time re-registration.
237
+ const onCheckout = marketplaces.filter((entry) => {
238
+ if (!isRecord(entry)) return false;
239
+ const { sourceType, source } = marketplaceSource(entry);
240
+ return sourceType === 'local' && sameLocalPath(source, SO_ROOT);
241
+ });
242
+ if (onCheckout.length > 0) {
243
+ const removes = onCheckout.map((entry) => `codex plugin marketplace remove ${String(entry.name)}`).join(' && ');
244
+ throw new InstallerError(
245
+ `Marketplace ${onCheckout.map((entry) => `'${String(entry.name)}'`).join(', ')} reads this clone '${SO_ROOT}' — `
246
+ + `the Codex plugin cache becomes a full copy incl. .env.local (#1518). Re-register once: `
247
+ + `${removes} && node scripts/codex-install.mjs (it adds the packed copy at '${STAGE_DIR}').`,
248
+ ERROR_CATEGORIES.USER,
249
+ );
250
+ }
219
251
  const matches = marketplaces.filter((entry) => isRecord(entry) && entry.name === MARKETPLACE_NAME);
220
252
 
221
253
  if (matches.length > 1) {
@@ -227,28 +259,61 @@ function assertMarketplaceCompatible(payload) {
227
259
  }
228
260
  if (matches.length === 0) return;
229
261
 
230
- const existing = matches[0];
231
- const sourceType = existing.marketplaceSource?.sourceType;
232
- const source = existing.marketplaceSource?.source ?? existing.root;
233
- if (sourceType !== 'local' || !sameLocalPath(source, SO_ROOT)) {
262
+ const { sourceType, source } = marketplaceSource(matches[0]);
263
+ if (sourceType !== 'local' || !sameLocalPath(source, STAGE_DIR)) {
234
264
  throw new InstallerError(
235
265
  `Marketplace '${MARKETPLACE_NAME}' already points to '${String(source)}' (${String(sourceType)}), `
236
- + `not '${SO_ROOT}'. Resolve the conflict manually after reviewing `
266
+ + `not '${STAGE_DIR}'. Resolve the conflict manually after reviewing `
237
267
  + `'codex plugin marketplace list --json'; this installer will not remove or replace it.`,
238
268
  ERROR_CATEGORIES.USER,
239
269
  );
240
270
  }
241
271
  }
242
272
 
273
+ /**
274
+ * Codex's personal marketplace (`~/.agents/plugins/marketplace.json`, paths
275
+ * relative to the home dir) may list a plugin that resolves to this clone — on
276
+ * the reference host `~/plugins/session-orchestrator` is a symlink to the main
277
+ * checkout, and the resulting `session-orchestrator@local` cache held
278
+ * `.env.local` (#1518). That catalog belongs to the operator, so this is a
279
+ * read-only hint naming the cleanup, never a refusal or an edit.
280
+ */
281
+ function printPersonalMarketplaceHint(json) {
282
+ const home = os.homedir();
283
+ const catalog = path.join(home, '.agents', 'plugins', 'marketplace.json');
284
+ let plugins;
285
+ try {
286
+ plugins = JSON.parse(readFileSync(catalog, 'utf8')).plugins;
287
+ } catch {
288
+ return;
289
+ }
290
+ if (!Array.isArray(plugins)) return;
291
+ const hits = plugins.filter((plugin) => (
292
+ isRecord(plugin) && typeof plugin.source?.path === 'string'
293
+ && sameLocalPath(path.resolve(home, plugin.source.path), SO_ROOT)
294
+ ));
295
+ for (const plugin of hits) {
296
+ const linked = path.resolve(home, plugin.source.path);
297
+ const message = `Hint: ${catalog} lists '${String(plugin.name)}' at '${linked}', which resolves to this clone — `
298
+ + `its Codex cache copies the whole checkout incl. .env.local (#1518). Cleanup, once: `
299
+ + `(1) let this installer finish (it removes ${PLUGIN_NAME}@local after a healthy install); `
300
+ + `(2) remove that entry from ${catalog}; (3) delete the link '${linked}' if it is a symlink; `
301
+ + `(4) after restarting Codex, delete the old cache folders under ~/.codex/plugins/cache/local/ `
302
+ + `(${PLUGIN_NAME}/ and any plugin-install-*/ or plugin-backup-*/ holding ${PLUGIN_NAME}).`;
303
+ if (json) process.stderr.write(`${message}\n`);
304
+ else progress(json, message);
305
+ }
306
+ }
307
+
243
308
  function assertMarketplaceAddResult(payload) {
244
309
  if (payload.marketplaceName !== MARKETPLACE_NAME) {
245
310
  throw new InstallerError(
246
311
  `Marketplace add returned '${String(payload.marketplaceName)}', expected '${MARKETPLACE_NAME}'.`,
247
312
  );
248
313
  }
249
- if (!sameLocalPath(payload.installedRoot, SO_ROOT)) {
314
+ if (!sameLocalPath(payload.installedRoot, STAGE_DIR)) {
250
315
  throw new InstallerError(
251
- `Marketplace add returned root '${String(payload.installedRoot)}', expected '${SO_ROOT}'.`,
316
+ `Marketplace add returned root '${String(payload.installedRoot)}', expected '${STAGE_DIR}'.`,
252
317
  );
253
318
  }
254
319
  if (typeof payload.alreadyAdded !== 'boolean') {
@@ -336,18 +401,38 @@ function install({ json }) {
336
401
  requireStableFeature(features, 'hooks');
337
402
  progress(json, 'Features: plugins stable true; hooks stable true');
338
403
 
339
- const expectedVersion = validateLocalContract();
340
- progress(json, `Local contract: valid (${expectedVersion})`);
341
-
342
404
  const marketplaceList = runCodexJson(
343
405
  ['plugin', 'marketplace', 'list', '--json'],
344
406
  'Marketplace list',
345
407
  );
408
+ // Checked before staging: a refused install must not replace the stage dir.
346
409
  assertMarketplaceCompatible(marketplaceList);
347
410
 
348
- progress(json, `Marketplace: adding '${SO_ROOT}'`);
411
+ printPersonalMarketplaceHint(json);
412
+
413
+ // The contract is checked on the new copy BEFORE it replaces the shared
414
+ // stage: an invalid copy must never become what Claude Code or Codex reads.
415
+ let expectedVersion;
416
+ const staged = stagePackage({
417
+ soRoot: SO_ROOT,
418
+ stageDir: STAGE_DIR,
419
+ installDeps: true,
420
+ log: (line) => progress(json, `Package: ${line}`),
421
+ validate: (dir) => {
422
+ try {
423
+ expectedVersion = validateLocalContract(dir);
424
+ return { ok: true };
425
+ } catch (error) {
426
+ return { ok: false, detail: error.message };
427
+ }
428
+ },
429
+ });
430
+ if (!staged.ok) throw new InstallerError(`Cannot stage the packed copy: ${staged.detail}`);
431
+ progress(json, `Local contract: valid (${expectedVersion})`);
432
+
433
+ progress(json, `Marketplace: adding '${STAGE_DIR}'`);
349
434
  const marketplaceAdd = runCodexJson(
350
- ['plugin', 'marketplace', 'add', SO_ROOT, '--json'],
435
+ ['plugin', 'marketplace', 'add', STAGE_DIR, '--json'],
351
436
  'Marketplace add',
352
437
  );
353
438
  assertMarketplaceAddResult(marketplaceAdd);
@@ -399,7 +484,7 @@ function install({ json }) {
399
484
  ok: true,
400
485
  codexVersion: formatVersion(version),
401
486
  marketplace: MARKETPLACE_NAME,
402
- marketplaceSource: SO_ROOT,
487
+ marketplaceSource: STAGE_DIR,
403
488
  marketplaceAlreadyAdded: marketplaceAdd.alreadyAdded,
404
489
  pluginId: PLUGIN_ID,
405
490
  pluginVersion: expectedVersion,
@@ -29,6 +29,7 @@
29
29
 
30
30
  import { existsSync, readFileSync } from 'node:fs';
31
31
  import { dirname, join } from 'node:path';
32
+ import { resolveEventsLedgerRoot } from './lib/platform.mjs';
32
33
  import { appendJsonl } from './lib/common.mjs';
33
34
  import {
34
35
  MEMORY_CLEANUP_EVENT,
@@ -49,6 +50,18 @@ import {
49
50
  } from './lib/session-schema.mjs';
50
51
  import { isMainModule } from './lib/is-main-module.mjs';
51
52
 
53
+ /**
54
+ * The events ledger beside `sessionsFile` — mapped to the main checkout when the
55
+ * metrics dir sits in a LINKED git worktree (#1514 point 1: one ledger per repo,
56
+ * the one the hooks write). Identity everywhere else, including a tmp fixture.
57
+ *
58
+ * @param {string} sessionsFile
59
+ * @returns {string}
60
+ */
61
+ function siblingEventsFile(sessionsFile) {
62
+ return join(resolveEventsLedgerRoot(dirname(sessionsFile)), 'events.jsonl');
63
+ }
64
+
52
65
  export { serializeSessionLineChecked };
53
66
 
54
67
  // A FULL object name only (SHA-1 or SHA-256 repo). A short sha or a symbolic
@@ -314,7 +327,7 @@ async function main() {
314
327
  const alreadyStamped =
315
328
  typeof repaired.memory_cleanup_at === 'string' && repaired.memory_cleanup_at.length > 0;
316
329
  if (!alreadyStamped) {
317
- const eventsFile = join(dirname(args.file), 'events.jsonl');
330
+ const eventsFile = siblingEventsFile(args.file);
318
331
  const signal = deriveMemoryCleanupSignal({
319
332
  eventsFile,
320
333
  sessionId: repaired.session_id,
@@ -455,7 +468,7 @@ async function main() {
455
468
  // without one the fallback cannot run, which is exactly when silence hid it.
456
469
  if (!hasOwn('session_start_ref')) {
457
470
  const headSha = ownUuid !== null
458
- ? readOwnStartHeadSha(join(dirname(args.file), 'events.jsonl'), ownUuid)
471
+ ? readOwnStartHeadSha(siblingEventsFile(args.file), ownUuid)
459
472
  : null;
460
473
  if (headSha !== null) {
461
474
  repaired = { ...repaired, session_start_ref: headSha };
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fleet-auflagen.mjs — read this session's navigator conditions ("Auflagen")
4
+ * and accept them only when they belong to this repo (#1520, navigator#190).
5
+ *
6
+ * Thin CLI over `readAuflagen()` in `scripts/lib/fleet-protocol.mjs`, called by
7
+ * session-start Phase 7.6 after the check-in.
8
+ *
9
+ * Usage:
10
+ * node scripts/fleet-auflagen.mjs <session_id>
11
+ *
12
+ * The own repo is `CLAUDE_PROJECT_DIR` (trimmed) or the working directory.
13
+ *
14
+ * stdout: one JSON line — `{ state: 'absent' }`, `{ state: 'accepted', auflagen }`
15
+ * or `{ state: 'rejected', reason }`. A rejected file counts as absent: the
16
+ * Standard-Auflagen apply. On rejection a WARN line goes to stderr.
17
+ *
18
+ * Exit codes:
19
+ * 0 — any of the three states (rejection is a result, not an error)
20
+ * 2 — usage error (no session id)
21
+ */
22
+
23
+ import { readAuflagen } from './lib/fleet-protocol.mjs';
24
+ import { isMainModule } from './lib/is-main-module.mjs';
25
+
26
+ const PREFIX = 'fleet-auflagen:';
27
+
28
+ /** @returns {Promise<number>} exit code */
29
+ async function main() {
30
+ const sessionId = process.argv[2];
31
+ if (!sessionId) {
32
+ process.stderr.write(`${PREFIX} usage: fleet-auflagen.mjs <session_id>\n`);
33
+ return 2;
34
+ }
35
+ const repoRoot = (process.env.CLAUDE_PROJECT_DIR || '').trim() || process.cwd();
36
+ const result = await readAuflagen(sessionId, { repoRoot });
37
+ if (result.state === 'rejected') {
38
+ process.stderr.write(
39
+ `${PREFIX} WARN: auflagen file ignored (${result.reason}) — treated as absent, Standard-Auflagen apply\n`,
40
+ );
41
+ }
42
+ process.stdout.write(JSON.stringify(result) + '\n');
43
+ return 0;
44
+ }
45
+
46
+ if (isMainModule(import.meta.url)) {
47
+ main().then(
48
+ (code) => { process.exitCode = code; },
49
+ (err) => {
50
+ process.stderr.write(`${PREFIX} ${err instanceof Error ? err.message : String(err)}\n`);
51
+ process.exitCode = 1;
52
+ },
53
+ );
54
+ }
@@ -54,7 +54,8 @@ import path from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import { execFileSync } from 'node:child_process';
56
56
  import yaml from 'js-yaml';
57
- import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
57
+ import { isUserInvocableSkill } from './lib/user-invocable-skills.mjs';
58
+ import { listCommandFiles, listSkillDirs } from './lib/plugin-surface.mjs';
58
59
 
59
60
  const POLICY_MARKER = '# Generated by scripts/generate-agents-skills.mjs; do not edit.\n';
60
61
 
@@ -185,7 +186,9 @@ export function renderMirror(skillName, portableFm, canonical = `skills/${skillN
185
186
  '',
186
187
  'Read the linked document in full and follow its complete workflow, including prechecks and stop conditions. Resolve its link relative to this SKILL.md, not the project working directory. The plugin root is three directories above this file. Resolve package paths such as `skills/` and `scripts/` from that root and relative links in the canonical document from its own directory. Keep the user’s project as the target of project operations.',
187
188
  ];
188
- if (command || isUserInvocableValue(portableFm.metadata?.['user-invocable'])) lines.push(
189
+ // The source `user-invocable` travels as a string in `metadata`; absent there
190
+ // means absent in the source, which Claude Code reads as invocable (#1515).
191
+ if (command || isUserInvocableSkill(portableFm.metadata ?? {})) lines.push(
189
192
  '',
190
193
  '`$ARGUMENTS` means the trailing user input after the selected command skill, or an empty string when absent. Preserve flags, quoted text, and Unicode as data. Do not perform global substitution in the command document, shell expansion on the argument string, or execution of that string as shell code. Pass command arguments through structured tool parameters or safely quoted individual arguments.',
191
194
  );
@@ -211,24 +214,14 @@ function readFrontmatter(absPath) {
211
214
 
212
215
  /** List `skills/<name>` directories that carry a SKILL.md, sorted. */
213
216
  export function listSourceSkills(pluginRoot) {
214
- const dir = path.join(pluginRoot, 'skills');
215
- if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
216
- return readdirSync(dir, { withFileTypes: true })
217
- .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('_'))
218
- .map((e) => e.name)
219
- .filter((n) => existsSync(path.join(dir, n, 'SKILL.md')))
220
- .sort();
217
+ return listSkillDirs(pluginRoot);
221
218
  }
222
219
 
223
220
  /** Union of canonical skills and commands, preserving command precedence. */
224
221
  function listSourceEntries(pluginRoot) {
225
222
  const sources = new Map(listSourceSkills(pluginRoot).map((name) => [name, `skills/${name}/SKILL.md`]));
226
- const commands = path.join(pluginRoot, 'commands');
227
- if (existsSync(commands) && statSync(commands).isDirectory()) {
228
- for (const entry of readdirSync(commands, { withFileTypes: true })) {
229
- if (!entry.isFile() || !entry.name.endsWith('.md') || /^[._]/.test(entry.name)) continue;
230
- sources.set(entry.name.slice(0, -3), `commands/${entry.name}`);
231
- }
223
+ for (const file of listCommandFiles(pluginRoot)) {
224
+ sources.set(file.slice(0, -3), `commands/${file}`);
232
225
  }
233
226
  return sources;
234
227
  }
@@ -250,7 +243,7 @@ function invocationPolicy(pluginRoot, name, canonical, fm) {
250
243
  return native.policy.allow_implicit_invocation;
251
244
  }
252
245
  }
253
- if (!isUserInvocableValue(fm['user-invocable'], canonical)) return undefined;
246
+ if (!isUserInvocableSkill(fm, canonical)) return undefined;
254
247
  }
255
248
  if (Object.hasOwn(fm, 'disable-model-invocation') && typeof fm['disable-model-invocation'] !== 'boolean') {
256
249
  throw new Error(`${canonical}: disable-model-invocation must be a boolean`);
@@ -9,7 +9,8 @@ import { dirname, join, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import yaml from 'js-yaml';
11
11
  import { toPortableFrontmatter } from './generate-agents-skills.mjs';
12
- import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
12
+ import { isUserInvocableSkill } from './lib/user-invocable-skills.mjs';
13
+ import { isHiddenSourceName } from './lib/plugin-surface.mjs';
13
14
 
14
15
  const OUTPUT = '.codex-plugin/skills';
15
16
  const MARKER = 'Generated by scripts/generate-codex-skills.mjs; do not edit.';
@@ -56,7 +57,8 @@ function validateFrontmatter(fm, name, path) {
56
57
 
57
58
  /**
58
59
  * The repo-wide marker for "operator-facing slash command" — delegated to
59
- * `isUserInvocableValue`, the ONE normaliser, so this generator, the Cursor and
60
+ * `isUserInvocableSkill` (absent key = invocable, #1515; values via the ONE
61
+ * normaliser `isUserInvocableValue`), so this generator, the Cursor and
60
62
  * Pi generators and the counters cannot disagree about a quoted or cased value.
61
63
  *
62
64
  * @param {Record<string, unknown>} fm
@@ -64,7 +66,7 @@ function validateFrontmatter(fm, name, path) {
64
66
  * @returns {boolean}
65
67
  */
66
68
  function isUserInvocable(fm, path) {
67
- return isUserInvocableValue(fm?.['user-invocable'], path);
69
+ return isUserInvocableSkill(fm, path);
68
70
  }
69
71
 
70
72
  /** Existing native policy is meaningful; Claude's user-invocable flag is not a substitute. */
@@ -92,7 +94,7 @@ function readSources(root) {
92
94
  const directoryPath = join(root, directory);
93
95
  if (!existsSync(directoryPath)) continue;
94
96
  for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
95
- if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
97
+ if (isHiddenSourceName(entry.name)) continue;
96
98
  const command = directory === 'commands';
97
99
  if (command ? !entry.isFile() || !entry.name.endsWith('.md') : !entry.isDirectory()) continue;
98
100
  const name = command ? entry.name.slice(0, -3) : entry.name;
@@ -106,7 +108,7 @@ function readSources(root) {
106
108
  catch (error) { throw new Error(`${path}: invalid YAML: ${error.message}`, { cause: error }); }
107
109
  validateFrontmatter(fm, name, path);
108
110
  // Policy precedence for a skill: a native `agents/openai.yaml` wins; else
109
- // an explicitly user-invocable skill (a slash command that used to be a
111
+ // a user-invocable skill (key `true` or absent; a slash command that used to be a
110
112
  // `commands/*.md` file) derives it from `disable-model-invocation` exactly
111
113
  // as command sources do; a library skill emits no policy.
112
114
  const policy = command
@@ -161,7 +163,7 @@ function renderSkill(source, warnings) {
161
163
  'Resolve this link relative to this SKILL.md, not the project working directory. The plugin root is three directories above this file. Resolve package paths such as `skills/` and `scripts/` from that root; resolve relative links inside the canonical document from its own directory. Keep the user’s project as the target of project operations.',
162
164
  ];
163
165
  // `$ARGUMENTS` semantics belong to every OPERATOR-FACING document — a
164
- // `commands/*.md`, and equally a skill that declares `user-invocable: true`
166
+ // `commands/*.md`, and equally a skill whose `user-invocable` is `true` (or absent)
165
167
  // and references `$ARGUMENTS` in its own body. Gating them on `source.command`
166
168
  // alone silently dropped the argument-handling contract from every command
167
169
  // body that was folded into its same-named skill.
@@ -27,7 +27,8 @@
27
27
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
- import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
30
+ import { isUserInvocableSkill, isUserInvocableValue } from './lib/user-invocable-skills.mjs';
31
+ import { listCommandFiles, listSkillDirs, parseFrontmatter } from './lib/plugin-surface.mjs';
31
32
 
32
33
  const __filename = fileURLToPath(import.meta.url);
33
34
  const SCRIPT_DIR = path.dirname(__filename);
@@ -46,73 +47,6 @@ function isDir(p) {
46
47
  try { return statSync(p).isDirectory(); } catch { return false; }
47
48
  }
48
49
 
49
- function commandFiles() {
50
- if (!existsSync(COMMANDS_DIR)) return [];
51
- return readdirSync(COMMANDS_DIR)
52
- .filter((name) => name.endsWith('.md'))
53
- .sort();
54
- }
55
-
56
- function skillDirs() {
57
- if (!existsSync(SKILLS_DIR)) return [];
58
- return readdirSync(SKILLS_DIR)
59
- .filter((name) => isDir(path.join(SKILLS_DIR, name)) && existsSync(path.join(SKILLS_DIR, name, 'SKILL.md')))
60
- .sort();
61
- }
62
-
63
- /**
64
- * Parse YAML-ish frontmatter including `>` / `|` folded scalars.
65
- *
66
- * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
67
- * two probes below miss the block entirely: the file parsed as "no
68
- * frontmatter", so every flag in it (`user-invocable`,
69
- * `disable-model-invocation`) silently disappeared and the skill was demoted
70
- * out of `.cursor/commands/` with no diagnostic anywhere. Both are normalised
71
- * away first — the same treatment `parseAgentFrontmatter`
72
- * (`scripts/lib/agent-frontmatter.mjs`) gives them.
73
- *
74
- * NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
75
- * `__BLOCK_SCALAR__` sentinel for a `description: >`, which is the form every
76
- * merged skill actually uses — routing through it would emit the sentinel as
77
- * the wrapper description.
78
- *
79
- * @param {string} content
80
- * @returns {Record<string, string>}
81
- */
82
- function parseFrontmatter(content) {
83
- const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
84
- if (!text.startsWith('---\n')) return {};
85
- const end = text.indexOf('\n---\n', 4);
86
- if (end === -1) return {};
87
-
88
- const lines = text.slice(4, end).split('\n');
89
- const fields = {};
90
- let i = 0;
91
- while (i < lines.length) {
92
- const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
93
- if (!match) {
94
- i += 1;
95
- continue;
96
- }
97
- const key = match[1];
98
- const raw = match[2];
99
- if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
100
- const folded = [];
101
- i += 1;
102
- while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
103
- folded.push(lines[i].replace(/^\s+/, ''));
104
- i += 1;
105
- }
106
- const joiner = raw.startsWith('|') ? '\n' : ' ';
107
- fields[key] = folded.filter(Boolean).join(joiner).trim();
108
- continue;
109
- }
110
- fields[key] = raw.replace(/^["']|["']$/g, '');
111
- i += 1;
112
- }
113
- return fields;
114
- }
115
-
116
50
  /**
117
51
  * Values that YAML would read as a non-string scalar even though every
118
52
  * character in them is "safe". `argument-hint: no` is a boolean, not the
@@ -194,8 +128,9 @@ Cursor has no Skill tool. When the command says to invoke a skill, Read \`skills
194
128
  /**
195
129
  * Skills that declare themselves operator-facing slash commands.
196
130
  *
197
- * The predicate is `isUserInvocableValue` from `scripts/lib/user-invocable-skills.mjs`
198
- * — the ONE normaliser for this marker, shared with the Pi and Codex generators
131
+ * The predicate is `isUserInvocableSkill` from `scripts/lib/user-invocable-skills.mjs`
132
+ * (absent key = invocable, #1515) over the ONE value normaliser `isUserInvocableValue`,
133
+ * shared with the Pi and Codex generators
199
134
  * and with every counter. The private copy this replaced read only a
200
135
  * whitespace-trimmed bare `true`, so `True` and `true # note` were demoted HERE
201
136
  * while the shared counter listed them as commands; the SKILL.md path is passed
@@ -204,7 +139,7 @@ Cursor has no Skill tool. When the command says to invoke a skill, Read \`skills
204
139
  * @returns {string[]} skill names, sorted
205
140
  */
206
141
  function userInvocableSkills() {
207
- return skillDirs().filter((name) => skillFlags(name).userInvocable);
142
+ return listSkillDirs(ROOT).filter((name) => skillFlags(name).userInvocable);
208
143
  }
209
144
 
210
145
  /**
@@ -225,7 +160,7 @@ function skillFlags(skillName) {
225
160
  if (cached) return cached;
226
161
  const file = path.join(SKILLS_DIR, skillName, 'SKILL.md');
227
162
  const fields = parseFrontmatter(readFileSync(file, 'utf8'));
228
- const userInvocable = isUserInvocableValue(fields['user-invocable'], file, 'user-invocable');
163
+ const userInvocable = isUserInvocableSkill(fields, file);
229
164
  const sourceDisablesModel = isUserInvocableValue(
230
165
  fields['disable-model-invocation'], file, 'disable-model-invocation',
231
166
  );
@@ -375,7 +310,7 @@ Cursor has no Skill tool. Treat "invoke the ${skillName} skill" as: Read \`skill
375
310
  */
376
311
  function expectedCommands() {
377
312
  const commands = new Map();
378
- for (const commandFile of commandFiles()) {
313
+ for (const commandFile of listCommandFiles(ROOT)) {
379
314
  commands.set(commandFile, renderCommand(commandFile));
380
315
  }
381
316
 
@@ -401,7 +336,7 @@ function expectedCommands() {
401
336
 
402
337
  function expectedSkills() {
403
338
  const skills = new Map();
404
- for (const skillName of skillDirs()) {
339
+ for (const skillName of listSkillDirs(ROOT)) {
405
340
  skills.set(skillName, renderSkill(skillName));
406
341
  }
407
342
  return skills;