session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -11,6 +11,9 @@
11
11
  * Responsibilities:
12
12
  * - Acquire a per-write file lock (`.orchestrator/rules.lock`) to serialise
13
13
  * concurrent writers — mirrors PSA-005 (withStateMdLock) pattern.
14
+ * - Before any write: project the instruction budget after the batch and
15
+ * refuse the WHOLE batch (nothing written, nothing stamped) when it would
16
+ * breach a ceiling — see `budgetPreflight`.
14
17
  * - For each approved proposal: path-safety guard → STRUCTURAL content gate
15
18
  * (#1015, see {@link frontmatterRefusalReason}) → mkdirSync → atomic
16
19
  * tmp+rename write → stamp the idempotency sidecar terminal via
@@ -54,8 +57,20 @@
54
57
  * @module reconcile/writer
55
58
  */
56
59
 
57
- import { mkdirSync, writeFileSync, renameSync, appendFileSync, realpathSync, statSync } from 'node:fs';
60
+ import {
61
+ mkdirSync,
62
+ mkdtempSync,
63
+ writeFileSync,
64
+ readFileSync,
65
+ readdirSync,
66
+ renameSync,
67
+ appendFileSync,
68
+ realpathSync,
69
+ rmSync,
70
+ statSync,
71
+ } from 'node:fs';
58
72
  import { randomBytes } from 'node:crypto';
73
+ import { tmpdir } from 'node:os';
59
74
  import path from 'node:path';
60
75
 
61
76
  import { withFileLock } from '../file-lock.mjs';
@@ -490,6 +505,228 @@ function isOperatorRejection(item) {
490
505
  return typeof item.content === 'string' && item.content.length > 0;
491
506
  }
492
507
 
508
+ // ---------------------------------------------------------------------------
509
+ // Instruction-budget pre-flight (#1316 follow-up)
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /** Refusal reason returned when the projected corpus would breach a ceiling. */
513
+ export const BUDGET_REFUSAL_REASON = 'instruction-budget-exceeded';
514
+
515
+ const BUDGET_HINT =
516
+ 'absorb the approved rule(s) into an existing thematic file under .claude/rules/ ' +
517
+ '(N provenance pairs in ONE file, globs:/paths: mirrored — docs/rule-authoring.md § Consolidated rules) ' +
518
+ 'and re-run; never raise the ceiling';
519
+
520
+ /**
521
+ * @typedef {Object} BudgetRefusal
522
+ * @property {string} reason - always {@link BUDGET_REFUSAL_REASON}.
523
+ * @property {'path-scoped-bytes'|'generated-bytes'|'always-on-bytes'|'directives'} axis
524
+ * @property {number} current - the axis value before the write.
525
+ * @property {number} projected - the axis value after the write.
526
+ * @property {number} ceiling - the ceiling the projection breaches.
527
+ * @property {string} hint
528
+ */
529
+
530
+ /**
531
+ * Project the instruction budget AFTER the pending repo-local writes and refuse
532
+ * the whole batch if the projection would breach a ceiling.
533
+ *
534
+ * WHY A PROJECTION AND NOT WRITE-THEN-ROLLBACK: a rollback has to undo rule
535
+ * files AND sidecar stamps, and a half-failed rollback is worse than the breach.
536
+ * Refusing before the first byte lands keeps the failure clean.
537
+ *
538
+ * HOW: the guard's own `computeInstructionBudget` runs twice — on the live rules
539
+ * directory, and on a throwaway overlay (a copy of that directory with the
540
+ * pending contents written over it). Re-running the guard's code rather than
541
+ * re-deriving its byte count means the projection cannot disagree with the
542
+ * verdict: same frontmatter stripping, same `globs:`/provenance predicates,
543
+ * all four axes, and an update of an existing file lands as its delta by
544
+ * construction. Ceilings follow `checkInstructionBudget`'s precedence
545
+ * (Session Config `instruction-budget.*` > module default), and
546
+ * `enabled: false` / `mode: off` skips the pre-flight — the operator opted out
547
+ * of the guard.
548
+ *
549
+ * REFUSE ONLY WHAT THIS WRITE WORSENS: an axis blocks the batch only when it is
550
+ * over in the projection AND the write grows it. A corpus that is already over
551
+ * therefore still accepts a shrinking consolidation — the remedy must never be
552
+ * blocked by the breach it repairs — and an unrelated pre-existing breach (say,
553
+ * the hand-authored always-on directive count) does not freeze reconcile.
554
+ *
555
+ * CEILING (BV-004): this is a projection of THIS batch against the corpus as it
556
+ * stands inside the rules lock. It does not cover rule edits made by hand or by
557
+ * any writer that does not take `.orchestrator/rules.lock`, and it projects
558
+ * with the Session Config ceilings while `tests/rules/receiving-review.test.mjs`
559
+ * asserts against the module defaults — so a repo that RAISES its ceiling in
560
+ * config can pass here and still go red there; that live test stays the second
561
+ * safety net. It also fails OPEN (one `errors[]` warning, then the write
562
+ * proceeds) when the guard cannot load or the overlay cannot be built,
563
+ * because blocking every reconcile write on a broken measurement is the larger
564
+ * harm. Revisit if a second writer of `.claude/rules/` appears, or if a repo
565
+ * ever carries a config ceiling above the module default.
566
+ *
567
+ * @param {Object} ctx
568
+ * @param {WriterApprovedItem[]} ctx.approvedItems
569
+ * @param {PreparedTarget|undefined} ctx.prep - the prepared `repo-local` target.
570
+ * @param {{repoRoot?: string, baselineRoot?: string}} ctx.roots
571
+ * @param {string} ctx.repoRoot
572
+ * @param {string[]} ctx.errors - receives fail-open warnings.
573
+ * @returns {Promise<BudgetRefusal|null>} null ⇒ proceed with the writes.
574
+ */
575
+ async function budgetPreflight({ approvedItems, prep, roots, repoRoot, errors }) {
576
+ if (!prep || !prep.ok) return null;
577
+
578
+ // Only what the write loop below would actually put on disk counts: the same
579
+ // content-type check, structural gate and destination resolution. Errors from
580
+ // this dry resolution go to a throwaway sink — the write loop reports them.
581
+ /** @type {Array<{rel: string, content: string}>} */
582
+ const pending = [];
583
+ for (const item of approvedItems) {
584
+ if (!item || typeof item.content !== 'string') continue;
585
+ if (frontmatterRefusalReason(item.content) !== null) continue;
586
+ const dest = resolveDest(item, 'repo-local', prep, roots, []);
587
+ if (dest === null) continue;
588
+ pending.push({ rel: path.relative(prep.dir, dest), content: item.content });
589
+ }
590
+ if (pending.length === 0) return null;
591
+
592
+ let overlay = null;
593
+ try {
594
+ // Dynamic import keeps the guard out of this module's static graph.
595
+ const guard = await import('../instruction-budget-guard.mjs');
596
+ const cfg = guard.loadInstructionBudgetConfig(repoRoot);
597
+ if (!cfg.enabled || cfg.mode === 'off') return null;
598
+ // computeInstructionBudget falls back to its module default for any
599
+ // non-number, so an absent optional key needs no special case here.
600
+ const ceilings = {
601
+ ceiling: cfg.ceiling,
602
+ byteCeiling: cfg['byte-ceiling'],
603
+ generatedByteCeiling: cfg['generated-byte-ceiling'],
604
+ pathScopedByteCeiling: cfg['path-scoped-byte-ceiling'],
605
+ };
606
+
607
+ const current = guard.computeInstructionBudget({ repoRoot, rulesDir: prep.dir, ...ceilings });
608
+
609
+ overlay = mkdtempSync(path.join(tmpdir(), 'reconcile-budget-'));
610
+ // The guard reads the rules directory flat (`readdirSync`, `.md` only).
611
+ for (const name of readdirSync(prep.dir)) {
612
+ if (!name.endsWith('.md')) continue;
613
+ try {
614
+ writeFileSync(path.join(overlay, name), readFileSync(path.join(prep.dir, name), 'utf8'), 'utf8');
615
+ } catch {
616
+ /* unreadable or a directory — the guard skips it too */
617
+ }
618
+ }
619
+ for (const { rel, content } of pending) {
620
+ const dest = path.join(overlay, rel);
621
+ mkdirSync(path.dirname(dest), { recursive: true });
622
+ writeFileSync(dest, content, 'utf8');
623
+ }
624
+
625
+ const projected = guard.computeInstructionBudget({ repoRoot, rulesDir: overlay, ...ceilings });
626
+
627
+ const axes = [
628
+ ['path-scoped-bytes', current.bySurface.pathScoped.bytes, projected.bySurface.pathScoped.bytes, projected.pathScopedByteCeiling, projected.overPathScopedBudget],
629
+ ['generated-bytes', current.bySurface.generated.bytes, projected.bySurface.generated.bytes, projected.generatedByteCeiling, projected.overGeneratedBudget],
630
+ ['always-on-bytes', current.totalBytes, projected.totalBytes, projected.byteCeiling, projected.overByteBudget],
631
+ ['directives', current.totalDirectives, projected.totalDirectives, projected.ceiling, projected.overDirectiveBudget],
632
+ ];
633
+ for (const [axis, cur, proj, ceiling, over] of axes) {
634
+ if (over && proj > cur) {
635
+ return { reason: BUDGET_REFUSAL_REASON, axis, current: cur, projected: proj, ceiling, hint: BUDGET_HINT };
636
+ }
637
+ }
638
+ return null;
639
+ } catch (err) {
640
+ const msg = err && err.message ? err.message : String(err);
641
+ errors.push(`instruction-budget pre-flight could not run (${msg}) — writes proceed unchecked (fail-open)`);
642
+ return null;
643
+ } finally {
644
+ if (overlay !== null) {
645
+ try {
646
+ rmSync(overlay, { recursive: true, force: true });
647
+ } catch {
648
+ /* a leftover tmp dir is harmless */
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // Telemetry (#1307)
656
+ // ---------------------------------------------------------------------------
657
+
658
+ /**
659
+ * Ledger name of the rule-write event (issue #1307).
660
+ *
661
+ * WHY A SECOND EVENT AND NOT A FIELD ON `orchestrator.reconcile.completed`:
662
+ * that event is emitted by the `runReconcile` wrapper — BEFORE the operator
663
+ * approval AUQ and before this module runs at all. A `dry_run: false` record
664
+ * therefore proves only that the engine ran and merged the candidate store; an
665
+ * operator who declined every proposal emits a byte-identical record to one who
666
+ * approved five. The emitter has to sit where the thing it proves happens, and
667
+ * that is here: after the write pass, in the one module that writes rule files.
668
+ *
669
+ * Emitting is NOT writing — the #693 FA2/FA3 brandmauer is untouched. This
670
+ * module remains the only writer of `.claude/rules/`, and every write it makes
671
+ * is still an operator-approved item handed in via `approved`.
672
+ */
673
+ export const RULES_WRITTEN_EVENT = 'orchestrator.reconcile.rules_written';
674
+
675
+ /**
676
+ * Record ONE rule-write pass in the repo's event ledger — best-effort.
677
+ *
678
+ * ZERO-WRITE IS EMITTED, DELIBERATELY. The alternative (emit only on a
679
+ * successful write) makes the maintenance loop unable to separate three
680
+ * materially different outcomes that would all produce NO record: the operator
681
+ * declined every proposal, every write was REFUSED by a guard (path-safety,
682
+ * the #1015 content gate, a missing baseline root), and the writer was never
683
+ * reached at all. `.claude/rules/host-resources.md` § HR-105 is explicit that a
684
+ * rule whose firing you cannot falsify is not a rule, and the sibling event's
685
+ * own docs row states the same convention in one line: *"`dry_run` is the
686
+ * discriminator, not the event's absence"*. So the discriminator here is a
687
+ * FIELD — `rules_written` against `approved_proposals` — never the absence of a
688
+ * record. The one case that emits nothing is the caller's true no-op (neither
689
+ * an approved nor a rejected item), which returns before the lock is taken.
690
+ *
691
+ * NEVER THROWS, and never propagates: `emitEvent` throws `EventValidationError`
692
+ * on a malformed record, and `writeApprovedRules`'s never-throws contract is
693
+ * older and more load-bearing than this telemetry. A failed emit degrades to a
694
+ * stderr WARN and the write result is returned unchanged.
695
+ *
696
+ * @param {WriteApprovedRulesResult} result
697
+ * @param {{repoRoot?: string, targets: string[], approvedCount: number}} ctx
698
+ * @returns {Promise<void>}
699
+ */
700
+ async function emitRulesWritten(result, ctx) {
701
+ const { repoRoot } = ctx;
702
+ // Same refusal as `emitReconcileCompleted` (#1119): with no repoRoot the
703
+ // ambient `SO_PROJECT_DIR` would receive synthetic records on every test run.
704
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return;
705
+
706
+ /** @type {Record<string, unknown>} */
707
+ const payload = {
708
+ rules_written: result.written,
709
+ approved_proposals: ctx.approvedCount,
710
+ rejected_archived: result.archived,
711
+ write_errors: result.errors.length,
712
+ };
713
+ // Allowlisted to the CLOSED {@link TARGET_DIRS} key set before entering the
714
+ // ledger — `targets` originates in operator-authored Session Config and is
715
+ // unbounded there, so an unknown value is untrusted text, never a
716
+ // measurement. Same treatment as `buildReconcilePayload`'s `targets`.
717
+ const known = Object.keys(TARGET_DIRS);
718
+ const targets = ctx.targets.filter((t) => known.includes(t));
719
+ if (targets.length > 0) payload.targets = targets;
720
+
721
+ try {
722
+ const { emitEvent } = await import('../events.mjs');
723
+ await emitEvent(RULES_WRITTEN_EVENT, payload, { repoRoot });
724
+ } catch (err) {
725
+ const msg = err && err.message ? err.message : String(err);
726
+ process.stderr.write(`reconcile-writer: ${RULES_WRITTEN_EVENT} emit failed (non-fatal): ${msg}\n`);
727
+ }
728
+ }
729
+
493
730
  // ---------------------------------------------------------------------------
494
731
  // Public API
495
732
  // ---------------------------------------------------------------------------
@@ -532,6 +769,12 @@ function isOperatorRejection(item) {
532
769
  * @property {number} written - number of rule files successfully written.
533
770
  * @property {number} archived - number of rejected records appended to the log.
534
771
  * @property {string[]} errors - per-item error strings (never fatal).
772
+ *
773
+ * On an instruction-budget refusal (see `budgetPreflight`) the same object also
774
+ * carries `ok: false` plus every {@link BudgetRefusal} field, `written` and
775
+ * `archived` are 0, and `errors[]` holds one line naming the axis and the hint —
776
+ * so a caller that only surfaces `errors[]` still shows the refusal. The success
777
+ * shape carries no `ok` key.
535
778
  */
536
779
 
537
780
  /**
@@ -618,6 +861,24 @@ export async function writeApprovedRules({
618
861
  }
619
862
  }
620
863
 
864
+ // Instruction-budget pre-flight — before the first write, inside the
865
+ // lock so no concurrent reconcile writer moves the corpus between the
866
+ // projection and the write. A refusal writes NOTHING: no rule file, no
867
+ // rejected-log line, no sidecar stamp (approved or rejected).
868
+ const budgetRefusal = await budgetPreflight({
869
+ approvedItems,
870
+ prep: prepared.get('repo-local'),
871
+ roots,
872
+ repoRoot,
873
+ errors,
874
+ });
875
+ if (budgetRefusal !== null) {
876
+ errors.push(
877
+ `${budgetRefusal.reason}: ${budgetRefusal.axis} would go ${budgetRefusal.current} → ${budgetRefusal.projected} (ceiling ${budgetRefusal.ceiling}) — nothing written, no candidate marked processed; ${budgetRefusal.hint}`,
878
+ );
879
+ return { written: 0, archived: 0, errors, ok: false, ...budgetRefusal };
880
+ }
881
+
621
882
  // ── Step 1: write approved rule files ──────────────────────────────────
622
883
  for (const item of approvedItems) {
623
884
  // Item-level guards run ONCE, before any target loop: content is a
@@ -765,15 +1026,21 @@ export async function writeApprovedRules({
765
1026
  { timeoutMs: 10000 },
766
1027
  );
767
1028
 
768
- // If lock acquisition failed, return a zeroed result with the lock error.
769
- if (lockResult.ok === false) {
770
- return {
771
- written: 0,
772
- archived: 0,
773
- errors: [`lock-${lockResult.reason ?? 'unknown'}`],
774
- };
775
- }
1029
+ // One emit for BOTH return points (#1307). A lock-acquisition failure is a
1030
+ // zero-write pass like any other and is recorded as one — its `write_errors: 1`
1031
+ // plus a non-zero `approved_proposals` is exactly the shape an operator needs
1032
+ // to see, and an emit placed on the success path alone would drop it silently.
1033
+ const result =
1034
+ lockResult.ok === false
1035
+ ? { written: 0, archived: 0, errors: [`lock-${lockResult.reason ?? 'unknown'}`] }
1036
+ : // Unwrap the result returned from inside the lock body.
1037
+ lockResult.value;
1038
+
1039
+ await emitRulesWritten(result, {
1040
+ repoRoot,
1041
+ targets: effectiveTargets,
1042
+ approvedCount: approvedItems.length,
1043
+ });
776
1044
 
777
- // Unwrap the result returned from inside the lock body.
778
- return lockResult.value;
1045
+ return result;
779
1046
  }
@@ -61,6 +61,14 @@
61
61
  * of starts is a broken instrument to be re-aimed, not a policy to obey.
62
62
  */
63
63
 
64
+ // `session-shape.mjs` is the ONE exported `agents-per-wave` resolver; this
65
+ // module used to carry a private third copy. Import-graph note (this file is
66
+ // hook-reachable via `hooks/_lib/hook-import-set.json`): `session-shape.mjs`
67
+ // statically imports only `session-schema/constants.mjs`, a pure-data leaf, and
68
+ // defers `events.mjs` to a lazy `await import()` — so nothing runs at module
69
+ // load here that did not run before.
70
+ import { resolveAgentCap } from '../session-shape.mjs';
71
+
64
72
  // ---------------------------------------------------------------------------
65
73
  // Verdict precedence helper
66
74
  // ---------------------------------------------------------------------------
@@ -252,29 +260,19 @@ function concurrencySignal(snapshot, concWarn) {
252
260
  }
253
261
 
254
262
  /**
255
- * Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
256
- * when no cap should apply. Mirrors `resolveApwCap()` in
257
- * `../wave-resource-gate.mjs` — kept as a local pure helper here rather than
258
- * a cross-module import since both sites are ≤10 lines and evolve
259
- * independently per their own gate's options shape.
263
+ * The session type this module resolves `agents-per-wave` FOR: none.
260
264
  *
261
- * `evaluate()` has no session-mode input in scope, so the object shape
262
- * resolves to `cap.default` the documented HR-003 convention writes the
263
- * override as `<default> (mode: <higher-ceiling>)`, i.e. `default` is the
264
- * MORE restrictive of the pair, so this can only under-apply a looser
265
- * mode-specific ceiling, never let a heavy repo exceed its base cap.
265
+ * `evaluate()` has no session-mode input in scope, so it deliberately asks
266
+ * `resolveAgentCap` for no type and takes the `.default` fallback. The HR-003
267
+ * convention writes the override as `<default> (mode: <higher-ceiling>)`, i.e.
268
+ * `default` is the MORE restrictive of the pair under-applying a looser
269
+ * mode-specific ceiling is safe, while passing a guessed `'deep'` here would
270
+ * silently RAISE a heavy repo's ceiling to the deep override on a loaded host.
271
+ * Naming the absence is what keeps that from being written by accident.
266
272
  *
267
- * @param {number|{default: number, [mode: string]: number}|*} cap
268
- * @returns {number|null}
273
+ * @type {undefined}
269
274
  */
270
- function resolveAgentsPerWaveCap(cap) {
271
- if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
272
- if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
273
- const def = cap.default;
274
- return typeof def === 'number' && Number.isFinite(def) ? def : null;
275
- }
276
- return null;
277
- }
275
+ const MODE_BLIND_SESSION_TYPE = undefined;
278
276
 
279
277
  /**
280
278
  * Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
@@ -423,7 +421,7 @@ export function evaluate(snapshot, thresholds, options = {}) {
423
421
  // the live-probe signals already computed.
424
422
  // ---------------------------------------------------------------------------
425
423
  const { heavyRepo, agentsPerWave } = options;
426
- const resolvedApwCap = resolveAgentsPerWaveCap(agentsPerWave);
424
+ const resolvedApwCap = resolveAgentCap(agentsPerWave, MODE_BLIND_SESSION_TYPE);
427
425
  if (heavyRepo === true && resolvedApwCap !== null) {
428
426
  cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
429
427
  }
@@ -121,7 +121,7 @@ function listManifestCategories(indexContent) {
121
121
  * @param {string|null} explicitArchetype
122
122
  * @returns {{archetype: string|null, known: boolean}}
123
123
  */
124
- function resolveArchetype(repoRoot, explicitArchetype) {
124
+ export function resolveArchetype(repoRoot, explicitArchetype) {
125
125
  if (explicitArchetype) {
126
126
  return { archetype: explicitArchetype.trim().toLowerCase(), known: true };
127
127
  }
@@ -194,6 +194,12 @@ function escapeRegex(s) {
194
194
  * at least one concrete bullet entry. This keeps `/bootstrap --sync-rules`
195
195
  * ready for future opt-in categories without requiring CLI changes.
196
196
  *
197
+ * `requiredBasenames` optionally adds explicitly required manifest entries,
198
+ * regardless of category or archetype scope. The caller supplies this data;
199
+ * this synchronous writer performs no external lookup. Every requested name
200
+ * must resolve uniquely in the full manifest before ANY file is written.
201
+ * Source validation, provenance, local overrides and dry-run still apply.
202
+ *
197
203
  * Vendoring sanitizer (issue #1098): every source file that reaches the write
198
204
  * decision is additionally scanned by `scanVendoringLeaks()`, and its findings
199
205
  * are collected into the additive `sanitizer[]` array. This runs in
@@ -214,7 +220,8 @@ function escapeRegex(s) {
214
220
  * dryRun?: boolean,
215
221
  * validate?: boolean,
216
222
  * requireProvenance?: boolean,
217
- * archetype?: string|null
223
+ * archetype?: string|null,
224
+ * requiredBasenames?: string[]|null
218
225
  * }} opts
219
226
  * @returns {{
220
227
  * written: string[],
@@ -233,6 +240,7 @@ export function syncRules({
233
240
  validate = true,
234
241
  requireProvenance = true,
235
242
  archetype = null,
243
+ requiredBasenames = null,
236
244
  } = {}) {
237
245
  const written = [];
238
246
  const skipped = [];
@@ -268,13 +276,35 @@ export function syncRules({
268
276
  ? categories
269
277
  : listManifestCategories(indexContent);
270
278
  const entries = parseIndex(indexContent, selectedCategories);
279
+ const fullManifest = parseIndex(indexContent, listManifestCategories(indexContent));
280
+ const required = new Set();
281
+ if (requiredBasenames !== null) {
282
+ if (!Array.isArray(requiredBasenames)) {
283
+ errors.push({ file: '_index.md', reason: 'requiredBasenames must be an array of unique manifest basenames' });
284
+ } else {
285
+ for (const name of requiredBasenames) {
286
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\.md$/.test(name) || required.has(name)) {
287
+ errors.push({ file: '_index.md', reason: 'invalid or duplicate required basename' });
288
+ continue;
289
+ }
290
+ required.add(name);
291
+ const matching = fullManifest.filter((entry) => basename(entry.relPath) === name);
292
+ if (matching.length !== 1) {
293
+ errors.push({ file: name, reason: 'required basename must resolve uniquely in _index.md' });
294
+ } else if (!entries.some((entry) => entry.relPath === matching[0].relPath)) {
295
+ entries.push(matching[0]);
296
+ }
297
+ }
298
+ }
299
+ if (errors.length > 0) return { written, skipped, preserved, errors, warnings, sanitizer };
300
+ }
271
301
 
272
302
  // Resolvability for the See-Also sanitizer is judged against the FULL
273
303
  // manifest, not `selectedCategories`: an archetype-scoped rule is a
274
304
  // legitimate citation target in every repo whose archetype matches, so
275
305
  // narrowing this to the current selection would report false leaks.
276
306
  const manifestBasenames = new Set(
277
- parseIndex(indexContent, listManifestCategories(indexContent)).map((e) => basename(e.relPath)),
307
+ fullManifest.map((e) => basename(e.relPath)),
278
308
  );
279
309
 
280
310
  if (entries.length === 0) {
@@ -297,7 +327,7 @@ export function syncRules({
297
327
 
298
328
  // Archetype filter (issue #722 Epic A Wave 3) — evaluated before any
299
329
  // file IO, so a skip never triggers a spurious "source file not found".
300
- if (archetypes !== null) {
330
+ if (archetypes !== null && !required.has(basename(relPath))) {
301
331
  if (!resolvedArchetype.known) {
302
332
  skipped.push({ file: relPath, reason: 'archetype-unknown' });
303
333
  continue;