session-orchestrator 4.2.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 (129) hide show
  1. package/.agents/skills/session-start/SKILL.md +1 -1
  2. package/.agents/skills/ux-grill/SKILL.md +22 -0
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +3 -2
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  7. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  8. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  9. package/.cursor/commands/ux-grill.md +14 -0
  10. package/.cursor/skills/session-start/SKILL.md +1 -1
  11. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  12. package/.cursor-plugin/plugin.json +1 -1
  13. package/AGENTS.md +2 -1
  14. package/CHANGELOG.md +94 -1
  15. package/README.md +98 -86
  16. package/agents/dialectic-deriver.md +11 -0
  17. package/commands/ux-grill.md +51 -0
  18. package/docs/USER-GUIDE.md +2 -2
  19. package/docs/codex-setup.md +8 -0
  20. package/docs/components.md +7 -7
  21. package/docs/events-schema.md +9 -5
  22. package/docs/github-mirror-protection.md +61 -20
  23. package/docs/migration-v5.md +62 -0
  24. package/docs/scope-collision-guard.md +14 -0
  25. package/hooks/_lib/hook-import-set.json +44 -2
  26. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  27. package/hooks/_lib/vcs-create-matcher.mjs +190 -3
  28. package/hooks/enforce-scope.mjs +201 -0
  29. package/hooks/hooks-codex.json +1 -1
  30. package/hooks/hooks-cursor.json +5 -0
  31. package/hooks/hooks.json +7 -2
  32. package/hooks/on-session-start.mjs +171 -49
  33. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  34. package/hooks/pre-auq-clarity.mjs +70 -18
  35. package/hooks/pre-bash-issue-budget.mjs +51 -4
  36. package/package.json +2 -1
  37. package/pi/prompts/ux-grill.md +12 -0
  38. package/scripts/ci/assert-vitest-green.mjs +4 -2
  39. package/scripts/dialectic-deriver.mjs +32 -8
  40. package/scripts/emit-session.mjs +72 -1
  41. package/scripts/lib/agent-status.mjs +441 -9
  42. package/scripts/lib/auq/schema.mjs +10 -3
  43. package/scripts/lib/ci-status-banner.mjs +29 -6
  44. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  45. package/scripts/lib/config.mjs +12 -1
  46. package/scripts/lib/eval/engine.mjs +7 -1
  47. package/scripts/lib/file-lock.mjs +114 -13
  48. package/scripts/lib/git-porcelain.mjs +113 -0
  49. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  50. package/scripts/lib/io.mjs +29 -4
  51. package/scripts/lib/issue-budget.mjs +336 -6
  52. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  53. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  54. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  55. package/scripts/lib/maintenance-due-banner.mjs +11 -1
  56. package/scripts/lib/peer-cards/merger.mjs +143 -0
  57. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  58. package/scripts/lib/project-hygiene.mjs +81 -30
  59. package/scripts/lib/quality-gate.mjs +14 -65
  60. package/scripts/lib/reconcile/engine.mjs +19 -1
  61. package/scripts/lib/reconcile/writer.mjs +278 -11
  62. package/scripts/lib/scope-echo.mjs +346 -0
  63. package/scripts/lib/session-lock.mjs +62 -2
  64. package/scripts/lib/session-record-repair.mjs +91 -0
  65. package/scripts/lib/session-schema/filters.mjs +26 -1
  66. package/scripts/lib/session-start-probes.mjs +419 -53
  67. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  68. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  69. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  70. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  71. package/scripts/lib/ux-grill/compare.mjs +285 -0
  72. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  73. package/scripts/lib/ux-grill/measures.mjs +431 -0
  74. package/scripts/lib/ux-grill/paths.mjs +224 -0
  75. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  76. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  77. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  78. package/scripts/lib/ux-grill/schema.mjs +321 -0
  79. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  80. package/scripts/lib/validate/check-unwired-features.mjs +48 -20
  81. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  82. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  83. package/scripts/mcp-server.sh +16 -1
  84. package/scripts/release.mjs +7 -2
  85. package/skills/bootstrap/SKILL.md +12 -209
  86. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  87. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  88. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  89. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  90. package/skills/claude-md-drift-check/SKILL.md +9 -2
  91. package/skills/claude-md-drift-check/checker.mjs +213 -21
  92. package/skills/discovery/SKILL.md +6 -173
  93. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  94. package/skills/discovery/probes-docs.md +8 -4
  95. package/skills/discovery/probes-supply-chain.md +4 -2
  96. package/skills/discovery/probes-ui.md +7 -3
  97. package/skills/discovery/probes-vault.md +12 -4
  98. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  99. package/skills/discovery/references/discovery-triage-state.md +54 -0
  100. package/skills/eval/rubric-v1.md +13 -0
  101. package/skills/evolve/SKILL.md +2 -458
  102. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  103. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  104. package/skills/plan/mode-retro.md +4 -3
  105. package/skills/reconcile/SKILL.md +10 -0
  106. package/skills/session-end/drift-operations.md +20 -5
  107. package/skills/session-end/metrics-collection.md +1 -0
  108. package/skills/session-end/phase-3-6-tail.md +4 -2
  109. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  110. package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
  111. package/skills/session-end/session-metrics-write.md +2 -0
  112. package/skills/session-plan/SKILL.md +2 -144
  113. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  114. package/skills/session-start/SKILL.md +24 -6
  115. package/skills/session-start/references/operations-contract.md +114 -0
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
  117. package/skills/session-start/soul.md +2 -2
  118. package/skills/test-runner/SKILL.md +1 -1
  119. package/skills/tmux-layout/SKILL.md +3 -1
  120. package/skills/ux-grill/SKILL.md +211 -0
  121. package/skills/ux-grill/rubric-v2.md +201 -0
  122. package/skills/ux-grill/soul.md +76 -0
  123. package/skills/wave-executor/SKILL.md +3 -128
  124. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  125. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  126. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  127. package/skills/wave-executor/references/wave-loop-review.md +18 -5
  128. package/templates/_shared/ux-manifest.template.md +149 -0
  129. package/templates/_shared/journey-manifest.md +0 -114
