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
@@ -2,9 +2,10 @@
2
2
  * owner-yaml.mjs — owner.yaml schema, validator, parser, writer (Issue #161, D1).
3
3
  *
4
4
  * Implements the public Owner Persona Layer API: schema validation, disk I/O,
5
- * and sensible defaults. Intentionally separate from `owner-config.mjs` (which
6
- * ships the richer D2-era schema with schema-version, metadata, and extended
7
- * fields). This module targets the simpler D1 surface defined in the #161 epic.
5
+ * and sensible defaults. This module IS the live owner-config surface — the
6
+ * richer D2-era schema (`owner-config.mjs`, with `schema-version`, `metadata`,
7
+ * and extended fields) was removed on 2026-09-09; this module's D1 surface
8
+ * from the #161 epic is now the only one.
8
9
  *
9
10
  * ── Schema (schema-version: 1) ───────────────────────────────────────────────
10
11
  *
@@ -95,17 +96,17 @@ let _yamlWarned = false;
95
96
  * Resolve `js-yaml` at CALL time instead of at import time (GH#62/#63).
96
97
  *
97
98
  * WHY `createRequire` and not `await import('js-yaml')`: every caller of
98
- * {@link loadOwnerConfig} consumes it SYNCHRONOUSLY. Measured 2026-09-06 with
99
+ * {@link loadOwnerConfig} consumes it SYNCHRONOUSLY. Measured 2026-09-09 with
99
100
  * `rg -n --glob '!tests/**' 'loadOwnerConfig\(' scripts hooks skills` (minus
100
- * this file and the unrelated async homonym in `owner-config-loader.mjs`):
101
- * 9 call sites 8 in code, 1 in `skills/session-start/SKILL.md:1113` — and
102
- * `rg 'await\s+loadOwnerConfig'` over the same scope returns ZERO. Eight read
103
- * `loadOwnerConfig().config` (`hooks/on-session-start.mjs:639`,
104
- * `hooks/skill-invocation-telemetry.mjs:155`, `scripts/telemetry.mjs:81,130`,
105
- * `scripts/vault-mirror.mjs:444`, `scripts/lib/telemetry/sync.mjs:202,269`,
106
- * and the SKILL.md snippet); the ninth destructures the same sync return
107
- * (`scripts/lib/soul-resolve.mjs:123`). `writeOwnerConfig` is likewise sync at
108
- * its single call site, `scripts/lib/owner-interview.mjs:228`.
101
+ * this file itself — this is the sole loader now, no other async homonym
102
+ * exists): 8 call sites, all synchronous `rg 'await\s+loadOwnerConfig'`
103
+ * over the same scope returns ZERO. All eight read `loadOwnerConfig().config`
104
+ * (`hooks/on-session-start.mjs:639`, `hooks/skill-invocation-telemetry.mjs:155`,
105
+ * `scripts/telemetry.mjs:81,130`, `scripts/vault-mirror.mjs:444`,
106
+ * `scripts/lib/telemetry/sync.mjs:513,643`, and
107
+ * `skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md:53`).
108
+ * `writeOwnerConfig` is likewise sync at its single call site,
109
+ * `scripts/lib/owner-interview.mjs:228`.
109
110
  *
110
111
  * Making either loader async would be a breaking change to all of them; a lazy
111
112
  * `require()` keeps the sync contract and only fails at CALL time — where the
@@ -308,3 +308,146 @@ function wrapManagedContent(raw) {
308
308
  function escapeRegex(s) {
309
309
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
310
310
  }
311
+
312
+ // ── body-string → section-map adapter (#1310) ────────────────────────────────
313
+ //
314
+ // The seam this closes: `dialectic-deriver` (agents/dialectic-deriver.md § Output
315
+ // format) emits ONE FULL PEER-CARD BODY STRING per target, while `mergePeerCard`
316
+ // above consumes a SECTION MAP keyed by sentinel name. Nothing translated between
317
+ // the two, so `/evolve dialectic --apply` could not complete (#1310, correcting
318
+ // #1303 point 3 — the signatures line up, the seam did not).
319
+ //
320
+ // The mapping is DERIVED, not invented: every managed region in the live cards
321
+ // wraps exactly one `## ` heading, so the heading IS the section unit. Existing
322
+ // names are read back out of the card rather than re-slugified, because the live
323
+ // names are NOT a pure function of their headings — measured 2026-09-11 in
324
+ // `.orchestrator/peers/AGENT.md`: "Guard and protocol-migration discipline" →
325
+ // `guard-and-protocol-migration` (drops "discipline") and "Review discipline — the
326
+ // refutation mandate" → `review-discipline-refutation-mandate` (drops "the").
327
+ // Re-slugifying either would APPEND a duplicate section instead of replacing it.
328
+
329
+ const H2_RE = /^##[ \t]+(.+?)[ \t]*$/gm;
330
+
331
+ /** Normalise a heading for matching: case- and whitespace-insensitive. */
332
+ function headingKey(heading) {
333
+ return heading.trim().toLowerCase().replace(/\s+/g, ' ');
334
+ }
335
+
336
+ /**
337
+ * Slugify a heading into a section name matching the `[\w-]+` grammar.
338
+ * Only used for headings with NO existing managed section (the append path).
339
+ * @param {string} heading
340
+ * @returns {string}
341
+ */
342
+ function slugifyHeading(heading) {
343
+ const slug = heading
344
+ .toLowerCase()
345
+ .replace(/[^a-z0-9]+/g, '-')
346
+ .replace(/^-+|-+$/g, '');
347
+ return slug.length > 0 ? slug : 'section';
348
+ }
349
+
350
+ /**
351
+ * Split a proposed full-body string into `{ <section-name>: <content> }`, reusing
352
+ * the existing card's section names wherever the heading already has a home.
353
+ *
354
+ * Behaviour for every input class (no case is silently dropped):
355
+ * • Heading whose text matches an existing managed section's own `## ` heading →
356
+ * mapped to that section's EXISTING name (`origin: 'existing'`) → REPLACE.
357
+ * • Heading with no existing section → slugified name (`origin: 'new'`) →
358
+ * APPEND. Collisions get a `-2`, `-3`, … suffix.
359
+ * • Text BEFORE the first `## ` heading → returned as `preamble`. It is NOT
360
+ * written into any section (it has no sentinel to own it); `mergeDerivedBody`
361
+ * surfaces it as an `unmapped-preamble` conflict so a caller cannot miss it.
362
+ * • Content under `###`+ headings stays inside its parent `##` section.
363
+ * • A proposed body that already carries BEGIN/END sentinels keeps them inside
364
+ * the section content — it is the deriver's job not to emit them (see
365
+ * `agents/dialectic-deriver.md` § Anti-patterns).
366
+ *
367
+ * Pure function — no IO, deterministic.
368
+ *
369
+ * @param {string} proposedBody — the deriver's full replacement body for one target
370
+ * @param {string} [existingBody] — the on-disk body, for existing-name lookup
371
+ * @returns {{ managedUpdates: Record<string,string>,
372
+ * mapping: Array<{heading: string, section: string, origin: 'existing'|'new'}>,
373
+ * preamble: string }}
374
+ */
375
+ export function deriveManagedUpdates(proposedBody, existingBody = '') {
376
+ if (typeof proposedBody !== 'string') {
377
+ throw new Error(`deriveManagedUpdates: proposedBody must be string (got ${typeof proposedBody}).`);
378
+ }
379
+ if (typeof existingBody !== 'string') {
380
+ throw new Error(`deriveManagedUpdates: existingBody must be string (got ${typeof existingBody}).`);
381
+ }
382
+
383
+ // heading-key → existing section name, read out of the live card
384
+ const existingByHeading = new Map();
385
+ const existingNames = new Set();
386
+ if (existingBody.length > 0) {
387
+ for (const s of parseSections(existingBody).sections) {
388
+ if (s.type !== 'managed') continue;
389
+ existingNames.add(s.name);
390
+ const h = s.content.match(/^##[ \t]+(.+?)[ \t]*$/m);
391
+ if (h && !existingByHeading.has(headingKey(h[1]))) {
392
+ existingByHeading.set(headingKey(h[1]), s.name);
393
+ }
394
+ }
395
+ }
396
+
397
+ H2_RE.lastIndex = 0;
398
+ const heads = [...proposedBody.matchAll(H2_RE)];
399
+ const preamble = (heads.length > 0 ? proposedBody.slice(0, heads[0].index) : proposedBody).trim();
400
+
401
+ /** @type {Record<string,string>} */
402
+ const managedUpdates = {};
403
+ const mapping = [];
404
+ const used = new Set();
405
+
406
+ for (let i = 0; i < heads.length; i++) {
407
+ const heading = heads[i][1];
408
+ const start = heads[i].index;
409
+ const end = i + 1 < heads.length ? heads[i + 1].index : proposedBody.length;
410
+ const content = proposedBody.slice(start, end).trim();
411
+
412
+ const existing = existingByHeading.get(headingKey(heading));
413
+ let section;
414
+ let origin;
415
+ if (existing !== undefined && !used.has(existing)) {
416
+ section = existing;
417
+ origin = 'existing';
418
+ } else {
419
+ const base = slugifyHeading(heading);
420
+ let candidate = base;
421
+ let n = 2;
422
+ while (used.has(candidate)) {
423
+ candidate = `${base}-${n++}`;
424
+ }
425
+ section = candidate;
426
+ origin = existingNames.has(candidate) ? 'existing' : 'new';
427
+ }
428
+
429
+ used.add(section);
430
+ managedUpdates[section] = content;
431
+ mapping.push({ heading, section, origin });
432
+ }
433
+
434
+ return { managedUpdates, mapping, preamble };
435
+ }
436
+
437
+ /**
438
+ * Merge a deriver-shaped FULL BODY STRING into an existing peer-card body.
439
+ * This is the function `/evolve dialectic --apply` calls; `mergePeerCard` stays
440
+ * the section-map primitive its existing callers already use.
441
+ *
442
+ * @param {string} existingBody
443
+ * @param {string} proposedBody
444
+ * @returns {MergeResult & { mapping: Array<{heading: string, section: string, origin: 'existing'|'new'}>, preamble: string }}
445
+ */
446
+ export function mergeDerivedBody(existingBody, proposedBody) {
447
+ const { managedUpdates, mapping, preamble } = deriveManagedUpdates(proposedBody, existingBody);
448
+ const result = mergePeerCard(existingBody, managedUpdates);
449
+ if (preamble.length > 0) {
450
+ result.conflicts.push({ type: 'unmapped-preamble', content: preamble });
451
+ }
452
+ return { ...result, mapping, preamble };
453
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
+ import { parsePorcelainEntries } from './git-porcelain.mjs';
16
17
  import { pathMatchesPattern } from './hardening.mjs';
17
18
 
18
19
  /**
@@ -66,28 +67,32 @@ export function checkUntrackedOverlap({ scope, cwd = process.cwd(), mode = 'warn
66
67
  }
67
68
 
68
69
  /**
69
- * Run `git status --porcelain` and extract untracked files (prefix "??").
70
+ * Run `git status --porcelain -z` and extract untracked files (status `??`).
70
71
  * Returns paths relative to `cwd`. Returns [] on any git failure.
71
72
  *
73
+ * `-z` is load-bearing (#1354): without it git C-quotes any path carrying a
74
+ * space, a `"`, a TAB or — under the default `core.quotePath=true` — a
75
+ * non-ASCII byte. The previous hand-unquoting here reversed only `\"` and
76
+ * `\\`, so `a\tb.txt` and `\303\244.txt` were returned VERBATIM — paths that
77
+ * exist nowhere on disk, which then matched no scope pattern and silently
78
+ * dropped the overlap this function exists to detect. `-z` emits every path
79
+ * raw, so there is no unquoting step at all. Parsing lives in the shared
80
+ * `scripts/lib/git-porcelain.mjs`.
81
+ *
72
82
  * @param {string} cwd
73
83
  * @returns {string[]}
74
84
  */
75
85
  export function listUntracked(cwd) {
76
86
  let stdout;
77
87
  try {
78
- stdout = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' });
88
+ stdout = execFileSync('git', ['status', '--porcelain', '-z'], { cwd, encoding: 'utf8' });
79
89
  } catch {
80
90
  return [];
81
91
  }
82
92
 
83
93
  const files = [];
84
- for (const rawLine of stdout.split('\n')) {
85
- if (!rawLine.startsWith('?? ')) continue;
86
- // Porcelain v1 wraps paths in quotes when they contain special chars. Strip them.
87
- let rel = rawLine.slice(3);
88
- if (rel.startsWith('"') && rel.endsWith('"')) {
89
- rel = rel.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
90
- }
94
+ for (const { status, path: rel } of parsePorcelainEntries(stdout)) {
95
+ if (status !== '??' || !rel) continue;
91
96
  // Directory entries end with `/` in porcelain output — expand to file list for fidelity.
92
97
  if (rel.endsWith('/')) {
93
98
  files.push(...expandDirectory(cwd, rel));
@@ -112,13 +117,14 @@ function expandDirectory(cwd, relDir) {
112
117
  try {
113
118
  const stdout = execFileSync(
114
119
  'git',
115
- ['ls-files', '--others', '--exclude-standard', '--', relDir],
120
+ ['ls-files', '-z', '--others', '--exclude-standard', '--', relDir],
116
121
  { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
117
122
  );
118
- const lines = stdout
119
- .split('\n')
120
- .map((line) => line.trim())
121
- .filter((line) => line.length > 0);
123
+ // `-z` for the same reason as the caller: git quotes special-char paths
124
+ // otherwise, and a quoted path does not exist on disk. NUL-separated
125
+ // output is emitted verbatim, so no trimming (which would eat a legal
126
+ // trailing space in a filename).
127
+ const lines = stdout.split('\0').filter((line) => line.length > 0);
122
128
  return lines.length > 0 ? lines : [relDir];
123
129
  } catch {
124
130
  return [relDir];
@@ -46,6 +46,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
46
46
  import { join } from 'node:path';
47
47
  import yaml from 'js-yaml';
48
48
  import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
49
+ import { parsePorcelainEntries } from './git-porcelain.mjs';
49
50
 
50
51
  /** Commits past the newest tag before release hygiene is worth mentioning. */
51
52
  export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
@@ -148,16 +149,17 @@ export function checkReleaseHygiene(repoRoot, driftCommits = DEFAULT_RELEASE_DRI
148
149
  * - Everything outside `.orchestrator/` is untouched, which is where a real
149
150
  * "nothing currently decides" stray lives.
150
151
  *
151
- * Prefix match on the porcelain path. `git status --porcelain` quotes a path only
152
- * when it carries special characters, so the optional leading quote is stripped
153
- * before comparing.
152
+ * Prefix match on the porcelain path. The caller reads `git status -z`, whose
153
+ * paths are NUL-terminated and therefore never quoted or escaped the value
154
+ * arriving here is the literal path, so it is compared verbatim. Do not
155
+ * reintroduce a leading-quote strip: under `-z` a path that really begins with
156
+ * `"` is a path, not a quoting artefact.
154
157
  *
155
- * @param {string} porcelainPath path field of a `?? ` porcelain line
158
+ * @param {string} porcelainPath path field of a `?? ` porcelain entry
156
159
  * @returns {boolean}
157
160
  */
158
161
  function isOwnRuntimeArtifact(porcelainPath) {
159
- const p = porcelainPath.replace(/^"/, '');
160
- return p === '.orchestrator' || p.startsWith('.orchestrator/');
162
+ return porcelainPath === '.orchestrator' || porcelainPath.startsWith('.orchestrator/');
161
163
  }
162
164
 
163
165
  /**
@@ -171,6 +173,14 @@ function isOwnRuntimeArtifact(porcelainPath) {
171
173
  * `.gitignore` intends to version something that was never committed. In one
172
174
  * repo this hid 23 rule files the `.gitignore` explicitly un-ignored.
173
175
  *
176
+ * Reads `git status -z`: without it git QUOTES any path carrying a space, tab or
177
+ * non-ASCII byte (`"ign dir/"`, `"ign\tt.bin"`, `"ign\303\244.bin"`), and the
178
+ * quoted string does not exist on disk — so `duBytesBatch` dropped it and its
179
+ * bytes never reached the sum. Measured (#1348): a tree with `ign dir/` 3 MB,
180
+ * `ign<TAB>t.bin` 5 MB, `ignä.bin` 5 MB and `big.bin` 5 MB reported "5 MB
181
+ * (largest: big.bin 5MB)" against an actual 18 MB, low enough to fall under the
182
+ * report threshold entirely. `-z` is NUL-separated and never quotes.
183
+ *
174
184
  * @param {string} repoRoot
175
185
  * @param {number} ballastMb
176
186
  * @returns {object[]}
@@ -179,26 +189,30 @@ export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
179
189
  const findings = [];
180
190
 
181
191
  const ignored = git(
182
- ['status', '--ignored=matching', '--porcelain', '--untracked-files=all'],
192
+ ['status', '--ignored=matching', '--porcelain', '--untracked-files=all', '-z'],
183
193
  repoRoot,
184
194
  );
185
195
  if (ignored === null) return findings;
186
196
 
187
197
  const ignoredPaths = [];
188
198
  let untrackedUnignored = 0;
189
- for (const line of ignored.split('\n').filter(Boolean)) {
190
- if (line.startsWith('!! ')) ignoredPaths.push(line.slice(3));
191
- else if (line.startsWith('?? ') && !isOwnRuntimeArtifact(line.slice(3))) untrackedUnignored++;
199
+ // Porcelain v1 `-z` parsing (including the bare rename/copy source field that
200
+ // follows an `R`/`C` entry) lives in the shared parser — see
201
+ // `scripts/lib/git-porcelain.mjs`. Only `!!`/`??` are of interest here, so a
202
+ // rename/copy entry falls through both branches exactly as before.
203
+ for (const { status, path } of parsePorcelainEntries(ignored)) {
204
+ if (status === '!!') ignoredPaths.push(path);
205
+ else if (status === '??' && !isOwnRuntimeArtifact(path)) untrackedUnignored++;
192
206
  }
193
207
 
194
208
  // Size only the top-level ignored entries — recursing every path would cost
195
209
  // more than the finding is worth on a large tree.
196
210
  let totalBytes = 0;
197
211
  const heaviest = [];
212
+ const sizes = duBytesBatch(repoRoot, ignoredPaths);
198
213
  for (const p of ignoredPaths) {
199
- const abs = join(repoRoot, p);
200
- const bytes = duBytes(abs);
201
- if (bytes === null) continue;
214
+ const bytes = sizes.get(stripTrailingSlash(p));
215
+ if (bytes === undefined) continue;
202
216
  totalBytes += bytes;
203
217
  heaviest.push({ path: p, bytes });
204
218
  }
@@ -228,26 +242,63 @@ export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
228
242
  return findings;
229
243
  }
230
244
 
245
+ // Ceiling on operands per `du` call. Every path is one argv entry and macOS
246
+ // caps argv+environment at ARG_MAX = 1 048 576 bytes; 500 paths stay far below
247
+ // that even at ~1 KB each. Today: 298 ignored top-level entries in this repo
248
+ // (2026-09-12) — one call. Revisit trigger: repos with more than ~5000 ignored
249
+ // entries, where `git status --ignored` itself turns expensive — then move the
250
+ // probe into a detached child with a cache (see the revisit note in
251
+ // scripts/lib/session-start-probes.mjs).
252
+ const DU_CHUNK_SIZE = 500;
253
+
254
+ /** @param {string} p */
255
+ function stripTrailingSlash(p) {
256
+ return p.replace(/\/+$/, '');
257
+ }
258
+
231
259
  /**
232
- * Directory/file size in bytes via `du -sk`, or null when unavailable.
233
- * `du` is POSIX and present on macOS and Linux; Windows yields null, which
234
- * degrades the ballast check to a silent skip rather than a crash.
235
- * @param {string} absPath
236
- * @returns {number|null}
260
+ * Sizes of repo-relative paths in bytes via batched `du -sk`, one process per
261
+ * {@link DU_CHUNK_SIZE} paths instead of one per path (spawn overhead was the
262
+ * whole cost: 298 single calls 0.685 s vs. one batched call 0.051 s).
263
+ *
264
+ * Keys are the paths without trailing slash; a path absent from the map could
265
+ * not be sized. `du` exits non-zero when ANY operand vanished or is unreadable
266
+ * but still sizes the rest on stdout, so that stdout is parsed rather than
267
+ * discarded — otherwise one bad path would silence the whole ballast check.
268
+ * No `du` at all (Windows) yields an empty map: a silent skip, not a crash.
269
+ * `-l` keeps parity with the old per-path measurement: without it a batched
270
+ * `du` counts an inode shared by hard links only under the FIRST operand
271
+ * (pnpm `node_modules/` pointing into one store → the later ones read ~0).
272
+ * BSD and GNU `du` both define `-l` as "count sizes many times if hard linked".
273
+ * @param {string} repoRoot
274
+ * @param {string[]} relPaths porcelain paths relative to repoRoot
275
+ * @returns {Map<string, number>}
237
276
  */
238
- function duBytes(absPath) {
239
- try {
240
- if (!existsSync(absPath)) return null;
241
- const out = execFileSync('du', ['-sk', absPath], {
242
- encoding: 'utf8',
243
- stdio: ['ignore', 'pipe', 'ignore'],
244
- timeout: 10_000,
245
- });
246
- const kb = Number(out.trim().split(/\s+/)[0]);
247
- return Number.isFinite(kb) ? kb * 1024 : null;
248
- } catch {
249
- return null;
277
+ function duBytesBatch(repoRoot, relPaths) {
278
+ const sizes = new Map();
279
+ const existing = relPaths.filter((p) => existsSync(join(repoRoot, p)));
280
+ for (let i = 0; i < existing.length; i += DU_CHUNK_SIZE) {
281
+ const chunk = existing.slice(i, i + DU_CHUNK_SIZE);
282
+ let out;
283
+ try {
284
+ out = execFileSync('du', ['-skl', '--', ...chunk], {
285
+ cwd: repoRoot,
286
+ encoding: 'utf8',
287
+ stdio: ['ignore', 'pipe', 'ignore'],
288
+ timeout: 30_000,
289
+ });
290
+ } catch (err) {
291
+ out = typeof err?.stdout === 'string' ? err.stdout : '';
292
+ }
293
+ for (const line of out.split('\n')) {
294
+ const tab = line.indexOf('\t');
295
+ if (tab <= 0) continue;
296
+ const kb = Number(line.slice(0, tab));
297
+ if (!Number.isFinite(kb)) continue;
298
+ sizes.set(stripTrailingSlash(line.slice(tab + 1)), kb * 1024);
299
+ }
250
300
  }
301
+ return sizes;
251
302
  }
252
303
 
253
304
  /**
@@ -65,6 +65,7 @@ import { dirname, join } from 'node:path';
65
65
  import { fileURLToPath } from 'node:url';
66
66
 
67
67
  import { emitEvent, sessionAttribution } from './events.mjs';
68
+ import { parsePorcelainZ } from './git-porcelain.mjs';
68
69
  import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
69
70
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
70
71
  import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
@@ -117,16 +118,18 @@ function resolveRepoRoot(explicit) {
117
118
  * Closed set of `degraded` reasons for {@link loadCommandsFromSessionConfigDetailed}.
118
119
  *
119
120
  * Its own enum, deliberately not shared with `ci-status-banner.mjs`'s
120
- * `DEGRADED_REASONS`: the members below are exactly the three ways THIS
121
+ * `DEGRADED_REASONS`: the members below are the ways THIS
121
122
  * config-read can fail, and an enum whose members are not exhaustively
122
123
  * reachable cannot be switched on exhaustively.
123
124
  *
125
+ * - `invalid-repo-root` — no non-blank string root was supplied.
124
126
  * - `script-missing` — `scripts/parse-config.mjs` is not on disk.
125
127
  * - `spawn-failed` — the subprocess exited non-zero, timed out, or wrote
126
128
  * nothing to stdout.
127
129
  * - `parse-error` — stdout was not parseable JSON (or the read threw).
128
130
  */
129
131
  export const CONFIG_READ_DEGRADED_REASONS = Object.freeze([
132
+ 'invalid-repo-root',
130
133
  'script-missing',
131
134
  'spawn-failed',
132
135
  'parse-error',
@@ -168,13 +171,18 @@ function sessionConfigFileExists(repoRoot) {
168
171
  * `degraded` is OMITTED, not set to null, on the success path, so a strict
169
172
  * `toEqual({commands: {…}})` pin holds for every readable config.
170
173
  *
171
- * Never throws.
174
+ * Requires an explicit, non-blank repository root; callers that intend cwd
175
+ * must resolve that default before invoking this loader. Never throws.
172
176
  *
173
177
  * @param {string} repoRoot
174
178
  * @returns {{commands: {lint?: string, typecheck?: string, test?: string},
175
- * degraded?: 'script-missing'|'spawn-failed'|'parse-error'}}
179
+ * degraded?: 'invalid-repo-root'|'script-missing'|'spawn-failed'|'parse-error'}}
176
180
  */
177
181
  export function loadCommandsFromSessionConfigDetailed(repoRoot) {
182
+ // Node inherits cwd for undefined/null, which would read another repo's config.
183
+ if (typeof repoRoot !== 'string' || !repoRoot.trim()) {
184
+ return { commands: {}, degraded: 'invalid-repo-root' };
185
+ }
178
186
  try {
179
187
  const scriptPath = join(
180
188
  dirname(fileURLToPath(import.meta.url)),
@@ -229,9 +237,9 @@ export function loadCommandsFromSessionConfigDetailed(repoRoot) {
229
237
  * Returns a partial object — keys that fail to resolve are simply absent
230
238
  * (the caller falls through to DEFAULT_COMMANDS for those).
231
239
  *
232
- * Thin wrapper over {@link loadCommandsFromSessionConfigDetailed}; byte-identical
233
- * return value for every input, including every failure path. Callers that need
234
- * to tell a failed read from an empty config use the detailed variant.
240
+ * Returns the commands half of {@link loadCommandsFromSessionConfigDetailed},
241
+ * including an empty object for an invalid root. Callers that need to tell a
242
+ * failed read from an empty config use the detailed variant.
235
243
  *
236
244
  * Never throws.
237
245
  *
@@ -277,6 +285,11 @@ function resolveCommands(override, repoRoot) {
277
285
  */
278
286
  function runGate(cmd, cwd) {
279
287
  try {
288
+ // Gate commands are executable configuration from the caller or local
289
+ // Session Config, not data interpolated into a command template. Shell
290
+ // syntax is intentional; callers must trust both command sources (including
291
+ // uncommitted config). See security.md: Session Config Command Trust.
292
+ // nosemgrep: unsafe-shell-spawn
280
293
  const result = spawnSync(cmd, {
281
294
  cwd,
282
295
  shell: true,
@@ -284,6 +297,14 @@ function runGate(cmd, cwd) {
284
297
  stdio: ['ignore', 'pipe', 'pipe'],
285
298
  timeout: GATE_TIMEOUT_MS,
286
299
  maxBuffer: 16 * 1024 * 1024, // 16 MiB cap
300
+ // #1360: gate runs are where worker over-subscription was measured to
301
+ // cause timeout failures (integration fixtures spawn their own Node/npm/
302
+ // git children on top of Vitest's workers). `vitest.config.mjs` reads this
303
+ // flag and bounds `maxWorkers`; a bare `npm test` never sets it and keeps
304
+ // Vitest's default fanout, which measured 59% faster on an idle host.
305
+ // Inherited by every gate subprocess, not only the test one — typecheck
306
+ // and lint ignore it, so setting it unconditionally costs nothing.
307
+ env: { ...process.env, SO_BOUNDED_WORKERS: '1' },
287
308
  });
288
309
  const combined = (result.stdout ?? '') + (result.stderr ?? '');
289
310
  const tail = combined.split('\n').slice(-OUTPUT_TAIL_LINES).join('\n').trim();
@@ -345,71 +366,6 @@ function writeLastGreenSha(repoRoot) {
345
366
  }
346
367
  }
347
368
 
348
- /**
349
- * Parse `git status --porcelain -z` stdout into repo-root-relative paths.
350
- *
351
- * `-z` is not a convenience flag here — it is the only shape of this command
352
- * whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
353
- * carrying a space, a non-ASCII name, a literal `"` and a rename:
354
- *
355
- * ```
356
- * git status --porcelain git status --porcelain -z
357
- * ------------------------------ ---------------------------------
358
- * M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
359
- * M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
360
- * ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
361
- * R old.mjs -> new.mjs R new.mjs \0 old.mjs
362
- * ```
363
- *
364
- * The non-`-z` form C-quotes any path containing a space, a `"` or — under the
365
- * default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
366
- * repairs only the non-ASCII third of that (measured: the space and the `"`
367
- * stayed quoted). A field-splitting parser over the non-`-z` form fails three
368
- * separate ways on one input — measured `awk '{print $2}'` output for the four
369
- * lines above: `"scripts/lib/old` (truncated at the space), the undecoded
370
- * `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
371
- * line. `-z` emits every path verbatim, so there is no unquoting step to get
372
- * wrong.
373
- *
374
- * Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
375
- * `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
376
- * per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
377
- * `old.mjs`) as if it were a real path. Both paths are kept — a file moved OUT
378
- * of `scripts/lib/` is as much a shared-lib touch as one moved in, and a fixer
379
- * needs the old path to make sense of the new one. `R`/`C` are checked in BOTH
380
- * status columns because git-status(1) documents `R `/`C ` (renamed/copied in
381
- * index) as well as ` R`/` C` (renamed/copied in work tree).
382
- *
383
- * Untracked DIRECTORIES are not a case this parser has to handle: the caller
384
- * passes `-uall`, which expands them to individual files (measured: `?? nd/`
385
- * became `?? nd/a.mjs` + `?? nd/b.mjs`).
386
- *
387
- * @param {string} raw — raw stdout of `git status --porcelain -z …`.
388
- * @returns {string[]} repo-root-relative paths, in git's emission order.
389
- */
390
- function parsePorcelainZ(raw) {
391
- const fields = String(raw ?? '').split('\0');
392
- const paths = [];
393
- for (let i = 0; i < fields.length; i += 1) {
394
- const entry = fields[i];
395
- // `XY P` is the shortest well-formed entry. Anything shorter — including
396
- // the empty trailing field `split` always produces — is not an entry
397
- // header, and the `[2] === ' '` check rejects a stray original-path field
398
- // that a malformed stream could leave unconsumed.
399
- if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
400
- const filePath = entry.slice(3);
401
- if (filePath) paths.push(filePath);
402
- const x = entry[0];
403
- const y = entry[1];
404
- if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
405
- i += 1;
406
- const original = fields[i];
407
- if (typeof original === 'string' && original) paths.push(original);
408
- }
409
- }
410
- return paths;
411
- }
412
-
413
369
  /**
414
370
  * Files that differ between `baseRef` and `HEAD` — the COMMITTED half of the
415
371
  * change set. Best-effort: `[]` on any git failure.
@@ -896,12 +896,23 @@ const KNOWN_TARGETS = ['repo-local', 'baseline'];
896
896
  * target list). `aborted`/`reason` appear only when the never-throws guard
897
897
  * fired; their absence means "ran to the end", never "unknown".
898
898
  *
899
+ * `candidate_store_merged` says the `reconcile-candidates.jsonl` idempotency
900
+ * sidecar was merged — the engine's ONLY disk write. It is NOT a rule-write
901
+ * signal: this event is emitted before the operator-approval AUQ and the engine
902
+ * never touches `.claude/rules/`. For "a rule reached `.claude/rules/`" read
903
+ * `orchestrator.reconcile.rules_written`. The legacy alias `written` carries the
904
+ * same boolean until 2027-03-13 (#1315).
905
+ *
899
906
  * @param {ReconcileResult} result
900
907
  * @param {{ trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
901
908
  * @returns {Record<string, unknown>}
902
909
  */
903
910
  function buildReconcilePayload(result, ctx) {
904
911
  const summary = (result && result.summary) || {};
912
+ // `summary.written` is the `reconcile-candidates.jsonl` SIDECAR merge flag —
913
+ // the engine's only disk write. Named `candidate_store_merged` in the payload
914
+ // since #1315; the local const keeps both emitted keys on one source.
915
+ const candidateStoreMerged = summary.written === true;
905
916
  /** @type {Record<string, unknown>} */
906
917
  const payload = {
907
918
  trigger: typeof ctx.trigger === 'string' && ctx.trigger.trim() !== '' ? ctx.trigger : 'unknown',
@@ -912,7 +923,14 @@ function buildReconcilePayload(result, ctx) {
912
923
  rejected: summary.rejected ?? 0,
913
924
  capped: summary.capped ?? 0,
914
925
  already_materialized: summary.alreadyMaterialized ?? 0,
915
- written: summary.written === true,
926
+ candidate_store_merged: candidateStoreMerged,
927
+ // @deprecated `written` — renamed to `candidate_store_merged` (#1315) because
928
+ // the old name read as "rule files were written", which this engine never does.
929
+ // Kept for one generation so existing readers keep working, per the
930
+ // `orchestrator.session.stopped` → `orchestrator.turn.stopped` precedent
931
+ // (#1234). REMOVAL: 2027-03-13. Written from the SAME expression as the new
932
+ // key, so the two can never disagree.
933
+ written: candidateStoreMerged,
916
934
  duration_ms: ctx.durationMs,
917
935
  };
918
936
  // `targets` originates in operator-authored Session Config (`reconcile.targets`)