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
@@ -19,10 +19,11 @@
19
19
  * node scripts/generate-pi-prompts.mjs --check
20
20
  */
21
21
 
22
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
- import { isUserInvocableValue } from './lib/user-invocable-skills.mjs';
25
+ import { isUserInvocableSkill } from './lib/user-invocable-skills.mjs';
26
+ import { listCommandFiles, listSkillDirs, parseFrontmatter } from './lib/plugin-surface.mjs';
26
27
 
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const SCRIPT_DIR = path.dirname(__filename);
@@ -33,74 +34,6 @@ const PROMPTS_DIR = path.join(ROOT, 'pi', 'prompts');
33
34
  const CHECK_ONLY = process.argv.includes('--check');
34
35
  const DESCRIPTION_MAX = 1024;
35
36
 
36
- function isDir(p) {
37
- try { return statSync(p).isDirectory(); } catch { return false; }
38
- }
39
-
40
- function commandFiles() {
41
- if (!existsSync(COMMANDS_DIR)) return [];
42
- return readdirSync(COMMANDS_DIR)
43
- .filter((name) => name.endsWith('.md'))
44
- .sort();
45
- }
46
-
47
- function skillDirs() {
48
- if (!existsSync(SKILLS_DIR)) return [];
49
- return readdirSync(SKILLS_DIR)
50
- .filter((name) => isDir(path.join(SKILLS_DIR, name)) && existsSync(path.join(SKILLS_DIR, name, 'SKILL.md')))
51
- .sort();
52
- }
53
-
54
- /**
55
- * Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
56
- * DECODED values (surrounding quotes stripped). Skill descriptions are folded
57
- * scalars in practice, which the previous line-at-a-time parser read as the
58
- * literal `>`. Mirrors `generate-cursor-adapter.mjs`.
59
- *
60
- * A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
61
- * two probes below miss the block entirely: the file parsed as "no
62
- * frontmatter", so `user-invocable` silently disappeared and the skill was
63
- * demoted out of `pi/prompts/` with no diagnostic anywhere. Both are normalised
64
- * away first — the same treatment `parseAgentFrontmatter`
65
- * (`scripts/lib/agent-frontmatter.mjs`) gives them.
66
- *
67
- * @param {string} content
68
- * @returns {Record<string, string>}
69
- */
70
- function parseFrontmatter(content) {
71
- const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
72
- if (!text.startsWith('---\n')) return {};
73
- const end = text.indexOf('\n---\n', 4);
74
- if (end === -1) return {};
75
-
76
- const lines = text.slice(4, end).split('\n');
77
- const fields = {};
78
- let i = 0;
79
- while (i < lines.length) {
80
- const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
81
- if (!match) {
82
- i += 1;
83
- continue;
84
- }
85
- const key = match[1];
86
- const raw = match[2];
87
- if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
88
- const folded = [];
89
- i += 1;
90
- while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
91
- folded.push(lines[i].replace(/^\s+/, ''));
92
- i += 1;
93
- }
94
- const joiner = raw.startsWith('|') ? '\n' : ' ';
95
- fields[key] = folded.filter(Boolean).join(joiner).trim();
96
- continue;
97
- }
98
- fields[key] = raw.replace(/^["']|["']$/g, '');
99
- i += 1;
100
- }
101
- return fields;
102
- }
103
-
104
37
  /** @see generate-cursor-adapter.mjs — same allow-list, same reasoning. */
105
38
  const YAML_SCALAR_LOOKALIKE = /^(?:true|false|yes|no|on|off|null|nan|[-+]?\.?inf|~)$/i;
106
39
  const YAML_PLAIN_SAFE = /^[A-Za-z0-9_][A-Za-z0-9 _.,()/-]*$/;
