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.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- 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-plan/SKILL.md +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-plan/SKILL.md +1 -1
- 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 +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- 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 +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -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/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- 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-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- 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 +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- 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 +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- 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-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- 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/bootstrap/standard-template.md +39 -24
- 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 +8 -4
- 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/docs-orchestrator/audience-mapping.md +1 -1
- 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/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- 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 +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- 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 +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- 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.
|
|
6
|
-
*
|
|
7
|
-
* fields)
|
|
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-
|
|
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
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `
|
|
105
|
-
* `scripts/
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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 (
|
|
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';
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
-
|
|
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`)
|