@@ -40,9 +40,9 @@ import path from 'node:path';
40
40
  import crypto from 'node:crypto';
41
41
 
42
42
  import { _parseStateMdLock } from '../config/state-md-lock.mjs';
43
- import { tryAcquireFileLock } from '../file-lock.mjs';
44
- import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
45
- import { nowIso, delay, parseLockBody } from './lock-body.mjs';
43
+ import { releaseFileLock, tryAcquireFileLock } from '../file-lock.mjs';
44
+ import { stableHostname } from '../host-identity.mjs';
45
+ import { nowIso, delay } from './lock-body.mjs';
46
46
 
47
47
  // ---------------------------------------------------------------------------
48
48
  // Constants
@@ -198,6 +198,9 @@ export async function acquireStateLock({
198
198
  * { ok: true } — lock unlinked
199
199
  * { ok: false, reason: 'not-found' } — no lock file exists
200
200
  * { ok: false, reason: 'not-owner' } — lock held by different holder/PID
201
+ * { ok: false, reason: 'busy' } — the shared `.acquire` guard
202
+ * stayed taken past its budget;
203
+ * the lock is left untouched
201
204
  * { ok: false, reason: 'fs-error', error } — filesystem failure
202
205
  *
203
206
  * Never throws.
@@ -210,42 +213,13 @@ export async function acquireStateLock({
210
213
  export function releaseStateLock({ repoRoot, sessionId, holder } = {}) {
211
214
  const lockFile = stateLockPathFor(repoRoot);
212
215
 
213
- let raw;
214
- try {
215
- raw = fs.readFileSync(lockFile, 'utf8');
216
- } catch (err) {
217
- if (err.code === 'ENOENT') {
218
- return { ok: false, reason: 'not-found' };
219
- }
220
- return { ok: false, reason: 'fs-error', error: err.message };
221
- }
222
-
223
- const lock = parseLockBody(raw);
224
- if (lock === null) {
225
- // Unparseable — refuse to delete; some other process may be writing now.
226
- return { ok: false, reason: 'not-owner' };
227
- }
228
-
229
- const expectedHolder = holder ?? sessionId ?? null;
230
- const ownerMatch = expectedHolder !== null
231
- ? lock.holder === expectedHolder
232
- // #1072: alias-aware host identity — a raw comparison strands the lock as
233
- // 'not-owner' once os.hostname() flips spelling mid-session.
234
- : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
235
-
236
- if (!ownerMatch) {
237
- return { ok: false, reason: 'not-owner' };
238
- }
239
-
240
- try {
241
- fs.unlinkSync(lockFile);
242
- return { ok: true };
243
- } catch (err) {
244
- if (err.code === 'ENOENT') {
245
- return { ok: false, reason: 'not-found' };
246
- }
247
- return { ok: false, reason: 'fs-error', error: err.message };
248
- }
216
+ // Consolidated onto releaseFileLock (#1349); the `pid` staleCheck makes the
217
+ // takeover race unreachable today — if this lock ever moves to a lease mode,
218
+ // the shared guard is what keeps release safe.
219
+ // `sessionId` is the legacy alias for `holder` (see the docblock); an empty
220
+ // expected holder falls through to releaseFileLock's PID + host owner check,
221
+ // exactly as the inline sequence did.
222
+ return releaseFileLock(lockFile, { holder: holder ?? sessionId ?? undefined });
249
223
  }
250
224
 
251
225
  /**
@@ -345,11 +319,15 @@ export async function withStateMdLock(repoRoot, fn, opts = {}) {
345
319
  caughtError = err;
346
320
  } finally {
347
321
  // Always release — even on fn() throw — so the lock does not leak.
348
- // Only WARN on fs-error: 'not-found' and 'not-owner' are recoverable race
349
- // conditions (someone else cleaned up our lock — already safe to proceed).
322
+ // WARN only where OUR lock is left behind: 'fs-error' and 'busy' (the
323
+ // shared guard stayed taken — #1349). 'not-found' and 'not-owner' are
324
+ // recoverable race conditions (someone else cleaned up our lock — already
325
+ // safe to proceed) and stay silent, as before.
350
326
  const releaseResult = releaseStateLock({ repoRoot, holder });
351
327
  if (!releaseResult.ok && releaseResult.reason === 'fs-error') {
352
328
  console.warn(`withStateMdLock: release failed (fs-error: ${releaseResult.error ?? 'unknown'})`);
329
+ } else if (!releaseResult.ok && releaseResult.reason === 'busy') {
330
+ console.warn('withStateMdLock: release failed (busy: acquire guard held) — state.lock left for its stale policy');
353
331
  }
354
332
  }
355
333
 
@@ -310,7 +310,17 @@ export async function computeMaintenanceDue(opts = {}) {
310
310
 
311
311
  // --- reconcile (S3) ----------------------------------------------------
312
312
  if (nudge.nudge === true) {
313
- markDue('reconcile', isoDay(nudge.lastRunAt) ?? 'never');
313
+ // HR-106: report what the rule JUDGED. `computeReconcileNudge` nudges on
314
+ // a BACKLOG (eligible-unmaterialized learnings), never on a date — so
315
+ // printing `lastRunAt` here put today's date next to the word "due" and
316
+ // read as "last run today and already due again". Measured 2026-09-09
317
+ // (learning 013a45ba): with 108 learnings capped under
318
+ // `max-proposals-per-run: 10`, the row stayed due after every run while
319
+ // showing that run's own date. `reasons` is the judgment itself.
320
+ const why = Array.isArray(nudge.reasons) && nudge.reasons.length > 0
321
+ ? nudge.reasons.join(', ')
322
+ : `last run ${isoDay(nudge.lastRunAt) ?? 'never'}`;
323
+ markDue('reconcile', why);
314
324
  }
315
325
  }
316
326
 
@@ -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';
@@ -284,6 +285,11 @@ function resolveCommands(override, repoRoot) {
284
285
  */
285
286
  function runGate(cmd, cwd) {
286
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
287
293
  const result = spawnSync(cmd, {
288
294
  cwd,
289
295
  shell: true,
@@ -291,6 +297,14 @@ function runGate(cmd, cwd) {
291
297
  stdio: ['ignore', 'pipe', 'pipe'],
292
298
  timeout: GATE_TIMEOUT_MS,
293
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' },
294
308
  });
295
309
  const combined = (result.stdout ?? '') + (result.stderr ?? '');
296
310
  const tail = combined.split('\n').slice(-OUTPUT_TAIL_LINES).join('\n').trim();
@@ -352,71 +366,6 @@ function writeLastGreenSha(repoRoot) {
352
366
  }
353
367
  }
354
368
 
355
- /**
356
- * Parse `git status --porcelain -z` stdout into repo-root-relative paths.
357
- *
358
- * `-z` is not a convenience flag here — it is the only shape of this command
359
- * whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
360
- * carrying a space, a non-ASCII name, a literal `"` and a rename:
361
- *
362
- * ```
363
- * git status --porcelain git status --porcelain -z
364
- * ------------------------------ ---------------------------------
365
- * M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
366
- * M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
367
- * ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
368
- * R old.mjs -> new.mjs R new.mjs \0 old.mjs
369
- * ```
370
- *
371
- * The non-`-z` form C-quotes any path containing a space, a `"` or — under the
372
- * default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
373
- * repairs only the non-ASCII third of that (measured: the space and the `"`
374
- * stayed quoted). A field-splitting parser over the non-`-z` form fails three
375
- * separate ways on one input — measured `awk '{print $2}'` output for the four
376
- * lines above: `"scripts/lib/old` (truncated at the space), the undecoded
377
- * `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
378
- * line. `-z` emits every path verbatim, so there is no unquoting step to get
379
- * wrong.
380
- *
381
- * Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
382
- * `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
383
- * per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
384
- * `old.mjs`) as if it were a real path. Both paths are kept — a file moved OUT
385
- * of `scripts/lib/` is as much a shared-lib touch as one moved in, and a fixer
386
- * needs the old path to make sense of the new one. `R`/`C` are checked in BOTH
387
- * status columns because git-status(1) documents `R `/`C ` (renamed/copied in
388
- * index) as well as ` R`/` C` (renamed/copied in work tree).
389
- *
390
- * Untracked DIRECTORIES are not a case this parser has to handle: the caller
391
- * passes `-uall`, which expands them to individual files (measured: `?? nd/`
392
- * became `?? nd/a.mjs` + `?? nd/b.mjs`).
393
- *
394
- * @param {string} raw — raw stdout of `git status --porcelain -z …`.
395
- * @returns {string[]} repo-root-relative paths, in git's emission order.
396
- */
397
- function parsePorcelainZ(raw) {
398
- const fields = String(raw ?? '').split('\0');
399
- const paths = [];
400
- for (let i = 0; i < fields.length; i += 1) {
401
- const entry = fields[i];
402
- // `XY P` is the shortest well-formed entry. Anything shorter — including
403
- // the empty trailing field `split` always produces — is not an entry
404
- // header, and the `[2] === ' '` check rejects a stray original-path field
405
- // that a malformed stream could leave unconsumed.
406
- if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
407
- const filePath = entry.slice(3);
408
- if (filePath) paths.push(filePath);
409
- const x = entry[0];
410
- const y = entry[1];
411
- if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
412
- i += 1;
413
- const original = fields[i];
414
- if (typeof original === 'string' && original) paths.push(original);
415
- }
416
- }
417
- return paths;
418
- }
419
-
420
369
  /**
421
370
  * Files that differ between `baseRef` and `HEAD` — the COMMITTED half of the
422
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`)