@@ -138,7 +71,8 @@ function clampDescription(text) {
138
71
  /**
139
72
  * Skills that declare themselves operator-facing slash commands.
140
73
  *
141
- * The predicate is `isUserInvocableValue` from `scripts/lib/user-invocable-skills.mjs`
74
+ * The predicate is `isUserInvocableSkill` from `scripts/lib/user-invocable-skills.mjs`
75
+ * (absent key = invocable, as Claude Code reads it — #1515; values via `isUserInvocableValue`)
142
76
  * — the ONE normaliser for this marker, shared with the Cursor and Codex
143
77
  * generators and with every counter. A local copy here is how the quoted-value
144
78
  * disagreement arose (`"true"`: wrapper generated on two adapters, counted by
@@ -147,10 +81,10 @@ function clampDescription(text) {
147
81
  * @returns {string[]} skill names, sorted
148
82
  */
149
83
  function userInvocableSkills() {
150
- return skillDirs().filter((name) => {
84
+ return listSkillDirs(ROOT).filter((name) => {
151
85
  const file = path.join(SKILLS_DIR, name, 'SKILL.md');
152
86
  const fields = parseFrontmatter(readFileSync(file, 'utf8'));
153
- return isUserInvocableValue(fields['user-invocable'], file);
87
+ return isUserInvocableSkill(fields, file);
154
88
  });
155
89
  }
156
90
 
@@ -217,7 +151,7 @@ Read that skill file and follow it exactly. When it references \`$ARGUMENTS\`, s
217
151
  */
218
152
  function expectedPrompts() {
219
153
  const prompts = new Map();
220
- for (const commandFile of commandFiles()) {
154
+ for (const commandFile of listCommandFiles(ROOT)) {
221
155
  prompts.set(commandFile, renderPrompt(commandFile));
222
156
  }
223
157
 
@@ -124,6 +124,14 @@ function classifyFailure(err) {
124
124
 
125
125
  const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
126
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';
127
135
  if (
128
136
  haystack.includes('gh auth login') ||
129
137
  haystack.includes('not logged in') ||
@@ -30,10 +30,11 @@
30
30
  * separate helpers. No external deps — Node 20+ stdlib only.
31
31
  */
32
32
 
33
- import { readFile, writeFile, rename, unlink, mkdir, readdir, stat, lstat } from 'node:fs/promises';
33
+ import { readFile, rename, unlink, mkdir, readdir, stat, lstat } from 'node:fs/promises';
34
34
  import { existsSync } from 'node:fs';
35
35
  import { randomUUID } from 'node:crypto';
36
36
  import path from 'node:path';
37
+ import { atomicWriteText, envelopeToError } from './io.mjs';
37
38
 
38
39
  import { filterRealSessions } from './session-schema.mjs';
39
40
 
@@ -284,7 +285,7 @@ export async function shouldDispatchAutoDialectic({
284
285
  /**
285
286
  * Write `.orchestrator/dialectic-last-run` with the given ISO timestamp.
286
287
  *
287
- * Atomicity: write to `<path>.<rand>.tmp`, then rename(). Same-fs rename is
288
+ * Atomicity: `atomicWriteText` (`<dir>/.<name>.tmp.<hex>`, then rename()). Same-fs rename is
288
289
  * atomic on POSIX — observers see either the previous file or the new one,
289
290
  * never a half-written intermediate (mirrors auto-dream.mjs:248-251).
290
291
  *
@@ -312,9 +313,10 @@ export async function writeDialecticLastRun({ repoRoot, isoTimestamp } = {}) {
312
313
  const target = lastRunPath(repoRoot);
313
314
  try {
314
315
  await mkdir(path.dirname(target), { recursive: true });
315
- const tmp = `${target}.${randomUUID().slice(0, 8)}.tmp`;
316
- await writeFile(tmp, `${isoTimestamp}\n`, 'utf8');
317
- await rename(tmp, target);
316
+ const res = await atomicWriteText(target, `${isoTimestamp}\n`, {
317
+ tmpPrefix: `.${path.basename(target)}.tmp`,
318
+ });
319
+ if (!res.ok) return { ok: false, error: res.error };
318
320
  return { ok: true, path: target };
319
321
  } catch (err) {
320
322
  return { ok: false, error: err.message };
@@ -401,11 +403,10 @@ export async function writeDialecticPending({
401
403
  ].join('\n');
402
404
 
403
405
  const content = `${frontmatter}${diff}${diff.endsWith('\n') ? '' : '\n'}`;
404
- const tmp = `${target}.${randomUUID().slice(0, 8)}.tmp`;
405
- await writeFile(tmp, content, 'utf8');
406
- await rename(tmp, target);
406
+ const res = await atomicWriteText(target, content, { tmpPrefix: `.${path.basename(target)}.tmp` });
407
+ if (!res.ok) throw envelopeToError(res);
407
408
 
408
- return { path: target, bytes: Buffer.byteLength(content, 'utf8') };
409
+ return { path: target, bytes: res.bytes };
409
410
  }
410
411
 
411
412
  /**
@@ -21,10 +21,10 @@
21
21
  * No external deps — Node 20+ stdlib only.
22
22
  */
23
23
 
24
- import { readFile, writeFile, rename, unlink, mkdir } from 'node:fs/promises';
24
+ import { readFile, unlink, mkdir } from 'node:fs/promises';
25
25
  import { existsSync, statSync } from 'node:fs';
26
- import { randomUUID } from 'node:crypto';
27
26
  import path from 'node:path';
27
+ import { atomicWriteText, envelopeToError } from './io.mjs';
28
28
 
29
29
  import { readCanonicalSessions } from './sessions-canonical.mjs';
30
30
  import { filterRealSessions } from './session-schema.mjs';
@@ -205,7 +205,7 @@ function pendingDreamPath(repoRoot) {
205
205
  * the metadata session-end's Final Report and the next session's --apply-pending
206
206
  * step both rely on.
207
207
  *
208
- * Atomicity: write to `<path>.<rand>.tmp`, then rename(). Same-fs rename is
208
+ * Atomicity: `atomicWriteText` (`<dir>/.<name>.tmp.<hex>`, then rename()). Same-fs rename is
209
209
  * atomic on POSIX — observers see either the previous file or the new one,
210
210
  * never a half-written intermediate.
211
211
  *
@@ -245,11 +245,10 @@ export async function writePendingDream({
245
245
  ].join('\n');
246
246
 
247
247
  const content = `${frontmatter}${diff}${diff.endsWith('\n') ? '' : '\n'}`;
248
- const tmp = `${target}.${randomUUID().slice(0, 8)}.tmp`;
249
- await writeFile(tmp, content, 'utf8');
250
- await rename(tmp, target);
248
+ const res = await atomicWriteText(target, content, { tmpPrefix: `.${path.basename(target)}.tmp` });
249
+ if (!res.ok) throw envelopeToError(res);
251
250
 
252
- return { path: target, bytes: Buffer.byteLength(content, 'utf8') };
251
+ return { path: target, bytes: res.bytes };
253
252
  }
254
253
 
255
254
  /**
@@ -472,9 +471,8 @@ export async function applyPendingDream({ repoRoot, memoryDir, maxAgeDays = 14 }
472
471
 
473
472
  const newBody = extractedBlock.trimEnd() + '\n';
474
473
  await mkdir(path.dirname(memoryPath), { recursive: true });
475
- const tmp = `${memoryPath}.${randomUUID().slice(0, 8)}.tmp`;
476
- await writeFile(tmp, newBody, 'utf8');
477
- await rename(tmp, memoryPath);
474
+ const res = await atomicWriteText(memoryPath, newBody, { tmpPrefix: '.MEMORY.md.tmp' });
475
+ if (!res.ok) throw envelopeToError(res);
478
476
 
479
477
  const linesAfter = newBody.length === 0 ? 0 : newBody.split('\n').length;
480
478
 
@@ -17,9 +17,10 @@
17
17
  * linkChildLoopToCoordinator(childRunId, parentRunId)— Phase D linkage documentation helper
18
18
  */
19
19
 
20
- import { writeFileSync, renameSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { mkdirSync, readFileSync } from 'node:fs';
21
21
  import path from 'node:path';
22
- import crypto from 'node:crypto';
22
+
23
+ import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Schema version
@@ -38,7 +39,11 @@ export const SCHEMA_VERSION = 1;
38
39
  * Crash-safe: a partial tmpfile is never visible at the destination path.
39
40
  * POSIX rename(2) is atomic within the same filesystem.
40
41
  *
41
- * The directory must already exist (callers are responsible for mkdirSync).
42
+ * Write path: `atomicWriteWithBackup` (`../io.mjs`), which also creates a
43
+ * missing parent directory (mkdir -p) and unlinks its tmp on failure. The tmp
44
+ * keeps the `<file>.tmp-<pid>.<hex>` shape so the `*.jsonl.tmp-*` crash-residue
45
+ * ignore pattern (bootstrap store-lock-ignore) still matches it. Throws on
46
+ * failure with the original message and `code` (`envelopeToError`).
42
47
  *
43
48
  * @param {object} record — the record to serialize as a JSONL line
44
49
  * @param {string} jsonlPath — destination JSONL path
@@ -53,9 +58,10 @@ export function appendJsonlAtomic(record, jsonlPath) {
53
58
  if (existing.length > 0 && !existing.endsWith('\n')) existing += '\n';
54
59
 
55
60
  const line = JSON.stringify(record) + '\n';
56
- const tmp = `${jsonlPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
57
- writeFileSync(tmp, existing + line, 'utf8');
58
- renameSync(tmp, jsonlPath);
61
+ const res = atomicWriteWithBackup(jsonlPath, existing + line, {
62
+ tmpPrefix: `${path.basename(jsonlPath)}.tmp-${process.pid}`,
63
+ });
64
+ if (!res.ok) throw envelopeToError(res);
59
65
  }
60
66
 
61
67
  // ---------------------------------------------------------------------------
@@ -22,9 +22,9 @@
22
22
  * — a missing or structurally invalid lock is refused, not synthesized.
23
23
  */
24
24
 
25
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
26
- import { join, dirname } from 'node:path';
27
- import { randomBytes } from 'node:crypto';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
28
28
  import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
29
29
 
30
30
  const PRECONDITION_MESSAGE_SUFFIX =
@@ -84,20 +84,16 @@ function sanitizeLockValue(value) {
84
84
  }
85
85
 
86
86
  /**
87
- * Atomically write `content` to `filePath` via tmp-file + renameSync — the
88
- * same crash-safe pattern used by `scripts/lib/io.mjs#writeJsonAtomicSync`
89
- * and `scripts/lib/session-lock.mjs`, adapted for raw (non-JSON) text.
87
+ * Atomically write `content` to `filePath` via tmp-file + rename
88
+ * (`scripts/lib/io.mjs#atomicWriteWithBackup`). Throws on failure with the
89
+ * original message and `code` (`envelopeToError`).
90
90
  *
91
91
  * @param {string} filePath
92
92
  * @param {string} content
93
93
  */
94
94
  function writeTextAtomicSync(filePath, content) {
95
- const dir = dirname(filePath);
96
- mkdirSync(dir, { recursive: true });
97
- const tmpSuffix = randomBytes(6).toString('hex');
98
- const tmpFile = join(dir, `.bootstrap-lock-refresh.${tmpSuffix}.tmp`);
99
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
100
- renameSync(tmpFile, filePath);
95
+ const res = atomicWriteWithBackup(filePath, content, { tmpPrefix: '.bootstrap-lock-refresh.tmp' });
96
+ if (!res.ok) throw envelopeToError(res);
101
97
  }
102
98
 
103
99
  /**
@@ -14,7 +14,7 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
14
14
  * GENERIC over any consumer repo, where a slash command still IS a
15
15
  * `commands/*.md` file. The 2026-09-16 command→skill fold (#1370) is a fact
16
16
  * about THIS plugin — 24 command files folded into their same-named
17
- * `skills/<name>/SKILL.md` behind an explicit `user-invocable: true` — so
17
+ * `skills/<name>/SKILL.md` behind `user-invocable: true` (absent counts as true, #1515) — so
18
18
  * teaching the shared checker that union would mis-measure every repo that never
19
19
  * folded. `scripts/site-numbers.mjs` `countCommands()` carries the union for this
20
20
  * plugin's own tile, and names the divergence in its docblock.
@@ -13,6 +13,10 @@
13
13
  * Tolerant parser: any other value (or an absent block) resolves to disabled.
14
14
  *
15
15
  * Consumer: `hooks/post-tooluse-frontend-slop.mjs`.
16
+ *
17
+ * @deprecated since 5.8.0 (#1121) — the hook this block drives is removed in
18
+ * the next major. The parser stays side-effect free; the hook prints the
19
+ * deprecation notice when the block is enabled.
16
20
  */
17
21
 
18
22
  import { promises as fs } from 'node:fs';
@@ -32,6 +36,7 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
32
36
  *
33
37
  * @param {string} content — full file contents
34
38
  * @returns {{ enabled: boolean }}
39
+ * @deprecated since 5.8.0 (#1121) — removal in the next major.
35
40
  */
36
41
  export function _parseFrontendSlopHook(content) {
37
42
  const defaults = { enabled: false };
@@ -85,6 +90,7 @@ export function _parseFrontendSlopHook(content) {
85
90
  *
86
91
  * @param {{ repoRoot?: string }} [opts]
87
92
  * @returns {Promise<{ enabled: boolean }>}
93
+ * @deprecated since 5.8.0 (#1121) — removal in the next major.
88
94
  */
89
95
  export async function loadFrontendSlopHookConfig(opts = {}) {
90
96
  // Called at USE time, never at module load: the deprecated `SO_PROJECT_DIR`
@@ -186,7 +186,11 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
186
186
  // String fields
187
187
  const vcs = _coerceString(kv, 'vcs', undefined);
188
188
  const gitlabHost = _coerceString(kv, 'gitlab-host', undefined);
189
- const mirror = _coerceString(kv, 'mirror', undefined);
189
+ // `mirror` keeps an explicit `none` as the string 'none' instead of the
190
+ // generic _coerceString null (#1034): session-end Phase 4.4 must tell an
191
+ // explicit opt-out (`mirror: none` → never push) from an absent key
192
+ // (null → push when a `github` remote exists, the pre-#1034 behaviour).
193
+ const mirror = _getVal(kv, 'mirror', undefined) === 'none' ? 'none' : _coerceString(kv, 'mirror', undefined);
190
194
  const special = _coerceString(kv, 'special', undefined);
191
195
  const pencil = _coerceString(kv, 'pencil', undefined);
192
196
  const testCommand = _coerceString(kv, 'test-command', 'npm test');
@@ -44,8 +44,9 @@
44
44
  */
45
45
 
46
46
  import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
47
- import { join } from 'node:path';
47
+ import { eventsFilePath } from './events.mjs';
48
48
  import { isMainModule } from './is-main-module.mjs';
49
+ import { getProjectDir } from './platform.mjs';
49
50
 
50
51
  const DEFAULT_INTERVAL_S = 2;
51
52
  const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
@@ -454,8 +455,9 @@ function sleep(ms) {
454
455
  * @param {number} intervalS
455
456
  */
456
457
  async function tailLoop(intervalS) {
457
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.cwd();
458
- const absPath = join(pluginRoot, EVENTS_FILE_REL);
458
+ // #1517: the PROJECT root, never CLAUDE_PLUGIN_ROOT (the installed plugin
459
+ // directory, whose ledger is not this repo's).
460
+ const absPath = eventsFilePath(getProjectDir()); // #1514: the repo's one ledger
459
461
  /** @type {Map<number, WaveSummary>} */
460
462
  const state = new Map();
461
463
  /** @type {Set<string>} */
@@ -202,7 +202,11 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
202
202
  const days = (nowMs - latestMs) / MS_PER_DAY;
203
203
  return days > 0 ? days : 0;
204
204
  } catch {
205
- // No sessions file (or unreadable) ⇒ never worked on ⇒ maximally stale.
205
+ // Not the missing/unreadable-ledger path: readCanonicalSessions already
206
+ // returns [] for both (ENOENT silently, any other read failure with a
207
+ // stderr WARN — sessions-canonical.mjs § #1188), handled by the
208
+ // `records.length === 0` branch above. This catch only absorbs an
209
+ // unexpected throw (e.g. a non-string repoRoot) ⇒ maximally stale.
206
210
  return STALENESS_CAP_DAYS;
207
211
  }
208
212
  }
@@ -39,11 +39,12 @@
39
39
 
40
40
  import { existsSync, statSync, readFileSync } from 'node:fs';
41
41
  import { join } from 'node:path';
42
+ import { getProjectDir } from './platform.mjs';
42
43
 
43
44
  // Ceiling (BV-004): one process per session that invoked session-start
44
45
  // (monitors/monitors.json `when: on-skill-invoke:session-start`), alive until
45
46
  // SIGTERM; one stat + one full readFileSync of ONE local file per 900 s
46
- // (resolved against CLAUDE_PLUGIN_ROOT, not the repo); no network, no repo
47
+ // (resolved against the project dir via getProjectDir(), #1517); no network, no repo
47
48
  // enumeration, no Session Config read (runs even with `ecosystem-health: false`).
48
49
  // The watched file has NO producer (0 writers, measured 2026-09-18:
49
50
  // `rg -l "ecosystem-health\.jsonl"` → this file only) — every run emits
@@ -129,8 +130,9 @@ function fingerprint(absPath) {
129
130
  * @param {number} intervalS
130
131
  */
131
132
  async function watchLoop(intervalS) {
132
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.cwd();
133
- const absPath = join(pluginRoot, STATE_FILE_REL);
133
+ // #1517: the PROJECT root, never CLAUDE_PLUGIN_ROOT (the installed plugin
134
+ // directory, whose metrics are not this repo's).
135
+ const absPath = join(getProjectDir(), STATE_FILE_REL);
134
136
 
135
137
  // Startup event — confirms the watcher is alive even when nothing has
136
138
  // happened yet. Without this, a "no events" stream is indistinguishable