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.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- 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 {
|
|
45
|
-
import { nowIso, delay
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
349
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
|
85
|
-
if (
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
|
158
|
+
* @param {string} porcelainPath path field of a `?? ` porcelain entry
|
|
156
159
|
* @returns {boolean}
|
|
157
160
|
*/
|
|
158
161
|
function isOwnRuntimeArtifact(porcelainPath) {
|
|
159
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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`)
|